Skip to content

refactor(ai): Inspector AI Assistant Architecture V1 (draft)#357

Draft
dobrinyonkov wants to merge 45 commits into
masterfrom
refactor/ai-assistant-architecture-v1
Draft

refactor(ai): Inspector AI Assistant Architecture V1 (draft)#357
dobrinyonkov wants to merge 45 commits into
masterfrom
refactor/ai-assistant-architecture-v1

Conversation

@dobrinyonkov

@dobrinyonkov dobrinyonkov commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Refactors the AI Assistant tab from a single 1500-line module into five small modules with explicit boundaries. Production behaviour is unchanged. The point is to make the code easier to test, easier to change, and easier to reason about.

Why

The AI tab mixed UI rendering, session lifecycle, conversation history, prompt construction, Chrome prompt-api transport, and streaming into one file. You couldn't test any of it without a real Chrome environment and a downloaded local model. This PR splits it into five units, each with a focused job and unit tests.

What's in the PR

New modules

  • PromptBuilder — builds the system prompt, formats app metadata, formats the selected control as inspection context, truncates JSON, builds session seed messages. Pure functions. No Chrome APIs.
  • PromptClient — single boundary around the background prompt-api port. Translates port-protocol status strings to canonical capability state names. Owns session lifecycle and streaming buffer.
  • ConversationStore — persists conversation history per inspected URL. Wraps chrome.storage.local behind a small async API.
  • AssistantController — workflow coordinator. Owns capability state, conversation memory for the current URL, session creation, reseeding, per-turn context injection, streaming, and persistence. Has no DOM, Chrome, or storage dependency. Talks to the view through a local event bus.
  • AssistantTranscript — renders the chat transcript. Owns the messages DOM, markdown, JSON expand/collapse, copy-to-clipboard, scrolling.

Reshaped

  • AIChat — trimmed from ~1500 lines to ~560. Now a thin view over the controller. Owns input, banner, dialog, controller wiring. Delegates all state to AssistantController and all transcript rendering to AssistantTranscript.

Removed

  • AISessionManager.js — replaced by PromptClient + AssistantController.
  • ChatStorageManager.js — replaced by ConversationStore.

Boundaries

┌─────────────┐   events    ┌─────────────────────┐
│   AIChat    │ ◄──────────►│ AssistantController │
│  (view)     │   methods   │ (workflow)          │
└─────┬───────┘             └──┬───┬───┬──────────┘
      │                        │   │   │
      ▼                        ▼   ▼   ▼
┌─────────────┐         ┌────────┐ ┌──────┐ ┌────────┐
│ AssistantTr.│         │ Prompt │ │Prompt│ │ Conv.  │
│ (transcript)│         │ Client │ │Build.│ │ Store  │
└─────────────┘         └───┬────┘ └──────┘ └───┬────┘
                            │                   │
                            ▼                   ▼
                    background port      chrome.storage
                    (prompt-api)

The view never talks to Chrome runtime, storage, or the model directly. Each arrow is a unit-testable seam.

Diff shape

  • 20 files changed
  • +4,042 / −1,867 lines
  • 6 new prod modules with full unit coverage
  • 6 new spec files, 436 tests total (up from 396 on master)
  • Test code is ~54% of additions (1.19× test-to-prod)

Testing

  • grunt test — 436 tests passing
  • Lint clean (jshint + eslint)
  • Coverage thresholds met (90% across statements/branches/functions/lines per karma.conf.js)
  • Pre-commit hook runs the full suite

What's not in this PR

  • No changes to the background service worker's prompt-api port contract.
  • No UI redesign.
  • No new agent or multi-step behaviour.
  • No change to which model the extension talks to.

Status

The refactor is feature-complete on this branch. Left in draft until I get a first round of feedback from a maintainer.

…tate

Chrome Prompt API rejects 'system' role messages that aren't first in
the session. Previously every promptStreaming call sent a fresh
[system, ...history, user] array, which broke on the second turn since
the session already had internal history.

Switch to the API's intended model:
- createSession accepts initialPrompts; seed with system prompt + any
  prior user/assistant turns.
- promptStreaming sends only the new user message; session retains
  history internally.
- _loadHistory re-seeds the session after loading stored messages so
  the model has context on chat reopen.

