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.
This commit is contained in:
程序员阿江(Relakkes)
2026-10-05 18:23:14 +08:00
parent 760ee4e481
commit 18fb774a51
8 changed files with 80 additions and 6 deletions
@@ -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(<ChatInput />)
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(<ChatInput compact />)
setComposerText('/btw', 4)
+4 -2
View File
@@ -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))
@@ -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<HTMLDivElement>(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)]"
>
<WorkspaceLauncher variant="menu" dock={dock} onSelect={onSelect} reviewUnavailableReason={reviewUnavailableReason} />
<WorkspaceLauncher variant="menu" dock={dock} onSelect={onSelect} reviewUnavailableReason={reviewUnavailableReason} sideChatAvailable={sideChatAvailable} />
</div>,
document.body,
)
@@ -22,6 +22,16 @@ describe('WorkspaceLauncher', () => {
])
})
it('leaves side chat out while there is no conversation to fork', () => {
render(<WorkspaceLauncher onSelect={vi.fn()} sideChatAvailable={false} />)
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(<WorkspaceLauncher onSelect={vi.fn()} />)
// The hint is what makes the launcher teach its own shortcuts rather than
@@ -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'}`}
>
<ul className={menu ? 'space-y-px' : 'my-auto w-full max-w-[420px] space-y-px'} role={menu ? 'presentation' : undefined} aria-label={menu ? undefined : t('workspace.launcher.label')}>
{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
@@ -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' ? (
<SideChatSurface parentSessionId={sessionId} sideChatId={activeTab.sideChatId} visible={visible} />
@@ -269,6 +273,7 @@ export function WorkspaceSurface({
dock={dock}
initialFocus={initialMenuFocus.current}
reviewUnavailableReason={reviewUnavailableReason}
sideChatAvailable={sideChatAvailable}
onSelect={selectFromMenu}
onClose={closeMenu}
/>
+28
View File
@@ -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(<ActiveSession sessionId={id} />)
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'
+6 -1
View File
@@ -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}
/>
</aside>
</>
) : 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 && (
<div className="flex justify-end px-4 py-2">
<IconButton icon={<MessageCircleQuestion size={16} strokeWidth={1.75} aria-hidden="true" />} label={t('sideChat.title')} size="sm"
pressed={sideChatOpen} onClick={() => void openSideChat(activeTabId)} />