From 18fb774a51ab856f5de3bf35fd06216a00213c94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 5 Oct 2026 18:23:14 +0800 Subject: [PATCH] fix(desktop): hide side chat entries until a session has a conversation A side chat forks the parent transcript, so the server rejects it for a blank session. The new-session page still showed the side chat button in its top-right corner, and every click only produced a "Start a conversation before opening a side chat" toast. Gate every way in on the session having a conversation: the top-right button now only renders on mobile once the session has messages, and the workspace launcher, its + menu and the /btw slash menu entry leave side chat out of a blank session. --- .../src/components/chat/ChatInput.test.tsx | 15 ++++++++++ desktop/src/components/chat/ChatInput.tsx | 6 ++-- .../components/workbench/WorkspaceAddMenu.tsx | 5 ++-- .../workbench/WorkspaceLauncher.test.tsx | 10 +++++++ .../workbench/WorkspaceLauncher.tsx | 10 ++++++- .../components/workbench/WorkspaceSurface.tsx | 5 ++++ desktop/src/pages/ActiveSession.test.tsx | 28 +++++++++++++++++++ desktop/src/pages/ActiveSession.tsx | 7 ++++- 8 files changed, 80 insertions(+), 6 deletions(-) diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index 47034014..5ddb179d 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -826,6 +826,21 @@ describe('ChatInput file mentions', () => { expect(getComposerText()).toBe('') }) + it('leaves /btw out of the slash menu until the session has a conversation to fork', async () => { + useSessionStore.setState(state => ({ sessions: state.sessions.map(session => ({ ...session, messageCount: 0 })) })) + useChatStore.setState(state => ({ sessions: { [sessionId]: { ...state.sessions[sessionId]!, messages: [] } } })) + render() + const btwOptions = () => screen.queryAllByRole('option').filter(option => option.textContent?.startsWith('/btw')) + setComposerText('/bt', 3) + await act(async () => { await new Promise(resolve => setTimeout(resolve, 50)) }) + expect(btwOptions()).toEqual([]) + + act(() => useChatStore.setState(state => ({ sessions: { [sessionId]: { ...state.sessions[sessionId]!, messages: [{ id: 'first', type: 'user_text', content: 'hi', timestamp: 1 }] } } }))) + setComposerText('', 0) + setComposerText('/bt', 3) + await waitFor(() => expect(btwOptions()).toHaveLength(1)) + }) + it('opens an empty full side chat with /btw without sending a main message', async () => { render() setComposerText('/btw', 4) diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 2ac065e7..07f94b85 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -336,6 +336,7 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis const isHeroComposer = variant === 'hero' && !isMemberSession && !compact const resolvedWorkDir = activeSession?.workDir || gitInfo?.workDir || undefined const showLaunchControls = !isMemberSession && !sideChat && messageCount === 0 + const sideChatAvailable = Boolean(sideChat) || messageCount > 0 // `useCompactControls` is about room, so it asks the column how wide it is — // never "is a side panel open". Until a measurement lands (jsdom, first // paint) it defers to the caller's `compact`, which keeps the @@ -665,8 +666,9 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis names.add(name.toLowerCase()) commands.push({ name, description: reference.description, kind: reference.kind }) } - return commands.filter(isComposerSlashCommandVisible) - }, [agentSlashCommands, slashCommands, composerReferences, t]) + // `/btw` forks the conversation so far; a blank session has none to fork. + return commands.filter(command => isComposerSlashCommandVisible(command) && (sideChatAvailable || command.name !== 'btw')) + }, [agentSlashCommands, slashCommands, composerReferences, sideChatAvailable, t]) const filteredCommandGroups = useMemo(() => { return groupSlashCommands(filterSlashCommands(allSlashCommands, slashFilter)) diff --git a/desktop/src/components/workbench/WorkspaceAddMenu.tsx b/desktop/src/components/workbench/WorkspaceAddMenu.tsx index 63f03ef3..c241a1d0 100644 --- a/desktop/src/components/workbench/WorkspaceAddMenu.tsx +++ b/desktop/src/components/workbench/WorkspaceAddMenu.tsx @@ -13,13 +13,14 @@ export type WorkspaceAddMenuProps = { dock: WorkspaceDock initialFocus?: 'first' | 'last' reviewUnavailableReason?: string | null + sideChatAvailable?: boolean onSelect: (kind: WorkspaceTabKind) => void onClose: () => void } /** The empty dock owns a launcher; the + button owns a menu over its current content. */ export function WorkspaceAddMenu({ - id, anchorRef, dock, initialFocus, reviewUnavailableReason, onSelect, onClose, + id, anchorRef, dock, initialFocus, reviewUnavailableReason, sideChatAvailable, onSelect, onClose, }: WorkspaceAddMenuProps) { const t = useTranslation() const menuRef = useRef(null) @@ -57,7 +58,7 @@ export function WorkspaceAddMenu({ style={position.style} className="z-[var(--z-dropdown)] w-[260px] max-w-[calc(100vw-12px)] overflow-y-auto rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] p-1 shadow-[var(--shadow-dropdown)]" > - + , document.body, ) diff --git a/desktop/src/components/workbench/WorkspaceLauncher.test.tsx b/desktop/src/components/workbench/WorkspaceLauncher.test.tsx index 21a89386..9df17ef7 100644 --- a/desktop/src/components/workbench/WorkspaceLauncher.test.tsx +++ b/desktop/src/components/workbench/WorkspaceLauncher.test.tsx @@ -22,6 +22,16 @@ describe('WorkspaceLauncher', () => { ]) }) + it('leaves side chat out while there is no conversation to fork', () => { + render() + expect(screen.getAllByRole('button').map((item) => item.getAttribute('data-testid'))).toEqual([ + 'workspace-launcher-review', + 'workspace-launcher-terminal', + 'workspace-launcher-browser', + 'workspace-launcher-file', + ]) + }) + it('advertises the four global resource shortcuts while clicks retain their dock', () => { render() // The hint is what makes the launcher teach its own shortcuts rather than diff --git a/desktop/src/components/workbench/WorkspaceLauncher.tsx b/desktop/src/components/workbench/WorkspaceLauncher.tsx index c6cd2954..34e2741d 100644 --- a/desktop/src/components/workbench/WorkspaceLauncher.tsx +++ b/desktop/src/components/workbench/WorkspaceLauncher.tsx @@ -42,11 +42,19 @@ export type WorkspaceLauncherProps = { * missing feature. */ reviewUnavailableReason?: string | null + /** + * A side chat forks the parent transcript, so a session with no + * conversation yet has nothing to fork. Unlike review, the entry is left + * out rather than disabled: it becomes available by talking, not by + * changing the folder. + */ + sideChatAvailable?: boolean } export function WorkspaceLauncher({ onSelect, reviewUnavailableReason, + sideChatAvailable = true, dock = 'side', variant = 'empty', }: WorkspaceLauncherProps) { @@ -61,7 +69,7 @@ export function WorkspaceLauncher({ className={menu ? '' : `flex min-h-0 flex-1 items-start justify-center overflow-y-auto ${compact ? 'px-4 py-2' : 'px-6 py-10'}`} >
    - {ENTRIES.filter(entry => dock !== 'bottom' || entry.kind !== 'side-chat').map(({ kind, labelKey, shortcut, Icon }) => { + {ENTRIES.filter(entry => entry.kind !== 'side-chat' || (dock !== 'bottom' && sideChatAvailable)).map(({ kind, labelKey, shortcut, Icon }) => { const disabledReason = kind === 'review' ? reviewUnavailableReason ?? null : null // The hint advertises the app command; a pointer choice uses this dock. const hint = shortcut ? formatWorkspaceShortcut(shortcut, platform) : null diff --git a/desktop/src/components/workbench/WorkspaceSurface.tsx b/desktop/src/components/workbench/WorkspaceSurface.tsx index c8b897d1..ca07cc18 100644 --- a/desktop/src/components/workbench/WorkspaceSurface.tsx +++ b/desktop/src/components/workbench/WorkspaceSurface.tsx @@ -31,6 +31,8 @@ export type WorkspaceSurfaceProps = { cwd: string /** Reason the review entry is unavailable here, e.g. "not a Git repository". */ reviewUnavailableReason?: string | null + /** False until the session has a conversation for a side chat to fork. */ + sideChatAvailable?: boolean /** * Whether this dock is on screen. The bottom dock stays mounted while hidden * so xterm keeps its geometry, so "mounted" and "visible" are not the same @@ -51,6 +53,7 @@ export function WorkspaceSurface({ dock, cwd, reviewUnavailableReason = null, + sideChatAvailable = true, visible = true, }: WorkspaceSurfaceProps) { const t = useTranslation() @@ -237,6 +240,7 @@ export function WorkspaceSurface({ onSelect={handleLauncherSelect} dock={dock} reviewUnavailableReason={reviewUnavailableReason} + sideChatAvailable={sideChatAvailable} /> ) : activeTab === null ? null : activeTab.kind === 'side-chat' ? ( @@ -269,6 +273,7 @@ export function WorkspaceSurface({ dock={dock} initialFocus={initialMenuFocus.current} reviewUnavailableReason={reviewUnavailableReason} + sideChatAvailable={sideChatAvailable} onSelect={selectFromMenu} onClose={closeMenu} /> diff --git a/desktop/src/pages/ActiveSession.test.tsx b/desktop/src/pages/ActiveSession.test.tsx index 6f342c3e..1f9fc50f 100644 --- a/desktop/src/pages/ActiveSession.test.tsx +++ b/desktop/src/pages/ActiveSession.test.tsx @@ -258,6 +258,34 @@ describe('ActiveSession task polling', () => { expect(screen.getByTestId('message-list')).toBe(main) }) + it.each([false, true])('offers no side chat in a blank session until it has a conversation (mobile: %s)', (isMobile) => { + viewportMocks.isMobile = isMobile + const id = `blank-side-parent-${isMobile ? 'mobile' : 'desktop'}` + useSettingsStore.setState({ locale: 'en' }) + useTabStore.setState({ activeTabId: id, tabs: [{ sessionId: id, title: 'Untitled Session', type: 'session', status: 'idle' }] }) + useSessionStore.setState({ sessions: [{ id, title: 'Untitled Session', messageCount: 0, createdAt: '', modifiedAt: '', projectPath: '/repo', workDir: '/repo', workDirExists: true }] }) + useChatStore.setState({ sessions: { [id]: { ...createDefaultSessionState(), connectionState: 'connected', historyStatus: 'ready', historyHydrated: true } } }) + if (!isMobile) useWorkspaceStore.getState().setLayout(id, 'split') + render() + + expect(screen.getByTestId('empty-session-hero')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Side chat' })).not.toBeInTheDocument() + if (!isMobile) { + expect(screen.getByTestId('workspace-launcher-review')).toBeInTheDocument() + expect(screen.queryByTestId('workspace-launcher-side-chat')).not.toBeInTheDocument() + } + + act(() => useChatStore.setState(state => ({ sessions: { ...state.sessions, [id]: { ...state.sessions[id]!, messages: [{ id: 'u', type: 'user_text', content: 'hi', timestamp: 1 }] } } }))) + + expect(screen.queryByTestId('empty-session-hero')).not.toBeInTheDocument() + const launcherEntry = screen.queryByTestId('workspace-launcher-side-chat') + expect(Boolean(launcherEntry)).toBe(!isMobile) + const headerEntry = screen.getAllByRole('button', { name: 'Side chat' }).filter(button => button !== launcherEntry) + expect(headerEntry).toHaveLength(1) + fireEvent.click(headerEntry[0]!) + expect(openSideChat).toHaveBeenCalledWith(id) + }) + it('can hide a mobile side chat without destroying its temporary tab', () => { viewportMocks.isMobile = true const id = 'mobile-side-parent' diff --git a/desktop/src/pages/ActiveSession.tsx b/desktop/src/pages/ActiveSession.tsx index a2fd9c36..2beb8374 100644 --- a/desktop/src/pages/ActiveSession.tsx +++ b/desktop/src/pages/ActiveSession.tsx @@ -886,13 +886,18 @@ export function ActiveSession({ sessionId, active = true }: { sessionId?: string dock="side" cwd={getSessionTerminalCwd(session) ?? ''} reviewUnavailableReason={workspaceIsGitRepo === false ? t('workspace.launcher.reviewNeedsGit') : null} + sideChatAvailable={!isEmpty} visible={active} /> ) : null}} > - {(isEmpty || isMobileLayout) && ( + {/* + Mobile has no session header, so its side-chat entry lives here. A + blank session has nothing for a side chat to fork, so it gets none. + */} + {isMobileLayout && !isEmpty && (