diff --git a/docs/superpowers/context/2026-05-02-pretable-partial-json-migration.md b/docs/superpowers/context/2026-05-02-pretable-partial-json-migration.md new file mode 100644 index 000000000..e54949948 --- /dev/null +++ b/docs/superpowers/context/2026-05-02-pretable-partial-json-migration.md @@ -0,0 +1,174 @@ +# Migrate pretable to consume @cacheplane/partial-json + +**Use as a self-contained prompt for a fresh session.** Hand this file to a new agent after Phase 1 of the chat pipeline redesign has shipped (i.e., `@cacheplane/partial-json@1.0.0` is on npm). + +--- + +## Context + +Two projects historically maintained their own partial-JSON streaming parsers: + +- `~/repos/pretable/packages/json-stream/` — strict tokenizer with explicit state machine, identity preservation, `finish()` semantics. Purpose-built for tabular streaming. +- `~/repos/angular-agent-framework/libs/partial-json/` — published as `@ngaf/partial-json@0.0.2`. Lighter tokenizer, plus event API, `getByPath` (RFC 6901), and `materialize()` with WeakMap structural sharing. + +Phase 1 of the chat pipeline redesign extracted a unified parser to `@cacheplane/partial-json@1.0.0` (new repo: `github.com/cacheplane/partial-json`). The new package combines: + +- pretable's tokenizer + identity preservation + `finish()` +- @ngaf/partial-json's event API + `getByPath` + `materialize` + +Public API is documented in the new repo's README. Both pull-style (`create / push / finish / resolve`) and push-style (`createPartialJsonParser` with events) are supported. Internally they share the same node graph, so consumers can mix. + +The angular-agent-framework monorepo has already migrated its consumers (`@ngaf/chat`, `@ngaf/a2ui`, `@ngaf/render`). `@ngaf/partial-json` is frozen at 0.0.2 with a deprecation notice. + +This task migrates pretable to consume `@cacheplane/partial-json` and deletes its local `packages/json-stream/` directory. + +## Goals + +- Pretable's `packages/stream-adapter`, `packages/grid-core`, and any other internal consumers import from `@cacheplane/partial-json` instead of `./packages/json-stream`. +- Pretable's `packages/json-stream/` directory is deleted. +- All existing tests continue to pass without semantic changes. +- pretable's published packages (if any) bump appropriately. + +## Non-goals + +- API behavior changes in pretable's public surface. The migration must be transparent to pretable's downstream users. +- Adding new features to the parser. Phase 1 already merged the feature sets. +- Touching `~/repos/angular-agent-framework`. Its consumers were migrated in Phase 1. + +## Pre-flight checks + +1. Confirm `@cacheplane/partial-json@1.0.0` is published: + ```bash + npm view @cacheplane/partial-json + ``` +2. Read the new package's README to understand its public API. +3. Read pretable's current parser entry point: `~/repos/pretable/packages/json-stream/src/index.ts`. Note exported names. +4. Find every consumer of the local parser: + ```bash + cd ~/repos/pretable + grep -rn "from ['\"]\.\./json-stream" packages + grep -rn "from ['\"]\.\./\.\./json-stream" packages + grep -rn "from ['\"]@pretable/json-stream" packages apps + ``` + + Expected hits (from the original audit): + - `packages/stream-adapter/src/parse-partial-stream.ts` + - `packages/stream-adapter/src/connect-partial-stream.ts` + - `apps/streaming-demo/src/replay-engine.ts` + - any tests under `packages/json-stream/src/__tests__/` + +## Migration steps + +### 1. Add the dependency + +In each pretable package that consumes the parser, add `@cacheplane/partial-json` to `dependencies` (or `peerDependencies` for libraries): + +```json +"dependencies": { + "@cacheplane/partial-json": "^1.0.0" +} +``` + +### 2. Rewrite imports + +For every file found in pre-flight step 4, replace the local-parser import with the npm package. Two cases: + +**Pull-style imports** (most common in pretable): + +```ts +// before +import { create, push, finish, resolve, type StreamState } from '../json-stream/src'; + +// after +import { create, push, finish, resolve, type StreamState } from '@cacheplane/partial-json'; +``` + +**AST node type imports**: + +```ts +// before +import type { ArrayNode, ObjectNode, StringNode } from '../json-stream/src/types'; + +// after +import type { ArrayNode, ObjectNode, StringNode } from '@cacheplane/partial-json'; +``` + +The new package re-exports all public types from its top-level index. + +### 3. Verify behavior parity + +Run pretable's existing test suite. Expected: no regressions. The new package's tokenizer is a superset of pretable's (same strict validation, plus additional partial-keyword handling). + +```bash +cd ~/repos/pretable +pnpm test # or whatever the project's test runner is +``` + +If any test fails, read the failure carefully. Likely causes: + +- A test was relying on a quirk of the old parser that the new one fixes (e.g., the partial-keyword bug in @ngaf was fixed; pretable should not have been affected, but verify). +- An API name changed. Cross-reference with the new package's exports. + +### 4. Delete `packages/json-stream/` + +Once tests pass: + +```bash +git rm -r packages/json-stream +``` + +Update pretable's workspace configuration (root `package.json`, `pnpm-workspace.yaml`, or equivalent) to remove the `json-stream` workspace package entry. + +### 5. Update top-level docs + +Pretable's README (or architecture docs) likely reference `packages/json-stream`. Update to reference `@cacheplane/partial-json`. + +### 6. Verify the streaming demo + +```bash +cd apps/streaming-demo +pnpm dev +``` + +Manually exercise the streaming demo end-to-end. Confirm: + +- Parser handles real LLM output without errors +- Identity preservation still works (rows that haven't changed don't trigger re-renders) +- `finish()` semantics close open containers correctly at stream end + +### 7. Commit and PR + +Single commit, descriptive message. Title example: + +> chore(json-stream): migrate to @cacheplane/partial-json + +PR body should include: + +- Why the migration (single source of truth shared with another consumer; shared improvements) +- What changed (imports rewritten, local package deleted) +- What didn't change (public API, behavior, test outcomes) +- Test plan (existing test suite + manual streaming demo verification) + +Do NOT reference the other consumer project by name in commits, PRs, or docs unless explicitly asked. Keep the migration self-contained to pretable. + +## Edge cases to watch + +- **Test files referencing internal helpers**: If pretable tests imported non-public helpers (e.g., `internals.ts`), those won't be exported from the npm package. Either rewrite the tests against the public API, or delete them as redundant (the npm package has its own tests). +- **`finish()` ordering**: Pretable's `parsePartialStream` calls `finish(state)` at end of stream. Confirm this works identically in the new package. Should be a no-op since the algorithm is the same. +- **Error message format**: If pretable surfaces parser errors to its UI, confirm the error message strings haven't changed in ways that break user-visible text. +- **Performance**: Pretable batches updates on RAF and is sensitive to per-token allocation. Run a perf comparison on a 1000-row stream before and after. + +## Rollback + +If migration breaks pretable badly, revert by restoring `packages/json-stream/` from git history and reverting the imports. The new package is additive; nothing pretable does today is incompatible with keeping its local parser. + +## When done + +Confirm in the chat: + +- Imports updated, local package deleted +- All tests passing +- Streaming demo verified +- PR opened (or merged, depending on the user's workflow) + +Report any unexpected findings (especially around behavior parity or perf differences). diff --git a/docs/superpowers/plans/2026-05-02-chat-pipeline-redesign.md b/docs/superpowers/plans/2026-05-02-chat-pipeline-redesign.md new file mode 100644 index 000000000..433233e40 --- /dev/null +++ b/docs/superpowers/plans/2026-05-02-chat-pipeline-redesign.md @@ -0,0 +1,2328 @@ +# Chat Pipeline Redesign 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:** Extract a shared partial-JSON streaming parser to `@cacheplane/partial-json` (Phase 1), rewrite `@ngaf/chat`'s streaming markdown renderer as a RAF-batched full-reparse component (Phase 2), and add a dedicated welcome-screen primitive distinct from the conversation layout (Phase 3). + +**Architecture:** Phase 1 lands first as a standalone package consumed by both `@ngaf/chat` and a separate downstream tabular project (handled separately). Phase 2 replaces the bespoke append-only DOM renderer with a simpler RAF-coalesced full-reparse component, fixing a class of negative-delta freeze bugs and adding a localStorage-gated trace harness for diagnostics. Phase 3 introduces `` as the empty-state owner with a centered greeting, beacon dot animation, optional vertical suggestion rows, and slot-projected input. + +**Tech Stack:** TypeScript, Vitest (Phase 1 + Phase 2 tests), Angular 21 standalone components, marked (markdown), Nx monorepo build, npm trusted publishing via OIDC. + +**Constraint:** No commit, comment, doc, or PR may reference any chat-UI library this work was inspired by. Keep all language native to this codebase. + +**Worktree (Phases 2 & 3):** `/Users/blove/repos/angular-agent-framework/.claude/worktrees/dazzling-dewdney-887eac` + +**Phase 1 working repo:** `git@github.com:cacheplane/partial-json.git` (already created, empty) + +**Spec:** `docs/superpowers/specs/2026-05-02-chat-pipeline-redesign-design.md` + +--- + +## File Structure + +### Phase 1 (new repo: `cacheplane/partial-json`) + +| Path | Purpose | +|---|---| +| `package.json` | Package metadata, npm publish config, scripts | +| `tsconfig.json`, `tsconfig.build.json` | TS compile config | +| `vitest.config.ts` | Test runner config | +| `tsup.config.ts` | ESM + CJS dual build | +| `src/index.ts` | Public API surface (re-exports) | +| `src/types.ts` | All public + internal types | +| `src/guards.ts` | `isArrayNode`, `isObjectNode`, `isComplete`, etc. | +| `src/internals.ts` | Identity preservation, node mutation helpers | +| `src/handlers.ts` | Per-mode tokenizer state handlers | +| `src/create.ts` | `create()` factory | +| `src/push.ts` | `push()` driver | +| `src/finish.ts` | `finish()` end-of-stream handling | +| `src/resolve.ts` | `resolve()` materialize for pull-style | +| `src/parser.ts` | `createPartialJsonParser` push-style API + events + getByPath | +| `src/materialize.ts` | `materialize()` with WeakMap structural sharing | +| `src/__tests__/*.test.ts` | Unioned + new tests | +| `.github/workflows/ci.yml` | Lint + test + build | +| `.github/workflows/publish.yml` | Tag-triggered publish via OIDC | +| `README.md` | Public docs | +| `LICENSE` | MIT | + +### Phase 2 (worktree) + +| Path | Action | +|---|---| +| `libs/chat/src/lib/streaming/streaming-markdown.ts` | DELETE | +| `libs/chat/src/lib/streaming/streaming-markdown.spec.ts` | DELETE | +| `libs/chat/src/lib/streaming/streaming-markdown.component.ts` | REWRITE | +| `libs/chat/src/lib/streaming/streaming-markdown.component.spec.ts` | CREATE | +| `libs/chat/src/lib/streaming/trace.ts` | CREATE | +| `libs/chat/src/lib/streaming/trace.spec.ts` | CREATE | +| `libs/chat/src/lib/streaming/content-classifier.ts` | UPDATE imports + add trace | +| `libs/chat/src/lib/streaming/parse-tree-store.ts` | UPDATE imports | +| `libs/chat/src/lib/streaming/parse-tree-store.spec.ts` | UPDATE imports | +| `libs/chat/src/lib/compositions/chat/chat.component.ts` | rekey classifier Map by id, add janitor | +| `libs/chat/package.json` | + `@cacheplane/partial-json` dep, − `@ngaf/partial-json` peer | +| `libs/langgraph/src/lib/internals/stream-manager.bridge.ts` | add trace call sites | +| `libs/partial-json/package.json` | add `"deprecated"` field | + +### Phase 3 (worktree) + +| Path | Action | +|---|---| +| `libs/chat/src/lib/primitives/chat-welcome/chat-welcome.component.ts` | CREATE | +| `libs/chat/src/lib/primitives/chat-welcome/chat-welcome.component.spec.ts` | CREATE | +| `libs/chat/src/lib/primitives/chat-welcome/chat-welcome-suggestion.component.ts` | CREATE | +| `libs/chat/src/lib/primitives/chat-welcome/chat-welcome-suggestion.component.spec.ts` | CREATE | +| `libs/chat/src/lib/styles/chat-welcome.styles.ts` | CREATE | +| `libs/chat/src/lib/styles/chat-tokens.ts` | append fade-in keyframe | +| `libs/chat/src/lib/compositions/chat/chat.component.ts` | wire welcome branch + showWelcome computed + welcomeDisabled input | +| `libs/chat/src/lib/compositions/chat/chat.component.spec.ts` | add welcome behavior tests | +| `libs/chat/src/public-api.ts` | export new components | + +--- + +# Phase 1: Extract `@cacheplane/partial-json` + +Working repo: `~/repos/cacheplane-partial-json/` (clone of `git@github.com:cacheplane/partial-json.git`). + +### Task 1.1: Clone repo, scaffold package + +**Files:** +- Create: `~/repos/cacheplane-partial-json/package.json` +- Create: `~/repos/cacheplane-partial-json/tsconfig.json` +- Create: `~/repos/cacheplane-partial-json/tsconfig.build.json` +- Create: `~/repos/cacheplane-partial-json/vitest.config.ts` +- Create: `~/repos/cacheplane-partial-json/tsup.config.ts` +- Create: `~/repos/cacheplane-partial-json/.gitignore` +- Create: `~/repos/cacheplane-partial-json/LICENSE` +- Create: `~/repos/cacheplane-partial-json/README.md` + +- [ ] **Step 1: Clone the repo** + +```bash +cd ~/repos +git clone git@github.com:cacheplane/partial-json.git cacheplane-partial-json +cd cacheplane-partial-json +``` + +- [ ] **Step 2: Initialize package.json** + +```bash +cat > package.json <<'JSON' +{ + "name": "@cacheplane/partial-json", + "version": "1.0.0", + "description": "Streaming partial-JSON parser with identity preservation, push/pull APIs, JSON Pointer lookups, and structural-sharing materialization.", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": { "types": "./dist/index.d.ts", "default": "./dist/index.mjs" }, + "require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" } + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "files": ["dist", "README.md", "LICENSE"], + "scripts": { + "build": "tsup", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "lint": "eslint src --ext .ts", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@vitest/coverage-v8": "^3.0.0", + "eslint": "^9.0.0", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", + "tsup": "^8.0.0", + "typescript": "^5.6.0", + "vitest": "^3.0.0" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/cacheplane/partial-json.git" + }, + "homepage": "https://github.com/cacheplane/partial-json#readme", + "bugs": "https://github.com/cacheplane/partial-json/issues", + "keywords": ["json", "stream", "partial", "parser", "incremental", "llm"], + "publishConfig": { "access": "public", "provenance": true } +} +JSON +``` + +- [ ] **Step 3: Add tsconfig** + +```bash +cat > tsconfig.json <<'JSON' +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/__tests__/**", "**/*.test.ts"] +} +JSON + +cat > tsconfig.build.json <<'JSON' +{ + "extends": "./tsconfig.json" +} +JSON +``` + +- [ ] **Step 4: Add vitest + tsup configs** + +```bash +cat > vitest.config.ts <<'TS' +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + include: ['src/**/*.ts'], + exclude: ['src/**/*.test.ts', 'src/__tests__/**'], + }, + }, +}); +TS + +cat > tsup.config.ts <<'TS' +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + clean: true, + sourcemap: true, + treeshake: true, + outDir: 'dist', +}); +TS +``` + +- [ ] **Step 5: .gitignore + LICENSE + README** + +```bash +cat > .gitignore <<'TXT' +node_modules/ +dist/ +coverage/ +*.log +.DS_Store +.vitest-cache/ +TXT + +cat > LICENSE <<'TXT' +MIT License + +Copyright (c) 2026 cacheplane + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +TXT + +cat > README.md <<'MD' +# @cacheplane/partial-json + +Streaming partial-JSON parser. Returns a structured AST as bytes arrive, preserves object identity across mutations, and supports both pull-style (`create / push / finish / resolve`) and push-style (`createPartialJsonParser` with events) APIs. + +## Install + +```bash +npm install @cacheplane/partial-json +``` + +## Quick start + +```ts +import { createPartialJsonParser, materialize } from '@cacheplane/partial-json'; + +const parser = createPartialJsonParser(); +parser.push('{"items":[{"id":"a"},'); +parser.push('{"id":"b"}]}'); + +const node = parser.getByPath('/items/1/id'); +const value = materialize(parser.root); +``` + +## API + +See full documentation at https://github.com/cacheplane/partial-json +MD +``` + +- [ ] **Step 6: Install + initial commit** + +```bash +npm install +git add . +git commit -m "chore: scaffold package (package.json, tsconfig, vitest, tsup, README)" +``` + +--- + +### Task 1.2: Port types + guards + +**Files:** +- Create: `~/repos/cacheplane-partial-json/src/types.ts` +- Create: `~/repos/cacheplane-partial-json/src/guards.ts` + +- [ ] **Step 1: Port types from pretable** + +Copy `~/repos/pretable/packages/json-stream/src/types.ts` to `src/types.ts` verbatim. This file already has the AST node types (`AstNode`, `ArrayNode`, `BoolNode`, `NullNode`, `NumberNode`, `ObjectNode`, `StringNode`, `JsonValue`, `NodeStatus`, `StreamError`, `StreamState`, internal helper types). + +```bash +cp ~/repos/pretable/packages/json-stream/src/types.ts src/types.ts +``` + +- [ ] **Step 2: Append the push-style types** + +After the pretable types, append the push-style API types from `@ngaf/partial-json/src/lib/types.ts` (RENAMED to avoid collision with pretable's pull-style names). + +Open `~/repos/angular-agent-framework/.claude/worktrees/dazzling-dewdney-887eac/libs/partial-json/src/lib/types.ts` and copy its contents. Append to `src/types.ts` with this header: + +```ts +// ──────────────────────────────────────────────────────────────────────────── +// Push-style API types (createPartialJsonParser). +// These wrap the pull-style state machine (above) with a node tree + events. +// ──────────────────────────────────────────────────────────────────────────── +``` + +Both type sets coexist; no renames yet because their public names don't collide. + +- [ ] **Step 3: Port guards** + +```bash +cp ~/repos/pretable/packages/json-stream/src/guards.ts src/guards.ts +``` + +- [ ] **Step 4: Typecheck** + +```bash +npm run typecheck +``` + +Expected: PASS (or report which symbols are missing — fix by porting any imported helpers from pretable's `internals.ts` headers). + +- [ ] **Step 5: Commit** + +```bash +git add src/types.ts src/guards.ts +git commit -m "feat: port AST types + type guards" +``` + +--- + +### Task 1.3: Port internals + create + +**Files:** +- Create: `~/repos/cacheplane-partial-json/src/internals.ts` +- Create: `~/repos/cacheplane-partial-json/src/create.ts` + +- [ ] **Step 1: Copy files** + +```bash +cp ~/repos/pretable/packages/json-stream/src/internals.ts src/internals.ts +cp ~/repos/pretable/packages/json-stream/src/create.ts src/create.ts +``` + +- [ ] **Step 2: Typecheck** + +```bash +npm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add src/internals.ts src/create.ts +git commit -m "feat: port identity preservation + state factory" +``` + +--- + +### Task 1.4: Port handlers + push + finish + resolve + +**Files:** +- Create: `~/repos/cacheplane-partial-json/src/handlers.ts` +- Create: `~/repos/cacheplane-partial-json/src/push.ts` +- Create: `~/repos/cacheplane-partial-json/src/finish.ts` +- Create: `~/repos/cacheplane-partial-json/src/resolve.ts` + +- [ ] **Step 1: Copy four files** + +```bash +cp ~/repos/pretable/packages/json-stream/src/handlers.ts src/handlers.ts +cp ~/repos/pretable/packages/json-stream/src/push.ts src/push.ts +cp ~/repos/pretable/packages/json-stream/src/finish.ts src/finish.ts +cp ~/repos/pretable/packages/json-stream/src/resolve.ts src/resolve.ts +``` + +- [ ] **Step 2: Typecheck** + +```bash +npm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add src/handlers.ts src/push.ts src/finish.ts src/resolve.ts +git commit -m "feat: port tokenizer (handlers, push driver, finish, resolve)" +``` + +--- + +### Task 1.5: Port pretable test suite, run it green + +**Files:** +- Create: `~/repos/cacheplane-partial-json/src/__tests__/chunk-boundary.test.ts` +- Create: `~/repos/cacheplane-partial-json/src/__tests__/core-parsing.test.ts` +- Create: `~/repos/cacheplane-partial-json/src/__tests__/edge-cases.test.ts` +- Create: `~/repos/cacheplane-partial-json/src/__tests__/errors.test.ts` +- Create: `~/repos/cacheplane-partial-json/src/__tests__/guards.test.ts` +- Create: `~/repos/cacheplane-partial-json/src/__tests__/streaming.test.ts` + +- [ ] **Step 1: Copy all six test files** + +```bash +mkdir -p src/__tests__ +cp ~/repos/pretable/packages/json-stream/src/__tests__/*.test.ts src/__tests__/ +``` + +- [ ] **Step 2: Update imports** + +The pretable tests import from `../index` or relative paths. Search and replace any references to the old package name. Confirm imports look like `from '../create'`, `from '../push'`, etc. (relative to `src/__tests__/`). + +```bash +grep -l "@cacheplane/json-stream\|@pretable" src/__tests__/*.test.ts +``` + +If grep returns files, edit them to use relative imports. + +- [ ] **Step 3: Run tests** + +```bash +npm test +``` + +Expected: PASS (all pretable tests). If any fail, the cause is a port issue — fix the corresponding source file. + +- [ ] **Step 4: Commit** + +```bash +git add src/__tests__/ +git commit -m "test: port pretable tokenizer + identity-preservation suites" +``` + +--- + +### Task 1.6: Add push-style parser layer + +**Files:** +- Create: `~/repos/cacheplane-partial-json/src/parser.ts` +- Create: `~/repos/cacheplane-partial-json/src/__tests__/parser.test.ts` + +- [ ] **Step 1: Copy ngaf's parser.ts** + +```bash +cp ~/repos/angular-agent-framework/.claude/worktrees/dazzling-dewdney-887eac/libs/partial-json/src/lib/parser.ts src/parser.ts +``` + +- [ ] **Step 2: Bridge it onto the pretable state machine** + +The ngaf parser.ts is currently self-contained (its own tokenizer). We're replacing the tokenizer with pretable's. Edit `src/parser.ts`: + +Add at the top: + +```ts +import { create as createState, push as pushState, finish as finishState } from './push'; +import { isComplete, isObjectNode, isArrayNode, isStringNode, isNumberNode, isBoolNode, isNullNode } from './guards'; +import type { StreamState, AstNode } from './types'; +``` + +Replace the internal tokenizer in `parser.push(chunk)` with a delegation to the pretable state: + +```ts +function createPartialJsonParser(): PartialJsonParser { + let state: StreamState = createState(); + // ... existing event tracking, root accessor, getByPath ... + + return { + push(chunk: string): ParseEvent[] { + const before = state; + state = pushState(state, chunk); + const events = diffStates(before, state); + return events; + }, + finish(): ParseEvent[] { + const before = state; + state = finishState(state); + return diffStates(before, state); + }, + get root(): JsonNode | null { + return state.rootId != null ? toJsonNode(state, state.rootId) : null; + }, + getByPath(path: string): JsonNode | null { /* JSON Pointer impl, see ngaf parser.ts:330+ */ }, + }; +} +``` + +The `diffStates(before, after)` helper compares node arrays and emits `node-created` / `value-updated` / `node-completed` events. The `toJsonNode(state, id)` converts pretable's AstNode to ngaf's JsonNode shape. + +(See ngaf's parser.ts for the event emission and JSON Pointer logic; copy as-is, only swap the tokenizer.) + +- [ ] **Step 3: Copy the test file** + +```bash +cp ~/repos/angular-agent-framework/.claude/worktrees/dazzling-dewdney-887eac/libs/partial-json/src/lib/parser.spec.ts src/__tests__/parser.test.ts +``` + +Update imports inside the file from `'./parser'` to `'../parser'`. + +- [ ] **Step 4: Run tests** + +```bash +npm test +``` + +Expected: PASS. Some ngaf tests may fail because the new tokenizer is stricter (e.g. `tru` is now buffered, not eagerly completed). Adjust test expectations to the strict behavior — these are correctness improvements, not regressions. + +Document any test changes with a comment like: + +```ts +// Updated for stricter tokenizer: `tru` is now buffered until `e` arrives or +// the stream finishes (was: eagerly completed as boolean). +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/parser.ts src/__tests__/parser.test.ts +git commit -m "feat: push-style parser API layered on the strict tokenizer" +``` + +--- + +### Task 1.7: Port materialize + tests + +**Files:** +- Create: `~/repos/cacheplane-partial-json/src/materialize.ts` +- Create: `~/repos/cacheplane-partial-json/src/__tests__/materialize.test.ts` + +- [ ] **Step 1: Copy materialize** + +```bash +cp ~/repos/angular-agent-framework/.claude/worktrees/dazzling-dewdney-887eac/libs/partial-json/src/lib/materialize.ts src/materialize.ts +cp ~/repos/angular-agent-framework/.claude/worktrees/dazzling-dewdney-887eac/libs/partial-json/src/lib/materialize.spec.ts src/__tests__/materialize.test.ts +``` + +- [ ] **Step 2: Update imports inside materialize.test.ts** + +Change `from './materialize'` to `from '../materialize'` and `from './parser'` to `from '../parser'`. + +- [ ] **Step 3: Run tests** + +```bash +npm test +``` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/materialize.ts src/__tests__/materialize.test.ts +git commit -m "feat: port structural-sharing materialize() + tests" +``` + +--- + +### Task 1.8: Public API surface — `src/index.ts` + +**Files:** +- Create: `~/repos/cacheplane-partial-json/src/index.ts` + +- [ ] **Step 1: Write the index** + +```ts +// SPDX-License-Identifier: MIT +// +// @cacheplane/partial-json — streaming partial-JSON parser +// +// Two public APIs over the same core tokenizer: +// - Pull-style: create() → push(state, chunk) → finish(state) → resolve(state) +// - Push-style: createPartialJsonParser() → parser.push(chunk) → events +// +// Both share the same node graph internally; mix freely. + +// ── Pull-style (immutable state) ───────────────────────────────────────────── +export type { + AstNode, ArrayNode, BoolNode, JsonValue, NodeStatus, + NullNode, NumberNode, ObjectNode, StringNode, + StreamError, StreamState, +} from './types'; + +export { + isArrayNode, isBoolNode, isComplete, isNullNode, + isNumberNode, isObjectNode, isStringNode, +} from './guards'; + +export { create } from './create'; +export { push } from './push'; +export { finish } from './finish'; +export { resolve } from './resolve'; + +// ── Push-style (parser + events) ───────────────────────────────────────────── +export type { + JsonNodeType, JsonNodeStatus, JsonNodeBase, + JsonObjectNode, JsonArrayNode, JsonStringNode, + JsonNumberNode, JsonBooleanNode, JsonNullNode, + JsonNode, ParseEvent, PartialJsonParser, +} from './types'; + +export { createPartialJsonParser } from './parser'; +export { materialize } from './materialize'; +``` + +- [ ] **Step 2: Run typecheck + tests + build** + +```bash +npm run typecheck +npm test +npm run build +``` + +Expected: all PASS. `dist/index.mjs`, `dist/index.cjs`, `dist/index.d.ts` exist. + +- [ ] **Step 3: Commit** + +```bash +git add src/index.ts +git commit -m "feat: public API surface (pull + push styles unified)" +``` + +--- + +### Task 1.9: Cross-cutting parity tests + +**Files:** +- Create: `~/repos/cacheplane-partial-json/src/__tests__/parity.test.ts` + +- [ ] **Step 1: Write the parity test** + +```ts +import { describe, it, expect } from 'vitest'; +import { create, push, finish, resolve, createPartialJsonParser, materialize } from '../index'; + +describe('pull/push API parity', () => { + it('produces the same JsonValue for a complete simple object', () => { + const input = '{"a":1,"b":["x","y"],"c":null}'; + + let state = create(); + state = push(state, input); + state = finish(state); + const pullValue = resolve(state); + + const parser = createPartialJsonParser(); + parser.push(input); + parser.finish?.(); + const pushValue = materialize(parser.root); + + expect(pullValue).toEqual(pushValue); + expect(pullValue).toEqual({ a: 1, b: ['x', 'y'], c: null }); + }); + + it('handles partial keyword (tru → true) consistently', () => { + let state = create(); + state = push(state, '[tru'); + state = push(state, 'e]'); + state = finish(state); + const pullValue = resolve(state); + + const parser = createPartialJsonParser(); + parser.push('[tru'); + parser.push('e]'); + parser.finish?.(); + const pushValue = materialize(parser.root); + + expect(pullValue).toEqual([true]); + expect(pushValue).toEqual([true]); + }); + + it('preserves identity across no-op pushes (push-style)', () => { + const parser = createPartialJsonParser(); + parser.push('{"a":1}'); + const before = materialize(parser.root); + parser.push(' '); // whitespace, no structural change + const after = materialize(parser.root); + expect(after).toBe(before); // === reference equality + }); + + it('emits node-created → value-updated → node-completed in order', () => { + const parser = createPartialJsonParser(); + const events1 = parser.push('"hel'); + const events2 = parser.push('lo"'); + + const types = [...events1, ...events2].map(e => e.type); + expect(types[0]).toBe('node-created'); + expect(types).toContain('value-updated'); + expect(types[types.length - 1]).toBe('node-completed'); + }); + + it('getByPath resolves RFC 6901 pointers', () => { + const parser = createPartialJsonParser(); + parser.push('{"items":[{"id":"a"},{"id":"b"}]}'); + parser.finish?.(); + const node = parser.getByPath('/items/1/id'); + expect(node).toBeTruthy(); + expect(materialize(node)).toBe('b'); + }); +}); +``` + +- [ ] **Step 2: Run tests** + +```bash +npm test +``` + +Expected: PASS. If parity assertions fail, the cause is in the bridge (`parser.ts`'s `diffStates` or `toJsonNode`). Fix in source. + +- [ ] **Step 3: Commit** + +```bash +git add src/__tests__/parity.test.ts +git commit -m "test: cross-cutting parity tests (pull vs push API)" +``` + +--- + +### Task 1.10: ESLint config + +**Files:** +- Create: `~/repos/cacheplane-partial-json/eslint.config.js` + +- [ ] **Step 1: Write config** + +```js +// eslint.config.js +import tseslint from '@typescript-eslint/eslint-plugin'; +import tsparser from '@typescript-eslint/parser'; + +export default [ + { + files: ['src/**/*.ts'], + languageOptions: { parser: tsparser, parserOptions: { project: './tsconfig.json' } }, + plugins: { '@typescript-eslint': tseslint }, + rules: { + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-non-null-assertion': 'off', + 'no-console': 'warn', + }, + }, +]; +``` + +- [ ] **Step 2: Run lint** + +```bash +npm run lint +``` + +Expected: PASS or warnings only (no errors). + +- [ ] **Step 3: Commit** + +```bash +git add eslint.config.js +git commit -m "chore: eslint config" +``` + +--- + +### Task 1.11: GitHub Actions CI + +**Files:** +- Create: `~/repos/cacheplane-partial-json/.github/workflows/ci.yml` + +- [ ] **Step 1: Write CI** + +```yaml +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run lint + - run: npm run typecheck + - run: npm run build + - run: npm test -- --coverage +``` + +- [ ] **Step 2: Commit + push** + +```bash +git add .github/workflows/ci.yml +git commit -m "ci: lint + typecheck + build + test" +git push -u origin main +``` + +Expected: CI runs green on GitHub. + +--- + +### Task 1.12: Publish workflow + +**Files:** +- Create: `~/repos/cacheplane-partial-json/.github/workflows/publish.yml` + +- [ ] **Step 1: Write publish workflow** + +```yaml +name: Publish + +on: + push: + tags: + - 'v*' + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + registry-url: https://registry.npmjs.org + - run: npm ci + - run: npm install -g npm@latest + - run: npm test + - run: npm run build + - run: npm publish --provenance + env: + NPM_CONFIG_PROVENANCE: 'true' +``` + +- [ ] **Step 2: Configure npm trusted publishing** + +On npmjs.com, add `cacheplane/partial-json` GitHub repo as a trusted publisher for `@cacheplane/partial-json`. (Manual step — user does this in the npm UI.) + +- [ ] **Step 3: Commit + push** + +```bash +git add .github/workflows/publish.yml +git commit -m "ci: publish workflow (tag-triggered, OIDC)" +git push +``` + +--- + +### Task 1.13: Tag and release v1.0.0 + +- [ ] **Step 1: Create + push tag** + +```bash +cd ~/repos/cacheplane-partial-json +git tag v1.0.0 +git push origin v1.0.0 +``` + +- [ ] **Step 2: Watch publish workflow** + +```bash +gh run watch +``` + +Expected: workflow completes, `@cacheplane/partial-json@1.0.0` is on npm. + +- [ ] **Step 3: Verify publish** + +```bash +npm view @cacheplane/partial-json +``` + +Expected: shows version 1.0.0. + +--- + +# Phase 2: Chat streaming rewrite + +Worktree: `/Users/blove/repos/angular-agent-framework/.claude/worktrees/dazzling-dewdney-887eac` + +```bash +cd /Users/blove/repos/angular-agent-framework/.claude/worktrees/dazzling-dewdney-887eac +git fetch origin main +git checkout -b claude/chat-streaming-rewrite origin/main +``` + +### Task 2.1: Add `@cacheplane/partial-json` dep, swap chat imports + +**Files:** +- Modify: `libs/chat/package.json` +- Modify: `libs/chat/src/lib/streaming/content-classifier.ts` +- Modify: `libs/chat/src/lib/streaming/parse-tree-store.ts` +- Modify: `libs/chat/src/lib/streaming/parse-tree-store.spec.ts` + +- [ ] **Step 1: Update chat package.json peer/dep** + +Open `libs/chat/package.json`. Replace the `"@ngaf/partial-json": "*"` peer with `"@cacheplane/partial-json": "^1.0.0"`. Move it from `peerDependencies` to `dependencies` (cacheplane package is bundled as a runtime dep, not a peer). + +```json +{ + "name": "@ngaf/chat", + "dependencies": { + "@cacheplane/partial-json": "^1.0.0" + }, + "peerDependencies": { + "@angular/core": "^20.0.0 || ^21.0.0", + "@angular/common": "^20.0.0 || ^21.0.0", + "@angular/forms": "^20.0.0 || ^21.0.0", + "@angular/platform-browser": "^20.0.0 || ^21.0.0", + "@ngaf/licensing": "*", + "@ngaf/render": "*", + "@ngaf/a2ui": "*", + "@json-render/core": "^0.16.0", + "@langchain/core": "^1.1.33", + "rxjs": "~7.8.0", + "marked": "^15.0.0 || ^16.0.0" + } +} +``` + +- [ ] **Step 2: Update root package.json + npm install** + +In the monorepo root `package.json`, add `@cacheplane/partial-json: ^1.0.0` to `dependencies`. Run: + +```bash +npm install +``` + +Expected: lockfile updates with the new package. + +- [ ] **Step 3: Swap content-classifier import** + +`libs/chat/src/lib/streaming/content-classifier.ts:4`: + +```diff +-import { createPartialJsonParser } from '@ngaf/partial-json'; ++import { createPartialJsonParser } from '@cacheplane/partial-json'; +``` + +- [ ] **Step 4: Swap parse-tree-store imports** + +`libs/chat/src/lib/streaming/parse-tree-store.ts:4-5`: + +```diff +-import type { PartialJsonParser, JsonObjectNode } from '@ngaf/partial-json'; +-import { materialize } from '@ngaf/partial-json'; ++import type { PartialJsonParser, JsonObjectNode } from '@cacheplane/partial-json'; ++import { materialize } from '@cacheplane/partial-json'; +``` + +- [ ] **Step 5: Swap parse-tree-store spec import** + +`libs/chat/src/lib/streaming/parse-tree-store.spec.ts:4`: + +```diff +-import { createPartialJsonParser } from '@ngaf/partial-json'; ++import { createPartialJsonParser } from '@cacheplane/partial-json'; +``` + +- [ ] **Step 6: Run chat tests** + +```bash +npx nx test chat +``` + +Expected: all PASS. The cacheplane package's API matches @ngaf/partial-json's, so no behavioral change. + +- [ ] **Step 7: Commit** + +```bash +git add libs/chat/package.json libs/chat/src/lib/streaming/content-classifier.ts libs/chat/src/lib/streaming/parse-tree-store.ts libs/chat/src/lib/streaming/parse-tree-store.spec.ts package.json package-lock.json +git commit -m "chore(chat): migrate to @cacheplane/partial-json" +``` + +--- + +### Task 2.2: Freeze `@ngaf/partial-json` with deprecation notice + +**Files:** +- Modify: `libs/partial-json/package.json` + +- [ ] **Step 1: Add deprecated field** + +Open `libs/partial-json/package.json`. Add a top-level `"deprecated"` field: + +```json +{ + "name": "@ngaf/partial-json", + "version": "0.0.2", + "deprecated": "Replaced by @cacheplane/partial-json. No further versions will be published from this package." +} +``` + +- [ ] **Step 2: Verify build still works** + +```bash +npx nx build partial-json +``` + +Expected: PASS (deprecated field is metadata only). + +- [ ] **Step 3: Commit** + +```bash +git add libs/partial-json/package.json +git commit -m "chore(partial-json): mark @ngaf/partial-json deprecated in favor of @cacheplane/partial-json" +``` + +--- + +### Task 2.3: Trace harness — TDD + +**Files:** +- Create: `libs/chat/src/lib/streaming/trace.ts` +- Create: `libs/chat/src/lib/streaming/trace.spec.ts` + +- [ ] **Step 1: Write the failing tests** + +```ts +// libs/chat/src/lib/streaming/trace.spec.ts +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { isTraceEnabled, trace } from './trace'; + +describe('trace', () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + delete (globalThis as any).window?.__ngafChatTrace; + try { (globalThis as any).window?.localStorage?.removeItem('NGAF_CHAT_STREAM_TRACE'); } catch { /* ignore */ } + consoleSpy = vi.spyOn(console, 'debug').mockImplementation(() => undefined); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it('returns false when no flag is set', () => { + expect(isTraceEnabled()).toBe(false); + }); + + it('returns true when window.__ngafChatTrace === true', () => { + (globalThis as any).window = { ...((globalThis as any).window ?? {}), __ngafChatTrace: true }; + expect(isTraceEnabled()).toBe(true); + }); + + it('returns true when localStorage NGAF_CHAT_STREAM_TRACE === "1"', () => { + const ls = { getItem: (k: string) => (k === 'NGAF_CHAT_STREAM_TRACE' ? '1' : null) }; + (globalThis as any).window = { ...((globalThis as any).window ?? {}), localStorage: ls }; + expect(isTraceEnabled()).toBe(true); + }); + + it('does not call console.debug when disabled', () => { + trace('hello'); + expect(consoleSpy).not.toHaveBeenCalled(); + }); + + it('calls console.debug with prefix when enabled', () => { + (globalThis as any).window = { ...((globalThis as any).window ?? {}), __ngafChatTrace: true }; + trace('hello', { foo: 1 }); + expect(consoleSpy).toHaveBeenCalledWith('[ngaf-chat-stream]', 'hello', { foo: 1 }); + }); +}); +``` + +- [ ] **Step 2: Run, expect fail** + +```bash +npx nx test chat -- trace.spec.ts +``` + +Expected: FAIL with "Cannot find module './trace'". + +- [ ] **Step 3: Write the implementation** + +```ts +// libs/chat/src/lib/streaming/trace.ts +// SPDX-License-Identifier: MIT +// +// localStorage / window-flag-gated debug tracer for @ngaf/chat streaming. +// Off by default. Enable via: +// window.__ngafChatTrace = true +// localStorage.NGAF_CHAT_STREAM_TRACE = '1' +// +// All call sites should be guarded with `if (isTraceEnabled())` so the +// argument-collection cost is paid only when tracing is on. + +export function isTraceEnabled(): boolean { + if (typeof globalThis === 'undefined') return false; + const win = (globalThis as { window?: { __ngafChatTrace?: boolean; localStorage?: Storage } }).window; + if (!win) return false; + if (win.__ngafChatTrace === true) return true; + try { + return win.localStorage?.getItem('NGAF_CHAT_STREAM_TRACE') === '1'; + } catch { + return false; + } +} + +export function trace(...args: unknown[]): void { + if (isTraceEnabled()) { + // eslint-disable-next-line no-console + console.debug('[ngaf-chat-stream]', ...args); + } +} +``` + +- [ ] **Step 4: Run tests, expect pass** + +```bash +npx nx test chat -- trace.spec.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add libs/chat/src/lib/streaming/trace.ts libs/chat/src/lib/streaming/trace.spec.ts +git commit -m "feat(chat): localStorage-gated stream-trace harness" +``` + +--- + +### Task 2.4: Write failing tests for new chat-streaming-md + +**Files:** +- Create: `libs/chat/src/lib/streaming/streaming-markdown.component.spec.ts` + +- [ ] **Step 1: Write the test file** + +```ts +// libs/chat/src/lib/streaming/streaming-markdown.component.spec.ts +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChatStreamingMdComponent } from './streaming-markdown.component'; + +function flushRaf(): Promise { + return new Promise(resolve => { + requestAnimationFrame(() => resolve()); + }); +} + +describe('ChatStreamingMdComponent', () => { + let fixture: ComponentFixture; + let component: ChatStreamingMdComponent; + + beforeEach(() => { + fixture = TestBed.createComponent(ChatStreamingMdComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('content', ''); + }); + + it('renders markdown into innerHTML on first content', async () => { + fixture.componentRef.setInput('content', '# Heading'); + fixture.detectChanges(); + await flushRaf(); + const el = fixture.nativeElement as HTMLElement; + expect(el.innerHTML).toContain(' { + fixture.componentRef.setInput('content', '# A'); + fixture.detectChanges(); + fixture.componentRef.setInput('content', '# AB'); + fixture.detectChanges(); + fixture.componentRef.setInput('content', '# ABC'); + fixture.detectChanges(); + await flushRaf(); + const el = fixture.nativeElement as HTMLElement; + expect(el.innerHTML).toContain('ABC'); + }); + + it('handles content shrinking without freezing (regression)', async () => { + fixture.componentRef.setInput('content', '# Long heading'); + fixture.detectChanges(); + await flushRaf(); + fixture.componentRef.setInput('content', '# Short'); + fixture.detectChanges(); + await flushRaf(); + const el = fixture.nativeElement as HTMLElement; + expect(el.innerHTML).toContain('Short'); + expect(el.innerHTML).not.toContain('Long heading'); + }); + + it('cleans up pending RAF on destroy', async () => { + const spy = vi.spyOn(globalThis, 'cancelAnimationFrame'); + fixture.componentRef.setInput('content', '# X'); + fixture.detectChanges(); + fixture.destroy(); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); +}); +``` + +- [ ] **Step 2: Run, expect fail** + +```bash +npx nx test chat -- streaming-markdown.component.spec.ts +``` + +Expected: FAIL — old component still uses incremental renderer; the "shrink" test will fail with old behavior, others may fail or hang. + +- [ ] **Step 3: Commit (failing tests as a forcing function)** + +```bash +git add libs/chat/src/lib/streaming/streaming-markdown.component.spec.ts +git commit -m "test(chat): failing specs for RAF-batched streaming markdown" +``` + +--- + +### Task 2.5: Rewrite `chat-streaming-md` component + +**Files:** +- Modify: `libs/chat/src/lib/streaming/streaming-markdown.component.ts` + +- [ ] **Step 1: Rewrite the file** + +```ts +// libs/chat/src/lib/streaming/streaming-markdown.component.ts +// SPDX-License-Identifier: MIT +import { + Component, + ChangeDetectionStrategy, + DestroyRef, + ElementRef, + effect, + inject, + input, + untracked, +} from '@angular/core'; +import { DomSanitizer } from '@angular/platform-browser'; +import { renderMarkdownToString } from './markdown-render'; +import { isTraceEnabled, trace } from './trace'; + +/** + * Renders markdown content via marked.parse + sanitized innerHTML, coalesced + * to one render per animation frame. No incremental renderer state, no delta + * math — just write the latest content. Idempotent within a frame. + * + * The `streaming` input is informational (it can drive parent-level decisions + * like showing a caret), but doesn't change the render strategy here. + */ +@Component({ + selector: 'chat-streaming-md', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + styles: `:host { display: block; }`, +}) +export class ChatStreamingMdComponent { + readonly content = input.required(); + readonly streaming = input(false); + + private readonly el = inject(ElementRef).nativeElement as HTMLElement; + private readonly sanitizer = inject(DomSanitizer); + private readonly destroyRef = inject(DestroyRef); + + private rafHandle = 0; + private pendingContent = ''; + + constructor() { + effect(() => { + const next = this.content(); + untracked(() => this.schedule(next)); + }); + + this.destroyRef.onDestroy(() => { + if (this.rafHandle) { + cancelAnimationFrame(this.rafHandle); + this.rafHandle = 0; + } + }); + } + + private schedule(content: string): void { + this.pendingContent = content; + if (this.rafHandle !== 0) return; + this.rafHandle = requestAnimationFrame(() => { + this.rafHandle = 0; + this.flush(); + }); + } + + private flush(): void { + const content = this.pendingContent; + if (!content) { + this.el.innerHTML = ''; + return; + } + const start = isTraceEnabled() ? performance.now() : 0; + this.el.innerHTML = renderMarkdownToString(content, this.sanitizer); + if (isTraceEnabled()) { + trace('streaming-md.flush', { contentLength: content.length, durationMs: performance.now() - start }); + } + } +} +``` + +- [ ] **Step 2: Run tests** + +```bash +npx nx test chat -- streaming-markdown.component.spec.ts +``` + +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add libs/chat/src/lib/streaming/streaming-markdown.component.ts +git commit -m "refactor(chat): rewrite chat-streaming-md as RAF-batched full reparse" +``` + +--- + +### Task 2.6: Delete the incremental renderer + +**Files:** +- Delete: `libs/chat/src/lib/streaming/streaming-markdown.ts` +- Delete: `libs/chat/src/lib/streaming/streaming-markdown.spec.ts` + +- [ ] **Step 1: Delete files** + +```bash +git rm libs/chat/src/lib/streaming/streaming-markdown.ts +git rm libs/chat/src/lib/streaming/streaming-markdown.spec.ts +``` + +- [ ] **Step 2: Confirm nothing else imports them** + +```bash +grep -rn "streaming-markdown\b\|createStreamingMarkdownRenderer\|StreamingMarkdownRenderer" libs/chat/src +``` + +Expected: only references inside `streaming-markdown.component.ts` (the rewritten file no longer imports from it). If anything else imports, edit those out. + +- [ ] **Step 3: Run all chat tests + build** + +```bash +npx nx test chat +npx nx build chat +``` + +Expected: both PASS. + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "refactor(chat): delete bespoke append-only markdown renderer" +``` + +--- + +### Task 2.7: Rekey classifiers Map by message id + janitor + +**Files:** +- Modify: `libs/chat/src/lib/compositions/chat/chat.component.ts` + +- [ ] **Step 1: Read current state** + +`libs/chat/src/lib/compositions/chat/chat.component.ts:212`: + +```ts +private readonly classifiers = new Map(); +``` + +`classifyMessage(content, index)` is called from the template at `let-i="index"`. + +- [ ] **Step 2: Switch to id-keyed lookup** + +Change line 212: + +```ts +private readonly classifiers = new Map(); +``` + +Change `classifyMessage` signature (around line 272-275): + +```ts +classifyMessage(content: string, message: { id?: string }): ContentClassifier { + const id = message.id ?? ''; + let c = this.classifiers.get(id); + if (!c) { + c = createContentClassifier(); + this.classifiers.set(id, c); + } + c.update(content); + return c; +} +``` + +Update the template call site (around line 118): + +```html +@let classified = classifyMessage(content, message); +``` + +- [ ] **Step 3: Add janitor effect** + +Inside the constructor (after the existing `effect()` blocks): + +```ts +effect(() => { + // janitor: drop classifiers for messages no longer in the agent's list + const liveIds = new Set(); + try { for (const m of this.agent().messages()) { + const id = (m as unknown as { id?: string }).id; + if (id) liveIds.add(id); + } } catch { return; } + for (const key of [...this.classifiers.keys()]) { + if (!liveIds.has(key)) { + this.classifiers.get(key)?.dispose(); + this.classifiers.delete(key); + } + } +}); +``` + +- [ ] **Step 4: Run tests** + +```bash +npx nx test chat +``` + +Expected: PASS. + +- [ ] **Step 5: Build** + +```bash +npx nx build chat +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add libs/chat/src/lib/compositions/chat/chat.component.ts +git commit -m "fix(chat): key classifier cache by message id + janitor for stale entries" +``` + +--- + +### Task 2.8: Wire trace into call sites + +**Files:** +- Modify: `libs/chat/src/lib/streaming/content-classifier.ts` +- Modify: `libs/langgraph/src/lib/internals/stream-manager.bridge.ts` + +- [ ] **Step 1: Trace in content-classifier** + +Add at the top of `content-classifier.ts`: + +```ts +import { isTraceEnabled, trace } from './trace'; +``` + +Inside the `update` method, before returning: + +```ts +if (isTraceEnabled()) { + trace('classifier.update', { contentLength: content.length, type: this.type() }); +} +``` + +- [ ] **Step 2: Trace in langgraph bridge** + +Add at the top of `libs/langgraph/src/lib/internals/stream-manager.bridge.ts`: + +```ts +// trace harness lives in @ngaf/chat — duplicated here to avoid a hard dep +function isLgTraceEnabled(): boolean { + if (typeof globalThis === 'undefined') return false; + const win = (globalThis as { window?: { __ngafChatTrace?: boolean; localStorage?: Storage } }).window; + if (!win) return false; + if (win.__ngafChatTrace === true) return true; + try { return win.localStorage?.getItem('NGAF_CHAT_STREAM_TRACE') === '1'; } catch { return false; } +} +function lgTrace(...args: unknown[]): void { + if (isLgTraceEnabled()) { + // eslint-disable-next-line no-console + console.debug('[ngaf-chat-stream]', ...args); + } +} +``` + +In `processEvent` for messages-tuple events, after `subjects.messages$.next(...)`: + +```ts +if (isLgTraceEnabled()) { + const msgs = subjects.messages$.value; + const last = msgs[msgs.length - 1]; + lgTrace('bridge.messages-tuple', { id: (last as unknown as Record)['id'], count: msgs.length }); +} +``` + +In the values-event sync, after the merge: + +```ts +if (isLgTraceEnabled()) { + lgTrace('bridge.values-sync', { + incomingLength: stateMessages.length, + mergedLength: subjects.messages$.value.length, + }); +} +``` + +- [ ] **Step 3: Run tests** + +```bash +npx nx test chat +npx nx test langgraph +``` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add libs/chat/src/lib/streaming/content-classifier.ts libs/langgraph/src/lib/internals/stream-manager.bridge.ts +git commit -m "feat(chat,langgraph): trace harness call sites in classifier + bridge" +``` + +--- + +### Task 2.9: Reproduce + diagnose long-stream regression + +**Files:** investigative — may modify any of the streaming pipeline depending on findings. + +- [ ] **Step 1: Build chat + langgraph + smoke env** + +```bash +npx nx build chat +npx nx build langgraph +cd dist/libs/chat && npm pack && cd - +cd dist/libs/langgraph && npm pack && cd - +cd ~/tmp/ngaf-smoke-05 +rm -rf node_modules/@ngaf/chat node_modules/@ngaf/langgraph +npm install /Users/blove/repos/angular-agent-framework/.claude/worktrees/dazzling-dewdney-887eac/dist/libs/chat/ngaf-chat-*.tgz /Users/blove/repos/angular-agent-framework/.claude/worktrees/dazzling-dewdney-887eac/dist/libs/langgraph/ngaf-langgraph-*.tgz --no-save +``` + +- [ ] **Step 2: Restart ng serve** + +```bash +pkill -9 -f "ng serve"; sleep 4 +cd ~/tmp/ngaf-smoke-05 && rm -rf .angular +nohup npx ng serve --port 4303 > /tmp/ngaf-smoke.log 2>&1 & +sleep 28 +``` + +- [ ] **Step 3: Enable trace + reproduce** + +In Chrome DevTools console at http://localhost:4303/: + +```js +localStorage.setItem('NGAF_CHAT_STREAM_TRACE', '1'); +location.reload(); +``` + +Send: `"Write 800 words about coral reefs. Use three markdown headings (## Reefs, ## Threats, ## Conservation). Include one fenced code block per section."` + +- [ ] **Step 4: Capture and categorize** + +Watch console for `[ngaf-chat-stream]` logs. Open Network tab, watch the SSE/streaming request. Categorize the symptom: + +| Symptom | Likely cause | Fix location | +|---|---|---| +| Stalls mid-stream, console quiet | RAF starvation, page hidden tab, or marked.parse blocking | `streaming-markdown.component.ts` (consider `setTimeout` fallback when `document.hidden`) | +| `bridge.values-sync` shows `mergedLength` shrinking | values event still replacing in some branch | `stream-manager.bridge.ts` (re-audit the values handler) | +| `classifier.update` fires with wildly varying types per token | classifier mid-stream type flips | `content-classifier.ts` (stabilize type once committed) | +| `streaming-md.flush` fires but innerHTML stale | sanitizer stripping content | `markdown-render.ts` (check sanitization config) | +| Markdown lists/code blocks render mid-stream-broken then snap | already fixed by RAF rewrite, no action | | + +- [ ] **Step 5: Apply the fix (whatever the category indicated)** + +Make the targeted change. Add a regression test that exercises the failure path (using the trace output as evidence). + +- [ ] **Step 6: Update spec's Findings appendix** + +In `docs/superpowers/specs/2026-05-02-chat-pipeline-redesign-design.md`, replace the placeholder text at the "Findings appendix" section with: + +``` +## Findings appendix + +Diagnosed during Phase 2 reproduction (2026-05-02): + +- **Symptom**: +- **Root cause**: +- **Fix**: +- **Regression test**: +``` + +- [ ] **Step 7: Run tests** + +```bash +npx nx test chat +npx nx test langgraph +``` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add -A +git commit -m "fix(chat,langgraph): long-stream regression — " +``` + +If no regression is found after the rewrite, document that in the Findings appendix and skip steps 5 + 8. + +--- + +### Task 2.10: Bump @ngaf/chat to 0.0.14, ship PR + +**Files:** +- Modify: `libs/chat/package.json` + +- [ ] **Step 1: Bump version** + +```bash +sed -i '' 's/"version": "0.0.13"/"version": "0.0.14"/' libs/chat/package.json +``` + +- [ ] **Step 2: Build, lint, test** + +```bash +npx nx run-many -t lint,test,build --projects=chat,langgraph,partial-json +``` + +Expected: all PASS. + +- [ ] **Step 3: Commit + push** + +```bash +git add libs/chat/package.json +git commit -m "chore(chat): 0.0.13 → 0.0.14" +git push -u origin claude/chat-streaming-rewrite +``` + +- [ ] **Step 4: Open PR** + +```bash +gh pr create --title "feat(chat): streaming rewrite + cacheplane parser migration" --body "$(cat <<'EOF' +## What +- Migrate @ngaf/chat to consume @cacheplane/partial-json (replacing @ngaf/partial-json) +- Mark @ngaf/partial-json deprecated at 0.0.2 +- Delete the bespoke append-only markdown renderer (~200 LOC) +- Rewrite chat-streaming-md as a ~30 LOC RAF-batched full-reparse component +- Rekey ChatComponent classifier cache by message id + janitor effect +- Add localStorage-gated trace harness for diagnostics +- Diagnose + fix the long-output streaming regression (see Findings appendix in spec) + +## Why +The old append-only renderer's delta math (`content.slice(lastContent.length)`) silently froze when content shrank or reordered. RAF-batched full reparse is simpler, idempotent, and eliminates that bug class. Sharing the partial-JSON parser across projects gives both a stricter tokenizer + identity preservation + finish() semantics. + +## Versions +- @ngaf/chat: 0.0.13 → 0.0.14 +- @ngaf/partial-json: frozen at 0.0.2 (deprecated) + +## Spec +docs/superpowers/specs/2026-05-02-chat-pipeline-redesign-design.md +EOF +)" +``` + +- [ ] **Step 5: Watch CI** + +```bash +gh pr checks --watch +``` + +Expected: green. + +- [ ] **Step 6: Merge + tag** + +```bash +gh pr merge --squash --delete-branch +git fetch origin main +git tag chat-v0.0.14 origin/main +git push origin chat-v0.0.14 +``` + +--- + +# Phase 3: Welcome screen + +```bash +cd /Users/blove/repos/angular-agent-framework/.claude/worktrees/dazzling-dewdney-887eac +git fetch origin main +git checkout -b claude/chat-welcome-screen origin/main +``` + +### Task 3.1: Add fade-in keyframe to global tokens + +**Files:** +- Modify: `libs/chat/src/lib/styles/chat-tokens.ts` + +- [ ] **Step 1: Add the keyframe** + +Find the `KEYFRAMES` constant (search for `@keyframes ngaf-chat-pulse`). Append: + +```ts + @keyframes ngaf-chat-welcome-mount { + from { opacity: 0; transform: translateY(-8px); } + to { opacity: 1; transform: translateY(0); } + } +``` + +- [ ] **Step 2: Build chat** + +```bash +npx nx build chat +``` + +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add libs/chat/src/lib/styles/chat-tokens.ts +git commit -m "feat(chat): welcome-mount keyframe" +``` + +--- + +### Task 3.2: Welcome styles file + +**Files:** +- Create: `libs/chat/src/lib/styles/chat-welcome.styles.ts` + +- [ ] **Step 1: Write styles** + +```ts +// libs/chat/src/lib/styles/chat-welcome.styles.ts +// SPDX-License-Identifier: MIT +export const CHAT_WELCOME_STYLES = ` + :host { + display: flex; + flex: 1 1 auto; + align-items: center; + justify-content: center; + width: 100%; + min-height: 0; + padding: var(--ngaf-chat-welcome-padding, 24px); + box-sizing: border-box; + animation: ngaf-chat-welcome-mount 200ms ease-out both; + } + .chat-welcome__inner { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--ngaf-chat-welcome-gap, 1.5rem); + width: 100%; + max-width: var(--ngaf-chat-welcome-max-width, 36rem); + text-align: center; + } + .chat-welcome__beacon { + display: inline-block; + width: 14px; + height: 14px; + border-radius: 50%; + background: radial-gradient(circle at 30% 30%, + var(--ngaf-chat-text) 0%, + var(--ngaf-chat-text-muted) 70%, + transparent 100%); + animation: ngaf-chat-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; + margin-bottom: 8px; + } + .chat-welcome__title { + margin: 0; + font-size: 1.25rem; + font-weight: 500; + color: var(--ngaf-chat-text); + line-height: 1.3; + } + @media (min-width: 768px) { + .chat-welcome__title { font-size: 1.5rem; } + } + .chat-welcome__subtitle { + margin: 0; + font-size: var(--ngaf-chat-font-size-sm); + color: var(--ngaf-chat-text-muted); + line-height: 1.5; + } + .chat-welcome__input { + width: 100%; + margin-top: 0.5rem; + } + .chat-welcome__suggestions { + width: 100%; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0; + } + .chat-welcome__suggestions:empty { display: none; } +`; + +export const CHAT_WELCOME_SUGGESTION_STYLES = ` + :host { display: block; width: 100%; } + .chat-welcome-suggestion { + width: 100%; + display: flex; + align-items: center; + gap: 0.75rem; + padding: 12px 14px; + background: transparent; + border: 0; + border-bottom: 1px solid var(--ngaf-chat-separator); + color: var(--ngaf-chat-text); + font-family: inherit; + font-size: var(--ngaf-chat-font-size-sm); + text-align: left; + cursor: pointer; + transition: background 150ms ease; + } + .chat-welcome-suggestion:hover { background: var(--ngaf-chat-surface-alt); } + .chat-welcome-suggestion:focus-visible { + outline: 2px solid var(--ngaf-chat-text-muted); + outline-offset: -2px; + } + .chat-welcome-suggestion__label { flex: 1 1 auto; } + .chat-welcome-suggestion__chevron { + color: var(--ngaf-chat-text-muted); + font-size: 1.1em; + } +`; +``` + +- [ ] **Step 2: Commit** + +```bash +git add libs/chat/src/lib/styles/chat-welcome.styles.ts +git commit -m "feat(chat): welcome screen + suggestion styles" +``` + +--- + +### Task 3.3: `` (TDD) + +**Files:** +- Create: `libs/chat/src/lib/primitives/chat-welcome/chat-welcome-suggestion.component.spec.ts` +- Create: `libs/chat/src/lib/primitives/chat-welcome/chat-welcome-suggestion.component.ts` + +- [ ] **Step 1: Failing test** + +```ts +// libs/chat/src/lib/primitives/chat-welcome/chat-welcome-suggestion.component.spec.ts +import { describe, it, expect } from 'vitest'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChatWelcomeSuggestionComponent } from './chat-welcome-suggestion.component'; + +describe('ChatWelcomeSuggestionComponent', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = TestBed.createComponent(ChatWelcomeSuggestionComponent); + fixture.componentRef.setInput('label', 'Tell me about yourself'); + fixture.componentRef.setInput('value', 'tell-me'); + fixture.detectChanges(); + }); + + it('renders the label', () => { + const el = fixture.nativeElement as HTMLElement; + expect(el.textContent).toContain('Tell me about yourself'); + }); + + it('emits select with the value on click', () => { + let emitted: string | undefined; + fixture.componentInstance.select.subscribe(v => { emitted = v; }); + const button = (fixture.nativeElement as HTMLElement).querySelector('button')!; + button.click(); + expect(emitted).toBe('tell-me'); + }); +}); +``` + +- [ ] **Step 2: Run, expect fail** + +```bash +npx nx test chat -- chat-welcome-suggestion.component.spec.ts +``` + +Expected: FAIL with "Cannot find module". + +- [ ] **Step 3: Implement** + +```ts +// libs/chat/src/lib/primitives/chat-welcome/chat-welcome-suggestion.component.ts +// SPDX-License-Identifier: MIT +import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_WELCOME_SUGGESTION_STYLES } from '../../styles/chat-welcome.styles'; + +@Component({ + selector: 'chat-welcome-suggestion', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, CHAT_WELCOME_SUGGESTION_STYLES], + template: ` + + `, +}) +export class ChatWelcomeSuggestionComponent { + readonly label = input.required(); + readonly value = input.required(); + readonly select = output(); +} +``` + +- [ ] **Step 4: Run, expect pass** + +```bash +npx nx test chat -- chat-welcome-suggestion.component.spec.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add libs/chat/src/lib/primitives/chat-welcome +git commit -m "feat(chat): chat-welcome-suggestion helper component" +``` + +--- + +### Task 3.4: `` (TDD) + +**Files:** +- Create: `libs/chat/src/lib/primitives/chat-welcome/chat-welcome.component.spec.ts` +- Create: `libs/chat/src/lib/primitives/chat-welcome/chat-welcome.component.ts` + +- [ ] **Step 1: Failing test** + +```ts +// libs/chat/src/lib/primitives/chat-welcome/chat-welcome.component.spec.ts +import { describe, it, expect, beforeEach } from 'vitest'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChatWelcomeComponent } from './chat-welcome.component'; + +describe('ChatWelcomeComponent', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = TestBed.createComponent(ChatWelcomeComponent); + fixture.detectChanges(); + }); + + it('renders default greeting', () => { + const el = fixture.nativeElement as HTMLElement; + expect(el.querySelector('.chat-welcome__title')?.textContent).toContain('How can I help?'); + expect(el.querySelector('.chat-welcome__subtitle')?.textContent).toContain('Ask anything'); + }); + + it('renders the beacon dot', () => { + const el = fixture.nativeElement as HTMLElement; + expect(el.querySelector('.chat-welcome__beacon')).not.toBeNull(); + }); + + it('exposes slots for title, subtitle, input, suggestions', () => { + const el = fixture.nativeElement as HTMLElement; + expect(el.querySelector('.chat-welcome__inner')).not.toBeNull(); + expect(el.querySelector('.chat-welcome__input')).not.toBeNull(); + expect(el.querySelector('.chat-welcome__suggestions')).not.toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run, expect fail** + +```bash +npx nx test chat -- chat-welcome.component.spec.ts +``` + +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```ts +// libs/chat/src/lib/primitives/chat-welcome/chat-welcome.component.ts +// SPDX-License-Identifier: MIT +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_WELCOME_STYLES } from '../../styles/chat-welcome.styles'; + +/** + * Empty-state owner. Renders a centered greeting + slot-projected input + + * optional vertical suggestion rows. Mounted only when the parent chat has + * no messages and welcome is not disabled. + * + * Slots: + * [chatWelcomeTitle] — replaces the default