Fixes 'system role message must be the first message of a session'
errors after Reset history and on multi-turn conversations.
- _loadHistory: clear _messages before repopulating so re-entry doesn't
  duplicate every turn (every tab switch was bloating the array).
- _loadHistory: re-seed the session even when no stored history exists,
  so switching to a fresh app context refreshes the system prompt
  (framework version, theme, libraries) instead of keeping the old one.
- _loadHistory: guard against re-entry while streaming or already
  re-seeding to avoid racing concurrent createSession calls.
- _initializeSession: skip empty-content turns so the mid-stream
  assistant placeholder doesn't get replayed as a blank turn.
- handleCreateSession (background): create the new LanguageModel
  session first and only destroy the old one on success — keeps the
  previous session usable if init throws.
Introduce a PromptBuilder module that owns the system prompt body,
application metadata formatting, selected-control (Inspection Context)
formatting, truncation rules, and session seed message construction.
PromptBuilder is deterministic and free of Chrome APIs.

AISessionManager keeps its public surface unchanged and delegates
buildSystemPrompt and user-prompt formatting (inside promptStreaming)
to a PromptBuilder instance. The Chrome extension port protocol with
the background service worker is unchanged.

Prompt-formatting tests migrated from AISessionManager.spec.js to a
new PromptBuilder.spec.js. AISessionManager.spec.js retains only
transport/session assertions.
…undary

Carve the local AI transport (chrome.runtime.connect({ name: 'prompt-api' }),
port message types, streaming async iterator, capability/usage/disconnect
handling) into a new PromptClient module. PromptClient consumes already-built
prompts and seed messages from PromptBuilder and does not perform prompt
construction itself.

Reduce AISessionManager to a thin facade composing PromptBuilder + PromptClient
so AIChat continues to work with no observable behavior change. The Chrome
extension port contract with the background service worker is unchanged.

Migrate AISessionManager.spec.js from internal-field assertions onto
external-behavior delegation tests. Add transport-level PromptClient.spec.js
covering capability state, download progress, session creation, streaming
chunks, usage info, mid-stream error, and mid-stream disconnect, all driven
through a deterministic fake port.

Issue: .scratch/ai-assistant-architecture-v1/issues/02-reshape-prompt-client.md
Captures the domain model and refactor plan for Inspector AI Assistant V1:

- CONTEXT.md: canonical glossary (Inspector AI Assistant, Prompt Builder,
  Prompt Client, Conversation Store, Assistant Controller, etc.)
- docs/adr/0001: incremental refactor decision and target boundaries
- docs/agents/: issue tracker, triage labels, domain doc conventions
- .scratch/ai-assistant-architecture-v1/: PRD and 6 tracer-bullet issues
- prompt.md: TDD-flavored runner prompt for autonomous slices

No production code changes.
Wrap the existing chat storage logic in a named ConversationStore
interface that owns Conversation Memory: load, append, clear,
retention limit (50), and storage-key shape per inspected URL.
ChatStorageManager becomes a thin delegating shim so AIChat behavior
is unchanged. No other module accesses chrome.storage directly for
chat history after this slice.

Migrates the legacy ChatStorageManager spec onto ConversationStore's
public surface; adds tests pinning the retention limit and the
role+content-only persistence rule (Inspection Context must not
leak into Conversation Memory).

Refs: .scratch/ai-assistant-architecture-v1/issues/03-introduce-conversation-store.md
Issue 02 (Reshape session manager into Prompt Client interface) and
issue 03 (Introduce Conversation Store boundary) are both shipped:
PromptClient owns the prompt-api port transport, ConversationStore
owns chrome.storage-backed Conversation Memory, and AISessionManager /
ChatStorageManager remain only as thin delegating shims for AIChat.

[INTERNAL]
… view

Carve out the Assistant Controller boundary so the Inspector AI Assistant
workflow lives in a single named place. AssistantController depends only
on PromptBuilder, PromptClient, and ConversationStore and owns:

- Assistant Capability State resolution (ready, downloadable, downloading,
  unsupported, unavailable, streaming-failed, unknown — definitive
  vocabulary finalized in slice 05)
- Conversation Memory loading per inspected URL
- session creation and reseed with system prompt + replayed turns
- per-turn Inspection Context injection (never persisted)
- streaming send, save of both turns, streaming-failure recovery
- clear-and-reseed and reseed-on-URL-change

