(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 && (
} label={t('sideChat.title')} size="sm"
pressed={sideChatOpen} onClick={() => void openSideChat(activeTabId)} />