diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 0288cfac..354f8d28 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,16 +1,10 @@ -import { ImageCapabilityNotice } from "./ImageCapabilityNotice"; import { MagicNotesPanel } from './MagicNotesPanel'; import { AnchoredMenu } from './AnchoredMenu'; import { useMagicNoteDraft } from './use-magic-note-draft'; import type { MagicNoteSource } from '../../shared/magic-notes-contracts'; -import { RuntimeNativeClientActions } from "./RuntimeNativeClientActions"; import type { TerminalSnapshot } from "../../shared/terminal-contracts"; import LocalInferencePage from "./LocalInferencePage"; import { ApplicationMenu } from './ApplicationMenu'; -import { AttachmentResultButton } from './AttachmentResultButton'; -import { AttachmentActions, AttachmentStatus } from './AttachmentActions'; -import { PendingDocumentImports } from './PendingDocumentImports'; -import { AttachmentCapabilityNotice } from './AttachmentCapabilityNotice'; import { DocumentConversationContext } from './DocumentConversationContext'; import { maximumAttachmentsPerMessage } from '../../shared/attachment-limits'; import { buildRuntimeHistory } from '../../shared/runtime-history'; @@ -28,41 +22,27 @@ import { } from "../../shared/application-settings-contracts"; import type { ImageOperation } from "../../shared/image-generation-contracts"; import { - Bot, ChartColumn, CheckCircle2, - ChevronDown, ChevronUp, CircleAlert, - CircleHelp, Download, - FileText, Grid2X2, Info, - Library, LoaderCircle, Maximize2, MessageSquarePlus, MessageSquare, - Mic, Minimize2, Minus, Moon, MoreHorizontal, - Paperclip, PanelLeft, Search, - Send, Settings, - RefreshCw, - ShieldCheck, - SlidersHorizontal, PanelRightClose, PanelRightOpen, - Sparkles, - Square, Sun, - TerminalSquare, X, } from "lucide-react"; import { @@ -99,15 +79,10 @@ import type { RuntimeControl, RuntimeSettings, } from "../../shared/contracts"; -import { - defaultContextCompressionSettings, - maximumPastedImageBytes, -} from "../../shared/contracts"; import { buildConversationSummaryHistory, estimatedContextRequestOverheadTokens, estimateMessagesTokens, - getEffectiveContextTriggerTokens, } from "../../shared/context-window"; import { agentRuntimeSelectionKey, @@ -122,7 +97,6 @@ import { runtimeModelLabel, runtimeProviderLabel, } from "./runtime-selection"; -import { RuntimeModelPicker } from "./RuntimeModelPicker"; import type { ActivityHistorySnapshot, AssistantProject, @@ -185,12 +159,12 @@ import { type ConversationListRowHandlers, type ConversationSidebarActions, } from "./ConversationSidebar"; -import { - ComposerDraftEffect, - ComposerDraftHasText, - ComposerDraftText, - createComposerDraftStore, -} from "./composer-draft-store"; +import { createComposerDraftStore } from "./composer-draft-store"; +import { createComposerMenuStore } from "./composer-menu-store"; +import type { ComposerMenuOption, RuntimeActionChoice } from "./ComposerMenuSelect"; +import { formatAttachmentSize, resizeComposerTextarea } from "./composer-textarea"; +import { Composer } from "./Composer"; +import { useComposerActions } from "./use-composer-actions"; import { LiveMessageStoreContext } from "./live-message-store"; import { createConversationPersistence, @@ -222,7 +196,6 @@ import { import { EmptyState, PageShell, - SegmentedControl, ScopeBadge, } from "./WorkspacePrimitives"; import { ProjectSwitcher } from "./ProjectSwitcher"; @@ -241,7 +214,6 @@ import { type CustomTaskDestination, } from "./CustomTaskDialog"; import { ConversationHistorySlot } from "./ConversationHistorySlot"; -import { ConversationInputQueue } from "./ConversationInputQueue"; import type { SettingsCategoryId } from "./settings-categories"; import type { SettingsLeaveRequester } from "./SettingsPanel"; import { formatShortcutForDisplay, type GlobalShortcutSettingsSnapshot } from "../../shared/shortcut"; @@ -272,7 +244,6 @@ import type { RemoteProjectRecoveryState } from "../../shared/remote-project-rec import { ReleaseNotesDialog } from "./ReleaseNotesDialog"; import { scheduleIdleRoutePreload } from "./idle-route-preload"; import { createPreloadableComponent } from "./preloadable-component"; -import { formatCompactTokens } from "./token-format"; import { filterKeepAliveEntries, pruneKeepAliveEntries, @@ -626,6 +597,8 @@ const emptyTokenUsage: TokenUsageSummary = { const storageKey = "goodbuddy.conversations.v1"; const emptyConversationTasks: AssistantTask[] = []; +const emptyAttachments: ContextAttachment[] = []; +const emptyImageReferences: AssistantArtifact[] = []; const activeProjectStorageKey = "goodbuddy.active-project.v1"; @@ -964,60 +937,6 @@ function runtimeChoiceLabel( )}`; } -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). -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 }); -} - const imageDataUrlPattern = /^data:image\/(png|jpeg|webp);base64,/u; function getImageDownloadName( @@ -1150,191 +1069,6 @@ function WindowControls({ ); } -type ComposerMenuOption = { - value: T; - label: string; - description: string; - disabled?: boolean; -}; - -type RuntimeActionChoice = ComposerMenuOption & { - action?: { type: "command"; id: string } | { type: "prompt"; prompt: string }; -}; - -function ComposerMenuSelect({ - 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[]; - triggerLabel?: string; - value: T; -}): React.JSX.Element { - const { t } = useTranslation("app"); - const buttonRef = useRef(null); - const menuRef = useRef(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('[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 ( -
- - {menuOpen && ( -
{ - const items = Array.from( - event.currentTarget.querySelectorAll( - '[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) => ( - - ))} -
- )} -
- ); -} - function App(): React.JSX.Element { @@ -1464,9 +1198,8 @@ function App(): React.JSX.Element { composerDrafts.set(conversationId, update), [composerDrafts], ); - const resizeActiveComposer = useCallback((): void => { - resizeComposerTextarea(inputRef.current); - }, []); + // Composer popups (options, pickers, runtime menu) open and close without re-rendering App. + const [composerMenus] = useState(createComposerMenuStore); // Async composer operations retain the conversation that started them. const setInput = useCallback( (update: SetStateAction): void => setConversationInput(activeId, update), @@ -1484,19 +1217,6 @@ function App(): React.JSX.Element { const [runtime, setRuntime] = useState(); const [runtimeStatusKey, setRuntimeStatusKey] = useState(""); const [runtimeSettings, setRuntimeSettings] = useState(); - const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false); - const [knowledgeScopeOpen, setKnowledgeScopeOpen] = useState(false); - const [composerOptionsOpen, setComposerOptionsOpen] = useState(false); - const composerOptionsRef = useRef(null); - const composerOptionsTriggerRef = useRef(null); - const [composerMenuOpen, setComposerMenuOpen] = useState< - | "expert" - | "mode" - | "runtime-agent" - | "runtime-action" - | "runtime-preset" - | undefined - >(); const [runtimeCustomization, setRuntimeCustomization] = useState(); const [runtimeNativeSnapshot, setRuntimeNativeSnapshot] = @@ -1519,7 +1239,6 @@ function App(): React.JSX.Element { useState(false); const runtimeCustomizationRequestRef = useRef(0); const runtimeMenuButtonRef = useRef(null); - const runtimeMenuRef = useRef(null); const [runtimeSwitching, setRuntimeSwitching] = useState(false); const [appearanceTheme, setAppearanceTheme] = useState(loadAppearanceTheme); @@ -1542,42 +1261,6 @@ function App(): React.JSX.Element { const toggleAppearanceTheme = useCallback((): void => { setAppearanceTheme(resolvedAppearanceTheme === "dark" ? "light" : "dark"); }, [resolvedAppearanceTheme]); - const setExpertMenuOpen = useCallback((open: boolean): void => { - setComposerMenuOpen(open ? "expert" : undefined); - if (open) { - setRuntimeMenuOpen(false); - setKnowledgeScopeOpen(false); - } - }, []); - const setModeMenuOpen = useCallback((open: boolean): void => { - setComposerMenuOpen(open ? "mode" : undefined); - if (open) { - setRuntimeMenuOpen(false); - setComposerOptionsOpen(false); - setKnowledgeScopeOpen(false); - } - }, []); - const setRuntimeAgentMenuOpen = useCallback((open: boolean): void => { - setComposerMenuOpen(open ? "runtime-agent" : undefined); - if (open) { - setRuntimeMenuOpen(false); - setKnowledgeScopeOpen(false); - } - }, []); - const setRuntimeActionMenuOpen = useCallback((open: boolean): void => { - setComposerMenuOpen(open ? "runtime-action" : undefined); - if (open) { - setRuntimeMenuOpen(false); - setKnowledgeScopeOpen(false); - } - }, []); - const setRuntimePresetMenuOpen = useCallback((open: boolean): void => { - setComposerMenuOpen(open ? "runtime-preset" : undefined); - if (open) { - setRuntimeMenuOpen(false); - setKnowledgeScopeOpen(false); - } - }, []); const assistantExpertOptions = useMemo[]>( () => [ { @@ -1989,9 +1672,9 @@ function App(): React.JSX.Element { const [attachmentsByConversation, setAttachmentsByConversation] = useState< Record >({}); - const attachments = attachmentsByConversation[activeId] ?? []; + const attachments = attachmentsByConversation[activeId] ?? emptyAttachments; const [imageReferencesByConversation, setImageReferencesByConversation] = useState>({}); - const imageReferences = imageReferencesByConversation[activeId] ?? []; + const imageReferences = imageReferencesByConversation[activeId] ?? emptyImageReferences; const attachmentsRef = useRef(new Map()); const attachmentSaveQueue = useRef>(Promise.resolve()); useEffect(() => window.goodbuddy.context.onDraftChanged((conversationId, saved) => { @@ -2097,8 +1780,6 @@ function App(): React.JSX.Element { const knowledgeLoadRequestRef = useRef(0); const citationRequestRef = useRef(0); const failedKnowledgeLibraryIdRef = useRef(undefined); - const knowledgeScopeTriggerRef = useRef(null); - const knowledgeScopePopoverRef = useRef(null); const [legacyActivityHistory] = useState(loadLegacyActivityHistory); const [activityRecords, setActivityRecords] = useState( legacyActivityHistory.records, @@ -2466,67 +2147,6 @@ function App(): React.JSX.Element { }; }, [closeNarrowSidebar, narrowWindow, sidebarOpen]); - useEffect(() => { - if (!knowledgeScopeOpen) { - return; - } - const focusFrame = requestAnimationFrame(() => { - knowledgeScopePopoverRef.current - ?.querySelector("input") - ?.focus(); - }); - const isScopeTarget = (target: EventTarget | null): boolean => - target instanceof Node && - (knowledgeScopePopoverRef.current?.contains(target) === true || - knowledgeScopeTriggerRef.current?.contains(target) === true); - const closeOnOutsidePointer = (event: PointerEvent): void => { - if (!isScopeTarget(event.target)) { - setKnowledgeScopeOpen(false); - } - }; - const closeOnEscape = (event: KeyboardEvent): void => { - if (event.key !== "Escape" || event.defaultPrevented) { - return; - } - event.preventDefault(); - setKnowledgeScopeOpen(false); - knowledgeScopeTriggerRef.current?.focus(); - }; - document.addEventListener("pointerdown", closeOnOutsidePointer); - document.addEventListener("keydown", closeOnEscape); - return () => { - cancelAnimationFrame(focusFrame); - document.removeEventListener("pointerdown", closeOnOutsidePointer); - document.removeEventListener("keydown", closeOnEscape); - }; - }, [knowledgeScopeOpen]); - - useEffect(() => { - if (!composerOptionsOpen) { - return; - } - const isOptionsTarget = (target: EventTarget | null): boolean => - target instanceof Node && - (composerOptionsRef.current?.contains(target) === true || - composerOptionsTriggerRef.current?.contains(target) === true); - const dismissOutside = (event: Event): void => { - if (!isOptionsTarget(event.target)) { - setComposerOptionsOpen(false); - setKnowledgeScopeOpen(false); - } - }; - const frame = requestAnimationFrame(() => { - composerOptionsRef.current?.querySelector("button:not(:disabled)")?.focus(); - }); - document.addEventListener("pointerdown", dismissOutside); - document.addEventListener("focusin", dismissOutside); - return () => { - cancelAnimationFrame(frame); - document.removeEventListener("pointerdown", dismissOutside); - document.removeEventListener("focusin", dismissOutside); - }; - }, [composerOptionsOpen]); - useEffect(() => cancelLiveMessageFlush, [cancelLiveMessageFlush]); useEffect(() => { @@ -3096,51 +2716,6 @@ function App(): React.JSX.Element { }, [runtimeActionOptions, setInput], ); - - useEffect(() => { - if (!runtimeMenuOpen) { - return; - } - const menu = runtimeMenuRef.current; - if (!menu) { - return; - } - const menuItems = Array.from( - menu.querySelectorAll( - '[role="menuitemradio"], [role="menuitem"]', - ), - ).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 isRuntimeMenuTarget = (target: EventTarget | null): boolean => - target instanceof Node && - (menu.contains(target) || - runtimeMenuButtonRef.current?.contains(target) === true); - const dismissOnOutsidePointer = (event: PointerEvent): void => { - if (!isRuntimeMenuTarget(event.target)) { - setRuntimeMenuOpen(false); - } - }; - const dismissOnOutsideFocus = (event: FocusEvent): void => { - if (!isRuntimeMenuTarget(event.target)) { - setRuntimeMenuOpen(false); - } - }; - document.addEventListener("pointerdown", dismissOnOutsidePointer); - document.addEventListener("focusin", dismissOnOutsideFocus); - return () => { - cancelAnimationFrame(focusFrame); - document.removeEventListener("pointerdown", dismissOnOutsidePointer); - document.removeEventListener("focusin", dismissOnOutsideFocus); - }; - }, [activeRuntimeSelectionKey, runtimeMenuOpen]); // The committed active ID, or a just-created conversation until the next // commit, so repeated new-conversation requests reuse it. const conversationNavigationRef = useRef({ activeId }); @@ -3552,7 +3127,7 @@ function App(): React.JSX.Element { const selection = resolved.selection; runtimeMenuButtonRef.current?.focus(); setRuntimeSwitching(true); - setRuntimeMenuOpen(false); + composerMenus.closeRuntimeMenu(); const requestId = runtimeStatusRequestRef.current + 1; runtimeStatusRequestRef.current = requestId; const generation = runtimeSwitchGenerationRef.current; @@ -3628,6 +3203,7 @@ function App(): React.JSX.Element { [ activeConversation, activeProject, + composerMenus, runtimeSettings, runtimeSwitching, setConversations, @@ -5845,8 +5421,7 @@ function App(): React.JSX.Element { } await attachmentSaveQueue.current; await window.goodbuddy.conversationQueue.enqueueUser(queueInput); - setComposerMenuOpen(undefined); - setRuntimeMenuOpen(false); + composerMenus.closeMenus(); setInput(""); updateAttachments([]); setImageReferencesByConversation(current => ({ ...current, [conversationId]: [] })); @@ -5866,8 +5441,7 @@ function App(): React.JSX.Element { return; } dispatchedConversationQueueItems.current.add(queuedDispatch.item.id); - setComposerMenuOpen(undefined); - setRuntimeMenuOpen(false); + composerMenus.closeMenus(); preparingConversations.current.add(conversationId); clearConversationCompleted(conversationId); setConversationActivity(conversationId, true); @@ -7068,14 +6642,6 @@ function App(): React.JSX.Element { }; const isRunning = activeConversation?.running ?? false; - const [composerContext, setComposerContext] = useState({ activeId, isRunning, view }); - if (composerContext.activeId !== activeId || composerContext.isRunning !== isRunning || composerContext.view !== view) { - setComposerContext({ activeId, isRunning, view }); - setComposerOptionsOpen(false); - setKnowledgeScopeOpen(false); - setComposerMenuOpen(undefined); - setRuntimeMenuOpen(false); - } const activeConversationQueueItems = useMemo( () => conversationQueueItems.filter((item) => item.conversationId === activeId), @@ -7106,52 +6672,6 @@ function App(): React.JSX.Element { (itemId: string) => window.goodbuddy.conversationQueue.remove(itemId), [], ); - const runtimeAgentControlAvailable = - activeRuntimeSelection?.provider === "opencode" && - runtimeAgentOptions.length > 1; - const runtimePresetControlAvailable = - activeRuntimeSelection?.provider === "continue" && - runtimePresetOptions.length > 1; - const runtimeActionControlAvailable = - (activeRuntimeSelection?.provider === "opencode" || - activeRuntimeSelection?.provider === "continue") && - runtimeActionOptions.length > 1; - const runtimeControlsAvailable = - runtimeAgentControlAvailable || - runtimePresetControlAvailable || - runtimeActionControlAvailable; - const runtimeControlsProvider = runtimeControlsAvailable - ? activeRuntimeSelection?.provider === "opencode" - ? "OpenCode" - : activeRuntimeSelection?.provider === "continue" - ? "Continue" - : undefined - : undefined; - const runtimeControlsLabel = runtimeControlsProvider - ? t("composer.runtimeControls.groupLabel", { - runtime: runtimeControlsProvider, - }) - : ""; - const composerOptionSummary = [ - selectedExpertId && runtime?.capability !== "image-generation" - ? assistantExpertOptions.find((option) => option.value === selectedExpertId)?.label - : undefined, - enabledKnowledgeLibraryIds.length > 0 - ? t("composer.knowledge.select", { count: enabledKnowledgeLibraryIds.length }) - : undefined, - activeConversation?.knowledgeRetrievalMode === "always" - ? t("composer.knowledge.always") - : undefined, - runtimeAgentControlAvailable && selectedRuntimeAgent - ? runtimeAgentOptions.find((option) => option.value === selectedRuntimeAgent)?.label - : undefined, - runtimePresetControlAvailable && selectedContinuePreset - ? runtimePresetOptions.find((option) => option.value === selectedContinuePreset)?.label - : undefined, - runtimeActionControlAvailable && selectedRuntimeCommand - ? runtimeActionOptions.find((option) => option.action?.type === "command" && option.action.id === selectedRuntimeCommand)?.label - : undefined, - ].filter(Boolean).join(" · "); const runtimeContextCompactAvailable = activeRuntimeSelection?.provider === "model" ? !activeConversation?.remote && @@ -7189,67 +6709,59 @@ function App(): React.JSX.Element { return conversation.id; }; - const composerContextMetrics = useMemo(() => { - if ( - !activeConversation || - !runtimeSettings || - !activeRuntimeSelection || - activeConversation.remote - ) { - return undefined; - } - const resolvedRuntimeSelection = activeRuntimeSelection; - const activeModelProfile = - "profileId" in resolvedRuntimeSelection && - resolvedRuntimeSelection.profileId - ? runtimeSettings.modelProfiles.find( - (candidate) => candidate.id === resolvedRuntimeSelection.profileId, - ) - : undefined; - if (activeModelProfile?.protocol === "openai-images-generations") { - return undefined; - } - const latest = activeConversation.contextMetrics; - const applicableLatest = - latest?.runtimeSelectionKey === - agentRuntimeSelectionKey(resolvedRuntimeSelection) - ? latest - : undefined; - if (!applicableLatest) { - return undefined; - } - const compressionSettings = - runtimeSettings.contextCompression ?? defaultContextCompressionSettings; - const contextTokens = applicableLatest.contextTokens; - const contextWindowTokens = activeModelProfile?.contextWindowTokens; - const effectiveTriggerTokens = getEffectiveContextTriggerTokens({ - triggerTokens: compressionSettings.triggerTokens, - contextWindowTokens, - }); - const denominatorTokens = contextWindowTokens; - const percentage = - denominatorTokens === undefined - ? undefined - : Math.round((contextTokens / denominatorTokens) * 100); - - return { - contextTokens, - effectiveTriggerTokens, - contextWindowTokens, - compressionEnabled: - resolvedRuntimeSelection.provider === "model" && - compressionSettings.enabled, - source: applicableLatest.source, - basis: - applicableLatest.basis ?? - (applicableLatest.source === "estimated" && - activeConversation.contextCompressionState - ? "conversation" - : "model-call"), - denominatorTokens, - percentage, - }; - }, [activeConversation, activeRuntimeSelection, runtimeSettings]); + const composerActions = useComposerActions({ + submit: () => void submit(), + stop: () => void stop(), + selectContextFiles: (paths) => void selectContextFiles(paths), + addContext: (action) => void addContext(action), + isSelectingContextFiles: () => selectingContextFilesRef.current, + setContextError, + removeAttachment: (attachmentId) => { + void window.goodbuddy.context.remove(attachmentId); + updateAttachments((current) => current.filter((item) => item.id !== attachmentId)); + }, + removeImageReference: (conversationId, artifactId) => + setImageReferencesByConversation((current) => ({ + ...current, + [conversationId]: (current[conversationId] ?? []).filter((item) => item.id !== artifactId), + })), + restoreQueueItem: async (conversationId, itemId) => { + await attachmentSaveQueue.current; + const restored = await window.goodbuddy.conversationQueue.restoreToDraft(itemId, ""); + setConversationInput(conversationId, (current) => + [current, restored.prompt].filter(Boolean).join("\n\n")); + }, + interruptQueueItem: interruptConversationQueueItem, + removeQueueItem: removeConversationQueueItem, + queueError: handleConversationQueueError, + toggleVoiceInput, + compactRuntimeContext: () => void compactRuntimeContext(), + openImageViewer, + openModelSettings: openImageModelSettings, + setEnabledKnowledgeLibraryIds, + setKnowledgeRetrievalMode: (mode) => + setConversations((current) => + current.map((conversation) => + conversation.id === activeId + ? { ...conversation, knowledgeRetrievalMode: mode, updatedAt: Date.now() } + : conversation, + ), + ), + selectExpert: setSelectedExpertId, + selectRuntimeAgent: setSelectedRuntimeAgent, + selectContinuePreset: setSelectedContinuePreset, + selectRuntimeAction, + setWorkMode, + switchRuntime: (layer) => void switchRuntime(layer), + prepareNativeClientConversation, + openNativeTerminal: (terminal, origin) => { + const focus = activeProjectIdRef.current === origin.projectId && + activeConversationIdRef.current === origin.conversationId && viewRef.current === "chat"; + setNativeTerminals((current) => [...current, { terminal, focus }]); + if (focus) setAssistantSidebarOpen(true); + }, + notify, + }); const mainSidebarOpen = narrowWindow && sidebarOpen; const canResizePrimarySidebar = sidebarOpen && !narrowWindow; @@ -7257,23 +6769,6 @@ function App(): React.JSX.Element { window.innerWidth, ); const backgroundIsolated = mainSidebarOpen; - const runtimeState = - runtimeSwitching || - !runtime || - runtimeStatusKey !== activeRuntimeSelectionKey - ? "connecting" - : runtime.available - ? "ready" - : "unavailable"; - const runtimeDetailId = "composer-runtime-detail"; - const runtimeDetail = - runtimeState === "connecting" - ? t("runtime.connecting") - : runtime?.detail || - (runtimeState === "unavailable" - ? t("runtime.unavailable") - : t("runtime.state.ready")); - const composerContextErrorId = "composer-context-error"; const runBrowserCommand = async ( command: ( browserApi: NonNullable, @@ -7836,908 +7331,58 @@ function App(): React.JSX.Element { ) : ( <> - { - await attachmentSaveQueue.current; - const restored = await window.goodbuddy.conversationQueue.restoreToDraft(itemId, ""); - setInput((current) => [current, restored.prompt].filter(Boolean).join("\n\n")); - }} - running={conversationExecutionRunning} + -
- - profile.protocol === "openai-images-generations" && profile.allowConversationInvocation === true - ) ?? false} - onOpenModelSettings={openImageModelSettings} - /> - {composerOptionSummary && ( -
- {composerOptionSummary} -
- )} - - {attachments.some((attachment) => attachment.kind === 'image') && item.id).join(',')}:${runtimeSettings?.defaultModelProfileId}:${runtimeStatusKey}`} />} - {(attachments.length > 0 || imageReferences.length > 0 || - selectingContextFiles) && ( -
- {imageReferences.map(artifact =>
- {artifact.content && } - {artifact.title}{t('chat.images.edit')} - -
)} - {attachments.map((attachment) => ( -
- {attachment.kind === "image" && - (attachment.thumbnailUrl || attachment.contentUrl) ? ( - - ) : ( - - )} - - {attachment.name} - - - - {formatAttachmentSize(attachment.size)} - - - - {attachment.resultId && } - - - - -
- ))} - {selectingContextFiles && ( -
-
- )} -
- )} -
- - {(draft) => ( - <> -