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
This commit is contained in:
Bohdan Triapitsyn
2026-08-11 17:00:23 +03:00
parent a20b87e984
commit 22b3665077
4 changed files with 80 additions and 42 deletions
@@ -712,6 +712,13 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
const isVSCode = isVSCodeRuntime(); const isVSCode = isVSCodeRuntime();
const chatSurfaceMode = useChatSurfaceMode(); const chatSurfaceMode = useChatSurfaceMode();
const draftOpen = Boolean(newSessionDraft?.open); 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); const initError = useGlobalSyncStore((s) => s.error);
// Despite the historical name, this now covers mobile too: the mobile // Despite the historical name, this now covers mobile too: the mobile
// composer enters the same fullscreen-input mode via its drag handle. // composer enters the same fullscreen-input mode via its drag handle.
@@ -722,12 +729,10 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
// row that holds both columns, so its width never depends on the panel's // row that holds both columns, so its width never depends on the panel's
// own visibility. // own visibility.
const { rowRef: workStatusRowRef, visible: workStatusVisible, fits: workStatusFits } = useWorkStatusVisibility({ const { rowRef: workStatusRowRef, visible: workStatusVisible, fits: workStatusFits } = useWorkStatusVisibility({
directory: effectiveSessionDirectory, directory: workStatusDirectory,
isMobile, isMobile,
isVSCode, 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 // Surfaces that never host the panel skip it entirely; the rest keep it
// mounted so its visibility can animate rather than snap. // mounted so its visibility can animate rather than snap.
const workStatusPanelMountable = !isMobile const workStatusPanelMountable = !isMobile
@@ -1082,20 +1087,37 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
// No transform on this root: it would become the containing block for // No transform on this root: it would become the containing block for
// the fullscreen composer's position:fixed visual-viewport pinning in // the fullscreen composer's position:fixed visual-viewport pinning in
// mobile browsers (see ChatInput's composerFormRef effect). // mobile browsers (see ChatInput's composerFormRef effect).
<div data-composer-bound className="relative flex h-full flex-col bg-background"> <div ref={workStatusRowRef} className="flex h-full min-h-0 bg-background">
{useCompactDraftLayout && !isDesktopExpandedInput ? <DraftWelcome /> : null} <div data-composer-bound className="relative flex min-w-0 flex-1 flex-col bg-background">
<div {useCompactDraftLayout && !isDesktopExpandedInput ? <DraftWelcome /> : null}
className={cn( <div
'relative z-10 flex min-h-0', className={cn(
isDesktopExpandedInput 'relative z-10 flex min-h-0',
? 'flex-1 bg-background' isDesktopExpandedInput
: useCompactDraftLayout ? 'flex-1 bg-background'
? 'bg-background px-0' : useCompactDraftLayout
: 'flex-1 items-center justify-center bg-background px-0 pb-[6vh]' ? 'bg-background px-0'
)} : 'flex-1 items-center justify-center bg-background px-0 pb-[6vh]'
> )}
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={scrollToBottomOnSend} />} >
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={scrollToBottomOnSend} />}
</div>
{workStatusOverlayMountable ? (
<WorkStatusPanel
overlay
visible={showWorkStatusOverlay}
sessionId={null}
directory={workStatusDirectory ?? null}
/>
) : null}
</div> </div>
{workStatusPanelMountable ? (
<WorkStatusPanel
visible={showWorkStatusPanel}
sessionId={null}
directory={workStatusDirectory ?? null}
/>
) : null}
</div> </div>
); );
} }
@@ -1280,7 +1302,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
overlay overlay
visible={showWorkStatusOverlay} visible={showWorkStatusOverlay}
sessionId={currentSessionId ?? null} sessionId={currentSessionId ?? null}
directory={effectiveSessionDirectory ?? null} directory={workStatusDirectory ?? null}
/> />
) : null} ) : null}
@@ -1302,7 +1324,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, aut
<WorkStatusPanel <WorkStatusPanel
visible={showWorkStatusPanel} visible={showWorkStatusPanel}
sessionId={currentSessionId ?? null} sessionId={currentSessionId ?? null}
directory={effectiveSessionDirectory ?? null} directory={workStatusDirectory ?? null}
/> />
) : null} ) : null}
</div> </div>
@@ -50,10 +50,9 @@ exactly as it already does when the context panel opens.
`WORK_STATUS_PANEL_WIDTH` of panel. `WORK_STATUS_PANEL_WIDTH` of panel.
`ChatContainer` additionally suppresses it in mini-chat and in expanded-input `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 mode. It remains available on a new-session draft: when the draft targets a
its own layout before the one that hosts the panel. The repository readouts project or pending worktree, the panel uses that directory for project, MCP,
would apply there — branch and working-tree state inform what to ask for — so and usage readouts before a session exists.
this is a gap worth closing rather than a decision.
`rowRef` is a **callback ref, not an object ref**. An object ref gives no signal `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 when the node attaches, so the measuring effect read `.current`, found nothing
@@ -8,7 +8,8 @@ import { useSession, useSessionMessages } from '@/sync/sync-context';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore'; import { useUIStore } from '@/stores/useUIStore';
import { useProjectsStore } from '@/stores/useProjectsStore'; 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 { resolveUsageTone } from '@/lib/quota';
import { sessionEvents } from '@/lib/sessionEvents'; import { sessionEvents } from '@/lib/sessionEvents';
import { normalizePath } from '@/lib/pathNormalization'; import { normalizePath } from '@/lib/pathNormalization';
@@ -85,27 +86,22 @@ export const WorkStatusPrimaryGroup: React.FC<Props> = ({ sessionId, directory,
const branch = gitStatus?.current?.trim() || null; const branch = gitStatus?.current?.trim() || null;
// The panel's directory can be a worktree, so the project is the registered const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
// one whose path contains it — longest match wins, since projects can nest. // 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( const projectLabel = useProjectsStore(
React.useCallback((state) => { React.useCallback((state) => {
const normalizedDirectory = normalizeProjectPath(directory ?? null); const project = resolveProjectForSessionDirectory(
if (!normalizedDirectory) return null; state.projects,
let best: { path: string; label: string } | null = null; availableWorktreesByProject,
for (const project of state.projects) { directory,
const projectPath = normalizeProjectPath(project.path); );
if (!projectPath) continue; if (!project) return null;
const contains = normalizedDirectory === projectPath return project.label?.trim()
|| normalizedDirectory.startsWith(`${projectPath}/`); || project.path.split('/').filter(Boolean).pop()
if (!contains) continue; || project.path;
if (best && best.path.length >= projectPath.length) continue; }, [availableWorktreesByProject, directory]),
const label = project.label?.trim()
|| projectPath.split('/').filter(Boolean).pop()
|| projectPath;
best = { path: projectPath, label };
}
return best?.label ?? null;
}, [directory]),
); );
// Read-only: PR watching is owned by the background tracker. Starting a watch // Read-only: PR watching is owned by the background tracker. Starting a watch
@@ -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]);
});
});