From 22b3665077d53e80714bda4b397430b8450defe2 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 11 Aug 2026 17:00:03 +0300 Subject: [PATCH] feat: show work status on draft sessions and resolve project worktrees Keeps the work status panel aligned to the draft's target directory Resolves project labels for sibling worktrees using shared session-directory logic Adds coverage for resolving a worktree back to its registered project --- .../ui/src/components/chat/ChatContainer.tsx | 58 +++++++++++++------ .../chat/work-status/DOCUMENTATION.md | 7 +-- .../work-status/WorkStatusPrimaryGroup.tsx | 36 +++++------- packages/ui/src/lib/projectResolution.test.ts | 21 +++++++ 4 files changed, 80 insertions(+), 42 deletions(-) create mode 100644 packages/ui/src/lib/projectResolution.test.ts diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 476b2f14..d2e7ffd1 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -712,6 +712,13 @@ export const ChatContainer: React.FC = ({ active = true, aut const isVSCode = isVSCodeRuntime(); const chatSurfaceMode = useChatSurfaceMode(); const draftOpen = Boolean(newSessionDraft?.open); + // A draft can target another project or a pending worktree before it has a + // session. Keep the panel on that same directory so its project, MCP, and + // usage readouts describe where the draft will run rather than the project + // the user came from. + const workStatusDirectory = draftOpen + ? newSessionDraft?.bootstrapPendingDirectory ?? newSessionDraft?.directoryOverride ?? effectiveSessionDirectory + : effectiveSessionDirectory; const initError = useGlobalSyncStore((s) => s.error); // Despite the historical name, this now covers mobile too: the mobile // composer enters the same fullscreen-input mode via its drag handle. @@ -722,12 +729,10 @@ export const ChatContainer: React.FC = ({ active = true, aut // 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: effectiveSessionDirectory, + directory: workStatusDirectory, isMobile, isVSCode, }); - // Session view only. The draft branch returns its own layout before this - // one, so the panel has no place there yet. // Surfaces that never host the panel skip it entirely; the rest keep it // mounted so its visibility can animate rather than snap. const workStatusPanelMountable = !isMobile @@ -1082,20 +1087,37 @@ export const ChatContainer: React.FC = ({ active = true, aut // No transform on this root: it would become the containing block for // the fullscreen composer's position:fixed visual-viewport pinning in // mobile browsers (see ChatInput's composerFormRef effect). -
- {useCompactDraftLayout && !isDesktopExpandedInput ? : null} -
- {promptReadOnly ? : } +
+
+ {useCompactDraftLayout && !isDesktopExpandedInput ? : null} +
+ {promptReadOnly ? : } +
+ {workStatusOverlayMountable ? ( + + ) : null}
+ {workStatusPanelMountable ? ( + + ) : null}
); } @@ -1280,7 +1302,7 @@ export const ChatContainer: React.FC = ({ active = true, aut overlay visible={showWorkStatusOverlay} sessionId={currentSessionId ?? null} - directory={effectiveSessionDirectory ?? null} + directory={workStatusDirectory ?? null} /> ) : null} @@ -1302,7 +1324,7 @@ export const ChatContainer: React.FC = ({ active = true, aut ) : null}
diff --git a/packages/ui/src/components/chat/work-status/DOCUMENTATION.md b/packages/ui/src/components/chat/work-status/DOCUMENTATION.md index d19da5c8..ae573895 100644 --- a/packages/ui/src/components/chat/work-status/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/work-status/DOCUMENTATION.md @@ -50,10 +50,9 @@ exactly as it already does when the context panel opens. `WORK_STATUS_PANEL_WIDTH` of panel. `ChatContainer` additionally suppresses it in mini-chat and in expanded-input -mode, and the panel does not appear on a new-session draft: that branch returns -its own layout before the one that hosts the panel. The repository readouts -would apply there — branch and working-tree state inform what to ask for — so -this is a gap worth closing rather than a decision. +mode. It remains available on a new-session draft: when the draft targets a +project or pending worktree, the panel uses that directory for project, MCP, +and usage readouts before a session exists. `rowRef` is a **callback ref, not an object ref**. An object ref gives no signal when the node attaches, so the measuring effect read `.current`, found nothing diff --git a/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx b/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx index e1ad8669..a5f12e15 100644 --- a/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx +++ b/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx @@ -8,7 +8,8 @@ import { useSession, useSessionMessages } from '@/sync/sync-context'; import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore } from '@/stores/useUIStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; -import { normalizeProjectPath } from '@/lib/projectResolution'; +import { resolveProjectForSessionDirectory } from '@/lib/projectResolution'; +import { useSessionUIStore } from '@/sync/session-ui-store'; import { resolveUsageTone } from '@/lib/quota'; import { sessionEvents } from '@/lib/sessionEvents'; import { normalizePath } from '@/lib/pathNormalization'; @@ -85,27 +86,22 @@ export const WorkStatusPrimaryGroup: React.FC = ({ sessionId, directory, const branch = gitStatus?.current?.trim() || null; - // The panel's directory can be a worktree, so the project is the registered - // one whose path contains it — longest match wins, since projects can nest. + const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); + // Worktrees normally sit beside rather than beneath their project directory, + // so a prefix match alone cannot find their owning project. Reuse the shared + // session-directory resolver, which consults the discovered worktree map. const projectLabel = useProjectsStore( React.useCallback((state) => { - const normalizedDirectory = normalizeProjectPath(directory ?? null); - if (!normalizedDirectory) return null; - let best: { path: string; label: string } | null = null; - for (const project of state.projects) { - const projectPath = normalizeProjectPath(project.path); - if (!projectPath) continue; - const contains = normalizedDirectory === projectPath - || normalizedDirectory.startsWith(`${projectPath}/`); - if (!contains) continue; - if (best && best.path.length >= projectPath.length) continue; - const label = project.label?.trim() - || projectPath.split('/').filter(Boolean).pop() - || projectPath; - best = { path: projectPath, label }; - } - return best?.label ?? null; - }, [directory]), + const project = resolveProjectForSessionDirectory( + state.projects, + availableWorktreesByProject, + directory, + ); + if (!project) return null; + return project.label?.trim() + || project.path.split('/').filter(Boolean).pop() + || project.path; + }, [availableWorktreesByProject, directory]), ); // Read-only: PR watching is owned by the background tracker. Starting a watch diff --git a/packages/ui/src/lib/projectResolution.test.ts b/packages/ui/src/lib/projectResolution.test.ts new file mode 100644 index 00000000..16fc823d --- /dev/null +++ b/packages/ui/src/lib/projectResolution.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'bun:test'; +import { resolveProjectForSessionDirectory } from './projectResolution'; + +const projects = [ + { id: 'openchamber', path: '/workspace/openchamber', label: 'OpenChamber' }, +]; + +describe('resolveProjectForSessionDirectory', () => { + test('resolves a sibling worktree to its registered project', () => { + const worktrees = new Map([ + ['/workspace/openchamber', [{ + path: '/workspace/openchamber-feature', + projectDirectory: '/workspace/openchamber', + branch: 'feature', + label: 'feature', + }]], + ]); + + expect(resolveProjectForSessionDirectory(projects, worktrees, '/workspace/openchamber-feature')).toEqual(projects[0]); + }); +});