AIChat is now a thin view over the controller: rendering, markdown,
JSON / code viewers, dialogs, scroll behavior, and clipboard helpers
stay; session lifecycle, streaming orchestration, history persistence,
and context injection are gone from the view and replaced by controller
calls plus an event-listener wiring (capability-state-changed,
conversation-loaded, stream-chunk, stream-complete, stream-failed,
conversation-cleared).

A new AssistantController.spec exercises the full V1 Agent Validation
Loop with deterministic fakes for PromptClient and ConversationStore
and the real PromptBuilder, with no Chrome runtime dependency. Existing
DOM-based AIChat tests continue to pass unchanged.

[INTERNAL]
… semantics

Address standards and spec review findings on the slice 04 commit.

Spec fixes (slice 04 / PRD):
- sendUserMessage no longer appends the user turn to ConversationStore
  before the stream completes. On streaming failure, neither the user nor
  the assistant turn is persisted, so reseed never replays an orphan user
  message. Matches slice 04 "saving completed user/assistant turns".
- session creation failures during initialize, clearConversation, setUrl,
  and downloadModel reseed now resolve the Assistant Capability State to
  'session-failed' instead of throwing. session-failed is one of the
  canonical first-class states listed in the PRD; it is no longer just
  an unhandled rejection.
- streaming-failed now recovers: a subsequent successful sendUserMessage
  resurfaces a 'ready' capability state so the developer does not stay
  stuck on a failure banner (PRD user story #8).
- AIChat capability-state routing is explicit per state; 'unknown' and
  'streaming-failed' no longer fall through to the unavailable banner,
  and 'session-failed' surfaces clearly. Refactored the dispatch into a
  small prototype lookup map to keep cyclomatic complexity within JSHint
  limits and to make slice 05 additions trivial.

Standards / readability:
- AssistantController#on JSDoc now justifies the local in-process event
  bus and explains why this is not a chrome.runtime message dispatch.
- AIChat conversation-cleared handler has a comment confirming the
  innerHTML literal is static, with user/assistant content always routed
  through _escapeHtml or _parseMarkdown via _addMessage.
- Hoisted duplicated test helpers (initializedReady, awaitStreamController)
  to module scope with JSDoc.

416 -> 421 tests, 5 new tests covering the spec fixes above.
grunt lint and grunt test green.

[INTERNAL]
…patch

Address follow-up review findings on the slice 04 hardening commit.

Spec fixes:
- Seed Assistant Capability State as the canonical PRD value 'unavailable'
  (with a 'Checking model status...' message) instead of the ad-hoc string
  'unknown'. The PRD enumerates exactly seven first-class capability states
  and 'unknown' was not one of them; this brings the controller fully in
  line with the canonical vocabulary before slice 05.
- AIChat capability-state dispatch now has an explicit fallback for
  unmapped Assistant Capability States: it routes to the unavailable
  banner with the controller's supplied message and logs a console.warn
  so a missing handler is detectable during development. Previously an
  unmapped state was silently dropped, which weakened the PRD guarantee
  that capability states are first-class.

Standards / readability:
- Extracted each entry of _capabilityStateHandlers into its own documented
  AIChat.prototype._render*CapabilityState method; the map now holds
  references rather than anonymous functions, so each renderer has full
  JSDoc and a stable name in stack traces.
- awaitStreamController helper is now bounded by a maxAttempts polling
  budget (default 500 x 1ms); if production code never calls
  promptStreaming, the helper rejects with a descriptive error instead
  of hanging until Mocha's suite-level timeout.

421 -> 422 tests, 1 new test covering the unmapped-state fallback, 1
existing test rewritten to assert canonical PRD vocabulary at construction.
grunt lint and grunt test green.

[INTERNAL]
Slice 04 (Introduce Assistant Controller and the V1 Agent Validation
Loop) is shipped:

- AssistantController owns capability resolution, conversation memory
  lifecycle, session creation and reseed, per-turn Inspection Context
  injection, streaming, persistence of completed turns, and clear /
  reseed semantics. It depends only on PromptBuilder, PromptClient,
  and ConversationStore.
- AIChat no longer owns session lifecycle, streaming orchestration,
  history persistence, or context injection; it interacts only with
  the controller and listens to its event stream.
- The full V1 Agent Validation Loop is exercised by 22 controller-level
  tests using deterministic fakes for PromptClient and ConversationStore
  and the real PromptBuilder — no Chrome runtime dependency.
- Existing DOM-based AIChat tests still pass; a new view-level test
  guards against silent-drop of unmapped capability states.

Implementing commits: b846529, 5f0530d, 3675be1.

[INTERNAL]
Incorporates master's ec1befd (PR #350: fix(ai): seed system+history via
initialPrompts; let session manage state). Master's hunks targeted code
that has been refactored into PromptBuilder, PromptClient, and
AssistantController on this branch; the conflicts were resolved by
keeping the post-refactor shape and verifying that both behavior
improvements from PR #350 are preserved in their new homes:

  - background/main.js handleCreateSession still creates the new
    LanguageModel session before destroying the prior one, so a failure
    during init leaves the previous session usable.
  - PromptBuilder.buildSeedMessages still skips empty assistant
    placeholders when replaying Conversation Memory, so an interrupted
    mid-stream slot never reseeds as a blank turn.

