perf(renderer): extract the chat composer into a memoized Composer with stable actions and a popup store
This commit is contained in:
+117
-1472
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,139 @@
|
|||||||
|
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { createRef, Profiler } from "react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { Conversation } from "./chat-conversation";
|
||||||
|
import { Composer, type ComposerProps } from "./Composer";
|
||||||
|
import { createComposerDraftStore } from "./composer-draft-store";
|
||||||
|
import { createComposerMenuStore } from "./composer-menu-store";
|
||||||
|
import { createConversationStores } from "./conversation-store";
|
||||||
|
import { changeUiLocale } from "./i18n";
|
||||||
|
import type { ComposerActions } from "./use-composer-actions";
|
||||||
|
|
||||||
|
function conversation(id: string, overrides: Partial<Conversation> = {}): Conversation {
|
||||||
|
return { id, title: id, updatedAt: 1, messages: [], ...overrides } as Conversation;
|
||||||
|
}
|
||||||
|
|
||||||
|
const noop = (): void => undefined;
|
||||||
|
const actions = new Proxy({}, {
|
||||||
|
get: () => noop,
|
||||||
|
}) as ComposerActions;
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const stores = createConversationStores(
|
||||||
|
[conversation("a"), conversation("b")],
|
||||||
|
{ flushIntervalMs: 0 },
|
||||||
|
);
|
||||||
|
const drafts = createComposerDraftStore();
|
||||||
|
const props: ComposerProps = {
|
||||||
|
actions,
|
||||||
|
activeProjectId: "",
|
||||||
|
activeRuntimeSelection: undefined,
|
||||||
|
assistantExpertOptions: [],
|
||||||
|
attachmentButtonRef: createRef(),
|
||||||
|
attachmentOperations: 0,
|
||||||
|
attachments: [],
|
||||||
|
composerDrafts: drafts,
|
||||||
|
contextError: undefined,
|
||||||
|
conversationHint: "",
|
||||||
|
conversationId: "a",
|
||||||
|
conversationStore: stores.conversations,
|
||||||
|
effectiveWorkMode: "ask",
|
||||||
|
executionRunning: false,
|
||||||
|
externalInstances: [],
|
||||||
|
fileSelectionProgress: undefined,
|
||||||
|
imageReferences: [],
|
||||||
|
inputRef: createRef(),
|
||||||
|
keyboardHint: "",
|
||||||
|
knowledgeLibraries: [],
|
||||||
|
menuStore: createComposerMenuStore(),
|
||||||
|
nativeClientAvailable: false,
|
||||||
|
nativeClientContextKey: "",
|
||||||
|
projectRecoveryBlocked: false,
|
||||||
|
projectRuntimeSelection: undefined,
|
||||||
|
projectUsesManagedSsh: false,
|
||||||
|
queueItems: [],
|
||||||
|
runtime: undefined,
|
||||||
|
runtimeActionOptions: [],
|
||||||
|
runtimeAgentOptions: [],
|
||||||
|
runtimeContextCompactAvailable: false,
|
||||||
|
runtimeContextCompacting: false,
|
||||||
|
runtimeLabel: "Model",
|
||||||
|
runtimeMenuButtonRef: createRef(),
|
||||||
|
runtimeModelDetail: undefined,
|
||||||
|
runtimeNativeSnapshot: undefined,
|
||||||
|
runtimePresetOptions: [],
|
||||||
|
runtimeSettings: undefined,
|
||||||
|
runtimeStatusKey: "",
|
||||||
|
runtimeSwitching: false,
|
||||||
|
selectedContinuePreset: "",
|
||||||
|
selectedExpertId: "",
|
||||||
|
selectedRuntimeAgent: "",
|
||||||
|
selectedRuntimeCommand: "",
|
||||||
|
selectingContextFiles: false,
|
||||||
|
updateAttachmentBusy: noop,
|
||||||
|
voiceListening: false,
|
||||||
|
voiceRecording: false,
|
||||||
|
workspaceView: "chat",
|
||||||
|
workModeOptions: [],
|
||||||
|
};
|
||||||
|
const renders = vi.fn();
|
||||||
|
const view = render(
|
||||||
|
<Profiler id="composer" onRender={renders}>
|
||||||
|
<Composer {...props} />
|
||||||
|
</Profiler>,
|
||||||
|
);
|
||||||
|
renders.mockClear();
|
||||||
|
return { drafts, props, renders, rerender: view.rerender, store: stores.conversations };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await changeUiLocale("en-US");
|
||||||
|
vi.stubGlobal("goodbuddy", {
|
||||||
|
context: { pendingParsing: vi.fn(async () => []) },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Composer render boundary", () => {
|
||||||
|
it("does not re-render when another conversation changes", () => {
|
||||||
|
const { renders, store } = setup();
|
||||||
|
act(() => store.set((current) => current.map((item) =>
|
||||||
|
item.id === "b" ? { ...item, title: "B", updatedAt: 2 } : item)));
|
||||||
|
expect(renders).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not re-render when its own conversation only gains a message", () => {
|
||||||
|
const { renders, store } = setup();
|
||||||
|
act(() => store.set((current) => current.map((item) =>
|
||||||
|
item.id === "a"
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
updatedAt: 2,
|
||||||
|
messages: [{ id: "m1", role: "user", content: "hi", createdAt: 1, state: "done", blocks: [] } as unknown as Conversation["messages"][number]],
|
||||||
|
}
|
||||||
|
: item)));
|
||||||
|
expect(renders).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-renders when its conversation starts running and shows the stop button", () => {
|
||||||
|
const { renders, store } = setup();
|
||||||
|
act(() => store.set((current) => current.map((item) =>
|
||||||
|
item.id === "a"
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
messages: [{ id: "m1", role: "assistant", content: "", createdAt: 1, state: "streaming", blocks: [] } as unknown as Conversation["messages"][number]],
|
||||||
|
}
|
||||||
|
: item)));
|
||||||
|
expect(renders).toHaveBeenCalled();
|
||||||
|
expect(screen.getByRole("button", { name: "Stop generating" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("typing in another conversation's draft does not re-render the composer", () => {
|
||||||
|
const { drafts, renders } = setup();
|
||||||
|
act(() => drafts.set("b", "hello"));
|
||||||
|
expect(renders).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,191 @@
|
|||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import { useEffect, useRef, type ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
// A composer picker (expert, work mode, runtime agent/preset/action) with a
|
||||||
|
// roving-focus menu. Moved out of App.tsx unchanged.
|
||||||
|
|
||||||
|
export type ComposerMenuOption<T extends string> = {
|
||||||
|
value: T;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeActionChoice = ComposerMenuOption<string> & {
|
||||||
|
action?: { type: "command"; id: string } | { type: "prompt"; prompt: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ComposerMenuSelect<T extends string>({
|
||||||
|
ariaLabel,
|
||||||
|
className,
|
||||||
|
describedBy,
|
||||||
|
disabled = false,
|
||||||
|
icon,
|
||||||
|
menuOpen,
|
||||||
|
onChange,
|
||||||
|
onOpenChange,
|
||||||
|
options,
|
||||||
|
triggerLabel,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
ariaLabel: string;
|
||||||
|
className: string;
|
||||||
|
describedBy?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
icon: ReactNode;
|
||||||
|
menuOpen: boolean;
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
options: readonly ComposerMenuOption<T>[];
|
||||||
|
triggerLabel?: string;
|
||||||
|
value: T;
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const { t } = useTranslation("app");
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const selectedOption =
|
||||||
|
options.find((option) => option.value === value) ?? options[0];
|
||||||
|
const selectionLabel = t("composer.menuSelection", {
|
||||||
|
label: ariaLabel,
|
||||||
|
selection: selectedOption?.label ?? "",
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menuOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const menu = menuRef.current;
|
||||||
|
if (!menu) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const menuItems = Array.from(
|
||||||
|
menu.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]'),
|
||||||
|
).filter((item) => !item.disabled);
|
||||||
|
const initialItem =
|
||||||
|
menuItems.find((item) => item.getAttribute("aria-checked") === "true") ??
|
||||||
|
menuItems[0];
|
||||||
|
menuItems.forEach((item) => {
|
||||||
|
item.tabIndex = item === initialItem ? 0 : -1;
|
||||||
|
});
|
||||||
|
const focusFrame = requestAnimationFrame(() => {
|
||||||
|
initialItem?.focus();
|
||||||
|
});
|
||||||
|
const isMenuTarget = (target: EventTarget | null): boolean =>
|
||||||
|
target instanceof Node &&
|
||||||
|
(menu.contains(target) || buttonRef.current?.contains(target) === true);
|
||||||
|
const dismissOnOutsidePointer = (event: PointerEvent): void => {
|
||||||
|
if (!isMenuTarget(event.target)) {
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const dismissOnOutsideFocus = (event: FocusEvent): void => {
|
||||||
|
if (!isMenuTarget(event.target)) {
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("pointerdown", dismissOnOutsidePointer);
|
||||||
|
document.addEventListener("focusin", dismissOnOutsideFocus);
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(focusFrame);
|
||||||
|
document.removeEventListener("pointerdown", dismissOnOutsidePointer);
|
||||||
|
document.removeEventListener("focusin", dismissOnOutsideFocus);
|
||||||
|
};
|
||||||
|
}, [menuOpen, onOpenChange, value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`runtime-picker composer-picker ${className}`}>
|
||||||
|
<button
|
||||||
|
aria-describedby={describedBy}
|
||||||
|
aria-expanded={menuOpen}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-label={selectionLabel}
|
||||||
|
className="model-button composer-picker__button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onOpenChange(!menuOpen)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (
|
||||||
|
!menuOpen &&
|
||||||
|
(event.key === "ArrowDown" ||
|
||||||
|
event.key === "Enter" ||
|
||||||
|
event.key === " ")
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
onOpenChange(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
ref={buttonRef}
|
||||||
|
title={selectionLabel}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span className="model-button__label">
|
||||||
|
{triggerLabel ?? selectedOption?.label}
|
||||||
|
</span>
|
||||||
|
<ChevronDown aria-hidden="true" size={14} />
|
||||||
|
</button>
|
||||||
|
{menuOpen && (
|
||||||
|
<div
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className="runtime-picker__menu composer-picker__menu"
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
const items = Array.from(
|
||||||
|
event.currentTarget.querySelectorAll<HTMLButtonElement>(
|
||||||
|
'[role="menuitemradio"]',
|
||||||
|
),
|
||||||
|
).filter((item) => !item.disabled);
|
||||||
|
const currentIndex = items.indexOf(
|
||||||
|
document.activeElement as HTMLButtonElement,
|
||||||
|
);
|
||||||
|
let nextIndex: number | undefined;
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
nextIndex = (currentIndex + 1) % items.length;
|
||||||
|
} else if (event.key === "ArrowUp") {
|
||||||
|
nextIndex = (currentIndex - 1 + items.length) % items.length;
|
||||||
|
} else if (event.key === "Home") {
|
||||||
|
nextIndex = 0;
|
||||||
|
} else if (event.key === "End") {
|
||||||
|
nextIndex = items.length - 1;
|
||||||
|
} else if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
onOpenChange(false);
|
||||||
|
buttonRef.current?.focus();
|
||||||
|
}
|
||||||
|
const nextItem =
|
||||||
|
nextIndex === undefined ? undefined : items.at(nextIndex);
|
||||||
|
if (nextItem) {
|
||||||
|
event.preventDefault();
|
||||||
|
items.forEach((item) => {
|
||||||
|
item.tabIndex = item === nextItem ? 0 : -1;
|
||||||
|
});
|
||||||
|
nextItem.focus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
ref={menuRef}
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
{options.map((option) => (
|
||||||
|
<button
|
||||||
|
aria-checked={option.value === value}
|
||||||
|
disabled={option.disabled}
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(option.value);
|
||||||
|
onOpenChange(false);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
buttonRef.current?.focus();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
role="menuitemradio"
|
||||||
|
tabIndex={option.value === value ? 0 : -1}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
<small>{option.description}</small>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { useCallback, useLayoutEffect, useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open/closed state of the composer's popups (options dialog, knowledge
|
||||||
|
* scope, the pickers and the runtime menu). It lives outside App so opening a
|
||||||
|
* menu re-renders only the composer.
|
||||||
|
*
|
||||||
|
* The state belongs to one composer context (conversation, running state and
|
||||||
|
* view). Switching context closes every popup, as the earlier
|
||||||
|
* reset-during-render in App did, without an extra App render.
|
||||||
|
*/
|
||||||
|
export type ComposerMenuId =
|
||||||
|
| "expert"
|
||||||
|
| "mode"
|
||||||
|
| "runtime-agent"
|
||||||
|
| "runtime-action"
|
||||||
|
| "runtime-preset";
|
||||||
|
|
||||||
|
export type ComposerMenuState = {
|
||||||
|
readonly optionsOpen: boolean;
|
||||||
|
readonly knowledgeScopeOpen: boolean;
|
||||||
|
readonly menu: ComposerMenuId | undefined;
|
||||||
|
readonly runtimeMenuOpen: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const closedComposerMenus: ComposerMenuState = Object.freeze({
|
||||||
|
optionsOpen: false,
|
||||||
|
knowledgeScopeOpen: false,
|
||||||
|
menu: undefined,
|
||||||
|
runtimeMenuOpen: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ComposerMenuUpdate =
|
||||||
|
| Partial<ComposerMenuState>
|
||||||
|
| ((current: ComposerMenuState) => Partial<ComposerMenuState>);
|
||||||
|
|
||||||
|
export type ComposerMenuStore = ReturnType<typeof createComposerMenuStore>;
|
||||||
|
|
||||||
|
function sameMenus(left: ComposerMenuState, right: ComposerMenuState): boolean {
|
||||||
|
return (
|
||||||
|
left.optionsOpen === right.optionsOpen &&
|
||||||
|
left.knowledgeScopeOpen === right.knowledgeScopeOpen &&
|
||||||
|
left.menu === right.menu &&
|
||||||
|
left.runtimeMenuOpen === right.runtimeMenuOpen
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createComposerMenuStore() {
|
||||||
|
let key = "";
|
||||||
|
let menus = closedComposerMenus;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
const notify = (): void => {
|
||||||
|
for (const listener of [...listeners]) listener();
|
||||||
|
};
|
||||||
|
const store = {
|
||||||
|
/** The popups of `contextKey`; closed for any other context. */
|
||||||
|
get(contextKey: string): ComposerMenuState {
|
||||||
|
return contextKey === key ? menus : closedComposerMenus;
|
||||||
|
},
|
||||||
|
/** Makes `contextKey` current; popups of an earlier context stay closed. */
|
||||||
|
retain(contextKey: string): void {
|
||||||
|
if (contextKey === key) return;
|
||||||
|
const wasOpen = menus !== closedComposerMenus;
|
||||||
|
key = contextKey;
|
||||||
|
menus = closedComposerMenus;
|
||||||
|
if (wasOpen) notify();
|
||||||
|
},
|
||||||
|
update(contextKey: string, update: ComposerMenuUpdate): void {
|
||||||
|
const current = store.get(contextKey);
|
||||||
|
const patch = typeof update === "function" ? update(current) : update;
|
||||||
|
const next = { ...current, ...patch };
|
||||||
|
key = contextKey;
|
||||||
|
if (sameMenus(current, next)) {
|
||||||
|
menus = current;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
menus = sameMenus(next, closedComposerMenus) ? closedComposerMenus : next;
|
||||||
|
notify();
|
||||||
|
},
|
||||||
|
/** Closes the pickers and the runtime menu of whatever context is current. */
|
||||||
|
closeMenus(): void {
|
||||||
|
store.update(key, { menu: undefined, runtimeMenuOpen: false });
|
||||||
|
},
|
||||||
|
closeRuntimeMenu(): void {
|
||||||
|
store.update(key, { runtimeMenuOpen: false });
|
||||||
|
},
|
||||||
|
subscribe(listener: () => void): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useComposerMenus(
|
||||||
|
store: ComposerMenuStore,
|
||||||
|
contextKey: string,
|
||||||
|
): [ComposerMenuState, (update: ComposerMenuUpdate) => void] {
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
store.retain(contextKey);
|
||||||
|
}, [contextKey, store]);
|
||||||
|
const getSnapshot = useCallback(() => store.get(contextKey), [contextKey, store]);
|
||||||
|
const menus = useSyncExternalStore(store.subscribe, getSnapshot);
|
||||||
|
const update = useCallback(
|
||||||
|
(next: ComposerMenuUpdate): void => store.update(contextKey, next),
|
||||||
|
[contextKey, store],
|
||||||
|
);
|
||||||
|
return [menus, update];
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Composer textarea sizing and attachment size formatting, moved out of App.tsx.
|
||||||
|
|
||||||
|
export function formatAttachmentSize(size: number): string {
|
||||||
|
return `${Math.max(1, Math.ceil(size / 1024))} KB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const composerTextareaMinHeight = 72;
|
||||||
|
const composerTextareaMaxHeight = 220;
|
||||||
|
|
||||||
|
const composerTextareaSizes = new WeakMap<
|
||||||
|
HTMLTextAreaElement,
|
||||||
|
{ height: number; value: string }
|
||||||
|
>();
|
||||||
|
|
||||||
|
function clampComposerTextareaHeight(height: number): number {
|
||||||
|
return Math.max(
|
||||||
|
composerTextareaMinHeight,
|
||||||
|
Math.min(height, composerTextareaMaxHeight),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sizing reads scrollHeight, which forces a synchronous layout of the whole
|
||||||
|
// window. Skip the read when the result is known, and avoid the extra
|
||||||
|
// "auto" reset layout when text was only appended (it can only grow).
|
||||||
|
export function resizeComposerTextarea(textarea: HTMLTextAreaElement | null): void {
|
||||||
|
if (!textarea) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = textarea.value;
|
||||||
|
const previous = composerTextareaSizes.get(textarea);
|
||||||
|
const previousHeightApplied =
|
||||||
|
previous !== undefined &&
|
||||||
|
textarea.style.height === `${previous.height}px`;
|
||||||
|
let height: number;
|
||||||
|
if (previousHeightApplied && value === previous.value) {
|
||||||
|
height = previous.height;
|
||||||
|
} else if (
|
||||||
|
previousHeightApplied &&
|
||||||
|
previous.value !== "" &&
|
||||||
|
value.startsWith(previous.value)
|
||||||
|
) {
|
||||||
|
height =
|
||||||
|
previous.height >= composerTextareaMaxHeight
|
||||||
|
? composerTextareaMaxHeight
|
||||||
|
: clampComposerTextareaHeight(textarea.scrollHeight);
|
||||||
|
} else {
|
||||||
|
textarea.style.height = "auto";
|
||||||
|
height = clampComposerTextareaHeight(textarea.scrollHeight);
|
||||||
|
}
|
||||||
|
const nextHeight = `${height}px`;
|
||||||
|
if (textarea.style.height !== nextHeight) {
|
||||||
|
textarea.style.height = nextHeight;
|
||||||
|
}
|
||||||
|
composerTextareaSizes.set(textarea, { height, value });
|
||||||
|
}
|
||||||
@@ -222,6 +222,66 @@ export function useActiveConversationView(
|
|||||||
return useConversationStoreSelector(store, selector, sameActiveConversationView, subscribe);
|
return useConversationStoreSelector(store, selector, sameActiveConversationView, subscribe);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fields of one conversation the composer shows. Unlike the active
|
||||||
|
* conversation view it leaves out the message count and artifact IDs, so a
|
||||||
|
* new message or a tool result does not re-render the composer.
|
||||||
|
*/
|
||||||
|
export type ComposerConversationView = Pick<
|
||||||
|
Conversation,
|
||||||
|
| "id" | "runtimeSelection" | "knowledgeLibraryIds" | "knowledgeRetrievalMode"
|
||||||
|
| "remote" | "contextMetrics" | "contextCompressionState"
|
||||||
|
> & { running: boolean };
|
||||||
|
|
||||||
|
export function selectComposerConversationView(
|
||||||
|
conversation: Conversation | undefined,
|
||||||
|
): ComposerConversationView | undefined {
|
||||||
|
if (!conversation) return undefined;
|
||||||
|
return {
|
||||||
|
id: conversation.id,
|
||||||
|
runtimeSelection: conversation.runtimeSelection,
|
||||||
|
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
|
||||||
|
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
||||||
|
remote: conversation.remote,
|
||||||
|
contextMetrics: conversation.contextMetrics,
|
||||||
|
contextCompressionState: conversation.contextCompressionState,
|
||||||
|
running: conversation.messages.some((message) => message.state === "streaming"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sameComposerConversationView(
|
||||||
|
left: ComposerConversationView | undefined,
|
||||||
|
right: ComposerConversationView | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (left === right) return true;
|
||||||
|
if (!left || !right) return false;
|
||||||
|
return left.id === right.id && left.running === right.running &&
|
||||||
|
left.knowledgeRetrievalMode === right.knowledgeRetrievalMode &&
|
||||||
|
sameArrayItems(left.knowledgeLibraryIds ?? [], right.knowledgeLibraryIds ?? []) &&
|
||||||
|
(left.knowledgeLibraryIds === undefined) === (right.knowledgeLibraryIds === undefined) &&
|
||||||
|
sameData(left.runtimeSelection, right.runtimeSelection) &&
|
||||||
|
sameData(left.remote, right.remote) &&
|
||||||
|
sameData(left.contextMetrics, right.contextMetrics) &&
|
||||||
|
sameData(left.contextCompressionState, right.contextCompressionState);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useComposerConversationView(
|
||||||
|
store: ConversationStore,
|
||||||
|
conversationId: string,
|
||||||
|
): ComposerConversationView | undefined {
|
||||||
|
const selector = useCallback(
|
||||||
|
(conversations: Conversation[]) => selectComposerConversationView(
|
||||||
|
conversations.find((conversation) => conversation.id === conversationId),
|
||||||
|
),
|
||||||
|
[conversationId],
|
||||||
|
);
|
||||||
|
const subscribe = useCallback(
|
||||||
|
(listener: () => void) => store.subscribeConversation(conversationId, listener),
|
||||||
|
[conversationId, store],
|
||||||
|
);
|
||||||
|
return useConversationStoreSelector(store, selector, sameComposerConversationView, subscribe);
|
||||||
|
}
|
||||||
|
|
||||||
/** The conversations with these IDs, in this order; stable while unchanged. */
|
/** The conversations with these IDs, in this order; stable while unchanged. */
|
||||||
export function useConversationsById(
|
export function useConversationsById(
|
||||||
store: ConversationStore,
|
store: ConversationStore,
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { SetStateAction } from "react";
|
||||||
|
import type { InteractiveWorkMode } from "../../shared/assistant-contracts";
|
||||||
|
import type { ContextAttachment } from "../../shared/contracts";
|
||||||
|
import type { RuntimeSelectionLayer } from "../../shared/runtime-selection-contracts";
|
||||||
|
import type { TerminalSnapshot } from "../../shared/terminal-contracts";
|
||||||
|
import type { ImageViewerItem } from "./ChatTimeline";
|
||||||
|
import { useStableHandlers } from "./stable-derived-value";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the composer asks App to do. App passes its latest closures;
|
||||||
|
* the returned object never changes identity, so the memoized Composer does
|
||||||
|
* not re-render because App created new callbacks.
|
||||||
|
*
|
||||||
|
* Calls run the implementation of App's latest commit. Async work that must
|
||||||
|
* stay with the conversation it started in receives that conversation's ID
|
||||||
|
* as an argument instead of reading App's current one.
|
||||||
|
*/
|
||||||
|
export type ComposerActions = {
|
||||||
|
submit: () => void;
|
||||||
|
stop: () => void;
|
||||||
|
selectContextFiles: (paths?: string[]) => void;
|
||||||
|
addContext: (
|
||||||
|
action: () => Promise<ContextAttachment | ContextAttachment[]>,
|
||||||
|
) => void;
|
||||||
|
isSelectingContextFiles: () => boolean;
|
||||||
|
setContextError: (message: string | undefined) => void;
|
||||||
|
removeAttachment: (attachmentId: string) => void;
|
||||||
|
removeImageReference: (conversationId: string, artifactId: string) => void;
|
||||||
|
restoreQueueItem: (conversationId: string, itemId: string) => Promise<void>;
|
||||||
|
interruptQueueItem: (itemId: string) => Promise<void>;
|
||||||
|
removeQueueItem: (itemId: string) => Promise<void>;
|
||||||
|
queueError: (message: string) => void;
|
||||||
|
toggleVoiceInput: () => void;
|
||||||
|
compactRuntimeContext: () => void;
|
||||||
|
openImageViewer: (item: ImageViewerItem, trigger: HTMLElement) => void;
|
||||||
|
openModelSettings: () => void;
|
||||||
|
setEnabledKnowledgeLibraryIds: (update: SetStateAction<string[]>) => void;
|
||||||
|
setKnowledgeRetrievalMode: (mode: "auto" | "always") => void;
|
||||||
|
selectExpert: (expertId: string) => void;
|
||||||
|
selectRuntimeAgent: (agentId: string) => void;
|
||||||
|
selectContinuePreset: (presetId: string) => void;
|
||||||
|
selectRuntimeAction: (value: string) => void;
|
||||||
|
setWorkMode: (mode: InteractiveWorkMode) => void;
|
||||||
|
switchRuntime: (layer: RuntimeSelectionLayer | undefined) => void;
|
||||||
|
prepareNativeClientConversation: () => Promise<string>;
|
||||||
|
openNativeTerminal: (
|
||||||
|
terminal: TerminalSnapshot,
|
||||||
|
origin: { projectId: string; conversationId: string },
|
||||||
|
) => void;
|
||||||
|
notify: (notification: { tone: "error" | "success"; message: string }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useComposerActions(latest: ComposerActions): ComposerActions {
|
||||||
|
return useStableHandlers(latest);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user