From 33f1fa1a812868da49f1ee33ba9b8817d4da0329 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:10:18 -0700 Subject: [PATCH 1/4] Add read-only chat cards to home canvas --- .../chat/hooks/useChatTranscriptReadModel.ts | 60 +++++++ .../chat/stores/__tests__/chatStore.test.ts | 58 ++++++ src/features/chat/stores/chatStore.ts | 49 ++++++ .../chat/ui/ChatTranscriptSurface.tsx | 165 ++++++++++++++++++ src/features/chat/ui/ChatView.tsx | 118 ++----------- .../experiments/experimentDefinitions.ts | 7 + src/features/home/widgets/ChatCanvasCard.tsx | 105 +++++++++++ .../home/widgets/ChatPinWidget.test.tsx | 85 +++++++++ src/features/home/widgets/ChatPinWidget.tsx | 38 +++- src/features/home/widgets/catalog.ts | 21 +++ src/shared/i18n/locales/en/home.json | 6 +- src/shared/i18n/locales/en/settings.json | 4 + src/shared/i18n/locales/es/home.json | 6 +- src/shared/i18n/locales/es/settings.json | 4 + 14 files changed, 621 insertions(+), 105 deletions(-) create mode 100644 src/features/chat/hooks/useChatTranscriptReadModel.ts create mode 100644 src/features/chat/ui/ChatTranscriptSurface.tsx create mode 100644 src/features/home/widgets/ChatCanvasCard.tsx 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/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/ui/ChatTranscriptSurface.tsx b/src/features/chat/ui/ChatTranscriptSurface.tsx new file mode 100644 index 000000000..e3c45e47c --- /dev/null +++ b/src/features/chat/ui/ChatTranscriptSurface.tsx @@ -0,0 +1,165 @@ +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 } 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; + footer?: ReactNode; + footerStatus?: ReactNode; + suppressEmptyPlaceholder?: boolean; +} + +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, + footer, + footerStatus, + suppressEmptyPlaceholder = false, + ...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 ? ( + ); - const conversationPlaceholder = showTimelineLoading ? ( - - ) : suppressEmptyConversationPlaceholder ? ( - - + ); } diff --git a/src/features/experiments/experimentDefinitions.ts b/src/features/experiments/experimentDefinitions.ts index e0cd0dc53..1ba2d3a5c 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,11 @@ export const EXPERIMENT_DEFINITIONS = [ descriptionKey: "experiments.voiceConversation.description", defaultEnabled: true, }, + { + id: CHAT_ON_CANVAS_EXPERIMENT_ID, + titleKey: "experiments.chatOnCanvas.title", + descriptionKey: "experiments.chatOnCanvas.description", + }, { id: AVATAR_COLLECTION_PAGE_EXPERIMENT_ID, titleKey: "experiments.avatarCollectionPage.title", diff --git a/src/features/home/widgets/ChatCanvasCard.tsx b/src/features/home/widgets/ChatCanvasCard.tsx new file mode 100644 index 000000000..4f91f93c1 --- /dev/null +++ b/src/features/home/widgets/ChatCanvasCard.tsx @@ -0,0 +1,105 @@ +import { IconArrowsMinimize, IconExternalLink } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { useChatTranscriptReadModel } from "@/features/chat/hooks/useChatTranscriptReadModel"; +import { DEFAULT_CHAT_TITLE } from "@/features/chat/lib/sessionTitle"; +import type { ChatSession } from "@/features/chat/stores/chatSessionStore"; +import { ChatTranscriptSurface } from "@/features/chat/ui/ChatTranscriptSurface"; +import { LoadingBerd } from "@/features/chat/ui/LoadingBerd"; +import { ActiveChatBerdIndicator } from "@/shared/ui/SessionActivityIndicator"; +import { Button } from "@/shared/ui/button"; +import { cn } from "@/shared/lib/cn"; + +interface ChatCanvasCardProps { + session: ChatSession; + onCollapse: () => void; + onOpenFullChat: () => void; +} + +export function ChatCanvasCard({ + session, + onCollapse, + onOpenFullChat, +}: ChatCanvasCardProps) { + const { t } = useTranslation(["home", "chat"]); + const transcript = useChatTranscriptReadModel(session.id); + const { chatState, streamingMessageId } = transcript.runtime; + const title = session.title.trim() || DEFAULT_CHAT_TITLE; + const showActivity = + chatState === "thinking" || + chatState === "streaming" || + chatState === "waiting" || + chatState === "compacting"; + + return ( +
+
+ {showActivity ? : null} +

{title}

+
event.stopPropagation()} + > + + +
+
+
event.stopPropagation()} + > + + + +
+ ) : null + } + /> + +
+ ); +} diff --git a/src/features/home/widgets/ChatPinWidget.test.tsx b/src/features/home/widgets/ChatPinWidget.test.tsx index 61b05f13a..e2f314085 100644 --- a/src/features/home/widgets/ChatPinWidget.test.tsx +++ b/src/features/home/widgets/ChatPinWidget.test.tsx @@ -6,6 +6,33 @@ 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: ({ + onCollapse, + onOpenFullChat, + }: { + onCollapse: () => void; + onOpenFullChat: () => void; + }) => ( +
+ + +
+ ), +})); + vi.mock("@/shared/i18n", () => ({ useLocaleFormatting: () => ({ formatRelativeTimeToNow: () => "just now", @@ -44,6 +71,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 +96,63 @@ 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("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..55a969756 100644 --- a/src/features/home/widgets/ChatPinWidget.tsx +++ b/src/features/home/widgets/ChatPinWidget.tsx @@ -1,4 +1,8 @@ +import { useEffect } 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,14 @@ function resolveSession(sessions: ChatSession[], id: string | null) { export function ChatPinWidget({ instance, + onUpdateState, shouldIgnoreActivation, onSelectSession, }: 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 +68,43 @@ export function ChatPinWidget({ .filter(Boolean) .join(" · "); } + const isExpanded = + chatOnCanvasEnabled && instance.state?.presentation === "expanded"; + 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 ( + onUpdateState({ presentation: "collapsed" })} + onOpenFullChat={() => onSelectSession?.(session.id)} + /> + ); + } const isCompact = (instance.height ?? 80) <= 96; return (