Adds two tests exercising these behaviors at the right boundaries:
  - AssistantController.spec.js: empty assistant placeholders from
    Conversation Memory are not replayed into the session seed.
  - PromptClient.spec.js: a failed second createSession leaves
    hasActiveSession() === true so the prior background session can
    remain usable.

See .scratch/ai-assistant-architecture-v1/issues/08-merge-master-preserve-behavior.md.
- Remove dead _isReseedingSession field from AIChat that landed via the
  auto-merge from master. The view no longer owns reseed coordination;
  the controller does. The field had no reader, no other writer, and
  no test exercising it.
- Shorten the new Assistant Controller test title to match the local
  imperative-short convention used elsewhere in the file.
- Tighten the new Prompt Client create-before-destroy companion test:
  use async/await sequencing instead of nested .then chains so the
  test is no longer coupled to the synchronous registration order
  inside the Promise executor.

Known gap (documented as follow-up, out of scope for the merge issue):
the actual create-before-destroy ordering invariant lives in
background/main.js handleCreateSession, which sits inside the service
worker IIFE and is not unit-testable without restructuring (forbidden
by issue 08). The added Prompt Client test still covers the client-side
companion invariant — that PromptClient does not optimistically clear
hasActiveSession on a failed reseed — which is the strongest assertion
available from the testable boundary.
Drop the view-private translation table (status-needs-download,
status-error) and the per-state handler map. The AIChat banner now
derives its CSS class directly from the controller's canonical status
name (status-<status>) for every Assistant Capability State, and the
download button is bound to the two states that admit it.

AssistantController.initialize() now maps a rejected Prompt Client
capability check onto an 'unavailable' canonical state instead of
letting the promise reject, so the view never has to render an ad-hoc
'error' banner from a raw exception.

session-failed gets its own LESS rule (recoverable warning) and the
view re-exposes the clear-history affordance for it, making the
user-facing recovery action visible. streaming-failed continues to
leave the prior banner untouched per PRD user story #8; tests now
make that contract explicit rather than relying on the default
download-button visibility.

Closes slice 05 of .scratch/ai-assistant-architecture-v1.
Close slice 06: trim AIChat to a thin view over AssistantController.

Most of the trimming had already landed across slices 04, 05 and 08.
This commit closes the two remaining boundary gaps:

1. AIChat tests no longer depend on real session, storage, or capability
   behavior. The top-level beforeEach previously built the view with a
   real AssistantController, which in turn built a real ConversationStore
   that requires chrome.storage.local. The suite only happened to pass
   in the full Karma run because other specs leak window.chrome onto the
   shared test window; running tests/modules/ui/AIChat.spec.js in
   isolation crashed in the beforeEach. Hoist createFakeController() to
   the top of the spec and have every beforeEach build the view with
   { controller: fakeController }. The view is now tested at its true
   seam — the controller surface — and the spec passes in isolation.

2. Removed three dead view-side fields that suggested workflow ownership:
   _currentContext (write-only), _messages (write-only), and _currentUrl
   (only used to dedupe setUrl calls that the controller already
   short-circuits). Their presence read like the view holds Inspection
   Context, Conversation Memory, and URL state when it does not. Dropped
   the matching internal-state assertions from Constructor &
   Initialization.

