From 5f8d79fc9afb705e60e2cfeb3991849c82cedd1a Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Wed, 13 May 2026 20:29:27 +0200 Subject: [PATCH 01/13] =?UTF-8?q?FE-7XX:=20Planning=20sync=20=E2=80=94=20C?= =?UTF-8?q?onversational=20Workspace=20Runtime=20umbrella?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Planning baseline for the umbrella's frontier-item decomposition. Adds three new frontier definitions for the runtime tracks not yet captured in PLAN.md (Tracks 2/3/5), flips continuous-workspace to done after FE-709, and retires side-chat-persistence-v4a as superseded by Track 2. This is a draft planning PR — Linear issue TBD. Per planning-pr convention: planning baselines for shared downstream branches go on a separate planning branch off main (here, off ln/fe-705-extensions so it inherits the new PLAN.md format from Lu's stack and shows only the umbrella additions in the diff). Changes: - Context paragraph: name the umbrella + its 5 sub-tracks - Sequencing: continuous-workspace done; insert chat-runtime-threads, thread-context-provision, reconciliation-runtime into Next - Three new frontier definitions: chat-runtime-threads (Track 2), reconciliation-runtime (Track 3), thread-context-provision (Track 5) - continuous-workspace: Linear=FE-709, Status=done - Horizon: side-chat-persistence-v4a removed (superseded) - Recently Completed: FE-709 entry - Dependencies: TRACK A restructured as the umbrella; RETIRED section added for v4a Co-Authored-By: Claude Opus 4.7 (1M context) --- memory/PLAN.md | 84 +++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 66 insertions(+), 18 deletions(-) diff --git a/memory/PLAN.md b/memory/PLAN.md index b860f0cc..ba3aabd1 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -15,7 +15,7 @@ The interaction model is mature: four-phase interview, interviewer-autonomous question format, phase-agnostic preface cards with workspace exploration, structured review with per-item commenting, observer knowledge extraction, workflow ownership extraction, distribution hardening, graph view's structured-list peer route, the first relation-first observer capture seam, the multi-chat substrate, side-chat V3.0 hard-impact cascade, and side-chat V3.1 agent-grouped reconciliation resolution all ship as working product. -The next product arc is a **continuous conversational workspace** plus a stronger semantic/generative substrate. Continuous workspace is active in a parallel lane and gives the chat runtime a stable phase-addressable host. The FE-705 branch contributes an integration substrate — a local agent capability CLI and external LLM-as-user probe harness — that should be reconciled into main before graph-review and scenario-options work depends on generated completed-spec fixtures. After that, the highest-coordination work is intent-graph semantics and the semantic changeset ledger; FE-701 should follow soon after the FE-705 reconciliation because the current schema already carries transitional multi-chat / reconciliation placeholders that only become coherent once `changeset` / `change` owns semantic mutation history. Lower-coordination provider, gitignore, and web-research work can proceed in parallel. +The next product arc is the **Conversational Workspace Runtime** umbrella (`docs/design/CONVERSATIONAL_WORKSPACE_RUNTIME.md`) plus a stronger semantic/generative substrate. The umbrella synthesizes MULTI_CHAT, SIDE_CHAT, PATCH_LEDGER, and CONTINUOUS_WORKSPACE_HYBRID into five sub-tracks: workspace shell (Track 1, shipped as `continuous-workspace` / FE-709), chat runtime with thread substrate (`chat-runtime-threads`), reconciliation runtime absorption (`reconciliation-runtime`), changeset ledger (`changeset-ledger`), and thread context provision (`thread-context-provision`). The shell is now the stable host; the chat runtime is the critical unblocker for reconciliation absorption and context provision; the changeset ledger runs in parallel. The umbrella supersedes the independent side-chat V4a persistence horizon — persistent side-chat history becomes the main chat stream where threads stay collapsed. The FE-705 branch contributes an integration substrate — a local agent capability CLI and external LLM-as-user probe harness — that should be reconciled into main before graph-review and scenario-options work depends on generated completed-spec fixtures. After that, the highest-coordination work is intent-graph semantics and the semantic changeset ledger; FE-701 should follow soon after the FE-705 reconciliation because the current schema already carries transitional multi-chat / reconciliation placeholders that only become coherent once `changeset` / `change` owns semantic mutation history. Lower-coordination provider, gitignore, and web-research work can proceed in parallel. The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agent-mutation design notes are reconciled into one direction. `docs/design/MULTI_CHAT.md` is the substrate document. `docs/design/SIDE_CHAT.md` describes side-chat V1 / V2 / V3.0 / V3.1 / V4 phasing on top of that substrate. `docs/design/PATCH_LEDGER.md` remains historical deeper design pressure for semantic mutation history, but canonical future-facing vocabulary is `changeset` / `change`. The product-layer ontology trajectory is split out as `docs/design/INTENT_GRAPH_SEMANTICS.md` and `docs/design/BEHAVIORAL_KERNELS.md`; broader synthesis lives in `docs/archive/design/INTENT_SPEC_EVOLUTION.md`. FE-705's branch-local strategy/proposal notes add scenario options, graph-review oracle, chat-local strategies, and concern/dependency mapping; those notes should become a canonical design doc when the branch is integrated. Coordination uses a substrate-strangler posture: keep existing frontend REST/SSE contracts stable while route adapters and capability adapters converge on shared server-owned handlers, then cut over UI flows only after parity and changeset-backed authority exist. The dev-layer self-tooling trajectory lives in `docs/design/ln-skills/EVOLUTION.md`. @@ -23,15 +23,17 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen ### Active -1. `continuous-workspace` — in progress in parallel lane — stable phase-addressable host for the chat runtime. -2. `agent-fixture-substrate` — branch-complete off main, reconciling — FE-705 integration substrate for JSONL agent capability CLI and LLM-as-user probes. +1. `agent-fixture-substrate` — branch-complete off main, reconciling — FE-705 integration substrate for JSONL agent capability CLI and LLM-as-user probes. ### Next -1. `intent-graph-semantics` — highest-coordination semantic substrate after FE-705 reconciliation. -2. `changeset-ledger` — schedule soon after FE-705 reconciliation; semantic history spine needed before canonical proposal acceptance, direct-edit atomicity, and productized scenario options. -3. `graph-review-scenario-options` — artifact-only critique/probe lane; can advance in parallel with FE-700 if it does not commit canonical graph truth. -4. `productized-scenario-options` — user-facing acceleration surface after FE-700 semantics, FE-701 changesets, and graph-review probes. +1. `chat-runtime-threads` — Track 2 of the runtime umbrella; immediate successor to continuous-workspace, unblocker for Tracks 3 and 5. First slice should be a sub-RFC on the thread substrate shape (p / q / r). +2. `intent-graph-semantics` — highest-coordination semantic substrate after FE-705 reconciliation. +3. `changeset-ledger` — Track 4 of the runtime umbrella; parallel with Track 2; semantic history spine needed before canonical proposal acceptance, direct-edit atomicity, and productized scenario options. +4. `thread-context-provision` — Track 5 of the runtime umbrella; after Track 2 lands the thread substrate. +5. `reconciliation-runtime` — Track 3 of the runtime umbrella; after Track 2 + Track 4 provide thread substrate and durable attribution. +6. `graph-review-scenario-options` — artifact-only critique/probe lane; can advance in parallel with FE-700 if it does not commit canonical graph truth. +7. `productized-scenario-options` — user-facing acceleration surface after FE-700 semantics, FE-701 changesets, and graph-review probes. ### Parallel / Low-conflict @@ -44,8 +46,7 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen - `relation-first-observer-enrichment` - `architect-generator-loop` - `server-mini-library-compartmentalization` -- `side-chat-persistence-v4a` -- `side-chat-v4b-item-versioning` +- `side-chat-v4b-item-versioning` (depends on `changeset-ledger`) - `dashboard-summaries` - `spatial-graph-layout` - `graph-view-active-path-filter` @@ -59,16 +60,55 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen ### continuous-workspace -- **Name:** Continuous workspace / phase-addressable interview surface -- **Linear:** unassigned in this plan snapshot +- **Name:** Continuous workspace / phase-addressable interview surface (Conversational Workspace Runtime — Track 1) +- **Linear:** FE-709 - **Kind:** structural -- **Status:** in-progress +- **Status:** done - **Objective:** Replace per-phase rendering boundaries with a cumulative center pane, realized phase sections, one chat runtime per specification, sidebar section navigation, scroll/focus behavior, and preservation of the single actionable frontier at the current reachable phase. - **Why now / unlocks:** Workflow read/write ownership is extracted, the multi-chat substrate ships chat containers below the specification, and side-chat V3.0/V3.1 closed the cascade surface. This gives future side-chat persistence, strategy chats, and graph/workspace routes a stable host without introducing a second durable workflow model. - **Acceptance:** Realized phase sections remain legible, future sections stay unreachable until valid, navigation is focus/scroll state only, and the current phase retains exactly one actionable frontier/recovery/handoff/completion affordance. - **Verification:** Manual workspace walkthroughs across kickoff-ready, active, review-active, recovery, close-to-next-phase, resume/reload, and future-phase deep-link states; regression tests around route/workflow state where available. - **Traceability:** A58; D86, D87, D110, D113, D114; I24, I102. -- **Design docs:** `docs/design/CONTINUOUS_WORKSPACE_HYBRID.md`; umbrella synthesis in `docs/design/CONVERSATIONAL_WORKSPACE_RUNTIME.md`. +- **Design docs:** `docs/design/CONTINUOUS_WORKSPACE_HYBRID.md`; umbrella synthesis in `docs/design/CONVERSATIONAL_WORKSPACE_RUNTIME.md` (Track 1). + +### chat-runtime-threads + +- **Name:** Chat runtime — thread substrate + in-stream rendering (Conversational Workspace Runtime — Track 2) +- **Linear:** unassigned in this plan snapshot +- **Kind:** structural +- **Status:** not-started +- **Objective:** Add a thread primitive to the chat substrate, render threads inline as collapsibles in the main chat surface (Cursor-style), and retire the SideChatPopover and transient staged-patches strip. Decide the thread substrate shape via a sub-RFC: (p) `parent_chat_id` on `chat`, (q) new `thread` table, or (r) UI-only rendering. +- **Why now / unlocks:** Track 1 (workspace shell) ships, providing the stable host. Threads are the critical unblocker for reconciliation absorption into the chat surface (Track 3), `#` mention / turn-zero / context provision (Track 5), and the retirement of the V3.1 popover and staged-patches surfaces. Supersedes the prior side-chat V4a persistence horizon — persistent side-chat history becomes the main chat stream where threads stay collapsed. +- **Acceptance:** Thread kinds (`interview`, `side`, `reconciliation`, `qa`) are representable in the substrate; threads render inline as collapsibles in the unified chat surface; SideChatPopover retires as cutover; transient staged-patches strip retires (replaced by in-thread mutation state); turn-zero (`turn_kind='kickoff'`) becomes the universal thread entry. +- **Verification:** Thread substrate schema/migration tests, in-stream collapsible rendering tests, manual walkthroughs for thread creation/display/collapse per kind, regression on existing interview flow. +- **Traceability:** A82, A83, A88; D86, D87, D110, D114, D138, D146; I111, I113. +- **Design docs:** `docs/design/CONVERSATIONAL_WORKSPACE_RUNTIME.md` §3.2 + §5 Track 2; `docs/design/MULTI_CHAT.md`; `docs/design/SIDE_CHAT.md`. + +### reconciliation-runtime + +- **Name:** Reconciliation runtime — async-by-default with in-stream thread (Conversational Workspace Runtime — Track 3) +- **Linear:** unassigned in this plan snapshot +- **Kind:** structural +- **Status:** not-started +- **Objective:** Absorb reconciliation into the unified chat surface as a target-grouped thread with async-by-default classifier scheduling and a "Reconcile Now" user trigger. Retire the standalone PendingReviewSection. Auto-confirmed rows resolve invisibly; only `auto-edit` (one-click apply) and `substantive` (judgment affordances) reach the user. +- **Why now / unlocks:** Tracks 2 (chat runtime) and 4 (changeset ledger) provide the thread substrate and durable attribution. The reconciliation thread replaces the V3.1 Pending review section and the side-chat popover's reconciliation surface with a conversational target-grouped thread inside the main chat. +- **Acceptance:** Reconciliation thread renders target-grouped (topologically sorted upstream-first per PATCH_LEDGER target ordering); async classifier runs in background; auto-confirmed never surfaces; auto-edit has one-click apply; substantive has judgment affordances; "Reconcile Now" trigger in workspace shell; standalone PendingReviewSection retired as cutover. +- **Verification:** Reconciliation thread rendering tests, classifier scheduling tests, target-ordering tests, manual walkthroughs for async classification + Reconcile Now trigger, regression on existing reconciliation flow. +- **Traceability:** A82, A88; D135, D137, D138, D146; I111, I113, I114. +- **Design docs:** `docs/design/CONVERSATIONAL_WORKSPACE_RUNTIME.md` §3.3 + §5 Track 3; `docs/design/MULTI_CHAT.md` §5; `docs/design/PATCH_LEDGER.md` §Target Ordering, §Reconciliation Flow. + +### thread-context-provision + +- **Name:** Thread context provision — TOON, `#` mention, turn-zero (Conversational Workspace Runtime — Track 5) +- **Linear:** unassigned in this plan snapshot +- **Kind:** structural +- **Status:** not-started +- **Objective:** Implement thread-scoped context specs (scope, root anchors, include rules), per-kind defaults (`full-graph` for interview, `explicit-set` for reconciliation, `neighborhood` for side, `explicit-set` for QA), TOON notation serializer for graph-structured context, `#` mention parser/resolver as a substrate mutation with durable `thread_context_item` rows, and turn-zero kickoff prompt assembly per thread kind. +- **Why now / unlocks:** Track 2 (chat runtime) provides the thread substrate. Context provision makes threads conversationally useful by giving each thread kind appropriate context framing, durable mention tracking, and assistant-led turn-zero kickoffs instead of a blank textarea. +- **Acceptance:** Thread context specs are persisted and replayable; `#` mention resolves to durable join rows and triggers context-spec change visible to the next turn's prompt assembler; TOON notation serializes graph structure for prompt context; turn-zero kickoff prompts are kind-appropriate; per-kind context-spec defaults apply correctly; mentions are revocable. +- **Verification:** Context-spec persistence tests, `#` mention resolution/disambiguation tests, TOON serializer tests, turn-zero prompt assembly tests per kind, manual walkthroughs for each thread kind's context provision. +- **Traceability:** A80, A81, A84, A85; D136, D137, D139, D140; I112. +- **Design docs:** `docs/design/CONVERSATIONAL_WORKSPACE_RUNTIME.md` §3.5 + §5 Track 5. ### agent-fixture-substrate @@ -347,6 +387,7 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen ## Recently Completed +- [2026-05-13] `continuous-workspace` — Done: FE-709 / PR #134. Replaced per-phase InterviewView with ContinuousWorkspaceView (cumulative center pane), extracted `useContinuousWorkspaceController`, added sidebar scroll-spy via WorkspaceFocusContext, extracted shared controller helpers to core, retired route-first test assumptions. Verified: `npm run verify` 1213 / 1214 pass (1 pre-existing flake). Watch: Step 5 route-collapse decision deferred — hybrid works as intended. - [2026-05-11] `side-chat-v3-1-agent-grouped-reconciliation` — Done: FE-674 / PR #124 + downstack closed the V3.x arc end-to-end with spec-level classifier route, per-row reset route, agent classification lifecycle, chips, per-class actions, and bulk Confirm-all / Apply-all-suggested. Verified: `npm run verify` 1178 / 1179 pass with one unrelated `side-chat-route` flake. Watch: A88 outer-loop walkthrough on a dense spec remains open to assess legibility vs V3.0's flat list. - [2026-05-11] `fe-698-reconciliation-context-pack` — Done: added proposal-only reconciliation prompt/context scenario rendering open reconciliation needs with source/target anchors, reason/status, prompt/context fingerprints, and read-only capability metadata. Verified: `npm run verify`. Watch: next FE-698 work can broaden read-only/proposal-only probes and Pi adapter spike without treating this pack as a resolution agent. - [2026-05-08] `side-chat-v3-0-hard-impact-cascade` — Done: FE-674 / PR #115 + #116 + #117 shipped hard-impact cascade through `reconciliation_need`, Pending review listing, and idempotent resolve. Verified: `npm run verify` (1063 tests, 0 lint warnings). Watch: A88 mechanical grouping remains only partially validated until outer-loop walkthrough on dense graphs. @@ -356,10 +397,14 @@ Older history: `docs/archive/PLAN_HISTORY.md` ## Dependencies ```text -TRACK A — Workspace shell (parallel colleague lane) -continuous-workspace - ├──→ stable host for side-chat-persistence-v4a - └──→ workspace-aware graph / structured-list peer routes +TRACK A — Conversational Workspace Runtime umbrella +continuous-workspace (Track 1, done — FE-709) + └──→ chat-runtime-threads (Track 2) + ├──→ reconciliation-runtime (Track 3, also needs Track 4) + └──→ thread-context-provision (Track 5) +changeset-ledger (Track 4, parallel with Track 2) + └──→ richer attribution in reconciliation-runtime (Track 3) + + unlocks architect-generator-loop and side-chat-v4b-item-versioning TRACK B — Agent fixture substrate / strangler handler seam prompt/context scenario substrate foundation (completed) @@ -395,10 +440,13 @@ workspace-gitignore-assist productized-web-research LOWER-PRIORITY / DEFERRED -side-chat-persistence-v4a / side-chat-v4b-item-versioning +side-chat-v4b-item-versioning (depends on changeset-ledger) spatial-graph-layout + graph-view-active-path-filter dashboard-summaries mcp-adapter / file-based-persistence / typed-fixture-builder-convergence structured-development-spec-registry portability-boundaries + +RETIRED +side-chat-persistence-v4a — superseded by chat-runtime-threads (Track 2) ``` From 404e85d128cb1eb0d1dcb6636dc70efd4383d830 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Wed, 13 May 2026 17:36:57 +0200 Subject: [PATCH 02/13] FE-709: Replace per-phase InterviewView with ContinuousWorkspaceView All four phase routes now render ContinuousWorkspaceView, which shows all realized phase sections stacked in one scroll surface. Unrealized future phases are skipped. Only the current reachable phase section has an actionable bottom artifact. Graph view is unchanged. InterviewView is preserved as-is for its existing test coverage. Co-authored-by: Amp --- src/client/__tests__/router.test.tsx | 15 + .../$id/_view/-continuous-workspace-view.tsx | 306 ++++++++++++++++++ .../$id/_view/acceptance-review.tsx | 4 +- .../specification/$id/_view/elicitation.tsx | 4 +- .../specification/$id/_view/grounding.tsx | 4 +- .../$id/_view/requirements-review.tsx | 4 +- 6 files changed, 329 insertions(+), 8 deletions(-) create mode 100644 src/client/routes/specification/$id/_view/-continuous-workspace-view.tsx diff --git a/src/client/__tests__/router.test.tsx b/src/client/__tests__/router.test.tsx index 8720d8dd..e6c922cf 100644 --- a/src/client/__tests__/router.test.tsx +++ b/src/client/__tests__/router.test.tsx @@ -105,6 +105,21 @@ vi.mock('../routes/specification/$id/_view/-interview-view.js', () => ({ }, })); +vi.mock('../routes/specification/$id/_view/-continuous-workspace-view.js', () => ({ + ContinuousWorkspaceView: () => { + interviewViewRenderCount += 1; + + useEffect(() => { + interviewViewMountCount += 1; + return () => { + interviewViewUnmountCount += 1; + }; + }, []); + + return

