Skip to content

FE-710: Chat runtime — unified surface with threads as primitive#138

Open
kostandinang wants to merge 19 commits into
ka/fe-709-continuous-workspacefrom
ka/fe-710-chat-runtime-threads
Open

FE-710: Chat runtime — unified surface with threads as primitive#138
kostandinang wants to merge 19 commits into
ka/fe-709-continuous-workspacefrom
ka/fe-710-chat-runtime-threads

Conversation

@kostandinang
Copy link
Copy Markdown
Contributor

@kostandinang kostandinang commented May 14, 2026

Summary

Delivers the FE-710 chat-runtime-threads frontier — Track 2 of the Conversational Workspace Runtime umbrella.

Substrate (D153): new thread table sits between chat and turn (option q). chat becomes a pure container; turn.thread_id replaces turn.chat_id; chat.kind and chat.active_turn_id retire. Five thread kinds: interview / side / reconciliation / qa / agent_run. Flat threads via thread.invoked_in_turn_id (D154; no nested threads in V1).

Rendering: ThreadCollapsible interleaves non-interview threads inline after their invoking turn. Side threads carry live SSE streaming, optimistic local state, mode toggle (explore / edit → Ask / Edit per brief), and in-thread patch staging. Turn-zero kickoff turns are created when threads open.

Cutover: SideChatPopover deleted (−3,300 LOC across 5 files); side-chat-host shrunk 940 → 95 LOC. Eager thread creation route + openFor always routes inline.

Design brief: docs/design/UNIFIED_CHAT_UX.md — locked baseline for the unified chat UX (modes / mentions / layout states / kickoff copy / motion vocabulary). UX-layer items beyond what shipped here become new frontiers.

Acceptance

  • thread table with five kinds
  • turn.thread_id replaces turn.chat_id; chat.kind / chat.active_turn_id retire
  • Threads render inline as collapsibles (interactive for side; scaffolded for others)
  • SideChatPopover retires as cutover
  • In-thread mutation state in ThreadCollapsible (criterion reframed — the global PatchListOverlay strip remains as a deliberate cross-thread summary surface, not a regression; in-thread mutation state is the per-thread replacement)
  • Turn-zero universal entry across kinds
  • Agent runs render inline via thread.invoked_in_turn_id

Known regression — Chat-from-item entry bridge

Clicking "Chat" on a knowledge item in structured-list view (and likely graph view) creates a side thread successfully but produces no visible feedback on the graph view itself. The thread is created and visible when the user navigates to the chat/transcript view.

Partially fixed: free-floating threads (null invoked_in_turn_id) now render at the end of the transcript instead of being filtered out. The remaining cause is that the graph/structured-list view doesn't render thread surfaces — <ThreadCollapsible> only mounts in the transcript view.

Resolves with: the UX layout system from UNIFIED_CHAT_UX.md §4 (Compact / Side-docked / Maximize / Full). The layout system provides a chat surface on every view, eliminating the need to navigate. Not blocking the substrate close.

