diff --git a/src/app/AppShell.navigation.test.tsx b/src/app/AppShell.navigation.test.tsx index 6926f031e..161e125ac 100644 --- a/src/app/AppShell.navigation.test.tsx +++ b/src/app/AppShell.navigation.test.tsx @@ -26,7 +26,6 @@ import type { Message } from "@/shared/types/messages"; import type { GitState } from "@/shared/types/git"; import { setMultiWorkspaceEnabled } from "@/features/workspaces/multiWorkspacePreference"; import { OPEN_SETTINGS_EVENT } from "@/features/settings/lib/settingsEvents"; -import { useVoiceConversationStore } from "@/features/voice-conversation/stores/voiceConversationStore"; import { SHORTCUT_PREFERENCES_STORAGE_KEY } from "@/features/shortcuts/lib/shortcutRegistry"; import { useShortcutsDialogStore } from "@/features/shortcuts/stores/shortcutsDialogStore"; import { useProjectStore } from "@/features/projects/stores/projectStore"; diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 31fd1ad23..28f9463e9 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -1245,13 +1245,11 @@ export function AppShell({ null, ); const hydratingPinnedSessionIdsRef = useRef>(new Set()); - const hydratePinnedChatSessions = useCallback( async (sessionIds: string[]) => { const uniqueSessionIds = [...new Set(sessionIds)].filter(Boolean); const sessionStore = useChatSessionStore.getState(); const sessionsToLoad: string[] = []; - for (const sessionId of uniqueSessionIds) { if (hydratingPinnedSessionIdsRef.current.has(sessionId)) { continue; diff --git a/src/app/ui/AppShellContent.tsx b/src/app/ui/AppShellContent.tsx index 61cd1c078..c03e78cac 100644 --- a/src/app/ui/AppShellContent.tsx +++ b/src/app/ui/AppShellContent.tsx @@ -220,6 +220,7 @@ export function AppShellContent({ onStartProjectChat={onStartProjectChat} onCreatePersona={onCreatePersona} onCreateProject={onCreateProject} + onWorkspaceNameRequest={onWorkspaceNameRequest} onOpenAutomation={openHomeAutomation} onOpenSkills={() => onNavigateSkills(null)} onOpenAutomations={openHomeAutomations} diff --git a/src/features/chat/capabilities/ConversationComposerAdmissionParity.test.tsx b/src/features/chat/capabilities/ConversationComposerAdmissionParity.test.tsx new file mode 100644 index 000000000..a56ea2e92 --- /dev/null +++ b/src/features/chat/capabilities/ConversationComposerAdmissionParity.test.tsx @@ -0,0 +1,155 @@ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useConversationComposerBinding } from "./ConversationComposerCapability"; +import type { ChatSession } from "@/features/chat/stores/chatSessionStore"; + +const mocks = vi.hoisted(() => ({ + handleSend: vi.fn(() => true), + sendDeferredAnyway: vi.fn(() => true), + steerDraftMessage: vi.fn(() => true), + steerQueuedMessage: vi.fn(() => true), + securityPending: false, +})); + +vi.mock("@/features/chat/hooks/useChatSessionController", () => ({ + useChatSessionController: () => ({ + handleSend: mocks.handleSend, + sendDeferredAnyway: mocks.sendDeferredAnyway, + steerDraftMessage: mocks.steerDraftMessage, + steerQueuedMessage: mocks.steerQueuedMessage, + }), +})); + +vi.mock("@/features/chat/stores/chatSessionStore", () => ({ + useChatSessionStore: ( + selector: (state: { sessions: ChatSession[] }) => unknown, + ) => selector({ sessions: [] }), +})); + +vi.mock("@/features/chat/stores/sessionWindowStore", () => ({ + useSessionWindowStore: ( + selector: (state: { isOpenInWindow: () => boolean }) => unknown, + ) => selector({ isOpenInWindow: () => false }), +})); + +vi.mock("@/features/security/stores/securityConfirmationStore", () => ({ + useSecurityConfirmationStore: ( + selector: (state: { + pendingBySessionId: Record; + }) => unknown, + ) => + selector({ + pendingBySessionId: mocks.securityPending ? { "session-1": [{}] } : {}, + }), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => + key === "toolbar.agentBuilderPrepareFailed" + ? "Agent preparation failed" + : "Session creation failed", + }), +})); + +const ordinarySession = { + id: "session-1", + title: "Chat", + createdAt: "2026-08-23T00:00:00.000Z", + updatedAt: "2026-08-23T00:00:00.000Z", + messageCount: 1, +} satisfies ChatSession; + +function useSurfaceBinding( + surface: "chat" | "canvas", + session: ChatSession, + readOnlyReason?: string, +) { + return useConversationComposerBinding({ + target: { + kind: "existingSession", + sessionId: session.id, + sessionSnapshot: session, + readOnlyReason, + readOnlyWhenOpenInAnotherWindow: surface === "canvas", + }, + }); +} + +describe("existing-session composer cross-surface parity", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.securityPending = false; + }); + + it.each([ + ["ordinary", ordinarySession, undefined, false], + [ + "session creation failure", + { + ...ordinarySession, + creationState: "failed" as const, + creationError: "Creation failed", + }, + undefined, + true, + ], + [ + "execution target failure", + { + ...ordinarySession, + intent: "build-agent" as const, + targetAgentDraftState: "failed" as const, + }, + undefined, + true, + ], + ["read-only", ordinarySession, "Read only", true], + ])("gives ChatView and canvas the same %s admission and ordinary/queue rejection", (_label, session, readOnlyReason, blocked) => { + const chat = renderHook(() => + useSurfaceBinding("chat", session, readOnlyReason), + ).result; + const canvas = renderHook(() => + useSurfaceBinding("canvas", session, readOnlyReason), + ).result; + + expect(canvas.current.admissionBlocked).toBe(chat.current.admissionBlocked); + expect(canvas.current.admissionBlockingReason).toBe( + chat.current.admissionBlockingReason, + ); + expect(canvas.current.onSend("ordinary")).toBe(!blocked); + expect(chat.current.onSend("ordinary")).toBe(!blocked); + expect(canvas.current.onSendQueue).toBe( + blocked ? undefined : mocks.sendDeferredAnyway, + ); + expect(chat.current.onSendQueue).toBe( + blocked ? undefined : mocks.sendDeferredAnyway, + ); + }); + + it("blocks ordinary, queue/deferred, MCP, and voice entry points while security confirmation is pending", () => { + mocks.securityPending = true; + const chat = renderHook(() => useSurfaceBinding("chat", ordinarySession)) + .result.current; + const canvas = renderHook(() => + useSurfaceBinding("canvas", ordinarySession), + ).result.current; + + for (const binding of [chat, canvas]) { + expect(binding.target).toMatchObject({ + admission: { + blocked: true, + securityConfirmationPending: true, + }, + }); + expect(binding.onSend("ordinary")).toBe(false); + // MCP and voice consume this same admitted onSend handler in full chat; + // canvas has neither extra path, so it cannot bypass the rejection. + expect(binding.onSend("mcp")).toBe(false); + expect(binding.onSend("voice")).toBe(false); + expect(binding.onSendQueue).toBeUndefined(); + } + expect(mocks.handleSend).not.toHaveBeenCalled(); + expect(mocks.sendDeferredAnyway).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/chat/capabilities/ConversationComposerCapability.test.tsx b/src/features/chat/capabilities/ConversationComposerCapability.test.tsx new file mode 100644 index 000000000..78c1e080f --- /dev/null +++ b/src/features/chat/capabilities/ConversationComposerCapability.test.tsx @@ -0,0 +1,340 @@ +import { render } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChatInputProps } from "@/features/chat/types"; +import { + ConversationComposerCapability, + type ConversationComposerBinding, + type ConversationComposerTarget, +} from "./ConversationComposerCapability"; + +const chatInputSpy = vi.fn(); + +vi.mock("@/features/chat/ui/ChatInput", () => ({ + ChatInput: (props: ChatInputProps) => { + chatInputSpy(props); + return
; + }, +})); + +function createController() { + const deferredRecord = { + kind: "deferred" as const, + recordId: "deferred-1", + payload: { text: "waiting for workspace" }, + state: { status: "naming" as const, desired: [] }, + }; + return { + handleSend: vi.fn(), + steerDraftMessage: vi.fn(), + canSteerMessage: true, + steerQueuedMessage: vi.fn(), + canSteerQueuedMessage: true, + projectMetadataPending: false, + isCompactingContext: false, + workspaceSetupInProgress: false, + unresolvedDeferredSend: false, + deferredWorkspaceError: null, + deferredWorkspaceRecord: deferredRecord, + defaultWorkspaceSetup: null, + queue: { + queuedMessage: deferredRecord.payload, + queuedRecord: deferredRecord, + queuedRecords: [ + deferredRecord, + { + kind: "transport-ready" as const, + recordId: "ready-2", + payload: { text: "second" }, + }, + ], + update: vi.fn(), + beginEditing: vi.fn(), + cancelEditing: vi.fn(), + dismiss: vi.fn(), + }, + sendDeferredAnyway: vi.fn(), + stopStreaming: vi.fn(), + chatState: "streaming" as const, + skillsEnabled: true, + skillProjectDirs: ["/project"], + fileMentionProjectDirs: ["/project"], + selectedProvider: "goose", + draftValue: "draft", + draftAttachments: [{ id: "attachment" }], + handleDraftChange: vi.fn(), + handleDraftAttachmentsChange: vi.fn(), + selectedSkills: [{ id: "skill" }], + handleSkillsChange: vi.fn(), + personas: [{ id: "persona" }], + selectedPersonaId: "persona", + handlePersonaChange: vi.fn(), + pickerAgents: [{ id: "goose", label: "Goose" }], + providersLoading: false, + handleProviderChange: vi.fn(), + currentModelId: "model", + currentModelProviderId: "provider", + currentModelName: "Model", + currentExecutionTarget: { provider: "goose" }, + availableModels: [{ id: "model", name: "Model" }], + modelsLoading: false, + modelStatusMessage: null, + handleModelChange: vi.fn(), + handlePickerOpen: vi.fn(), + reasoningEffort: { currentValue: "high" }, + handleReasoningEffortChange: vi.fn(), + selectedProjectId: "project", + availableProjects: [{ id: "project", name: "Project" }], + handleProjectChange: vi.fn(), + tokenState: { + accumulatedTotal: 12, + contextLimit: 100, + accumulatedCost: 0.5, + }, + isContextUsageReady: true, + compactConversation: vi.fn(), + canCompactContext: true, + supportsCompactionControls: true, + createDeferredWorkspace: vi.fn(), + cancelDeferredWorkspaceName: vi.fn(), + submitDeferredWorkspaceName: vi.fn(), + skipDeferredWorkspace: vi.fn(), + }; +} + +function latestProps() { + return chatInputSpy.mock.calls.at(-1)?.[0] as ChatInputProps; +} + +function createBinding( + controller: ReturnType, + target: + | Extract + | { + kind: "existingSession"; + sessionId: string; + admission?: Partial< + Extract< + ConversationComposerTarget, + { kind: "existingSession" } + >["admission"] + >; + }, +) { + const completeTarget: ConversationComposerTarget = + target.kind === "existingSession" + ? { + ...target, + admission: { + blocked: false, + securityConfirmationPending: false, + ...target.admission, + }, + } + : target; + const admissionBlockingReason = + completeTarget.kind === "existingSession" + ? completeTarget.admission.blockingReason + : undefined; + const admissionBlocked = + completeTarget.kind === "existingSession" && + completeTarget.admission.blocked; + return { + controller, + target: completeTarget, + admissionBlockingReason, + admissionBlocked, + onSend: admissionBlocked ? vi.fn(() => false) : controller.handleSend, + onSendQueue: admissionBlocked ? undefined : controller.sendDeferredAnyway, + } as never; +} + +describe("ConversationComposerCapability surface parity", () => { + beforeEach(() => chatInputSpy.mockClear()); + + it("preserves Home deferred-queue policy while sharing draft and selection behavior", () => { + const controller = createController(); + render( + , + ); + + const props = latestProps(); + expect(props.surface).toBe("pill"); + expect(props.composerActions.queuedMessages).toEqual([ + { recordId: "ready-2", payload: { text: "second" } }, + ]); + expect(props.composerActions.onUpdateQueue).toBeUndefined(); + expect(props.composerActions.onEditQueue).toBeUndefined(); + expect(props.composerActions.onCancelQueueEdit).toBeUndefined(); + expect(props.composerActions.onDismissQueue).toBeUndefined(); + expect(props.initialValue).toBe("draft"); + expect(props.initialAttachments).toBe(controller.draftAttachments); + expect(props.selectedSkills).toBe(controller.selectedSkills); + expect(props.personaPicker?.selectedPersonaId).toBe("persona"); + expect(props.agentModelPicker?.currentModelId).toBe("model"); + expect(props.projectPicker?.selectedProjectId).toBe("project"); + expect(props.reasoningEffort?.config).toBe(controller.reasoningEffort); + expect(props.contextUsage).not.toHaveProperty("onCompactContext"); + }); + + it("preserves chat-footer steering, handoff, disabled reasons, voice, and context policy", () => { + const controller = createController(); + const voiceConversation = { visible: true, onToggle: vi.fn() }; + render( + , + ); + + const props = latestProps(); + expect(props.surface).toBe("bare"); + expect(props.innerBareSurface).toBe(true); + expect(props.className).toBe("hidden"); + expect(props.controls?.autoFocus).toBe(false); + expect(props.composerActions.onSteerMessage).toBeUndefined(); + expect(props.composerActions.canSteerMessage).toBe(false); + expect(props.composerActions.onSteerQueuedMessage).toBeUndefined(); + expect(props.composerActions.queuedMessage).toBeNull(); + expect(props.composerActions.queuedMessages).toEqual([]); + expect(props.composerActions.onDismissQueue).toBeUndefined(); + expect(props.composerActions.sendDisabled).toBe(true); + expect(props.composerActions.sendDisabledReason).toBe( + "Agent preparation failed", + ); + expect(props.composerActions.disabled).toBe(true); + expect(props.composerActions.onSendQueue).toBeUndefined(); + expect(props.composerActions.onSend("blocked")).toBe(false); + expect(controller.handleSend).not.toHaveBeenCalled(); + expect(props.composerActions.onStop).toBe(controller.stopStreaming); + expect(props.composerActions.voiceConversation).toBe(voiceConversation); + expect(props.contextUsage?.onCompactContext).toBe( + controller.compactConversation, + ); + expect(props.agentModelPicker?.providerColumnMode).toBe("gated"); + }); +}); + +describe("ConversationComposerCapability authority boundary", () => { + it("does not allow presentation policy to assert durable admission authority", () => { + const controller = createController(); + render( + , + ); + + const props = latestProps(); + expect(props.surface).toBe("pill"); + expect(props.composerActions.sendDisabled).toBe(true); + expect(props.composerActions.sendDisabledReason).toBe("Read only"); + expect(props.composerActions.onStop).toBeUndefined(); + expect(props.controls).toEqual({ + agentModelPicker: false, + attachments: false, + autoFocus: false, + fileMentions: false, + projectPicker: false, + skills: false, + voice: false, + }); + }); + + it("does not let rendering policy independently enable a failed target", () => { + const controller = createController(); + const binding = createBinding(controller, { + kind: "existingSession", + sessionId: "session-1", + admission: { + blocked: true, + blockingReason: "Session creation failed", + }, + }); + const { rerender } = render( + , + ); + + expect(latestProps().composerActions.sendDisabled).toBe(true); + expect(latestProps().composerActions.sendDisabledReason).toBe( + "Session creation failed", + ); + expect(latestProps().composerActions.onSendQueue).toBeUndefined(); + + rerender( + , + ); + + expect(latestProps().composerActions.sendDisabled).toBe(true); + expect(latestProps().composerActions.sendDisabledReason).toBe( + "Session creation failed", + ); + }); + + it("makes contradictory target authority and independent bindings unrepresentable", () => { + const contradictoryTarget: ConversationComposerTarget = { + kind: "pendingConversation", + sessionId: null, + // @ts-expect-error Pending conversations cannot independently claim read-only authority. + readOnlyReason: "Read only", + }; + const independentlyAssertedBinding: ConversationComposerBinding = { + controller: createController() as never, + target: { + kind: "existingSession", + sessionId: "session-1", + admission: { blocked: false, securityConfirmationPending: false }, + }, + // @ts-expect-error The hook's private brand cannot be independently asserted. + fakeBrand: true, + }; + expect(contradictoryTarget.kind).toBe("pendingConversation"); + expect(independentlyAssertedBinding.target.kind).toBe("existingSession"); + }); +}); diff --git a/src/features/chat/capabilities/ConversationComposerCapability.tsx b/src/features/chat/capabilities/ConversationComposerCapability.tsx new file mode 100644 index 000000000..67984a7ef --- /dev/null +++ b/src/features/chat/capabilities/ConversationComposerCapability.tsx @@ -0,0 +1,387 @@ +import { useCallback, type RefObject } from "react"; +import { summarizeProjectWorkspaceStartup } from "@/features/projects/lib/projectChatWorkspaces"; +import type { + ChatInputControls, + ChatInputVoiceConversation, +} from "@/features/chat/types"; +import { + useChatSessionController, + type WorkspaceNameRequest, +} from "@/features/chat/hooks/useChatSessionController"; +import { ChatInput } from "@/features/chat/ui/ChatInput"; +import { WorkspaceSetupChoice } from "@/features/chat/ui/WorkspaceSetupChoice"; +import { + useSessionAddressedComposerAdmission, + type SessionAddressedComposerAdmission, +} from "@/features/chat/hooks/useSessionAddressedComposerAdmission"; + +export type ConversationComposerTarget = + | { + kind: "pendingConversation"; + sessionId: string | null; + } + | { + kind: "existingSession"; + sessionId: string; + admission: SessionAddressedComposerAdmission; + }; + +export interface ConversationComposerRenderingPolicy { + presentation: { + surface: "pill" | "bare"; + innerBareSurface?: boolean; + providerColumnMode: "visible" | "gated"; + }; + allowedInteractions?: { + controls?: ChatInputControls; + allowRecallLastMessage?: boolean; + }; + lifecycleConstraints?: { + handoff?: { active: boolean; inProgress: boolean }; + voiceConversation?: ChatInputVoiceConversation; + }; +} + +const conversationComposerBindingBrand: unique symbol = Symbol( + "ConversationComposerBinding", +); + +interface UseConversationComposerBindingOptions { + target: + | Extract + | { + kind: "existingSession"; + sessionId: string; + sessionSnapshot?: Parameters< + typeof useSessionAddressedComposerAdmission + >[0]["sessionSnapshot"]; + readOnlyReason?: string; + readOnlyWhenOpenInAnotherWindow?: boolean; + }; + onMessageAccepted?: (sessionId: string) => void; + onCreatePersonaRequested?: () => void; + onWorkspaceNameRequest?: (request: WorkspaceNameRequest) => void; +} + +export function useConversationComposerBinding({ + target: requestedTarget, + onMessageAccepted, + onCreatePersonaRequested, + onWorkspaceNameRequest, +}: UseConversationComposerBindingOptions) { + const admission = useSessionAddressedComposerAdmission({ + sessionId: + requestedTarget.kind === "existingSession" + ? requestedTarget.sessionId + : null, + sessionSnapshot: + requestedTarget.kind === "existingSession" + ? requestedTarget.sessionSnapshot + : undefined, + readOnlyReason: + requestedTarget.kind === "existingSession" + ? requestedTarget.readOnlyReason + : undefined, + readOnlyWhenOpenInAnotherWindow: + requestedTarget.kind === "existingSession" + ? requestedTarget.readOnlyWhenOpenInAnotherWindow + : false, + }); + const target: ConversationComposerTarget = + requestedTarget.kind === "existingSession" + ? { + kind: "existingSession", + sessionId: requestedTarget.sessionId, + admission, + } + : requestedTarget; + const controller = useChatSessionController({ + sessionId: target.sessionId, + isHomeSession: target.kind === "pendingConversation", + readOnly: + target.kind === "existingSession" && + Boolean(target.admission.readOnlyReason), + onMessageAccepted, + onCreatePersonaRequested, + onWorkspaceNameRequest, + }); + + const admissionBlockingReason = + target.kind === "existingSession" + ? target.admission.blockingReason + : undefined; + const admissionBlocked = + target.kind === "existingSession" && target.admission.blocked; + const rejectSend = useCallback( + (..._args: Parameters) => false, + [], + ); + const onSend = admissionBlocked ? rejectSend : controller.handleSend; + + return { + controller, + target, + admissionBlockingReason, + admissionBlocked, + onSend, + onSendQueue: admissionBlocked ? undefined : controller.sendDeferredAnyway, + [conversationComposerBindingBrand]: true as const, + }; +} + +export type ConversationComposerBinding = ReturnType< + typeof useConversationComposerBinding +>; + +interface ConversationComposerCapabilityProps { + binding: ConversationComposerBinding; + renderingPolicy: ConversationComposerRenderingPolicy; + onCreateProject?: (options?: { + onCreated?: (projectId: string) => void; + }) => void; + onRecallLastUserMessage?: () => string | null; + attachmentDropTargetRef?: RefObject; + onAttachmentDragOverChange?: (isDragOver: boolean) => void; +} + +export function ConversationComposerCapability({ + binding, + renderingPolicy, + onCreateProject, + onRecallLastUserMessage, + attachmentDropTargetRef, + onAttachmentDragOverChange, +}: ConversationComposerCapabilityProps) { + const { + controller, + target, + admissionBlockingReason, + admissionBlocked, + onSend, + onSendQueue, + } = binding; + const isPendingConversation = target.kind === "pendingConversation"; + const readOnlyReason = + target.kind === "existingSession" + ? target.admission.readOnlyReason + : undefined; + const isReadOnly = Boolean(readOnlyReason); + const lifecycle = renderingPolicy.lifecycleConstraints; + const securityConfirmationPending = + target.kind === "existingSession" && + target.admission.securityConfirmationPending; + const handoffActive = lifecycle?.handoff?.active === true; + const handoffInProgress = lifecycle?.handoff?.inProgress === true; + const deferredWorkspaceInFlight = + controller.deferredWorkspaceRecord?.state.status === "naming" || + controller.deferredWorkspaceRecord?.state.status === "creating"; + const queueRecords = isPendingConversation + ? (controller.queue.queuedRecords ?? []) + : (controller.queue.queuedRecords ?? + (controller.queue.queuedRecord ? [controller.queue.queuedRecord] : [])); + const visibleQueueRecords = isPendingConversation + ? queueRecords.filter( + (record) => !(record.kind === "deferred" && deferredWorkspaceInFlight), + ) + : queueRecords; + const workspaceSetup = + controller.defaultWorkspaceSetup ?? + controller.deferredWorkspaceRecord?.state; + const deferredWorkspaceStartup = summarizeProjectWorkspaceStartup( + workspaceSetup?.desired ?? [], + ); + const policyControls = renderingPolicy.allowedInteractions?.controls; + const controls: ChatInputControls | undefined = isReadOnly + ? { + agentModelPicker: false, + attachments: false, + autoFocus: false, + fileMentions: false, + projectPicker: false, + skills: false, + voice: false, + } + : !controller.skillsEnabled || handoffActive || policyControls + ? { + ...policyControls, + ...(!controller.skillsEnabled ? { skills: false } : {}), + ...(handoffActive ? { autoFocus: false } : {}), + } + : undefined; + return ( + + {controller.deferredWorkspaceError} +

+ ) : !isPendingConversation && + !isReadOnly && + deferredWorkspaceStartup.worktreeCount > 0 && + (workspaceSetup?.status === "choice" || + workspaceSetup?.status === "naming" || + workspaceSetup?.status === "creating") ? ( + + ) : null + } + skillProjectDirs={ + isPendingConversation ? undefined : controller.skillProjectDirs + } + fileMentionProjectDirs={ + isPendingConversation ? undefined : controller.fileMentionProjectDirs + } + skillProviderId={ + isPendingConversation ? undefined : controller.selectedProvider + } + composerActions={{ + onSend, + onSteerMessage: + isPendingConversation || admissionBlocked + ? undefined + : (text, personaId, attachments, options) => + controller.steerDraftMessage( + text, + personaId ?? undefined, + attachments, + options, + ), + canSteerMessage: + isPendingConversation || admissionBlocked + ? false + : controller.canSteerMessage, + onSteerQueuedMessage: admissionBlocked + ? undefined + : controller.steerQueuedMessage, + canSteerQueuedMessage: + !admissionBlocked && controller.canSteerQueuedMessage, + disabled: isPendingConversation + ? controller.projectMetadataPending + : admissionBlocked || + controller.projectMetadataPending || + controller.isCompactingContext, + sendDisabled: isPendingConversation + ? undefined + : admissionBlocked || controller.workspaceSetupInProgress, + sendDisabledReason: admissionBlockingReason, + queuedMessage: handoffInProgress + ? null + : (controller.queue.queuedMessage ?? + controller.deferredWorkspaceRecord?.payload ?? + null), + queuedMessages: handoffInProgress + ? [] + : visibleQueueRecords.map((record) => ({ + recordId: record.recordId, + payload: record.payload, + })), + onUpdateQueue: + isPendingConversation && deferredWorkspaceInFlight + ? undefined + : controller.queue.update, + onEditQueue: + isPendingConversation && deferredWorkspaceInFlight + ? undefined + : controller.queue.beginEditing, + onCancelQueueEdit: + isPendingConversation && deferredWorkspaceInFlight + ? undefined + : controller.queue.cancelEditing, + onSendQueue: + !controller.unresolvedDeferredSend && + (controller.deferredWorkspaceRecord?.state.status === "failed" || + controller.deferredWorkspaceRecord?.state.status === "held") + ? onSendQueue + : undefined, + onDismissQueue: + handoffInProgress || isReadOnly || deferredWorkspaceInFlight + ? undefined + : controller.queue.dismiss, + onStop: isReadOnly ? undefined : controller.stopStreaming, + isStreaming: + !isReadOnly && + (controller.chatState === "streaming" || + controller.chatState === "thinking"), + voiceConversation: lifecycle?.voiceConversation, + }} + onRecallLastUserMessage={ + isReadOnly || + renderingPolicy.allowedInteractions?.allowRecallLastMessage === false + ? undefined + : onRecallLastUserMessage + } + attachmentDropTargetRef={attachmentDropTargetRef} + onAttachmentDragOverChange={onAttachmentDragOverChange} + initialValue={controller.draftValue} + initialAttachments={controller.draftAttachments} + onDraftChange={controller.handleDraftChange} + onDraftAttachmentsChange={controller.handleDraftAttachmentsChange} + selectedSkills={controller.selectedSkills} + onSkillsChange={controller.handleSkillsChange} + personaPicker={{ + personas: controller.personas, + selectedPersonaId: controller.selectedPersonaId, + onPersonaChange: controller.handlePersonaChange, + }} + agentModelPicker={{ + providers: controller.pickerAgents, + providersLoading: controller.providersLoading, + selectedProvider: controller.selectedProvider, + onProviderChange: controller.handleProviderChange, + currentModelId: controller.currentModelId, + currentModelProviderId: controller.currentModelProviderId, + currentModel: controller.currentModelName ?? undefined, + currentExecutionTarget: controller.currentExecutionTarget, + availableModels: controller.availableModels, + modelsLoading: controller.modelsLoading, + modelStatusMessage: controller.modelStatusMessage, + onModelChange: controller.handleModelChange, + onPickerOpen: controller.handlePickerOpen, + providerColumnMode: renderingPolicy.presentation.providerColumnMode, + }} + reasoningEffort={{ + config: controller.reasoningEffort, + onChange: controller.handleReasoningEffortChange, + }} + projectPicker={{ + selectedProjectId: controller.selectedProjectId, + availableProjects: controller.availableProjects, + onProjectChange: controller.handleProjectChange, + onCreateProject: (options) => + onCreateProject?.({ + onCreated: (projectId) => { + controller.handleProjectChange(projectId); + options?.onCreated?.(projectId); + }, + }), + }} + contextUsage={{ + contextTokens: controller.tokenState.accumulatedTotal, + contextLimit: controller.tokenState.contextLimit, + accumulatedCost: controller.tokenState.accumulatedCost, + isContextUsageReady: controller.isContextUsageReady, + ...(!isPendingConversation + ? { + onCompactContext: controller.compactConversation, + canCompactContext: controller.canCompactContext, + isCompactingContext: controller.isCompactingContext, + supportsCompactionControls: controller.supportsCompactionControls, + } + : {}), + }} + /> + ); +} diff --git a/src/features/chat/hooks/__tests__/useChat.test.ts b/src/features/chat/hooks/__tests__/useChat.test.ts index 3a7ebb280..9a16ec6f0 100644 --- a/src/features/chat/hooks/__tests__/useChat.test.ts +++ b/src/features/chat/hooks/__tests__/useChat.test.ts @@ -157,6 +157,27 @@ describe("useChat", () => { mockAcpPrepareSession.mockResolvedValue(undefined); }); + it("dispatches to its addressed session without changing route-active selection", async () => { + seedChatSession(); + useChatStore.setState({ activeSessionId: "route-session" }); + useChatSessionStore.setState({ activeSessionId: "route-session" }); + const { result } = renderHook(() => useChat("session-1")); + + await act(async () => { + await result.current.sendMessage("Canvas prompt"); + }); + + expect(mockAcpSendMessage).toHaveBeenCalledWith( + "session-1", + "Canvas prompt", + expect.any(Object), + ); + expect(useChatStore.getState().activeSessionId).toBe("route-session"); + expect(useChatSessionStore.getState().activeSessionId).toBe( + "route-session", + ); + }); + it("marks the streaming message stopped only after cancellation succeeds", async () => { const cancelDeferred = createDeferredPromise(); mockAcpCancelSession.mockReturnValue(cancelDeferred.promise); diff --git a/src/features/chat/hooks/useChat.ts b/src/features/chat/hooks/useChat.ts index 33c6f0f86..7c4a52b32 100644 --- a/src/features/chat/hooks/useChat.ts +++ b/src/features/chat/hooks/useChat.ts @@ -121,7 +121,6 @@ export function useChat( const runtime = useChatStore( (s) => s.sessionStateById[sessionId] ?? INITIAL_SESSION_CHAT_RUNTIME, ); - const setActiveSession = useChatStore((s) => s.setActiveSession); const addMessage = useChatStore((s) => s.addMessage); const clearMessages = useChatStore((s) => s.clearMessages); const setChatState = useChatStore((s) => s.setChatState); @@ -196,9 +195,6 @@ export function useChat( systemPromptOverride ?? agent?.systemPrompt; - // Ensure active session - setActiveSession(sessionId); - const abort = new AbortController(); abortRef.current = abort; @@ -259,7 +255,6 @@ export function useChat( }, [ sessionId, - setActiveSession, clearDraft, providerOverride, systemPromptOverride, @@ -414,7 +409,6 @@ export function useChat( flushBufferedStreamingUpdatesForSession(sessionId, { flushSubtitle: true, }); - setActiveSession(sessionId); setChatState(sessionId, "compacting"); setStreamingMessageId(sessionId, null); setError(sessionId, null); @@ -507,7 +501,6 @@ export function useChat( options, resolvePersonaInfo, sessionId, - setActiveSession, setChatState, setStreamingMessageId, setError, diff --git a/src/features/chat/hooks/useChatTranscriptReadModel.ts b/src/features/chat/hooks/useChatTranscriptReadModel.ts new file mode 100644 index 000000000..fcada3695 --- /dev/null +++ b/src/features/chat/hooks/useChatTranscriptReadModel.ts @@ -0,0 +1,60 @@ +import { useMemo } from "react"; +import { useAgentStore } from "@/features/agents/stores/agentStore"; +import { useProjectStore } from "@/features/projects/stores/projectStore"; +import { selectProjects } from "@/features/projects/stores/projectSelectors"; +import { resolveProjectDefaultArtifactRoot } from "@/features/projects/lib/chatProjectContext"; +import { useWorkspaceRepository } from "@/features/workspaces/workspaceRepository"; +import { useChatStore } from "@/features/chat/stores/chatStore"; +import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; +import { INITIAL_SESSION_CHAT_RUNTIME } from "@/shared/types/chat"; +import type { Message } from "@/shared/types/messages"; + +const EMPTY_MESSAGES: Message[] = []; + +/** + * Read-only, session-addressed data for transcript presentations. + * This deliberately excludes preparation, queue, draft, and dispatch behavior. + */ +export function useChatTranscriptReadModel(sessionId: string) { + const session = useChatSessionStore((state) => state.getSession(sessionId)); + const activeWorkspace = useChatSessionStore( + (state) => state.activeWorkspaceBySession[sessionId], + ); + const messages = useChatStore( + (state) => state.messagesBySession[sessionId] ?? EMPTY_MESSAGES, + ); + const runtime = useChatStore( + (state) => + state.sessionStateById[sessionId] ?? INITIAL_SESSION_CHAT_RUNTIME, + ); + const isLoadingHistory = useChatStore((state) => + state.loadingSessionIds.has(sessionId), + ); + const selectedPersona = useAgentStore((state) => + session?.personaId ? state.getPersonaById(session.personaId) : undefined, + ); + const projects = useProjectStore(selectProjects); + const project = session?.projectId + ? projects.find((candidate) => candidate.id === session.projectId) + : undefined; + const workspaceRepository = useWorkspaceRepository(); + const sessionArtifactCwd = useMemo(() => { + const workspacePath = workspaceRepository.chatWorkspaces(session, { + activePath: activeWorkspace?.path, + }).primary?.path; + return ( + workspacePath?.trim() || + resolveProjectDefaultArtifactRoot(project)?.trim() || + null + ); + }, [activeWorkspace?.path, project, session, workspaceRepository]); + + return { + session, + messages, + runtime, + isLoadingHistory, + selectedPersona, + sessionArtifactCwd, + }; +} diff --git a/src/features/chat/hooks/useSessionAddressedComposerAdmission.test.tsx b/src/features/chat/hooks/useSessionAddressedComposerAdmission.test.tsx new file mode 100644 index 000000000..68447c20d --- /dev/null +++ b/src/features/chat/hooks/useSessionAddressedComposerAdmission.test.tsx @@ -0,0 +1,124 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + deriveSessionAddressedComposerAdmission, + useSessionAddressedComposerAdmission, +} from "./useSessionAddressedComposerAdmission"; +import { + useChatSessionStore, + type ChatSession, +} from "../stores/chatSessionStore"; +import { useSessionWindowStore } from "../stores/sessionWindowStore"; +import { useSecurityConfirmationStore } from "@/features/security/stores/securityConfirmationStore"; + +const session = { + id: "session-1", + title: "Chat", + createdAt: "2026-08-23T00:00:00.000Z", + updatedAt: "2026-08-23T00:00:00.000Z", + messageCount: 1, +} satisfies ChatSession; + +function derive( + overrides: Partial< + Parameters[0] + > = {}, +) { + return deriveSessionAddressedComposerAdmission({ + session, + securityConfirmationPending: false, + sessionCreationFailureFallback: "Session creation failed", + executionTargetFailureReason: "Agent preparation failed", + ...overrides, + }); +} + +describe("session-addressed composer admission", () => { + beforeEach(() => { + useSecurityConfirmationStore.setState({ + pendingBySessionId: {}, + mountedSurfaceCountBySessionId: {}, + }); + useSessionWindowStore.setState({ + openSessions: {}, + handoffs: {}, + hasLoadedSnapshot: false, + }); + useChatSessionStore.setState({ sessions: [session] }); + }); + + it.each([ + ["ordinary session", {}, false, undefined], + [ + "session creation failure", + { session: { ...session, creationState: "failed" as const } }, + true, + "Session creation failed", + ], + [ + "execution target failure", + { + session: { + ...session, + intent: "build-agent" as const, + targetAgentDraftState: "failed" as const, + }, + }, + true, + "Agent preparation failed", + ], + ["read-only lifecycle", { readOnlyReason: "Read only" }, true, "Read only"], + [ + "pending security confirmation", + { securityConfirmationPending: true }, + true, + undefined, + ], + ])("derives %s consistently", (_label, overrides, blocked, blockingReason) => { + expect(derive(overrides)).toMatchObject({ blocked, blockingReason }); + }); + + it("reacts to the shared security queue and separate-window ownership", () => { + const { result } = renderHook(() => + useSessionAddressedComposerAdmission({ + sessionId: session.id, + sessionSnapshot: session, + readOnlyWhenOpenInAnotherWindow: true, + }), + ); + expect(result.current.blocked).toBe(false); + + act(() => { + useSecurityConfirmationStore.setState({ + pendingBySessionId: { + [session.id]: [ + { + request: { sessionId: session.id } as never, + title: "Security", + command: null, + alertText: "Alert", + resolve: () => undefined, + inferredExplanation: { status: "idle" }, + }, + ], + }, + }); + }); + expect(result.current).toMatchObject({ + blocked: true, + securityConfirmationPending: true, + }); + + act(() => { + useSecurityConfirmationStore.setState({ pendingBySessionId: {} }); + useSessionWindowStore.setState({ + openSessions: { [session.id]: "session-window" }, + }); + }); + expect(result.current).toMatchObject({ + blocked: true, + readOnlyReason: "Finishing current response...", + blockingReason: "Finishing current response...", + }); + }); +}); diff --git a/src/features/chat/hooks/useSessionAddressedComposerAdmission.ts b/src/features/chat/hooks/useSessionAddressedComposerAdmission.ts new file mode 100644 index 000000000..85214b4f1 --- /dev/null +++ b/src/features/chat/hooks/useSessionAddressedComposerAdmission.ts @@ -0,0 +1,102 @@ +import { useTranslation } from "react-i18next"; +import { isAgentBuilderVisible } from "@/features/chat/lib/chatCapabilityVisibility"; +import { + useChatSessionStore, + type ChatSession, +} from "@/features/chat/stores/chatSessionStore"; +import { useSessionWindowStore } from "@/features/chat/stores/sessionWindowStore"; +import { useSecurityConfirmationStore } from "@/features/security/stores/securityConfirmationStore"; + +export interface SessionAddressedComposerAdmissionOptions { + sessionId: string | null; + /** + * A surface may already own the current session snapshot before the shared + * store catches up (notably ChatView during draft-session promotion). + */ + sessionSnapshot?: ChatSession | null; + readOnlyReason?: string; + /** Canvas and other secondary in-window surfaces cannot write to a session + * whose lifecycle is owned by a separate session window. */ + readOnlyWhenOpenInAnotherWindow?: boolean; +} + +export interface SessionAddressedComposerAdmission { + readOnlyReason?: string; + blockingReason?: string; + securityConfirmationPending: boolean; + blocked: boolean; +} + +interface DeriveSessionAddressedComposerAdmissionOptions { + session: ChatSession | null; + readOnlyReason?: string; + securityConfirmationPending: boolean; + sessionCreationFailureFallback: string; + executionTargetFailureReason: string; +} + +/** Pure model shared by every composer addressed to an existing session. */ +export function deriveSessionAddressedComposerAdmission({ + session, + readOnlyReason, + securityConfirmationPending, + sessionCreationFailureFallback, + executionTargetFailureReason, +}: DeriveSessionAddressedComposerAdmissionOptions): SessionAddressedComposerAdmission { + const agentBuilderOpen = isAgentBuilderVisible(session, { + readOnly: Boolean(readOnlyReason), + }); + const blockingReason = + readOnlyReason ?? + (session?.creationState === "failed" + ? (session.creationError ?? sessionCreationFailureFallback) + : agentBuilderOpen && session?.targetAgentDraftState === "failed" + ? executionTargetFailureReason + : undefined); + + return { + readOnlyReason, + blockingReason, + securityConfirmationPending, + blocked: Boolean(blockingReason) || securityConfirmationPending, + }; +} + +/** + * Owns existing-session composer admission. Presentation surfaces consume this + * model; they do not independently infer session lifecycle or security state. + */ +export function useSessionAddressedComposerAdmission({ + sessionId, + sessionSnapshot, + readOnlyReason: assertedReadOnlyReason, + readOnlyWhenOpenInAnotherWindow = false, +}: SessionAddressedComposerAdmissionOptions): SessionAddressedComposerAdmission { + const { t } = useTranslation("chat"); + const storedSession = useChatSessionStore((state) => + sessionId && !sessionSnapshot + ? ((state.sessions ?? []).find( + (candidate) => candidate.id === sessionId, + ) ?? null) + : null, + ); + const openInSessionWindow = useSessionWindowStore((state) => + readOnlyWhenOpenInAnotherWindow && sessionId + ? state.isOpenInWindow(sessionId) + : false, + ); + const securityConfirmationPending = useSecurityConfirmationStore((state) => + sessionId ? (state.pendingBySessionId[sessionId]?.length ?? 0) > 0 : false, + ); + const readOnlyReason = + assertedReadOnlyReason ?? + (openInSessionWindow ? t("sessionWindow.readOnlyStatus") : undefined); + + return deriveSessionAddressedComposerAdmission({ + session: sessionSnapshot ?? storedSession, + readOnlyReason, + securityConfirmationPending, + sessionCreationFailureFallback: t("toolbar.sessionStartFailed"), + executionTargetFailureReason: t("toolbar.agentBuilderPrepareFailed"), + }); +} diff --git a/src/features/chat/lib/boundedConversationProjection.test.ts b/src/features/chat/lib/boundedConversationProjection.test.ts new file mode 100644 index 000000000..f64bbfbb1 --- /dev/null +++ b/src/features/chat/lib/boundedConversationProjection.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from "vitest"; +import type { Message, MessageContent } from "@/shared/types/messages"; +import { + HOME_CANVAS_RECENT_EXCHANGE_LIMIT, + projectRecentConversationExchanges, +} from "./boundedConversationProjection"; + +function message( + id: string, + role: Message["role"], + content: MessageContent[] = [{ type: "text", text: id }], + metadata: Message["metadata"] = { userVisible: true }, +): Message { + return { + id, + role, + created: Number(id.replace(/\D/g, "")) || 1, + content, + metadata, + }; +} + +function exchange( + index: number, + assistantContent?: MessageContent[], +): Message[] { + return [ + message(`user-${index}`, "user"), + message( + `assistant-${index}`, + "assistant", + assistantContent ?? [{ type: "text", text: `answer-${index}` }], + ), + ]; +} + +describe("projectRecentConversationExchanges", () => { + it("selects exactly the 10 most recent complete user-led exchanges", () => { + const source = Array.from({ length: 12 }, (_, index) => + exchange(index + 1), + ).flat(); + + const result = projectRecentConversationExchanges(source); + + expect(HOME_CANVAS_RECENT_EXCHANGE_LIMIT).toBe(10); + expect(result.messages.map(({ id }) => id)).toEqual( + Array.from({ length: 10 }, (_, index) => [ + `user-${index + 3}`, + `assistant-${index + 3}`, + ]).flat(), + ); + expect(result).toMatchObject({ + omittedExchangeCount: 2, + hasOmittedExchanges: true, + earliestVisibleMessageId: "user-3", + }); + }); + + it("keeps every event in a tool-heavy exchange without splitting it", () => { + const toolHeavy: Message[] = [ + message("user-1", "user"), + message("assistant-1a", "assistant", [ + { type: "thinking", text: "Planning" }, + { + type: "toolRequest", + id: "tool-1", + name: "delegate", + arguments: { task: "Inspect" }, + status: "completed", + subagentAgentName: "Rivet", + subagentTaskLabel: "Inspect", + }, + ]), + message("assistant-1b", "assistant", [ + { + type: "toolResponse", + id: "tool-1", + name: "delegate", + result: "Artifact produced", + isError: false, + }, + { type: "image", data: "image", mimeType: "image/png" }, + { type: "text", text: "Finished" }, + ]), + message("system-1", "system", [ + { + type: "systemNotification", + notificationType: "info", + text: "Compacted", + }, + ]), + ]; + const source = [ + ...exchange(0), + ...toolHeavy, + ...Array.from({ length: 9 }, (_, index) => exchange(index + 2)).flat(), + ]; + + const result = projectRecentConversationExchanges(source); + + expect(result.messages.map(({ id }) => id)).toEqual([ + "user-1", + "assistant-1a", + "assistant-1b", + "system-1", + ...Array.from({ length: 9 }, (_, index) => [ + `user-${index + 2}`, + `assistant-${index + 2}`, + ]).flat(), + ]); + }); + + it("preserves the complete current streaming exchange", () => { + const streamingExchange = [ + message("user-11", "user"), + message("assistant-11a", "assistant", [ + { type: "text", text: "Starting" }, + { + type: "toolRequest", + id: "live-tool", + name: "work", + arguments: {}, + status: "in_progress", + }, + ]), + message("assistant-11b", "assistant", [], { + userVisible: true, + completionStatus: "inProgress", + }), + ]; + const source = [ + ...Array.from({ length: 10 }, (_, index) => exchange(index + 1)).flat(), + ...streamingExchange, + ]; + + expect(projectRecentConversationExchanges(source).messages).toEqual([ + ...Array.from({ length: 9 }, (_, index) => exchange(index + 2)).flat(), + ...streamingExchange, + ]); + }); + + it("keeps an assistant/system prelude when all exchanges fit", () => { + const source = [ + message("system-prelude", "system"), + message("assistant-prelude", "assistant"), + ...exchange(1), + ...exchange(2), + ]; + + const result = projectRecentConversationExchanges(source); + + expect(result.messages).toEqual(source); + expect(result.earliestVisibleMessageId).toBe("system-prelude"); + expect(result.hasOmittedExchanges).toBe(false); + }); + + it("does not let invisible or assistant-only user events start exchanges", () => { + const invisible = message( + "hidden-user", + "user", + [{ type: "text", text: "control" }], + { + userVisible: false, + }, + ); + const assistantOnly = message("assistant-only-user", "user", [ + { + type: "text", + text: "agent context", + annotations: { audience: ["assistant"] }, + }, + ]); + const source = [ + ...exchange(0), + invisible, + assistantOnly, + ...Array.from({ length: 10 }, (_, index) => exchange(index + 1)).flat(), + ]; + + const result = projectRecentConversationExchanges(source); + + expect(result.omittedExchangeCount).toBe(1); + expect(result.messages[0]?.id).toBe("user-1"); + }); + + it("shows all messages when fewer than 10 exchanges exist", () => { + const source = Array.from({ length: 4 }, (_, index) => + exchange(index + 1), + ).flat(); + + const result = projectRecentConversationExchanges(source); + + expect(result.messages).toEqual(source); + expect(result).toMatchObject({ + omittedExchangeCount: 0, + hasOmittedExchanges: false, + earliestVisibleMessageId: "user-1", + }); + }); + + it("returns a new bounded array without mutating messages or source order", () => { + const source = Array.from({ length: 12 }, (_, index) => + exchange(index + 1), + ).flat(); + const snapshot = source.map(({ id }) => id); + + const result = projectRecentConversationExchanges(source); + + expect(source.map(({ id }) => id)).toEqual(snapshot); + expect(result.messages).not.toBe(source); + expect(result.messages[0]).toBe(source[4]); + }); +}); diff --git a/src/features/chat/lib/boundedConversationProjection.ts b/src/features/chat/lib/boundedConversationProjection.ts new file mode 100644 index 000000000..8232cd07f --- /dev/null +++ b/src/features/chat/lib/boundedConversationProjection.ts @@ -0,0 +1,62 @@ +import type { Message } from "@/shared/types/messages"; +import { getUserVisibleMessageContent } from "@/features/chat/transcript/projection"; + +/** Home canvas shows this many complete user-led conversation exchanges. */ +export const HOME_CANVAS_RECENT_EXCHANGE_LIMIT = 10; + +export interface BoundedConversationProjection { + messages: Message[]; + omittedExchangeCount: number; + hasOmittedExchanges: boolean; + earliestVisibleMessageId: string | null; +} + +/** + * A real user-authored message starts an exchange. Everything after it belongs + * to that exchange until the next real user-authored message. Invisible and + * assistant-only user records are transcript events, not exchange boundaries. + * + * Messages before the first real user-authored message are a prelude. The + * prelude is retained only when no complete exchange is omitted, so omission + * always removes whole exchanges and never leaves detached old context. + */ +export function projectRecentConversationExchanges( + messages: readonly Message[], +): BoundedConversationProjection { + const exchangeStarts: number[] = []; + + for (let index = 0; index < messages.length; index += 1) { + const message = messages[index]; + if (message && isRealUserAuthoredMessage(message)) { + exchangeStarts.push(index); + } + } + + const omittedExchangeCount = Math.max( + 0, + exchangeStarts.length - HOME_CANVAS_RECENT_EXCHANGE_LIMIT, + ); + const firstVisibleExchangeStart = exchangeStarts[omittedExchangeCount]; + const startIndex = + omittedExchangeCount > 0 + ? (firstVisibleExchangeStart ?? messages.length) + : 0; + const projectedMessages = messages.slice(startIndex); + + return { + messages: projectedMessages, + omittedExchangeCount, + hasOmittedExchanges: omittedExchangeCount > 0, + earliestVisibleMessageId: projectedMessages[0]?.id ?? null, + }; +} + +function isRealUserAuthoredMessage(message: Message): boolean { + if (message.role !== "user" || message.metadata?.userVisible === false) { + return false; + } + + return getUserVisibleMessageContent(message.content).some( + (content) => content.type === "text" || content.type === "image", + ); +} diff --git a/src/features/chat/stores/__tests__/chatStore.test.ts b/src/features/chat/stores/__tests__/chatStore.test.ts index 8a0f5134a..b644e12fc 100644 --- a/src/features/chat/stores/__tests__/chatStore.test.ts +++ b/src/features/chat/stores/__tests__/chatStore.test.ts @@ -34,6 +34,7 @@ describe("chatStore", () => { draftAttachmentsBySession: {}, activeSessionId: null, recentMessageSessionIds: [], + mountedTranscriptCountBySession: {}, isViewingActiveSession: false, isConnected: false, loadingSessionIds: new Set(), @@ -1255,6 +1256,7 @@ describe("chatStore draft localStorage persistence", () => { draftAttachmentsBySession: {}, activeSessionId: null, recentMessageSessionIds: [], + mountedTranscriptCountBySession: {}, isViewingActiveSession: false, isConnected: false, }); @@ -1327,6 +1329,7 @@ describe("chatStore session loading state", () => { draftAttachmentsBySession: {}, activeSessionId: null, recentMessageSessionIds: [], + mountedTranscriptCountBySession: {}, isViewingActiveSession: false, isConnected: false, loadingSessionIds: new Set(), @@ -1376,3 +1379,58 @@ describe("chatStore session loading state", () => { expect(useChatStore.getState().loadingSessionIds.size).toBe(0); }); }); + +describe("mounted transcript cache retention", () => { + beforeEach(() => { + useChatStore.setState({ + messagesBySession: {}, + sessionStateById: {}, + recentMessageSessionIds: [], + mountedTranscriptCountBySession: {}, + loadingSessionIds: new Set(), + }); + }); + + it("reference counts independent transcript mounts", () => { + const firstRelease = useChatStore + .getState() + .retainMountedTranscript("session-1"); + const secondRelease = useChatStore + .getState() + .retainMountedTranscript("session-1"); + + expect( + useChatStore.getState().mountedTranscriptCountBySession["session-1"], + ).toBe(2); + + firstRelease(); + firstRelease(); + expect( + useChatStore.getState().mountedTranscriptCountBySession["session-1"], + ).toBe(1); + + secondRelease(); + expect(useChatStore.getState().mountedTranscriptCountBySession).toEqual({}); + }); + + it("protects a settled mounted transcript without increasing the cache limit", () => { + const release = useChatStore + .getState() + .retainMountedTranscript("mounted-session"); + useChatStore.getState().setMessages("mounted-session", [makeMessage()]); + + for (let index = 0; index < 11; index += 1) { + useChatStore + .getState() + .setMessages(`other-session-${index}`, [makeMessage()]); + } + + expect( + useChatStore.getState().messagesBySession["mounted-session"], + ).toHaveLength(1); + expect( + Object.keys(useChatStore.getState().messagesBySession).length, + ).toBeLessThanOrEqual(10); + release(); + }); +}); diff --git a/src/features/chat/stores/chatStore.ts b/src/features/chat/stores/chatStore.ts index ccc3498be..d2133f5d6 100644 --- a/src/features/chat/stores/chatStore.ts +++ b/src/features/chat/stores/chatStore.ts @@ -163,6 +163,9 @@ function trimMessageSessionCache( const protectedSessionIds = new Set([ ...recentMessageSessionIds, ...additionalProtectedSessionIds, + ...Object.entries(state.mountedTranscriptCountBySession) + .filter(([, count]) => count > 0) + .map(([sessionId]) => sessionId), ]); const evictedSessionIds: string[] = []; let cachedSessionCount = Object.keys(state.messagesBySession).length; @@ -402,6 +405,7 @@ interface ChatStoreState { draftAttachmentsBySession: Record; activeSessionId: string | null; recentMessageSessionIds: string[]; + mountedTranscriptCountBySession: Record; isViewingActiveSession: boolean; isConnected: boolean; loadingSessionIds: Set; @@ -411,6 +415,7 @@ interface ChatStoreState { interface ChatStoreActions { setActiveSession: (sessionId: string) => void; setActiveSessionViewing: (isViewing: boolean) => void; + retainMountedTranscript: (sessionId: string) => () => void; addMessage: (sessionId: string, message: Message) => void; updateMessage: ( sessionId: string, @@ -553,6 +558,7 @@ const createChatStore: StateCreator< draftAttachmentsBySession: {}, activeSessionId: null, recentMessageSessionIds: [], + mountedTranscriptCountBySession: {}, isViewingActiveSession: false, isConnected: false, loadingSessionIds: new Set(), @@ -590,6 +596,43 @@ const createChatStore: StateCreator< setActiveSessionViewing: (isViewingActiveSession) => set({ isViewingActiveSession }), + retainMountedTranscript: (sessionId) => { + set((state) => ({ + mountedTranscriptCountBySession: { + ...state.mountedTranscriptCountBySession, + [sessionId]: + (state.mountedTranscriptCountBySession[sessionId] ?? 0) + 1, + }, + })); + + let released = false; + return () => { + if (released) return; + released = true; + let evictedSessionIds: string[] = []; + set((state) => { + const count = state.mountedTranscriptCountBySession[sessionId] ?? 0; + if (count <= 0) return state; + const mountedTranscriptCountBySession = { + ...state.mountedTranscriptCountBySession, + }; + if (count === 1) delete mountedTranscriptCountBySession[sessionId]; + else mountedTranscriptCountBySession[sessionId] = count - 1; + const nextState = { ...state, mountedTranscriptCountBySession }; + const trimmedCache = trimMessageSessionCache( + nextState, + state.recentMessageSessionIds, + ); + evictedSessionIds = trimmedCache.evictedSessionIds; + return { + mountedTranscriptCountBySession, + messagesBySession: trimmedCache.messagesBySession, + }; + }); + evictedSessionIds.forEach(clearReplayBuffer); + }; + }, + // Message management addMessage: (sessionId, message) => { const previousSessionStateById = get().sessionStateById; @@ -1902,6 +1945,11 @@ const createChatStore: StateCreator< const { [sessionId]: removedTarget, ...remainingTargets } = state.scrollTargetMessageBySession; void removedTarget; + const { + [sessionId]: removedMountedTranscriptCount, + ...remainingMountedTranscriptCounts + } = state.mountedTranscriptCountBySession; + void removedMountedTranscriptCount; return { messagesBySession: rest, sessionStateById: remainingSessionState, @@ -1911,6 +1959,7 @@ const createChatStore: StateCreator< skillDraftsBySession: remainingSkillDrafts, draftAttachmentsBySession: remainingDraftAttachments, scrollTargetMessageBySession: remainingTargets, + mountedTranscriptCountBySession: remainingMountedTranscriptCounts, activeSessionId: state.activeSessionId === sessionId ? null : state.activeSessionId, recentMessageSessionIds: removeRecentMessageSessionId( diff --git a/src/features/chat/types.ts b/src/features/chat/types.ts index 7d23d4f53..290b6e925 100644 --- a/src/features/chat/types.ts +++ b/src/features/chat/types.ts @@ -193,6 +193,7 @@ export interface ChatInputControls { attachments?: boolean; autoFocus?: boolean; fileMentions?: boolean; + personaPicker?: boolean; projectPicker?: boolean; skills?: boolean; voice?: boolean; diff --git a/src/features/chat/ui/ChatInput.tsx b/src/features/chat/ui/ChatInput.tsx index 0449d003c..dba6c0dc6 100644 --- a/src/features/chat/ui/ChatInput.tsx +++ b/src/features/chat/ui/ChatInput.tsx @@ -364,6 +364,7 @@ export function ChatInput({ attachments: controls?.attachments ?? attachmentsEnabled, autoFocus: controls?.autoFocus ?? true, fileMentions: controls?.fileMentions ?? true, + personaPicker: controls?.personaPicker ?? true, projectPicker: controls?.projectPicker ?? true, skills: controls?.skills ?? true, voice: controls?.voice ?? true, @@ -639,17 +640,24 @@ export function ChatInput({ : selectedPersonaId; const handleEffectivePersonaChange = useCallback( (personaId: string | null) => { + if (!scopedControls.personaPicker) { + return; + } if (editingQueuedPersona) { setEditingQueuedPersona(personaIntentFromComposer(personaId)); return; } onPersonaChange?.(personaId); }, - [editingQueuedPersona, onPersonaChange], + [editingQueuedPersona, onPersonaChange, scopedControls.personaPicker], ); const activePersona = useMemo( - () => personas.find((persona) => persona.id === effectivePersonaId) ?? null, - [effectivePersonaId, personas], + () => + scopedControls.personaPicker + ? (personas.find((persona) => persona.id === effectivePersonaId) ?? + null) + : null, + [effectivePersonaId, personas, scopedControls.personaPicker], ); const selectedProject = useMemo( () => @@ -715,7 +723,7 @@ export function ChatInput({ handleMentionConfirm, skillMentionItems, } = useMentionHandlers({ - personas, + personas: scopedControls.personaPicker ? personas : [], skillProjectDirs: skillMentionProjectDirs, fileMentionProjectDirs, skillProviderId, diff --git a/src/features/chat/ui/ChatTranscriptSurface.tsx b/src/features/chat/ui/ChatTranscriptSurface.tsx new file mode 100644 index 000000000..a2f23657c --- /dev/null +++ b/src/features/chat/ui/ChatTranscriptSurface.tsx @@ -0,0 +1,175 @@ +import { + useEffect, + useState, + type ComponentProps, + type ReactNode, + type RefObject, +} from "react"; +import { AnimatePresence, motion } from "motion/react"; +import { useTranslation } from "react-i18next"; +import type { Persona } from "@/shared/types/agents"; +import type { Message } from "@/shared/types/messages"; +import { scheduleAfterNextPaint } from "@/app/lib/scheduleAfterNextPaint"; +import { useChatStore } from "@/features/chat/stores/chatStore"; +import { ArtifactPolicyProvider } from "@/features/chat/hooks/ArtifactPolicyContext"; +import type { TranscriptSearchBackend } from "@/features/chat/lib/transcriptSearchBackend"; +import { ChatLoadingSkeleton } from "./ChatLoadingSkeleton"; +import { ConversationEmptyAvatar } from "./ConversationEmptyAvatar"; +import { + VirtualMessageTimelineGate, + type TranscriptRendererPolicy, +} from "./VirtualMessageTimelineGate"; + +type TimelineCallbacks = Pick< + ComponentProps, + | "onSendMcpAppMessage" + | "onRunShellCommand" + | "onEditProject" + | "onChangeFolder" + | "onOpenContextPanel" + | "onForkFromMessage" +>; + +export interface ChatTranscriptSurfaceProps extends TimelineCallbacks { + sessionId: string; + messages: Message[]; + streamingMessageId?: string | null; + isLoadingHistory: boolean; + selectedPersona?: Persona | null; + sessionCwd?: string | null; + scrollTargetMessageId?: string | null; + scrollTargetQuery?: string | null; + onScrollTargetHandled?: (messageId: string) => void; + searchContentRef?: RefObject; + searchBackendRef?: RefObject; + startContent?: ReactNode; + footer?: ReactNode; + footerStatus?: ReactNode; + suppressEmptyPlaceholder?: boolean; + /** The owning surface chooses presentation; full chat stays automatic. */ + rendererPolicy?: TranscriptRendererPolicy; +} + +function shouldStageInitialTranscript( + messages: readonly unknown[], + isLoadingHistory: boolean, +): boolean { + return messages.length > 0 && !isLoadingHistory; +} + +/** + * Chat-owned transcript lifecycle and rendering. Every mount keeps independent + * scroll/search state while sharing the session-addressed message/runtime data. + */ +export function ChatTranscriptSurface({ + sessionId, + messages, + streamingMessageId, + isLoadingHistory, + selectedPersona, + sessionCwd, + scrollTargetMessageId, + scrollTargetQuery, + onScrollTargetHandled, + searchContentRef, + searchBackendRef, + startContent, + footer, + footerStatus, + suppressEmptyPlaceholder = false, + rendererPolicy = "auto", + ...callbacks +}: ChatTranscriptSurfaceProps) { + const { t } = useTranslation("chat"); + const retainMountedTranscript = useChatStore( + (state) => state.retainMountedTranscript, + ); + const [initialGate, setInitialGate] = useState(() => ({ + sessionId, + pending: shouldStageInitialTranscript(messages, isLoadingHistory), + })); + const shouldStage = shouldStageInitialTranscript(messages, isLoadingHistory); + const isPreparing = + initialGate.sessionId === sessionId ? initialGate.pending : shouldStage; + const showLoading = isLoadingHistory || isPreparing; + const timelineMessages = isPreparing ? [] : messages; + + useEffect( + () => retainMountedTranscript(sessionId), + [retainMountedTranscript, sessionId], + ); + + // Only stage the first populated paint. Live updates stay in the mounted + // timeline and preserve this mount's scroll state. + // biome-ignore lint/correctness/useExhaustiveDependencies: sessionId resets the one-time gate. + useEffect(() => { + const pending = shouldStageInitialTranscript(messages, isLoadingHistory); + setInitialGate((current) => + current.sessionId === sessionId && current.pending === pending + ? current + : { sessionId, pending }, + ); + if (!pending) return; + return scheduleAfterNextPaint(() => { + setInitialGate((current) => + current.sessionId === sessionId && current.pending + ? { sessionId, pending: false } + : current, + ); + }); + }, [sessionId]); + + const placeholder = showLoading ? ( + + ) : suppressEmptyPlaceholder ? ( +