From e9701548096194a2d04b1d2451e4b32e97b16667 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 22 Aug 2026 17:24:05 +0300 Subject: [PATCH] fix(chat): yield the work status card to the context panel in project-less chats The visibility hook looked the context panel up under the directory the card reports about. A managed Chat reports about none, so the lookup ran against an empty key and answered "closed" while the panel was open. It now resolves the key the way the rail and the panel do, through useEffectiveDirectory. The reporting directory is no longer an input to this hook at all. --- .../ui/src/components/chat/ChatContainer.tsx | 1 - .../chat/work-status/DOCUMENTATION.md | 6 ++- .../useWorkStatusVisibility.test.ts | 43 ++++++++++++------- .../work-status/useWorkStatusVisibility.ts | 17 +++++--- 4 files changed, 45 insertions(+), 22 deletions(-) diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 433db7fa..d7676df7 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -761,7 +761,6 @@ export const ChatContainer: React.FC = ({ // row that holds both columns, so its width never depends on the panel's // own visibility. const { rowRef: workStatusRowRef, visible: workStatusVisible, fits: workStatusFits } = useWorkStatusVisibility({ - directory: workStatusDirectory, isMobile, isVSCode, }); diff --git a/packages/ui/src/components/chat/work-status/DOCUMENTATION.md b/packages/ui/src/components/chat/work-status/DOCUMENTATION.md index d2a872c6..0c310f50 100644 --- a/packages/ui/src/components/chat/work-status/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/work-status/DOCUMENTATION.md @@ -45,7 +45,11 @@ exactly as it already does when the context panel opens. - the user switched it off; - the runtime is mobile or VS Code; -- the context panel is open for the active directory; +- the context panel is open for the directory the app is effectively on — + looked up through `useEffectiveDirectory` and `normalizeContextPanelDirectoryKey`, + the same key the rail and the panel use. It is deliberately **not** the + directory this panel reports about: a managed Chat reports about none, and + that empty key answered "closed" for a context panel that was plainly open; - the row cannot fit `WORK_STATUS_MIN_CHAT_WIDTH` of transcript alongside `WORK_STATUS_PANEL_WIDTH` of panel. diff --git a/packages/ui/src/components/chat/work-status/useWorkStatusVisibility.test.ts b/packages/ui/src/components/chat/work-status/useWorkStatusVisibility.test.ts index ca502ab2..0e2980ba 100644 --- a/packages/ui/src/components/chat/work-status/useWorkStatusVisibility.test.ts +++ b/packages/ui/src/components/chat/work-status/useWorkStatusVisibility.test.ts @@ -10,14 +10,16 @@ type PanelState = { let panelByDirectory: Record = {}; let panelEnabled = true; +let effectiveDirectory: string | undefined = '/repo'; mock.module('@/stores/useUIStore', () => ({ useUIStore: (selector: (state: unknown) => unknown) => selector({ contextPanelByDirectory: panelByDirectory, workStatusPanelEnabled: panelEnabled }), + normalizeContextPanelDirectoryKey: (value: string) => value, })); -mock.module('@/lib/pathNormalization', () => ({ - normalizePath: (value?: string | null) => value ?? null, +mock.module('@/hooks/useEffectiveDirectory', () => ({ + useEffectiveDirectory: () => effectiveDirectory, })); const { useWorkStatusVisibility, WORK_STATUS_REQUIRED_ROW_WIDTH: REQUIRED } = await import( @@ -88,7 +90,7 @@ const installMinimalDom = () => { }; }; -type Args = { directory: string | null; isMobile: boolean; isVSCode: boolean }; +type Args = { isMobile: boolean; isVSCode: boolean }; /** * Renders the hook with a stand-in row node, attached through the returned @@ -130,6 +132,7 @@ const renderVisibility = (args: Args, rowWidth: number) => { beforeEach(() => { panelByDirectory = {}; panelEnabled = true; + effectiveDirectory = '/repo'; observed = []; notify = null; }); @@ -142,7 +145,7 @@ afterEach(() => { describe('useWorkStatusVisibility', () => { test('shows the panel when the row can afford both columns', () => { const { result, teardown } = renderVisibility( - { directory: '/repo', isMobile: false, isVSCode: false }, + { isMobile: false, isVSCode: false }, REQUIRED, ); expect(result.visible).toBe(true); @@ -151,7 +154,7 @@ describe('useWorkStatusVisibility', () => { test('hides the panel when the row cannot afford both columns', () => { const { result, teardown } = renderVisibility( - { directory: '/repo', isMobile: false, isVSCode: false }, + { isMobile: false, isVSCode: false }, REQUIRED - 1, ); expect(result.visible).toBe(false); @@ -173,7 +176,6 @@ describe('useWorkStatusVisibility', () => { const Probe: React.FC = () => { const { rowRef, visible } = useWorkStatusVisibility({ - directory: '/repo', isMobile: false, isVSCode: false, }); @@ -199,7 +201,7 @@ describe('useWorkStatusVisibility', () => { // In the app this is the chat area (chat + context panel); here `closest` // finds nothing, so the hook falls back to the row it was given. const { rowNode, teardown } = renderVisibility( - { directory: '/repo', isMobile: false, isVSCode: false }, + { isMobile: false, isVSCode: false }, REQUIRED, ); expect(observed).toHaveLength(1); @@ -209,7 +211,7 @@ describe('useWorkStatusVisibility', () => { test('reacts to a live resize across the threshold', () => { const { result, teardown } = renderVisibility( - { directory: '/repo', isMobile: false, isVSCode: false }, + { isMobile: false, isVSCode: false }, REQUIRED, ); expect(result.visible).toBe(true); @@ -228,7 +230,7 @@ describe('useWorkStatusVisibility', () => { '/repo': { isOpen: true, tabs: [{ id: 'tab-1', mode: 'git' }], activeTabId: 'tab-1' }, }; const { result, rowNode, teardown } = renderVisibility( - { directory: '/repo', isMobile: false, isVSCode: false }, + { isMobile: false, isVSCode: false }, REQUIRED, ); expect(result.visible).toBe(false); @@ -236,11 +238,23 @@ describe('useWorkStatusVisibility', () => { teardown(); }); + test('yields to the open context panel even when the chat reports on no project', () => { + // A Chat session carries no repository, so the panel describes no + // directory. The context panel is still keyed by the directory the app is + // on, and looking it up under the chat's empty one answered "closed". + panelByDirectory = { + '/repo': { isOpen: true, tabs: [{ id: 'tab-1', mode: 'git' }], activeTabId: 'tab-1' }, + }; + const { result, teardown } = renderVisibility({ isMobile: false, isVSCode: false }, REQUIRED); + expect(result.visible).toBe(false); + teardown(); + }); + test('ignores an open context panel that has no resolvable tab', () => { // ContextPanel renders nothing in that state, so it displaces nothing. panelByDirectory = { '/repo': { isOpen: true, tabs: [], activeTabId: null } }; const { result, teardown } = renderVisibility( - { directory: '/repo', isMobile: false, isVSCode: false }, + { isMobile: false, isVSCode: false }, REQUIRED, ); expect(result.visible).toBe(true); @@ -264,7 +278,6 @@ describe('useWorkStatusVisibility', () => { const Probe: React.FC = () => { const [attached, setAttached] = React.useState(false); const { rowRef, visible } = useWorkStatusVisibility({ - directory: '/repo', isMobile: false, isVSCode: false, }); @@ -292,7 +305,7 @@ describe('useWorkStatusVisibility', () => { // there is room for it. panelEnabled = false; const { result, teardown } = renderVisibility( - { directory: '/repo', isMobile: false, isVSCode: false }, + { isMobile: false, isVSCode: false }, REQUIRED * 2, ); expect(result.visible).toBe(false); @@ -302,7 +315,7 @@ describe('useWorkStatusVisibility', () => { test('reports no fit when the row is too narrow, whatever the switch says', () => { const { result, teardown } = renderVisibility( - { directory: '/repo', isMobile: false, isVSCode: false }, + { isMobile: false, isVSCode: false }, REQUIRED - 1, ); expect(result.fits).toBe(false); @@ -312,7 +325,7 @@ describe('useWorkStatusVisibility', () => { test('stays hidden on mobile and in VS Code regardless of width', () => { const mobile = renderVisibility( - { directory: '/repo', isMobile: true, isVSCode: false }, + { isMobile: true, isVSCode: false }, REQUIRED * 2, ); expect(mobile.result.visible).toBe(false); @@ -320,7 +333,7 @@ describe('useWorkStatusVisibility', () => { observed = []; const vscode = renderVisibility( - { directory: '/repo', isMobile: false, isVSCode: true }, + { isMobile: false, isVSCode: true }, REQUIRED * 2, ); expect(vscode.result.visible).toBe(false); diff --git a/packages/ui/src/components/chat/work-status/useWorkStatusVisibility.ts b/packages/ui/src/components/chat/work-status/useWorkStatusVisibility.ts index 541e4ae7..ecbdfe1d 100644 --- a/packages/ui/src/components/chat/work-status/useWorkStatusVisibility.ts +++ b/packages/ui/src/components/chat/work-status/useWorkStatusVisibility.ts @@ -1,6 +1,6 @@ import React from 'react'; -import { useUIStore } from '@/stores/useUIStore'; -import { normalizePath } from '@/lib/pathNormalization'; +import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore'; +import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; /** * Fixed panel width. The panel is not user-resizable: it is an object inside @@ -23,7 +23,6 @@ export const WORK_STATUS_REQUIRED_ROW_WIDTH = WORK_STATUS_PANEL_WIDTH + WORK_STATUS_PANEL_GUTTER + WORK_STATUS_MIN_CHAT_WIDTH; type Options = { - directory: string | null | undefined; isMobile: boolean; isVSCode: boolean; }; @@ -52,12 +51,20 @@ type Result = { * panel, oscillating forever. The row width is independent of the panel, so it * is the only stable input. */ -export const useWorkStatusVisibility = ({ directory, isMobile, isVSCode }: Options): Result => { +export const useWorkStatusVisibility = ({ isMobile, isVSCode }: Options): Result => { const [rowNode, setRowNode] = React.useState(null); const [rowWidth, setRowWidth] = React.useState(null); const rowRef = React.useCallback((node: HTMLDivElement | null) => { setRowNode(node); }, []); - const directoryKey = React.useMemo(() => normalizePath(directory ?? null), [directory]); + // Keyed exactly like the rail and the panel itself: whichever directory the + // app is effectively on, not the directory this panel reports about. A chat + // with no project reports on nothing, and looking the context panel up under + // that empty key answered "closed" while it was plainly open on screen. + const effectiveDirectory = useEffectiveDirectory(); + const directoryKey = React.useMemo( + () => (effectiveDirectory ? normalizeContextPanelDirectoryKey(effectiveDirectory) : ''), + [effectiveDirectory], + ); // Mirrors ContextPanel's own derivation: a panel with `isOpen` but no // resolvable active tab renders nothing, and must not displace this panel.