Skip to content
Draft
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
223 changes: 223 additions & 0 deletions docs/design/UNIFIED_CHAT_UX.md

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions drizzle/0020_chat_secondary_chat_columns.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
ALTER TABLE `chat` ADD `parent_chat_id` integer REFERENCES `chat`(`id`);--> statement-breakpoint
ALTER TABLE `chat` ADD `invoked_in_turn_id` integer REFERENCES `turn`(`id`);--> statement-breakpoint
ALTER TABLE `chat` ADD `pinned_item_id` integer REFERENCES `knowledge_item`(`id`);--> statement-breakpoint
ALTER TABLE `chat` ADD `pinned_span_hint` text;--> statement-breakpoint
CREATE INDEX `chat_parent_chat_id_idx` ON `chat` (`parent_chat_id`);--> statement-breakpoint
CREATE INDEX `chat_invoked_in_turn_id_idx` ON `chat` (`invoked_in_turn_id`);
1 change: 1 addition & 0 deletions drizzle/0021_chat_mode.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE `chat` ADD `mode` text;
14 changes: 14 additions & 0 deletions drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,20 @@
"when": 1776360000000,
"tag": "0019_reconciliation_need_agent_columns",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1779033600000,
"tag": "0020_chat_secondary_chat_columns",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1779120000000,
"tag": "0021_chat_mode",
"breakpoints": true
}
]
}
230 changes: 230 additions & 0 deletions memory/CARDS.md

Large diffs are not rendered by default.

24 changes: 13 additions & 11 deletions memory/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,16 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen
### Active