No production behavior change. The public surface used by panel/ui5/main.js
(setUrl, updateContext, onTabActivated, destroy) is unchanged. Full
grunt test green (435 tests).
The async iterator returned by promptStreaming() previously wired its
chunk / complete / error handlers lazily, only when 'for await' (or
iterator.next()) was first called. Real port messages arriving between
'_send(prompt-streaming)' and the first iteration were dropped. Today
this only happened to be safe because the AIChat 'for await' loop begins
on the same microtask that resolves the returned promise; any UI change
that deferred iteration (a microtask gap, a wrapping helper, a debounce)
would silently lose the first chunks.

Move the handlers and the in-memory buffer to be created synchronously
inside promptStreaming(), before the returned promise resolves. The
async iterator now only drains from the buffer; transport listeners are
never registered from inside the generator. Streaming becomes
order-independent: chunks delivered before the first iterator.next()
call are buffered and replayed in order.

Add a deterministic test on the PromptClient boundary that asserts
buffered delivery. Existing chunk-yield, mid-stream error, and
disconnect tests continue to pass unchanged.

AISessionManager and AIChat are not modified. No observable behavior
change in the Inspector AI Assistant tab.

Closes .scratch/ai-assistant-architecture-v1/issues/07-prewire-streaming-buffer.md
Delete the AISessionManager and ChatStorageManager facades — both had
zero production callers; only their own spec files depended on them.
The V1 boundaries (PromptBuilder, PromptClient, ConversationStore,
AssistantController) are now the only assistant modules, matching what
ADR-0001 described as the post-transition end state.

Fix the latent capability-mapping gap in AssistantController._mapAvailability:
the background service worker can emit 'downloading' (when the local
model is mid-download at the moment of the availability check) and
'error' (when the availability check itself throws). Before this
change, both collapsed silently into 'unavailable'. After this change,
'downloading' maps to the canonical 'downloading' Assistant Capability
State so the AI tab shows the download-in-progress banner instead of
a generic unavailable message; 'error' continues to map to 'unavailable'
but the transport-supplied message is preserved (this was already the
fallback behavior — the spec now guards it).

The mapping fix is done in place for this slice. Slice 02 of V2 will
move the translation behind the Prompt Client seam.

Closes .scratch/ai-assistant-architecture-v2/issues/01-close-adr-0001-and-fix-capability-mapping.md
…nt seam

Move the canonical Assistant Capability State translation from
AssistantController._mapAvailability into PromptClient.checkAvailability.
The Prompt Client now returns one of the canonical state names defined
in CONTEXT.md (unsupported, unavailable, downloadable, downloading,
ready) directly, instead of leaking the background service worker's
port-protocol status dialect (ready, needs-download, downloading,
unsupported, unavailable, error) past its boundary. The background
port message protocol itself is unchanged.

The redundant 'available' boolean is dropped from the checkAvailability
return shape; its only consumer was the controller's translation,
which also goes away. _mapAvailability is removed from
AssistantController.

Behavior-preserving: the same canonical state reaches the AIChat
view, just produced one seam earlier. Slice 01's mapping fix for the
downloading and error background statuses is preserved.

The Prompt Client spec now asserts the canonical name returned for
every background-port status it can receive, including the error and
unrecognized-status paths. The Assistant Controller spec drives its
fake Prompt Client with canonical state names directly so the test
double does not bookkeep a translation table.

Closes ai-assistant-architecture-v2 slice 02.
AIChat used to own about half its 1118 lines on markdown parsing, JSON
viewer rendering, code viewer rendering, scroll bookkeeping, debounced
streaming render, clipboard helpers, and HTML escaping. The AIChat spec
probed fourteen private rendering helpers, which was the strongest
internal-seam-wants-to-be-a-module signal in the assistant code.

Lift those concerns into a new named AssistantTranscript module in
modules/ai. The view now drives the transcript through a small
stream-shaped interface — appendUserTurn, appendSystemMessage,
beginAssistantTurn (returning a streamChunk/finalize handle), clear,
reset, scrollToBottom — and no longer reaches past it. AIChat keeps the
banner, input area, confirm dialog, token counter, and controller
subscription. The Assistant Controller is unchanged and is unaware the
Transcript exists.

Migrate the view-private rendering test probes to a new
AssistantTranscript spec that asserts behavior through the module's
interface, and trim the AIChat spec to view-level assertions
(controller-event wiring, banner CSS class from canonical capability
states, input/send wiring, dialog focus management, token-counter
behavior).

CONTEXT.md adds 'Assistant Transcript' to the glossary with an _Avoid_
list excluding chat history, conversation memory, and AIChat.

Closes slice 03 of Inspector AI Assistant architecture v2.
- Drop AssistantController#isStreaming() and PromptClient#hasActiveSession(),
  neither of which had a production caller. Tests now read the underlying
  state via the underscore-prefixed private members.
- Remove unused promptBuilder field from AssistantController.spec harness.
- Remove unused streamingHandle field from AIChat.spec fake transcript.
- Extract createClient() helper to collapse 17 duplicated test preambles
  in PromptClient.spec.
Drop references to removed agent workflow artifacts (PRD, ADR, slice
issues). Keep the rationale, drop the citation. Trim verbose JSDoc to
one-line descriptions where the function name was already self-evident.
Remove restating inline comments. Preserve security notes, ordering
constraints, and other non-obvious why-comments.
Remove JSDoc descriptions that just restate the function name. Keep the
JSDoc block and all tags so ESLint still passes. Delete a handful of
inline test-flow markers and one stale historical migration note.
Replace every `var` with `const` (or `let` where reassigned) across the
new AI modules and their specs. Replace the pre-existing 'self' aliases
in AssistantTranscript with 'that' to match the repo's
consistent-this rule.

Add `no-var` and `prefer-const` to eslintrc as warnings so future
contributions get a signal without breaking the build for existing
files that still use `var`.
The 'const that = this' pattern predates arrow functions. Convert every
inner callback in AssistantController and AssistantTranscript to an arrow
function, drop the alias, and use 'this' directly. 17 aliases removed,
34 inner callbacks converted. Outer prototype methods keep their
'function' form because they rely on dynamic 'this' binding.
Apply the writing style guide to every JSDoc description and inline
comment block across the AI modules and their specs. Drop filler
openings, hedging language, redundant second sentences, semicolons in
prose, and AI-cliche verbs (utilize, leverage, comprehensive, etc.).
Re-wrap JSDoc prose at 100 chars.

Removes ~180 lines of comment prose without losing any documented
constraint, security note, or ordering rule.
Replace the `options = options || {}; this._x = options.x || default;`
pattern with parameter-list destructuring across the 5 new AI module
constructors. The defaults are now visible at the function signature.

Kept AssistantTranscript's maxJsonDepth and streamDebounceMs outside the
destructure because the original guard rejected non-numbers, which the
destructuring default doesn't replicate.
Replace AIChat._CANONICAL_CAPABILITY_STATES (array) and the chain of
if-status-equals branches in _onCapabilityStateChanged with a single
config object. Each canonical state maps to a flags object indicating
whether the dispatch skips the banner, shows the clear-history button,
or refreshes the token counter.

Same behaviour, fewer branches to read.
The fake PromptClient in AssistantController.spec.js had a 50-line
hand-rolled stream object that implemented Symbol.asyncIterator manually
with a notify callback. Replace with an async generator that loops over
the same chunks/done/error queue. Same external behaviour, fewer moving
parts.
A JSDoc block whose only content is `@private` carries no information
beyond what the underscore prefix already conveys. Remove 29 such
blocks across AssistantTranscript, PromptClient, and AIChat.

JSDoc blocks with @param / @returns / actual prose are kept.
No production caller. The view subscribes to capability-state-changed
events instead of polling. Tests now read controller._capabilityState
directly, consistent with how _isStreaming and PromptClient._hasActiveSession
are tested.
Wire AIChat.destroy() to a window beforeunload listener in the devtools
panel entry point. When the panel page is dismissed (devtools closed,
target tab closed, devtools navigated away) the chain now runs:

  AIChat.destroy
    -> AssistantController.destroy
       -> PromptClient.destroy  (disconnects the prompt-api port,
                                 tells the background SW to destroy
                                 the LanguageModel session)
    -> AssistantTranscript.destroy  (no-op)

Without this, the long-lived port and the model session stayed alive in
the background service worker until Chrome's SW idle GC took them out.
Wire AIChat.onTabActivated() to a click listener on the #ai-tab element.
When the developer switches back to the AI Assistant tab, the transcript
scrolls to the latest turn so they see the most recent reply without
manually scrolling.

The callback is deferred via requestAnimationFrame so the scroll runs
after the TabBar click handler has flipped the selected attribute and
the content is actually visible.
Five /**\n */ orphan blocks sat above functions and carried no content
after the previous comment-stripping passes removed the descriptions
inside them.
…seed

clearConversation and setUrl destroy and recreate the LanguageModel session,
but the panel's token counter, quota-exhausted styling, and input disabled
state are only refreshed when AssistantController emits a capability-state
change. Neither path was emitting one on the happy branch, so after clearing
history the counter kept showing the pre-clear inputUsage — and if the user
had cleared specifically to escape quota-exhausted, the input stayed disabled
with 'Token quota exhausted. Clear history to continue.'

Route both reseed paths through a new _reseedAndAnnounceReady helper that
re-emits 'ready' after a successful reseed. The helper checks capability
state before emitting so a failed reseed's session-failed banner is not
immediately overwritten by ready.
clearConversation and setUrl destroy the active Prompt Client session
synchronously and reseed asynchronously, while AIChat fires Clear
History without awaiting the controller. A Send pressed in that
window raced the Prompt Client's '\''No active session. Call
createSession() first.'\'' guard.

Track the in-flight reseed in a new _pendingReseed promise (set up
synchronously at the destroy-then-reseed entry point so a Send issued
mid-microtask still observes it). sendUserMessage awaits it before
calling promptStreaming. If the reseed itself rejects, sendUserMessage
rejects with the reseed error and the existing session-failed
capability state stays on screen — we do not flip it to
streaming-failed, which would overwrite the more accurate banner.