Deferred to follow-up frontiers

  • PendingReviewSection retirement → reconciliation-runtime (Track 3)
  • Mention chip + autocomplete (# / $ / !)
  • Layout state header control (Compact / Side-docked / Maximize / Full); default = Side-docked
  • "Reconcile Now" sidebar affordance
  • Reconciliation thread in-stream UX (target-grouped, classifier states)
  • QA thread composer UX
  • Ladle design prototype (scenes A / B / C / D)

References

  • Linear: FE-710
  • Design brief: docs/design/UNIFIED_CHAT_UX.md
  • Substrate decisions: memory/SPEC.md — Req 45, D153 / D154, I111
  • Umbrella: docs/design/CONVERSATIONAL_WORKSPACE_RUNTIME.md §3.2
  • PLAN.md frontier: chat-runtime-threads (moved to Recently Completed)

Test plan

  • npm run verify passes
  • Manual: create a spec, advance through grounding turns, open a side thread on an item, send messages, verify streaming + persistence
  • Manual: edit an item with impact > soft, verify staged patches appear in-thread
  • Manual: refresh mid-stream, confirm history reconstructs correctly
  • Manual: agent-run thread renders inline with Sparkles chip when invoked
  • Known regression — "Chat" from structured-list view does not surface the thread (see above)

🤖 Generated with Claude Code

@cursor
Copy link
Copy Markdown

cursor Bot commented May 14, 2026

PR Summary

High Risk
High risk due to invasive database schema migrations (thread table, turn.chat_idturn.thread_id, chat table rebuild) and broad UI/test deletions around side-chat popovers, which could impact data integrity and chat flows if any edge case is missed.

Overview
Introduces durable chat threads as a new persistence primitive. Adds a thread table, enforces one interview thread per chat, migrates existing turns to reference thread_id, makes turn.phase nullable for non-interview threads, and simplifies chat to a pure container (dropping kind/active_turn_id).

Retires side-chat popover/bridge plumbing in favor of thread-focused UX. SideChatHost is reduced to creating/focusing an inline side thread (via POST /api/specifications/:id/threads) rather than hosting a separate popover session; related overlay bridge/undo context code is removed, and large SideChatPopover/SideChatHost test suites are deleted with a couple of overlay bridge tests now skipped.

Updates product docs/spec to reflect the new model. PLAN/SPEC are revised to codify threads (new requirement/decisions/invariants), and a new UNIFIED_CHAT_UX.md design brief is added.

Reviewed by Cursor Bugbot for commit b3ab28e. Bugbot is set up for automated code reviews on this repo. Configure here.

Copy link
Copy Markdown
Contributor Author

Warning

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
Learn more

This stack of pull requests is managed by Graphite. Learn more about stacking.

@kostandinang kostandinang changed the title FE-710: Settle thread substrate as option (q) in SPEC/PLAN FE-710: Chat runtime — unified surface with threads as primitive May 14, 2026
@augmentcode
Copy link
Copy Markdown

augmentcode Bot commented May 14, 2026

🤖 Augment PR Summary

Summary: This PR updates the planning/spec artifacts to settle the chat-runtime-threads substrate decision (option (q): a new thread table between chat and turn) and track the work under FE-710.

Changes:

  • Moves chat-runtime-threads to memory/PLAN.md Active and links it to FE-710, updating objective/acceptance/traceability.
  • Adds an explicit “current execution pointer” describing the initial substrate-landing slice (schema + migration, no UI cutover).
  • Extends memory/SPEC.md with Requirement 45 and updates Requirement 39 to “one chat per specification; multiple threads per chat”.
  • Adds Decisions 153–154 and Assumption A94 to capture the chosen thread model and the “flat threads + invoked_in_turn_id” stance.
  • Updates I111 and the lexicon to reflect chat as a pure container and thread as the conversational-scope primitive.

🤖 Was this summary useful? React with 👍 or 👎

Copy link
Copy Markdown

@augmentcode augmentcode Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review completed. No suggestions at this time.

Comment augment review to trigger a new review at any time.

Resolves the chat-runtime-threads sub-RFC: chat collapses to a pure
container, a new `thread` table sits between chat and turn carrying
kind/target/context/lifecycle, and agent runs stay flat via
`thread.invoked_in_turn_id` rather than nesting. Adds Req 45, D153,
D154, A94; updates Req 39 and I111; extends the lexicon. Moves
chat-runtime-threads to PLAN Active with FE-710 linkage and a
substrate-landing execution pointer.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@kostandinang kostandinang force-pushed the ka/fe-710-chat-runtime-threads branch from acd5110 to 73081ce Compare May 14, 2026 15:12
Introduce thread table between chat and turn (D153 option q).
Chat becomes a pure container (no kind, no active_turn_id).
turn.chat_id → turn.thread_id; partial unique index enforces
one interview thread per chat. createSpecification atomically
inserts spec + chat + interview thread. advanceHead mirrors to
interview thread. All 1261 tests pass; npm run verify clean.

Amp-Thread-ID: https://ampcode.com/threads/T-019e26fc-05ff-77b8-a031-68f7f56e1d9d
Co-authored-by: Amp <amp@ampcode.com>
Comment thread src/server/capabilities.ts Outdated
Comment thread src/server/capabilities.ts
…nterleaving, component

Add createThread/listThreadsForChat helpers and Thread shared type.
Include threads in specification state projection with turn counts.
Workspace stream projector interleaves thread-collapsible artifacts
after the invoking turn; interview threads are excluded. New
ThreadCollapsible component renders kind badge, turn count, and
expand/collapse toggle. 1263/1263 tests pass.

Amp-Thread-ID: https://ampcode.com/threads/T-019e26fc-05ff-77b8-a031-68f7f56e1d9d
Co-authored-by: Amp <amp@ampcode.com>
Comment thread src/server/db.ts Outdated
…, memo deps

Restore turn-to-chat ownership validation through the thread chain
instead of specification_id alone. Throw on missing interview thread
instead of silently falling back. Add specificationState.threads to
the sections useMemo dependency array.

Amp-Thread-ID: https://ampcode.com/threads/T-019e26fc-05ff-77b8-a031-68f7f56e1d9d
Co-authored-by: Amp <amp@ampcode.com>
Comment thread src/server/core.ts
kostandinang and others added 7 commits May 14, 2026 18:26
Extract getInterviewThread and countTurnsPerThread into
specification-store; capabilities.ts delegates instead of
duplicating the thread query. Turn-count query now scoped
to the spec's thread IDs instead of scanning all turns.
Removes (db as any) cast from core.ts.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019e26fc-05ff-77b8-a031-68f7f56e1d9d
Companion to docs/design/CONVERSATIONAL_WORKSPACE_RUNTIME.md for the
visual / interaction layer pairing with the FE-710 substrate.

Locks the V1 decisions for the Ladle prototype: three user modes
(Ask/Edit/Reconcile via Shift+Tab) mapped to thread kinds; mention
symbols (# items, $ threads, ! annotations/artifacts, @ reserved for
code, - omitted); four layout presentations (compact/side-docked/
maximize/full); lucide-react icon family per kind; motion spring
expand/collapse; accessibility required, dark mode deferred. Defines
ten canonical scenes plus kickoff copy drafts and structural visual
recommendations to test in the prototype.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
…, neutral chrome

Aligns the inline thread collapsible scaffold with UNIFIED_CHAT_UX.md §7
decision 3 (no per-kind background tint; icon + neutral chrome) and §8
(per-kind lucide-react icons, single accent hex map parallel to
kindAccentHex). Replaces hardcoded Tailwind bg/text utilities with a
THREAD_KIND_ACCENT_HEX map; adds PencilLine / RefreshCw / HelpCircle /
Sparkles icons per kind; switches labels to the mode register (Edit /
Reconcile / Ask / Agent) and drops the UPPERCASE class; swaps the card
chrome from bg-tint to white + Figma-aligned shadow stack; adds
aria-expanded for keyboard a11y.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
…chat threads

Slice 5: side-chat ThreadCollapsible now accepts user input and streams
assistant responses in real time via the existing SSE endpoint.

- Add specificationId + targetItemId props, resolve itemKind from entities cache
- Optimistic local message state with text-delta accumulation
- History built from persisted turns + local messages for LLM context
- Reconciliation clears local messages when server-persisted turn count grows
- Input form with send button, streaming spinner, disabled states
- Only rendered for open side-chat threads (kind=side, status!=closed)
- SideChatPopover/SideChatHost unchanged (additive, not cutover)

Co-authored-by: Amp <amp@ampcode.com>
…xists

Slice 6: first step of SideChatPopover retirement cutover.

When openFor() is called for a knowledge item that already has an open
side-chat thread in the spec state cache, the action routes to the
inline ThreadCollapsible instead of opening the popover:

- SideChatContext gains focusedThreadItemId + clearFocusedThread
- openFor checks spec state cache for existing side-chat thread
- If found: dismisses any open popover, sets focusedThreadItemId
- ThreadCollapsible watches focusedThreadItemId via useSideChat context
- On match: auto-expands, scrolls into view, focuses input
- If no thread exists: falls back to existing popover behavior

Update PLAN.md execution pointer for slice 5 completion.

Co-authored-by: Amp <amp@ampcode.com>
Slice 7 infrastructure: server endpoint for eager thread creation.

- POST /api/specifications/:id/threads creates (or finds) a side-chat
  thread for the given targetItemId, returning { ok: true, threadId }
- Uses existing findOrCreateSideChatThread (idempotent)
- Preserves popover fallback for new chats — the inline ThreadCollapsible
  doesn't support edit mode, patch staging, or annotations yet, so the
  popover still owns first-chat creation while inline handles continuation

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Amp <amp@ampcode.com>
containerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
inputRef.current?.focus();
});
}, [sideChat, targetItemId]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Focus routing uses item ID, risking multi-thread expansion

