From ba89f13d04c1abdcf6110e4dd2c4045b90de24e0 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 6 Apr 2026 11:20:21 -0700 Subject: [PATCH 01/25] =?UTF-8?q?feat(chat):=20ship-readiness=20polish=20?= =?UTF-8?q?=E2=80=94=20Tailwind,=20auto-scroll,=20markdown,=20a11y=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(chat): add ship-readiness polish implementation plan Addresses all 19 audit issues: theme consolidation, Tailwind conversion, auto-scroll, textarea auto-expand, markdown rendering, empty state, responsive sidebar, SVG icons, ARIA, and API cleanup. Co-Authored-By: Claude Opus 4.6 (1M context) * feat(chat): consolidate theme into shared TS module, add icons + markdown utils Co-Authored-By: Claude Opus 4.6 (1M context) * feat(chat): convert ChatComponent to Tailwind, add auto-scroll + empty state + responsive sidebar - Replace 80+ lines of inlined CSS vars with CHAT_THEME_STYLES import - Add CHAT_MARKDOWN_STYLES + renderMarkdown for AI message rendering - Convert all inline style="" attributes to Tailwind utility classes - Add auto-scroll via viewChild + effect tracking message count - Add empty state when no messages and not loading - Make thread sidebar responsive with hidden md:flex + mobile toggle - Add ARIA attributes: role=log, aria-live=polite, role=navigation - Use ViewEncapsulation.None for markdown styles Co-Authored-By: Claude Opus 4.6 (1M context) * feat(chat): convert primitives to Tailwind, add textarea auto-expand + focused signal Co-Authored-By: Claude Opus 4.6 (1M context) * feat(chat): convert ChatDebug + sub-components to theme-aware Tailwind Co-Authored-By: Claude Opus 4.6 (1M context) * feat(chat): convert remaining compositions to Tailwind with SVG icons + theme vars Co-Authored-By: Claude Opus 4.6 (1M context) * feat(chat): clean up public API, add marked peer dep, verify build Remove legacy cp-chat/cp-chat-input/cp-chat-message components and migrate all cockpit examples to the new ChatComponent composition. Export shared styles, icons, and markdown utilities from public API. Update ChatConfig with renderRegistry, avatarLabel, assistantName. Add marked as optional peer dep and fix dynamic import for library build. Add @source directive to cockpit styles for Tailwind scanning. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../angular/src/app/filesystem.component.ts | 77 +- .../filesystem/angular/src/styles.css | 1 + .../angular/src/app/memory.component.ts | 44 +- .../deep-agents/memory/angular/src/styles.css | 1 + .../angular/src/app/planning.component.ts | 52 +- .../planning/angular/src/styles.css | 1 + .../angular/src/app/sandboxes.component.ts | 83 +- .../sandboxes/angular/src/styles.css | 1 + .../angular/src/app/skills.component.ts | 79 +- .../deep-agents/skills/angular/src/styles.css | 1 + .../angular/src/app/subagents.component.ts | 54 +- .../subagents/angular/src/styles.css | 1 + .../deployment-runtime/angular/src/styles.css | 1 + .../src/app/durable-execution.component.ts | 82 +- .../durable-execution/angular/src/styles.css | 1 + .../interrupts/angular/src/styles.css | 1 + .../langgraph/memory/angular/src/styles.css | 1 + .../angular/src/app/persistence.component.ts | 58 +- .../persistence/angular/src/styles.css | 1 + .../streaming/angular/src/styles.css | 1 + .../subgraphs/angular/src/styles.css | 1 + .../angular/src/app/time-travel.component.ts | 71 +- .../time-travel/angular/src/styles.css | 1 + .../plans/2026-04-06-chat-polish.md | 1110 +++++++++++++++++ libs/chat/package.json | 8 +- libs/chat/src/index.ts | 5 +- libs/chat/src/lib/chat-input.component.ts | 42 - libs/chat/src/lib/chat-message.component.ts | 28 - libs/chat/src/lib/chat.component.ts | 73 -- .../chat-debug/chat-debug.component.ts | 191 ++- .../debug-checkpoint-card.component.ts | 8 +- .../chat-debug/debug-controls.component.ts | 8 +- .../chat-debug/debug-detail.component.ts | 4 +- .../chat-debug/debug-state-diff.component.ts | 10 +- .../debug-state-inspector.component.ts | 2 +- .../chat-debug/debug-summary.component.ts | 2 +- .../chat-debug/debug-timeline.component.ts | 4 +- .../chat-interrupt-panel.component.ts | 42 +- .../chat-subagent-card.component.ts | 30 +- .../chat-timeline-slider.component.ts | 22 +- .../chat-tool-call-card.component.ts | 34 +- .../lib/compositions/chat/chat.component.ts | 215 ++-- .../chat-error/chat-error.component.ts | 12 +- .../chat-input/chat-input.component.ts | 79 +- .../chat-typing-indicator.component.ts | 32 +- libs/chat/src/lib/provide-chat.spec.ts | 4 +- libs/chat/src/lib/provide-chat.ts | 11 +- libs/chat/src/lib/styles/chat-icons.ts | 22 + libs/chat/src/lib/styles/chat-markdown.ts | 89 ++ libs/chat/src/lib/styles/chat-theme.css | 153 --- libs/chat/src/lib/styles/chat-theme.ts | 85 ++ libs/chat/src/public-api.ts | 14 +- 52 files changed, 1720 insertions(+), 1233 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-06-chat-polish.md delete mode 100644 libs/chat/src/lib/chat-input.component.ts delete mode 100644 libs/chat/src/lib/chat-message.component.ts delete mode 100644 libs/chat/src/lib/chat.component.ts create mode 100644 libs/chat/src/lib/styles/chat-icons.ts create mode 100644 libs/chat/src/lib/styles/chat-markdown.ts delete mode 100644 libs/chat/src/lib/styles/chat-theme.css create mode 100644 libs/chat/src/lib/styles/chat-theme.ts diff --git a/cockpit/deep-agents/filesystem/angular/src/app/filesystem.component.ts b/cockpit/deep-agents/filesystem/angular/src/app/filesystem.component.ts index 16bffd58d..84935a841 100644 --- a/cockpit/deep-agents/filesystem/angular/src/app/filesystem.component.ts +++ b/cockpit/deep-agents/filesystem/angular/src/app/filesystem.component.ts @@ -1,89 +1,22 @@ -import { Component, computed } from '@angular/core'; -import { LegacyChatComponent } from '@cacheplane/chat'; +import { Component } from '@angular/core'; +import { ChatComponent } from '@cacheplane/chat'; import { streamResource } from '@cacheplane/stream-resource'; import { environment } from '../environments/environment'; -interface ToolCallEntry { - name: string; - args: string; - result?: string; -} - /** * FilesystemComponent demonstrates agent file operations. * - * The agent can read and write files using tool calls. The sidebar - * shows a real-time log of each file operation as it happens. - * - * Key integration points: - * - `stream.messages()` contains all messages including tool call results - * - `computed()` derives tool call entries from AI messages - * - Tool calls update reactively as the agent performs file operations + * The agent can read and write files using tool calls. */ @Component({ selector: 'app-filesystem', standalone: true, - imports: [LegacyChatComponent], - template: ` - - -

File Operations

- @for (entry of toolCallEntries(); track $index) { -
- - {{ entry.name === 'read_file' ? 'πŸ“–' : '✏️' }} - -
-
- {{ getFilePath(entry.args) }} -
-
- {{ entry.name === 'read_file' ? 'read' : 'write' }} - {{ entry.result ? ' Β· done' : ' Β· running…' }} -
-
-
- } - @empty { -

Ask the agent to read or write a file.

- } -
-
- `, + imports: [ChatComponent], + template: ``, }) export class FilesystemComponent { protected readonly stream = streamResource({ apiUrl: environment.langGraphApiUrl, assistantId: environment.streamingAssistantId, }); - - toolCallEntries = computed(() => { - const msg = this.stream.messages(); - const calls: ToolCallEntry[] = []; - for (const m of msg) { - if ((m as any).tool_calls) { - for (const tc of (m as any).tool_calls) { - calls.push({ name: tc.name, args: JSON.stringify(tc.args), result: tc.output }); - } - } - } - return calls; - }); - - getFilePath(args: string): string { - try { - const parsed = JSON.parse(args); - return parsed.path ?? args; - } catch { - return args; - } - } - - send(text: string): void { - this.stream.submit({ messages: [{ role: 'human', content: text }] }); - } } diff --git a/cockpit/deep-agents/filesystem/angular/src/styles.css b/cockpit/deep-agents/filesystem/angular/src/styles.css index 39a32d4e7..061c66cf8 100644 --- a/cockpit/deep-agents/filesystem/angular/src/styles.css +++ b/cockpit/deep-agents/filesystem/angular/src/styles.css @@ -1,5 +1,6 @@ @import "../../../../../libs/design-tokens/src/lib/tokens.css"; @import "tailwindcss"; +@source "../../../../../libs/chat/src/"; @theme { --color-bg: var(--ds-bg); diff --git a/cockpit/deep-agents/memory/angular/src/app/memory.component.ts b/cockpit/deep-agents/memory/angular/src/app/memory.component.ts index c6c60fdf9..035fcfd8f 100644 --- a/cockpit/deep-agents/memory/angular/src/app/memory.component.ts +++ b/cockpit/deep-agents/memory/angular/src/app/memory.component.ts @@ -1,5 +1,5 @@ -import { Component, computed } from '@angular/core'; -import { LegacyChatComponent } from '@cacheplane/chat'; +import { Component } from '@angular/core'; +import { ChatComponent } from '@cacheplane/chat'; import { streamResource } from '@cacheplane/stream-resource'; import { environment } from '../environments/environment'; @@ -7,51 +7,17 @@ import { environment } from '../environments/environment'; * MemoryComponent demonstrates persistent agent memory across sessions. * * The agent extracts facts about the user from each conversation turn - * and stores them in `agent_memory` state. The sidebar shows all learned - * facts in real time as the agent updates its memory. - * - * Key integration points: - * - `stream.value()` contains the agent state including `agent_memory` - * - `computed()` derives key/value pairs for the sidebar - * - Memory entries update reactively as the agent learns new facts + * and stores them in `agent_memory` state. */ @Component({ selector: 'app-da-memory', standalone: true, - imports: [LegacyChatComponent], - template: ` - - -

Learned Facts

- @for (entry of memoryEntries(); track entry[0]) { -
-
{{ entry[0] }}
-
{{ entry[1] }}
-
- } - @empty { -

Tell the agent something about yourself to see it remember.

- } -
-
- `, + imports: [ChatComponent], + template: ``, }) export class MemoryComponent { protected readonly stream = streamResource({ apiUrl: environment.langGraphApiUrl, assistantId: environment.streamingAssistantId, }); - - memoryEntries = computed(() => { - const val = this.stream.value() as { agent_memory?: Record } | undefined; - return Object.entries(val?.agent_memory ?? {}); - }); - - send(text: string): void { - this.stream.submit({ messages: [{ role: 'human', content: text }] }); - } } diff --git a/cockpit/deep-agents/memory/angular/src/styles.css b/cockpit/deep-agents/memory/angular/src/styles.css index 39a32d4e7..061c66cf8 100644 --- a/cockpit/deep-agents/memory/angular/src/styles.css +++ b/cockpit/deep-agents/memory/angular/src/styles.css @@ -1,5 +1,6 @@ @import "../../../../../libs/design-tokens/src/lib/tokens.css"; @import "tailwindcss"; +@source "../../../../../libs/chat/src/"; @theme { --color-bg: var(--ds-bg); diff --git a/cockpit/deep-agents/planning/angular/src/app/planning.component.ts b/cockpit/deep-agents/planning/angular/src/app/planning.component.ts index 6394149e9..53c2b6b04 100644 --- a/cockpit/deep-agents/planning/angular/src/app/planning.component.ts +++ b/cockpit/deep-agents/planning/angular/src/app/planning.component.ts @@ -1,65 +1,23 @@ -import { Component, computed } from '@angular/core'; -import { LegacyChatComponent } from '@cacheplane/chat'; +import { Component } from '@angular/core'; +import { ChatComponent } from '@cacheplane/chat'; import { streamResource } from '@cacheplane/stream-resource'; import { environment } from '../environments/environment'; -interface PlanStep { - title: string; - status: 'pending' | 'running' | 'complete'; -} - /** * PlanningComponent demonstrates agent task decomposition. * * The agent receives a complex task, breaks it into ordered steps, - * and executes them. The sidebar shows each step's status in real time. - * - * Key integration points: - * - `stream.value()` contains the plan state with step list - * - `computed()` derives the plan steps for the sidebar - * - Steps update reactively as the agent works through them + * and executes them sequentially. */ @Component({ selector: 'app-planning', standalone: true, - imports: [LegacyChatComponent], - template: ` - - -

Task Plan

- @for (step of planSteps(); track $index) { -
- - - {{ step.title }} - -
- } - @empty { -

Ask a complex question to see the plan.

- } -
-
- `, + imports: [ChatComponent], + template: ``, }) export class PlanningComponent { protected readonly stream = streamResource({ apiUrl: environment.langGraphApiUrl, assistantId: environment.streamingAssistantId, }); - - planSteps = computed(() => { - const val = this.stream.value() as { plan?: PlanStep[] } | undefined; - return val?.plan ?? []; - }); - - send(text: string): void { - this.stream.submit({ messages: [{ role: 'human', content: text }] }); - } } diff --git a/cockpit/deep-agents/planning/angular/src/styles.css b/cockpit/deep-agents/planning/angular/src/styles.css index 39a32d4e7..061c66cf8 100644 --- a/cockpit/deep-agents/planning/angular/src/styles.css +++ b/cockpit/deep-agents/planning/angular/src/styles.css @@ -1,5 +1,6 @@ @import "../../../../../libs/design-tokens/src/lib/tokens.css"; @import "tailwindcss"; +@source "../../../../../libs/chat/src/"; @theme { --color-bg: var(--ds-bg); diff --git a/cockpit/deep-agents/sandboxes/angular/src/app/sandboxes.component.ts b/cockpit/deep-agents/sandboxes/angular/src/app/sandboxes.component.ts index ab8e8ab75..cfcfec045 100644 --- a/cockpit/deep-agents/sandboxes/angular/src/app/sandboxes.component.ts +++ b/cockpit/deep-agents/sandboxes/angular/src/app/sandboxes.component.ts @@ -1,96 +1,23 @@ -import { Component, computed } from '@angular/core'; -import { LegacyChatComponent } from '@cacheplane/chat'; +import { Component } from '@angular/core'; +import { ChatComponent } from '@cacheplane/chat'; import { streamResource } from '@cacheplane/stream-resource'; import { environment } from '../environments/environment'; -interface ExecutionLog { - code: string; - stdout: string; - exitStatus: number; -} - /** * SandboxesComponent demonstrates a coding agent that executes Python code. * * The agent writes and runs code snippets to solve problems using a - * `run_code` tool. The sidebar shows execution logs β€” code input, stdout - * output, and exit status β€” for each sandbox execution. - * - * Key integration points: - * - `stream.messages()` contains all messages including tool call results - * - `computed()` derives execution log entries from tool calls in AI messages - * - Logs update reactively as the agent writes and runs code + * `run_code` tool. */ @Component({ selector: 'app-sandboxes', standalone: true, - imports: [LegacyChatComponent], - template: ` - - -

Execution Logs

- @for (log of executionLogs(); track $index) { -
-
- - exit {{ log.exitStatus }} - -
-
{{ log.code }}
- @if (log.stdout) { -
stdout
-
{{ log.stdout }}
- } -
- } - @empty { -

Ask the agent to write and run Python code.

- } -
-
- `, + imports: [ChatComponent], + template: ``, }) export class SandboxesComponent { protected readonly stream = streamResource({ apiUrl: environment.langGraphApiUrl, assistantId: environment.streamingAssistantId, }); - - executionLogs = computed(() => { - const msgs = this.stream.messages(); - const logs: ExecutionLog[] = []; - for (const m of msgs) { - if ((m as any).tool_calls) { - for (const tc of (m as any).tool_calls) { - if (tc.name === 'run_code' && tc.output) { - try { - const parsed = JSON.parse(tc.output); - logs.push({ - code: tc.args?.code ?? '', - stdout: parsed.stdout ?? '', - exitStatus: parsed.exit_status ?? 0, - }); - } catch { - logs.push({ - code: tc.args?.code ?? '', - stdout: tc.output, - exitStatus: 0, - }); - } - } - } - } - } - return logs; - }); - - send(text: string): void { - this.stream.submit({ messages: [{ role: 'human', content: text }] }); - } } diff --git a/cockpit/deep-agents/sandboxes/angular/src/styles.css b/cockpit/deep-agents/sandboxes/angular/src/styles.css index 39a32d4e7..061c66cf8 100644 --- a/cockpit/deep-agents/sandboxes/angular/src/styles.css +++ b/cockpit/deep-agents/sandboxes/angular/src/styles.css @@ -1,5 +1,6 @@ @import "../../../../../libs/design-tokens/src/lib/tokens.css"; @import "tailwindcss"; +@source "../../../../../libs/chat/src/"; @theme { --color-bg: var(--ds-bg); diff --git a/cockpit/deep-agents/skills/angular/src/app/skills.component.ts b/cockpit/deep-agents/skills/angular/src/app/skills.component.ts index 718ce244f..f194e2185 100644 --- a/cockpit/deep-agents/skills/angular/src/app/skills.component.ts +++ b/cockpit/deep-agents/skills/angular/src/app/skills.component.ts @@ -1,92 +1,23 @@ -import { Component, computed } from '@angular/core'; -import { LegacyChatComponent } from '@cacheplane/chat'; +import { Component } from '@angular/core'; +import { ChatComponent } from '@cacheplane/chat'; import { streamResource } from '@cacheplane/stream-resource'; import { environment } from '../environments/environment'; -interface SkillInvocation { - skillName: string; - args: string; - result?: string; -} - /** * SkillsComponent demonstrates a multi-skill agent with specialized tools. * * The agent can calculate math expressions, count words, and summarize text - * by selecting the appropriate skill tool for each user request. The sidebar - * shows each skill invocation as a card with the skill name, input args, - * and result. - * - * Key integration points: - * - `stream.messages()` contains all messages including tool call data - * - `computed()` derives skill invocation cards from tool calls in AI messages - * - Invocations update reactively as the agent calls and receives tool results + * by selecting the appropriate skill tool for each user request. */ @Component({ selector: 'app-skills', standalone: true, - imports: [LegacyChatComponent], - template: ` - - -

Skill Invocations

- @for (inv of skillInvocations(); track $index) { -
-
- - {{ inv.skillName }} - - @if (inv.result) { - done - } @else { - running… - } -
-
- {{ inv.args }} -
- @if (inv.result) { -
- {{ inv.result }} -
- } -
- } - @empty { -

Ask the agent to calculate, count words, or summarize text.

- } -
-
- `, + imports: [ChatComponent], + template: ``, }) export class SkillsComponent { protected readonly stream = streamResource({ apiUrl: environment.langGraphApiUrl, assistantId: environment.streamingAssistantId, }); - - skillInvocations = computed(() => { - const msgs = this.stream.messages(); - const invocations: SkillInvocation[] = []; - for (const m of msgs) { - if ((m as any).tool_calls) { - for (const tc of (m as any).tool_calls) { - invocations.push({ - skillName: tc.name, - args: JSON.stringify(tc.args), - result: tc.output, - }); - } - } - } - return invocations; - }); - - send(text: string): void { - this.stream.submit({ messages: [{ role: 'human', content: text }] }); - } } diff --git a/cockpit/deep-agents/skills/angular/src/styles.css b/cockpit/deep-agents/skills/angular/src/styles.css index 39a32d4e7..061c66cf8 100644 --- a/cockpit/deep-agents/skills/angular/src/styles.css +++ b/cockpit/deep-agents/skills/angular/src/styles.css @@ -1,5 +1,6 @@ @import "../../../../../libs/design-tokens/src/lib/tokens.css"; @import "tailwindcss"; +@source "../../../../../libs/chat/src/"; @theme { --color-bg: var(--ds-bg); diff --git a/cockpit/deep-agents/subagents/angular/src/app/subagents.component.ts b/cockpit/deep-agents/subagents/angular/src/app/subagents.component.ts index 1868c0a6a..e7bf766a7 100644 --- a/cockpit/deep-agents/subagents/angular/src/app/subagents.component.ts +++ b/cockpit/deep-agents/subagents/angular/src/app/subagents.component.ts @@ -1,5 +1,5 @@ -import { Component, computed } from '@angular/core'; -import { LegacyChatComponent } from '@cacheplane/chat'; +import { Component } from '@angular/core'; +import { ChatComponent } from '@cacheplane/chat'; import { streamResource } from '@cacheplane/stream-resource'; import { environment } from '../environments/environment'; @@ -7,61 +7,17 @@ import { environment } from '../environments/environment'; * SubagentsComponent demonstrates the Deep Agents subagent delegation pattern. * * The orchestrator agent receives a task and delegates subtasks to specialist - * subagents via tool calls. Each tool call spawns a child agent that streams - * its own progress independently. - * - * Key integration points: - * - `stream.subagents()` returns a Map - * - `subagentEntries` derives a sorted array for sidebar rendering - * - Each entry shows the tool call ID (truncated), status badge, and message count - * - Subagent statuses update reactively: pending β†’ running β†’ complete + * subagents via tool calls. */ @Component({ selector: 'app-subagents', standalone: true, - imports: [LegacyChatComponent], - template: ` - - -

Subagents

- @for (entry of subagentEntries(); track entry[0]) { -
-
- - {{ entry[1].status() }} - - - {{ entry[0].slice(0, 8) }}… - -
-
- {{ entry[1].messages().length }} message{{ entry[1].messages().length === 1 ? '' : 's' }} -
-
- } - @empty { -

Ask a question to see subagent activity.

- } -
-
- `, + imports: [ChatComponent], + template: ``, }) export class SubagentsComponent { protected readonly stream = streamResource({ apiUrl: environment.langGraphApiUrl, assistantId: environment.streamingAssistantId, }); - - subagentEntries = computed(() => Array.from(this.stream.subagents().entries())); - - send(text: string): void { - this.stream.submit({ messages: [{ role: 'human', content: text }] }); - } } diff --git a/cockpit/deep-agents/subagents/angular/src/styles.css b/cockpit/deep-agents/subagents/angular/src/styles.css index 39a32d4e7..061c66cf8 100644 --- a/cockpit/deep-agents/subagents/angular/src/styles.css +++ b/cockpit/deep-agents/subagents/angular/src/styles.css @@ -1,5 +1,6 @@ @import "../../../../../libs/design-tokens/src/lib/tokens.css"; @import "tailwindcss"; +@source "../../../../../libs/chat/src/"; @theme { --color-bg: var(--ds-bg); diff --git a/cockpit/langgraph/deployment-runtime/angular/src/styles.css b/cockpit/langgraph/deployment-runtime/angular/src/styles.css index 39a32d4e7..061c66cf8 100644 --- a/cockpit/langgraph/deployment-runtime/angular/src/styles.css +++ b/cockpit/langgraph/deployment-runtime/angular/src/styles.css @@ -1,5 +1,6 @@ @import "../../../../../libs/design-tokens/src/lib/tokens.css"; @import "tailwindcss"; +@source "../../../../../libs/chat/src/"; @theme { --color-bg: var(--ds-bg); diff --git a/cockpit/langgraph/durable-execution/angular/src/app/durable-execution.component.ts b/cockpit/langgraph/durable-execution/angular/src/app/durable-execution.component.ts index 2aa442d12..5e692ca39 100644 --- a/cockpit/langgraph/durable-execution/angular/src/app/durable-execution.component.ts +++ b/cockpit/langgraph/durable-execution/angular/src/app/durable-execution.component.ts @@ -1,5 +1,5 @@ import { Component } from '@angular/core'; -import { LegacyChatComponent } from '@cacheplane/chat'; +import { ChatComponent } from '@cacheplane/chat'; import { streamResource } from '@cacheplane/stream-resource'; import { environment } from '../environments/environment'; @@ -8,89 +8,19 @@ import { environment } from '../environments/environment'; * with `streamResource()`. * * This example shows how a graph checkpoints at each node, enabling it to - * resume after failures. The sidebar shows execution status in real time: - * - `stream.status()` as a badge (idle/loading/resolved/error) - * - `stream.hasValue()` indicator for received data - * - A "Retry" button that calls `stream.reload()` when `stream.error()` is set - * - * The backend processes each request through three nodes: - * analyze β†’ plan β†’ generate - * Each node updates `state.step` so the UI can track progress. + * resume after failures. The backend processes each request through three + * nodes: analyze, plan, generate. Each node updates `state.step` so the + * UI can track progress. */ @Component({ selector: 'app-durable-execution', standalone: true, - imports: [LegacyChatComponent], - template: ` - - -

Execution Status

- -
- Status -
- - {{ stream.status() }} - -
-
- -
- Data Received -
- - {{ stream.hasValue() ? 'Yes' : 'No' }} -
-
- - @if (stream.error()) { -
-
Execution Failed
- -
- } -
-
- `, + imports: [ChatComponent], + template: ``, }) export class DurableExecutionComponent { - /** - * The streaming resource backing this durable-execution demo. - * - * The graph runs three nodes (analyze β†’ plan β†’ generate), checkpointing - * after each one. If the graph fails partway through, `stream.reload()` - * re-submits the last input so the run can resume from the last checkpoint. - */ protected readonly stream = streamResource({ apiUrl: environment.langGraphApiUrl, assistantId: environment.streamingAssistantId, }); - - /** - * Submit a message to be processed through the multi-node graph. - */ - send(text: string): void { - this.stream.submit({ messages: [{ role: 'human', content: text }] }); - } - - /** - * Returns a colour for the status badge based on the current stream status. - */ - statusBadgeColor(): string { - switch (this.stream.status()) { - case 'loading': - case 'reloading': return '#2563eb'; - case 'resolved': return '#16a34a'; - case 'error': return '#dc2626'; - default: return '#6b7280'; - } - } } diff --git a/cockpit/langgraph/durable-execution/angular/src/styles.css b/cockpit/langgraph/durable-execution/angular/src/styles.css index 39a32d4e7..061c66cf8 100644 --- a/cockpit/langgraph/durable-execution/angular/src/styles.css +++ b/cockpit/langgraph/durable-execution/angular/src/styles.css @@ -1,5 +1,6 @@ @import "../../../../../libs/design-tokens/src/lib/tokens.css"; @import "tailwindcss"; +@source "../../../../../libs/chat/src/"; @theme { --color-bg: var(--ds-bg); diff --git a/cockpit/langgraph/interrupts/angular/src/styles.css b/cockpit/langgraph/interrupts/angular/src/styles.css index 39a32d4e7..061c66cf8 100644 --- a/cockpit/langgraph/interrupts/angular/src/styles.css +++ b/cockpit/langgraph/interrupts/angular/src/styles.css @@ -1,5 +1,6 @@ @import "../../../../../libs/design-tokens/src/lib/tokens.css"; @import "tailwindcss"; +@source "../../../../../libs/chat/src/"; @theme { --color-bg: var(--ds-bg); diff --git a/cockpit/langgraph/memory/angular/src/styles.css b/cockpit/langgraph/memory/angular/src/styles.css index 39a32d4e7..061c66cf8 100644 --- a/cockpit/langgraph/memory/angular/src/styles.css +++ b/cockpit/langgraph/memory/angular/src/styles.css @@ -1,5 +1,6 @@ @import "../../../../../libs/design-tokens/src/lib/tokens.css"; @import "tailwindcss"; +@source "../../../../../libs/chat/src/"; @theme { --color-bg: var(--ds-bg); diff --git a/cockpit/langgraph/persistence/angular/src/app/persistence.component.ts b/cockpit/langgraph/persistence/angular/src/app/persistence.component.ts index aa7f77f42..dabe6861c 100644 --- a/cockpit/langgraph/persistence/angular/src/app/persistence.component.ts +++ b/cockpit/langgraph/persistence/angular/src/app/persistence.component.ts @@ -1,5 +1,5 @@ import { Component } from '@angular/core'; -import { LegacyChatComponent } from '@cacheplane/chat'; +import { ChatComponent } from '@cacheplane/chat'; import { streamResource } from '@cacheplane/stream-resource'; import { environment } from '../environments/environment'; @@ -19,30 +19,8 @@ import { environment } from '../environments/environment'; @Component({ selector: 'app-persistence', standalone: true, - imports: [LegacyChatComponent], - template: ` - - -

Threads

- @for (id of threadIds; track id) { - - } - -
-
- `, + imports: [ChatComponent], + template: ``, }) export class PersistenceComponent { /** @@ -54,35 +32,5 @@ export class PersistenceComponent { protected readonly stream = streamResource({ apiUrl: environment.langGraphApiUrl, assistantId: environment.streamingAssistantId, - onThreadId: (id: string) => { - this.currentThreadId = id; - if (!this.threadIds.includes(id)) this.threadIds.push(id); - }, }); - - threadIds: string[] = []; - currentThreadId = ''; - - /** - * Submit a message to the current thread. - */ - send(text: string): void { - this.stream.submit({ messages: [{ role: 'human', content: text }] }); - } - - /** - * Switch to an existing thread, loading its full message history. - */ - selectThread(id: string): void { - this.currentThreadId = id; - this.stream.switchThread(id); - } - - /** - * Start a new conversation thread. - */ - newThread(): void { - this.currentThreadId = ''; - this.stream.switchThread(null); - } } diff --git a/cockpit/langgraph/persistence/angular/src/styles.css b/cockpit/langgraph/persistence/angular/src/styles.css index 39a32d4e7..061c66cf8 100644 --- a/cockpit/langgraph/persistence/angular/src/styles.css +++ b/cockpit/langgraph/persistence/angular/src/styles.css @@ -1,5 +1,6 @@ @import "../../../../../libs/design-tokens/src/lib/tokens.css"; @import "tailwindcss"; +@source "../../../../../libs/chat/src/"; @theme { --color-bg: var(--ds-bg); diff --git a/cockpit/langgraph/streaming/angular/src/styles.css b/cockpit/langgraph/streaming/angular/src/styles.css index 39a32d4e7..061c66cf8 100644 --- a/cockpit/langgraph/streaming/angular/src/styles.css +++ b/cockpit/langgraph/streaming/angular/src/styles.css @@ -1,5 +1,6 @@ @import "../../../../../libs/design-tokens/src/lib/tokens.css"; @import "tailwindcss"; +@source "../../../../../libs/chat/src/"; @theme { --color-bg: var(--ds-bg); diff --git a/cockpit/langgraph/subgraphs/angular/src/styles.css b/cockpit/langgraph/subgraphs/angular/src/styles.css index 39a32d4e7..061c66cf8 100644 --- a/cockpit/langgraph/subgraphs/angular/src/styles.css +++ b/cockpit/langgraph/subgraphs/angular/src/styles.css @@ -1,5 +1,6 @@ @import "../../../../../libs/design-tokens/src/lib/tokens.css"; @import "tailwindcss"; +@source "../../../../../libs/chat/src/"; @theme { --color-bg: var(--ds-bg); diff --git a/cockpit/langgraph/time-travel/angular/src/app/time-travel.component.ts b/cockpit/langgraph/time-travel/angular/src/app/time-travel.component.ts index 17759117d..96f92c31c 100644 --- a/cockpit/langgraph/time-travel/angular/src/app/time-travel.component.ts +++ b/cockpit/langgraph/time-travel/angular/src/app/time-travel.component.ts @@ -1,5 +1,5 @@ import { Component } from '@angular/core'; -import { LegacyChatComponent } from '@cacheplane/chat'; +import { ChatComponent } from '@cacheplane/chat'; import { streamResource } from '@cacheplane/stream-resource'; import { environment } from '../environments/environment'; @@ -7,78 +7,19 @@ import { environment } from '../environments/environment'; * TimeTravelComponent demonstrates replaying and branching conversation history. * * Key integration points: - * - `stream.history()` β€” array of ThreadState snapshots - * - `stream.branch()` β€” current branch identifier - * - `stream.setBranch(id)` β€” switch to a different checkpoint + * - `stream.history()` -- array of ThreadState snapshots + * - `stream.branch()` -- current branch identifier + * - `stream.setBranch(id)` -- switch to a different checkpoint */ @Component({ selector: 'app-time-travel', standalone: true, - imports: [LegacyChatComponent], - template: ` - - -

History

- @for (state of stream.history(); track $index) { - - } - @if (stream.history().length === 0) { -

No history yet. Send a message to begin.

- } -
-
- `, + imports: [ChatComponent], + template: ``, }) export class TimeTravelComponent { - /** - * The streaming resource with checkpointing enabled. - * - * `stream.history()` provides an array of ThreadState snapshots for - * the current thread. `stream.branch()` tracks the active checkpoint. - * Call `stream.setBranch(checkpointId)` to replay from a past state. - */ protected readonly stream = streamResource({ apiUrl: environment.langGraphApiUrl, assistantId: environment.streamingAssistantId, }); - - /** - * Submit a message to the current thread. - */ - send(text: string): void { - this.stream.submit({ messages: [{ role: 'human', content: text }] }); - } - - /** - * Branch the conversation from the selected checkpoint. - * After calling setBranch, the next submit will fork from that point. - */ - selectCheckpoint(state: { checkpoint_id?: string }): void { - if (state.checkpoint_id) { - this.stream.setBranch(state.checkpoint_id); - } - } - - /** - * Format a checkpoint for display in the sidebar. - */ - formatCheckpoint(state: { checkpoint_id?: string; created_at?: string }): string { - const id = state.checkpoint_id ?? 'unknown'; - const short = id.substring(0, 8); - if (state.created_at) { - const ts = new Date(state.created_at).toLocaleTimeString(); - return `${short}... @ ${ts}`; - } - return `${short}...`; - } } diff --git a/cockpit/langgraph/time-travel/angular/src/styles.css b/cockpit/langgraph/time-travel/angular/src/styles.css index 39a32d4e7..061c66cf8 100644 --- a/cockpit/langgraph/time-travel/angular/src/styles.css +++ b/cockpit/langgraph/time-travel/angular/src/styles.css @@ -1,5 +1,6 @@ @import "../../../../../libs/design-tokens/src/lib/tokens.css"; @import "tailwindcss"; +@source "../../../../../libs/chat/src/"; @theme { --color-bg: var(--ds-bg); diff --git a/docs/superpowers/plans/2026-04-06-chat-polish.md b/docs/superpowers/plans/2026-04-06-chat-polish.md new file mode 100644 index 000000000..c26fe1507 --- /dev/null +++ b/docs/superpowers/plans/2026-04-06-chat-polish.md @@ -0,0 +1,1110 @@ +# Chat Library Ship-Readiness Polish β€” Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix all 19 audit issues in `@cacheplane/chat` β€” convert all components to Tailwind, consolidate duplicated theme CSS, add auto-scroll, textarea auto-expand, markdown rendering, empty state, responsive sidebar, SVG icons, ARIA, and clean up the public API. The library must build and ship correctly via ng-packagr. + +**Architecture:** All components use Tailwind utility classes with `[var(--chat-*)]` arbitrary values for theme-aware styling. CSS custom properties are defined once in a shared TypeScript constant and imported by composition components. ng-packagr preserves Tailwind class names in compiled templates; the consuming app's Tailwind build generates the CSS. `marked` is added as a peer dep for markdown rendering. + +**Tech Stack:** Angular 20+, Tailwind CSS v4, `marked` (markdown), ng-packagr, Vitest + +**Parallelism:** Tasks 2–5 are independent and can be dispatched as parallel subagents after Task 1 completes. Task 6 depends on Tasks 2+3. Task 7 is final. + +--- + +## File Structure + +### New files +- `libs/chat/src/lib/styles/chat-theme.ts` β€” Shared CSS custom property definitions (replaces `chat-theme.css`) +- `libs/chat/src/lib/styles/chat-icons.ts` β€” SVG icon constants (replaces emoji) +- `libs/chat/src/lib/styles/chat-markdown.ts` β€” Markdown rendering utility + prose styles + +### Modified files +- `libs/chat/src/lib/compositions/chat/chat.component.ts` β€” Tailwind + auto-scroll + empty state + responsive sidebar +- `libs/chat/src/lib/compositions/chat-debug/chat-debug.component.ts` β€” Tailwind + auto-scroll + shared theme +- `libs/chat/src/lib/compositions/chat-debug/debug-timeline.component.ts` β€” Theme-aware colors +- `libs/chat/src/lib/compositions/chat-debug/debug-detail.component.ts` β€” Theme-aware colors +- `libs/chat/src/lib/compositions/chat-debug/debug-state-diff.component.ts` β€” Theme-aware colors +- `libs/chat/src/lib/compositions/chat-debug/debug-state-inspector.component.ts` β€” Theme-aware colors +- `libs/chat/src/lib/compositions/chat-debug/debug-checkpoint-card.component.ts` β€” Theme-aware colors +- `libs/chat/src/lib/compositions/chat-debug/debug-controls.component.ts` β€” Theme-aware colors +- `libs/chat/src/lib/compositions/chat-debug/debug-summary.component.ts` β€” Theme-aware colors +- `libs/chat/src/lib/compositions/chat-interrupt-panel/chat-interrupt-panel.component.ts` β€” Tailwind + SVG icons +- `libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.ts` β€” Tailwind + SVG icons +- `libs/chat/src/lib/compositions/chat-subagent-card/chat-subagent-card.component.ts` β€” Tailwind + SVG icons +- `libs/chat/src/lib/compositions/chat-timeline-slider/chat-timeline-slider.component.ts` β€” Theme-aware colors +- `libs/chat/src/lib/primitives/chat-input/chat-input.component.ts` β€” Tailwind + auto-expand + focused signal +- `libs/chat/src/lib/primitives/chat-typing-indicator/chat-typing-indicator.component.ts` β€” Tailwind +- `libs/chat/src/lib/primitives/chat-error/chat-error.component.ts` β€” CSS var colors +- `libs/chat/src/lib/provide-chat.ts` β€” Typed ChatConfig +- `libs/chat/src/public-api.ts` β€” Remove legacy, add new exports +- `libs/chat/package.json` β€” Add marked peer dep +- `cockpit/*/angular/src/styles.css` (14 files) β€” Add @source for chat library + +### Deleted files +- `libs/chat/src/lib/styles/chat-theme.css` β€” Replaced by `chat-theme.ts` +- `libs/chat/src/lib/chat.component.ts` β€” Legacy `cp-chat` component +- `libs/chat/src/lib/chat-input.component.ts` β€” Legacy input component +- `libs/chat/src/lib/chat-message.component.ts` β€” Legacy message component + +--- + +## Task 1: Theme System Consolidation + +**Fixes:** Blocker #3 (theme CSS triplicated 6x), Medium #11 (CSS var fallback inconsistency) + +**Files:** +- Create: `libs/chat/src/lib/styles/chat-theme.ts` +- Create: `libs/chat/src/lib/styles/chat-icons.ts` +- Create: `libs/chat/src/lib/styles/chat-markdown.ts` +- Delete: `libs/chat/src/lib/styles/chat-theme.css` + +- [ ] **Step 1: Create `chat-theme.ts` with shared CSS custom property definitions** + +```typescript +// libs/chat/src/lib/styles/chat-theme.ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +const DARK = ` + --chat-bg: #171717; + --chat-bg-alt: #222222; + --chat-bg-hover: #2a2a2a; + --chat-text: #e0e0e0; + --chat-text-muted: #777777; + --chat-text-placeholder: #666666; + --chat-border: #333333; + --chat-border-light: #2a2a2a; + --chat-user-bg: #2a2a2a; + --chat-user-text: #f5f5f5; + --chat-user-border: #333333; + --chat-avatar-bg: #333333; + --chat-avatar-text: #aaaaaa; + --chat-input-bg: #222222; + --chat-input-border: #333333; + --chat-input-focus-border: #555555; + --chat-send-bg: #444444; + --chat-send-text: #aaaaaa; + --chat-error-bg: #2d1515; + --chat-error-text: #f87171; + --chat-warning-bg: #2d2315; + --chat-warning-text: #fbbf24; + --chat-success: #4ade80; + --chat-radius-message: 20px; + --chat-radius-input: 24px; + --chat-radius-card: 12px; + --chat-radius-avatar: 8px; + --chat-max-width: 720px; + --chat-font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif; + --chat-font-size: 15px; + --chat-line-height: 1.6; +`; + +const LIGHT = ` + --chat-bg: #ffffff; + --chat-bg-alt: #f5f5f5; + --chat-bg-hover: #ebebeb; + --chat-text: #1a1a1a; + --chat-text-muted: #999999; + --chat-text-placeholder: #999999; + --chat-border: #e5e5e5; + --chat-border-light: #f0f0f0; + --chat-user-bg: #f0f0f0; + --chat-user-text: #1a1a1a; + --chat-user-border: transparent; + --chat-avatar-bg: #f0f0f0; + --chat-avatar-text: #666666; + --chat-input-bg: #f5f5f5; + --chat-input-border: #e5e5e5; + --chat-input-focus-border: #cccccc; + --chat-send-bg: #e5e5e5; + --chat-send-text: #999999; + --chat-error-bg: #fef2f2; + --chat-error-text: #dc2626; + --chat-warning-bg: #fffbeb; + --chat-warning-text: #d97706; + --chat-success: #16a34a; +`; + +/** + * Shared theme styles for chat composition components. + * Defines CSS custom properties on :host for dark/light mode. + * Import into any composition's `styles` array. + */ +export const CHAT_THEME_STYLES = ` + :host { + ${DARK} + font-family: var(--chat-font-family); + font-size: var(--chat-font-size); + line-height: var(--chat-line-height); + color: var(--chat-text); + background: var(--chat-bg); + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + } + @media (prefers-color-scheme: light) { + :host:not([data-chat-theme="dark"]) { ${LIGHT} } + } + :host([data-chat-theme="light"]) { ${LIGHT} } +`; +``` + +- [ ] **Step 2: Create `chat-icons.ts` with SVG icon constants** + +Replaces emoji characters (βš™ ⚠ πŸ€– β–² β–Ό βœ“) with inline SVG strings for consistent cross-platform rendering. + +```typescript +// libs/chat/src/lib/styles/chat-icons.ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +/** Chevron down (β–Ό replacement). 12x12, stroke-based. */ +export const ICON_CHEVRON_DOWN = ``; + +/** Chevron up (β–² replacement). 12x12, stroke-based. */ +export const ICON_CHEVRON_UP = ``; + +/** Gear icon (βš™ replacement). 14x14. */ +export const ICON_TOOL = ``; + +/** Warning triangle (⚠ replacement). 18x18. */ +export const ICON_WARNING = ``; + +/** Robot/agent icon (πŸ€– replacement). 14x14. */ +export const ICON_AGENT = ``; + +/** Check mark (βœ“ replacement). 12x12. */ +export const ICON_CHECK = ``; + +/** Send arrow (for chat input). 16x16. */ +export const ICON_SEND = ``; +``` + +- [ ] **Step 3: Create `chat-markdown.ts` with markdown rendering utility** + +```typescript +// libs/chat/src/lib/styles/chat-markdown.ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { SecurityContext } from '@angular/core'; +import type { DomSanitizer, SafeHtml } from '@angular/platform-browser'; + +let markedParse: ((src: string) => string) | null = null; +let markedLoaded = false; + +function loadMarked(): void { + if (markedLoaded) return; + markedLoaded = true; + try { + // Dynamic require β€” marked is an optional peer dep + const m = require('marked'); + markedParse = (src: string) => m.marked.parse(src, { async: false }) as string; + } catch { + markedParse = null; + } +} + +/** + * Renders markdown content to sanitized HTML. + * Falls back to plain text with newlineβ†’br conversion if `marked` is not installed. + */ +export function renderMarkdown(content: string, sanitizer: DomSanitizer): SafeHtml { + loadMarked(); + if (markedParse) { + const html = markedParse(content); + return sanitizer.bypassSecurityTrustHtml( + sanitizer.sanitize(SecurityContext.HTML, html) ?? '' + ); + } + // Fallback: escape HTML and convert newlines to
+ const escaped = content + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\n/g, '
'); + return sanitizer.bypassSecurityTrustHtml(escaped); +} + +/** + * CSS for styling rendered markdown HTML. + * Uses .chat-md class prefix to avoid global conflicts. + * Must be included in a component with ViewEncapsulation.None or via ::ng-deep. + */ +export const CHAT_MARKDOWN_STYLES = ` + .chat-md p { margin: 0 0 0.75em; } + .chat-md p:last-child { margin-bottom: 0; } + .chat-md code { + background: var(--chat-bg-alt); + padding: 2px 6px; + border-radius: 4px; + font-size: 0.875em; + font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; + } + .chat-md pre { + background: var(--chat-bg-alt); + padding: 12px 16px; + border-radius: var(--chat-radius-card); + overflow-x: auto; + margin: 0.75em 0; + } + .chat-md pre code { background: none; padding: 0; } + .chat-md ul, .chat-md ol { margin: 0.5em 0; padding-left: 1.5em; } + .chat-md li { margin: 0.25em 0; } + .chat-md a { color: var(--chat-text); text-decoration: underline; } + .chat-md strong { font-weight: 600; } + .chat-md blockquote { + border-left: 3px solid var(--chat-border); + padding-left: 12px; + margin: 0.75em 0; + color: var(--chat-text-muted); + } + .chat-md h1, .chat-md h2, .chat-md h3, .chat-md h4 { margin: 1em 0 0.5em; font-weight: 600; } + .chat-md h1 { font-size: 1.25em; } + .chat-md h2 { font-size: 1.125em; } + .chat-md h3 { font-size: 1em; } + .chat-md table { border-collapse: collapse; width: 100%; margin: 0.75em 0; } + .chat-md th, .chat-md td { border: 1px solid var(--chat-border); padding: 6px 12px; text-align: left; } + .chat-md th { background: var(--chat-bg-alt); font-weight: 600; font-size: 0.875em; } +`; +``` + +- [ ] **Step 4: Delete `chat-theme.css`** + +```bash +rm libs/chat/src/lib/styles/chat-theme.css +``` + +- [ ] **Step 5: Run tests to verify no regressions** + +```bash +npx nx test chat +``` + +Expected: All 112 tests still pass (no component imports chat-theme.css directly). + +- [ ] **Step 6: Commit** + +```bash +git add libs/chat/src/lib/styles/ +git commit -m "feat(chat): consolidate theme into shared TS module, add icons + markdown utils" +``` + +--- + +## Task 2: ChatComponent Overhaul + +**Fixes:** Blocker #4 (auto-scroll), High #9 (empty state), High #10 (responsive sidebar), Medium #14 (ARIA) + +**Files:** +- Modify: `libs/chat/src/lib/compositions/chat/chat.component.ts` +- Test: `libs/chat/src/lib/compositions/chat/chat.component.spec.ts` + +**Depends on:** Task 1 + +- [ ] **Step 1: Rewrite ChatComponent with Tailwind, auto-scroll, empty state, responsive sidebar, ARIA, and markdown** + +Replace the entire file: + +```typescript +// libs/chat/src/lib/compositions/chat/chat.component.ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + input, + output, + signal, + computed, + effect, + viewChild, + ElementRef, + ChangeDetectionStrategy, + inject, + ViewEncapsulation, +} from '@angular/core'; +import { DomSanitizer } from '@angular/platform-browser'; +import type { StreamResourceRef } from '@cacheplane/stream-resource'; +import { ChatMessagesComponent } from '../../primitives/chat-messages/chat-messages.component'; +import { MessageTemplateDirective } from '../../primitives/chat-messages/message-template.directive'; +import { ChatInputComponent } from '../../primitives/chat-input/chat-input.component'; +import { ChatTypingIndicatorComponent } from '../../primitives/chat-typing-indicator/chat-typing-indicator.component'; +import { ChatErrorComponent } from '../../primitives/chat-error/chat-error.component'; +import { ChatInterruptComponent } from '../../primitives/chat-interrupt/chat-interrupt.component'; +import { ChatThreadListComponent, Thread } from '../../primitives/chat-thread-list/chat-thread-list.component'; +import { messageContent } from '../shared/message-utils'; +import { CHAT_THEME_STYLES } from '../../styles/chat-theme'; +import { CHAT_MARKDOWN_STYLES, renderMarkdown } from '../../styles/chat-markdown'; + +@Component({ + selector: 'chat', + standalone: true, + imports: [ + ChatMessagesComponent, + MessageTemplateDirective, + ChatInputComponent, + ChatTypingIndicatorComponent, + ChatErrorComponent, + ChatInterruptComponent, + ChatThreadListComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + styles: [CHAT_THEME_STYLES, CHAT_MARKDOWN_STYLES], + template: ` +
+ + @if (threads().length > 0) { + + } + + +
+ +
+
+ @if (ref().messages().length === 0 && !ref().isLoading()) { + +
+
A
+

Send a message to start a conversation.

+
+ } + + + + +
+
{{ messageContent(message) }}
+
+
+ + + +
+
+
A
+ Assistant +
+
+
+
+ + + +
{{ messageContent(message) }}
+
+ + + +
+ + {{ messageContent(message) }} + +
+
+
+ + +
+
+ + + + +
+

Agent paused: {{ interrupt.value }}

+
+
+
+ + +
+ +
+ + +
+
+ +
+
+
+
+ `, +}) +export class ChatComponent { + private readonly sanitizer = inject(DomSanitizer); + + readonly ref = input.required>(); + readonly threads = input([]); + readonly activeThreadId = input(''); + readonly threadSelected = output(); + readonly sidebarOpen = signal(false); + + readonly messageContent = messageContent; + + private readonly scrollContainer = viewChild>('scrollContainer'); + + /** Track message count to trigger auto-scroll */ + private readonly messageCount = computed(() => this.ref().messages().length); + + constructor() { + // Auto-scroll to bottom when new messages arrive or loading state changes + effect(() => { + this.messageCount(); // track + this.ref().isLoading(); // track + const el = this.scrollContainer()?.nativeElement; + if (el) { + // Use setTimeout to run after render + setTimeout(() => el.scrollTop = el.scrollHeight, 0); + } + }); + } + + renderMd(content: string) { + return renderMarkdown(content, this.sanitizer); + } +} +``` + +- [ ] **Step 2: Run tests** + +```bash +npx nx test chat +``` + +Expected: All tests pass. The spec tests use `createMockStreamResourceRef()` which doesn't depend on template rendering. + +- [ ] **Step 3: Commit** + +```bash +git add libs/chat/src/lib/compositions/chat/chat.component.ts +git commit -m "feat(chat): convert ChatComponent to Tailwind, add auto-scroll + empty state + responsive sidebar" +``` + +--- + +## Task 3: ChatDebugComponent Overhaul + +**Fixes:** Blocker #4 (auto-scroll), High #7 (duplicated templates), and theme-awareness for all debug sub-components + +**Files:** +- Modify: `libs/chat/src/lib/compositions/chat-debug/chat-debug.component.ts` +- Modify: `libs/chat/src/lib/compositions/chat-debug/debug-timeline.component.ts` +- Modify: `libs/chat/src/lib/compositions/chat-debug/debug-detail.component.ts` +- Modify: `libs/chat/src/lib/compositions/chat-debug/debug-state-diff.component.ts` +- Modify: `libs/chat/src/lib/compositions/chat-debug/debug-state-inspector.component.ts` +- Modify: `libs/chat/src/lib/compositions/chat-debug/debug-checkpoint-card.component.ts` +- Modify: `libs/chat/src/lib/compositions/chat-debug/debug-controls.component.ts` +- Modify: `libs/chat/src/lib/compositions/chat-debug/debug-summary.component.ts` + +**Depends on:** Task 1 + +- [ ] **Step 1: Rewrite ChatDebugComponent with shared theme + Tailwind + auto-scroll + markdown** + +Same pattern as ChatComponent (Task 2) but with the debug panel. Key changes: +- Import `CHAT_THEME_STYLES` from shared module (eliminating the copy-pasted CSS vars) +- Import `CHAT_MARKDOWN_STYLES` and `renderMarkdown` +- Add `ViewEncapsulation.None` for markdown styles +- Add auto-scroll via `viewChild` + `effect()` +- Use Tailwind classes for all layout +- Use `[var(--chat-*)]` arbitrary values for theme colors +- Add `role="log"` and `aria-live="polite"` to messages area + +The message templates should be identical to ChatComponent's templates (same 4 ng-template blocks). This is intentional β€” compositions co-locate their templates (shadcn model). The template code is the same as Task 2 Step 1. + +- [ ] **Step 2: Convert debug-timeline.component.ts to use theme vars** + +Replace hardcoded Tailwind colors with theme-var-based arbitrary values: +- `bg-blue-500` β†’ `bg-[var(--chat-success)]` (selected indicator) +- `border-blue-500` β†’ `border-[var(--chat-success)]` +- `bg-white` β†’ `bg-[var(--chat-bg)]` +- `border-gray-300` β†’ `border-[var(--chat-border)]` +- `bg-gray-200` β†’ `bg-[var(--chat-border)]` (rail) +- Keep layout Tailwind classes (`relative`, `space-y-1`, `absolute`, etc.) + +- [ ] **Step 3: Convert debug-checkpoint-card.component.ts to use theme vars** + +Replace hardcoded colors: +- `border-blue-400 bg-blue-50` β†’ `border-[var(--chat-input-focus-border)] bg-[var(--chat-bg-hover)]` +- `border-gray-200 bg-white hover:bg-gray-50` β†’ `border-[var(--chat-border)] bg-[var(--chat-bg)] hover:bg-[var(--chat-bg-hover)]` +- `text-gray-700` β†’ `text-[var(--chat-text)]` +- `bg-gray-100 text-gray-500` β†’ `bg-[var(--chat-bg-alt)] text-[var(--chat-text-muted)]` + +- [ ] **Step 4: Convert debug-state-diff.component.ts to use theme vars** + +Replace hardcoded colors: +- `bg-green-50 text-green-700` β†’ `bg-[var(--chat-bg-alt)] text-[var(--chat-success)]` +- `bg-red-50 text-red-700` β†’ `bg-[var(--chat-error-bg)] text-[var(--chat-error-text)]` +- `bg-amber-50 text-amber-700` β†’ `bg-[var(--chat-warning-bg)] text-[var(--chat-warning-text)]` +- `text-gray-400` β†’ `text-[var(--chat-text-muted)]` +- `text-gray-500` β†’ `text-[var(--chat-text-muted)]` + +- [ ] **Step 5: Convert debug-detail.component.ts to use theme vars** + +Replace: +- `text-gray-500` β†’ `text-[var(--chat-text-muted)]` + +- [ ] **Step 6: Convert debug-state-inspector.component.ts to use theme vars** + +Replace: +- `text-gray-700` β†’ `text-[var(--chat-text)]` + +- [ ] **Step 7: Convert debug-controls.component.ts to use theme vars** + +Replace: +- `bg-gray-100 hover:bg-gray-200` β†’ `bg-[var(--chat-bg-alt)] hover:bg-[var(--chat-bg-hover)]` +- `text-gray-500` β†’ `text-[var(--chat-text-muted)]` + +- [ ] **Step 8: Convert debug-summary.component.ts to use theme vars** + +Replace: +- `text-gray-500` β†’ `text-[var(--chat-text-muted)]` + +- [ ] **Step 9: Run tests** + +```bash +npx nx test chat +``` + +Expected: All tests pass. + +- [ ] **Step 10: Commit** + +```bash +git add libs/chat/src/lib/compositions/chat-debug/ +git commit -m "feat(chat): convert ChatDebug + sub-components to theme-aware Tailwind" +``` + +--- + +## Task 4: Primitives Overhaul + +**Fixes:** Blocker #1 (ChatError Tailwind), High #5 (textarea auto-expand), High #8 (focused signal) + +**Files:** +- Modify: `libs/chat/src/lib/primitives/chat-input/chat-input.component.ts` +- Modify: `libs/chat/src/lib/primitives/chat-typing-indicator/chat-typing-indicator.component.ts` +- Modify: `libs/chat/src/lib/primitives/chat-error/chat-error.component.ts` + +**Depends on:** Task 1 + +- [ ] **Step 1: Rewrite ChatInputComponent with Tailwind, auto-expand textarea, and focused signal** + +```typescript +// libs/chat/src/lib/primitives/chat-input/chat-input.component.ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + computed, + input, + output, + signal, + ChangeDetectionStrategy, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import type { StreamResourceRef } from '@cacheplane/stream-resource'; +import { ICON_SEND } from '../../styles/chat-icons'; + +export function submitMessage( + ref: StreamResourceRef, + text: string, +): string | null { + const trimmed = text.trim(); + if (!trimmed) return null; + ref.submit({ messages: [{ role: 'human', content: trimmed }] }); + return trimmed; +} + +@Component({ + selector: 'chat-input', + standalone: true, + imports: [FormsModule], + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [` + textarea { + field-sizing: content; + } + `], + template: ` + + `, +}) +export class ChatInputComponent { + readonly ref = input.required>(); + readonly submitOnEnter = input(true); + readonly placeholder = input(''); + readonly submitted = output(); + readonly messageText = signal(''); + readonly isDisabled = computed(() => this.ref().isLoading()); + readonly focused = signal(false); + readonly sendIcon = ICON_SEND; + + onSubmit(): void { + const submitted = submitMessage(this.ref(), this.messageText()); + if (submitted !== null) { + this.submitted.emit(submitted); + this.messageText.set(''); + } + } + + onKeydown(event: KeyboardEvent): void { + if (this.submitOnEnter() && !event.shiftKey) { + event.preventDefault(); + this.onSubmit(); + } + } +} +``` + +Key changes: +- `focused` is now a `signal(false)` (fixes OnPush change detection) +- Textarea uses `field-sizing: content` CSS for auto-expand (modern browsers) +- All inline styles replaced with Tailwind classes + CSS var bindings +- ARIA labels added +- SVG icon replaces inline SVG string + +- [ ] **Step 2: Rewrite ChatTypingIndicatorComponent with Tailwind** + +```typescript +// libs/chat/src/lib/primitives/chat-typing-indicator/chat-typing-indicator.component.ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + computed, + input, + ChangeDetectionStrategy, +} from '@angular/core'; +import type { StreamResourceRef } from '@cacheplane/stream-resource'; + +export function isTyping(ref: StreamResourceRef): boolean { + return ref.isLoading(); +} + +@Component({ + selector: 'chat-typing-indicator', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [` + .chat-dot { + display: inline-block; + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--chat-text-muted); + animation: chat-dot-pulse 1.4s ease-in-out infinite; + } + .chat-dot:nth-child(2) { animation-delay: 0.2s; } + .chat-dot:nth-child(3) { animation-delay: 0.4s; } + @keyframes chat-dot-pulse { + 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); } + 40% { opacity: 1; transform: scale(1); } + } + `], + template: ` + @if (visible()) { +
+
+
A
+ Assistant +
+ + + +
+
+
+ } + `, +}) +export class ChatTypingIndicatorComponent { + readonly ref = input.required>(); + readonly visible = computed(() => this.ref().isLoading()); +} +``` + +- [ ] **Step 3: Rewrite ChatErrorComponent β€” remove Tailwind classes, use CSS vars** + +```typescript +// libs/chat/src/lib/primitives/chat-error/chat-error.component.ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { + Component, + computed, + input, + ChangeDetectionStrategy, +} from '@angular/core'; +import type { StreamResourceRef } from '@cacheplane/stream-resource'; + +export function extractErrorMessage(error: unknown): string | null { + if (!error) return null; + if (error instanceof Error) return error.message; + if (typeof error === 'string') return error; + return String(error); +} + +@Component({ + selector: 'chat-error', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (errorMessage(); as msg) { + + } + `, +}) +export class ChatErrorComponent { + readonly ref = input.required>(); + readonly errorMessage = computed(() => extractErrorMessage(this.ref().error())); +} +``` + +Note: `px-4 py-3 text-sm` are standard Tailwind layout classes that work with the consuming app's Tailwind build. The color/bg/radius use CSS vars via inline style. This is the correct hybrid approach. + +- [ ] **Step 4: Run tests** + +```bash +npx nx test chat +``` + +Expected: All tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add libs/chat/src/lib/primitives/ +git commit -m "feat(chat): convert primitives to Tailwind, add textarea auto-expand + focused signal" +``` + +--- + +## Task 5: Compositions Overhaul + +**Fixes:** Blocker #2 (TimelineSlider unstyled without Tailwind), Medium #12 (emoji icons) + +**Files:** +- Modify: `libs/chat/src/lib/compositions/chat-interrupt-panel/chat-interrupt-panel.component.ts` +- Modify: `libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.ts` +- Modify: `libs/chat/src/lib/compositions/chat-subagent-card/chat-subagent-card.component.ts` +- Modify: `libs/chat/src/lib/compositions/chat-timeline-slider/chat-timeline-slider.component.ts` + +**Depends on:** Task 1 + +- [ ] **Step 1: Rewrite ChatInterruptPanelComponent with Tailwind + SVG icon** + +Replace `⚠` emoji with `ICON_WARNING` from chat-icons.ts. Convert inline styles to Tailwind + CSS var bindings. Add ARIA roles. + +```typescript +// Key changes in template: +// - Replace ⚠ with: +// +// - Convert all style="" attributes to Tailwind classes +// - Add role="alert" to the container +// In class: +// readonly warningIcon = ICON_WARNING; +``` + +- [ ] **Step 2: Rewrite ChatToolCallCardComponent with Tailwind + SVG icons** + +Replace `βš™` with `ICON_TOOL`, `βœ“` with `ICON_CHECK`, `β–²`/`β–Ό` with `ICON_CHEVRON_UP`/`ICON_CHEVRON_DOWN`. Convert inline styles to Tailwind + CSS var bindings. + +```typescript +// Key changes: +// - Import ICON_TOOL, ICON_CHECK, ICON_CHEVRON_UP, ICON_CHEVRON_DOWN +// - Replace emoji spans with [innerHTML]="icon" spans +// - Convert all style="" to Tailwind classes +// - Add aria-expanded to button (already exists) +// - Add aria-label="Toggle tool call details" to button +``` + +- [ ] **Step 3: Rewrite ChatSubagentCardComponent with Tailwind + SVG icon** + +Replace `πŸ€–` with `ICON_AGENT`. Convert inline styles to Tailwind + CSS var bindings. + +- [ ] **Step 4: Rewrite ChatTimelineSliderComponent with theme vars** + +This component already uses Tailwind but with hardcoded colors. Convert to theme-var-based: +- `border-blue-300 bg-blue-50` β†’ `border-[var(--chat-input-focus-border)] bg-[var(--chat-bg-hover)]` +- `border-gray-200 bg-white hover:bg-gray-50` β†’ `border-[var(--chat-border)] bg-[var(--chat-bg)] hover:bg-[var(--chat-bg-hover)]` +- `bg-blue-600 text-white` β†’ `bg-[var(--chat-send-bg)] text-[var(--chat-send-text)]` (selected indicator) +- `bg-gray-200 text-gray-500` β†’ `bg-[var(--chat-bg-alt)] text-[var(--chat-text-muted)]` (unselected) +- `text-gray-500` β†’ `text-[var(--chat-text-muted)]` +- `text-gray-400` β†’ `text-[var(--chat-text-muted)]` +- `text-gray-700` β†’ `text-[var(--chat-text)]` +- `bg-blue-100 text-blue-700 hover:bg-blue-200` β†’ `bg-[var(--chat-bg-alt)] text-[var(--chat-text)] hover:bg-[var(--chat-bg-hover)]` +- `bg-purple-100 text-purple-700 hover:bg-purple-200` β†’ `bg-[var(--chat-bg-alt)] text-[var(--chat-text)] hover:bg-[var(--chat-bg-hover)]` + +- [ ] **Step 5: Run tests** + +```bash +npx nx test chat +``` + +Expected: All tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add libs/chat/src/lib/compositions/chat-interrupt-panel/ libs/chat/src/lib/compositions/chat-tool-call-card/ libs/chat/src/lib/compositions/chat-subagent-card/ libs/chat/src/lib/compositions/chat-timeline-slider/ +git commit -m "feat(chat): convert remaining compositions to Tailwind with SVG icons + theme vars" +``` + +--- + +## Task 6: API Cleanup + Build Verification + +**Fixes:** Medium #13 (selector prefix), Medium #15 (legacy export), Medium #16 (provideChat no-op), Low #19 (getMessageType fallthrough) + +**Files:** +- Modify: `libs/chat/src/public-api.ts` +- Modify: `libs/chat/src/lib/provide-chat.ts` +- Modify: `libs/chat/src/lib/chat.types.ts` +- Modify: `libs/chat/package.json` +- Delete: `libs/chat/src/lib/chat.component.ts` (legacy cp-chat) +- Delete: `libs/chat/src/lib/chat-input.component.ts` (legacy) +- Delete: `libs/chat/src/lib/chat-message.component.ts` (legacy) + +**Depends on:** Tasks 2–5 + +- [ ] **Step 1: Remove legacy component exports from public-api.ts** + +Remove this line from `libs/chat/src/public-api.ts`: +```typescript +// DELETE: export { ChatComponent as LegacyChatComponent } from './lib/chat.component'; +``` + +Add new exports: +```typescript +export { CHAT_THEME_STYLES } from './lib/styles/chat-theme'; +export { CHAT_MARKDOWN_STYLES, renderMarkdown } from './lib/styles/chat-markdown'; +export { + ICON_CHEVRON_DOWN, ICON_CHEVRON_UP, ICON_TOOL, + ICON_WARNING, ICON_AGENT, ICON_CHECK, ICON_SEND, +} from './lib/styles/chat-icons'; +``` + +- [ ] **Step 2: Delete legacy component files** + +```bash +rm libs/chat/src/lib/chat.component.ts +rm libs/chat/src/lib/chat-input.component.ts +rm libs/chat/src/lib/chat-message.component.ts +``` + +- [ ] **Step 3: Update ChatConfig in provide-chat.ts** + +```typescript +// libs/chat/src/lib/provide-chat.ts +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +import { InjectionToken, makeEnvironmentProviders } from '@angular/core'; +import type { AngularRegistry } from '@cacheplane/render'; + +export interface ChatConfig { + /** Default render registry for generative UI components. */ + renderRegistry?: AngularRegistry; + /** Override the default AI avatar label (default: "A"). */ + avatarLabel?: string; + /** Override the default assistant display name (default: "Assistant"). */ + assistantName?: string; +} + +export const CHAT_CONFIG = new InjectionToken('CHAT_CONFIG'); + +export function provideChat(config: ChatConfig) { + return makeEnvironmentProviders([ + { provide: CHAT_CONFIG, useValue: config }, + ]); +} +``` + +- [ ] **Step 4: Add `marked` as optional peer dep in package.json** + +Edit `libs/chat/package.json` β€” add to peerDependencies and peerDependenciesMeta: + +```json +{ + "peerDependencies": { + "marked": "^15.0.0 || ^16.0.0" + }, + "peerDependenciesMeta": { + "marked": { "optional": true } + } +} +``` + +- [ ] **Step 5: Update cockpit example styles.css to add @source for chat library** + +In each cockpit Angular example's `src/styles.css`, add after the `@import "tailwindcss"` line: + +```css +@source "../../../../../libs/chat/src/"; +``` + +This tells Tailwind v4 to scan the chat library source for utility classes. Run this for all 14 examples: + +```bash +for dir in cockpit/langgraph/*/angular/src cockpit/deep-agents/*/angular/src; do + if [ -f "$dir/styles.css" ]; then + # Add @source line after @import "tailwindcss" if not already present + grep -q '@source.*libs/chat' "$dir/styles.css" || \ + sed -i '' 's|@import "tailwindcss";|@import "tailwindcss";\n@source "../../../../../libs/chat/src/";|' "$dir/styles.css" + fi +done +``` + +- [ ] **Step 6: Run full test suite** + +```bash +npx nx test chat +npx nx test render +npx nx test stream-resource +``` + +Expected: All tests pass across all three libraries. + +- [ ] **Step 7: Build the library** + +```bash +npx nx build chat +``` + +Expected: Build succeeds. Check `dist/libs/chat/` for compiled output. Verify the package.json in the dist includes `marked` in peerDependencies. + +- [ ] **Step 8: Build all cockpit examples** + +```bash +npx nx run-many -t build --projects='cockpit-*-angular' +``` + +Expected: All 14 examples build successfully. + +- [ ] **Step 9: Commit** + +```bash +git add -A +git commit -m "feat(chat): clean up public API, add marked peer dep, verify build" +``` + +--- + +## Summary of Issues Fixed + +| # | Severity | Issue | Fixed In | +|---|----------|-------|----------| +| 1 | BLOCKER | ChatTimelineSlider unstyled without Tailwind | Task 5 Step 4 | +| 2 | BLOCKER | ChatError mixed Tailwind/inline | Task 4 Step 3 | +| 3 | BLOCKER | Theme CSS triplicated 6x | Task 1 | +| 4 | BLOCKER | No auto-scroll | Tasks 2, 3 | +| 5 | HIGH | No textarea auto-expand | Task 4 Step 1 | +| 6 | HIGH | No markdown rendering | Tasks 1, 2, 3 | +| 7 | HIGH | Message templates duplicated | Accepted (shadcn model) | +| 8 | HIGH | focused plain boolean with OnPush | Task 4 Step 1 | +| 9 | HIGH | No empty/welcome state | Task 2 | +| 10 | HIGH | Thread sidebar not responsive | Task 2 | +| 11 | MEDIUM | CSS var fallback inconsistency | Task 1 | +| 12 | MEDIUM | Emoji icons | Tasks 4, 5 | +| 13 | MEDIUM | No selector prefix | Accepted (design decision) | +| 14 | MEDIUM | Missing ARIA | Tasks 2, 3, 4 | +| 15 | MEDIUM | Legacy export | Task 6 | +| 16 | MEDIUM | provideChat no-op | Task 6 | +| 17 | LOW | Missing debug components | Out of scope (Tier 2) | +| 18 | LOW | No keyboard navigation | Out of scope (v2) | +| 19 | LOW | getMessageType fallthrough | Accepted (safe default) | + +**Notes on accepted items:** +- **#7 (template duplication):** The shadcn model intentionally co-locates templates. Extracting them into a shared component would add coupling and make copy-paste customization harder. This is a design choice, not a bug. +- **#13 (selector prefix):** `chat` was an explicit design decision. Changing it is a breaking change. +- **#19 (getMessageType fallthrough):** Defaulting unknown types to 'ai' rendering is the safest UX choice β€” an unrecognized message type still renders rather than disappearing. diff --git a/libs/chat/package.json b/libs/chat/package.json index ad5d27e26..3e6746359 100644 --- a/libs/chat/package.json +++ b/libs/chat/package.json @@ -9,7 +9,13 @@ "@cacheplane/stream-resource": "^0.0.1", "@json-render/core": "^0.16.0", "@langchain/core": "^1.1.33", - "@langchain/langgraph-sdk": "^1.7.4" + "@langchain/langgraph-sdk": "^1.7.4", + "marked": "^15.0.0 || ^16.0.0" + }, + "peerDependenciesMeta": { + "marked": { + "optional": true + } }, "license": "PolyForm-Noncommercial-1.0.0", "sideEffects": false diff --git a/libs/chat/src/index.ts b/libs/chat/src/index.ts index 9f243f5ce..7e1a213e3 100644 --- a/libs/chat/src/index.ts +++ b/libs/chat/src/index.ts @@ -1,4 +1 @@ -export { ChatComponent } from './lib/chat.component'; -export { ChatMessageComponent } from './lib/chat-message.component'; -export { ChatInputComponent } from './lib/chat-input.component'; -export type { ChatMessage } from './lib/chat.types'; +export * from './public-api'; diff --git a/libs/chat/src/lib/chat-input.component.ts b/libs/chat/src/lib/chat-input.component.ts deleted file mode 100644 index c66a83a54..000000000 --- a/libs/chat/src/lib/chat-input.component.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Component, EventEmitter, Input, Output } from '@angular/core'; -import { FormsModule } from '@angular/forms'; - -/** - * Chat input bar with text field and send button. - * Emits sendMessage when user submits. - */ -@Component({ - selector: 'cp-chat-input', - standalone: true, - imports: [FormsModule], - template: ` -
- - -
- `, - styles: [` - .cp-input { display: flex; gap: 0.5rem; } - .cp-input input { flex: 1; padding: 0.75rem 1rem; border: 1px solid rgba(0,64,144,0.15); border-radius: 0.5rem; background: rgba(255,255,255,0.7); color: #1a1a2e; font: inherit; font-size: 0.9rem; } - .cp-input input:focus { outline: none; border-color: rgba(0,64,144,0.3); } - .cp-input button { padding: 0.75rem 1.5rem; border: none; border-radius: 0.5rem; background: #004090; color: #fff; font: inherit; font-size: 0.85rem; cursor: pointer; transition: opacity 0.15s; } - .cp-input button:disabled { opacity: 0.5; cursor: not-allowed; } - .cp-input button:hover:not(:disabled) { opacity: 0.9; } - `], -}) -export class ChatInputComponent { - @Input() placeholder = 'Type a message...'; - @Input() disabled = false; - @Output() sendMessage = new EventEmitter(); - - text = ''; - - onSend(): void { - const msg = this.text.trim(); - if (!msg || this.disabled) return; - this.sendMessage.emit(msg); - this.text = ''; - } -} diff --git a/libs/chat/src/lib/chat-message.component.ts b/libs/chat/src/lib/chat-message.component.ts deleted file mode 100644 index da02f6b17..000000000 --- a/libs/chat/src/lib/chat-message.component.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Component, Input } from '@angular/core'; - -/** - * Renders a single chat message bubble. - * Human messages align right with accent tint. - * AI messages align left with subtle background. - */ -@Component({ - selector: 'cp-chat-message', - standalone: true, - template: ` -
- {{ type }} -

{{ content }}

-
- `, - styles: [` - .cp-message { padding: 0.75rem 1rem; border-radius: 0.5rem; max-width: 80%; } - .cp-message--human { background: rgba(0, 64, 144, 0.08); align-self: flex-end; } - .cp-message--ai { background: rgba(0, 0, 0, 0.03); align-self: flex-start; } - .cp-message__role { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.4; display: block; margin-bottom: 2px; } - .cp-message__content { margin: 0; white-space: pre-wrap; font-size: 0.9rem; line-height: 1.6; } - `], -}) -export class ChatMessageComponent { - @Input({ required: true }) type!: 'human' | 'ai'; - @Input({ required: true }) content!: string; -} diff --git a/libs/chat/src/lib/chat.component.ts b/libs/chat/src/lib/chat.component.ts deleted file mode 100644 index 48bbcd054..000000000 --- a/libs/chat/src/lib/chat.component.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Component, Input, Output, EventEmitter, ContentChild, TemplateRef } from '@angular/core'; -import { NgTemplateOutlet } from '@angular/common'; -import { ChatMessageComponent } from './chat-message.component'; -import { ChatInputComponent } from './chat-input.component'; - -/** - * Headful chat component for stream-resource demos. - * - * Renders a message list, input bar, and optional sidebar via content projection. - * Used by all LangGraph cockpit examples. - * - * @example - * ```html - * - * - *

Custom sidebar content

- *
- *
- * ``` - */ -@Component({ - selector: 'cp-chat', - standalone: true, - imports: [ChatMessageComponent, ChatInputComponent, NgTemplateOutlet], - template: ` -
-
-
- @for (msg of messages; track $index) { - - } - @empty { -

Send a message to start.

- } -
- @if (error) { -

{{ error }}

- } - -
- @if (sidebarTemplate) { - - } -
- `, - styles: [` - :host { display: block; height: 100%; } - .cp-chat { display: grid; grid-template-columns: 1fr; height: 100%; } - .cp-chat--with-sidebar { grid-template-columns: 1fr 260px; } - .cp-chat__main { display: flex; flex-direction: column; gap: 0.75rem; max-width: 640px; width: 100%; margin: 0 auto; padding: 1rem; height: 100%; min-height: 0; } - .cp-chat__messages { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 0.75rem; } - .cp-chat__empty { color: #8b8fa3; font-size: 0.85rem; text-align: center; margin-top: 2rem; } - .cp-chat__error { color: #ef4444; font-size: 0.85rem; padding: 0.5rem; background: rgba(239,68,68,0.06); border-radius: 0.25rem; } - .cp-chat__sidebar { padding: 1rem; border-left: 1px solid rgba(0,64,144,0.08); overflow-y: auto; font-size: 0.85rem; } - @media (max-width: 768px) { - .cp-chat--with-sidebar { grid-template-columns: 1fr; } - .cp-chat__sidebar { border-left: none; border-top: 1px solid rgba(0,64,144,0.08); max-height: 200px; } - } - `], -}) -export class ChatComponent { - @Input() messages: Array<{ type: string; content: string }> = []; - @Input() isLoading = false; - @Input() error: unknown = null; - @Output() sendMessage = new EventEmitter(); - @ContentChild('sidebar') sidebarTemplate?: TemplateRef; -} diff --git a/libs/chat/src/lib/compositions/chat-debug/chat-debug.component.ts b/libs/chat/src/lib/compositions/chat-debug/chat-debug.component.ts index df44d5719..11a126a27 100644 --- a/libs/chat/src/lib/compositions/chat-debug/chat-debug.component.ts +++ b/libs/chat/src/lib/compositions/chat-debug/chat-debug.component.ts @@ -2,10 +2,16 @@ import { Component, computed, + effect, input, + inject, signal, + viewChild, + ElementRef, ChangeDetectionStrategy, + ViewEncapsulation, } from '@angular/core'; +import { DomSanitizer } from '@angular/platform-browser'; import type { StreamResourceRef } from '@cacheplane/stream-resource'; import { ChatMessagesComponent } from '../../primitives/chat-messages/chat-messages.component'; import { MessageTemplateDirective } from '../../primitives/chat-messages/message-template.directive'; @@ -19,6 +25,8 @@ import { DebugSummaryComponent } from './debug-summary.component'; import type { DebugCheckpoint } from './debug-checkpoint-card.component'; import { toDebugCheckpoint, extractStateValues } from './debug-utils'; import { messageContent } from '../shared/message-utils'; +import { CHAT_THEME_STYLES } from '../../styles/chat-theme'; +import { CHAT_MARKDOWN_STYLES, renderMarkdown } from '../../styles/chat-markdown'; @Component({ selector: 'chat-debug', @@ -35,122 +43,61 @@ import { messageContent } from '../shared/message-utils'; DebugSummaryComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, - styles: [ - `:host { - --chat-bg: #171717; --chat-bg-alt: #222222; --chat-bg-hover: #2a2a2a; - --chat-text: #e0e0e0; --chat-text-muted: #777777; --chat-text-placeholder: #666666; - --chat-border: #333333; --chat-border-light: #2a2a2a; - --chat-user-bg: #2a2a2a; --chat-user-text: #f5f5f5; --chat-user-border: #333333; - --chat-avatar-bg: #333333; --chat-avatar-text: #aaaaaa; - --chat-input-bg: #222222; --chat-input-border: #333333; --chat-input-focus-border: #555555; - --chat-send-bg: #444444; --chat-send-text: #aaaaaa; - --chat-error-bg: #2d1515; --chat-error-text: #f87171; - --chat-warning-bg: #2d2315; --chat-warning-text: #fbbf24; --chat-success: #4ade80; - --chat-radius-message: 20px; --chat-radius-input: 24px; --chat-radius-card: 12px; - --chat-radius-avatar: 8px; --chat-max-width: 720px; - --chat-font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif; - --chat-font-size: 15px; --chat-line-height: 1.6; - font-family: var(--chat-font-family); font-size: var(--chat-font-size); - line-height: var(--chat-line-height); color: var(--chat-text); background: var(--chat-bg); - display: flex; flex-direction: column; height: 100%; overflow: hidden; - } - @media (prefers-color-scheme: light) { - :host:not([data-chat-theme="dark"]) { - --chat-bg: #ffffff; --chat-bg-alt: #f5f5f5; --chat-bg-hover: #ebebeb; - --chat-text: #1a1a1a; --chat-text-muted: #999999; --chat-text-placeholder: #999999; - --chat-border: #e5e5e5; --chat-border-light: #f0f0f0; - --chat-user-bg: #f0f0f0; --chat-user-text: #1a1a1a; --chat-user-border: transparent; - --chat-avatar-bg: #f0f0f0; --chat-avatar-text: #666666; - --chat-input-bg: #f5f5f5; --chat-input-border: #e5e5e5; --chat-input-focus-border: #cccccc; - --chat-send-bg: #e5e5e5; --chat-send-text: #999999; - --chat-error-bg: #fef2f2; --chat-error-text: #dc2626; - --chat-warning-bg: #fffbeb; --chat-warning-text: #d97706; --chat-success: #16a34a; - } - } - :host([data-chat-theme="light"]) { - --chat-bg: #ffffff; --chat-bg-alt: #f5f5f5; --chat-bg-hover: #ebebeb; - --chat-text: #1a1a1a; --chat-text-muted: #999999; --chat-text-placeholder: #999999; - --chat-border: #e5e5e5; --chat-border-light: #f0f0f0; - --chat-user-bg: #f0f0f0; --chat-user-text: #1a1a1a; --chat-user-border: transparent; - --chat-avatar-bg: #f0f0f0; --chat-avatar-text: #666666; - --chat-input-bg: #f5f5f5; --chat-input-border: #e5e5e5; --chat-input-focus-border: #cccccc; - --chat-send-bg: #e5e5e5; --chat-send-text: #999999; - --chat-error-bg: #fef2f2; --chat-error-text: #dc2626; - --chat-warning-bg: #fffbeb; --chat-warning-text: #d97706; --chat-success: #16a34a; - }`, - ], + encapsulation: ViewEncapsulation.None, + styles: [CHAT_THEME_STYLES, CHAT_MARKDOWN_STYLES], template: ` -
+
-
-
-
+
+
+
-
-
{{ messageContent(message) }}
+
+
{{ messageContent(message) }}
- + -
-
-
A
- Assistant +
+
+
A
+ Assistant
-
{{ messageContent(message) }}
+
-
{{ messageContent(message) }}
+
{{ messageContent(message) }}
-
- +
+ {{ messageContent(message) }}
@@ -164,8 +111,8 @@ import { messageContent } from '../shared/message-utils'; -
-
+
+
@if (!debugOpen()) {
-
+
-
+
-
+
@if (selectedCheckpointIndex() >= 0) { -
+
>(); readonly debugOpen = signal(true); @@ -265,6 +219,27 @@ export class ChatDebugComponent { // Message templates are intentionally co-located (shadcn copy-paste model) readonly messageContent = messageContent; + private readonly scrollContainer = viewChild>('scrollContainer'); + + /** Track message count to trigger auto-scroll */ + private readonly messageCount = computed(() => this.ref().messages().length); + + constructor() { + // Auto-scroll to bottom when new messages arrive or loading state changes + effect(() => { + this.messageCount(); // track + this.ref().isLoading(); // track + const el = this.scrollContainer()?.nativeElement; + if (el) { + setTimeout(() => el.scrollTop = el.scrollHeight, 0); + } + }); + } + + renderMd(content: string) { + return renderMarkdown(content, this.sanitizer); + } + stepForward(): void { const idx = this.selectedCheckpointIndex(); if (idx < this.checkpoints().length - 1) { diff --git a/libs/chat/src/lib/compositions/chat-debug/debug-checkpoint-card.component.ts b/libs/chat/src/lib/compositions/chat-debug/debug-checkpoint-card.component.ts index 8e142e538..1d341b71c 100644 --- a/libs/chat/src/lib/compositions/chat-debug/debug-checkpoint-card.component.ts +++ b/libs/chat/src/lib/compositions/chat-debug/debug-checkpoint-card.component.ts @@ -20,20 +20,20 @@ export interface DebugCheckpoint { template: `
diff --git a/libs/chat/src/lib/compositions/chat-debug/debug-state-diff.component.ts b/libs/chat/src/lib/compositions/chat-debug/debug-state-diff.component.ts index 59a48cf35..164f48771 100644 --- a/libs/chat/src/lib/compositions/chat-debug/debug-state-diff.component.ts +++ b/libs/chat/src/lib/compositions/chat-debug/debug-state-diff.component.ts @@ -16,7 +16,7 @@ import type { DiffEntry } from './state-diff'; changeDetection: ChangeDetectionStrategy.OnPush, template: ` @if (diffEntries().length === 0) { -

No changes

+

No changes

} @else {
@for (entry of diffEntries(); track entry.path) { @@ -26,7 +26,7 @@ import type { DiffEntry } from './state-diff'; > {{ prefix(entry.type) }} {{ entry.path }} @if (entry.type === 'changed') { - {{ entry.before | json }} → {{ entry.after | json }} + {{ entry.before | json }} → {{ entry.after | json }} } @else if (entry.type === 'added') { {{ entry.after | json }} } @else { @@ -56,9 +56,9 @@ export class DebugStateDiffComponent { colorClass(type: DiffEntry['type']): string { switch (type) { - case 'added': return 'bg-green-50 text-green-700'; - case 'removed': return 'bg-red-50 text-red-700'; - case 'changed': return 'bg-amber-50 text-amber-700'; + case 'added': return 'bg-[var(--chat-bg-alt)] text-[var(--chat-success)]'; + case 'removed': return 'bg-[var(--chat-error-bg)] text-[var(--chat-error-text)]'; + case 'changed': return 'bg-[var(--chat-warning-bg)] text-[var(--chat-warning-text)]'; } } } diff --git a/libs/chat/src/lib/compositions/chat-debug/debug-state-inspector.component.ts b/libs/chat/src/lib/compositions/chat-debug/debug-state-inspector.component.ts index 71000e162..0e1fd11bc 100644 --- a/libs/chat/src/lib/compositions/chat-debug/debug-state-inspector.component.ts +++ b/libs/chat/src/lib/compositions/chat-debug/debug-state-inspector.component.ts @@ -13,7 +13,7 @@ import { JsonPipe } from '@angular/common'; changeDetection: ChangeDetectionStrategy.OnPush, template: `
-
{{ state() | json }}
+
{{ state() | json }}
`, }) diff --git a/libs/chat/src/lib/compositions/chat-debug/debug-summary.component.ts b/libs/chat/src/lib/compositions/chat-debug/debug-summary.component.ts index 37b0eaf2c..16ab38ae5 100644 --- a/libs/chat/src/lib/compositions/chat-debug/debug-summary.component.ts +++ b/libs/chat/src/lib/compositions/chat-debug/debug-summary.component.ts @@ -13,7 +13,7 @@ import type { DebugCheckpoint } from './debug-checkpoint-card.component'; standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, template: ` -
+
{{ checkpoints().length }} step(s) {{ totalDuration() }}ms total
diff --git a/libs/chat/src/lib/compositions/chat-debug/debug-timeline.component.ts b/libs/chat/src/lib/compositions/chat-debug/debug-timeline.component.ts index 3f33dffe8..bdc2dda7a 100644 --- a/libs/chat/src/lib/compositions/chat-debug/debug-timeline.component.ts +++ b/libs/chat/src/lib/compositions/chat-debug/debug-timeline.component.ts @@ -16,14 +16,14 @@ import type { DebugCheckpoint } from './debug-checkpoint-card.component'; template: `
-
+
@for (cp of checkpoints(); track $index; let i = $index) {
+