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 ? (
+
+ ) : (
+
+
+ {selectedPersona ? (
+
+
+
+
+
+ ) : null}
+
+
+ {t("emptyState.startAConversation")}
+
+
+ );
+
+ return (
+
+
+
+ );
+}
diff --git a/src/features/chat/ui/ChatView.tsx b/src/features/chat/ui/ChatView.tsx
index a5a4a126f..4900d9361 100644
--- a/src/features/chat/ui/ChatView.tsx
+++ b/src/features/chat/ui/ChatView.tsx
@@ -2,23 +2,16 @@ import {
useCallback,
useEffect,
useLayoutEffect,
- useMemo,
useRef,
useState,
type CSSProperties,
} from "react";
-import { AnimatePresence, motion } from "motion/react";
+import { AnimatePresence } from "motion/react";
import { IconLayoutSidebarLeftCollapse } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
-import { VirtualMessageTimelineGate } from "./VirtualMessageTimelineGate";
import { ChatSearchBar } from "./ChatSearchBar";
-import { WorkspaceSetupChoice } from "./WorkspaceSetupChoice";
-import { summarizeProjectWorkspaceStartup } from "@/features/projects/lib/projectChatWorkspaces";
-import { ChatInput } from "./ChatInput";
+import { ChatTranscriptSurface } from "./ChatTranscriptSurface";
import { LoadingBerd } from "./LoadingBerd";
-import { ChatLoadingSkeleton } from "./ChatLoadingSkeleton";
-import { ConversationEmptyAvatar } from "./ConversationEmptyAvatar";
-import { ArtifactPolicyProvider } from "../hooks/ArtifactPolicyContext";
import { ChatRightRail } from "./ChatRightRail";
import {
ARTIFACT_VIEWER_RAIL_ALLOWANCE_PX,
@@ -35,10 +28,11 @@ import { useFocusRegion } from "@/app/focus/FocusRegionProvider";
import { perfLog } from "@/shared/lib/perfLog";
import { Badge } from "@/shared/ui/badge";
import { cn } from "@/shared/lib/cn";
+import type { WorkspaceNameRequest } from "../hooks/useChatSessionController";
import {
- useChatSessionController,
- type WorkspaceNameRequest,
-} from "../hooks/useChatSessionController";
+ ConversationComposerCapability,
+ useConversationComposerBinding,
+} from "../capabilities/ConversationComposerCapability";
import { useResizableAgentBuilderRail } from "../hooks/useResizableAgentBuilderRail";
import { useWorkspaceRepository } from "@/features/workspaces/workspaceRepository";
import { useChangeSessionFolder } from "../hooks/useChangeSessionFolder";
@@ -46,7 +40,6 @@ import {
useChatSessionStore,
type ChatSession,
} from "../stores/chatSessionStore";
-import type { ChatInputControls } from "../types";
import { TerminalCapability } from "@/features/terminal/capabilities/TerminalCapability";
import { useTerminalController } from "@/features/terminal/hooks/useTerminalController";
import { TerminalDockPreview } from "@/features/terminal/ui/TerminalDockPreview";
@@ -67,7 +60,6 @@ import {
isContextPanelVisible,
} from "@/features/chat/lib/chatCapabilityVisibility";
import type { TranscriptSearchBackend } from "@/features/chat/lib/transcriptSearchBackend";
-import { scheduleAfterNextPaint } from "@/app/lib/scheduleAfterNextPaint";
import type { GlobalComposerHandoffRect } from "@/shared/ui/GlobalComposerPill";
import { useVoiceConversationController } from "@/features/voice-conversation/hooks/useVoiceConversationController";
import { usePocketVoiceSetup } from "@/features/voice-conversation/hooks/usePocketVoiceSetup";
@@ -79,20 +71,12 @@ import { requestOpenSettings } from "@/features/settings/lib/settingsEvents";
import { useVoiceConversationStore } from "@/features/voice-conversation/stores/voiceConversationStore";
import {
SecurityConfirmationPanel,
- useHasPendingSecurityConfirmation,
useRegisterSecurityConfirmationSurface,
} from "@/features/security/ui/SecurityConfirmationPanel";
const CHAT_RESPONDING_PILL_CLASS =
"rounded-full bg-surface-chat-responding-pill-bg text-surface-chat-responding-pill-fg shadow-[var(--shadow-chat)] [--shimmer-ink:var(--color-surface-chat-responding-pill-fg)]";
const CLOSED_RIGHT_RAIL_DOCK_TARGET_WIDTH_PX = 48;
-function shouldStageInitialTranscript(
- messages: readonly unknown[],
- isLoadingHistory: boolean,
-): boolean {
- return messages.length > 0 && !isLoadingHistory;
-}
-
interface ChatViewProps {
sessionId: string;
activeSession?: ChatSession | null;
@@ -132,8 +116,6 @@ export function ChatView({
}: ChatViewProps) {
const { t } = useTranslation("chat");
useRegisterSecurityConfirmationSurface(sessionId);
- const hasPendingSecurityConfirmation =
- useHasPendingSecurityConfirmation(sessionId);
const isArtifactViewerOpen = useOpenArtifact(sessionId) !== null;
const mountStart = useRef(performance.now());
const terminalRootRef = useRef(null);
@@ -150,12 +132,17 @@ export function ChatView({
backendRef: transcriptSearchBackendRef,
});
const { close: closeSearch } = search;
- const controller = useChatSessionController({
- sessionId,
- readOnly: Boolean(readOnlyStatus),
+ const composerBinding = useConversationComposerBinding({
+ target: {
+ kind: "existingSession",
+ sessionId,
+ sessionSnapshot: activeSession,
+ readOnlyReason: readOnlyStatus,
+ },
onCreatePersonaRequested: onCreatePersona,
onWorkspaceNameRequest,
});
+ const { controller, admissionBlocked, onSend } = composerBinding;
const activeSessionClientSessionId = activeSession?.clientSessionId ?? null;
useLayoutEffect(() => {
@@ -251,7 +238,7 @@ export function ChatView({
// Voice delivery only needs to wait for admission. Holding its per-session
// queue through the full run would prevent later utterances from steering
// the active run.
- onSend: controller.handleSend,
+ onSend,
enabled: capabilities.voiceConversation,
isGooseSession: controller.selectedProvider === "goose",
pocketReady: voiceReady,
@@ -263,6 +250,7 @@ export function ChatView({
},
readOnly: Boolean(readOnlyStatus),
disabled:
+ admissionBlocked ||
controller.projectMetadataPending ||
controller.isCompactingContext ||
controller.isLoadingHistory ||
@@ -360,8 +348,6 @@ export function ChatView({
const agentBuilderGridTemplate = isAgentBuilderChatCollapsed
? "0fr 1fr"
: `${1 - builderFraction}fr ${builderFraction}fr`;
- const isAgentBuilderTargetFailed =
- isAgentBuilderOpen && effectiveSession?.targetAgentDraftState === "failed";
const hasVisibleRightRail =
isAgentBuilderOpen ||
Boolean(
@@ -574,56 +560,17 @@ export function ChatView({
const onTimelineChangeFolder =
!isReadOnly && changeFolderSessionId ? handleChangeFolder : undefined;
- const showIndicator =
- controller.chatState === "thinking" ||
- controller.chatState === "streaming" ||
- controller.chatState === "waiting" ||
- controller.chatState === "compacting";
+ const shouldShowLoadingIndicator =
+ !controller.isLoadingHistory &&
+ (controller.chatState === "thinking" ||
+ controller.chatState === "streaming" ||
+ controller.chatState === "waiting" ||
+ controller.chatState === "compacting");
const loadingChatState = controller.chatState as
| "thinking"
| "streaming"
| "waiting"
| "compacting";
- const chatInputControls = useMemo(() => {
- if (isReadOnly) {
- return {
- agentModelPicker: false,
- attachments: false,
- autoFocus: false,
- fileMentions: false,
- projectPicker: false,
- skills: false,
- voice: false,
- };
- }
-
- if (!controller.skillsEnabled || composerHandoffActive) {
- return {
- ...(!controller.skillsEnabled ? { skills: false } : {}),
- ...(composerHandoffActive ? { autoFocus: false } : {}),
- };
- }
-
- return undefined;
- }, [composerHandoffActive, controller.skillsEnabled, isReadOnly]);
- const shouldStageTranscript = shouldStageInitialTranscript(
- controller.messages,
- controller.isLoadingHistory,
- );
- const [initialTranscriptGate, setInitialTranscriptGate] = useState(() => ({
- sessionId,
- pending: shouldStageTranscript,
- }));
- const isPreparingInitialTranscript =
- initialTranscriptGate.sessionId === sessionId
- ? initialTranscriptGate.pending
- : shouldStageTranscript;
- const showTimelineLoading =
- controller.isLoadingHistory || isPreparingInitialTranscript;
- const shouldShowLoadingIndicator = showIndicator && !showTimelineLoading;
- const timelineMessages = isPreparingInitialTranscript
- ? []
- : controller.messages;
const suppressEmptyConversationPlaceholder =
composerHandoffInProgress || controller.queue.queuedMessage !== null;
const handleForkFromMessage = useCallback(
@@ -645,44 +592,6 @@ export function ChatView({
[controller.messages, effectiveSession?.id, isReadOnly, onForkChat],
);
- // Only gate the first render for a session. Later live updates should stream
- // into the mounted timeline without showing the skeleton again.
- // biome-ignore lint/correctness/useExhaustiveDependencies: sessionId is the reset signal for the initial transcript gate.
- useEffect(() => {
- const pending = shouldStageInitialTranscript(
- controller.messages,
- controller.isLoadingHistory,
- );
-
- setInitialTranscriptGate((current) =>
- current.sessionId === sessionId && current.pending === pending
- ? current
- : { sessionId, pending },
- );
-
- if (!pending) {
- return;
- }
-
- return scheduleAfterNextPaint(() => {
- setInitialTranscriptGate((current) =>
- current.sessionId === sessionId && current.pending
- ? { sessionId, pending: false }
- : current,
- );
- });
- }, [sessionId]);
-
- let sendDisabledReason: string | undefined;
- if (readOnlyStatus) {
- sendDisabledReason = readOnlyStatus;
- } else if (effectiveSession?.creationState === "failed") {
- sendDisabledReason =
- effectiveSession.creationError ?? t("toolbar.sessionStartFailed");
- } else if (isAgentBuilderTargetFailed) {
- sendDisabledReason = t("toolbar.agentBuilderPrepareFailed");
- }
-
// The composer is owned by the timeline so it stays mounted across loading,
// empty, and populated states without losing focus or draft text.
const footerStatus = composerHandoffActive ? null : readOnlyStatus ? (
@@ -727,13 +636,6 @@ export function ChatView({
return null;
}, [controller.messages]);
- const workspaceSetup = controller.defaultWorkspaceSetup
- ? controller.defaultWorkspaceSetup
- : controller.deferredWorkspaceRecord?.state;
- const deferredWorkspaceStartup = summarizeProjectWorkspaceStartup(
- workspaceSetup?.desired ?? [],
- );
-
const composerFooter = (
-
- {controller.deferredWorkspaceError}
-
- ) : !isReadOnly &&
- deferredWorkspaceStartup.worktreeCount > 0 &&
- (workspaceSetup?.status === "choice" ||
- workspaceSetup?.status === "naming" ||
- workspaceSetup?.status === "creating") ? (
-
- ) : null
- }
- controls={chatInputControls}
- skillProjectDirs={controller.skillProjectDirs}
- fileMentionProjectDirs={controller.fileMentionProjectDirs}
- skillProviderId={controller.selectedProvider}
- composerActions={{
- onSend: controller.handleSend,
- onSteerMessage: (text, personaId, attachments, options) =>
- controller.steerDraftMessage(
- text,
- personaId ?? undefined,
- attachments,
- options,
- ),
- canSteerMessage: controller.canSteerMessage,
- onSteerQueuedMessage: controller.steerQueuedMessage,
- canSteerQueuedMessage: controller.canSteerQueuedMessage,
- disabled:
- isReadOnly ||
- controller.projectMetadataPending ||
- controller.isCompactingContext,
- sendDisabled:
- isReadOnly ||
- effectiveSession?.creationState === "failed" ||
- isAgentBuilderTargetFailed ||
- controller.workspaceSetupInProgress,
- sendDisabledReason,
- queuedMessage: composerHandoffInProgress
- ? null
- : (controller.queue.queuedMessage ??
- controller.deferredWorkspaceRecord?.payload ??
- null),
- queuedMessages: composerHandoffInProgress
- ? []
- : (
- controller.queue.queuedRecords ??
- (controller.queue.queuedRecord
- ? [controller.queue.queuedRecord]
- : [])
- ).map((record) => ({
- recordId: record.recordId,
- payload: record.payload,
- })),
- onUpdateQueue: controller.queue.update,
- onEditQueue: controller.queue.beginEditing,
- onCancelQueueEdit: controller.queue.cancelEditing,
- onSendQueue:
- !isReadOnly &&
- !controller.unresolvedDeferredSend &&
- (controller.deferredWorkspaceRecord?.state.status === "failed" ||
- controller.deferredWorkspaceRecord?.state.status === "held") &&
- effectiveSession?.creationState !== "failed"
- ? controller.sendDeferredAnyway
- : undefined,
- onDismissQueue:
- composerHandoffInProgress ||
- isReadOnly ||
- controller.deferredWorkspaceRecord?.state.status === "creating" ||
- controller.deferredWorkspaceRecord?.state.status === "naming"
- ? undefined
- : controller.queue.dismiss,
- onStop: isReadOnly ? undefined : controller.stopStreaming,
- isStreaming:
- !isReadOnly &&
- (controller.chatState === "streaming" ||
- controller.chatState === "thinking"),
- voiceConversation,
+
- 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,
- onCompactContext: controller.compactConversation,
- canCompactContext: controller.canCompactContext,
- isCompactingContext: controller.isCompactingContext,
- supportsCompactionControls: controller.supportsCompactionControls,
- }}
/>
);
- const conversationPlaceholder = showTimelineLoading ? (
-
- ) : suppressEmptyConversationPlaceholder ? (
-
- ) : (
-
-
- {controller.selectedPersona ? (
-
-
-
-
-
- ) : null}
-
-
- {t("emptyState.startAConversation")}
-
-
- );
const timelineSessionId = effectiveSession?.id ?? sessionId;
const messageTimeline = (
-
);
+
useFocusRegion({
id: "terminal",
label: "terminal",
@@ -983,11 +731,7 @@ export function ChatView({
});
return (
-
+ <>
-
+ >
);
}
diff --git a/src/features/chat/ui/MessageTimeline.tsx b/src/features/chat/ui/MessageTimeline.tsx
index 84beec01b..36d7eea4e 100644
--- a/src/features/chat/ui/MessageTimeline.tsx
+++ b/src/features/chat/ui/MessageTimeline.tsx
@@ -77,6 +77,8 @@ interface MessageTimelineProps extends MessageTimelineBubbleCallbacks {
searchContentRef?: Ref;
className?: string;
tailPaddingPx?: number;
+ /** Owning-surface content shown before the first transcript row. */
+ startContent?: ReactNode;
/** Pinned to the bottom of the timeline while the conversation scrolls behind it. */
footer?: ReactNode;
/** Status or activity surface shown in the footer control row above the
@@ -130,6 +132,7 @@ export function MessageTimeline({
onOpenContextPanel,
className,
tailPaddingPx,
+ startContent,
footer,
footerStatus,
placeholder,
@@ -1449,6 +1452,7 @@ export function MessageTimeline({
className="mx-auto w-full max-w-[var(--chat-transcript-container-max-width)] flex-1 px-[var(--chat-transcript-inline-padding)] pt-4"
style={{ paddingBottom: messageListBottomPaddingPx }}
>
+ {startContent}
{snapshot.rows.map((row, index) => (
;
+export type TranscriptRendererPolicy = "auto" | "classic";
+
interface VirtualMessageTimelineGateProps extends MessageTimelineProps {
sessionId: string;
+ rendererPolicy?: TranscriptRendererPolicy;
/** Filled by the virtual timeline with its indexed search backend. The
classic timeline mounts everything, so the search controller falls back
to direct DOM matching when this stays null. */
@@ -18,6 +21,7 @@ interface VirtualMessageTimelineGateProps extends MessageTimelineProps {
export function VirtualMessageTimelineGate({
sessionId,
+ rendererPolicy = "auto",
searchBackendRef,
...timelineProps
}: VirtualMessageTimelineGateProps) {
@@ -26,10 +30,11 @@ export function VirtualMessageTimelineGate({
);
const virtualRendererEnabled = virtualRendererExperiment?.enabled ?? false;
+ const useVirtualRenderer =
+ rendererPolicy === "auto" && virtualRendererEnabled;
const loadedTranscript = useMemo(
- () =>
- virtualRendererEnabled ? createLoadedTranscriptState(sessionId) : null,
- [sessionId, virtualRendererEnabled],
+ () => (useVirtualRenderer ? createLoadedTranscriptState(sessionId) : null),
+ [sessionId, useVirtualRenderer],
);
if (!loadedTranscript) {
diff --git a/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx b/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx
index 5450908e6..98ae3cac7 100644
--- a/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx
+++ b/src/features/chat/ui/__tests__/ChatView.mcpApp.test.tsx
@@ -15,12 +15,14 @@ import {
} from "@/app/contexts/TopBarActionsContext";
import { TERMINAL_FALLBACK_CWD_STORAGE_KEY } from "@/features/terminal/lib/terminalCwdPreference";
import type { ChatSession } from "../../stores/chatSessionStore";
+import { useSecurityConfirmationStore } from "@/features/security/stores/securityConfirmationStore";
import { ChatView } from "../ChatView";
const mocks = vi.hoisted(() => ({
messageTimelineSpy: vi.fn(),
chatInputSpy: vi.fn(),
chatRightRailSpy: vi.fn(),
+ voiceControllerSpy: vi.fn(),
setRightRailOpen: vi.fn(),
patchSession: vi.fn(),
handleSend: vi.fn(() => true),
@@ -93,14 +95,17 @@ vi.mock("react-i18next", () => ({
vi.mock(
"@/features/voice-conversation/hooks/useVoiceConversationController",
() => ({
- useVoiceConversationController: () => ({
- lifecycle: "stopped",
- uiState: "off",
- microphoneMuted: false,
- start: vi.fn(),
- stop: vi.fn(),
- toggleMicrophone: vi.fn(),
- }),
+ useVoiceConversationController: (options: unknown) => {
+ mocks.voiceControllerSpy(options);
+ return {
+ lifecycle: "stopped",
+ uiState: "off",
+ microphoneMuted: false,
+ start: vi.fn(),
+ stop: vi.fn(),
+ toggleMicrophone: vi.fn(),
+ };
+ },
}),
);
@@ -393,6 +398,7 @@ describe("ChatView MCP app messaging", () => {
mocks.messageTimelineSpy.mockClear();
mocks.chatInputSpy.mockClear();
mocks.chatRightRailSpy.mockClear();
+ mocks.voiceControllerSpy.mockClear();
mocks.setRightRailOpen.mockClear();
mocks.patchSession.mockClear();
mocks.handleSend.mockClear();
@@ -407,6 +413,10 @@ describe("ChatView MCP app messaging", () => {
mocks.activeWorkspaceBySession = {};
mocks.afterNextPaintCallbacks = [];
mocks.autoFlushAfterNextPaint = true;
+ useSecurityConfirmationStore.setState({
+ pendingBySessionId: {},
+ mountedSurfaceCountBySessionId: {},
+ });
window.localStorage.clear();
mockMatchMedia(false);
mocks.useChatSessionController.mockReturnValue({
@@ -501,6 +511,30 @@ describe("ChatView MCP app messaging", () => {
});
});
+ it("keeps full chat automatic and passes the complete transcript", () => {
+ const completeMessages = Array.from({ length: 12 }, (_, index) => ({
+ id: `user-${index + 1}`,
+ role: "user" as const,
+ created: index,
+ content: [{ type: "text" as const, text: `Question ${index + 1}` }],
+ metadata: { userVisible: true },
+ }));
+ mocks.useChatSessionController.mockReturnValue({
+ ...mocks.useChatSessionController(),
+ messages: completeMessages,
+ });
+
+ render();
+
+ const timelineProps = mocks.messageTimelineSpy.mock.calls.at(-1)?.[0] as {
+ messages: typeof completeMessages;
+ rendererPolicy?: string;
+ };
+ expect(timelineProps.messages).toBe(completeMessages);
+ expect(timelineProps.messages).toHaveLength(12);
+ expect(timelineProps.rendererPolicy).toBe("auto");
+ });
+
it("does not pass fork-from-message in read-only mode", () => {
render(
{
expect(chatInputProps.className).toBeUndefined();
});
+ it("blocks and hides composer, queue, MCP, and voice delivery while security confirmation is pending", () => {
+ const sendDeferredAnyway = vi.fn();
+ mocks.useChatSessionController.mockReturnValue({
+ ...mocks.useChatSessionController(),
+ deferredWorkspaceRecord: {
+ kind: "deferred",
+ recordId: "deferred-1",
+ payload: { text: "queued" },
+ state: { status: "held", desired: [] },
+ },
+ queue: { queuedMessage: { text: "queued" }, dismiss: vi.fn() },
+ sendDeferredAnyway,
+ });
+ useSecurityConfirmationStore.setState({
+ pendingBySessionId: {
+ "session-1": [
+ {
+ request: { sessionId: "session-1" } as never,
+ title: "Security",
+ command: null,
+ alertText: "Alert",
+ resolve: () => undefined,
+ inferredExplanation: { status: "idle" },
+ },
+ ],
+ },
+ });
+
+ render();
+
+ const chatInputProps = mocks.chatInputSpy.mock.calls.at(-1)?.[0] as {
+ className?: string;
+ composerActions: {
+ onSend: (text: string) => boolean;
+ onSendQueue?: () => boolean;
+ onSteerMessage?: unknown;
+ onSteerQueuedMessage?: unknown;
+ };
+ };
+ expect(chatInputProps.className).toBe("hidden");
+ expect(chatInputProps.composerActions.onSend("blocked")).toBe(false);
+ expect(chatInputProps.composerActions.onSendQueue).toBeUndefined();
+ expect(chatInputProps.composerActions.onSteerMessage).toBeUndefined();
+ expect(chatInputProps.composerActions.onSteerQueuedMessage).toBeUndefined();
+ expect(mocks.handleSend).not.toHaveBeenCalled();
+ expect(sendDeferredAnyway).not.toHaveBeenCalled();
+
+ const timelineProps = mocks.messageTimelineSpy.mock.calls.at(-1)?.[0] as {
+ onSendMcpAppMessage?: unknown;
+ };
+ expect(timelineProps.onSendMcpAppMessage).toBeUndefined();
+
+ const voiceOptions = mocks.voiceControllerSpy.mock.calls.at(-1)?.[0] as {
+ onSend: (text: string) => boolean;
+ disabled: boolean;
+ };
+ expect(voiceOptions.disabled).toBe(true);
+ expect(voiceOptions.onSend("blocked voice")).toBe(false);
+ expect(mocks.handleSend).not.toHaveBeenCalled();
+ });
+
it("shows the empty-state placeholder while keeping the composer mounted for a fresh chat", () => {
mocks.useChatSessionController.mockReturnValue({
...mocks.useChatSessionController(),
@@ -1222,6 +1317,53 @@ describe("ChatView MCP app messaging", () => {
);
});
+ it("blocks ordinary, deferred, and voice delivery when the execution target fails", () => {
+ const sendDeferredAnyway = vi.fn();
+ mocks.useChatSessionController.mockReturnValue({
+ ...mocks.useChatSessionController(),
+ deferredWorkspaceRecord: {
+ kind: "deferred",
+ recordId: "deferred-1",
+ payload: { text: "queued" },
+ state: { status: "held", desired: [] },
+ },
+ queue: { queuedMessage: { text: "queued" }, dismiss: vi.fn() },
+ sendDeferredAnyway,
+ });
+ const activeSession = {
+ id: "session-1",
+ title: "Build agent",
+ createdAt: "2026-05-27T00:00:00.000Z",
+ updatedAt: "2026-05-27T00:00:00.000Z",
+ messageCount: 0,
+ intent: "build-agent",
+ targetAgentDraftState: "failed",
+ } satisfies ChatSession;
+
+ render();
+
+ const chatInputProps = mocks.chatInputSpy.mock.calls.at(-1)?.[0] as {
+ composerActions: {
+ onSend: (text: string) => boolean;
+ onSendQueue?: () => boolean;
+ disabled?: boolean;
+ };
+ };
+ expect(chatInputProps.composerActions.disabled).toBe(true);
+ expect(chatInputProps.composerActions.onSendQueue).toBeUndefined();
+ expect(chatInputProps.composerActions.onSend("blocked")).toBe(false);
+ expect(mocks.handleSend).not.toHaveBeenCalled();
+ expect(sendDeferredAnyway).not.toHaveBeenCalled();
+
+ const voiceOptions = mocks.voiceControllerSpy.mock.calls.at(-1)?.[0] as {
+ onSend: (text: string) => boolean;
+ disabled: boolean;
+ };
+ expect(voiceOptions.disabled).toBe(true);
+ expect(voiceOptions.onSend("blocked voice")).toBe(false);
+ expect(mocks.handleSend).not.toHaveBeenCalled();
+ });
+
it("keeps the canonical composer enabled when a pending builder is closed", () => {
const activeSession = {
id: "session-1",
diff --git a/src/features/chat/ui/__tests__/MessageTimeline.test.tsx b/src/features/chat/ui/__tests__/MessageTimeline.test.tsx
index 5e0e27179..1f1c8f73e 100644
--- a/src/features/chat/ui/__tests__/MessageTimeline.test.tsx
+++ b/src/features/chat/ui/__tests__/MessageTimeline.test.tsx
@@ -1318,6 +1318,9 @@ describe("MessageTimeline", () => {
name: "Jump to latest",
});
expect(jumpButton).toHaveClass("h-8", "w-8");
+ expect(jumpButton.closest('[data-testid="message-timeline-footer"]')).toBe(
+ screen.getByTestId("message-timeline-footer"),
+ );
expect(screen.queryByText("Jump to latest")).not.toBeInTheDocument();
});
diff --git a/src/features/chat/ui/__tests__/VirtualMessageTimelineGate.test.tsx b/src/features/chat/ui/__tests__/VirtualMessageTimelineGate.test.tsx
index 8d5497977..ec2eac839 100644
--- a/src/features/chat/ui/__tests__/VirtualMessageTimelineGate.test.tsx
+++ b/src/features/chat/ui/__tests__/VirtualMessageTimelineGate.test.tsx
@@ -143,6 +143,26 @@ describe("VirtualMessageTimelineGate", () => {
expect(mocks.legacyTimelineSpy).not.toHaveBeenCalled();
});
+ it("honors an owning surface's explicit classic renderer policy", () => {
+ expect(
+ setExperimentEnabled(TRANSCRIPT_VIRTUAL_RENDERER_EXPERIMENT_ID, true),
+ ).toBe(true);
+
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId("legacy-message-timeline")).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("virtual-message-timeline"),
+ ).not.toBeInTheDocument();
+ expect(mocks.virtualTimelineSpy).not.toHaveBeenCalled();
+ });
+
it("replaces loaded transcript state when the virtual renderer is toggled", () => {
expect(
setExperimentEnabled(TRANSCRIPT_VIRTUAL_RENDERER_EXPERIMENT_ID, true),
diff --git a/src/features/experiments/__tests__/ExperimentsSettings.test.tsx b/src/features/experiments/__tests__/ExperimentsSettings.test.tsx
index a20e3daa2..37324174f 100644
--- a/src/features/experiments/__tests__/ExperimentsSettings.test.tsx
+++ b/src/features/experiments/__tests__/ExperimentsSettings.test.tsx
@@ -6,6 +6,7 @@ import {
AVATAR_COLLECTION_PAGE_EXPERIMENT_ID,
BERDY_ONBOARDING_EXPERIMENT_ID,
BUILDERBOT_SURFACE_EXPERIMENT_ID,
+ CHAT_ON_CANVAS_EXPERIMENT_ID,
EXPERIMENT_DEFINITIONS,
RELATED_PULL_REQUESTS_EXPERIMENT_ID,
SKILL_DISCOVERY_EXPERIMENT_ID,
@@ -135,12 +136,24 @@ describe("ExperimentsSettings", () => {
SKILL_DISCOVERY_EXPERIMENT_ID,
STARTER_TASKS_EXPERIMENT_ID,
VOICE_CONVERSATION_EXPERIMENT_ID,
+ CHAT_ON_CANVAS_EXPERIMENT_ID,
AVATAR_COLLECTION_PAGE_EXPERIMENT_ID,
BERDY_ONBOARDING_EXPERIMENT_ID,
RELATED_PULL_REQUESTS_EXPERIMENT_ID,
]);
});
+ it("keeps chat on canvas manual-only and off by default", () => {
+ expect(
+ EXPERIMENT_DEFINITIONS.find(
+ ({ id }) => id === CHAT_ON_CANVAS_EXPERIMENT_ID,
+ ),
+ ).toMatchObject({
+ defaultEnabled: false,
+ manualEnableOnly: true,
+ });
+ });
+
it("groups onboarding experiments and resets all onboarding experiences", async () => {
vi.stubEnv("DEV", true);
const user = userEvent.setup();
diff --git a/src/features/experiments/experimentDefinitions.ts b/src/features/experiments/experimentDefinitions.ts
index e0cd0dc53..bee329150 100644
--- a/src/features/experiments/experimentDefinitions.ts
+++ b/src/features/experiments/experimentDefinitions.ts
@@ -57,6 +57,8 @@ export const TRANSCRIPT_VIRTUAL_RENDERER_EXPERIMENT_ID =
export const AVATAR_COLLECTION_PAGE_EXPERIMENT_ID = "avatar-collection-page";
+export const CHAT_ON_CANVAS_EXPERIMENT_ID = "chat-on-canvas";
+
export const STARTER_TASKS_EXPERIMENT_ID = "onboarding-starter-tasks";
export const BERDY_ONBOARDING_EXPERIMENT_ID = "berdy-onboarding";
@@ -96,6 +98,13 @@ export const EXPERIMENT_DEFINITIONS = [
descriptionKey: "experiments.voiceConversation.description",
defaultEnabled: true,
},
+ {
+ id: CHAT_ON_CANVAS_EXPERIMENT_ID,
+ titleKey: "experiments.chatOnCanvas.title",
+ descriptionKey: "experiments.chatOnCanvas.description",
+ defaultEnabled: false,
+ manualEnableOnly: true,
+ },
{
id: AVATAR_COLLECTION_PAGE_EXPERIMENT_ID,
titleKey: "experiments.avatarCollectionPage.title",
diff --git a/src/features/home/lib/homeLayoutMapper.test.ts b/src/features/home/lib/homeLayoutMapper.test.ts
index 22b00e449..0196f128e 100644
--- a/src/features/home/lib/homeLayoutMapper.test.ts
+++ b/src/features/home/lib/homeLayoutMapper.test.ts
@@ -142,6 +142,46 @@ describe("homeLayoutMapper", () => {
});
});
+ it("round-trips an expanded chat presentation and its size memory", () => {
+ const widget: WidgetInstance = {
+ id: "00000000-0000-0000-0000-000000000001",
+ type: "chatPin",
+ x: 24,
+ y: 48,
+ z: 5,
+ width: 480,
+ height: 560,
+ state: {
+ sessionId: "session-1",
+ presentation: "expanded",
+ __sizeByProfile: {
+ "188x80": { width: 188, height: 80 },
+ },
+ },
+ };
+
+ const [item] = homeWidgetsToLayoutItems([widget]);
+
+ expect(item.widgetState).toEqual({
+ presentation: "expanded",
+ __sizeByProfile: {
+ "188x80": { width: 188, height: 80 },
+ },
+ });
+ expect(layoutItemsToHomeWidgets([item])[0]).toMatchObject({
+ type: "chatPin",
+ width: 480,
+ height: 560,
+ state: {
+ sessionId: "session-1",
+ presentation: "expanded",
+ __sizeByProfile: {
+ "188x80": { width: 188, height: 80 },
+ },
+ },
+ });
+ });
+
it("populates entity state only for non-synthetic targets", () => {
const widgets = layoutItemsToHomeWidgets([
layoutItem({ kind: "stickyNote", targetId: "onboarding:build-agent" }),
diff --git a/src/features/home/lib/homeLayoutMapper.ts b/src/features/home/lib/homeLayoutMapper.ts
index 5ff2cc850..40cc960b5 100644
--- a/src/features/home/lib/homeLayoutMapper.ts
+++ b/src/features/home/lib/homeLayoutMapper.ts
@@ -138,6 +138,27 @@ function mergeState(
return Object.keys(merged).length > 0 ? merged : undefined;
}
+function persistedChatStateFromItem(
+ item: LayoutItem,
+): Record | undefined {
+ if (typeof item.widgetState !== "object" || item.widgetState === null) {
+ return undefined;
+ }
+
+ const state: Record = {};
+ if (
+ item.widgetState.presentation === "expanded" ||
+ item.widgetState.presentation === "collapsed"
+ ) {
+ state.presentation = item.widgetState.presentation;
+ }
+ const sizeByProfile = readSizeByProfile(item.widgetState);
+ if (sizeByProfile) {
+ state[SIZE_BY_PROFILE_STATE_KEY] = sizeByProfile;
+ }
+ return Object.keys(state).length > 0 ? state : undefined;
+}
+
function persistedClockStateFromItem(
item: LayoutItem,
): Record | undefined {
@@ -283,7 +304,10 @@ function stateForItem(item: LayoutItem): Record | undefined {
case "persona":
return { agentId: item.targetId };
case "session":
- return { sessionId: item.targetId };
+ return mergeState(
+ { sessionId: item.targetId },
+ persistedChatStateFromItem(item),
+ );
case "project":
return { projectId: item.targetId };
case "automation":
@@ -382,8 +406,21 @@ function widgetStateForLayoutItem(
}
case "photo":
return sanitizePhotoState(instance.state);
+ case "session": {
+ const state: Record = {};
+ if (
+ instance.state?.presentation === "expanded" ||
+ instance.state?.presentation === "collapsed"
+ ) {
+ state.presentation = instance.state.presentation;
+ }
+ const sizeByProfile = readSizeByProfile(instance.state);
+ if (sizeByProfile) {
+ state[SIZE_BY_PROFILE_STATE_KEY] = sizeByProfile;
+ }
+ return Object.keys(state).length > 0 ? state : undefined;
+ }
case "persona":
- case "session":
case "project":
case "automation":
case "skill":
diff --git a/src/features/home/ui/HomeComposer.tsx b/src/features/home/ui/HomeComposer.tsx
index 7451d687c..dda4f57a9 100644
--- a/src/features/home/ui/HomeComposer.tsx
+++ b/src/features/home/ui/HomeComposer.tsx
@@ -1,8 +1,8 @@
-import { ChatInput } from "@/features/chat/ui/ChatInput";
import {
- useChatSessionController,
- type WorkspaceNameRequest,
-} from "@/features/chat/hooks/useChatSessionController";
+ ConversationComposerCapability,
+ useConversationComposerBinding,
+} from "@/features/chat/capabilities/ConversationComposerCapability";
+import type { WorkspaceNameRequest } from "@/features/chat/hooks/useChatSessionController";
import type { HomeScreenProps } from "./HomeScreen";
interface HomeComposerProps {
@@ -20,116 +20,23 @@ export function HomeComposer({
onWorkspaceNameRequest,
onCreateProject,
}: HomeComposerProps) {
- const controller = useChatSessionController({
- sessionId,
- isHomeSession: true,
+ const binding = useConversationComposerBinding({
+ target: { kind: "pendingConversation", sessionId },
onMessageAccepted: onActivateSession,
onCreatePersonaRequested: onCreatePersona,
onWorkspaceNameRequest,
});
- const deferredWorkspaceInFlight =
- controller.deferredWorkspaceRecord?.state.status === "naming" ||
- controller.deferredWorkspaceRecord?.state.status === "creating";
- const visibleQueuedRecords = (controller.queue.queuedRecords ?? []).filter(
- (record) => !(record.kind === "deferred" && deferredWorkspaceInFlight),
- );
-
return (
- ({
- recordId: record.recordId,
- payload: record.payload,
- })),
- onUpdateQueue: deferredWorkspaceInFlight
- ? undefined
- : controller.queue.update,
- onEditQueue: deferredWorkspaceInFlight
- ? undefined
- : controller.queue.beginEditing,
- onCancelQueueEdit: deferredWorkspaceInFlight
- ? undefined
- : controller.queue.cancelEditing,
- onSendQueue:
- !controller.unresolvedDeferredSend &&
- (controller.deferredWorkspaceRecord?.state.status === "failed" ||
- controller.deferredWorkspaceRecord?.state.status === "held")
- ? controller.sendDeferredAnyway
- : undefined,
- onDismissQueue: deferredWorkspaceInFlight
- ? undefined
- : controller.queue.dismiss,
- onStop: controller.stopStreaming,
- isStreaming:
- controller.chatState === "streaming" ||
- controller.chatState === "thinking",
- }}
- queuedMessageAccessory={
- controller.unresolvedDeferredSend ? (
-
- {controller.deferredWorkspaceError}
-
- ) : undefined
- }
- 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,
- }}
- 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,
+
);
}
diff --git a/src/features/home/ui/HomeView.tsx b/src/features/home/ui/HomeView.tsx
index 9dcedb574..1651ffa91 100644
--- a/src/features/home/ui/HomeView.tsx
+++ b/src/features/home/ui/HomeView.tsx
@@ -1,5 +1,6 @@
import { Crosshair, LayoutGrid } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import type { WorkspaceNameRequest } from "@/features/chat/hooks/useChatSessionController";
import { useTranslation } from "react-i18next";
import { prefetchProjectArtifactRenderer } from "@/features/projects/artifact/prefetchProjectArtifactRenderer";
import { OnboardingTourDialog } from "@/features/onboarding/ui/OnboardingTourDialog";
@@ -66,6 +67,7 @@ export interface HomeViewProps {
onOpenAutomation?: (automationId: string) => void;
onCreatePersona?: () => void;
onCreateProject?: () => void;
+ onWorkspaceNameRequest?: (request: WorkspaceNameRequest) => void;
onOpenSkills?: () => void;
onOpenAutomations?: () => void;
onResolveBerdyAgent?: () => Promise;
@@ -85,6 +87,7 @@ export function HomeView({
onOpenAutomation,
onCreatePersona,
onCreateProject,
+ onWorkspaceNameRequest,
onOpenSkills,
onOpenAutomations,
onResolveBerdyAgent,
@@ -408,7 +411,6 @@ export function HomeView({
const sessionIds = pinnedChatSessionIdKey
? pinnedChatSessionIdKey.split("\u001f")
: [];
-
if (sessionIds.length > 0) {
onHydratePinnedChatSessions(sessionIds);
}
@@ -514,6 +516,7 @@ export function HomeView({
onOpenAutomation={onOpenAutomation}
onCreatePersona={onCreatePersona}
onCreateProject={onCreateProject}
+ onWorkspaceNameRequest={onWorkspaceNameRequest}
onOpenSkills={onOpenSkills}
onOpenAutomations={onOpenAutomations}
onStartOnboardingTour={handleStartTour}
diff --git a/src/features/home/ui/WidgetCanvas.chatLifecycle.test.tsx b/src/features/home/ui/WidgetCanvas.chatLifecycle.test.tsx
new file mode 100644
index 000000000..82f882ef7
--- /dev/null
+++ b/src/features/home/ui/WidgetCanvas.chatLifecycle.test.tsx
@@ -0,0 +1,319 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import {
+ useEffect,
+ useState,
+ type ComponentProps,
+ type ReactNode,
+} from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore";
+import type { Message } from "@/shared/types/messages";
+import {
+ homeWidgetsToLayoutItems,
+ layoutItemsToHomeWidgets,
+} from "../lib/homeLayoutMapper";
+import type { WidgetInstance, WidgetMutationHandlers } from "../widgets/types";
+import { HOME_WIDGET_NODE_ATTR, WidgetCanvas } from "./WidgetCanvas";
+
+const mocks = vi.hoisted(() => ({
+ transcriptMounts: 0,
+ transcriptUnmounts: 0,
+ composerMounts: 0,
+ composerUnmounts: 0,
+ retainedTranscripts: [] as string[],
+ camera: { centerX: 0, centerY: 0, zoomBps: 10_000 },
+ constraints: {
+ minCenter: -10_000,
+ maxCenter: 10_000,
+ minSize: 1,
+ maxSize: 10_000,
+ minZoomBps: 1_000,
+ maxZoomBps: 20_000,
+ maxTitleOverrideLength: 120,
+ maxItems: 100,
+ },
+ saveCamera: vi.fn(),
+}));
+
+const messages: Message[] = [
+ {
+ id: "user-1",
+ role: "user",
+ created: Date.UTC(2026, 7, 20, 12, 0, 0),
+ content: [{ type: "text", text: "Keep this transcript mounted" }],
+ metadata: { userVisible: true },
+ },
+];
+
+vi.mock("@/features/experiments/experimentPreferences", () => ({
+ useExperiment: () => ({ enabled: true }),
+}));
+
+vi.mock("@/features/chat/hooks/useChatTranscriptReadModel", () => ({
+ useChatTranscriptReadModel: () => ({
+ messages,
+ isLoadingHistory: false,
+ selectedPersona: undefined,
+ sessionArtifactCwd: undefined,
+ runtime: { chatState: "idle", streamingMessageId: null },
+ }),
+}));
+
+vi.mock("@/features/chat/stores/chatStore", () => {
+ const state = {
+ messagesBySession: {},
+ queuedMessageBySession: {},
+ loadingSessionIds: new Set(),
+ retainMountedTranscript: (sessionId: string) => {
+ mocks.retainedTranscripts.push(sessionId);
+ return () => undefined;
+ },
+ };
+ const useChatStore = (selector: (value: typeof state) => unknown) =>
+ selector(state);
+ useChatStore.getState = () => ({
+ ...state,
+ markSessionRead: vi.fn(),
+ });
+ return { useChatStore };
+});
+
+vi.mock("@/features/chat/ui/ChatTranscriptSurface", async (importOriginal) => {
+ const actual =
+ await importOriginal<
+ typeof import("@/features/chat/ui/ChatTranscriptSurface")
+ >();
+ return {
+ ...actual,
+ ChatTranscriptSurface: (
+ props: ComponentProps,
+ ) => {
+ useEffect(() => {
+ mocks.transcriptMounts += 1;
+ return () => {
+ mocks.transcriptUnmounts += 1;
+ };
+ }, []);
+ return ;
+ },
+ };
+});
+
+vi.mock("@/features/chat/ui/VirtualMessageTimelineGate", () => ({
+ VirtualMessageTimelineGate: ({
+ showPlaceholder,
+ placeholder,
+ footer,
+ }: {
+ showPlaceholder: boolean;
+ placeholder: ReactNode;
+ footer?: ReactNode;
+ }) => (
+ <>
+ {showPlaceholder ? (
+ {placeholder}
+ ) : (
+ Hydrated transcript
+ )}
+ {footer}
+ >
+ ),
+}));
+
+vi.mock("@/features/chat/hooks/ArtifactPolicyContext", () => ({
+ ArtifactPolicyProvider: ({ children }: { children: ReactNode }) => children,
+}));
+
+vi.mock("@/features/chat/capabilities/ConversationComposerCapability", () => ({
+ useConversationComposerBinding: () => ({ binding: true }),
+ ConversationComposerCapability: () => {
+ useEffect(() => {
+ mocks.composerMounts += 1;
+ return () => {
+ mocks.composerUnmounts += 1;
+ };
+ }, []);
+ return ;
+ },
+}));
+
+vi.mock("@/features/projects/stores/projectStore", () => ({
+ useProjectStore: (selector: (state: { projects: never[] }) => unknown) =>
+ selector({ projects: [] }),
+}));
+
+vi.mock("@/shared/i18n", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useLocaleFormatting: () => ({ formatRelativeTimeToNow: () => "just now" }),
+ };
+});
+
+vi.mock("../stores/homeWidgetStore", () => ({
+ useHomeWidgetStore: (selector: (state: Record) => unknown) =>
+ selector({
+ camera: mocks.camera,
+ constraints: mocks.constraints,
+ saveCamera: mocks.saveCamera,
+ }),
+}));
+
+vi.mock("@/shared/profile/capabilities", () => ({
+ useProfileCapability: () => true,
+}));
+
+function expandedChat(): WidgetInstance {
+ return {
+ id: "expanded-chat",
+ type: "chatPin",
+ x: 20,
+ y: 20,
+ z: 1,
+ width: 480,
+ height: 560,
+ state: { sessionId: "canvas-session", presentation: "expanded" },
+ };
+}
+
+function CanvasHarness() {
+ const [instances, setInstances] = useState([expandedChat()]);
+ const mutatePosition = (id: string, x: number, y: number) => {
+ setInstances((current) => {
+ const moved = current.map((instance) =>
+ instance.id === id ? { ...instance, x, y } : instance,
+ );
+ // Exercise the same persistence serialization adopted after a confirmed save.
+ return layoutItemsToHomeWidgets(homeWidgetsToLayoutItems(moved));
+ });
+ };
+ const mutations: WidgetMutationHandlers = {
+ addWidget: vi.fn(),
+ moveWidget: (id, x, y) => mutatePosition(id, x, y),
+ resizeWidget: vi.fn(),
+ bumpZ: vi.fn(),
+ removeWidget: vi.fn(),
+ updateWidgetState: vi.fn(),
+ };
+ return ;
+}
+
+function TestProviders({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+describe("expanded chat canvas drag lifecycle", () => {
+ beforeEach(() => {
+ mocks.transcriptMounts = 0;
+ mocks.transcriptUnmounts = 0;
+ mocks.composerMounts = 0;
+ mocks.composerUnmounts = 0;
+ mocks.retainedTranscripts = [];
+ useChatSessionStore.setState({
+ sessions: [
+ {
+ id: "canvas-session",
+ title: "Canvas session",
+ createdAt: "2026-08-20T00:00:00.000Z",
+ updatedAt: "2026-08-20T00:00:00.000Z",
+ messageCount: 1,
+ },
+ ],
+ activeSessionId: null,
+ isLoading: false,
+ isLoadingMoreSessions: false,
+ hasHydratedSessions: true,
+ sessionPageCursor: null,
+ hasMoreSessions: false,
+ isRightRailOpen: false,
+ activeWorkspaceBySession: {},
+ });
+ vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({
+ x: 0,
+ y: 0,
+ top: 0,
+ left: 0,
+ right: 800,
+ bottom: 600,
+ width: 800,
+ height: 600,
+ toJSON: () => ({}),
+ });
+ HTMLElement.prototype.setPointerCapture = vi.fn();
+ HTMLElement.prototype.releasePointerCapture = vi.fn();
+ });
+
+ it("keeps transcript, composer, hydration, and availability ownership stable through drag movement and persistence", async () => {
+ const user = userEvent.setup();
+ const { container } = render(, { wrapper: TestProviders });
+
+ await waitFor(() =>
+ expect(screen.getByTestId("hydrated-transcript")).toBeInTheDocument(),
+ );
+ const transcript = screen.getByTestId("hydrated-transcript");
+ const composer = screen.getByTestId("canvas-card-composer");
+ const activationRegion = container.querySelector(
+ "[data-canvas-chat-activation='transcript']",
+ ) as HTMLElement;
+ fireEvent.click(activationRegion);
+ expect(
+ screen.getByRole("region", { name: "Canvas session" }),
+ ).toHaveAttribute("data-canvas-chat-focused", "true");
+
+ const widgetNode = container.querySelector(
+ `[${HOME_WIDGET_NODE_ATTR}]`,
+ ) as HTMLElement;
+ const canvas = container.querySelector(
+ "[data-home-widget-canvas]",
+ ) as HTMLElement;
+
+ await user.pointer([
+ {
+ keys: "[MouseLeft>]",
+ target: widgetNode,
+ coords: { clientX: 30, clientY: 30 },
+ },
+ {
+ target: canvas,
+ coords: { clientX: 1_030, clientY: 30 },
+ },
+ ]);
+ // Cross fully outside the viewport, then return before ending the drag.
+ expect(
+ screen.getByRole("region", { name: "Canvas session" }),
+ ).toHaveAttribute("data-canvas-chat-focused", "true");
+ await user.pointer([
+ { target: canvas, coords: { clientX: 60, clientY: 60 } },
+ {
+ keys: "[/MouseLeft]",
+ target: canvas,
+ coords: { clientX: 60, clientY: 60 },
+ },
+ ]);
+
+ await act(async () => undefined);
+ expect(screen.getByTestId("hydrated-transcript")).toBe(transcript);
+ expect(screen.getByTestId("canvas-card-composer")).toBe(composer);
+ expect(screen.queryByTestId("transcript-placeholder")).toBeNull();
+ expect(mocks.transcriptMounts).toBe(1);
+ expect(mocks.transcriptUnmounts).toBe(0);
+ expect(mocks.composerMounts).toBe(1);
+ expect(mocks.composerUnmounts).toBe(0);
+ expect(mocks.retainedTranscripts).toEqual(["canvas-session"]);
+ expect(
+ screen.getByRole("region", { name: "Canvas session" }),
+ ).toHaveAttribute("data-canvas-chat-focused", "true");
+ });
+});
diff --git a/src/features/home/ui/WidgetCanvas.test.tsx b/src/features/home/ui/WidgetCanvas.test.tsx
index 7da08660b..4afe8faae 100644
--- a/src/features/home/ui/WidgetCanvas.test.tsx
+++ b/src/features/home/ui/WidgetCanvas.test.tsx
@@ -24,6 +24,10 @@ import {
isStarterHomeLayoutEligible,
markStarterHomeLayoutEligible,
} from "@/features/home/onboarding/starterHomeLayout";
+import {
+ consumeFreshWidgetPlacement,
+ markFreshWidgetPlacement,
+} from "../lib/freshWidgetPlacements";
const HOME_WIDGET_NODE_SELECTOR = `[${HOME_WIDGET_NODE_ATTR}]`;
@@ -142,6 +146,47 @@ vi.mock("@/features/chat/stores/chatStore", () => ({
selector({ messagesBySession: mocks.messagesBySession }),
}));
+vi.mock("@/features/experiments/experimentPreferences", () => ({
+ useExperiment: () => ({ enabled: true }),
+}));
+
+vi.mock("../widgets/ChatCanvasCard", () => ({
+ ChatCanvasCard: ({
+ session,
+ isFocused,
+ onFocus,
+ shouldIgnoreActivation = () => false,
+ }: {
+ session: { title: string };
+ isFocused: boolean;
+ onFocus?: () => void;
+ shouldIgnoreActivation?: () => boolean;
+ }) => (
+
+
+
+
+
+
+ ),
+}));
+
vi.mock("@/features/automations/api/kgooseAutomations", () => ({
getAutomationTiles: mocks.getAutomationTiles,
getAutomationTile: mocks.getAutomationTile,
@@ -371,6 +416,149 @@ describe("WidgetCanvas", () => {
setDevicePixelRatio(1);
});
+ it("settles a freshly placed expanded chat like every other widget", () => {
+ const chat = chatWidget({
+ id: "fresh-expanded-chat",
+ state: { sessionId: "session-1", presentation: "expanded" },
+ });
+ markFreshWidgetPlacement(chat.id);
+
+ const { container } = renderCanvas({ instances: [chat] });
+ const frame = container.querySelector(
+ `[data-home-widget-id="${chat.id}"] fieldset`,
+ );
+
+ expect(frame).toHaveClass("animate-widget-settle");
+ consumeFreshWidgetPlacement(chat.id);
+ });
+
+ it("renders composers for multiple expanded chats independently of focus", () => {
+ const first = chatWidget({
+ id: "first-chat",
+ state: { sessionId: "session-1", presentation: "expanded" },
+ });
+ const second = chatWidget({
+ id: "second-chat",
+ state: { sessionId: "session-blank-title", presentation: "expanded" },
+ });
+
+ renderCanvas({ instances: [first, second] });
+
+ expect(screen.getAllByRole("textbox")).toHaveLength(2);
+ expect(screen.getByTestId("composer-First chat")).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Focus First chat" }),
+ ).toHaveAttribute("data-focused", "false");
+ expect(screen.getByRole("button", { name: /Focus\s*$/ })).toHaveAttribute(
+ "data-focused",
+ "false",
+ );
+ });
+
+ it("gives sole ephemeral focus to the deliberately clicked expanded chat", async () => {
+ const user = userEvent.setup();
+ const first = chatWidget({
+ id: "first-chat",
+ state: { sessionId: "session-1", presentation: "expanded" },
+ });
+ const second = chatWidget({
+ id: "second-chat",
+ state: { sessionId: "session-blank-title", presentation: "expanded" },
+ });
+ const { rerender } = renderCanvas({ instances: [first, second] });
+
+ const firstCard = screen.getByRole("button", { name: "Focus First chat" });
+ const secondCard = screen.getByRole("button", { name: /Focus\s*$/ });
+ expect(firstCard).toHaveAttribute("data-focused", "false");
+ expect(secondCard).toHaveAttribute("data-focused", "false");
+
+ await user.click(firstCard);
+ expect(firstCard).toHaveAttribute("data-focused", "true");
+ expect(secondCard).toHaveAttribute("data-focused", "false");
+
+ await user.click(secondCard);
+ expect(firstCard).toHaveAttribute("data-focused", "false");
+ expect(secondCard).toHaveAttribute("data-focused", "true");
+
+ rerender(
+
+
+ ,
+ );
+ expect(firstCard).toHaveAttribute("data-focused", "false");
+ });
+
+ it("clears chat focus when the canvas background or another widget takes ownership", async () => {
+ const user = userEvent.setup();
+ const chat = chatWidget({
+ state: { sessionId: "session-1", presentation: "expanded" },
+ });
+ const { container } = renderCanvas({ instances: [chat, agentWidget()] });
+ const chatCard = screen.getByRole("button", { name: "Focus First chat" });
+
+ await user.click(chatCard);
+ expect(chatCard).toHaveAttribute("data-focused", "true");
+
+ await user.pointer({
+ keys: "[MouseLeft]",
+ target: container.firstElementChild as Element,
+ coords: { clientX: 700, clientY: 500 },
+ });
+ expect(chatCard).toHaveAttribute("data-focused", "false");
+
+ await user.click(chatCard);
+ fireEvent.pointerDown(screen.getByRole("button", { name: /agent one/i }), {
+ button: 0,
+ pointerId: 2,
+ });
+ await waitFor(() =>
+ expect(chatCard).toHaveAttribute("data-focused", "false"),
+ );
+ });
+
+ it("requires fresh activation after a focused card is temporarily unavailable and remounts", async () => {
+ const user = userEvent.setup();
+ const chat = chatWidget({
+ state: { sessionId: "session-1", presentation: "expanded" },
+ });
+ const { rerender } = renderCanvas({ instances: [chat] });
+ const card = screen.getByRole("button", { name: "Focus First chat" });
+
+ await user.click(card);
+ expect(card).toHaveAttribute("data-focused", "true");
+
+ const temporarilyUnavailable = {
+ ...chat,
+ state: { ...chat.state, presentation: "collapsed" },
+ };
+ rerender(
+
+
+ ,
+ );
+ expect(
+ screen.queryByRole("button", { name: "Focus First chat" }),
+ ).toBeNull();
+
+ rerender(
+
+
+ ,
+ );
+ const remountedCard = screen.getByRole("button", {
+ name: "Focus First chat",
+ });
+ await waitFor(() =>
+ expect(remountedCard).toHaveAttribute("data-focused", "false"),
+ );
+
+ await user.click(remountedCard);
+ expect(remountedCard).toHaveAttribute("data-focused", "true");
+ });
+
it("renders widgets directly at snapped screen positions", () => {
mocks.homeWidgetState.camera = {
centerX: -10.25,
@@ -447,6 +635,119 @@ describe("WidgetCanvas", () => {
expect(widgetContent.style.height).toBe("240px");
});
+ it.each([
+ [5_000, "240px", "280px", "scale(0.5)"],
+ [7_500, "360px", "420px", "scale(0.75)"],
+ ])("uses the standard widget transform path for expanded chats at %i zoom bps", (zoomBps, expectedWidth, expectedHeight, expectedTransform) => {
+ mocks.homeWidgetState.camera = { centerX: 0, centerY: 0, zoomBps };
+
+ const { container } = renderCanvas({
+ instances: [
+ chatWidget({
+ width: 480,
+ height: 560,
+ state: { sessionId: "session-1", presentation: "expanded" },
+ }),
+ ],
+ });
+ const widgetNode = container.querySelector(
+ HOME_WIDGET_NODE_SELECTOR,
+ ) as HTMLElement;
+ const widgetContent = widgetNode.firstElementChild as HTMLElement;
+
+ expect(widgetNode.style.width).toBe(expectedWidth);
+ expect(widgetNode.style.height).toBe(expectedHeight);
+ expect(
+ widgetNode.style.getPropertyValue("--canvas-presentation-scale"),
+ ).toBe("");
+ expect(widgetContent.style.width).toBe("480px");
+ expect(widgetContent.style.height).toBe("560px");
+ expect(widgetContent.style.transform).toBe(expectedTransform);
+ expect(widgetContent.style.transformOrigin).toBe("top left");
+ });
+
+ it("keeps expanded chat drag persistence in world coordinates", async () => {
+ const user = userEvent.setup();
+ const moveWidget = vi.fn();
+ mocks.homeWidgetState.constraints = CANVAS_CONSTRAINTS;
+ mocks.homeWidgetState.camera = {
+ centerX: 0,
+ centerY: 0,
+ zoomBps: 7_500,
+ };
+ const chat = chatWidget({
+ x: 20,
+ y: 30,
+ width: 480,
+ height: 560,
+ state: { sessionId: "session-1", presentation: "expanded" },
+ });
+ const { container } = renderCanvas({
+ instances: [chat],
+ mutations: { moveWidget },
+ });
+ const canvas = container.firstElementChild as Element;
+ const widgetNode = container.querySelector(
+ HOME_WIDGET_NODE_SELECTOR,
+ ) as HTMLElement;
+ const dragHandle = widgetNode.querySelector(
+ "[data-home-widget-drag-handle='true']",
+ ) as HTMLElement;
+
+ await user.pointer([
+ {
+ keys: "[MouseLeft>]",
+ target: dragHandle,
+ coords: { clientX: 30, clientY: 40 },
+ },
+ { target: canvas, coords: { clientX: 60, clientY: 85 } },
+ {
+ keys: "[/MouseLeft]",
+ target: canvas,
+ coords: { clientX: 60, clientY: 85 },
+ },
+ ]);
+
+ expect(moveWidget).toHaveBeenCalledWith(
+ "chat-widget",
+ 60,
+ 90,
+ CANVAS_CONSTRAINTS,
+ { bringToFront: true },
+ );
+ });
+
+ it("does not drag an expanded chat from its transcript", async () => {
+ const user = userEvent.setup();
+ const moveWidget = vi.fn();
+ mocks.homeWidgetState.constraints = CANVAS_CONSTRAINTS;
+ const chat = chatWidget({
+ state: { sessionId: "session-1", presentation: "expanded" },
+ });
+ const { container } = renderCanvas({
+ instances: [chat],
+ mutations: { moveWidget },
+ });
+ const canvas = container.firstElementChild as Element;
+ const transcript = screen.getByTestId("transcript-First chat");
+
+ await user.pointer([
+ {
+ keys: "[MouseLeft>]",
+ target: transcript,
+ coords: { clientX: 30, clientY: 40 },
+ },
+ { target: canvas, coords: { clientX: 80, clientY: 100 } },
+ {
+ keys: "[/MouseLeft]",
+ target: canvas,
+ coords: { clientX: 80, clientY: 100 },
+ },
+ ]);
+
+ expect(moveWidget).not.toHaveBeenCalled();
+ });
+
it("lays out non-aspect resize previews at preview dimensions so text is not stretched", async () => {
const user = userEvent.setup();
mocks.homeWidgetState.constraints = CANVAS_CONSTRAINTS;
@@ -1169,6 +1470,31 @@ describe("WidgetCanvas", () => {
});
});
+ it("leaves interactive transcript and composer wheel gestures to the chat", () => {
+ vi.useFakeTimers();
+ const chat = chatWidget({
+ state: { sessionId: "session-1", presentation: "expanded" },
+ });
+ const { container } = renderCanvas({ instances: [chat] });
+ const canvas = container.firstElementChild as HTMLElement;
+ const transcript = screen.getByTestId("transcript-First chat");
+ const composer = screen.getByTestId("composer-First chat");
+
+ expect(transcript).toHaveClass("overflow-y-auto");
+ fireEvent.wheel(transcript, { deltaY: 30 });
+ transcript.scrollTop = 30;
+ fireEvent.scroll(transcript);
+ fireEvent.wheel(composer, { deltaY: 30 });
+ vi.advanceTimersByTime(150);
+
+ expect(transcript.scrollTop).toBe(30);
+ expect(mocks.saveCamera).not.toHaveBeenCalled();
+
+ fireEvent.wheel(canvas, { deltaY: 30 });
+ vi.advanceTimersByTime(150);
+ expect(mocks.saveCamera).toHaveBeenCalledTimes(1);
+ });
+
it("saves the camera after two-finger wheel pan settles", () => {
vi.useFakeTimers();
const { container } = renderCanvas();
diff --git a/src/features/home/ui/WidgetCanvas.tsx b/src/features/home/ui/WidgetCanvas.tsx
index 6f0e0fcf8..1d0f3c7f4 100644
--- a/src/features/home/ui/WidgetCanvas.tsx
+++ b/src/features/home/ui/WidgetCanvas.tsx
@@ -27,6 +27,7 @@ import {
widgetSizeForInstance,
widgetSizeProfile,
} from "../widgets/catalog";
+import type { WorkspaceNameRequest } from "@/features/chat/hooks/useChatSessionController";
import type {
WidgetInstance,
WidgetMutationHandlers,
@@ -49,6 +50,10 @@ import { useWidgetDragSuppression } from "./useWidgetDragSuppression";
*/
export const HOME_WIDGET_NODE_ATTR = "data-home-widget-node";
const HOME_WIDGET_NODE_SELECTOR = `[${HOME_WIDGET_NODE_ATTR}]`;
+const HOME_CANVAS_INTERACTIVE_SELECTOR =
+ "[data-home-canvas-interactive='true']";
+const HOME_WIDGET_DRAG_HANDLE_SELECTOR =
+ "[data-home-widget-drag-handle='true']";
interface WidgetCanvasProps extends WidgetNavigationHandlers {
instances: WidgetInstance[];
@@ -62,6 +67,7 @@ interface WidgetCanvasProps extends WidgetNavigationHandlers {
viewportLeftOcclusionPx?: number;
onCreatePersona?: () => void;
onCreateProject?: () => void;
+ onWorkspaceNameRequest?: (request: WorkspaceNameRequest) => void;
starterTasksAvailable?: boolean;
onRestoreStarterTasks?: () => void;
}
@@ -280,6 +286,7 @@ export function WidgetCanvas({
onOpenAutomation,
onCreatePersona,
onCreateProject,
+ onWorkspaceNameRequest,
onOpenSkills,
onOpenAutomations,
onStartOnboardingTour,
@@ -305,6 +312,12 @@ export function WidgetCanvas({
const [visuallyLiftedZ, setVisuallyLiftedZ] = useState<
Record
>({});
+ // Canvas chat focus is deliberately ephemeral. It is separate from route
+ // selection and widget persistence so restoring visible cards never creates
+ // a typing target or clears unread state.
+ const [focusedCanvasChatId, setFocusedCanvasChatId] = useState(
+ null,
+ );
const [picker, setPicker] = useState({
open: false,
x: 0,
@@ -488,23 +501,62 @@ export function WidgetCanvas({
const handleCanvasPointerDown = useCallback(
(event: React.PointerEvent) => {
- if (
- event.button !== 0 ||
- (event.target as HTMLElement).closest(HOME_WIDGET_NODE_SELECTOR)
- ) {
+ if (event.button !== 0) {
+ return;
+ }
+ if ((event.target as HTMLElement).closest(HOME_WIDGET_NODE_SELECTOR)) {
return;
}
+ setFocusedCanvasChatId(null);
beginPan(event);
},
[beginPan],
);
+ const handleCanvasChatAvailabilityChange = useCallback(
+ (widgetId: string, _available: boolean) => {
+ // Any mount/availability boundary invalidates the old gesture grant.
+ // A newly renderable card must be deliberately activated again.
+ setFocusedCanvasChatId((current) =>
+ current === widgetId ? null : current,
+ );
+ },
+ [],
+ );
+
const preventNativeDrag = useCallback((event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
}, []);
+ const handleCanvasWheel = useCallback(
+ (event: React.WheelEvent) => {
+ if (
+ (event.target as HTMLElement).closest(HOME_CANVAS_INTERACTIVE_SELECTOR)
+ ) {
+ return;
+ }
+
+ handleWheel(event);
+ },
+ [handleWheel],
+ );
+
+ useEffect(() => {
+ if (
+ focusedCanvasChatId &&
+ !instances.some(
+ (instance) =>
+ instance.id === focusedCanvasChatId &&
+ instance.type === "chatPin" &&
+ instance.state?.presentation === "expanded",
+ )
+ ) {
+ setFocusedCanvasChatId(null);
+ }
+ }, [focusedCanvasChatId, instances]);
+
const renderedInstances = useMemo(
() =>
instances.filter(
@@ -574,12 +626,21 @@ export function WidgetCanvas({
ref={canvasRef}
data-home-widget-canvas="true"
onContextMenu={handleCanvasContextMenu}
+ onPointerDownCapture={(event) => {
+ if (event.button !== 0) return;
+ const owner = (event.target as HTMLElement).closest(
+ HOME_WIDGET_NODE_SELECTOR,
+ );
+ if (!owner || owner.dataset.homeWidgetId !== focusedCanvasChatId) {
+ setFocusedCanvasChatId(null);
+ }
+ }}
onPointerDown={handleCanvasPointerDown}
onPointerMove={handlePointerMove}
onPointerUp={finishPointerGesture}
onPointerCancel={finishPointerGesture}
onDragStartCapture={preventNativeDrag}
- onWheel={handleWheel}
+ onWheel={handleCanvasWheel}
className="relative h-full w-full overflow-hidden bg-dot-grid select-none touch-none"
>
@@ -654,8 +715,25 @@ export function WidgetCanvas({
beginWidgetDrag(event, instance)}
+ onPointerDown={(event) => {
+ if (instance.id !== focusedCanvasChatId) {
+ setFocusedCanvasChatId(null);
+ }
+ const expandedCanvasChat =
+ instance.type === "chatPin" &&
+ instance.state?.presentation === "expanded";
+ if (
+ expandedCanvasChat &&
+ !(event.target as HTMLElement).closest(
+ HOME_WIDGET_DRAG_HANDLE_SELECTOR,
+ )
+ ) {
+ return;
+ }
+ beginWidgetDrag(event, instance);
+ }}
onDragStart={preventNativeDrag}
style={widgetStyle}
className={cn(
@@ -695,6 +773,16 @@ export function WidgetCanvas({
canvasDragPosition={dragPositions[instance.id]}
widgetResizePreviewActive={isResizePreview}
renderPaused={!widgetInViewport}
+ isCanvasChatFocused={focusedCanvasChatId === instance.id}
+ onFocusCanvasChat={() => setFocusedCanvasChatId(instance.id)}
+ onClearCanvasChatFocus={() =>
+ setFocusedCanvasChatId((current) =>
+ current === instance.id ? null : current,
+ )
+ }
+ onCanvasChatAvailabilityChange={
+ handleCanvasChatAvailabilityChange
+ }
currentMaxZ={currentMaxZ}
mutations={mutations}
shouldIgnoreActivation={
@@ -713,6 +801,7 @@ export function WidgetCanvas({
onOpenAutomation={onOpenAutomation}
onCreatePersona={onCreatePersona}
onCreateProject={onCreateProject}
+ onWorkspaceNameRequest={onWorkspaceNameRequest}
onOpenSkills={onOpenSkills}
onOpenAutomations={onOpenAutomations}
onStartOnboardingTour={onStartOnboardingTour}
diff --git a/src/features/home/ui/WidgetFrame.tsx b/src/features/home/ui/WidgetFrame.tsx
index 4925de834..5150896ac 100644
--- a/src/features/home/ui/WidgetFrame.tsx
+++ b/src/features/home/ui/WidgetFrame.tsx
@@ -8,6 +8,7 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import type { LayoutConstraints } from "@/features/layout/api/layout";
+import type { WorkspaceNameRequest } from "@/features/chat/hooks/useChatSessionController";
import { cn } from "@/shared/lib/cn";
import {
consumeFreshWidgetPlacement,
@@ -33,6 +34,14 @@ interface WidgetFrameProps extends WidgetNavigationHandlers {
canvasDragPosition?: { x: number; y: number };
widgetResizePreviewActive?: boolean;
renderPaused?: boolean;
+ isCanvasChatFocused?: boolean;
+ onFocusCanvasChat?: () => void;
+ onClearCanvasChatFocus?: () => void;
+ onCanvasChatAvailabilityChange?: (
+ widgetId: string,
+ available: boolean,
+ ) => void;
+ onWorkspaceNameRequest?: (request: WorkspaceNameRequest) => void;
shouldIgnoreActivation?: () => boolean;
gestureHandlers?: Partial
;
onVisualLiftReset?: (id: string) => void;
@@ -63,6 +72,10 @@ export function WidgetFrame({
canvasDragPosition,
widgetResizePreviewActive = false,
renderPaused = false,
+ isCanvasChatFocused = false,
+ onFocusCanvasChat,
+ onClearCanvasChatFocus,
+ onCanvasChatAvailabilityChange,
shouldIgnoreActivation = () => false,
gestureHandlers = {},
onVisualLiftReset = () => {},
@@ -77,6 +90,7 @@ export function WidgetFrame({
onOpenAutomation,
onCreatePersona,
onCreateProject,
+ onWorkspaceNameRequest,
onOpenSkills,
onOpenAutomations,
onStartOnboardingTour,
@@ -221,6 +235,11 @@ export function WidgetFrame({
onOpenAutomation={onOpenAutomation}
onCreatePersona={onCreatePersona}
onCreateProject={onCreateProject}
+ onWorkspaceNameRequest={onWorkspaceNameRequest}
+ isCanvasChatFocused={isCanvasChatFocused}
+ onFocusCanvasChat={onFocusCanvasChat}
+ onClearCanvasChatFocus={onClearCanvasChatFocus}
+ onCanvasChatAvailabilityChange={onCanvasChatAvailabilityChange}
onOpenSkills={onOpenSkills}
onOpenAutomations={onOpenAutomations}
onStartOnboardingTour={onStartOnboardingTour}
diff --git a/src/features/home/ui/useWidgetDragSuppression.test.tsx b/src/features/home/ui/useWidgetDragSuppression.test.tsx
index 7b429e420..984ddfd89 100644
--- a/src/features/home/ui/useWidgetDragSuppression.test.tsx
+++ b/src/features/home/ui/useWidgetDragSuppression.test.tsx
@@ -70,6 +70,20 @@ describe("useWidgetDragSuppression", () => {
expect(event.defaultPrevented).toBe(true);
});
+ it("suppresses activation after a cancelled pointer sequence", () => {
+ const { result } = renderHook(() => useWidgetDragSuppression());
+
+ act(() => {
+ result.current.frameHandlers.onPointerDownCapture(pointerEvent(10, 10));
+ result.current.frameHandlers.onPointerCancelCapture(pointerEvent(10, 10));
+ });
+
+ expect(result.current.shouldIgnoreActivation()).toBe(true);
+ const event = clickEvent();
+ window.dispatchEvent(event);
+ expect(event.defaultPrevented).toBe(true);
+ });
+
it("clears suppression after 600ms", () => {
const { result } = renderSuppressedHook();
expect(result.current.shouldIgnoreActivation()).toBe(true);
diff --git a/src/features/home/ui/useWidgetDragSuppression.ts b/src/features/home/ui/useWidgetDragSuppression.ts
index 0e5ed580d..679839a3e 100644
--- a/src/features/home/ui/useWidgetDragSuppression.ts
+++ b/src/features/home/ui/useWidgetDragSuppression.ts
@@ -131,7 +131,8 @@ export function useWidgetDragSuppression() {
const handlePointerCancelCapture = useCallback(() => {
pointerStartRef.current = null;
- }, []);
+ markDragged();
+ }, [markDragged]);
const handleClickCapture = useCallback((event: React.MouseEvent) => {
if (!suppressClickRef.current) {
diff --git a/src/features/home/widgets/ChatCanvasCard.performance.test.tsx b/src/features/home/widgets/ChatCanvasCard.performance.test.tsx
new file mode 100644
index 000000000..8c8eca668
--- /dev/null
+++ b/src/features/home/widgets/ChatCanvasCard.performance.test.tsx
@@ -0,0 +1,132 @@
+import { render, screen, waitFor, within } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import type { Message } from "@/shared/types/messages";
+import { ChatCanvasCard } from "./ChatCanvasCard";
+
+const LONG_HISTORY_EXCHANGE_COUNT = 120;
+
+function longHistory(sessionId: string): Message[] {
+ return Array.from({ length: LONG_HISTORY_EXCHANGE_COUNT }, (_, index) => [
+ {
+ id: `${sessionId}-user-${index + 1}`,
+ role: "user" as const,
+ created: index * 2,
+ content: [
+ { type: "text" as const, text: `${sessionId} question ${index + 1}` },
+ ],
+ metadata: { userVisible: true },
+ },
+ {
+ id: `${sessionId}-assistant-${index + 1}`,
+ role: "assistant" as const,
+ created: index * 2 + 1,
+ content: [
+ {
+ type: "toolRequest" as const,
+ id: `${sessionId}-tool-${index + 1}`,
+ name: "Inspect",
+ arguments: { index },
+ status: "completed" as const,
+ },
+ {
+ type: "toolResponse" as const,
+ id: `${sessionId}-tool-${index + 1}`,
+ name: "Inspect",
+ result: "done",
+ isError: false,
+ },
+ { type: "text" as const, text: `${sessionId} answer ${index + 1}` },
+ ],
+ metadata: { userVisible: true },
+ },
+ ]).flat();
+}
+
+const histories = {
+ "canvas-a": longHistory("canvas-a"),
+ "canvas-b": longHistory("canvas-b"),
+};
+
+vi.mock("@/features/chat/capabilities/ConversationComposerCapability", () => ({
+ useConversationComposerBinding: ({
+ target,
+ }: {
+ target: { sessionId: string };
+ }) => target,
+ ConversationComposerCapability: ({
+ binding,
+ }: {
+ binding: { sessionId: string };
+ }) => ,
+}));
+
+vi.mock("@/features/chat/hooks/useChatTranscriptReadModel", () => ({
+ useChatTranscriptReadModel: (sessionId: keyof typeof histories) => ({
+ messages: histories[sessionId],
+ isLoadingHistory: false,
+ selectedPersona: undefined,
+ sessionArtifactCwd: undefined,
+ runtime: { chatState: "idle", streamingMessageId: null },
+ }),
+}));
+
+function session(id: keyof typeof histories) {
+ return {
+ id,
+ title: id,
+ createdAt: "2026-08-20T00:00:00.000Z",
+ updatedAt: "2026-08-20T00:00:00.000Z",
+ messageCount: histories[id].length,
+ };
+}
+
+describe("ChatCanvasCard multi-card bounded mounting", () => {
+ it("mounts only 10 exchanges per card from two independent long histories", async () => {
+ render(
+
+
+
+
,
+ );
+
+ const cardA = screen.getByRole("region", { name: "canvas-a" });
+ const cardB = screen.getByRole("region", { name: "canvas-b" });
+
+ await waitFor(() => {
+ expect(within(cardA).getByText("canvas-a question 111")).toBeVisible();
+ expect(within(cardB).getByText("canvas-b question 111")).toBeVisible();
+ });
+
+ for (const card of [cardA, cardB]) {
+ const mountedMessageIds = new Set(
+ [...card.querySelectorAll("[data-transcript-message-id]")]
+ .map((node) => node.getAttribute("data-transcript-message-id"))
+ .filter(Boolean),
+ );
+ expect(
+ [...mountedMessageIds].filter((id) => id?.includes("-user-")),
+ ).toHaveLength(10);
+ // Tool-heavy assistant messages may project to several rows, but the
+ // mounted input remains bounded by the 10 selected exchanges.
+ expect(mountedMessageIds.size).toBeLessThanOrEqual(30);
+ expect(
+ within(card).getByText("Earlier messages are available in full chat"),
+ ).toBeVisible();
+ }
+
+ expect(within(cardA).queryByText("canvas-a question 110")).toBeNull();
+ expect(within(cardB).queryByText("canvas-b question 110")).toBeNull();
+ expect(screen.getByLabelText("Message canvas-a")).toBeVisible();
+ expect(screen.getByLabelText("Message canvas-b")).toBeVisible();
+ });
+});
diff --git a/src/features/home/widgets/ChatCanvasCard.test.tsx b/src/features/home/widgets/ChatCanvasCard.test.tsx
new file mode 100644
index 000000000..186732601
--- /dev/null
+++ b/src/features/home/widgets/ChatCanvasCard.test.tsx
@@ -0,0 +1,475 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { useEffect, type ReactNode } from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { useChatStore } from "@/features/chat/stores/chatStore";
+import { useProjectStore } from "@/features/projects/stores/projectStore";
+import type { Message } from "@/shared/types/messages";
+import { ChatCanvasCard } from "./ChatCanvasCard";
+
+const mocks = vi.hoisted(() => ({
+ bind: vi.fn(() => ({ binding: true })),
+ composer: vi.fn(() => (
+
+ )),
+ chatState: "idle" as "idle" | "streaming",
+ messages: [] as Message[],
+ isLoadingHistory: false,
+ transcriptMounts: 0,
+ transcriptUnmounts: 0,
+ transcriptProps: undefined as undefined | Record,
+}));
+
+vi.mock("@/features/chat/capabilities/ConversationComposerCapability", () => ({
+ useConversationComposerBinding: mocks.bind,
+ ConversationComposerCapability: mocks.composer,
+}));
+
+vi.mock("@/features/chat/hooks/useChatTranscriptReadModel", () => ({
+ useChatTranscriptReadModel: () => ({
+ messages: mocks.messages,
+ isLoadingHistory: mocks.isLoadingHistory,
+ selectedPersona: undefined,
+ sessionArtifactCwd: undefined,
+ runtime: { chatState: mocks.chatState, streamingMessageId: null },
+ }),
+}));
+
+vi.mock("@/features/chat/ui/ChatTranscriptSurface", () => ({
+ ChatTranscriptSurface: (props: Record) => {
+ mocks.transcriptProps = props;
+ useEffect(() => {
+ mocks.transcriptMounts += 1;
+ return () => {
+ mocks.transcriptUnmounts += 1;
+ };
+ }, []);
+ return (
+
+ {props.startContent as ReactNode}
+
+ );
+ },
+}));
+
+const session = {
+ id: "canvas-session",
+ title: "Canvas chat",
+ createdAt: "2026-08-20T00:00:00.000Z",
+ updatedAt: "2026-08-20T00:00:00.000Z",
+ messageCount: 1,
+};
+
+function renderCard(
+ isFocused: boolean,
+ onFocus = vi.fn(),
+ onOpenFullChat = vi.fn(),
+) {
+ return render(
+ ,
+ );
+}
+
+describe("ChatCanvasCard focus", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.chatState = "idle";
+ mocks.messages = [];
+ mocks.isLoadingHistory = false;
+ mocks.transcriptMounts = 0;
+ mocks.transcriptUnmounts = 0;
+ mocks.transcriptProps = undefined;
+ useChatStore.setState({ sessionStateById: {} });
+ useProjectStore.setState({ projects: [] });
+ });
+
+ it("keeps focus behavioral without adding a visible outline", () => {
+ renderCard(true);
+
+ const card = screen.getByRole("region", { name: "Canvas chat" });
+ expect(card).toHaveAttribute("data-canvas-chat-focused", "true");
+ expect(card).toHaveClass("cursor-default");
+ expect(card).not.toHaveClass("ring-2", "ring-ring", "ring-inset");
+ expect(
+ card.querySelector("[data-home-widget-drag-handle='true']"),
+ ).toHaveClass("cursor-grab", "active:cursor-grabbing");
+ expect(screen.getByTestId("transcript").parentElement).toHaveClass(
+ "cursor-text",
+ );
+ });
+
+ it("keeps the mounted transcript while history and live messages update", () => {
+ const { rerender } = renderCard(false);
+ const transcript = screen.getByTestId("transcript");
+
+ mocks.messages = [
+ {
+ id: "hydrated-message",
+ role: "user",
+ created: 1,
+ content: [{ type: "text", text: "Hydrated" }],
+ },
+ ];
+ mocks.isLoadingHistory = true;
+ rerender(
+ ,
+ );
+
+ expect(screen.getByTestId("transcript")).toBe(transcript);
+ expect(mocks.transcriptMounts).toBe(1);
+ expect(mocks.transcriptUnmounts).toBe(0);
+ });
+
+ it("keeps compact chat layout variables local without a density cascade", () => {
+ renderCard(false);
+ const card = screen.getByRole("region", { name: "Canvas chat" });
+
+ expect(card).not.toHaveClass("canvas-chat-density");
+ expect(card).not.toHaveAttribute("data-canvas-chat-density");
+ expect(card).toHaveClass(
+ "[--chat-transcript-inline-padding:0.75rem]",
+ "[--chat-transcript-max-width:100%]",
+ "[--chat-transcript-container-max-width:100%]",
+ "[--chat-user-message-max-width:85%]",
+ "[--chat-composer-max-width:100%]",
+ );
+ });
+
+ it("keeps the transcript footerless and owns one always-visible composer sibling", () => {
+ renderCard(false);
+
+ const transcript = screen.getByTestId("transcript");
+ const transcriptRegion = transcript.parentElement;
+ const composerSurface = screen.getByTestId("canvas-composer").parentElement;
+
+ expect(mocks.transcriptProps).not.toHaveProperty("footer");
+ expect(mocks.transcriptProps).toMatchObject({
+ rendererPolicy: "classic",
+ });
+ expect(
+ screen.queryByTestId("message-timeline-footer"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId("message-timeline-surface"),
+ ).not.toBeInTheDocument();
+ expect(composerSurface).toBe(transcriptRegion?.nextElementSibling);
+ expect(composerSurface).toHaveClass("shrink-0");
+ expect(screen.getAllByTestId("canvas-composer")).toHaveLength(1);
+ });
+
+ it("shows the history boundary only when complete older exchanges are omitted", () => {
+ mocks.messages = Array.from({ length: 11 }, (_, index) => [
+ {
+ id: `user-${index + 1}`,
+ role: "user" as const,
+ created: index * 2,
+ content: [{ type: "text" as const, text: `Question ${index + 1}` }],
+ metadata: { userVisible: true },
+ },
+ {
+ id: `assistant-${index + 1}`,
+ role: "assistant" as const,
+ created: index * 2 + 1,
+ content: [{ type: "text" as const, text: `Answer ${index + 1}` }],
+ metadata: { userVisible: true },
+ },
+ ]).flat();
+
+ renderCard(false);
+
+ expect(
+ screen.getByText("Earlier messages are available in full chat"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Open full chat" }),
+ ).toBeVisible();
+ expect(
+ (mocks.transcriptProps?.messages as Message[]).filter(
+ (message) => message.role === "user",
+ ),
+ ).toHaveLength(10);
+ });
+
+ it("uses the same session navigation for the boundary action", () => {
+ const onOpenFullChat = vi.fn();
+ mocks.messages = Array.from({ length: 11 }, (_, index) => [
+ {
+ id: `user-${index + 1}`,
+ role: "user" as const,
+ created: index * 2,
+ content: [{ type: "text" as const, text: `Question ${index + 1}` }],
+ },
+ {
+ id: `assistant-${index + 1}`,
+ role: "assistant" as const,
+ created: index * 2 + 1,
+ content: [{ type: "text" as const, text: `Answer ${index + 1}` }],
+ },
+ ]).flat();
+ renderCard(false, vi.fn(), onOpenFullChat);
+
+ fireEvent.click(screen.getByRole("button", { name: "Open full chat" }));
+
+ expect(onOpenFullChat).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not show a history boundary when all exchanges fit", () => {
+ mocks.messages = [
+ {
+ id: "user-1",
+ role: "user",
+ created: 1,
+ content: [{ type: "text", text: "Question" }],
+ },
+ ];
+
+ renderCard(false);
+
+ expect(
+ screen.queryByText("Earlier messages are available in full chat"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("keeps the unfocused transcript and composer visible without marking read", () => {
+ useChatStore.getState().markSessionUnread(session.id);
+ renderCard(false);
+
+ expect(screen.getByTestId("transcript")).toBeInTheDocument();
+ expect(screen.getByTestId("canvas-composer")).toBeInTheDocument();
+ expect(
+ useChatStore.getState().getSessionRuntime(session.id).hasUnread,
+ ).toBe(true);
+ expect(mocks.bind).toHaveBeenCalledTimes(1);
+ });
+
+ it("commits focus and read only from a classified transcript click", () => {
+ const onFocus = vi.fn();
+ useChatStore.getState().markSessionUnread(session.id);
+ renderCard(false, onFocus);
+ const region = screen.getByRole("region", { name: "Canvas chat" });
+ const transcript = screen.getByTestId("transcript");
+
+ fireEvent.mouseDown(region);
+ fireEvent.pointerDown(transcript);
+ expect(onFocus).not.toHaveBeenCalled();
+ expect(
+ useChatStore.getState().getSessionRuntime(session.id).hasUnread,
+ ).toBe(true);
+
+ fireEvent.click(transcript);
+
+ expect(onFocus).toHaveBeenCalledTimes(1);
+ expect(
+ useChatStore.getState().getSessionRuntime(session.id).hasUnread,
+ ).toBe(false);
+ });
+
+ it("does not focus or mark read after drag classification or cancellation", () => {
+ const onFocus = vi.fn();
+ const shouldIgnoreActivation = vi.fn(() => true);
+ useChatStore.getState().markSessionUnread(session.id);
+ render(
+ ,
+ );
+ const transcript = screen.getByTestId("transcript");
+
+ fireEvent.pointerDown(transcript, {
+ pointerId: 1,
+ clientX: 10,
+ clientY: 10,
+ });
+ fireEvent.pointerMove(transcript, {
+ pointerId: 1,
+ clientX: 40,
+ clientY: 40,
+ });
+ fireEvent.pointerCancel(transcript, { pointerId: 1 });
+ fireEvent.click(transcript);
+
+ expect(shouldIgnoreActivation).toHaveBeenCalledTimes(1);
+ expect(onFocus).not.toHaveBeenCalled();
+ expect(
+ useChatStore.getState().getSessionRuntime(session.id).hasUnread,
+ ).toBe(true);
+ });
+
+ it("collapse and open-full-chat controls do not focus or mark read", () => {
+ const onFocus = vi.fn();
+ const onCollapse = vi.fn();
+ const onOpenFullChat = vi.fn();
+ useChatStore.getState().markSessionUnread(session.id);
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /collapse/i }));
+ fireEvent.click(screen.getByRole("button", { name: /open/i }));
+
+ expect(onCollapse).toHaveBeenCalledTimes(1);
+ expect(onOpenFullChat).toHaveBeenCalledTimes(1);
+ expect(onFocus).not.toHaveBeenCalled();
+ expect(
+ useChatStore.getState().getSessionRuntime(session.id).hasUnread,
+ ).toBe(true);
+ });
+
+ it("keeps the shared existing-session composer mounted across focus changes", () => {
+ const { rerender } = renderCard(true);
+
+ const composer = screen.getByTestId("canvas-composer");
+ expect(composer).toBeInTheDocument();
+ expect(mocks.bind).toHaveBeenCalledWith(
+ expect.objectContaining({
+ target: expect.objectContaining({
+ kind: "existingSession",
+ sessionId: session.id,
+ sessionSnapshot: session,
+ readOnlyWhenOpenInAnotherWindow: true,
+ }),
+ }),
+ );
+ expect(mocks.composer).toHaveBeenCalledWith(
+ expect.objectContaining({
+ renderingPolicy: expect.objectContaining({
+ allowedInteractions: {
+ controls: expect.objectContaining({
+ agentModelPicker: false,
+ personaPicker: false,
+ projectPicker: false,
+ }),
+ },
+ }),
+ }),
+ undefined,
+ );
+
+ rerender(
+ ,
+ );
+ expect(screen.getByTestId("canvas-composer")).toBe(composer);
+ });
+
+ it("focuses and marks read once when the unfocused composer is used by pointer", () => {
+ const onFocus = vi.fn();
+ useChatStore.getState().markSessionUnread(session.id);
+ renderCard(false, onFocus);
+ const composer = screen.getByTestId("canvas-composer");
+
+ expect(onFocus).not.toHaveBeenCalled();
+ expect(
+ useChatStore.getState().getSessionRuntime(session.id).hasUnread,
+ ).toBe(true);
+
+ fireEvent.pointerDown(composer);
+ fireEvent.focus(composer);
+ fireEvent.click(composer);
+
+ expect(onFocus).toHaveBeenCalledTimes(1);
+ expect(
+ useChatStore.getState().getSessionRuntime(session.id).hasUnread,
+ ).toBe(false);
+ });
+
+ it("focuses and marks read when the composer receives keyboard focus", () => {
+ const onFocus = vi.fn();
+ useChatStore.getState().markSessionUnread(session.id);
+ renderCard(false, onFocus);
+
+ fireEvent.focus(screen.getByTestId("canvas-composer"));
+
+ expect(onFocus).toHaveBeenCalledTimes(1);
+ expect(
+ useChatStore.getState().getSessionRuntime(session.id).hasUnread,
+ ).toBe(false);
+ });
+
+ it("keeps transcript and composer interaction surfaces separate without adding a background", () => {
+ renderCard(false);
+
+ expect(screen.getByTestId("transcript")).toHaveClass("overflow-y-auto");
+ expect(screen.getByTestId("transcript").parentElement).toHaveAttribute(
+ "data-home-canvas-interactive",
+ "true",
+ );
+ const composerSurface = screen.getByTestId("canvas-composer").parentElement;
+ expect(composerSurface).toHaveAttribute(
+ "data-home-canvas-interactive",
+ "true",
+ );
+ expect(composerSurface).toHaveClass("shrink-0", "px-2", "pb-2");
+ expect(composerSurface).not.toHaveClass(
+ "rounded-sm",
+ "bg-surface-chat-composer",
+ "[backdrop-filter:var(--backdrop-composer-glass)]",
+ "border-t",
+ );
+ expect(composerSurface?.parentElement).toBe(
+ screen.getByRole("region", { name: "Canvas chat" }),
+ );
+ });
+
+ it("renders the session project icon in the header", () => {
+ useProjectStore.setState({
+ projects: [
+ {
+ id: "project-1",
+ path: "/projects/one",
+ name: "Project one",
+ description: "",
+ prompt: "",
+ icon: "tabler:brand-github",
+ color: "#123456",
+ projectWorkspaces: [],
+ workingDirs: [],
+ useWorktrees: false,
+ order: 0,
+ archivedAt: null,
+ },
+ ],
+ });
+
+ render(
+ ,
+ );
+
+ expect(
+ screen
+ .getByRole("region", { name: "Canvas chat" })
+ .querySelector("header svg"),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/features/home/widgets/ChatCanvasCard.transcript.test.tsx b/src/features/home/widgets/ChatCanvasCard.transcript.test.tsx
new file mode 100644
index 000000000..29260d5ca
--- /dev/null
+++ b/src/features/home/widgets/ChatCanvasCard.transcript.test.tsx
@@ -0,0 +1,124 @@
+import { screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { renderWithProviders } from "@/test/render";
+import { TRANSCRIPT_VIRTUAL_RENDERER_EXPERIMENT_ID } from "@/features/experiments/experimentDefinitions";
+import {
+ EXPERIMENT_PREFERENCES_STORAGE_KEY,
+ setExperimentEnabled,
+} from "@/features/experiments/experimentPreferences";
+import type { Message } from "@/shared/types/messages";
+import { ChatCanvasCard } from "./ChatCanvasCard";
+
+const assistantMessage: Message = {
+ id: "assistant-1",
+ role: "assistant",
+ created: Date.UTC(2026, 7, 20, 12, 1, 0),
+ content: [
+ { type: "text", text: "I’ll inspect the canvas geometry first." },
+ {
+ type: "toolRequest",
+ id: "tool-1",
+ name: "Inspect canvas geometry",
+ arguments: { surface: "canvas-card" },
+ status: "completed",
+ },
+ {
+ type: "toolResponse",
+ id: "tool-1",
+ name: "Inspect canvas geometry",
+ result: "Transform found",
+ isError: false,
+ },
+ { type: "text", text: "The assistant prose remains visible." },
+ ],
+ metadata: { userVisible: true },
+};
+
+const messages: Message[] = [
+ {
+ id: "user-1",
+ role: "user",
+ created: Date.UTC(2026, 7, 20, 12, 0, 0),
+ content: [{ type: "text", text: "Why is prose disappearing?" }],
+ metadata: { userVisible: true },
+ },
+ assistantMessage,
+];
+
+vi.mock("@/features/chat/capabilities/ConversationComposerCapability", () => ({
+ useConversationComposerBinding: () => ({ binding: true }),
+ ConversationComposerCapability: () => ,
+}));
+
+vi.mock("@/features/chat/hooks/useChatTranscriptReadModel", () => ({
+ useChatTranscriptReadModel: () => ({
+ messages,
+ isLoadingHistory: false,
+ selectedPersona: undefined,
+ sessionArtifactCwd: undefined,
+ runtime: { chatState: "streaming", streamingMessageId: "assistant-1" },
+ }),
+}));
+
+const session = {
+ id: "canvas-session",
+ title: "Canvas chat",
+ createdAt: "2026-08-20T00:00:00.000Z",
+ updatedAt: "2026-08-20T00:00:00.000Z",
+ messageCount: messages.length,
+};
+
+describe("ChatCanvasCard transcript renderer", () => {
+ beforeEach(() => {
+ localStorage.removeItem(EXPERIMENT_PREFERENCES_STORAGE_KEY);
+ expect(
+ setExperimentEnabled(TRANSCRIPT_VIRTUAL_RENDERER_EXPERIMENT_ID, true),
+ ).toBe(true);
+
+ class ResizeObserverMock {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ }
+ vi.stubGlobal("ResizeObserver", ResizeObserverMock);
+ });
+
+ it("uses the classic transcript renderer inside the standard scaled widget path", async () => {
+ renderWithProviders(
+
+
+
,
+ );
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("I’ll inspect the canvas geometry first."),
+ ).toBeInTheDocument();
+ });
+
+ expect(
+ screen.getByText("The assistant prose remains visible."),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Inspect canvas geometry")).toBeInTheDocument();
+ const timeline = screen.getByTestId("message-timeline-scroll");
+ const composer = screen.getByLabelText("Message");
+
+ expect(timeline).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("message-timeline-footer"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId("message-timeline-surface"),
+ ).not.toBeInTheDocument();
+ expect(composer).toBeInTheDocument();
+ expect(composer.closest("[data-home-canvas-interactive='true']")).toBe(
+ timeline.closest("[data-canvas-chat-activation='transcript']")
+ ?.nextElementSibling,
+ );
+ });
+});
diff --git a/src/features/home/widgets/ChatCanvasCard.tsx b/src/features/home/widgets/ChatCanvasCard.tsx
new file mode 100644
index 000000000..a5dd04e21
--- /dev/null
+++ b/src/features/home/widgets/ChatCanvasCard.tsx
@@ -0,0 +1,270 @@
+import { IconArrowsMaximize, IconArrowsMinimize } from "@tabler/icons-react";
+import { useMemo, useRef } from "react";
+import { useTranslation } from "react-i18next";
+import { useChatTranscriptReadModel } from "@/features/chat/hooks/useChatTranscriptReadModel";
+import type { WorkspaceNameRequest } from "@/features/chat/hooks/useChatSessionController";
+import {
+ ConversationComposerCapability,
+ useConversationComposerBinding,
+} from "@/features/chat/capabilities/ConversationComposerCapability";
+import { DEFAULT_CHAT_TITLE } from "@/features/chat/lib/sessionTitle";
+import { projectRecentConversationExchanges } from "@/features/chat/lib/boundedConversationProjection";
+import type { ChatSession } from "@/features/chat/stores/chatSessionStore";
+import { useChatStore } from "@/features/chat/stores/chatStore";
+import { ChatTranscriptSurface } from "@/features/chat/ui/ChatTranscriptSurface";
+import { LoadingBerd } from "@/features/chat/ui/LoadingBerd";
+import { selectProjects } from "@/features/projects/stores/projectSelectors";
+import { useProjectStore } from "@/features/projects/stores/projectStore";
+import { ProjectIcon } from "@/features/projects/ui/ProjectIcon";
+import { ActiveChatBerdIndicator } from "@/shared/ui/SessionActivityIndicator";
+import { Button } from "@/shared/ui/button";
+import { cn } from "@/shared/lib/cn";
+
+function CanvasCardComposer({
+ session,
+ onCreatePersona,
+ onCreateProject,
+ onWorkspaceNameRequest,
+}: {
+ session: ChatSession;
+ onCreatePersona?: () => void;
+ onCreateProject?: () => void;
+ onWorkspaceNameRequest?: (request: WorkspaceNameRequest) => void;
+}) {
+ const binding = useConversationComposerBinding({
+ target: {
+ kind: "existingSession",
+ sessionId: session.id,
+ sessionSnapshot: session,
+ readOnlyWhenOpenInAnotherWindow: true,
+ },
+ onCreatePersonaRequested: onCreatePersona,
+ onWorkspaceNameRequest,
+ });
+
+ return (
+
+ );
+}
+
+interface ChatCanvasCardProps {
+ session: ChatSession;
+ isFocused: boolean;
+ onFocus?: () => void;
+ shouldIgnoreActivation?: () => boolean;
+ onCreatePersona?: () => void;
+ onCreateProject?: () => void;
+ onWorkspaceNameRequest?: (request: WorkspaceNameRequest) => void;
+ onCollapse: () => void;
+ onOpenFullChat: () => void;
+}
+
+export function ChatCanvasCard({
+ session,
+ isFocused,
+ onFocus,
+ shouldIgnoreActivation = () => false,
+ onCreatePersona,
+ onCreateProject,
+ onWorkspaceNameRequest,
+ onCollapse,
+ onOpenFullChat,
+}: ChatCanvasCardProps) {
+ const { t } = useTranslation(["home", "chat"]);
+ const transcript = useChatTranscriptReadModel(session.id);
+ const projects = useProjectStore(selectProjects);
+ const project = session.projectId
+ ? projects.find((candidate) => candidate.id === session.projectId)
+ : undefined;
+ const { chatState, streamingMessageId } = transcript.runtime;
+ const title = session.title.trim() || DEFAULT_CHAT_TITLE;
+ const showActivity =
+ chatState === "thinking" ||
+ chatState === "streaming" ||
+ chatState === "waiting" ||
+ chatState === "compacting";
+ const composerPointerActivationRef = useRef(false);
+ const boundedTranscript = useMemo(
+ () => projectRecentConversationExchanges(transcript.messages),
+ [transcript.messages],
+ );
+
+ const focusAndMarkRead = () => {
+ if (shouldIgnoreActivation()) {
+ return;
+ }
+ onFocus?.();
+ useChatStore.getState().markSessionRead(session.id);
+ };
+
+ const activateComposerFromPointer = () => {
+ composerPointerActivationRef.current = true;
+ queueMicrotask(() => {
+ composerPointerActivationRef.current = false;
+ });
+ focusAndMarkRead();
+ };
+
+ const activateComposerFromFocus = () => {
+ if (composerPointerActivationRef.current) {
+ composerPointerActivationRef.current = false;
+ return;
+ }
+ focusAndMarkRead();
+ };
+
+ return (
+
+
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: transcript body click grants ephemeral canvas composer focus after the canvas gesture classifier. */}
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: keyboard users focus the composer directly; this handler classifies pointer click ownership only. */}
+ event.stopPropagation()}
+ onClick={focusAndMarkRead}
+ >
+
+ {t("home:widgets.chatPin.earlierMessages")}
+
+
+ ) : null
+ }
+ footerStatus={
+ showActivity && !transcript.isLoadingHistory ? (
+
+ ) : null
+ }
+ />
+
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: this normal-flow surface classifies pointer ownership while its nested composer controls retain keyboard semantics. */}
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: keyboard activation is handled by the nested composer controls and focus capture. */}
+
{
+ event.stopPropagation();
+ activateComposerFromPointer();
+ }}
+ onClick={(event) => event.stopPropagation()}
+ onFocusCapture={activateComposerFromFocus}
+ >
+
+
+
+ );
+}
diff --git a/src/features/home/widgets/ChatPinWidget.test.tsx b/src/features/home/widgets/ChatPinWidget.test.tsx
index 61b05f13a..cbbd2593b 100644
--- a/src/features/home/widgets/ChatPinWidget.test.tsx
+++ b/src/features/home/widgets/ChatPinWidget.test.tsx
@@ -6,6 +6,40 @@ import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore";
import { ChatPinWidget } from "./ChatPinWidget";
import type { WidgetInstance } from "./types";
+const { mockUseExperiment } = vi.hoisted(() => ({
+ mockUseExperiment: vi.fn(() => ({ enabled: false })),
+}));
+
+vi.mock("@/features/experiments/experimentPreferences", () => ({
+ useExperiment: mockUseExperiment,
+}));
+
+vi.mock("./ChatCanvasCard", () => ({
+ ChatCanvasCard: ({
+ isFocused,
+ onFocus,
+ onCollapse,
+ onOpenFullChat,
+ }: {
+ isFocused: boolean;
+ onFocus?: () => void;
+ onCollapse: () => void;
+ onOpenFullChat: () => void;
+ }) => (
+
+
+
+
+
+ ),
+}));
+
vi.mock("@/shared/i18n", () => ({
useLocaleFormatting: () => ({
formatRelativeTimeToNow: () => "just now",
@@ -44,6 +78,7 @@ function instance(sessionId: string): WidgetInstance {
describe("ChatPinWidget", () => {
beforeEach(() => {
resetStores();
+ mockUseExperiment.mockReturnValue({ enabled: false });
});
it("does not fall back to another session when the pinned id is missing", () => {
@@ -68,6 +103,120 @@ describe("ChatPinWidget", () => {
expect(screen.getByText("Loading pinned chat...")).toBeInTheDocument();
});
+ it("expands in place instead of navigating when the experiment is enabled", async () => {
+ const user = userEvent.setup();
+ const onUpdateState = vi.fn();
+ const onSelectSession = vi.fn();
+ mockUseExperiment.mockReturnValue({ enabled: true });
+ useChatSessionStore.getState().addSession({
+ id: "session-pinned",
+ title: "Pinned chat",
+ createdAt: "2026-04-01T00:00:00.000Z",
+ updatedAt: "2026-04-01T00:00:00.000Z",
+ messageCount: 1,
+ });
+
+ render(
+
,
+ );
+ await user.click(screen.getByRole("button"));
+
+ expect(onUpdateState).toHaveBeenCalledWith({ presentation: "expanded" });
+ expect(onSelectSession).not.toHaveBeenCalled();
+ });
+
+ it("restores an expanded card and exposes collapse and full-chat actions", async () => {
+ const user = userEvent.setup();
+ const onUpdateState = vi.fn();
+ const onSelectSession = vi.fn();
+ mockUseExperiment.mockReturnValue({ enabled: true });
+ useChatSessionStore.getState().addSession({
+ id: "session-pinned",
+ title: "Pinned chat",
+ createdAt: "2026-04-01T00:00:00.000Z",
+ updatedAt: "2026-04-01T00:00:00.000Z",
+ messageCount: 1,
+ });
+ const expanded = {
+ ...instance("session-pinned"),
+ state: { sessionId: "session-pinned", presentation: "expanded" },
+ };
+
+ render(
+
,
+ );
+ await user.click(screen.getByRole("button", { name: "Collapse" }));
+ await user.click(screen.getByRole("button", { name: "Open full chat" }));
+
+ expect(onUpdateState).toHaveBeenCalledWith({ presentation: "collapsed" });
+ expect(onSelectSession).toHaveBeenCalledWith("session-pinned");
+ });
+
+ it("clears ephemeral focus when collapsing without persisting it", async () => {
+ const user = userEvent.setup();
+ const onUpdateState = vi.fn();
+ const onClearCanvasChatFocus = vi.fn();
+ mockUseExperiment.mockReturnValue({ enabled: true });
+ useChatSessionStore.getState().addSession({
+ id: "session-pinned",
+ title: "Pinned chat",
+ createdAt: "2026-04-01T00:00:00.000Z",
+ updatedAt: "2026-04-01T00:00:00.000Z",
+ messageCount: 1,
+ });
+
+ render(
+
,
+ );
+ await user.click(screen.getByRole("button", { name: "Collapse" }));
+
+ expect(onClearCanvasChatFocus).toHaveBeenCalledTimes(1);
+ expect(onUpdateState).toHaveBeenCalledWith({ presentation: "collapsed" });
+ });
+
+ it("signals unavailable on temporary failure and again on remount", () => {
+ const onAvailabilityChange = vi.fn();
+ mockUseExperiment.mockReturnValue({ enabled: true });
+ useChatSessionStore.getState().addSession({
+ id: "session-pinned",
+ title: "Pinned chat",
+ createdAt: "2026-04-01T00:00:00.000Z",
+ updatedAt: "2026-04-01T00:00:00.000Z",
+ messageCount: 1,
+ });
+ const expanded = {
+ ...instance("session-pinned"),
+ state: { sessionId: "session-pinned", presentation: "expanded" },
+ };
+ const { unmount } = render(
+
,
+ );
+
+ expect(onAvailabilityChange).toHaveBeenLastCalledWith("chat-pin-1", true);
+ unmount();
+ expect(onAvailabilityChange).toHaveBeenLastCalledWith("chat-pin-1", false);
+ });
+
it("selects an unavailable pinned session so it can retry loading", async () => {
const user = userEvent.setup();
const onSelectSession = vi.fn();
diff --git a/src/features/home/widgets/ChatPinWidget.tsx b/src/features/home/widgets/ChatPinWidget.tsx
index cd9c6bdc4..62becf069 100644
--- a/src/features/home/widgets/ChatPinWidget.tsx
+++ b/src/features/home/widgets/ChatPinWidget.tsx
@@ -1,4 +1,8 @@
+import { useEffect, useLayoutEffect } from "react";
import { useTranslation } from "react-i18next";
+import { useExperiment } from "@/features/experiments/experimentPreferences";
+import { CHAT_ON_CANVAS_EXPERIMENT_ID } from "@/features/experiments/experimentDefinitions";
+import { ChatCanvasCard } from "./ChatCanvasCard";
import { IconMessageCircle } from "@tabler/icons-react";
import { sessionActivityAt } from "@/features/chat/lib/sessionActivity";
import { DEFAULT_CHAT_TITLE } from "@/features/chat/lib/sessionTitle";
@@ -26,11 +30,21 @@ function resolveSession(sessions: ChatSession[], id: string | null) {
export function ChatPinWidget({
instance,
+ onUpdateState,
shouldIgnoreActivation,
onSelectSession,
+ onCreatePersona,
+ onCreateProject,
+ onWorkspaceNameRequest,
+ isCanvasChatFocused = false,
+ onFocusCanvasChat,
+ onClearCanvasChatFocus,
+ onCanvasChatAvailabilityChange,
}: WidgetRenderProps) {
const { t } = useTranslation("home");
const { formatRelativeTimeToNow } = useLocaleFormatting();
+ const chatOnCanvasEnabled =
+ useExperiment(CHAT_ON_CANVAS_EXPERIMENT_ID)?.enabled === true;
const sessions = useChatSessionStore((state) => state.sessions);
const sessionId = getSessionId(instance.state);
const isLoadingSession = useChatStore((state) =>
@@ -61,16 +75,62 @@ export function ChatPinWidget({
.filter(Boolean)
.join(" · ");
}
+ const isExpanded =
+ chatOnCanvasEnabled && instance.state?.presentation === "expanded";
+ // Viewport pausing is a visual-work hint, not a lifecycle boundary. A drag
+ // can transiently move this mounted card outside the viewport; availability
+ // remains owned by the expanded card/session lifecycle so focus and local
+ // transcript/composer state survive that movement.
+ const canvasChatAvailable = Boolean(isExpanded && session && !isUnavailable);
+ useLayoutEffect(() => {
+ onCanvasChatAvailabilityChange?.(instance.id, canvasChatAvailable);
+ return () => onCanvasChatAvailabilityChange?.(instance.id, false);
+ }, [canvasChatAvailable, instance.id, onCanvasChatAvailabilityChange]);
+ useEffect(() => {
+ if (!chatOnCanvasEnabled && instance.state?.presentation === "expanded") {
+ onUpdateState({ presentation: "collapsed" });
+ }
+ }, [chatOnCanvasEnabled, instance.state?.presentation, onUpdateState]);
const handleClick = useWidgetActivationGuard(shouldIgnoreActivation, () => {
- if (session) onSelectSession?.(session.id);
+ if (!session) return;
+ if (chatOnCanvasEnabled) {
+ onUpdateState({ presentation: "expanded" });
+ return;
+ }
+ onSelectSession?.(session.id);
});
+
+ if (isExpanded && session && !isUnavailable) {
+ return (
+
{
+ onClearCanvasChatFocus?.();
+ onUpdateState({ presentation: "collapsed" });
+ }}
+ onOpenFullChat={() => onSelectSession?.(session.id)}
+ />
+ );
+ }
const isCompact = (instance.height ?? 80) <= 96;
return (