mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
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:
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
Reference in New Issue
Block a user