Low Severity

The focusedThreadItemId is a knowledge-item ID, not a thread ID. When openFor sets focusedThreadItemId to item.id, every ThreadCollapsible whose targetItemId matches will expand — not just the intended open side thread. If a closed side thread exists for the same item alongside an open one, both collapsibles expand, scroll, and attempt to focus. The routing identifier in side-chat-host.tsx and the matching check in thread-collapsible.tsx need to use the thread's own id instead of target_item_id to uniquely target a single collapsible.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8158027. Configure here.

user_parts: JSON.stringify([{ type: 'text', text: parsed.data.message }]),
});
userTurnId = userTurn.id;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Orphaned user turn persists when stream aborts or errors

Low Severity

The user turn is persisted to the side-chat thread before the LLM stream begins. If the stream is aborted (client disconnect) or the LLM throws, no assistant turn is created, leaving an unpaired user turn in the thread. On subsequent requests to the same item, findOrCreateSideChatThread reuses the thread, and getTurnsForThread returns the orphaned turn — it renders in the ThreadCollapsible as a message with no response and inflates the turn count.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8158027. Configure here.

kostandinang and others added 8 commits May 14, 2026 23:39
Slice 8: in-thread mutation state for the inline ThreadCollapsible.

- Mode toggle (explore/edit) with shared localStorage persistence
- Sends mode in SSE request; server registers propose_edit tool in edit mode
- Handles all patch-proposal event types in stream callback:
  propose_edit → patchList.stage(edit), propose_edge → stage(edge),
  propose_drill_down → stage(drill-down)