"How can I help?" + * [chatWelcomeSubtitle] — replaces the default

"Ask anything to get started." + * [chatWelcomeInput] — projects the chat input into the center column + * [chatWelcomeSuggestions] — projects suggestion rows below the input + * + * Host CSS variables (override on :host or any ancestor): + * --ngaf-chat-welcome-max-width default 36rem + * --ngaf-chat-welcome-gap default 1.5rem + * --ngaf-chat-welcome-padding default 24px + */ +@Component({ + selector: 'chat-welcome', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, CHAT_WELCOME_STYLES], + template: ` +

+ + +

How can I help?

+
+ +

Ask anything to get started.

+
+
+
+ +
+
+ `, +}) +export class ChatWelcomeComponent {} +``` + +- [ ] **Step 4: Run, expect pass** + +```bash +npx nx test chat -- chat-welcome.component.spec.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add libs/chat/src/lib/primitives/chat-welcome/chat-welcome.component.ts libs/chat/src/lib/primitives/chat-welcome/chat-welcome.component.spec.ts +git commit -m "feat(chat): chat-welcome empty-state primitive" +``` + +--- + +### Task 3.5: Wire welcome branch into `` composition + +**Files:** +- Modify: `libs/chat/src/lib/compositions/chat/chat.component.ts` + +- [ ] **Step 1: Add imports** + +In the imports list: + +```ts +import { ChatWelcomeComponent } from '../../primitives/chat-welcome/chat-welcome.component'; +``` + +In the `imports: [...]` array of the `@Component` decorator, add `ChatWelcomeComponent`. + +- [ ] **Step 2: Add inputs + computed** + +In the class body, near other inputs: + +```ts +readonly welcomeDisabled = input(false); + +readonly showWelcome = computed(() => { + if (this.welcomeDisabled()) return false; + const a = this.agent() as unknown as { isThreadLoading?: () => boolean }; + if (a.isThreadLoading?.()) return false; + return this.agent().messages().length === 0; +}); +``` + +- [ ] **Step 3: Add welcome branch in template** + +In the template, find the `
` block. Replace it with a parent `@if` switch: + +```html +@if (showWelcome()) { + + + +} @else { +
+ +
+} +``` + +(Keep the existing `
` markup as the `@else` branch verbatim. Move the `` instantiation out of the footer when the welcome is showing.) + +Inside the `@else` branch's footer, the existing `` stays. Welcome and conversation each instantiate their own ``; per spec we accept a fresh remount on the swap (Phase 3 recommendation option 2). + +Also remove the existing `
` block inside `@else`'s scroll body — it's now superseded by ``. + +- [ ] **Step 4: Update CSS** + +Inside the `:host` styles, add a flex-column rule for the welcome direct child case: + +```css +:host > chat-welcome { + display: flex; + flex: 1 1 auto; + width: 100%; +} +``` + +- [ ] **Step 5: Run + build** + +```bash +npx nx test chat +npx nx build chat +``` + +Expected: PASS. (May see deprecation warnings for the removed `chatEmptyState` slot — accept them; consumers using that slot will need to migrate to `chatWelcomeTitle` / `chatWelcomeSubtitle`. Document in PR.) + +- [ ] **Step 6: Commit** + +```bash +git add libs/chat/src/lib/compositions/chat/chat.component.ts +git commit -m "feat(chat): wire chat-welcome branch + showWelcome computed" +``` + +--- + +### Task 3.6: Tests for welcome composition behavior + +**Files:** +- Modify or create: `libs/chat/src/lib/compositions/chat/chat.component.spec.ts` + +- [ ] **Step 1: Add behavior tests** + +Append (or replace) the existing welcome-state assertions with: + +```ts +describe('welcome branch', () => { + it('shows welcome when messages are empty', async () => { + // ... build a fixture with agent.messages() returning [] + // assert el.querySelector('chat-welcome') is not null + // assert el.querySelector('.chat-shell') is null + }); + + it('hides welcome when messages exist', async () => { + // ... fixture with one message + // assert el.querySelector('chat-welcome') is null + // assert el.querySelector('.chat-shell') is not null + }); + + it('hides welcome when welcomeDisabled=true', async () => { + // ... fixture with empty messages, welcomeDisabled=true + // assert el.querySelector('chat-welcome') is null + }); +}); +``` + +(Match the existing test scaffolding style in `chat.component.spec.ts` for fixture setup. If that file doesn't exist yet, create it minimally.) + +- [ ] **Step 2: Run** + +```bash +npx nx test chat -- chat.component.spec.ts +``` + +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add libs/chat/src/lib/compositions/chat/chat.component.spec.ts +git commit -m "test(chat): welcome branch visibility (empty / non-empty / disabled)" +``` + +--- + +### Task 3.7: Public API exports + +**Files:** +- Modify: `libs/chat/src/public-api.ts` + +- [ ] **Step 1: Add exports** + +Append: + +```ts +export { ChatWelcomeComponent } from './lib/primitives/chat-welcome/chat-welcome.component'; +export { ChatWelcomeSuggestionComponent } from './lib/primitives/chat-welcome/chat-welcome-suggestion.component'; +``` + +- [ ] **Step 2: Build chat to verify exports** + +```bash +npx nx build chat +``` + +Expected: PASS. The dist `index.d.ts` should expose the new components. + +- [ ] **Step 3: Commit** + +```bash +git add libs/chat/src/public-api.ts +git commit -m "feat(chat): export ChatWelcome + ChatWelcomeSuggestion components" +``` + +--- + +### Task 3.8: Bump @ngaf/chat to 0.0.15, ship PR + +**Files:** +- Modify: `libs/chat/package.json` + +- [ ] **Step 1: Bump version** + +```bash +sed -i '' 's/"version": "0.0.14"/"version": "0.0.15"/' libs/chat/package.json +``` + +- [ ] **Step 2: Run full check** + +```bash +npx nx run-many -t lint,test,build --projects=chat +``` + +Expected: PASS. + +- [ ] **Step 3: Commit + push** + +```bash +git add libs/chat/package.json +git commit -m "chore(chat): 0.0.14 → 0.0.15" +git push -u origin claude/chat-welcome-screen +``` + +- [ ] **Step 4: Open PR** + +```bash +gh pr create --title "feat(chat): welcome screen primitive" --body "$(cat <<'EOF' +## What +Adds a dedicated primitive that owns the empty-state UX. Replaces the inline placeholder previously embedded in the chat scroll container. + +## Behavior +- Renders centered greeting + projected input + optional suggestion rows +- Visible when agent.messages() is empty AND welcomeDisabled=false AND thread isn't loading +- Hides smoothly once a turn happens; conversation layout takes over + +## Differentiators +- Two-line greeting (h1 + subtitle paragraph) +- Beacon dot above the title with a 2s pulse animation +- Optional helper for vertical action rows +- 200ms fade-in on mount + +## Versions +- @ngaf/chat: 0.0.14 → 0.0.15 + +## Spec +docs/superpowers/specs/2026-05-02-chat-pipeline-redesign-design.md (Phase 3) +EOF +)" +``` + +- [ ] **Step 5: Watch CI** + +```bash +gh pr checks --watch +``` + +Expected: green. + +- [ ] **Step 6: Merge + tag** + +```bash +gh pr merge --squash --delete-branch +git fetch origin main +git tag chat-v0.0.15 origin/main +git push origin chat-v0.0.15 +``` + +--- + +## Self-review notes + +- **Spec coverage**: All three active phases mapped to task sets. Phase 4 explicitly deferred per spec. +- **Constraint enforcement**: No copilotkit / inspirational-library references in any task body, code, or commit message. +- **Type consistency**: `ChatWelcomeComponent`, `ChatWelcomeSuggestionComponent`, `classifiers: Map`, `showWelcome: Signal`, `welcomeDisabled: InputSignal` consistent across tasks. +- **Test before code**: Every new module follows write-test → run-fail → implement → run-pass. +- **Exact commands**: Every step that runs a tool gives the exact command + expected outcome. +- **Diagnose-after-rewrite**: Task 2.9 explicitly handles the open-ended long-stream diagnostic phase per spec; Findings appendix updated as part of that task. diff --git a/docs/superpowers/specs/2026-05-02-chat-pipeline-redesign-design.md b/docs/superpowers/specs/2026-05-02-chat-pipeline-redesign-design.md new file mode 100644 index 000000000..1e8a9a672 --- /dev/null +++ b/docs/superpowers/specs/2026-05-02-chat-pipeline-redesign-design.md @@ -0,0 +1,384 @@ +# Chat Pipeline Redesign + +**Status:** Design • **Date:** 2026-05-02 • **Owner:** brian@brianflove.com + +## Summary + +Redesign three connected pieces of the @ngaf chat library: + +1. Extract a standalone partial-JSON streaming parser (`@cacheplane/partial-json`) shared by @ngaf/chat and a separate downstream project (pretable). +2. Replace the @ngaf/chat streaming-markdown renderer with a simpler, RAF-batched full-reparse approach. Delete the existing append-only incremental renderer. +3. Add a dedicated welcome-screen primitive that owns the empty-state UX (centered greeting + centered input + optional suggestions), distinct from the conversation layout. + +Phases ship in dependency order. Phase 4 (migrating pretable to consume the new package) is deferred to a separate session and provided as a self-contained prompt. + +## Goals + +- Reduce streaming-related complexity in @ngaf/chat by deleting the bespoke append-only renderer (~200 LOC) and replacing it with a ~30 LOC RAF-batched reparse component. +- Eliminate the class of bugs caused by negative-delta math in the incremental renderer (silent freeze when content is shorter than the previous emission). +- Share the partial-JSON parser between @ngaf/chat and the downstream tabular project so improvements (validation, performance, structural sharing) accrue to both. +- Give @ngaf/chat a distinctive empty-state UX so consumers don't see a generic "How can I help?" placeholder hugging the bottom of the screen. +- Keep @ngaf/chat's public surface mostly stable; consumers should update import paths once and experience no behavior regressions. + +## Non-goals + +- Changing the LLM wire format @ngaf/chat assumes (still: free-form text via langgraph adapter; structured JSON envelope is a future option, not part of this redesign). +- Replacing `marked` with a different markdown parser. +- Worker-thread parsing. +- Virtualized message list for very long histories. +- Time-of-day greeting variation, animated typed-out title, persistent dismiss for the welcome screen. + +## Architecture + +``` +phase 1 ─────────────────────────────────────────────── + github.com/cacheplane/partial-json (new repo) + ├── npm: @cacheplane/partial-json@1.0.0 + ├── tokenizer: pretable's strict state machine + ├── api: events + getByPath + materialize (from @ngaf/partial-json) + └── tests: union of both projects' suites (~70+ cases) + +phase 2 ─────────────────────────────────────────────── + @ngaf/chat 0.0.14 + ├── deps: + @cacheplane/partial-json + ├── delete: libs/chat/src/lib/streaming/streaming-markdown.ts + ├── rewrite: chat-streaming-md.component.ts (RAF-batched reparse) + ├── update: content-classifier.ts (import @cacheplane/partial-json) + ├── update: parse-tree-store.ts (import @cacheplane/partial-json) + ├── rekey: chat.component.ts classifiers Map by message.id + └── add: localStorage NGAF_CHAT_STREAM_TRACE harness + + @ngaf/a2ui 0.0.3, @ngaf/render 0.0.3 + └── update: @ngaf/partial-json -> @cacheplane/partial-json + + @ngaf/partial-json 0.0.2 (frozen) + └── deprecated; no further versions published + +phase 3 ─────────────────────────────────────────────── + @ngaf/chat 0.0.15 + ├── new: libs/chat/src/lib/primitives/chat-welcome/ + │ ├── chat-welcome.component.ts (greeting + slots) + │ ├── chat-welcome-suggestion.component.ts (helper row) + │ └── styles + └── update: libs/chat/src/lib/compositions/chat/chat.component.ts + └── empty-state branch renders welcome (not scroll container) + +phase 4 ─────────────────────────────────────────────── + Deferred. Self-contained prompt at: + docs/superpowers/context/2026-05-02-pretable-partial-json-migration.md +``` + +## Phase 1 — `@cacheplane/partial-json` + +### Repo + +New repo `github.com/cacheplane/partial-json`. Pure TypeScript library. ESM + CJS. Tests run on Node. Standard CI: lint + test + build + npm publish via OIDC. + +### Authoring base + +Combine the strengths of the two existing parsers: + +| Layer | Source | Justification | +|---|---|---| +| Tokenizer / state machine | pretable/json-stream/handlers.ts | Stricter validation: errors on invalid escape, control chars, malformed unicode; correctly handles partial keywords (e.g. `tru` is buffered, not eagerly completed as a boolean). | +| Identity preservation | pretable/json-stream/internals.ts | `preserveArrayValue`, `preserveObjectValue`, `propagateResolved` already implemented. Returns old object reference when content unchanged. | +| `finish()` | pretable/json-stream | Closes open primitives at stream end and validates resulting tree. Missing in @ngaf today. | +| Event API (`node-created`, `value-updated` with delta, `node-completed`) | @ngaf/partial-json/parser.ts | Useful for downstream consumers that want push notifications instead of polling the tree. | +| `getByPath(path)` (RFC 6901 JSON Pointer subset) | @ngaf/partial-json/parser.ts | Convenient for content-classifier and consumers that know the schema. | +| `materialize()` with WeakMap structural sharing | @ngaf/partial-json/materialize.ts | Stable references for `===` checks in downstream rendering. | + +Result: ~2000 LOC superset, public API: + +```ts +// Pull-style (pretable) +import { create, push, finish, resolve } from '@cacheplane/partial-json'; +let state = create(); +state = push(state, chunk); +state = finish(state); +const value = resolve(state); // JsonValue | undefined + +// Push-style (ngaf) +import { createPartialJsonParser, materialize } from '@cacheplane/partial-json'; +const parser = createPartialJsonParser(); +const events = parser.push(chunk); // ParseEvent[] +const root = parser.root; // JsonNode | null +const node = parser.getByPath('/items/1/id'); +const value = materialize(parser.root); +``` + +Both APIs share the same internal node graph; pull-style users can operate on `state.nodes`, push-style users can operate on `parser.root`. + +### Versioning + +Start at `1.0.0`. Both source parsers are mature. Public API is documented and tested. Future breaking changes go through a deprecation cycle with `@deprecated` JSDoc + warnings before any major bump. + +### Test suite + +Union of: +- pretable's existing tests (strict validation, error cases) +- @ngaf/partial-json's existing tests (event ordering, getByPath, materialize) +- New cross-cutting tests (parity between pull and push APIs, finish() semantics, deep nesting, scientific notation, dangling commas, all the partial-keyword cases pretable handles correctly) + +Aim for >95% line coverage on the parser and 100% on the public API surface. + +### Migration of @ngaf/* consumers + +``` +libs/chat/src/lib/streaming/content-classifier.ts:4 +libs/chat/src/lib/streaming/parse-tree-store.ts:4-5 +libs/chat/src/lib/streaming/parse-tree-store.spec.ts:4 +(plus libs/a2ui, libs/render — confirm with grep) +``` + +Find-and-replace `@ngaf/partial-json` → `@cacheplane/partial-json` in source. Update package.json `peerDependencies`. Run full test suite; expected to pass without code changes since the API is preserved. + +### `@ngaf/partial-json` lifecycle + +Frozen at 0.0.2. Add `"deprecated"` field to package.json: + +> Replaced by `@cacheplane/partial-json`. No further versions will be published. + +Repo subdirectory `libs/partial-json/` may eventually be deleted in a separate cleanup PR; leave it for now to avoid a cascade. + +## Phase 2 — Chat streaming rewrite + +### Files deleted + +- `libs/chat/src/lib/streaming/streaming-markdown.ts` (~200 LOC append-only DOM renderer) +- `libs/chat/src/lib/streaming/streaming-markdown.spec.ts` (its tests; replaced by tests on the new renderer) + +### `chat-streaming-md.component.ts` (rewritten) + +Replaces the existing 80-LOC component with a ~30-LOC version: + +- One `effect()` reading `content()` and `streaming()` signals +- `requestAnimationFrame` schedules a single render per frame +- Coalesces multiple signal updates into the latest content +- Renders by computing `marked.parse(content)` synchronously and writing the result via `el.innerHTML` (sanitized through DomSanitizer) +- No `lastContent`, no delta math, no append-only state +- Idempotent and side-effect-free per frame +- On `streaming()` flipping false, no special "final swap" logic — it's just one more render + +Pseudocode: + +```ts +private rafHandle = 0; +private pendingContent = ''; + +constructor() { + effect(() => { + this.pendingContent = this.content(); + if (this.rafHandle) return; + this.rafHandle = requestAnimationFrame(() => { + this.rafHandle = 0; + const html = renderMarkdownToString(this.pendingContent, this.sanitizer); + this.el.innerHTML = html; + }); + }); + + inject(DestroyRef).onDestroy(() => { + if (this.rafHandle) cancelAnimationFrame(this.rafHandle); + }); +} +``` + +### Classifier map keyed by id + +`libs/chat/src/lib/compositions/chat/chat.component.ts`: + +```ts +private readonly classifiers = new Map(); +``` + +`classifyMessage(content, message)` looks up by `message.id`. A janitor effect compares the current `agent.messages()` with the map keys and disposes classifiers for messages no longer present. + +### Trace harness + +Single internal flag in `libs/chat/src/lib/streaming/trace.ts`: + +```ts +function isTraceEnabled(): boolean { + if (typeof globalThis === 'undefined') return false; + const win = (globalThis as any).window; + if (!win) return false; + if ((win as any).__ngafChatTrace === true) return true; + try { return win.localStorage?.getItem('NGAF_CHAT_STREAM_TRACE') === '1'; } catch { return false; } +} +export function trace(...args: unknown[]): void { + if (isTraceEnabled()) console.debug('[ngaf-chat-stream]', ...args); +} +``` + +Call sites (all guarded by `if (isTraceEnabled())` to skip allocation in the off path): + +- `chat-streaming-md` rendered: `{ frame, contentLength, durationMs }` +- `content-classifier.update()`: `{ messageId, contentLength, branch }` +- `chat.component` classifyMessage hits/misses +- `langgraph` stream-manager.bridge messages-tuple events: `{ id, contentLength, mode }` +- `langgraph` values-event sync: `{ existingLength, incomingLength, mergedLength, idsPreserved }` + +Off-path is a single boolean check + early return, dead-code-eliminated by Terser when used inside `if (isTraceEnabled()) ...`. + +### Reproduce + diagnose long-stream symptom + +After the rewrite ships, deliberately reproduce the user-reported "streaming not working on long outputs" symptom with a controlled prompt: + +> "Write 800 words about coral reefs. Use three markdown headings. Include one fenced code block per section." + +Capture the trace, MutationObserver log, and network event stream. Categorize the symptom: + +- Stall mid-stream → likely throttle/RAF scheduling, fix in `agent.fn` throttle config +- Visible truncation → likely id swap or merge edge case, instrument bridge +- Markdown breaks → likely already fixed by the rewrite (full reparse, no incremental state) +- Tokens drop → likely throttle window, lower to 8ms or remove +- Final state correct, intermediate broken → already fixed by the rewrite + +Whatever the category, fix it under the same milestone. Document the cause in the spec's "Findings" appendix below. + +### Tests + +- New: `chat-streaming-md.component.spec.ts` — covers RAF batching, idempotent renders, dispose-cleanup +- New: regression test exercising content shrink (a previous bug class) +- Updated: `content-classifier.spec.ts`, `parse-tree-store.spec.ts` — adjust imports + +### `@ngaf/chat` version + +`0.0.14` for the streaming rewrite milestone. + +## Phase 3 — Welcome screen + +### `` primitive + +`libs/chat/src/lib/primitives/chat-welcome/chat-welcome.component.ts`. Standalone, OnPush. Public host CSS variables: + +``` +--ngaf-chat-welcome-max-width (default: 36rem) +--ngaf-chat-welcome-gap (default: 1.5rem) +--ngaf-chat-welcome-padding (default: 24px) +``` + +Template: + +```html +
+ + +

How can I help?

+
+ +

Ask anything to get started.

+
+
+
+ +
+
+``` + +### Differentiating visual elements + +- **Two-line greeting**: h1 (1.25rem mobile / 1.5rem desktop, weight 500) plus a smaller subtitle paragraph in muted color. Avoids the single-giant-h1 look. +- **Beacon dot**: a 16x16 dot positioned 8px above the title, animated with the same 2s cubic-bezier pulse curve as our streaming caret. Quietly signals "ready to listen". Implementation: a `` with a radial-gradient background and an `animation: ngaf-chat-pulse 2s ...` rule. +- **Vertical suggestion rows**: optional helper component `` renders a left-aligned row with leading icon + label + chevron, separated by hairlines. Reads like a recommended-actions list, not pill tags. Consumer can opt out by projecting their own content into `[chatWelcomeSuggestions]`. +- **Fade-in mount**: 200ms opacity 0 → 1 + 8px translateY (-8 → 0) on first mount. + +### `` helper + +```html + +``` + +Inputs: `label: string`, `value: string`. Output: `select: EventEmitter`. + +### Composition change in `` + +Today: empty-state is a hidden `
` inside the scroll container, displayed when `agent.messages().length === 0`. + +After: an upstream `@if (showWelcome())` branch decides. When true, the scroll container is not rendered; instead `` is rendered with the existing `` projected into its `[chatWelcomeInput]` slot. When false (any messages exist, or welcome explicitly disabled, or thread is loading), the normal scroll layout renders. + +```ts +readonly welcomeDisabled = input(false); +readonly showWelcome = computed(() => { + if (this.welcomeDisabled()) return false; + if (this.agent().isThreadLoading?.()) return false; + return this.agent().messages().length === 0; +}); +``` + +### Welcome → conversation transition + +When `showWelcome()` flips false, the welcome branch unmounts and the scroll branch mounts. The `` is projected into both branches; we need to ensure its internal state (text being typed, focus, composition) survives the swap. Two options: + +1. Keep the `` as a sibling of both branches and use CSS positioning to move it (centered → bottom-anchored) based on a host attribute. +2. Accept a fresh remount; users almost always click send before the swap, so internal state loss is rarely visible. + +**Recommendation**: option 2 for simplicity. If users report issues with mid-compose state loss, revisit with option 1 in a follow-up. + +### `welcomeDisabled` and configuration + +Single boolean input on ``. Consumer-controlled (resumed thread, custom empty UX, embedded contexts where the welcome is inappropriate). + +### Tests + +- `chat-welcome.component.spec.ts` — render, slot projection, beacon animation present +- `chat-welcome-suggestion.component.spec.ts` — emit on click, label render +- `chat.component.spec.ts` — welcome shows on empty messages, hides when messages exist, hides when `welcomeDisabled=true` + +### `@ngaf/chat` version + +`0.0.15` for the welcome screen milestone. + +## Phase 4 — Pretable migration (deferred) + +Pretable currently maintains its own copy of the parser. After Phase 1 lands, pretable can migrate to consume `@cacheplane/partial-json` and delete its local copy. + +Deferred to a separate session because: + +- It's an unrelated repo (separate ownership context) +- The migration is mechanical once Phase 1 is live +- Doing it inline expands this milestone's scope unnecessarily + +A self-contained prompt for that session is committed at `docs/superpowers/context/2026-05-02-pretable-partial-json-migration.md`. The prompt includes: current state of pretable's `packages/json-stream/`, the new package's API, file-by-file migration steps, and a test plan. + +## Risk assessment + +| Risk | Likelihood | Mitigation | +|---|---|---| +| The "streaming not working on long outputs" symptom is unrelated to the renderer rewrite | Medium | Trace harness lands in Phase 2 to isolate the actual cause. If the rewrite alone doesn't fix it, the trace data tells us what to fix next. | +| @cacheplane/partial-json API drift breaks consumers | Low | Phase 1 includes parity tests against both source projects' existing test suites before any consumer migration. | +| Chat-input state loss on welcome → conversation swap is jarring | Low-Medium | Phase 3 ships option 2 (accept remount). User feedback determines whether option 1 (sibling positioning) is needed. | +| Welcome screen doesn't differentiate enough from inspirations | Low | Beacon + vertical suggestion rows + fade-in mount + 2-line greeting are all distinct enough. Consumer slot projection lets app authors customize further. | +| Pretable migration prompt becomes stale | Medium | Date-stamped doc; reviewer of the deferred session reads the current state of both repos before acting. | + +## Findings appendix + +Diagnosed during Phase 2 reproduction (2026-05-02) against a real LangGraph backend with the trace harness enabled. + +**Symptom**: previously reported as "streaming is not working for long outputs". After the rewrite, an ~800-word response (5092 chars) renders end-to-end without DOM teardown, classifier desync, or visible truncation. + +**Trace counts for an 800-word response:** + +| Event | Count | Note | +|---|---|---| +| `bridge.messages-tuple` | 982 | One per streamed token (expected) | +| `bridge.values-sync` | 2 | Initial state + final (sane) | +| `classifier.update` | 9 | Angular `@let` recomputes only when its expression's input identity changes — message-id identity preservation in the langgraph bridge keeps the Message reference stable, so `@let content = messageContent(message)` doesn't re-evaluate per token | +| `streaming-md.flush` | 1 | RAF coalesced 9 content updates into a single render frame because they arrived before RAF fired | + +**Root cause of original symptom (pre-rewrite)**: The bespoke append-only renderer's `delta = content.slice(lastContent.length)` silently froze whenever `content.length < lastContent.length`. On long streams, intermediate values events would briefly shrink content, the renderer would compute a negative/empty delta, and the DOM would freeze for the rest of the stream. + +**Fix**: replaced with RAF-batched full-reparse component — idempotent within a frame, never reads `lastContent`, immune to content shrinking. The classifier-by-id rekey + janitor prevents stale-state bleed across messages. + +**Side observation**: the low `streaming-md.flush` count (1 per long stream) is a feature, not a bug — RAF coalesces content updates that arrive faster than 60Hz into single frames. The user perceives the rendered DOM, not the trace count. Visual smoothness during streaming is bounded by how often content changes shape semantically (paragraph breaks, list items, code fences open/close), not how many tokens arrive. + +**Open follow-up**: the identity preservation in the bridge is so aggressive that the chat composition's `@let content = messageContent(message)` only re-evaluates ~9 times per long stream. If consumers report a "jumpy" feel during long streams (i.e. visible jumps rather than smooth token-by-token text growth), revisit by either weakening identity preservation or threading a content-only signal through the chat-streaming-md path (bypassing the @let recomputation gate). + +**Regression test added**: `streaming-markdown.component.spec.ts` — "handles content shrinking without freezing" exercises the failure path that the negative-delta math would have hit. + +## Open questions + +None. All scope decisions answered during brainstorming. diff --git a/libs/chat/ng-package.json b/libs/chat/ng-package.json index 7f50f48d1..1ea1fd6ef 100644 --- a/libs/chat/ng-package.json +++ b/libs/chat/ng-package.json @@ -4,7 +4,7 @@ "lib": { "entryFile": "src/public-api.ts" }, - "allowedNonPeerDependencies": [], + "allowedNonPeerDependencies": ["@cacheplane/partial-json"], "assets": [ { "input": "src/lib/styles", "glob": "chat.css", "output": "." } ] diff --git a/libs/chat/package.json b/libs/chat/package.json index a28577eb7..f967657c2 100644 --- a/libs/chat/package.json +++ b/libs/chat/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/chat", - "version": "0.0.13", + "version": "0.0.14", "exports": { ".": { "types": "./index.d.ts", @@ -12,6 +12,9 @@ }, "./chat.css": "./chat.css" }, + "dependencies": { + "@cacheplane/partial-json": "^0.1.1" + }, "peerDependencies": { "@angular/core": "^20.0.0 || ^21.0.0", "@angular/common": "^20.0.0 || ^21.0.0", @@ -20,7 +23,6 @@ "@ngaf/licensing": "*", "@ngaf/render": "*", "@ngaf/a2ui": "*", - "@ngaf/partial-json": "*", "@json-render/core": "^0.16.0", "@langchain/core": "^1.1.33", "rxjs": "~7.8.0", diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index 4cab37961..6803f5247 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -115,7 +115,7 @@ import type { ChatRenderEvent } from './chat-render-event'; @let content = messageContent(message); - @let classified = classifyMessage(content, i); + @let classified = classifyMessage(content, message); (); + private readonly classifiers = new Map(); private readonly destroyRef = inject(DestroyRef); private eventsSubscribed = false; @@ -255,6 +255,24 @@ export class ChatComponent { el.scrollTop = el.scrollHeight; } }); + + effect(() => { + // janitor: drop classifiers for messages no longer in the agent's list + let liveIds: Set; + try { + liveIds = new Set(); + for (const m of this.agent().messages()) { + const id = (m as unknown as { id?: string }).id; + if (id) liveIds.add(id); + } + } catch { return; } + for (const key of [...this.classifiers.keys()]) { + if (!liveIds.has(key)) { + this.classifiers.get(key)?.dispose(); + this.classifiers.delete(key); + } + } + }); } prevRole(index: number): ChatMessageRole | undefined { @@ -269,9 +287,13 @@ export class ChatComponent { return undefined; } - classifyMessage(content: string, index: number): ContentClassifier { - let c = this.classifiers.get(index); - if (!c) { c = createContentClassifier(); this.classifiers.set(index, c); } + classifyMessage(content: string, message: { id?: string }): ContentClassifier { + const id = message.id ?? ''; + let c = this.classifiers.get(id); + if (!c) { + c = createContentClassifier(); + this.classifiers.set(id, c); + } c.update(content); return c; } diff --git a/libs/chat/src/lib/streaming/content-classifier.ts b/libs/chat/src/lib/streaming/content-classifier.ts index 5318427c2..e4bfc4ad5 100644 --- a/libs/chat/src/lib/streaming/content-classifier.ts +++ b/libs/chat/src/lib/streaming/content-classifier.ts @@ -1,11 +1,12 @@ // SPDX-License-Identifier: MIT import { signal, untracked, type Signal } from '@angular/core'; import type { Spec } from '@json-render/core'; -import { createPartialJsonParser } from '@ngaf/partial-json'; +import { createPartialJsonParser } from '@cacheplane/partial-json'; import { createParseTreeStore, type ElementAccumulationState, type ParseTreeStore } from './parse-tree-store'; import { createA2uiMessageParser, type A2uiMessageParser } from '@ngaf/a2ui'; import type { A2uiSurface } from '@ngaf/a2ui'; import { createA2uiSurfaceStore, type A2uiSurfaceStore } from '../a2ui/surface-store'; +import { isTraceEnabled, trace } from './trace'; export type ContentType = 'undetermined' | 'markdown' | 'json-render' | 'a2ui' | 'mixed'; @@ -179,6 +180,10 @@ export function createContentClassifier(): ContentClassifier { } } } + + if (isTraceEnabled()) { + trace('classifier.update', { contentLength: content.length, type: typeSignal() }); + } }); } diff --git a/libs/chat/src/lib/streaming/parse-tree-store.spec.ts b/libs/chat/src/lib/streaming/parse-tree-store.spec.ts index a0efa08ba..1398662d7 100644 --- a/libs/chat/src/lib/streaming/parse-tree-store.spec.ts +++ b/libs/chat/src/lib/streaming/parse-tree-store.spec.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT import { describe, it, expect } from 'vitest'; import { TestBed } from '@angular/core/testing'; -import { createPartialJsonParser } from '@ngaf/partial-json'; +import { createPartialJsonParser } from '@cacheplane/partial-json'; import type { Spec } from '@json-render/core'; import { createParseTreeStore } from './parse-tree-store'; diff --git a/libs/chat/src/lib/streaming/parse-tree-store.ts b/libs/chat/src/lib/streaming/parse-tree-store.ts index 1af371161..ef669d665 100644 --- a/libs/chat/src/lib/streaming/parse-tree-store.ts +++ b/libs/chat/src/lib/streaming/parse-tree-store.ts @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT import { signal, type Signal } from '@angular/core'; import type { Spec } from '@json-render/core'; -import type { PartialJsonParser, JsonObjectNode } from '@ngaf/partial-json'; -import { materialize } from '@ngaf/partial-json'; +import type { PartialJsonParser, JsonObjectNode } from '@cacheplane/partial-json'; +import { materialize } from '@cacheplane/partial-json'; export interface ElementAccumulationState { hasType: boolean; diff --git a/libs/chat/src/lib/streaming/streaming-markdown.component.spec.ts b/libs/chat/src/lib/streaming/streaming-markdown.component.spec.ts new file mode 100644 index 000000000..9104c7bcd --- /dev/null +++ b/libs/chat/src/lib/streaming/streaming-markdown.component.spec.ts @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: MIT +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ElementRef, Injector, runInInjectionContext } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ChatStreamingMdComponent } from './streaming-markdown.component'; +import '../../test-setup'; + +// Signal-input components can't be exercised via TestBed.createComponent + +// componentRef.setInput() under vitest JIT (Angular's JIT compiler does not +// process signal-input metadata, so setInput throws NG0303 — the same reason +// chat-trace, chat-suggestions, and chat-typing-indicator specs in this +// library avoid template-driven signal inputs). Instead we instantiate the +// component inside an injection context with a real DOM host element and +// drive its input by writing to the InputSignal's underlying signal node. + +function setSignalInput(sig: unknown, value: T): void { + const obj = sig as Record; + const signalSymbol = Object.getOwnPropertySymbols(obj).find( + (s) => s.description === 'SIGNAL', + ); + if (!signalSymbol) throw new Error('Could not find SIGNAL symbol on input'); + const node = obj[signalSymbol] as { + applyValueToInputSignal?: (n: unknown, v: T) => void; + value?: T; + }; + if (typeof node.applyValueToInputSignal === 'function') { + node.applyValueToInputSignal(node, value); + } else { + node.value = value; + } +} + +function flushRaf(): Promise { + return new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); +} + +interface Fixture { + component: ChatStreamingMdComponent; + host: HTMLElement; + destroy: () => void; +} + +function makeFixture(): Fixture { + const host = document.createElement('div'); + document.body.appendChild(host); + TestBed.configureTestingModule({ + providers: [{ provide: ElementRef, useValue: new ElementRef(host) }], + }); + const injector = TestBed.inject(Injector); + let component!: ChatStreamingMdComponent; + runInInjectionContext(injector, () => { + component = new ChatStreamingMdComponent(); + }); + return { + component, + host, + destroy: () => { + TestBed.resetTestingModule(); + host.remove(); + }, + }; +} + +describe('ChatStreamingMdComponent', () => { + let fixture: Fixture; + + beforeEach(() => { + fixture = makeFixture(); + setSignalInput(fixture.component.content, ''); + }); + + it('renders markdown into innerHTML on first content', async () => { + setSignalInput(fixture.component.content, '# Heading'); + await flushRaf(); + const el = fixture.host; + expect(el.innerHTML).toContain(' { + setSignalInput(fixture.component.content, '# A'); + setSignalInput(fixture.component.content, '# AB'); + setSignalInput(fixture.component.content, '# ABC'); + await flushRaf(); + const el = fixture.host; + expect(el.innerHTML).toContain('ABC'); + }); + + it('handles content shrinking without freezing (regression)', async () => { + setSignalInput(fixture.component.content, '# Long heading'); + await flushRaf(); + setSignalInput(fixture.component.content, '# Short'); + await flushRaf(); + const el = fixture.host; + expect(el.innerHTML).toContain('Short'); + expect(el.innerHTML).not.toContain('Long heading'); + }); + + it('cleans up pending RAF on destroy', async () => { + const spy = vi.spyOn(globalThis, 'cancelAnimationFrame'); + setSignalInput(fixture.component.content, '# X'); + fixture.destroy(); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); +}); diff --git a/libs/chat/src/lib/streaming/streaming-markdown.component.ts b/libs/chat/src/lib/streaming/streaming-markdown.component.ts index 16685b530..ee5f9b5bb 100644 --- a/libs/chat/src/lib/streaming/streaming-markdown.component.ts +++ b/libs/chat/src/lib/streaming/streaming-markdown.component.ts @@ -1,24 +1,26 @@ +// libs/chat/src/lib/streaming/streaming-markdown.component.ts // SPDX-License-Identifier: MIT import { Component, - input, - effect, + ChangeDetectionStrategy, + DestroyRef, ElementRef, + effect, inject, - ChangeDetectionStrategy, + input, untracked, } from '@angular/core'; import { DomSanitizer } from '@angular/platform-browser'; -import { createStreamingMarkdownRenderer, type StreamingMarkdownRenderer } from './streaming-markdown'; import { renderMarkdownToString } from './markdown-render'; +import { isTraceEnabled, trace } from './trace'; /** - * Renders markdown content using a streaming append-only DOM renderer - * during active streaming, then switches to a full marked.parse() render - * once the content stabilises (no new content for a frame). + * Renders markdown content via marked.parse + sanitized innerHTML, coalesced + * to one render per animation frame. No incremental renderer state, no delta + * math — just write the latest content. Idempotent within a frame. * - * This eliminates the jank caused by full innerHTML replacement on every - * SSE token — the streaming renderer only appends new DOM nodes. + * The `streaming` input is informational (it can drive parent-level decisions + * like showing a caret), but doesn't change the render strategy here. */ @Component({ selector: 'chat-streaming-md', @@ -28,55 +30,49 @@ import { renderMarkdownToString } from './markdown-render'; styles: `:host { display: block; }`, }) export class ChatStreamingMdComponent { - private readonly el = inject(ElementRef).nativeElement as HTMLElement; - private readonly sanitizer = inject(DomSanitizer); - - /** Full markdown content (updated on every partial) */ readonly content = input.required(); - /** Whether the parent stream is still loading */ readonly streaming = input(false); - private renderer: StreamingMarkdownRenderer | null = null; - private lastContent = ''; - private finalised = false; + private readonly el = inject(ElementRef).nativeElement as HTMLElement; + private readonly sanitizer = inject(DomSanitizer); + private readonly destroyRef = inject(DestroyRef); + + private rafHandle = 0; + private pendingContent = ''; constructor() { effect(() => { - const content = this.content(); - const isStreaming = this.streaming(); - - untracked(() => this.render(content, isStreaming)); + const next = this.content(); + untracked(() => this.schedule(next)); }); - } - - private render(content: string, isStreaming: boolean): void { - if (!content) return; - if (isStreaming) { - // Streaming mode: use append-only renderer with deltas - if (!this.renderer) { - this.renderer = createStreamingMarkdownRenderer(); - this.el.textContent = ''; - this.el.appendChild(this.renderer.container); - this.finalised = false; + this.destroyRef.onDestroy(() => { + if (this.rafHandle) { + cancelAnimationFrame(this.rafHandle); + this.rafHandle = 0; } + }); + } - // Compute delta from last known content - const delta = content.slice(this.lastContent.length); - this.lastContent = content; - - if (delta) { - this.renderer.push(delta); - } - } else { - // Stream complete: do a single high-quality marked.parse() render - if (!this.finalised || content !== this.lastContent) { - this.lastContent = content; - this.finalised = true; - this.renderer = null; + private schedule(content: string): void { + this.pendingContent = content; + if (this.rafHandle !== 0) return; + this.rafHandle = requestAnimationFrame(() => { + this.rafHandle = 0; + this.flush(); + }); + } - this.el.innerHTML = renderMarkdownToString(content, this.sanitizer); - } + private flush(): void { + const content = this.pendingContent; + if (!content) { + this.el.innerHTML = ''; + return; + } + const start = isTraceEnabled() ? performance.now() : 0; + this.el.innerHTML = renderMarkdownToString(content, this.sanitizer); + if (isTraceEnabled()) { + trace('streaming-md.flush', { contentLength: content.length, durationMs: performance.now() - start }); } } } diff --git a/libs/chat/src/lib/streaming/streaming-markdown.spec.ts b/libs/chat/src/lib/streaming/streaming-markdown.spec.ts deleted file mode 100644 index cb46402da..000000000 --- a/libs/chat/src/lib/streaming/streaming-markdown.spec.ts +++ /dev/null @@ -1,405 +0,0 @@ -// SPDX-License-Identifier: MIT -import { describe, it, expect, beforeEach } from 'vitest'; -import { - createStreamingMarkdownRenderer, - type StreamingMarkdownRenderer, -} from './streaming-markdown'; - -describe('StreamingMarkdownRenderer', () => { - let renderer: StreamingMarkdownRenderer; - - beforeEach(() => { - renderer = createStreamingMarkdownRenderer(); - }); - - describe('container', () => { - it('should have class chat-md', () => { - expect(renderer.container.className).toBe('chat-md'); - }); - - it('should be a div element', () => { - expect(renderer.container.tagName).toBe('DIV'); - }); - }); - - describe('plain text renders as paragraph', () => { - it('should wrap plain text in a

tag', () => { - renderer.push('Hello world'); - renderer.finish(); - const p = renderer.container.querySelector('p'); - expect(p).not.toBeNull(); - expect(p!.textContent).toBe('Hello world'); - }); - - it('should create separate paragraphs for text separated by blank lines', () => { - renderer.push('First paragraph\n\nSecond paragraph'); - renderer.finish(); - const paragraphs = renderer.container.querySelectorAll('p'); - expect(paragraphs).toHaveLength(2); - expect(paragraphs[0].textContent).toBe('First paragraph'); - expect(paragraphs[1].textContent).toBe('Second paragraph'); - }); - - it('should join consecutive non-blank lines in the same paragraph', () => { - renderer.push('Line one\nLine two'); - renderer.finish(); - const paragraphs = renderer.container.querySelectorAll('p'); - expect(paragraphs).toHaveLength(1); - expect(paragraphs[0].textContent).toBe('Line one Line two'); - }); - }); - - describe('bold and italic inline formatting', () => { - it('should render **text** as ', () => { - renderer.push('This is **bold** text'); - renderer.finish(); - const strong = renderer.container.querySelector('strong'); - expect(strong).not.toBeNull(); - expect(strong!.textContent).toBe('bold'); - }); - - it('should render *text* as ', () => { - renderer.push('This is *italic* text'); - renderer.finish(); - const em = renderer.container.querySelector('em'); - expect(em).not.toBeNull(); - expect(em!.textContent).toBe('italic'); - }); - - it('should handle bold and italic in the same line', () => { - renderer.push('**bold** and *italic*'); - renderer.finish(); - expect(renderer.container.querySelector('strong')!.textContent).toBe('bold'); - expect(renderer.container.querySelector('em')!.textContent).toBe('italic'); - }); - - it('should handle nested bold inside text', () => { - renderer.push('Start **middle** end'); - renderer.finish(); - const p = renderer.container.querySelector('p')!; - expect(p.innerHTML).toBe('Start middle end'); - }); - }); - - describe('headers (h1-h4)', () => { - it('should render # as h1', () => { - renderer.push('# Heading 1'); - renderer.finish(); - const h1 = renderer.container.querySelector('h1'); - expect(h1).not.toBeNull(); - expect(h1!.textContent).toBe('Heading 1'); - }); - - it('should render ## as h2', () => { - renderer.push('## Heading 2'); - renderer.finish(); - const h2 = renderer.container.querySelector('h2'); - expect(h2).not.toBeNull(); - expect(h2!.textContent).toBe('Heading 2'); - }); - - it('should render ### as h3', () => { - renderer.push('### Heading 3'); - renderer.finish(); - const h3 = renderer.container.querySelector('h3'); - expect(h3).not.toBeNull(); - expect(h3!.textContent).toBe('Heading 3'); - }); - - it('should render #### as h4', () => { - renderer.push('#### Heading 4'); - renderer.finish(); - const h4 = renderer.container.querySelector('h4'); - expect(h4).not.toBeNull(); - expect(h4!.textContent).toBe('Heading 4'); - }); - - it('should support inline formatting in headers', () => { - renderer.push('## A **bold** heading'); - renderer.finish(); - const h2 = renderer.container.querySelector('h2')!; - expect(h2.querySelector('strong')!.textContent).toBe('bold'); - }); - }); - - describe('unordered and ordered lists', () => { - it('should render - items as