Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 66 additions & 18 deletions memory/PLAN.md

Large diffs are not rendered by default.

46 changes: 21 additions & 25 deletions src/client/__tests__/router.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ import type { SpecificationState } from '@/shared/specification.js';
import { queryClient } from '../query-client.js';

const fetchMock = vi.fn<typeof fetch>();
let interviewViewRenderCount = 0;
let interviewViewMountCount = 0;
let interviewViewUnmountCount = 0;
let workspaceViewRenderCount = 0;
let workspaceViewMountCount = 0;
let workspaceViewUnmountCount = 0;

const minimalSpecificationState: SpecificationState = {
specification: {
Expand Down Expand Up @@ -86,18 +86,14 @@ vi.mock('../routes/-project-list.js', () => ({
fetchSpecificationListLoaderData: vi.fn(async () => []),
}));

vi.mock('../routes/specification/$id/_view/-interview-controller', () => ({
useInterviewController: () => ({ __brand: 'interview-controller' }),
}));

vi.mock('../routes/specification/$id/_view/-interview-view.js', () => ({
InterviewView: () => {
interviewViewRenderCount += 1;
vi.mock('../routes/specification/$id/_view/-continuous-workspace-view.js', () => ({
ContinuousWorkspaceView: () => {
workspaceViewRenderCount += 1;

useEffect(() => {
interviewViewMountCount += 1;
workspaceViewMountCount += 1;
return () => {
interviewViewUnmountCount += 1;
workspaceViewUnmountCount += 1;
};
}, []);

Expand Down Expand Up @@ -171,9 +167,9 @@ async function renderRouteAt(pathname: string) {
beforeEach(() => {
queryClient.clear();
fetchMock.mockReset();
interviewViewRenderCount = 0;
interviewViewMountCount = 0;
interviewViewUnmountCount = 0;
workspaceViewRenderCount = 0;
workspaceViewMountCount = 0;
workspaceViewUnmountCount = 0;
fetchMock.mockImplementation(async (input) => defaultFetchHandler(input));
vi.stubGlobal('fetch', fetchMock);
});
Expand Down Expand Up @@ -303,9 +299,9 @@ describe('generated routeTree', () => {
await renderRouteAt('/specification/42/grounding');

expect(await screen.findByRole('heading', { name: 'Interview screen' })).toBeTruthy();
expect(interviewViewRenderCount).toBe(1);
expect(interviewViewMountCount).toBe(1);
expect(interviewViewUnmountCount).toBe(0);
expect(workspaceViewRenderCount).toBe(1);
expect(workspaceViewMountCount).toBe(1);
expect(workspaceViewUnmountCount).toBe(0);

await act(async () => {
await queryClient.invalidateQueries({ queryKey: ['specification', '42', 'entities'] });
Expand All @@ -324,17 +320,17 @@ describe('generated routeTree', () => {
return url === '/api/specifications/42';
});
expect(specificationFetches).toHaveLength(1);
expect(interviewViewRenderCount).toBe(1);
expect(interviewViewMountCount).toBe(1);
expect(interviewViewUnmountCount).toBe(0);
expect(workspaceViewRenderCount).toBe(1);
expect(workspaceViewMountCount).toBe(1);
expect(workspaceViewUnmountCount).toBe(0);
});

it('refreshes the specification bundle without remounting the interview route for mutation-owned invalidation', async () => {
await renderRouteAt('/specification/42/grounding');

expect(await screen.findByRole('heading', { name: 'Interview screen' })).toBeTruthy();
expect(interviewViewMountCount).toBe(1);
expect(interviewViewUnmountCount).toBe(0);
expect(workspaceViewMountCount).toBe(1);
expect(workspaceViewUnmountCount).toBe(0);

await act(async () => {
await queryClient.invalidateQueries({ queryKey: ['specification', '42', 'bundle'] });
Expand All @@ -353,8 +349,8 @@ describe('generated routeTree', () => {
return url === '/api/specifications/42/entities?mode=active-path';
});
expect(entityFetches).toHaveLength(1);
expect(interviewViewMountCount).toBe(1);
expect(interviewViewUnmountCount).toBe(0);
expect(workspaceViewMountCount).toBe(1);
expect(workspaceViewUnmountCount).toBe(0);
});

it('redirects a completed specification index to the output route through one authoritative bundle fetch path', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
} from '@/shared/phase-descriptors.js';
import type { SpecificationTurn } from '@/shared/specification.js';

import { useWorkspaceFocus } from './-workspace-focus.js';

function formatStatus(status: WorkflowPhaseState['status']): string {
switch (status) {
case 'closed':
Expand Down Expand Up @@ -96,6 +98,8 @@ export function PhaseNavigationSidebar({
const currentReachablePhase = getCurrentReachablePhase(workflow);
const phaseTurnCounts = getPhaseTurnCounts(turns);
const outputAvailable = allWorkflowPhasesClosed(workflow);
const workspaceFocus = useWorkspaceFocus();
const focusedPhase = workspaceFocus?.focusedPhase ?? null;

return (
<aside
Expand Down Expand Up @@ -185,8 +189,11 @@ export function PhaseNavigationSidebar({
<Link
to={getPhaseRoutePath(phase) as '/specification/$id/grounding'}
params={{ id: specificationId }}
activeProps={{ className: 'is-active' }}
className="group/phase block min-w-0 text-left transition-colors"
activeProps={focusedPhase ? undefined : { className: 'is-active' }}
Comment thread
kostandinang marked this conversation as resolved.
className={cn(
'group/phase block min-w-0 text-left transition-colors',
focusedPhase === phase && 'is-active',
)}
>
{body}
</Link>
Expand Down
21 changes: 21 additions & 0 deletions src/client/routes/specification/$id/-workspace-focus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { createContext, useContext, useMemo, useState, type ReactNode } from 'react';

import type { WorkflowPhase } from '@/shared/api-types.js';

interface WorkspaceFocusState {
readonly focusedPhase: WorkflowPhase | null;
readonly setFocusedPhase: (phase: WorkflowPhase | null) => void;
}

const WorkspaceFocusContext = createContext<WorkspaceFocusState | null>(null);

export function WorkspaceFocusProvider({ children }: { children: ReactNode }) {
const [focusedPhase, setFocusedPhase] = useState<WorkflowPhase | null>(null);
const value = useMemo(() => ({ focusedPhase, setFocusedPhase }), [focusedPhase]);

return <WorkspaceFocusContext value={value}>{children}</WorkspaceFocusContext>;
}

export function useWorkspaceFocus(): WorkspaceFocusState | null {
return useContext(WorkspaceFocusContext);
}
Loading
Loading