Adds AssistantController spec coverage for the Clear History race, the
URL-change race, and the failed-reseed case using a deterministic
deferred fake createSession().
Inspection Context (the selected control's snapshot) now stays attached to every
sendUserMessage until one of three explicit triggers detaches it: a different
control is selected, the developer clicks the ✕ on the pill, or the inspected
page navigates to a different URL. Clearing Conversation Memory no longer drops
the snapshot — the two concepts are orthogonal.

Controller changes:
- Rename _pendingInspectionContext to _inspectionContext (the field is sticky,
  not pending).
- sendUserMessage no longer nulls the snapshot after consuming it.
- setUrl clears the snapshot on a real URL change (after dedupe) and emits a
  new inspection-context-cleared event before reseeding.
- updateInspectionContext(null) emits inspection-context-cleared when a snapshot
  was attached; replacing one snapshot with another does not emit.
- clearConversation does not touch the snapshot and does not emit
  inspection-context-cleared.

View changes:
- AIChat subscribes to inspection-context-cleared and hides the Context pill in
  the handler.
- The ✕ click handler only calls updateInspectionContext(null); pill visibility
  flows through the event round-trip.

Tests:
- Existing #updateInspectionContext test is inverted — it now asserts the
  snapshot is injected into every subsequent prompt, not just the next one.
- Adds regression coverage for clear-history + send, snapshot replacement,
  URL-change detach, dedupe path, never-persisted invariant, and the new event
  emission rules in both AssistantController and AIChat.

PRD: .scratch/ai-inspection-context-sticky/PRD.md
ADR: docs/adr/0002-inspection-context-lifecycle.md
…script

The chat transcript rendered "System"-labelled messages for two different
categories that never belonged there:

- Confirmations of a UI action the developer just performed
  ("Chat history cleared", "Context cleared - no control is currently
  selected") — redundant with the visible state change (empty transcript,
  disappearing pill).
- Transient errors for user actions ("Error: <stream failure>", "Error
  clearing history: ...", "Failed to copy to clipboard") — category-confused;
  errors are not conversation turns.

Both are gone. Confirmations are dropped outright: the empty transcript and
the hidden pill are themselves the confirmation. Errors move into a new
`.ai-error-slot` above the input row — a muted-red single-line status area
that stays hidden until an error occurs and auto-clears on the developer's
next input activity (input, keydown, or send).

The only surviving `appendSystemMessage` call is the 70%-usage advisory tip.
That is intentional — it is the only remaining message in that category that
genuinely belongs in the conversation flow.

AIChat changes:
- Add `.ai-error-slot` inside `.ai-input-area`, positioned between the
  context-info pill and the input row. `role="status"` +
  `aria-live="polite"` so screen readers announce errors non-interruptively.
- Add `_showError(message)` / `_clearError()` private helpers; `_showError`
  replaces any current message (single-line, no stacking).
- Redirect three error paths (`stream-failed`, `_performClearHistory` catch,
  clipboard-failure) into `_showError`.
- Drop the two confirmation `appendSystemMessage` calls from the
  `conversation-cleared` handler and `_clearContext`.
- Wire `_clearError` into the input's `input`/`keydown` handlers and
  `_handleSendMessage`.

AssistantTranscript changes:
- Accept an `onCopyFailed` callback via constructor options and invoke it
  on clipboard failure instead of calling `appendSystemMessage`. The
  transcript keeps its separation from the input area — it just notifies
  the view, which owns the error slot.
- `appendSystemMessage` and the `_appendMessage('system', ...)` path remain
  for the token-usage warning.

Tests:
- Invert prior `appendSystemMessage`-was-called assertions for the
  clear-history, context-clear, stream-failed, and clipboard paths into
  DOM/slot assertions (hidden state, text content, absence of a
  `.message-system` node).
- Add coverage for slot placement, ARIA attributes, replace-not-stack
  behaviour, and auto-clear on typing / keydown / send.
- Keep the token-usage warning assertion — no regression.

PRD: .scratch/ai-remove-system-messages/PRD.md
Issue: .scratch/ai-remove-system-messages/ISSUE-1-remove-system-messages.md
The .ai-error-slot rules were added to app/styles/less/modules/AIChat.less
in db3fd0d but the compiled tests/styles/themes/{light,dark}/*.css were not
regenerated in that commit. This runs grunt less to bring them back in sync.
The AI Chat token counter's default color (@gray-dark, #a3a3a3) failed
WCAG AA against the panel background. Three severity classes already
toggled by AIChat.js (warning, warning-critical, quota-exhausted) were
either mis-colored or entirely missing in CSS.

Changes:
- .token-counter default: color @gray-dark -> @gray-darkest for legibility
  (#222 on light ~15:1, #949494 on dark ~5:1).
- .warning: recolored from red to amber (@amber); font-weight normalized
  to 500 (was 600) so pill width stays constant across states.
- .warning-critical: new rule, red (@red-saturated). Dark theme inherits
  the existing @red-saturated -> teal remap intentionally.
- .quota-exhausted: new rule, dark red (@red-dark).
- New theme variables @amber and @red-dark in light.less and dark.less
  with theme-appropriate hex values (per PRD).

All four states use fade(<color>, 10%) for the pill background. No JS,
DOM, ARIA, or threshold changes. No transitions or animations.
…ation

Match the AI Chat panel's post-clear state to its pristine fresh-load
state: when the conversation has no messages, neither the token-counter
pill nor the Clear History button appear in the input footer.

A single `_hasMessages` boolean on AIChat drives both. It flips true
on send, on stream-complete, and on non-empty conversation-loaded;
it flips false on conversation-cleared.

- Token counter starts with the semantic `hidden` attribute and
  `.token-counter[hidden] { display: none; }` mirrors the existing
  `.ai-error-slot[hidden]` idiom.
- `_updateTokenCounter()` returns early when `_hasMessages` is false,
  clearing text and state classes and hiding the pill. When usage info
  is briefly null or rejected in a non-empty conversation, the last
  valid text and state class are retained rather than flickering off.
- Clear History button is hidden on conversation-cleared, revealed on
  the first send, and its show path on the `ready` capability state is
  gated on `_hasMessages` so a post-clear reseed's `ready` announcement
  does not re-reveal it. The `session-failed` recovery path still
  force-shows it.

Screen-reader semantics (role=status, aria-live=polite) unchanged.
Remove duplicate explanations of the _hasMessages gate across AIChat.js,
consolidate Inspection Context stickiness docs onto updateInspectionContext,
and shorten JSDoc on one-line delegates. Fix a stale comment on the
streaming-failed capability config that referenced the removed system-message
error path.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant