perf(renderer): extract the chat composer into a memoized Composer with stable actions and a popup store
This commit is contained in:
+120
-1475
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
export function useConversationsById(
|
||||
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