1. `agent-fixture-substrate` — branch-complete off main, reconciling — FE-705 integration substrate for JSONL agent capability CLI and LLM-as-user probes.
2. `chat-runtime-secondary-chats` — FE-716; branch `ka/fe-716-chat-runtime-unified-secondary-chats` stacked on `ln/fe-709-reconciliations` (PR #139). V1 narrowing in effect; current slice queue in `memory/CARDS.md`.

### Next

1. `chat-runtime-secondary-chats` — Track 2 of the runtime umbrella; immediate successor to continuous-workspace. Implement inline/collapsible secondary chats over existing chat/turn; explicitly defer a `thread` table.
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. `chat-context-provision` — Track 5 of the runtime umbrella recast as transcript-first context; can proceed against chat/turn once secondary-chat entry/anchor shape is settled.
5. `reconciliation-runtime` — Track 3 of the runtime umbrella; after Track 2 + Track 4 provide the secondary-chat surface 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.
1. `intent-graph-semantics` — highest-coordination semantic substrate after FE-705 reconciliation.
2. `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.
3. `chat-context-provision` — Track 5 of the runtime umbrella recast as transcript-first context; can proceed against chat/turn once secondary-chat entry/anchor shape is settled.
4. `reconciliation-runtime` — Track 3 of the runtime umbrella; after Track 2 + Track 4 provide the secondary-chat surface and durable attribution.
5. `graph-review-scenario-options` — artifact-only critique/probe lane; can advance in parallel with FE-700 if it does not commit canonical graph truth.
6. `productized-scenario-options` — user-facing acceleration surface after FE-700 semantics, FE-701 changesets, and graph-review probes.

### Parallel / Low-conflict

Expand Down Expand Up @@ -74,15 +74,17 @@ The May 2026 intent-spec, multi-chat, changeset-ledger, prompt/context, and agen
### chat-runtime-secondary-chats

- **Name:** Chat runtime — inline secondary chats (Conversational Workspace Runtime — Track 2)
- **Linear:** FE-710 if retitled; otherwise unassigned in this plan snapshot
- **Linear:** FE-716
- **Kind:** structural
- **Status:** not-started / replanned
- **Status:** active — V1 narrowing in effect; current card queue in `memory/CARDS.md`
- **Objective:** Render side, reconciliation, qa, and strategy chats inline as collapsible secondary chats in the workspace using the existing chat/turn substrate. Defer schema-level `thread`; do not add `thread` / `turn.thread_id` unless a later RFC proves chat/turn insufficient. Retire the SideChatPopover as a UI surface only after parity exists over durable secondary chats.
- **V1 narrowing (FE-716 scope):** Frame V1 as "every behavior the current side-chat (V3.1) supports today, surfaced through the elevated unified-workspace shape." Build only what that framing requires: substrate columns on `chat` (`parent_chat_id`, `invoked_in_turn_id`, `pinned_item_id`, `pinned_span_hint`) without enum changes; durable secondary-chat persistence; inline collapsible rendering; turn-zero kickoff with server-supplied snapshots; Ask/Edit modes; `#` knowledge-item symbol injection only; lightweight reconciliation-element view (full reconciliation runtime stays Track 3); agent-run inline rendering; SideChatPopover deletion. Explicitly defer to follow-up frontiers: `$` secondary-chat mention symbol, full reconciliation target-grouped UX, QA composer refinements, strategy sub-chat UI, layout-state header control, mention autocomplete, snapshot builders, item-version-gated refresh. Design brief `docs/design/UNIFIED_CHAT_UX.md` is the canonical reference for the broader ceiling and stays unedited.
- **Why now / unlocks:** Track 1 (workspace shell) ships, providing the stable host. Inline secondary chats are the critical unblocker for reconciliation absorption (Track 3) and give chat-context provision (Track 5) stable initiating anchors without creating a competing strategy/context substrate. Supersedes the prior side-chat V4a persistence horizon — persistent side-chat history becomes durable secondary chats rendered inline.
- **Acceptance:** Secondary chat kinds (`side`, `reconciliation`, `qa`, `strategy`) are representable with chat/turn; each active/resumable chat preserves one open assistant/system-first frontier turn; secondary chats render inline/collapsible in the unified workspace; SideChatPopover retires as cutover; transient staged-patches strip does not become a new source of semantic truth; turn-zero (`turn_kind='kickoff'`) seeds secondary chats with explicit context snapshots.
- **Acceptance:** Secondary chat kinds (`side`, `reconciliation`, `qa`, `strategy`) are representable with chat/turn; each active/resumable chat preserves one open assistant/system-first frontier turn; secondary chats render inline/collapsible in the unified workspace; SideChatPopover retires as cutover; transient staged-patches strip does not become a new source of semantic truth; turn-zero (`turn_kind='kickoff'`) seeds secondary chats with explicit context snapshots (full snapshot lifecycle deferred to Track 5).
- **Verification:** Chat/turn persistence and reload tests, inline secondary-chat rendering tests, one-open-frontier-per-chat tests, manual walkthroughs for side/qa/strategy chat creation/display/collapse, and regression on existing interview flow.
- **Open question (resolve in Card 1 / Card 6):** Agent-run inline rendering — fifth `chat.kind` enum value, system-authored sub-chat reusing an existing kind, or a derived projection over `first_turn_role`. HANDOFF flagged for explicit decision; default posture is to keep the enum at `interview` + `side_chat` and project agent-run from `first_turn_role='system'` unless substrate behavior justifies promotion.
- **Traceability:** Requirement 45; A49, A94; D86, D87, D110, D114, D138, D153; I111, I116, I120.
- **Design docs:** `docs/design/CONVERSATIONAL_WORKSPACE_RUNTIME.md` §3.2 + §5 Track 2; `docs/design/MULTI_CHAT.md`; `docs/design/SIDE_CHAT.md`; `docs/design/SPEC_EVOLUTION_STRATEGIES.md`.
- **Design docs:** `docs/design/CONVERSATIONAL_WORKSPACE_RUNTIME.md` §3.2 + §5 Track 2; `docs/design/MULTI_CHAT.md`; `docs/design/SIDE_CHAT.md`; `docs/design/SPEC_EVOLUTION_STRATEGIES.md`; design brief `docs/design/UNIFIED_CHAT_UX.md` (to be brought forward verbatim from PR #138 in Card 0; do not edit).

### reconciliation-runtime

Expand Down
160 changes: 160 additions & 0 deletions src/client/components/__tests__/secondary-chat-collapsible.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// @vitest-environment happy-dom

import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { z } from 'zod/v4';

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

import { SecondaryChatCollapsible } from '../secondary-chat-collapsible.js';

type SecondaryChat = z.infer<typeof secondaryChatStateSchema>;

const baseChat: SecondaryChat['chat'] = {
id: 7,
specification_id: 1,
kind: 'side_chat',
parent_chat_id: 1,
invoked_in_turn_id: 3,
pinned_item_id: null,
pinned_span_hint: null,
mode: 'explore',
};

afterEach(() => cleanup());

describe('SecondaryChatCollapsible', () => {
it('renders the header for a secondary chat with a kickoff turn', () => {
const chat: SecondaryChat = {
chat: baseChat,
kickoffTurn: {
id: 99,
specification_id: 1,
parent_turn_id: null,
phase: 'grounding',
turn_kind: 'kickoff',
question: '',
why: null,
impact: null,
answer: null,
is_resolution: false,
user_parts: null,
assistant_parts: 'Editing this item.',
created_at: '',
},
};

render(<SecondaryChatCollapsible secondaryChat={chat} />);

expect(screen.getByTestId('secondary-chat-collapsible')).toBeTruthy();
expect(screen.getByTestId('secondary-chat-collapsible-trigger')).toBeTruthy();
});

it('starts collapsed — body content is not visible', () => {
const chat: SecondaryChat = {
chat: baseChat,
kickoffTurn: {
id: 99,
specification_id: 1,
parent_turn_id: null,
phase: 'grounding',
turn_kind: 'kickoff',
question: '',
why: null,
impact: null,
answer: null,
is_resolution: false,
user_parts: null,
assistant_parts: 'Editing this item.',
created_at: '',
},
};

render(<SecondaryChatCollapsible secondaryChat={chat} />);

expect(screen.queryByText('Editing this item.')).toBeNull();
});

it('expands on trigger click and reveals kickoff turn assistant_parts', () => {
const chat: SecondaryChat = {
chat: baseChat,
kickoffTurn: {
id: 99,
specification_id: 1,
parent_turn_id: null,
phase: 'grounding',
turn_kind: 'kickoff',
question: '',
why: null,
impact: null,
answer: null,
is_resolution: false,
user_parts: null,
assistant_parts: 'Editing this item.',
created_at: '',
},
};

render(<SecondaryChatCollapsible secondaryChat={chat} />);
fireEvent.click(screen.getByTestId('secondary-chat-collapsible-trigger'));

expect(screen.getByText('Editing this item.')).toBeTruthy();
});

it('renders an empty body when no kickoff turn exists', () => {
const chat: SecondaryChat = { chat: baseChat, kickoffTurn: null };

render(<SecondaryChatCollapsible secondaryChat={chat} />);
fireEvent.click(screen.getByTestId('secondary-chat-collapsible-trigger'));

const body = screen.getByTestId('secondary-chat-collapsible-body');
expect(body.textContent?.trim()).toBe('');
});

it('renders the mode toggle reflecting the persisted mode', () => {
const chat: SecondaryChat = { chat: { ...baseChat, mode: 'edit' }, kickoffTurn: null };
render(<SecondaryChatCollapsible secondaryChat={chat} />);
const toggle = screen.getByTestId('secondary-chat-mode-toggle');
expect(toggle.dataset.mode).toBe('edit');
expect(screen.getByTestId('secondary-chat-mode-edit').getAttribute('aria-pressed')).toBe('true');
expect(screen.getByTestId('secondary-chat-mode-ask').getAttribute('aria-pressed')).toBe('false');
});

it('falls back to explore mode when chat.mode is null', () => {
const chat: SecondaryChat = { chat: { ...baseChat, mode: null }, kickoffTurn: null };
render(<SecondaryChatCollapsible secondaryChat={chat} />);
const toggle = screen.getByTestId('secondary-chat-mode-toggle');
expect(toggle.dataset.mode).toBe('explore');
});

it('invokes onSetMode when the user clicks a different mode', () => {
const onSetMode = vi.fn();
const chat: SecondaryChat = { chat: { ...baseChat, mode: 'explore' }, kickoffTurn: null };
render(<SecondaryChatCollapsible secondaryChat={chat} onSetMode={onSetMode} />);
fireEvent.click(screen.getByTestId('secondary-chat-mode-edit'));
expect(onSetMode).toHaveBeenCalledWith('edit');
});

it('does not invoke onSetMode when clicking the already-active mode', () => {
const onSetMode = vi.fn();
const chat: SecondaryChat = { chat: { ...baseChat, mode: 'explore' }, kickoffTurn: null };
render(<SecondaryChatCollapsible secondaryChat={chat} onSetMode={onSetMode} />);
fireEvent.click(screen.getByTestId('secondary-chat-mode-ask'));
expect(onSetMode).not.toHaveBeenCalled();
});

it('disables the toggle while a mode update is in flight', () => {
const onSetMode = vi.fn();
const chat: SecondaryChat = { chat: { ...baseChat, mode: 'explore' }, kickoffTurn: null };
render(<SecondaryChatCollapsible secondaryChat={chat} onSetMode={onSetMode} isModeUpdating />);
expect(screen.getByTestId('secondary-chat-mode-edit').hasAttribute('disabled')).toBe(true);
fireEvent.click(screen.getByTestId('secondary-chat-mode-edit'));
expect(onSetMode).not.toHaveBeenCalled();
});

it('disables the toggle when no onSetMode handler is provided (read-only display)', () => {
const chat: SecondaryChat = { chat: { ...baseChat, mode: 'explore' }, kickoffTurn: null };
render(<SecondaryChatCollapsible secondaryChat={chat} />);
expect(screen.getByTestId('secondary-chat-mode-edit').hasAttribute('disabled')).toBe(true);
});
});
Loading
Loading