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:
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user