Interview screen

; + }, +})); + vi.mock('../routes/specification/$id/-export-preview.js', () => ({ ExportPreview: () =>

Export screen

, })); diff --git a/src/client/routes/specification/$id/_view/-continuous-workspace-view.tsx b/src/client/routes/specification/$id/_view/-continuous-workspace-view.tsx new file mode 100644 index 00000000..47f89c36 --- /dev/null +++ b/src/client/routes/specification/$id/_view/-continuous-workspace-view.tsx @@ -0,0 +1,306 @@ +import { Link } from '@tanstack/react-router'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { Button } from '@/client/components/app-shell'; +import { ChatScroll } from '@/client/components/chat-scroll'; +import { ActivityPlaceholder } from '@/client/components/question-cards'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/client/components/ui/dialog'; +import { cn } from '@/client/lib/utils'; +import type { WorkflowPhase } from '@/shared/api-types.js'; +import type { ActivitySummary, BrunchUIMessage } from '@/shared/chat.js'; +import { getForceClosePhaseAction, getPhaseClosureCommandText } from '@/shared/phase-close.js'; +import { + getCurrentOpenPhase, + getNextActivePhase, + getPhaseRoutePath, + getWorkflowPhaseLabel, + phaseOrder, +} from '@/shared/phase-descriptors.js'; +import { getPhaseIntentMarkerLabel } from '@/shared/phase-intents.js'; +import { getPersistedActivitySummary } from '@/shared/specification-state.js'; +import type { SpecificationState, SpecificationTurn } from '@/shared/specification.js'; + +import { useSpecificationBundleData } from '../-specification-data.js'; +import { useInterviewController } from './-interview-controller.js'; +import { + specificationWorkspaceStream, + type WorkspaceStreamArtifact, + type WorkspaceStreamMarker, +} from './-workspace-stream-projector.js'; +import { WorkspaceTranscriptArtifacts } from './-workspace-transcript-artifacts.js'; + +function canForceClosePhase(workflow: SpecificationState['workflow'], phase: SpecificationTurn['phase']) { + return getForceClosePhaseAction(workflow, phase).available; +} + +function getControlMarkerLabel(message: BrunchUIMessage): string | null { + const phaseIntent = message.parts?.find( + (part): part is Extract[number], { type: 'data-phase-intent' }> => + part.type === 'data-phase-intent', + ); + if (phaseIntent) { + return getPhaseIntentMarkerLabel(phaseIntent.data); + } + + const phaseConfirmation = message.parts?.find( + (part): part is Extract[number], { type: 'data-confirmation' }> => + part.type === 'data-confirmation', + ); + return phaseConfirmation ? getPhaseClosureCommandText(phaseConfirmation.data) : null; +} + +function projectLiveControlMarkers(messages: readonly BrunchUIMessage[]): WorkspaceStreamMarker[] { + for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) { + const message = messages[messageIndex]; + if (!message || /^turn-\d+-/.test(message.id) || message.role !== 'user') { + continue; + } + + const label = getControlMarkerLabel(message); + if (label) { + return [{ label }]; + } + } + + return []; +} + +function renderActivitySummary(activitySummary: ActivitySummary | null | undefined) { + if (!activitySummary) { + return null; + } + + return ; +} + +function renderPersistedActivity(turn: Pick | undefined) { + return renderActivitySummary(getPersistedActivitySummary(turn)); +} + +function getReadinessLabel(readiness: SpecificationState['workflow']['phases'][WorkflowPhase]['readiness']) { + return readiness[0]!.toUpperCase() + readiness.slice(1); +} + +interface PhaseSection { + readonly phase: WorkflowPhase; + readonly artifacts: readonly WorkspaceStreamArtifact[]; + readonly phaseTurns: readonly SpecificationTurn[]; + readonly isActive: boolean; +} + +export function ContinuousWorkspaceView({ initialPhase }: { initialPhase: WorkflowPhase }) { + const [isClosePhaseModalOpen, setIsClosePhaseModalOpen] = useState(false); + const specificationState = useSpecificationBundleData(); + const currentReachablePhase = getCurrentOpenPhase(specificationState.workflow.phases); + const activePhase = currentReachablePhase ?? phaseOrder[phaseOrder.length - 1]!; + + const controller = useInterviewController(activePhase); + const { specification, workflow, captureStatusByTurnId, structuralArtifactTurnIds } = controller; + + const sections = useMemo((): readonly PhaseSection[] => { + const result: PhaseSection[] = []; + + for (const phase of phaseOrder) { + const phaseState = workflow.phases[phase]; + + if (phaseState.status === 'unstarted' && phase !== activePhase) { + continue; + } + + if (phase === activePhase) { + const controlMarkers = projectLiveControlMarkers(controller.chat.messages); + const { streamArtifacts } = specificationWorkspaceStream({ + phase, + phaseTurns: controller.phaseTurns, + phaseState, + bottomArtifact: controller.bottomArtifact, + controlMarkers, + structuralArtifactTurnIds, + }); + result.push({ + phase, + artifacts: streamArtifacts, + phaseTurns: controller.phaseTurns, + isActive: true, + }); + } else { + const phaseTurns = specificationState.turns.filter((t) => t.phase === phase); + const { streamArtifacts } = specificationWorkspaceStream({ + phase, + phaseTurns, + phaseState, + bottomArtifact: null, + structuralArtifactTurnIds: specificationState.structuralArtifactTurnIds, + }); + result.push({ phase, artifacts: streamArtifacts, phaseTurns, isActive: false }); + } + } + + return result; + }, [activePhase, controller, specificationState, structuralArtifactTurnIds, workflow.phases]); + + // Scroll to the initial phase section on mount + const sectionRefs = useRef>(new Map()); + const hasScrolledRef = useRef(false); + + useEffect(() => { + if (hasScrolledRef.current) return; + + const targetPhase = sections.some((s) => s.phase === initialPhase) ? initialPhase : activePhase; + const targetElement = sectionRefs.current.get(targetPhase); + + if (targetElement) { + targetElement.scrollIntoView({ behavior: 'instant', block: 'start' }); + hasScrolledRef.current = true; + } + }, [initialPhase, activePhase, sections]); + + const activePhaseState = workflow.phases[activePhase]; + const nextPhase = getNextActivePhase(workflow.phases, activePhase); + const phaseIndex = phaseOrder.indexOf(activePhase); + const phaseNumber = phaseIndex + 1; + const phaseTotal = phaseOrder.length; + const showClosePhaseAction = canForceClosePhase(workflow, activePhase); + const showAdvanceAction = activePhaseState.status === 'closed' && Boolean(nextPhase); + const showExportAction = activePhaseState.status === 'closed' && !nextPhase; + const readinessLabel = getReadinessLabel(activePhaseState.readiness); + const activePhaseTurns = controller.phaseTurns; + const turnCountLabel = `${activePhaseTurns.length} ${activePhaseTurns.length === 1 ? 'turn' : 'turns'}`; + const confirmCloseLabel = `Confirm ${getWorkflowPhaseLabel(activePhase).toLowerCase()} closure`; + + const handleConfirmClosePhase = () => { + setIsClosePhaseModalOpen(false); + controller.chat.forcePhaseClosure(activePhase); + }; + + return ( + <> +
+
+
+ + Phase {phaseNumber}/{phaseTotal} – {getWorkflowPhaseLabel(activePhase)} + +
+ + Status: + + {activePhaseState.status === 'in_progress' + ? 'In-Progress' + : activePhaseState.status === 'closed' + ? 'Closed' + : 'Unstarted'} + + + · + + {activePhaseTurns.length} {activePhaseTurns.length === 1 ? 'Turn' : 'Turns'} + +
+
+ {showAdvanceAction && nextPhase ? ( + + Advance to {getWorkflowPhaseLabel(nextPhase)} + + ) : showExportAction ? ( + + Open export preview + + ) : null} +
+ +
+ {sections.map((section) => ( +
{ + if (el) { + sectionRefs.current.set(section.phase, el); + } + }} + > + +
+ ))} + +
+
+ + {showClosePhaseAction && activePhaseTurns.length >= 3 && ( +
+ +
+ )} +
+ + + + + Close {getWorkflowPhaseLabel(activePhase)} phase? + + This will record a user-forced close for the active phase without waiting for an interviewer + recommendation. + + + +
+
+
Readiness
+
{readinessLabel}
+
+
+
Turn count
+
{turnCountLabel}
+
+
+ + + + + +
+
+ + ); +} diff --git a/src/client/routes/specification/$id/_view/acceptance-review.tsx b/src/client/routes/specification/$id/_view/acceptance-review.tsx index 51c5140b..339b25ea 100644 --- a/src/client/routes/specification/$id/_view/acceptance-review.tsx +++ b/src/client/routes/specification/$id/_view/acceptance-review.tsx @@ -1,9 +1,9 @@ import { createFileRoute } from '@tanstack/react-router'; -import { InterviewView } from './-interview-view.js'; +import { ContinuousWorkspaceView } from './-continuous-workspace-view.js'; function AcceptanceReviewView() { - return ; + return ; } export const Route = createFileRoute('/specification/$id/_view/acceptance-review')({ diff --git a/src/client/routes/specification/$id/_view/elicitation.tsx b/src/client/routes/specification/$id/_view/elicitation.tsx index aaa099ca..06dbfa94 100644 --- a/src/client/routes/specification/$id/_view/elicitation.tsx +++ b/src/client/routes/specification/$id/_view/elicitation.tsx @@ -1,9 +1,9 @@ import { createFileRoute } from '@tanstack/react-router'; -import { InterviewView } from './-interview-view.js'; +import { ContinuousWorkspaceView } from './-continuous-workspace-view.js'; function ElicitationView() { - return ; + return ; } export const Route = createFileRoute('/specification/$id/_view/elicitation')({ diff --git a/src/client/routes/specification/$id/_view/grounding.tsx b/src/client/routes/specification/$id/_view/grounding.tsx index b06cc7c8..c7ff021a 100644 --- a/src/client/routes/specification/$id/_view/grounding.tsx +++ b/src/client/routes/specification/$id/_view/grounding.tsx @@ -2,10 +2,10 @@ import { createFileRoute } from '@tanstack/react-router'; import { groundingWorkflowPhase } from '@/shared/phase-descriptors.js'; -import { InterviewView } from './-interview-view.js'; +import { ContinuousWorkspaceView } from './-continuous-workspace-view.js'; function GroundingView() { - return ; + return ; } export const Route = createFileRoute('/specification/$id/_view/grounding')({ diff --git a/src/client/routes/specification/$id/_view/requirements-review.tsx b/src/client/routes/specification/$id/_view/requirements-review.tsx index 317dc55d..cfb4dd3a 100644 --- a/src/client/routes/specification/$id/_view/requirements-review.tsx +++ b/src/client/routes/specification/$id/_view/requirements-review.tsx @@ -1,9 +1,9 @@ import { createFileRoute } from '@tanstack/react-router'; -import { InterviewView } from './-interview-view.js'; +import { ContinuousWorkspaceView } from './-continuous-workspace-view.js'; function RequirementsReviewView() { - return ; + return ; } export const Route = createFileRoute('/specification/$id/_view/requirements-review')({ From c9b9e995c1851b2a4b01287c34f7f65d0d81211f Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Wed, 13 May 2026 17:46:07 +0200 Subject: [PATCH 03/13] FE-709: Extract useContinuousWorkspaceController MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One controller owns the single chat session, lifecycle hooks, auto-phase-intent, and per-phase section projection. ContinuousWorkspaceView no longer double-reads from useSpecificationBundleData + useInterviewController — it uses only the new controller. useInterviewController is preserved unchanged for InterviewView tests. Co-authored-by: Amp --- .../_view/-continuous-workspace-controller.ts | 642 ++++++++++++++++++ .../$id/_view/-continuous-workspace-view.tsx | 113 +-- 2 files changed, 653 insertions(+), 102 deletions(-) create mode 100644 src/client/routes/specification/$id/_view/-continuous-workspace-controller.ts diff --git a/src/client/routes/specification/$id/_view/-continuous-workspace-controller.ts b/src/client/routes/specification/$id/_view/-continuous-workspace-controller.ts new file mode 100644 index 00000000..d003bf61 --- /dev/null +++ b/src/client/routes/specification/$id/_view/-continuous-workspace-controller.ts @@ -0,0 +1,642 @@ +import { useChat } from '@ai-sdk/react'; +import { useRouter } from '@tanstack/react-router'; +import { DefaultChatTransport } from 'ai'; +import type { ChatStatus } from 'ai'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { + useSubmitPhaseIntentMutation, + useSubmitTurnResponseMutation, +} from '@/client/mutations/interview-mutations'; +import type { ReviewAction, WorkflowPhase } from '@/shared/api-types.js'; +import { brunchDataPartSchemas, getActivityToolLabel, summarizeAssistantActivity } from '@/shared/chat.js'; +import type { ActivitySummary, BrunchUIMessage } from '@/shared/chat.js'; +import { + createConfirmProposedPhaseClosureCommand, + createForceCloseActivePhaseCommand, + getPhaseClosureCommandText, +} from '@/shared/phase-close.js'; +import type { DataConfirmation } from '@/shared/phase-close.js'; +import { getCurrentOpenPhase, getPhaseRoutePath, phaseOrder } from '@/shared/phase-descriptors.js'; +import type { PhaseIntentRequest } from '@/shared/phase-intents.js'; +import { type SpecificationMode, type SpecificationTurn } from '@/shared/specification.js'; + +import { + useInvalidateSpecificationQueryDomains, + usePromoteStreamedFrontierTurnToBundle, + useSpecificationBundleData, +} from '../-specification-data.js'; +import { + buildPhaseTurnIds, + createInterviewControllerViewState, + createInterviewDurableSpecificationState, + filterMessagesByPhase, + reconcileStablePhaseTurns, +} from './-interview-controller-core.js'; +import type { InterviewControllerBottomArtifactState } from './-interview-controller.js'; +import { useInterviewDataAdapter } from './-interview-data.js'; +import { getSpecificationScopedChatId } from './-interview-hydration.js'; +import { + useSpecificationRuntimeLifecycle, + useSpecificationScopedAutoPhaseIntent, +} from './-specification-lifecycle.js'; +import { specificationWorkspaceStream, type WorkspaceStreamArtifact } from './-workspace-stream-projector.js'; + +const MAX_TOOL_DETAIL_LENGTH = 80; +const HYDRATED_TURN_MESSAGE_ID_PATTERN = /^turn-\d+-/; + +function isLiveAssistantMessage(message: BrunchUIMessage): boolean { + return message.role === 'assistant' && !HYDRATED_TURN_MESSAGE_ID_PATTERN.test(message.id); +} + +function getLatestLiveAssistantMessage( + messages: readonly BrunchUIMessage[], + status: ChatStatus, +): BrunchUIMessage | undefined { + if (status !== 'submitted' && status !== 'streaming') { + return undefined; + } + + for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) { + const message = messages[messageIndex]; + if (message && isLiveAssistantMessage(message)) { + return message; + } + } + + return undefined; +} + +function truncateToolDetail(value: string): string { + const sanitized = value.replace(/[\n\r]+/g, ' ').trim(); + return sanitized.length > MAX_TOOL_DETAIL_LENGTH + ? `${sanitized.slice(0, MAX_TOOL_DETAIL_LENGTH - 1)}…` + : sanitized; +} + +function getToolInputString(input: Record, key: string): string | null { + const value = input[key]; + return typeof value === 'string' && value.trim() ? truncateToolDetail(value) : null; +} + +function extractToolDetail(input: unknown): string | null { + if (input === null || typeof input !== 'object') { + return null; + } + + const record = input as Record; + const command = getToolInputString(record, 'command'); + if (command) { + return command; + } + + const path = getToolInputString(record, 'path') ?? getToolInputString(record, 'workdir'); + const pattern = getToolInputString(record, 'pattern'); + if (pattern && path) { + return truncateToolDetail(`${pattern} in ${path}`); + } + if (path) { + return path; + } + + for (const key of ['glob', 'query', 'url', 'requestFilePath', 'responseFilePath'] as const) { + const value = getToolInputString(record, key); + if (value) { + return value; + } + } + + return null; +} + +function getLiveToolItems(messages: readonly BrunchUIMessage[], status: ChatStatus) { + const liveAssistantMessage = getLatestLiveAssistantMessage(messages, status); + if (!liveAssistantMessage?.parts) { + return undefined; + } + + const toolItems = new Map< + string, + { + detail?: string; + key: string; + label: string; + } + >(); + + for (const part of liveAssistantMessage.parts) { + const label = part ? getActivityToolLabel(part) : null; + if (!part || !label || !('input' in part) || !('state' in part) || !('toolCallId' in part)) { + continue; + } + + const existing = toolItems.get(part.toolCallId); + const detail = extractToolDetail(part.input) ?? existing?.detail; + + toolItems.set(part.toolCallId, { + ...(detail ? { detail } : {}), + key: part.toolCallId, + label, + }); + } + + return toolItems.size > 0 ? [...toolItems.values()] : undefined; +} + +function getLatestAssistantActivity( + messages: readonly BrunchUIMessage[], + status: ChatStatus, +): ActivitySummary | undefined { + const liveAssistantMessage = getLatestLiveAssistantMessage(messages, status); + if (!liveAssistantMessage?.parts) { + return undefined; + } + + return summarizeAssistantActivity(liveAssistantMessage.parts) ?? undefined; +} + +function getLatestReasoningText( + messages: readonly BrunchUIMessage[], + status: ChatStatus, +): string | undefined { + const liveAssistantMessage = getLatestLiveAssistantMessage(messages, status); + if (!liveAssistantMessage?.parts) { + return undefined; + } + + const chunks: string[] = []; + for (const part of liveAssistantMessage.parts) { + if (part.type === 'reasoning') { + chunks.push(part.text); + } + } + + return chunks.length > 0 ? chunks.join('') : undefined; +} + +function sameTurnReferences( + left: readonly SpecificationTurn[], + right: readonly SpecificationTurn[], +): boolean { + return left.length === right.length && left.every((turn, index) => turn === right[index]); +} + +export interface ContinuousWorkspaceSection { + readonly phase: WorkflowPhase; + readonly artifacts: readonly WorkspaceStreamArtifact[]; + readonly phaseTurns: readonly SpecificationTurn[]; + readonly isActive: boolean; +} + +export interface ContinuousWorkspaceChatState { + readonly messages: readonly BrunchUIMessage[]; + readonly status: ChatStatus; + readonly isLoading: boolean; + readonly isStreaming: boolean; + readonly submitText: (text: string) => void; + readonly confirmPhaseClosure: (phase: SpecificationTurn['phase'], turnId: number) => void; + readonly forcePhaseClosure: (phase: SpecificationTurn['phase']) => void; +} + +export interface ContinuousWorkspaceController { + readonly specification: ReturnType['specification']; + readonly workflow: ReturnType['workflow']; + readonly sections: readonly ContinuousWorkspaceSection[]; + readonly activePhase: WorkflowPhase; + readonly captureStatusByTurnId: ReadonlyMap; + readonly chat: ContinuousWorkspaceChatState; + readonly bottomArtifact: InterviewControllerBottomArtifactState | null; +} + +export function useContinuousWorkspaceController(): ContinuousWorkspaceController { + const specificationState = useSpecificationBundleData(); + const turns = specificationState.turns; + const router = useRouter(); + const { invalidateSpecificationBundle, invalidateEntities } = useInvalidateSpecificationQueryDomains(); + const promoteStreamedFrontierTurnToBundle = usePromoteStreamedFrontierTurnToBundle(); + const specificationId = specificationState.specification.id; + + const currentReachablePhase = getCurrentOpenPhase(specificationState.workflow.phases); + const activePhase = currentReachablePhase ?? phaseOrder[phaseOrder.length - 1]!; + + const refreshReadModel = useCallback( + () => invalidateSpecificationBundle(), + [invalidateSpecificationBundle], + ); + const { durableSpecification, ephemeralChat } = useInterviewDataAdapter(specificationState); + + // Active-phase turn stabilization (lifted from useInterviewController) + const phaseTurnIds = useMemo(() => buildPhaseTurnIds(turns, activePhase), [activePhase, turns]); + const durablePhaseTurns = useMemo( + () => turns.filter((turn) => turn.phase === activePhase), + [activePhase, turns], + ); + const [stablePhaseTurns, setStablePhaseTurns] = useState(() => durablePhaseTurns); + const stablePhaseKeyRef = useRef(`${durableSpecification.specification.id}:${activePhase}`); + const stablePhaseKey = `${durableSpecification.specification.id}:${activePhase}`; + const projectedPhaseTurns = useMemo( + () => + stablePhaseKeyRef.current === stablePhaseKey + ? reconcileStablePhaseTurns(stablePhaseTurns, durablePhaseTurns) + : durablePhaseTurns, + [durablePhaseTurns, stablePhaseKey, stablePhaseTurns], + ); + + useEffect(() => { + setStablePhaseTurns((current) => + sameTurnReferences(current, projectedPhaseTurns) ? current : projectedPhaseTurns, + ); + stablePhaseKeyRef.current = stablePhaseKey; + }, [projectedPhaseTurns, stablePhaseKey]); + + // Chat transport + lifecycle (spec-scoped, not phase-scoped) + const transport = useMemo( + () => new DefaultChatTransport({ api: `/api/specifications/${specificationId}/chat` }), + [specificationId], + ); + const navigateToPhase = useCallback( + (nextPhase: WorkflowPhase) => + router.navigate({ + to: getPhaseRoutePath(nextPhase) as '/specification/$id/grounding', + params: { id: String(specificationId) }, + }), + [router, specificationId], + ); + const runtime = useSpecificationRuntimeLifecycle({ + specificationId, + phase: activePhase, + workflow: durableSpecification.workflow, + turns, + structuralArtifactTurnIds: specificationState.structuralArtifactTurnIds, + refreshReadModel, + refreshEntities: invalidateEntities, + navigateToPhase, + }); + const handleChatData = useCallback( + (dataPart: { type: string; data?: unknown }) => { + runtime.handleObserverResult(dataPart, async () => { + await Promise.all([refreshReadModel(), invalidateEntities()]); + }); + }, + [invalidateEntities, refreshReadModel, runtime], + ); + + const { messages, sendMessage, status, error } = useChat({ + id: getSpecificationScopedChatId(durableSpecification.specification.id), + transport, + messages: [...ephemeralChat.seedMessages], + dataPartSchemas: brunchDataPartSchemas, + onData: handleChatData, + onFinish: runtime.handleChatFinish, + }); + const submitTurnResponseMutation = useSubmitTurnResponseMutation({ + specificationId, + turn: durableSpecification.lastTurn, + sendMessage, + }); + const submitPhaseIntentMutation = useSubmitPhaseIntentMutation({ specificationId }); + const controlErrorMessage = submitPhaseIntentMutation.errorMessage ?? error?.message ?? null; + const isLoading = status === 'submitted' || status === 'streaming'; + + // Active-phase messages (phase-filtered for view state + live activity) + const phaseMessages = useMemo( + () => filterMessagesByPhase(messages, phaseTurnIds), + [messages, phaseTurnIds], + ); + const liveActivity = useMemo( + () => getLatestAssistantActivity(phaseMessages, status), + [phaseMessages, status], + ); + const liveReasoningText = useMemo( + () => getLatestReasoningText(phaseMessages, status), + [phaseMessages, status], + ); + const liveToolItems = useMemo(() => getLiveToolItems(phaseMessages, status), [phaseMessages, status]); + const liveToolsRunning = + (liveToolItems?.length ?? 0) > 0 && (status === 'streaming' || status === 'submitted'); + + // Chat actions + const submitText = useCallback( + (text: string) => { + if (!text.trim() || isLoading) { + return; + } + + void sendMessage({ text }); + }, + [isLoading, sendMessage], + ); + + const submitPhaseClosureCommand = useCallback( + (command: DataConfirmation) => { + if (isLoading) { + return; + } + + runtime.submitPhaseClosureCommand(() => + sendMessage({ + parts: [ + { type: 'text', text: getPhaseClosureCommandText(command) }, + { + type: 'data-confirmation', + data: command, + }, + ], + }), + ); + }, + [isLoading, runtime.submitPhaseClosureCommand, sendMessage], + ); + + const submitTypedPhaseIntent = useCallback( + async (intent: PhaseIntentRequest): Promise => { + if (isLoading) { + return false; + } + + const result = + intent.kind === 'phase-entry' + ? await submitPhaseIntentMutation.submitPhaseEntry( + intent.phase, + intent.mode ? { mode: intent.mode } : undefined, + ) + : await submitPhaseIntentMutation.submitPhaseContinue(intent.phase); + if (!result) { + return false; + } + + await Promise.resolve( + sendMessage({ + parts: [ + { + type: 'data-phase-intent', + data: intent, + }, + ], + }), + ); + return true; + }, + [isLoading, sendMessage, submitPhaseIntentMutation], + ); + + const confirmPhaseClosure = useCallback( + (closurePhase: SpecificationTurn['phase'], turnId: number) => { + submitPhaseClosureCommand(createConfirmProposedPhaseClosureCommand(closurePhase, turnId)); + }, + [submitPhaseClosureCommand], + ); + + const forcePhaseClosure = useCallback( + (closurePhase: SpecificationTurn['phase']) => { + submitPhaseClosureCommand(createForceCloseActivePhaseCommand(closurePhase)); + }, + [submitPhaseClosureCommand], + ); + + const isAutoSubmittingPhaseIntent = useSpecificationScopedAutoPhaseIntent({ + specificationId, + phase: activePhase, + workflow: durableSpecification.workflow, + landing: durableSpecification.landing, + chatStatus: status, + submitPhaseIntent: submitTypedPhaseIntent, + }); + + // Active-phase view state (bottom artifact) + const viewState = useMemo( + () => + createInterviewControllerViewState( + durableSpecification, + activePhase, + phaseMessages, + isLoading, + runtime.submittedTurnId, + isAutoSubmittingPhaseIntent, + ), + [ + durableSpecification, + isAutoSubmittingPhaseIntent, + isLoading, + activePhase, + phaseMessages, + runtime.submittedTurnId, + ], + ); + + // Promote streamed frontier turn + useEffect(() => { + if (viewState.bottomArtifact?.kind !== 'pending-question') { + return; + } + + const pendingQuestion = viewState.bottomArtifact.pendingQuestion; + if (!pendingQuestion.acknowledgedTurnId) { + return; + } + + promoteStreamedFrontierTurnToBundle({ + turnId: pendingQuestion.acknowledgedTurnId, + phase: activePhase, + question: pendingQuestion, + }); + }, [activePhase, promoteStreamedFrontierTurnToBundle, viewState.bottomArtifact]); + + // Enrich the bottom artifact with callbacks (same mapping as useInterviewController) + const enrichedBottomArtifact: InterviewControllerBottomArtifactState | null = viewState.bottomArtifact + ? viewState.bottomArtifact.kind === 'persisted-turn' + ? { + kind: 'persisted-turn', + turn: viewState.bottomArtifact.turn, + state: viewState.bottomArtifact.state, + disabled: viewState.bottomArtifact.state === 'submitted', + errorMessage: submitTurnResponseMutation.errorMessage, + liveActivity, + submitTurnResponse: async ( + positions: number[], + freeText?: string, + reviewAction?: ReviewAction, + itemComments?: Array<{ reviewItemId: string; comment: string }>, + ) => { + const activeTurn = + viewState.bottomArtifact?.kind === 'persisted-turn' ? viewState.bottomArtifact.turn : null; + if (activeTurn === null) { + return; + } + + await runtime.submitTrackedTurnResponse(activeTurn, () => + submitTurnResponseMutation.submitTurnResponse(positions, freeText, reviewAction, itemComments), + ); + }, + } + : viewState.bottomArtifact.kind === 'pending-question' + ? { + kind: 'pending-question', + pendingQuestion: viewState.bottomArtifact.pendingQuestion, + disabled: true, + liveActivity, + } + : viewState.bottomArtifact.kind === 'kickoff' + ? (() => { + const kickoff = viewState.bottomArtifact.kickoff; + + return { + kind: 'kickoff' as const, + kickoff, + disabled: isLoading, + errorMessage: controlErrorMessage, + submitKickoff: (selectedMode?: SpecificationMode) => { + if (isLoading) { + return; + } + + if (kickoff.phase === 'grounding' && kickoff.mode === 'start' && selectedMode) { + void submitTypedPhaseIntent({ + kind: 'phase-entry', + phase: kickoff.phase, + mode: selectedMode, + }); + return; + } + + void submitTypedPhaseIntent( + kickoff.mode === 'start' + ? { + kind: 'phase-entry', + phase: kickoff.phase, + } + : { + kind: 'phase-continue', + phase: kickoff.phase, + }, + ); + }, + }; + })() + : viewState.bottomArtifact.kind === 'recovery' + ? (() => { + const recovery = viewState.bottomArtifact.recovery; + + return { + kind: 'recovery' as const, + recovery, + disabled: isLoading, + errorMessage: controlErrorMessage, + submitRecovery: () => { + if (isLoading) { + return; + } + + void submitTypedPhaseIntent({ + kind: 'phase-continue', + phase: recovery.phase, + }); + }, + }; + })() + : viewState.bottomArtifact.kind === 'phase-summary' + ? (() => { + const phaseSummary = viewState.bottomArtifact.phaseSummary; + + return { + kind: 'phase-summary' as const, + phaseSummary, + disabled: isLoading, + confirmPhaseSummary: () => confirmPhaseClosure(phaseSummary.phase, phaseSummary.turnId), + }; + })() + : viewState.bottomArtifact.kind === 'generating' + ? { + kind: 'generating' as const, + liveActivity, + liveReasoningText, + liveToolItems: liveToolItems?.map(({ detail, key, label }) => ({ + detail, + key, + label, + })), + liveToolsRunning, + pendingPreface: viewState.bottomArtifact.pendingPreface, + } + : viewState.bottomArtifact.kind === 'phase-handoff' + ? { + kind: 'phase-handoff' as const, + phase: viewState.bottomArtifact.phase, + nextPhase: viewState.bottomArtifact.nextPhase, + summary: viewState.bottomArtifact.summary, + isReviewPhase: viewState.bottomArtifact.isReviewPhase, + } + : { + kind: 'workflow-complete' as const, + phase: viewState.bottomArtifact.phase, + summary: viewState.bottomArtifact.summary, + isReviewPhase: viewState.bottomArtifact.isReviewPhase, + } + : null; + + // Project sections for all realized phases + const sections = useMemo((): readonly ContinuousWorkspaceSection[] => { + const result: ContinuousWorkspaceSection[] = []; + + for (const phase of phaseOrder) { + const phaseState = durableSpecification.workflow.phases[phase]; + + if (phaseState.status === 'unstarted' && phase !== activePhase) { + continue; + } + + if (phase === activePhase) { + const { streamArtifacts } = specificationWorkspaceStream({ + phase, + phaseTurns: projectedPhaseTurns, + phaseState, + bottomArtifact: enrichedBottomArtifact, + structuralArtifactTurnIds: specificationState.structuralArtifactTurnIds, + }); + result.push({ + phase, + artifacts: streamArtifacts, + phaseTurns: projectedPhaseTurns, + isActive: true, + }); + } else { + const closedPhaseTurns = turns.filter((t) => t.phase === phase); + const { streamArtifacts } = specificationWorkspaceStream({ + phase, + phaseTurns: closedPhaseTurns, + phaseState, + bottomArtifact: null, + structuralArtifactTurnIds: specificationState.structuralArtifactTurnIds, + }); + result.push({ phase, artifacts: streamArtifacts, phaseTurns: closedPhaseTurns, isActive: false }); + } + } + + return result; + }, [ + activePhase, + durableSpecification.workflow.phases, + enrichedBottomArtifact, + projectedPhaseTurns, + specificationState.structuralArtifactTurnIds, + turns, + ]); + + return { + specification: viewState.specification, + workflow: viewState.workflow, + sections, + activePhase, + captureStatusByTurnId: runtime.captureStatusByTurnId, + chat: { + messages: phaseMessages, + status, + isLoading, + isStreaming: status === 'streaming', + submitText, + confirmPhaseClosure, + forcePhaseClosure, + }, + bottomArtifact: enrichedBottomArtifact, + }; +} diff --git a/src/client/routes/specification/$id/_view/-continuous-workspace-view.tsx b/src/client/routes/specification/$id/_view/-continuous-workspace-view.tsx index 47f89c36..e95e31cc 100644 --- a/src/client/routes/specification/$id/_view/-continuous-workspace-view.tsx +++ b/src/client/routes/specification/$id/_view/-continuous-workspace-view.tsx @@ -1,5 +1,5 @@ import { Link } from '@tanstack/react-router'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Button } from '@/client/components/app-shell'; import { ChatScroll } from '@/client/components/chat-scroll'; @@ -14,64 +14,24 @@ import { } from '@/client/components/ui/dialog'; import { cn } from '@/client/lib/utils'; import type { WorkflowPhase } from '@/shared/api-types.js'; -import type { ActivitySummary, BrunchUIMessage } from '@/shared/chat.js'; -import { getForceClosePhaseAction, getPhaseClosureCommandText } from '@/shared/phase-close.js'; +import type { ActivitySummary } from '@/shared/chat.js'; +import { getForceClosePhaseAction } from '@/shared/phase-close.js'; import { - getCurrentOpenPhase, getNextActivePhase, getPhaseRoutePath, getWorkflowPhaseLabel, phaseOrder, } from '@/shared/phase-descriptors.js'; -import { getPhaseIntentMarkerLabel } from '@/shared/phase-intents.js'; import { getPersistedActivitySummary } from '@/shared/specification-state.js'; import type { SpecificationState, SpecificationTurn } from '@/shared/specification.js'; -import { useSpecificationBundleData } from '../-specification-data.js'; -import { useInterviewController } from './-interview-controller.js'; -import { - specificationWorkspaceStream, - type WorkspaceStreamArtifact, - type WorkspaceStreamMarker, -} from './-workspace-stream-projector.js'; +import { useContinuousWorkspaceController } from './-continuous-workspace-controller.js'; import { WorkspaceTranscriptArtifacts } from './-workspace-transcript-artifacts.js'; function canForceClosePhase(workflow: SpecificationState['workflow'], phase: SpecificationTurn['phase']) { return getForceClosePhaseAction(workflow, phase).available; } -function getControlMarkerLabel(message: BrunchUIMessage): string | null { - const phaseIntent = message.parts?.find( - (part): part is Extract[number], { type: 'data-phase-intent' }> => - part.type === 'data-phase-intent', - ); - if (phaseIntent) { - return getPhaseIntentMarkerLabel(phaseIntent.data); - } - - const phaseConfirmation = message.parts?.find( - (part): part is Extract[number], { type: 'data-confirmation' }> => - part.type === 'data-confirmation', - ); - return phaseConfirmation ? getPhaseClosureCommandText(phaseConfirmation.data) : null; -} - -function projectLiveControlMarkers(messages: readonly BrunchUIMessage[]): WorkspaceStreamMarker[] { - for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) { - const message = messages[messageIndex]; - if (!message || /^turn-\d+-/.test(message.id) || message.role !== 'user') { - continue; - } - - const label = getControlMarkerLabel(message); - if (label) { - return [{ label }]; - } - } - - return []; -} - function renderActivitySummary(activitySummary: ActivitySummary | null | undefined) { if (!activitySummary) { return null; @@ -88,63 +48,11 @@ function getReadinessLabel(readiness: SpecificationState['workflow']['phases'][W return readiness[0]!.toUpperCase() + readiness.slice(1); } -interface PhaseSection { - readonly phase: WorkflowPhase; - readonly artifacts: readonly WorkspaceStreamArtifact[]; - readonly phaseTurns: readonly SpecificationTurn[]; - readonly isActive: boolean; -} - export function ContinuousWorkspaceView({ initialPhase }: { initialPhase: WorkflowPhase }) { const [isClosePhaseModalOpen, setIsClosePhaseModalOpen] = useState(false); - const specificationState = useSpecificationBundleData(); - const currentReachablePhase = getCurrentOpenPhase(specificationState.workflow.phases); - const activePhase = currentReachablePhase ?? phaseOrder[phaseOrder.length - 1]!; - - const controller = useInterviewController(activePhase); - const { specification, workflow, captureStatusByTurnId, structuralArtifactTurnIds } = controller; - - const sections = useMemo((): readonly PhaseSection[] => { - const result: PhaseSection[] = []; - - for (const phase of phaseOrder) { - const phaseState = workflow.phases[phase]; - - if (phaseState.status === 'unstarted' && phase !== activePhase) { - continue; - } - - if (phase === activePhase) { - const controlMarkers = projectLiveControlMarkers(controller.chat.messages); - const { streamArtifacts } = specificationWorkspaceStream({ - phase, - phaseTurns: controller.phaseTurns, - phaseState, - bottomArtifact: controller.bottomArtifact, - controlMarkers, - structuralArtifactTurnIds, - }); - result.push({ - phase, - artifacts: streamArtifacts, - phaseTurns: controller.phaseTurns, - isActive: true, - }); - } else { - const phaseTurns = specificationState.turns.filter((t) => t.phase === phase); - const { streamArtifacts } = specificationWorkspaceStream({ - phase, - phaseTurns, - phaseState, - bottomArtifact: null, - structuralArtifactTurnIds: specificationState.structuralArtifactTurnIds, - }); - result.push({ phase, artifacts: streamArtifacts, phaseTurns, isActive: false }); - } - } - return result; - }, [activePhase, controller, specificationState, structuralArtifactTurnIds, workflow.phases]); + const { specification, workflow, sections, activePhase, captureStatusByTurnId, chat } = + useContinuousWorkspaceController(); // Scroll to the initial phase section on mount const sectionRefs = useRef>(new Map()); @@ -171,13 +79,14 @@ export function ContinuousWorkspaceView({ initialPhase }: { initialPhase: Workfl const showAdvanceAction = activePhaseState.status === 'closed' && Boolean(nextPhase); const showExportAction = activePhaseState.status === 'closed' && !nextPhase; const readinessLabel = getReadinessLabel(activePhaseState.readiness); - const activePhaseTurns = controller.phaseTurns; + const activeSection = sections.find((s) => s.isActive); + const activePhaseTurns = activeSection?.phaseTurns ?? []; const turnCountLabel = `${activePhaseTurns.length} ${activePhaseTurns.length === 1 ? 'turn' : 'turns'}`; const confirmCloseLabel = `Confirm ${getWorkflowPhaseLabel(activePhase).toLowerCase()} closure`; const handleConfirmClosePhase = () => { setIsClosePhaseModalOpen(false); - controller.chat.forcePhaseClosure(activePhase); + chat.forcePhaseClosure(activePhase); }; return ( @@ -262,7 +171,7 @@ export function ContinuousWorkspaceView({ initialPhase }: { initialPhase: Workfl @@ -295,7 +204,7 @@ export function ContinuousWorkspaceView({ initialPhase }: { initialPhase: Workfl - From a69fb07bd741e9f4a13d19439e24e4b39134c619 Mon Sep 17 00:00:00 2001 From: Kostandin Angjellari Date: Wed, 13 May 2026 17:49:55 +0200 Subject: [PATCH 04/13] FE-709: Sidebar scroll-spy highlighting via WorkspaceFocusContext Added WorkspaceFocusContext to bridge scroll position from the continuous workspace view to the phase navigation sidebar. IntersectionObserver on section divs updates focusedPhase; the sidebar uses it for is-active highlighting instead of route-active matching when available. Route-based highlighting is preserved as fallback for graph view and export routes. Co-authored-by: Amp --- .../$id/-phase-navigation-sidebar.tsx | 11 ++++- .../specification/$id/-workspace-focus.tsx | 20 +++++++++ .../$id/_view/-continuous-workspace-view.tsx | 44 +++++++++++++++++++ src/client/routes/specification/$id/route.tsx | 29 ++++++------ 4 files changed, 89 insertions(+), 15 deletions(-) create mode 100644 src/client/routes/specification/$id/-workspace-focus.tsx diff --git a/src/client/routes/specification/$id/-phase-navigation-sidebar.tsx b/src/client/routes/specification/$id/-phase-navigation-sidebar.tsx index dbaed537..c4134404 100644 --- a/src/client/routes/specification/$id/-phase-navigation-sidebar.tsx +++ b/src/client/routes/specification/$id/-phase-navigation-sidebar.tsx @@ -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': @@ -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 (