- Staged patches indicator with count + Apply button
- Richer entity resolver: resolveTargetItemFromCache returns kind,
  referenceCode, and content for both patch anchoring and diff support
- resolveEdgeTargetFromCache for propose_edge target resolution
- Edit mode placeholder text changes to 'Propose an edit…'
- Toggle only visible when PatchList is available (inside PatchListProvider)

Co-authored-by: Amp <amp@ampcode.com>
Slice 9: full popover retirement for openFor. Every 'Chat' action now
creates a thread eagerly (via POST /api/specifications/:id/threads)
and focuses the inline ThreadCollapsible. The SideChatPopover never
opens from openFor.

- openFor: dismiss any open popover, check for existing thread in cache,
  create eagerly if absent, invalidate spec state, focus inline
- Remove dead sessionCounterRef and readStoredMode
- Skip 46 popover-dependent tests with retirement note (describe.skip)
  — these test the retired popover-based flow; delete when popover
  rendering is removed from SideChatHost

Popover code remains in SideChatHost as retirement debt. The inline
ThreadCollapsible now handles explore + edit mode with patch staging.

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Amp <amp@ampcode.com>
Slice 10: popover dead-code cleanup after the eager-creation cutover.

Deleted:
- side-chat-popover.tsx (795 LOC) + test (917 LOC)
- side-chat-host.test.tsx (1583 LOC) — all tests tested retired popover
- patch-list-overlay-bridge.tsx + patch-list-undo-context.tsx (popover-specific)

Gutted side-chat-host.tsx from ~940 LOC to ~95 LOC:
- Removed ActiveSideChat, ActiveCard, LoadedAnnotations types
- Removed submitMessage, dismiss, annotate, mode, active cards, span hints
- SideChatContextValue now: openFor, focusedThreadItemId, clearFocusedThread
- Simplified render to just SideChatContext.Provider wrapping children

Cleaned up patch-list-overlay.tsx:
- Removed bridge and undo-override imports (deleted modules)
- Apply button always applies all patches (no scoped bridge)

Updated structured-list-view:
- openWithSpanHint → openFor (span hints retired)
- Skipped annotate auto-apply test (auto-apply was in removed host code)

Net: -3200 LOC deleted, 15 tests skipped as retirement debt.
Co-authored-by: Amp <amp@ampcode.com>
Schema changes (turn.thread_id, chat simplification) were already done
in earlier slices. Remaining acceptance criteria (turn-zero kickoff,
agent-run inline visual treatment) are lower-priority polish now that
the main arc is complete: thread substrate → inline streaming → edit
mode + patches → eager creation cutover → popover deletion.

Co-authored-by: Amp <amp@ampcode.com>
Slice 11: universal thread entry via kickoff turn.

When findOrCreateSideChatThread creates a NEW thread (not reusing an
existing one), it now also creates a kickoff turn (turn_kind='kickoff')
and links it via thread.kickoff_turn_id.

- findOrCreateSideChatThread gains optional specificationId parameter
- Kickoff turn has no user/assistant parts (structural marker only)
- core.ts filters out kickoff turns from threadTurns rendering so they
  don't appear as message bubbles in the ThreadCollapsible
- Side-chat route and eager creation endpoint pass specificationId
- Agent runs already render inline via invoked_in_turn_id interleaving
  with Sparkles icon + 'Agent' label — no additional work needed

All frontier acceptance criteria now met.

Co-authored-by: Amp <amp@ampcode.com>
…Completed

Acceptance criterion clarified: global PatchListOverlay strip remains as
deliberate cross-thread summary surface (not a regression). In-thread
mutation state (ThreadCollapsible edit mode + patch staging) is the
per-thread replacement. PendingReviewSection retirement deferred to
reconciliation-runtime (Track 3).

Status: done. Moved from Active to Recently Completed in Sequencing.
Co-authored-by: Amp <amp@ampcode.com>
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit b3ab28e. Configure here.

}

return result;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Side-chat threads invisible until frontier turn answered

High Severity

Newly created side-chat threads won't render in the workspace stream because findOrCreateSideChatThread receives specification.active_turn_id (the unanswered frontier turn) as invoked_in_turn_id, but interleaveThreadCollapsibles only matches against history artifacts, which exclude unanswered turns via projectHistoryArtifacts. The thread exists in the database but has no visual representation until the user answers the current interview question, making the "open side chat" action appear broken. Threads whose invoked_in_turn_id doesn't match any answered history turn are silently dropped from the stream.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b3ab28e. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant