Skip to content

chore: sync dev into main for 0.4.5 release - #249

Merged
Predixx merged 73 commits into
mainfrom
dev
Jun 1, 2026
Merged

Predixx merged 73 commits into
mainfrom
dev

Conversation

@Predixx

@Predixx Predixx commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator

Sync all merged dev work into main ahead of the 0.4.5 release: Open VSX recorder-less build variant (#246), Theia auto-clone removed (now Scorpio, #162), release-workflow simplification + edge-case hardening (#247), 0.4.5 bump + changelog (#248), and the accumulated feature/refactor work since 0.4.4. Conflict-free; merged tree is identical to dev. Merge as a merge commit to preserve history (matching the previous sync).

Predixx and others added 30 commits May 11, 2026 22:10
…er undo (#145)

* feat(recording): write initial metadata.json at session start

Allows live viewers to fetch sessionStartTime before the session ends.
The initial write happens after the sessionStart event is emitted (outside
the synchronous commit block) and is best-effort (not awaited). The
end-of-session writeMetadata then overwrites with final endTime + eventCount.

- SessionMetadata.endTime now permits null (for in-progress sessions)
- lifecycleController writes initialMetadata with endTime: null after sessionStart

* feat(recording): tighten flush cadence and fix flush() promise semantics

Reduces live-recording latency floor from ~5s to ~1s and ensures
`await writer.flush()` actually means buffered events are on disk.

- BUFFER_THRESHOLD: 20 -> 10
- FLUSH_INTERVAL_MS: 5000 -> 1000
- Public flush() always enqueues a real flush, no longer returns early
  from the debounce shortcut. Multiple concurrent callers each get
  their own flush; the first drains the buffer, subsequent ones find
  it empty and return.
- _enqueueThresholdFlush() retains the original debounce for
  appendEvent-triggered and timer-triggered flushes.
- _flushRequested is now honored after any lane work completes (not
  just flushes), so a threshold trigger during writeSnapshot or
  writeMetadata no longer waits for the next unrelated trigger.

* feat(recording-viewer): faster tailer poll and add currentLineNo getter

- Default pollIntervalMs reduced from 1000 to 250 ms; cuts the
  tailer's contribution to live latency by 4x.
- New currentLineNo() getter lets SSE handlers coordinate per-
  connection disk catch-up with the shared tailer's broadcast
  position. Read-only; no mutable per-subscriber seek is exposed.
- New startAtEnd option opts a tailer into starting at EOF with
  _lineNo derived from existing newlines, so SSE id: values stay
  aligned with file line numbers (needed for Last-Event-ID).

* feat(recording-viewer): tail-limit query param on /events

Adds opt-in ?tail=N parameter to GET /api/recordings/:id/events. Without
the param the endpoint still returns the full archive (contract unchanged).

Internally the new server/eventsReader.ts streams the JSONL file and keeps
only the last N lines via a ringbuffer, so memory is O(N) regardless of
file size. App.tsx's post-sessionEnd reload now uses ?tail=5000 to avoid
re-crashing the tab when a long live session ends and the archive view
attempts to load tens of thousands of events.

* feat(recording-viewer): SSE catch-up with Last-Event-ID handoff

Stops the initial-connect burst where the shared LiveTailer would
re-broadcast every line in events.jsonl to every new subscriber.

- LiveTailer now supports an opt-in startAtEnd mode that seeks to EOF
  and counts existing newlines to set _lineNo, then broadcasts only
  newly-appended content.
- LiveTailerRegistry enables startAtEnd when events.jsonl is non-empty
  at construction time (the per-connection catch-up handles history),
  and leaves it off for sessions whose file doesn't yet exist (so the
  first appended content still gets emitted normally).
- /events/stream now reads Last-Event-ID and ?tail=N before any I/O,
  subscribes to the shared tailer in buffer-only mode, reads catch-up
  from disk (Last-Event-ID resume, ?tail override, or default 5000),
  reads the gap up to the tailer's currentLineNo() to cover lines that
  landed during catch-up, drains the live buffer with dedupe via
  lineNo, then switches to direct-write mode.
- No mutable per-subscriber seek is exposed; the shared tailer is
  strictly append-only-broadcast.

* feat(recording-viewer): ringbuffer cap and RAF batching in useLiveSession

Eliminates the O(n^2) render storm and unbounded memory growth that
crashed the browser tab on long live sessions.

Behavior changes:
- Events are accumulated in a ref and flushed via requestAnimationFrame,
  so React renders at most ~60Hz regardless of SSE frame rate. jsdom
  fallback uses setTimeout(0) for tests.
- A ringbuffer cap of MAX_LIVE_EVENTS (5000) drops the oldest events
  when exceeded, holding memory at a bounded ceiling.
- sessionEnd flushes synchronously so the terminal state lands before
  EventSource closes; a pre-cap on incoming pending events protects
  against pathological catch-up bursts.
- A closed flag short-circuits all callbacks after cleanup, preventing
  late-arriving SSE frames from setting state on an unmounted hook.
- All refs reset on every setup AND cleanup, so a pending RAF can't
  leak events into a freshly-mounted session.
- error is cleared on any successful flush, replacing the previous
  behavior where a stale 'Disconnected, retrying...' message could
  persist after reconnect.

* feat(recording-viewer): useDeferredValue on live events for chart renders

Wraps live.events in useDeferredValue before threading it through
displayedEvents into the chart, timeline, and event-stream children.
React can now interrupt expensive renders of stale snapshots when a
newer SSE batch arrives, smoothing out sustained-load behavior.

live.latestEventTimestamp stays non-deferred so the annotation hotkeys
anchor at the correct wall-clock position.

* feat(recording-viewer): virtualize EventStream with react-virtuoso

Replaces the non-virtualized DOM list (one node per event) with
Virtuoso so the live event list only renders the visible window plus
a small overscan buffer. Memory and reconcile cost are bounded
regardless of stream length.

- followOutput='smooth' auto-scrolls to bottom on new items, gated by
  atBottomStateChange so the user can read older events without being
  yanked back.
- isScrolling drops the follow-playback toggle when the user actively
  scrolls away, replacing the old listRef scroll listener.
- scrollToTimestamp and follow-playback now resolve to a stream index
  via in-memory binary-ish search and call Virtuoso's scrollToIndex,
  replacing the previous querySelectorAll('[data-timestamp]') traversal.
- computeItemKey preserves the existing per-row key shape.

* test: add coverage for the new live-viewer behaviors

- recordingsApi.tail: ?tail=N param boundaries (full, N, 0, oversize,
  invalid, negative).
- recordingsApi.sse.catchup: default tail on connect, ?tail override,
  Last-Event-ID resume, empty catch-up when ID beyond file, 400 on bad
  tail, no-duplicate guarantee on overlap.
- liveTailer: currentLineNo getter, startAtEnd skip-historical-only,
  startAtEnd doesn't drop content written after construction.
- eventsReader: readLastNLines and readLinesAfter behavior including
  empty-line handling and limit boundaries.
- sessionRecorder: initial metadata.json written at session start with
  endTime: null; final write overwrites with concrete endTime + count.

* fix(recording-viewer): address codex final-review findings

1. useLiveSession: per-effect closures replace shared refs. The previous
   closedRef was reset to false on every effect run, allowing a queued
   onmessage from the previous session-id to write into the new
   session's buffers after cleanup ran.

2. EventStream: stable content-derived row keys. expandedTerminals and
   computeItemKey now use `${timestamp}-${type}` instead of array
   index, so terminal expansion state stays attached to the right row
   when the live ringbuffer trims off old events and remaining indexes
   shift.

3. EventStream: Virtuoso now has a determinate viewport. .event-list
   uses height (not max-height) so the virtualizer measures correctly;
   the Virtuoso element itself is given style={{ height: '100%' }}.

4. readLastNLines(0): no longer short-circuits with endLineNo=0.
   Streams the file to compute the real cursor so the SSE gap-read
   doesn't treat tail=0 as 'no lines exist yet' and replay the full
   file as a gap.

5. LiveTailer: replaced the first-poll startAtEnd heuristic with
   explicit initialOffset / initialLineNo / initialMtimeNs options.
   The registry now computes these synchronously at acquire time via
   statSync + sync newline-count, so lines appended between
   construction and the first poll are still emitted instead of being
   silently skipped as 'historical content'.

Tests added: SSE tail=0 produces no historical flood; registry seeds
cursor at acquire time so lines appended before first poll still emit;
LiveTailer initial cursor + race coverage; readLastNLines(0) endLineNo
contract documented.

* fix(recording-viewer): close two follow-up codex findings

1. Direct-mode SSE writes now dedupe via sentMaxLineNo. The variable
   was hoisted out of the catch-up async block into the outer SSE
   handler scope and the tailer subscribe callback checks it before
   emitting in direct mode. This closes the race where a tailer poll
   running after directMode flips could re-emit a line that catch-up
   already sent (when the seeded cursor was behind that line at
   gap-read time).

2. EventStream now derives each event's row key from a per-instance
   WeakMap-backed keyer instead of timestamp+type. Same-millisecond
   same-type events (textChange / diagnostics during bursts) no longer
   collide; Virtuoso's computeItemKey can safely reuse rows only when
   the underlying event object is the same. WeakMap lets dropped
   ringbuffer events GC naturally.

* feat(recording-viewer): undo/redo for live markers via Cmd/Ctrl+Z

Adds a semantic-queue mutation controller that lets the user undo and redo
struggle/context markers while a live session is recording. Cmd/Ctrl+Z
walks the history back, Cmd/Ctrl+Shift+Z (and Ctrl+Y on Windows/Linux)
re-applies the most recently undone marker. Any new marker invalidates the
redo stack.

Server: new DELETE /api/recordings/:sessionId/annotations/:id endpoint
(synchronous read-modify-write, live-mode carve-out alongside POST). Auth
gate updated to allow both shapes without RECORDING_VIEWER_ALLOW_WRITE=1.

Client: createAnnotationController owns annotation state via a single
Promise-chained queue of semantic operations. addLabel / undoLast /
redoLast / reset all enqueue and capture both a generation counter and
the sessionId so cross-session writes can never leak after a navigation
or session-end reload. useAnnotationMutations is a thin React adapter.
useLiveHotkeys extended with onUndo / onRedo callbacks; handler logic
extracted to a pure function for unit testing.

Also: fix latent bug where saveAnnotations dropped failed-PUT responses
without rollback, now rolls back guarded by sessionId and reference-equal
state check.

Tests: 58 new cases (annotation controller, hotkey handler matrix, DELETE
endpoint + live-mode carve-out, codex-flagged races: temp-id-undo,
failed-undo-then-redo, reset mid-flight, real-load ordering).

* docs(changelog): add unreleased entry for live viewer improvements
…ut (#148)

* feat(ws-statusbar): gate visibility on authentication state

Default behavior previously surfaced a red WS Disconnected indicator when
the user was logged out, suggesting a connection failure even though no
WebSocket existed. WebSocketStatusBarService now accepts an external
authentication signal and hides itself when logged out, unless the
artemis.showWebSocketStatusBar setting is explicitly enabled for
diagnostics.

* feat(extension): sync ws statusbar auth state on login/logout/startup

Calls websocketStatusBarService.setAuthenticated(...) from the three auth
entry points: updateAuthContext (login/logout), the initial-auth try
block, and the catch fallback. Without this hook the status bar would
remain in its default unauthenticated state across login transitions.

* docs(changelog): note ws statusbar hide-on-logout fix
…pdown to input, scroll shadows, consistent tag column (#149)

- Conversations now sorted by lastActivity desc at snapshot time
- Dropdown height tracks actual input section via ResizeObserver + CSS var
- Scroll shadows (top + bottom) on each list, pure CSS, no JS
- Course tag column reserves min-width 4em so untagged rows align consistently
- Diagnostics report includes courseId per exercise for orphan-course debugging
…504) (#150)

* docs(spec): Iris API migration to unified sessions (PR #12504)

Design for adapting the extension's Iris API client after Artemis develop
merged the per-context session endpoints into one unified surface.
Two codex review rounds; explicitly approved after the resolver-extraction
fix and test-file scope expansion.

* docs(plan): Iris API migration to unified sessions

9-task TDD plan covering types, helpers, primitives, composition layer,
caller migration, test stub migration (with onFirstCall/onSecondCall
discipline for the dual getChatMessages calls), legacy method deletion,
and acceptance. Codex-approved after 2 rounds.

* feat(iris): add IrisChatMode and IrisChatSessionSummary types

* feat(iris): add contextToIrisMode helper

* refactor(iris): extract resolveCourseIdFromContext into shared module

* feat(iris-api): add unified chat session primitives

* refactor(iris): orchestrate sessions via unified overview + per-session messages

* refactor(iris): dispatch WebSocket session client via contextToIrisMode

* test(e2e): point uncommittedChanges helper at unified Iris API

* refactor(iris-api): remove legacy per-context session methods

* chore(changelog): note Iris API migration to unified sessions

* chore(changelog): rephrase Iris API entry to user-facing wording
* feat: server-side problem statement rendering (v2, React stack)

Integrate Artemis POST /api/exercise/problem-statement/render endpoint
into the React-based extension with progressive enhancement:

- ProblemStatementRenderService: LRU cache, feature detection, race guards
- ProblemStatement.tsx: accepts serverRenderedHtml prop, skips client processing
- CSS bridge maps VS Code theme variables to Artemis server CSS variables
- Typed ProblemStatementRendered message contract
- Background render triggered on exercise open, result sent via postMessage

* debug: add SSR/Client indicator to Exercise Description header

* debug: add console.log for SSR message reception in webview

* fix: include SSR result in exerciseDetailInit message to avoid race condition

* chore: remove debug indicators and console.log from SSR integration

* fix: improve CSS bridge to match VS Code theme (fonts, headings, code, task colors)

* feat: add dev-mode CSS toggle to switch between VS Code bridge and raw server CSS

* refactor: move SSR CSS toggle button to Developer Tools panel

* fix: keep server pink for inline code, use semi-transparent background for visibility

* fix: ensure server pink inline code color wins over client CSS module styles

* fix: constrain PlantUML SVG diagrams to fit within container width

* fix: brighten PlantUML SVG diagrams for better visibility in dark themes

* feat: add 'View SSR HTML' button in dev tools to inspect server-rendered HTML

* fix: increase SVG brightness filter to 1.8 for visible diagram lines in dark mode

* fix: target SVG link/entity elements specifically for dark mode visibility

* debug: log darkMode value in SSR request

* fix: scope PlantUML SVG overrides to dark mode, re-trigger SSR on reload

Dark-mode CSS overrides for PlantUML diagrams (stroke: #ccc/#aaa) made
lines invisible in light mode. Scoped to body.vscode-dark and
body.vscode-high-contrast only.

Reload button dropped back to client render because
handleReloadExerciseDetail never re-triggered background SSR. Added
backgroundRenderProblemStatement to WebViewActionHandler interface and
call it after sendInitData in the reload path.

* fix: re-render SSR on VS Code theme change

When the user switches between light/dark themes, invalidate the SSR
cache and re-request from the server with the correct darkMode value.
Without this, switching to light mode kept showing the dark-themed SVG
(light strokes on transparent background = invisible on white).

* fix: darken PlantUML arrows in light mode for better visibility

The Artemis light theme uses #808080 for extension/composition arrows,
which is too faint on white. Darken to #444 in vscode-light and
vscode-high-contrast-light, scoped via attribute selector to only
target the gray arrows (not the already-black dependency arrows).

* fix: match Artemis web PlantUML appearance in light mode

Server SSR produces muted SVGs (#808080 arrows/text, 0.5px borders)
while the Artemis web client renders green arrows, green attribute
text, and visible box borders. CSS overrides now:
- Bump entity border stroke-width from 0.5 to 1
- Recolor #808080 arrows to #28a745 (Artemis green)
- Recolor #808080 attribute/method text to green
- Increase arrow stroke-width to 1.5

* fix: increase PlantUML entity border stroke-width to 2px

* feat: add SSR preview panel, remove all PlantUML SVG CSS overrides

Add "Preview SSR" dev tools button that opens a webview panel showing
the rendered server HTML (including inline SVG diagrams).

Remove all PlantUML SVG CSS overrides (dark-mode stroke lightening,
light-mode green recoloring, border-width bumps). The server delivers
the correct colors via the Artemis PlantUML themes — the same code
path as the web client. CSS overrides were fragile and unnecessary.

* fix: remove width:auto and !important overrides from PlantUML SVGs

Stop overriding SVG width and remove !important flags. Match the
server's own CSS exactly (max-width:100%;height:auto). The extra
width:auto !important was likely causing SVG stroke rendering
differences between the sidebar and SSR preview panel.

* fix: allow inline style attributes in CSP for server-rendered SVGs

The CSP style-src directive with nonce blocked ALL inline style=
attributes, including those on SVG elements from the server-rendered
problem statement. This made SVG strokes/borders invisible because
PlantUML uses style="stroke:#000;stroke-width:0.5" on rect/path
elements while fill colors use SVG attributes (which aren't affected).

Add style-src-attr 'unsafe-inline' to allow inline style attributes
while keeping nonce protection for <style> elements.

* fix: pass feedbacks from latest submission to SSR render

Was accessing submissions[0] (oldest) instead of the latest submission
sorted by ID. The feedbacks with testCase.id were on the latest result
but we were reading from the first array element. Now matches the same
sorting logic as exerciseDataLoader.

* feat: bridge server task CSS for icons, stats, and status colors

The server's embedded <style> block is blocked by CSP (no nonce).
Replicate the task-related CSS rules in our CSS module so checkmarks,
crosses, x/y stats, and status colors render correctly. Uses VS Code
theme variables with server CSS variable fallbacks.

* fix: allow server <style> through CSP, remove replicated CSS

Replace nonce-based style-src with 'unsafe-inline' so the server's
embedded <style> block is no longer blocked by CSP. This means the
server's own CSS for tasks, icons, stats, diagrams, and typography
is applied directly — no need to replicate it in our CSS module.

Removed ~50 lines of bridged server CSS (task icons, stats colors,
SVG layout). Only the CSS variable bridge remains (mapping VS Code
theme vars to server CSS custom properties).

* fix: allow data: URIs in img-src CSP for task status icons

* refactor: remove CSS bridge, use pure server rendering for SSR

Server-rendered content now uses only the server's own CSS — no
VS Code theme variable bridge, no heading overrides, no inline code
fixes. The server <style> comes through CSP directly.

Removed: CSS variable bridge, heading/code overrides, useServerCssOnly
toggle and prop. Client-rendered fallback still uses .problemStatement.

Note: dark mode will need server-side CSS fixes (fallback colors are
light-mode only).

* feat: add Preview Light / Preview Dark buttons for SSR debugging

Each button fetches fresh SSR from the server with explicit darkMode
flag, bypassing the cache. Opens in a preview panel with matching
background color. Useful for tuning the server's dark mode CSS.

Also adds darkModeOverride parameter to render service for this.

* fix: pass CSP nonce to interactive script for task feedback modal

The dynamically created script element for the server's interactive
JS was blocked by CSP (script-src requires nonce). Pass the nonce
via data-csp-nonce on the root element and apply it to the script.

* refactor: remove client-side problem statement rendering

Server-side rendering is now the only rendering path. Removed:
- problemStatementProcessor.ts (Markdown/KaTeX/PlantUML client pipeline)
- plantUmlCommands.ts (inline PlantUML rendering via extension API)
- PlantUmlRendered/PlantUmlError extension messages
- RenderPlantUmlInline webview command
- All .problemStatement CSS rules (200+ lines of client styling)
- vscodeApi prop from ProblemStatementProps

When SSR is unavailable, raw markdown is shown as plain text fallback.
The ProblemStatement component is now ~60 lines (was ~160).

* refactor: use VS Code language instead of Artemis user profile langKey

Simpler and more consistent — the extension UI is already in the
VS Code language, so the SSR content should match. Removed userLangKey
parameter from render/debouncedRender, now always uses vscode.env.language.

* refactor: hardcode locale to 'en' since extension is English-only

* refactor: rename interactive to includeJs, add includeCss to request DTO

Match server-side rename of interactive → includeJs for consistency
with the new includeCss flag. Both default to true on the server.

* feat: show skeleton loading state for problem statement instead of raw markdown

* fix: remove dead code, consolidate CSS, fix ExamExerciseDetail SSR regression

* feat(problem-statement): use iframe srcdoc for SSR rendering

Replace dangerouslySetInnerHTML with iframe srcdoc to render the
server-provided HTML document. This enables KaTeX and interactive
scripts to execute properly, matching how iOS WKWebView handles
SSR content. Add auto-resize and frame-src CSP directive.

* feat(problem-statement): render KaTeX client-side via bundled npm package

Replace the iframe approach with dangerouslySetInnerHTML + client-side
KaTeX rendering. The server provides formula placeholders which are
rendered after DOM injection using the bundled katex npm package.
Copy KaTeX assets to dist/katex/ at build time. Strip server KaTeX
script/link tags and extract body content from the full HTML document.

* refactor(problem-statement): memoize body HTML and fix regex robustness

Use useMemo for bodyHtml derivation to avoid regex recomputation on
every render. Use [\s\S]*? instead of [^<]* in KaTeX script stripping
for multiline content. Remove duplicate katex.min.css import (already
in index.tsx).

* fix(problem-statement): use forEach over NodeList to avoid iterator requirement

* test(exercise-detail): update problem-statement test to expect SSR-rendered HTML

The ProblemStatement component no longer renders raw markdown; it waits
for server-rendered HTML via problemStatementRendered message and shows
a skeleton until then. Simulate that message in the test.

* feat(problem-statement): request inlineImages from server for SSR

VS Code webviews have no JWT cookie, so absolute image URLs returned by
the SSR endpoint cannot be loaded. Hardcode inlineImages=true so the
server embeds images as base64 data URIs. Include the flag in the cache
input hash to invalidate stale entries if the value ever changes.

* chore(api): drop unused submitStudentExam (resurrected by dev merge)

The function was removed in dev cleanup f2922cd but came back via the
old iris-thaumantias path during the dev merge into this branch. No
callers exist.

* chore(esbuild): drop dead copyKatexAssets

KaTeX is consumed via npm import in webview/index.tsx (CSS) and
ProblemStatement.tsx (formula rendering). Nothing references dist/katex,
so the build-time copy step is dead.

* chore(plantuml): drop dead extension-side PlantUML stack

Webview no longer renders PlantUML client-side (server-side rendering
handles it now), so the extension-side counterparts are dead:

- ArtemisApiService.renderPlantUmlToSvg
- artemis.renderPlantUmlFromWebview command + registration
- processPlantUml util + re-export
- plantUmlProcessor + artemisApi PlantUML unit tests
- LogCategory.PLANTUML (no remaining users)
- baseWebviewProvider EVENT_TYPES entries for plantUmlRendered / plantUmlError

* chore(deps): drop unused marked package

Top-level marked was only consumed by problemStatementProcessor, which
this PR removed. Streamdown / mermaid still pull their own pinned
versions transitively, which is fine.

- package.json: drop marked + @types/marked
- knip.json: drop @types/marked ignore
- src/extension/types/ambient/marked.d.ts: deleted
- package-lock.json: regenerated

* refactor(ssr): drop interactiveScript plumbing and unused render helpers

The iframe srcdoc design that needed interactiveScript was replaced by
dangerouslySetInnerHTML + bundled-KaTeX rendering. The script field was
never executed in the new path, so all of its plumbing was dead.

- Drop interactiveScript through DTO, ServerRenderResult, app state,
  webview provider, message contracts, and ExerciseDetailView state.
- Drop the data-csp-nonce attribute on the root element (was only for
  the dropped script).
- Drop the orphan source: 'server' field on ServerRenderResult.
- Hardcode includeJs: false in the request builder (server no longer
  needs to emit the script).
- Drop unused debouncedRender (and debounceTimers/pendingResolvers
  fields), invalidateExercise, and isServerAvailable from the service.
- Drop the unused isExamExercise param from render() — exam view uses
  its own plaintext fallback.
- Drop PreviewSsrHtml command + handler (no senders; Fresh SSR Preview
  is the only entry point).
- Update stale comment in ProblemStatement.tsx; remove a CSP comment
  block whose wording was tripping the csp.test.ts security regex.

* fix(exam): render problem statement as escaped plaintext

Exam mode never goes through the SSR pipeline (it short-circuits in
ProblemStatementRenderService), so passing raw markdown into the
SSR-only ProblemStatement component injected unrendered markdown as
HTML.

Replace with an inline Container that shows a notice and the markdown
inside a <pre> via React text (escaped), no dangerouslySetInnerHTML.
This is a holding pattern until exam mode is removed entirely.

* chore(ssr): drop unused exports (knip pass)

- domain/index.ts: drop the four problem-statement-rendering re-exports
  (callers import directly from problemStatementRendering.ts).
- problemStatementRenderService: unexport mapFeedbacksToTestInputs,
  buildResultSummary, ServerRenderResult — only consumed internally.

* fix(ssr): guard against exam markdown reaching the render endpoint

_backgroundRenderProblemStatement is called unconditionally on
VS Code theme changes (registerThemeListener). When an exam exercise
is the active view, currentExerciseData structurally satisfies the
ExerciseDetailsResponse cast, so the active exam markdown was being
POSTed to /api/exercise/problem-statement/render even though the
exam view itself ignores the response.

Bail out at the top of the renderer if the active state is not
'exercise-detail'.

* fix(ssr): dispose render service config listener

ProblemStatementRenderService registered an onDidChangeConfiguration
listener in its constructor that was never disposed. The long-lived
instance in artemisWebviewProvider leaked one listener for the
lifetime of the host; the short-lived instances created per
FreshSsrPreview leaked one listener per invocation.

- Implement vscode.Disposable on the service; capture and dispose the
  config listener.
- Push the long-lived instance onto provider._disposables.
- Wrap FreshSsrPreview render in try/finally to dispose the
  short-lived instance.

* refactor(problem-statement): drop dead downloadLinks plumbing, tighten tests

The downloadLinks prop was permanently fed an empty array ('simplified -
in real implementation would parse from markdown'). The Downloads
section, its CSS, its tests, and the onDownload callback were all dead.

- Drop downloadLinks + onDownload from ProblemStatementProps.
- Drop the Downloads section and its 3 CSS classes.
- Drop the 4 download-related tests.
- Move vi.useRealTimers() into afterEach so a fake-timer test failure
  cannot leak into the next test.
- Make the link-href test actually assert the href attribute.
- Add a small test covering the KaTeX <script>/<link> stripping.

* refactor(messages): name the rendered-problem-statement payload type once

The { html: string } shape was duplicated in exerciseDetailInit and
problemStatementRendered. Pull it into a named
RenderedProblemStatementPayload so the shape is described in one place.

* fix(api): validate problem-statement render DTO before returning

The DTO's html field is injected via dangerouslySetInnerHTML. Add a
small runtime type guard for html / contentHash / rendererVersion so
a malformed server response surfaces as a clear error instead of
silently turning into 'undefined' HTML in the DOM.

* docs(ssr): correct misleading log wording

The unsupported-server flag is reset on Artemis URL config change, so
'disabled permanently' was misleading. Reword to 'disabled until
config change'.

* test(exercise-detail): use ExtensionMsg constant instead of magic string

Drop the raw 'problemStatementRendered' string in favour of the
discriminated-union constant so a future rename of the message type
fails at compile time, not at runtime.

* test(ssr): add unit tests for ProblemStatementRenderService

Cover behaviour that was previously untested:
- short-circuit on missing markdown or exerciseId
- request shape (includeJs:false, locale, darkMode, inlineImages)
- relative URL rewriting against the configured server
- cache hit + invalidation
- 404 / 405 / 501 permanently disabling within a session
- transient 500 retries
- config-change resets the unsupported-server flag and clears cache
- dispose() releases the config listener

* refactor(ssr): drop dead resultSummary payload, consolidate feedbacks extraction

Audit against Artemis backend (ProblemStatementRenderingService.java) revealed
that resultSummary only emits a data-result DOM attribute, consumed exclusively
by interactive.js. Since the extension hardcodes includeJs:false, every field
of ResultSummaryInput was dead on arrival.

testResults stays — load-bearing for task icons, X-of-N stats, and PlantUML
coloring regardless of includeJs.

Also collapses the latestSubmission/latestResult extraction logic that was
duplicated across artemisWebviewProvider, utilityCommands, and exerciseDataLoader
into a single pickHighestId helper. Service render() now takes an options object
and walks participation internally, so call sites stop reaching into submissions
and results.

Codex flagged a related risk: the duplicated call sites sorted by id while
buildResultSummary used completionDate, which would have selected a different
result than the feedbacks payload. Dropping resultSummary eliminates that
inconsistency.

* fix(ssr): enforce Artemis validation constraints on testResults payload

Backend rejects requests that violate ProblemStatementRenderRequestDTO /
TestFeedbackInputDTO validation (testResults @SiZe(max=100), testName
@SiZe(max=500), message @SiZe(max=5000), duplicate testIds → 422).

The mapper now:
- clamps testName to 500 chars and message to 5000 chars
- dedupes by testId (first occurrence wins)
- caps testResults at 100 entries

Long compile/test output in feedback.detailText was the realistic trigger.
It easily exceeds 5000 chars and would have caused the entire render request
to fail server-side validation, leaving the user with no rendered problem
statement.

Adds unit coverage for the new constraints and a dedicated test file for
participationHelpers (pickHighestId + extractLatestFeedbacks).

* fix(ssr): short-circuit render when markdown violates DTO validation

Backend ProblemStatementRenderRequestDTO also constrains markdown:
@SiZe(max=100_000) and a null-byte regex reject. A failed request would
not be classified as 'server unsupported' (non-{404,405,501}), so it would
retry on the next render attempt and keep failing.

Skipping client-side avoids the wasted round-trip and the noisy log loop.

Adds two unit tests covering oversize and null-byte markdown.
* feat: server-side problem statement rendering (v2, React stack)

Integrate Artemis POST /api/exercise/problem-statement/render endpoint
into the React-based extension with progressive enhancement:

- ProblemStatementRenderService: LRU cache, feature detection, race guards
- ProblemStatement.tsx: accepts serverRenderedHtml prop, skips client processing
- CSS bridge maps VS Code theme variables to Artemis server CSS variables
- Typed ProblemStatementRendered message contract
- Background render triggered on exercise open, result sent via postMessage

* debug: add SSR/Client indicator to Exercise Description header

* debug: add console.log for SSR message reception in webview

* fix: include SSR result in exerciseDetailInit message to avoid race condition

* chore: remove debug indicators and console.log from SSR integration

* fix: improve CSS bridge to match VS Code theme (fonts, headings, code, task colors)

* feat: add dev-mode CSS toggle to switch between VS Code bridge and raw server CSS

* refactor: move SSR CSS toggle button to Developer Tools panel

* fix: keep server pink for inline code, use semi-transparent background for visibility

* fix: ensure server pink inline code color wins over client CSS module styles

* fix: constrain PlantUML SVG diagrams to fit within container width

* fix: brighten PlantUML SVG diagrams for better visibility in dark themes

* feat: add 'View SSR HTML' button in dev tools to inspect server-rendered HTML

* fix: increase SVG brightness filter to 1.8 for visible diagram lines in dark mode

* fix: target SVG link/entity elements specifically for dark mode visibility

* debug: log darkMode value in SSR request

* fix: scope PlantUML SVG overrides to dark mode, re-trigger SSR on reload

Dark-mode CSS overrides for PlantUML diagrams (stroke: #ccc/#aaa) made
lines invisible in light mode. Scoped to body.vscode-dark and
body.vscode-high-contrast only.

Reload button dropped back to client render because
handleReloadExerciseDetail never re-triggered background SSR. Added
backgroundRenderProblemStatement to WebViewActionHandler interface and
call it after sendInitData in the reload path.

* fix: re-render SSR on VS Code theme change

When the user switches between light/dark themes, invalidate the SSR
cache and re-request from the server with the correct darkMode value.
Without this, switching to light mode kept showing the dark-themed SVG
(light strokes on transparent background = invisible on white).

* fix: darken PlantUML arrows in light mode for better visibility

The Artemis light theme uses #808080 for extension/composition arrows,
which is too faint on white. Darken to #444 in vscode-light and
vscode-high-contrast-light, scoped via attribute selector to only
target the gray arrows (not the already-black dependency arrows).

* fix: match Artemis web PlantUML appearance in light mode

Server SSR produces muted SVGs (#808080 arrows/text, 0.5px borders)
while the Artemis web client renders green arrows, green attribute
text, and visible box borders. CSS overrides now:
- Bump entity border stroke-width from 0.5 to 1
- Recolor #808080 arrows to #28a745 (Artemis green)
- Recolor #808080 attribute/method text to green
- Increase arrow stroke-width to 1.5

* fix: increase PlantUML entity border stroke-width to 2px

* feat: add SSR preview panel, remove all PlantUML SVG CSS overrides

Add "Preview SSR" dev tools button that opens a webview panel showing
the rendered server HTML (including inline SVG diagrams).

Remove all PlantUML SVG CSS overrides (dark-mode stroke lightening,
light-mode green recoloring, border-width bumps). The server delivers
the correct colors via the Artemis PlantUML themes — the same code
path as the web client. CSS overrides were fragile and unnecessary.

* fix: remove width:auto and !important overrides from PlantUML SVGs

Stop overriding SVG width and remove !important flags. Match the
server's own CSS exactly (max-width:100%;height:auto). The extra
width:auto !important was likely causing SVG stroke rendering
differences between the sidebar and SSR preview panel.

* fix: allow inline style attributes in CSP for server-rendered SVGs

The CSP style-src directive with nonce blocked ALL inline style=
attributes, including those on SVG elements from the server-rendered
problem statement. This made SVG strokes/borders invisible because
PlantUML uses style="stroke:#000;stroke-width:0.5" on rect/path
elements while fill colors use SVG attributes (which aren't affected).

Add style-src-attr 'unsafe-inline' to allow inline style attributes
while keeping nonce protection for <style> elements.

* fix: pass feedbacks from latest submission to SSR render

Was accessing submissions[0] (oldest) instead of the latest submission
sorted by ID. The feedbacks with testCase.id were on the latest result
but we were reading from the first array element. Now matches the same
sorting logic as exerciseDataLoader.

* feat: bridge server task CSS for icons, stats, and status colors

The server's embedded <style> block is blocked by CSP (no nonce).
Replicate the task-related CSS rules in our CSS module so checkmarks,
crosses, x/y stats, and status colors render correctly. Uses VS Code
theme variables with server CSS variable fallbacks.

* fix: allow server <style> through CSP, remove replicated CSS

Replace nonce-based style-src with 'unsafe-inline' so the server's
embedded <style> block is no longer blocked by CSP. This means the
server's own CSS for tasks, icons, stats, diagrams, and typography
is applied directly — no need to replicate it in our CSS module.

Removed ~50 lines of bridged server CSS (task icons, stats colors,
SVG layout). Only the CSS variable bridge remains (mapping VS Code
theme vars to server CSS custom properties).

* fix: allow data: URIs in img-src CSP for task status icons

* refactor: remove CSS bridge, use pure server rendering for SSR

Server-rendered content now uses only the server's own CSS — no
VS Code theme variable bridge, no heading overrides, no inline code
fixes. The server <style> comes through CSP directly.

Removed: CSS variable bridge, heading/code overrides, useServerCssOnly
toggle and prop. Client-rendered fallback still uses .problemStatement.

Note: dark mode will need server-side CSS fixes (fallback colors are
light-mode only).

* feat: add Preview Light / Preview Dark buttons for SSR debugging

Each button fetches fresh SSR from the server with explicit darkMode
flag, bypassing the cache. Opens in a preview panel with matching
background color. Useful for tuning the server's dark mode CSS.

Also adds darkModeOverride parameter to render service for this.

* fix: pass CSP nonce to interactive script for task feedback modal

The dynamically created script element for the server's interactive
JS was blocked by CSP (script-src requires nonce). Pass the nonce
via data-csp-nonce on the root element and apply it to the script.

* refactor: remove client-side problem statement rendering

Server-side rendering is now the only rendering path. Removed:
- problemStatementProcessor.ts (Markdown/KaTeX/PlantUML client pipeline)
- plantUmlCommands.ts (inline PlantUML rendering via extension API)
- PlantUmlRendered/PlantUmlError extension messages
- RenderPlantUmlInline webview command
- All .problemStatement CSS rules (200+ lines of client styling)
- vscodeApi prop from ProblemStatementProps

When SSR is unavailable, raw markdown is shown as plain text fallback.
The ProblemStatement component is now ~60 lines (was ~160).

* refactor: use VS Code language instead of Artemis user profile langKey

Simpler and more consistent — the extension UI is already in the
VS Code language, so the SSR content should match. Removed userLangKey
parameter from render/debouncedRender, now always uses vscode.env.language.

* refactor: hardcode locale to 'en' since extension is English-only

* refactor: rename interactive to includeJs, add includeCss to request DTO

Match server-side rename of interactive → includeJs for consistency
with the new includeCss flag. Both default to true on the server.

* feat: show skeleton loading state for problem statement instead of raw markdown

* fix: remove dead code, consolidate CSS, fix ExamExerciseDetail SSR regression

* feat(problem-statement): use iframe srcdoc for SSR rendering

Replace dangerouslySetInnerHTML with iframe srcdoc to render the
server-provided HTML document. This enables KaTeX and interactive
scripts to execute properly, matching how iOS WKWebView handles
SSR content. Add auto-resize and frame-src CSP directive.

* feat(problem-statement): render KaTeX client-side via bundled npm package

Replace the iframe approach with dangerouslySetInnerHTML + client-side
KaTeX rendering. The server provides formula placeholders which are
rendered after DOM injection using the bundled katex npm package.
Copy KaTeX assets to dist/katex/ at build time. Strip server KaTeX
script/link tags and extract body content from the full HTML document.

* refactor(problem-statement): memoize body HTML and fix regex robustness

Use useMemo for bodyHtml derivation to avoid regex recomputation on
every render. Use [\s\S]*? instead of [^<]* in KaTeX script stripping
for multiline content. Remove duplicate katex.min.css import (already
in index.tsx).

* fix(problem-statement): use forEach over NodeList to avoid iterator requirement

* test(exercise-detail): update problem-statement test to expect SSR-rendered HTML

The ProblemStatement component no longer renders raw markdown; it waits
for server-rendered HTML via problemStatementRendered message and shows
a skeleton until then. Simulate that message in the test.

* feat(problem-statement): request inlineImages from server for SSR

VS Code webviews have no JWT cookie, so absolute image URLs returned by
the SSR endpoint cannot be loaded. Hardcode inlineImages=true so the
server embeds images as base64 data URIs. Include the flag in the cache
input hash to invalidate stale entries if the value ever changes.

* chore(api): drop unused submitStudentExam (resurrected by dev merge)

The function was removed in dev cleanup f2922cd but came back via the
old iris-thaumantias path during the dev merge into this branch. No
callers exist.

* chore(esbuild): drop dead copyKatexAssets

KaTeX is consumed via npm import in webview/index.tsx (CSS) and
ProblemStatement.tsx (formula rendering). Nothing references dist/katex,
so the build-time copy step is dead.

* chore(plantuml): drop dead extension-side PlantUML stack

Webview no longer renders PlantUML client-side (server-side rendering
handles it now), so the extension-side counterparts are dead:

- ArtemisApiService.renderPlantUmlToSvg
- artemis.renderPlantUmlFromWebview command + registration
- processPlantUml util + re-export
- plantUmlProcessor + artemisApi PlantUML unit tests
- LogCategory.PLANTUML (no remaining users)
- baseWebviewProvider EVENT_TYPES entries for plantUmlRendered / plantUmlError

* chore(deps): drop unused marked package

Top-level marked was only consumed by problemStatementProcessor, which
this PR removed. Streamdown / mermaid still pull their own pinned
versions transitively, which is fine.

- package.json: drop marked + @types/marked
- knip.json: drop @types/marked ignore
- src/extension/types/ambient/marked.d.ts: deleted
- package-lock.json: regenerated

* refactor(ssr): drop interactiveScript plumbing and unused render helpers

The iframe srcdoc design that needed interactiveScript was replaced by
dangerouslySetInnerHTML + bundled-KaTeX rendering. The script field was
never executed in the new path, so all of its plumbing was dead.

- Drop interactiveScript through DTO, ServerRenderResult, app state,
  webview provider, message contracts, and ExerciseDetailView state.
- Drop the data-csp-nonce attribute on the root element (was only for
  the dropped script).
- Drop the orphan source: 'server' field on ServerRenderResult.
- Hardcode includeJs: false in the request builder (server no longer
  needs to emit the script).
- Drop unused debouncedRender (and debounceTimers/pendingResolvers
  fields), invalidateExercise, and isServerAvailable from the service.
- Drop the unused isExamExercise param from render() — exam view uses
  its own plaintext fallback.
- Drop PreviewSsrHtml command + handler (no senders; Fresh SSR Preview
  is the only entry point).
- Update stale comment in ProblemStatement.tsx; remove a CSP comment
  block whose wording was tripping the csp.test.ts security regex.

* fix(exam): render problem statement as escaped plaintext

Exam mode never goes through the SSR pipeline (it short-circuits in
ProblemStatementRenderService), so passing raw markdown into the
SSR-only ProblemStatement component injected unrendered markdown as
HTML.

Replace with an inline Container that shows a notice and the markdown
inside a <pre> via React text (escaped), no dangerouslySetInnerHTML.
This is a holding pattern until exam mode is removed entirely.

* chore(ssr): drop unused exports (knip pass)

- domain/index.ts: drop the four problem-statement-rendering re-exports
  (callers import directly from problemStatementRendering.ts).
- problemStatementRenderService: unexport mapFeedbacksToTestInputs,
  buildResultSummary, ServerRenderResult — only consumed internally.

* fix(ssr): guard against exam markdown reaching the render endpoint

_backgroundRenderProblemStatement is called unconditionally on
VS Code theme changes (registerThemeListener). When an exam exercise
is the active view, currentExerciseData structurally satisfies the
ExerciseDetailsResponse cast, so the active exam markdown was being
POSTed to /api/exercise/problem-statement/render even though the
exam view itself ignores the response.

Bail out at the top of the renderer if the active state is not
'exercise-detail'.

* fix(ssr): dispose render service config listener

ProblemStatementRenderService registered an onDidChangeConfiguration
listener in its constructor that was never disposed. The long-lived
instance in artemisWebviewProvider leaked one listener for the
lifetime of the host; the short-lived instances created per
FreshSsrPreview leaked one listener per invocation.

- Implement vscode.Disposable on the service; capture and dispose the
  config listener.
- Push the long-lived instance onto provider._disposables.
- Wrap FreshSsrPreview render in try/finally to dispose the
  short-lived instance.

* refactor(problem-statement): drop dead downloadLinks plumbing, tighten tests

The downloadLinks prop was permanently fed an empty array ('simplified -
in real implementation would parse from markdown'). The Downloads
section, its CSS, its tests, and the onDownload callback were all dead.

- Drop downloadLinks + onDownload from ProblemStatementProps.
- Drop the Downloads section and its 3 CSS classes.
- Drop the 4 download-related tests.
- Move vi.useRealTimers() into afterEach so a fake-timer test failure
  cannot leak into the next test.
- Make the link-href test actually assert the href attribute.
- Add a small test covering the KaTeX <script>/<link> stripping.

* refactor(messages): name the rendered-problem-statement payload type once

The { html: string } shape was duplicated in exerciseDetailInit and
problemStatementRendered. Pull it into a named
RenderedProblemStatementPayload so the shape is described in one place.

* fix(api): validate problem-statement render DTO before returning

The DTO's html field is injected via dangerouslySetInnerHTML. Add a
small runtime type guard for html / contentHash / rendererVersion so
a malformed server response surfaces as a clear error instead of
silently turning into 'undefined' HTML in the DOM.

* docs(ssr): correct misleading log wording

The unsupported-server flag is reset on Artemis URL config change, so
'disabled permanently' was misleading. Reword to 'disabled until
config change'.

* test(exercise-detail): use ExtensionMsg constant instead of magic string

Drop the raw 'problemStatementRendered' string in favour of the
discriminated-union constant so a future rename of the message type
fails at compile time, not at runtime.

* test(ssr): add unit tests for ProblemStatementRenderService

Cover behaviour that was previously untested:
- short-circuit on missing markdown or exerciseId
- request shape (includeJs:false, locale, darkMode, inlineImages)
- relative URL rewriting against the configured server
- cache hit + invalidation
- 404 / 405 / 501 permanently disabling within a session
- transient 500 retries
- config-change resets the unsupported-server flag and clears cache
- dispose() releases the config listener

* refactor(ssr): drop dead resultSummary payload, consolidate feedbacks extraction

Audit against Artemis backend (ProblemStatementRenderingService.java) revealed
that resultSummary only emits a data-result DOM attribute, consumed exclusively
by interactive.js. Since the extension hardcodes includeJs:false, every field
of ResultSummaryInput was dead on arrival.

testResults stays — load-bearing for task icons, X-of-N stats, and PlantUML
coloring regardless of includeJs.

Also collapses the latestSubmission/latestResult extraction logic that was
duplicated across artemisWebviewProvider, utilityCommands, and exerciseDataLoader
into a single pickHighestId helper. Service render() now takes an options object
and walks participation internally, so call sites stop reaching into submissions
and results.

Codex flagged a related risk: the duplicated call sites sorted by id while
buildResultSummary used completionDate, which would have selected a different
result than the feedbacks payload. Dropping resultSummary eliminates that
inconsistency.

* fix(ssr): enforce Artemis validation constraints on testResults payload

Backend rejects requests that violate ProblemStatementRenderRequestDTO /
TestFeedbackInputDTO validation (testResults @SiZe(max=100), testName
@SiZe(max=500), message @SiZe(max=5000), duplicate testIds → 422).

The mapper now:
- clamps testName to 500 chars and message to 5000 chars
- dedupes by testId (first occurrence wins)
- caps testResults at 100 entries

Long compile/test output in feedback.detailText was the realistic trigger.
It easily exceeds 5000 chars and would have caused the entire render request
to fail server-side validation, leaving the user with no rendered problem
statement.

Adds unit coverage for the new constraints and a dedicated test file for
participationHelpers (pickHighestId + extractLatestFeedbacks).

* fix(ssr): short-circuit render when markdown violates DTO validation

Backend ProblemStatementRenderRequestDTO also constrains markdown:
@SiZe(max=100_000) and a null-byte regex reject. A failed request would
not be classified as 'server unsupported' (non-{404,405,501}), so it would
retry on the next render attempt and keep failing.

Skipping client-side avoids the wasted round-trip and the noisy log loop.

Adds two unit tests covering oversize and null-byte markdown.

* docs(spec): test-results view tracking and per-task feedback modal

Design doc for tracking when users view test results (overall and per-task)
plus adding click-handling on [task] entries in the SSR'd problem statement
to open a per-task feedback modal.

* docs(spec): revise test-results tracking spec per codex review

Address 6 findings from codex review of v1:
- Switch wiring from CommandContext injection to provider-events +
  sessionRecorderWiring subscription (matches existing chat/intervention
  pattern, no CommandContext changes needed).
- Move TestResultsOverlay rendering out of SubmissionStatus into
  ExerciseDetailView so the parent owns viewId/duration lifecycle and
  can enforce the single-modal invariant.
- Use real ProblemStatement prop name (serverRenderedHtml, not html).
- Reconcile close-event schema with lifecycle text: add participationId
  and resultId to close events; add 'replaced' to closeReason union;
  remove ambiguous 'synthetic close' wording.
- Add explicit failure semantics for filterTestCasesByIds when
  testCase.id is missing, stale, or from a different participation.
- Add command-contract tests covering WebCmd registration,
  COMMANDS_REQUIRING_PAYLOAD, and the provider-event wiring.

* docs(spec): fix codex round-2 findings on test-results tracking spec

- WebCmd payload schema now mirrors recorder event schema (participationId,
  resultId, required 4-variant closeReason).
- Provider-event wiring is implementable via IProviderRegistry extension:
  add getArtemisWebviewProvider/setArtemisWebviewProvider with new
  IArtemisWebviewProvider interface. CommandContext stays unchanged.
- Exam-mode non-goal corrected. ExamExerciseDetailView gets its own
  untracked overlay mount; tracking is regular-exercise-detail only.
- closeIdentity snapshot now includes participationId/resultId so the
  snapshot pattern works for the new schema.
- Lifecycle flow text aligned with provider-events pattern; no longer
  says command handlers call the recorder directly.

* docs(spec): apply codex round-3 cleanup nits

- Architecture diagram onClose union now matches detailed spec (3 user
  reasons; 'replaced' is parent-emitted, not overlay-emitted).
- Activation file named explicitly (extension.ts:100) rather than
  'or wherever'.
- Note that the 4 new EventEmitters must be disposed with the provider.
- Tighten command-handler test description: getPayload only catches the
  missing-payload case, does not shape-validate.

* docs(plan): test-results tracking and per-task feedback modal

20-task TDD implementation plan for the spec at
docs/superpowers/specs/2026-05-12-test-results-tracking-design.md.

* docs: revise spec + plan per codex review of plan (round 1)

Spec changes:
- Drop 'backdrop' from closeReason union. Current full-screen modal layout
  has no clickable backdrop area; only X-button, Escape, and 'replaced'
  (synthetic on modal switch) remain.

Plan changes:
- Task 8: subscribe directly via artemisWebviewProvider from
  RecorderWiringDeps (no providerRegistry indirection). Extend the existing
  sessionRecorderWiring.test.ts with 4 forwarding tests.
- Tasks 15 + 16 (separate SubmissionStatus and ExamExerciseDetailView
  commits) merged into Task 17 (now: Migrate overlay ownership and wire
  ExerciseDetailView tracking) — single atomic commit avoids the
  intermediate broken-typecheck state.
- Task 17: use real variable names (participation, participationId),
  store testIds + derive filtered list live so the modal updates with
  new build results (matches spec). Real test for the 'replaced' flow
  via DOM-simulated task click (replaces placeholder).
- Task 17 fixture: results live on submission.results (matches the
  component's lookup path), not participation.results.
- Task 16: CSS uses :global(.artemis-task) so the literal SSR class
  is targeted under CSS modules.
- Task 3: add contract test for COMMANDS_REQUIRING_PAYLOAD membership
  (spec-required, was missing).

* docs: drop 'replaced' closeReason; tighten plan per codex review of plan (round 2)

Spec changes:
- closeReason union reduced to 'button' | 'escape' (no 'replaced').
  Rationale: the TestResultsOverlay is full-screen, which covers all
  in-UI triggers for the other modal. The user cannot reach a switch
  affordance while a modal is open, so the 'replaced' transition is
  unreachable. Documented this reasoning in the spec.
- OpenTaskView state now stores testIds (not snapshotted 'filtered'),
  matching the live-derivation pattern the plan uses.

Plan changes:
- Drop 'replaced' tests and the defensive 'if (openOther) handleOtherClose'
  branches in handlers — both are dead code paths now.
- Task 8 wiring tests rewritten to use the real makeWiringHarness +
  stubWebviewProvider pattern; harness now exposes artemisWebviewProvider
  so tests can fire its events.
- Task 15 ProblemStatement test snippet no longer duplicates existing
  imports; only adds 'userEvent' and appends the new describe block.
- Final-verification counts replaced with approximate per-file deltas
  (the prior exact numbers became wrong after Task 3 and Task 8 added
  more tests).

* docs: address codex round-3 coverage gaps (test-results tracking plan)

- Task 2: add positive test for recordTaskFeedbackClosed (closeReason='escape')
  alongside the existing closed-event tests.
- Task 9: add three new positive tests for testResultsOverviewClosed,
  taskFeedbackOpened, taskFeedbackClosed command handlers (only
  TestResultsOverviewOpened was being asserted).
- Task 17: re-add the task-click React test that was lost when 'replaced'
  was dropped. New test dispatches ProblemStatementRendered, clicks the
  rendered .artemis-task[data-test-ids] span, asserts taskFeedbackOpened
  payload (taskName, testIds, counts, viewId).
- Spec: remove the manual test step describing the impossible
  task-modal-then-See-test-results switch (full-screen overlay blocks it,
  matches the round-2 reasoning at spec line 96).

* docs(plan): fix task-count nit in final verification (2 not 1 ExerciseDetailView tests)

* feat(recorder): add TestResultsOverviewView and TaskFeedbackView event types

* feat(recorder): add public API for test-results and task-feedback view events

* feat(messages): add webview commands for test-results view tracking

* feat(provider): add IArtemisWebviewProvider interface for view-tracking events

* feat(provider-registry): expose ArtemisWebviewProvider via registry

* feat(provider): emit view-tracking events from ArtemisWebviewProvider

* feat(activation): register ArtemisWebviewProvider in provider registry

* feat(wiring): subscribe to view-tracking events and forward to recorder

* feat(commands): add TestResultsTrackingCommandModule

* feat(commands): register TestResultsTrackingCommandModule

* feat(webview): add makeViewId helper for view-event pairing

* feat(testcase): carry testCase.id through to UI test cases for per-task filtering

* feat(webview): add filterTestCasesByIds helper

* feat(overlay): add taskName prop and typed onClose(reason) to TestResultsOverlay

* feat(problem-statement): emit onTaskClick when a [task] span is clicked

* style(problem-statement): pointer cursor and underline on clickable task spans

* feat(exercise-detail): migrate overlay ownership and wire test-results view tracking

- Remove TestResultsOverlay mount from SubmissionStatus; replace toggle prop
  with onOpenTestResults callback.
- Exam view (ExamExerciseDetailView) now mounts its own untracked overlay.
- ExerciseDetailView owns both overlay instances (overview + per-task),
  generates viewId per open, computes durationMs at close, and forwards
  the four new tracking commands. Live re-derivation of filtered tests
  from current testCases lets the modal update across build-result events.
- Update test fixture so result feedbacks live on the submission (matching
  the real component's lookup path).
- Update SubmissionStatus tests to reflect renamed prop (onOpenTestResults).

* refactor: hoist OpenViewState types to module scope; drop unused export

- Move OpenViewState/OpenTaskViewState interface declarations out of the
  ExerciseDetailView function body to module scope. They were not visible
  to tests or sibling files; hoisting makes them importable and removes
  the visual noise inside the component.
- TestResultsOverlayCloseReason is internal-only (no external imports);
  remove the unused 'export' keyword (knip flagged it).

* style(problem-statement): preserve default task style, hover-only affordance

Remove always-on underline on clickable tasks; rely on cursor + hover color
shift (vscode-textLink-activeForeground) so the resting state matches the
Artemis SSR default exactly.
The hand-rolled thumbs-down SVG path was malformed and rendered as a
filled blob when selected. Swapped both feedback buttons to the Lucide
ThumbsUp/ThumbsDown icons already imported elsewhere via iconMap.
…151)

Swapped ~25 hand-rolled <svg> icons across BackLink, ServiceHealth,
TextInput, IrisChatView, ChatInput, CodeBlock, ContextSelector,
ReferencedFiles, and ExamConductionView for the matching lucide-react
components (Send, Menu, Info, Eye/EyeOff, ChevronLeft/Down/Right,
RefreshCw, File/FileText, BookOpen, MessageSquare, Check, XCircle,
Search, Circle, ExternalLink, Copy).

Removes a class of bugs (the recent thumbs-down filled-blob issue came
from a malformed inline path) and aligns with the existing iconMap
tree-shaking pattern. All descendant CSS selectors keep working since
Lucide renders <svg> with currentColor strokes.

138 vitest tests pass, tsc + eslint clean.
The provider's createNewSession redirected to the workspace exercise
whenever a workspace exercise was detected and the active context was
something else, swallowing the click instead of creating a new session.
The dedicated "Chat about Workspace Exercise/Course" buttons in the
context selector already cover that switch, so the redirect is dead
UX. Delegate straight to the session service.
Planning specs and plans for intervention-ui-toggle, test-results-tracking,
and iris-api-unified-sessions are no longer load-bearing — the features
they describe have all shipped. Drop them so the repo stops carrying
historical planning snapshots that nothing else references.
…nd EQ snapshot intake (#152)

Four thesis-evaluation-relevant correctness fixes in the struggle-detection
pipeline. All four would have biased recorded metrics or distorted UI
delivery behaviour during a study run.

#1  Adaptive cadence now only increments on explicit 'user-action' dismissals.
    Implicit lifecycle dismissals ('replaced', 'hidden', 'session-end') no
    longer skew the per-trigger ignore counters that drive threshold
    escalation.

#2  Dismiss events without a triggerType are skipped with a logged warning
    instead of falling back to 'idle' (which polluted that bucket).

#1b InterventionState.lastDismissed is no longer flipped by implicit
    dismissals. Previously, a subtle-hint replace or a build-success hide
    would suppress the next subtle/notification delivery via
    InterventionFilter, even though the user never dismissed anything.
    _emitDismiss is now pure event emission; the user-action paths set
    the flag explicitly.

#5  Build results no longer take an inline snapshot path next to the
    onDidEmitCompileEquivalent listener. Both save and build snapshots
    flow through a single listener that propagates event.source,
    eliminating the fragile source-based filter. handleBuildResult
    returns void.

Tests: 794 unit / 135 struggle / 0 failures.
…ndpoints (#153)

#3  getOrCreateVcsAccessToken no longer falls back to PUT on every error.
    Empirically verified against the Artemis backend (AccountResource.java +
    UserTestService.java): 'no token yet' surfaces as 404. Only that status
    triggers the create-token PUT now; 401/403/5xx and network errors
    propagate so the caller does not pile a second failure on top of an
    already-broken auth or server state.

#4  getLatestPendingSubmission no longer maps every error to null.
    Policy: 404 or empty/null body → null; 401/403/5xx → throw; malformed
    non-empty JSON → throw MalformedResponseError with endpoint context.
    Caller in exerciseDataLoader was updated with a matching enrichment-
    error policy (401/403 and MalformedResponseError rethrow; transport
    failures log a warning and the exercise page still loads).

Additional hardening:
  - New MalformedResponseError subclass of ApiError so callers can
    distinguish schema/contract failures from transport failures cleanly
    (replaces the prior stringly-typed message check).
  - getLatestResultWithFeedbacks now wraps JSON parse failures in
    MalformedResponseError so the loader policy applies uniformly.
  - parseProgrammingSubmission now rejects arrays and requires a real
    finite numeric id (was tolerating null/false/'' via Number() coercion).

Tests: 815 unit / 135 struggle / 0 failures. Plan and code reviewed by
codex; all blockers resolved before commit.
While the first connect() awaits _client.deactivate(), a parallel connect()
call observes state==='connecting' at the top of the method and returns the
existing _connectDeferred.promise. If the catch path then fired (deactivate
failed, auth headers empty), the old code dropped the deferred without
settling it, and every concurrent awaiter hung forever.

The catch now routes through _settleDeferred(error). To keep the
single-caller catch path clean (no external awaiter on the internally-created
deferred), _settleDeferred attaches a defensive .catch() to the deferred's
promise before rejecting it. External awaiters still observe the rejection
via their own await; Node no longer reports an unhandled rejection when no
external awaiter exists.

Tests:
  - concurrent-awaiter scenario: deactivate held open by a gate so a second
    connect() attaches to the same deferred; both awaiters must reject with
    the underlying error.
  - single-caller scenario: process.on('unhandledRejection') hook asserts
    no rejection escapes the new catch path.
…-command bandaid (#155)

Pre-existing code wrapped `vscode.commands.registerCommand` for the subtle-
accept command in a try/catch that silently swallowed VS Code's
"command already exists" error. That kept tests passing across multiple
TelemetryManager instances but routed status-bar clicks at runtime to a
stale handler whenever the lifecycle accidentally double-registered.

Production lifecycle:
  - extension.ts:deactivate now calls `activeTelemetryManager.dispose()`
    explicitly (was `endCurrentSession()`), wrapped in try/catch with a
    logged error. The dispose path tears down InterventionService's
    command/status-bar disposables; the old session-end-only path left
    them registered for the rest of the host process.
  - TelemetryManager.dispose() is now idempotent (early return on
    `_disposed`). VS Code disposes `context.subscriptions` after our
    explicit deactivate call, so the second teardown must be a safe no-op.
  - InterventionService no longer wraps registerCommand in a try/catch.
    Duplicate registration fails loudly now.

Tests:
  - Every suite that constructs InterventionService or TelemetryManager
    now stubs `vscode.commands.registerCommand` via a sinon sandbox so it
    does not collide with the extension's own activate()'d instance.
    `sandbox.restore()` in teardown guarantees cleanup even if a test
    throws mid-flight.
  - Added a small TelemetryManager.dispose() idempotency test.

818 unit / 135 struggle / 0 fail.
…ord* wrappers (#156)

All fifteen public `record*` methods (chat / view / EQ / lifecycle events)
had identical boilerplate: phase guard, `Date.now()` timestamp, empty
`opts`, current-generation capture, then delegate to
`LifecycleController.recordInternal`. About 200 LOC of repetition that hid
small accidental divergences across methods.

Centralised the pattern in `_record(event)`. Public methods become
declarative payload definitions.

Narrow on purpose:
  - `onNewResult` and any collector-based path (which carries its own
    timestamp / opts) still calls `_lifecycle.recordInternal` directly.
  - Generation is captured synchronously inside `_record`, identical to
    the previous per-method behaviour.

`RecordedEvent` is a discriminated union and the built-in `Omit` collapses
it; a distributive omit `RecordedEventWithoutTimestamp` keeps each variant
intact. `timestamp` is spread first so the serialised JSON line preserves
its pre-refactor byte-level field order.

818 unit / 135 struggle / 3 recorder-e2e — all green.
…157)

The single 110-LOC command handler had four concerns in line: collect
diagnostics, build the status report, decide the action set, dispatch the
user's choice. It also swallowed every auth-header failure silently and
duplicated the action labels between the build site and the handler switch.

Extracted:
  - `collectWebSocketStatus(ws, auth)` → `WSStatusSnapshot`
  - `buildStatusReport(status)` → markdown string
  - `decideStatusActions(status)` → `WSStatusAction[]`
  - `buildStatusHeadline(status)` → one-line notification headline
  - `handleStatusAction(action, ws, report)` → dispatcher

`WS_STATUS_ACTION` is a const-tuple so the labels can no longer drift
between build/handle sites. The previously silent catch on auth-header
lookup now logs at `warn` — the surrounding code path runs only on
explicit user request (artemis.checkWebSocketStatus), so visibility in
the output channel during a diagnostics session is desired.

Public API, UI/UX, and reconnect semantics unchanged.

818 unit / 0 fail.
The single 145-LOC `connect()` method covered six concerns: in-flight
reuse / state guards, generation bump, prior-client teardown, auth-header
validation, STOMP config assembly, and error settling. The race-sensitive
ordering (generation bumped before deactivate await, deferred created
before the try block, superseded-check after client assignment) was
implicit and easy to break in a future edit.

Extracted helpers:
  - `_maybeReuseInflightConnect()` — early-return for in-flight or stale
    connecting, throws for disconnecting / gave-up.
  - `_teardownExistingClient()` — deactivate + clear subs + null the
    client reference.
  - `_prepareConnectionContext()` — server URL, auth headers, JWT
    validation (pre-flight only — `connectHeaders` stays empty by
    design), ws URL.
  - `_buildStompConfig(generation, authHeaders, wsUrl)` — full StompConfig
    with generation-checked lifecycle callbacks.
  - `_handleConnectError(error)` — log + clearSubs + transition + settle
    the deferred (the settle keeps PR-3's concurrent-awaiter fix intact).

`connect()` is now a 25-line coordinator. Critical ordering is preserved:
generation bump → deferred creation → try { teardown await → context →
build config → createClient } → superseded check → activate. Every STOMP
callback still captures the same generation snapshot via the closure
parameter.

No behavior change. 818 unit / 135 struggle / 0 fail.
The 155-LOC handler nested six concerns (payload validation, git pre-flight,
default-clone-path validation, three-button "set default / choose / don't
ask" modal, programmatic clone, post-clone cache update) inside a single
try/catch. The "Clone cancelled - …" cancellation notice appeared five
times with two slightly different wordings; the FIFO eviction at size 10
used a bare magic number with an "LRU" comment that was inaccurate.

Extracted:
  - `_resolveCloneDestination(exerciseTitle)` — full path-resolution
    decision tree. Returns undefined on user cancel; cancellation
    messaging is a side effect.
  - `_validateDefaultClonePath(p)` — returns `undefined` if usable, or a
    short reason string for the warning toast.
  - `_pickFolderOrCancelClone(label, title)` — folder picker + the
    standard cancellation notice. Replaces five near-identical duplicates.
  - `_rememberClonedRepo(participationId, repoPath, title)` — FIFO
    eviction at `MAX_CLONED_REPO_CACHE_SIZE`. Comment now spells out
    FIFO-not-LRU, matching the actual insertion-order semantics.

`handleCloneRepository` itself is now ~40 lines: payload check → git
availability → resolveCloneDestination → buildAuthenticatedUrl →
programmatic clone → rememberClonedRepo → notice → Open-Folder prompt.

Minor copy-cleanup: the two slightly different cancellation wordings
("no folder selected." / "no destination selected.") are unified to
"no folder selected." Behavior, flow, modal buttons, picker labels, and
default-clone-path persistence are otherwise preserved.

Magic number 10 → `MAX_CLONED_REPO_CACHE_SIZE` constant.

818 unit / 0 fail.
Replaces ten silent 'as Promise<T>' / 'as unknown[]' casts in artemisApi.ts
with parser calls so an unexpected response shape from Artemis surfaces
as `MalformedResponseError` (subclass of `ApiError`) instead of garbage
typed-as-T flowing into the UI. Builds on the per-endpoint policy
established in PR-2 (exerciseDataLoader rethrows
`instanceof MalformedResponseError`).

New helper module `extension/src/extension/domain/responseValidation.ts`:
  - `expectObject(label, data)` — non-array object check.
  - `expectArray<T>(label, data, elementValidator?)` — array check + optional
    per-element validator.
  - `parseApiObject<T>(label, data, required?)` — object check + listed
    required primitive-typed fields.

All three throw `MalformedResponseError` so callers can keep using
`instanceof MalformedResponseError` to distinguish schema failures from
transport failures.

Endpoints wired up:
  - getArchivedCourses → expectArray + per-element expectObject
  - getCoursesForDashboard, getCourseForDashboard,
    getExerciseDetails → parseApiObject (shape only)
  - getIrisCourseChatSettings → parseApiObject
  - getChatMessages → expectArray + per-element expectObject
  - sendChatMessage (primary + fallback) → parseApiObject
  - getCurrentChat, createChatSession → parseApiObject with required
    {id: number}
  - listChatSessionsForCourse → expectArray + per-element parseApiObject
    with required {id, entityId, creationDate, mode}
  - getExamSidebarData → expectArray + per-element expectObject
  - getOwnStudentExam, startStudentExam → parseApiObject
  - getBuildLogs → simplified to expectArray(parseBuildLogEntry)
  - getLatestResultWithFeedbacks → parses to `unknown` first, then
    parseApiObject<ResultSummary> (was silently casting any non-empty
    JSON to ResultSummary, e.g. `[]`, `42`, `"ok"`)

Two remaining `as unknown` casts in renderProblemStatement live inside
the pre-existing `isRenderedProblemStatementDTO` typed-guard path and
stay as is.

Tests: 837 unit / 0 fail / 3 skip. Twelve helper-coverage tests plus
five integration tests on representative endpoints.
…161)

Synced four recording event types that the extension already emits but the
viewer had no handling for (configurationSnapshot, configurationChange,
testResultsOverviewView, taskFeedbackView). Each gets a marker color, a
badge CSS pair, EventStream + TrackingTimeline detail rendering, and a
legend entry in RecordingInfo. The constants.ts exhaustiveness check now
covers them so future stale syncs fail at compile time.
)

Deletes code that exists but does nothing, to make the extension easier
for a new developer to follow. All changes are behavior-preserving except
the removal of ThrashingDetector, an unused developer-mode debug metric
that fed no decision, EQ input, or recording event.

- AuthManager: drop unused getStoredTokenValue, simplify getStoredToken
- Delete the dead hasRecentlyClonedRepo chain (no callers)
- LoggingService: drop unused viewWarn
- IrisWebSocketSessionClient: drop write-only _isSubscribed field
- Delete ThrashingDetector (163 LOC; hashed the whole document on every
  keystroke for two debug-output lines)
- RecorderLifecycleState: drop the dead EventEmitter — the live
  state-change path is LifecycleController's plain callback
- BuildResultTracker: drop the misleading WebSocketMessageHandler
  interface and an always-empty disposables array
- Drop the unused messages parameter from createSessionWithDetails
- Delete ViewActionService, a pure pass-through layer; inline its three
  methods into ArtemisWebviewProvider (error boundaries preserved)
- SessionRecorder: remove a misleading byte-layout comment
…#164)

* refactor(extension): introduce @-path aliases for cross-layer imports

Replaces deep relative path traversals (../../../../shared/...) with
readable @-aliases so a new developer can tell at a glance which layer
an import crosses into.

- tsconfig.json: add baseUrl + paths for @extension, @webview, @shared
- compile-tests / watch-tests: run tsc-alias so the out/ test build
  resolves aliases to relative paths (tsc does not rewrite them)
- esbuild.js: set explicit tsconfig on both build contexts
- vitest.config.mts: mirror the three aliases in resolve.alias
- eslint.config.mjs: add layer-boundary rules — webview must not import
  @extension, extension must not import @webview, shared must not import
  either (prevents cross-layer leakage the aliases would otherwise allow)
- rewrite 653 imports across 254 files: cross-layer and deep same-layer
  imports become aliases; same-directory / single-level siblings stay
  relative; JSON and repo-root imports are left untouched

Behavior-preserving — module resolution only. Verified: check-types,
lint, knip, esbuild bundle (no leftover @-specifiers in dist/ or out/),
unit 835, struggle 135, react 873 all green.

* fix(extension): pin tsc-alias in lockfile, extend boundary lint to entry

- package-lock.json: reconcile the direct tsc-alias spec to the exact
  1.8.17 pin (was still ^1.8.17)
- eslint.config.mjs: include src/extension.ts in the extension-host
  layer-boundary block so the entry file is guarded too
* refactor(extension): sort and group imports consistently

Adds eslint-plugin-simple-import-sort so import order is enforced and
auto-fixable instead of ad-hoc. Imports are grouped, in order:

  1. side-effect imports (CSS etc.) — kept first, relative order preserved
  2. external packages (node builtins, vscode, npm)
  3. @shared/* layer
  4. @extension/* and @webview/* layers
  5. relative imports

simple-import-sort reorders named members but does not format them, so
the ESLint core comma-spacing rule is enabled to normalise the
separators it leaves behind. Applied repo-wide via eslint --fix.

Behavior-preserving — import order only. Verified: check-types, lint,
esbuild bundle, unit 835, struggle 135, react 873 all green; webview
side-effect CSS order (base.css before katex) preserved.

* refactor(extension): normalise multi-line import member layout

simple-import-sort reorders named members but does not reformat them,
which left 62 multi-line imports with inconsistent indentation and
brace placement. A codemod rewrites every named import/export to a
single line, or — past 120 chars — to a clean one-member-per-line
block. Member order (set by simple-import-sort) is unchanged.

Verified: check-types, lint, esbuild, unit 835 / struggle 135 /
react 873 all green.
* refactor(extension): remove exam mode entirely

Exam mode (exam start, conduction, exam-exercise detail, exam timer) is
out of scope for the extension's core purpose and carried a large amount
of code, state, types, and tests. Remove it in full.

- Delete the dedicated exam views, components, hooks, worker, utils, and
  stores, plus examErrorHandler and all exam-only tests.
- Strip exam entries from the shared message contracts (ExtensionMsg,
  WebviewCmd, COMMANDS_REQUIRING_PAYLOAD), domain types, and apiResponses.
- Remove the exam payload variants from AppStateManager's navigation
  union; currentExerciseData now returns ExerciseDetailsResponse only,
  which drops several `as` casts at the call sites.
- toCourseDetailData now explicitly omits server-provided exam data so no
  exam payload reaches the webview.
- Drop the exam exercise branches in ParticipationActions.
- Update the developer docs and architecture diagrams.

Closes #172. Also closes #169 (the unreachable-key bug lived in the
deleted examErrorHandler) and removes two cast sites tracked in #171.

* refactor(extension): remove dead code orphaned by exam-mode removal

After removing exam mode, several components and a hook were left with no
remaining consumers (their only callers were the deleted exam views):

- BuildProgress component (+ CSS + test) — only rendered by ExamExerciseDetail
- TestResults component (+ CSS + test) — only rendered by ExamExerciseDetail
- useRelativeTime hook — only used by ExamStart
- ExerciseDetail/types.ts: TestResultsProps + TestCase (used only by TestResults)
- domainTypes.ts: stale ExerciseDetail re-export (was for exam navigation)
- dompurify / @types/dompurify direct dependency — DOMPurify was only used
  by ExamStart; still present transitively via mermaid

Verified with knip (zero unused files/exports/dependencies), tsc, eslint,
716 react tests and 835 unit tests passing.
The Iris WebSocket only delivers complete MESSAGE / STATUS frames, never
chunked content. The store's appendStreamChunk / finishStreaming actions
were therefore unreachable, and the same was true for setMessages,
setMessageStatus, and the streamingChunks / visibleChunks read side that
fed StreamingMessage.

Removes all unreachable streaming infrastructure (4 store actions,
StreamingMessage component + CSS module, streaming props on MessageBubble,
visibleChunks reads in ChatMessageList) and their tests. StreamingState
is reduced to { isStreaming: boolean }, startStreaming() takes no args,
and the thinking-indicator path is preserved (driven by resetTransientChatUi
on assistant AddMessage, as in production today).

ChatMessage.status / errorMessage and the MessageBubble error UI are kept
deliberately. The error UI still reads them, and the missing write path
on send rejection is a separate concern.
…178) (#180)

* fix(webview): clear stuck thinking indicator on Iris send rejection (#178)

When the extension host rejected a send synchronously (no-context, no-ai,
iris-disabled), the webview was never told. The thinking indicator stayed
on forever, the optimistic user message was orphaned, and for no-context
the only feedback was a VS Code toast.

This change introduces an explicit SendRejected message from host to
webview, scoped by localId + localSessionId so stale rejections cannot
clobber a fresh request after the user switched session.

UI / UX

- Failed user messages now keep their original content visible and gain
  a "Not sent" footer with the rejection reason and a Retry button.
- Retry is gated per reason: iris-disabled is permanently unretryable,
  no-ai stays disabled while .noai is still detected, no-context stays
  disabled while no context is selected.
- ChatInput is now disabled while a send is in flight (prevents racing
  sends from each other's transient UI).
- no-context drops the VS Code toast; the inline error is sufficient.

Wire protocol

- sendMessage command gains localId + localSessionId.
- New SendRejected extension message: { localId, localSessionId, reason,
  errorMessage }.
- markMessageFailed only matches role==='user' && status==='sending' and
  returns whether it matched, so we never reset transient UI for a
  rejection that didn't correspond to a pending message.
- Backward fallback: if the host receives a sendMessage without
  correlation IDs, it falls back to an assistant-style AddMessage so
  the existing reset-on-AddMessage path still self-heals the indicator.

Tests

- useChatStore: markMessageFailed (matched / non-matched / wrong-role /
  wrong-status) and removeMessage.
- MessageBubble: Retry click, Retry disabled, original content preserved
  on error, no feedback buttons on failed messages.
- irisChat.flow: full rejection round-trip, stale rejection ignored,
  Retry removes+resends with fresh localId, ChatInput disabled during
  send, Retry disabled when reason still holds.

Out of scope: the catch-block error path in _handleChatMessage (covers
post-acceptance transport/backend failures, different UX model).

Closes #178

* test(webview): direct coverage for racing-second-send and retry-of-retry

Addresses codex review feedback on PR #180. The previous flow tests
proved the ChatInput becomes disabled and that a single retry produces
a fresh localId, but did not directly exercise:

- a second user-initiated Enter while streaming is true (verifying
  exactly one sendMessage command lands on the wire),
- a retry of an already-retried message followed by a second rejection
  (verifying the retried entry can itself be marked failed and that
  thinking clears again).

Both cases now have dedicated assertions in irisChat.flow.test.tsx.
)

* fix(extension): scope pending submissions per participation (#168)

The exerciseDataLoader's per-participation loop assigned every
participation's pending submission onto a single shared
`exerciseDetails.pendingSubmission` field, so only the last
participation's pending build survived. The webview then risked showing
a stale or wrong "build in progress" state when the user was viewing
a different participation (e.g. practice repo while the graded repo
had the surviving entry).

Data shape change

- New shared DTO `PendingSubmissionStatus` in apiResponses.ts. The
  `ExerciseDetailsResponse` carries
  `pendingSubmissionsByParticipationId: Record<number, PendingSubmissionStatus>`
  instead of the old singleton.
- Webview store mirrors the map. WebSocket-driven updates
  (`updateSubmissionProcessing`, `updateBuildStatus`) mutate per
  participationId — entries for other participations are preserved.
- `updateBuildStatus` falls back to `findParticipationForResult` when
  the payload omits `participationId`, so stale pending state cannot
  remain for result events lacking that field.
- ExerciseDetailView picks the entry matching the currently-selected
  participation (`participation?.id !== undefined ? map[id] : null`).

Normalization

`api.getLatestPendingSubmission` returns a `ProgrammingSubmission` (no
top-level `state`, no `participationId`). The loader normalizes that
into a bare `{ participationId }` entry — state and `buildTimingInfo`
only ever arrive from the WebSocket submissionProcessing path. The UI
is already tolerant of undefined `state` and chains `buildTimingInfo?`.

Parallelization

Per-participation enrichment now runs in parallel via
`Promise.allSettled`. Auth (401/403) and `MalformedResponseError` are
collected and rethrown after every per-participation task has settled,
matching the previous abort-on-fatal semantics while preventing
background log spam from sibling calls after rejection. Non-fatal
failures (network, 5xx) are logged and the load continues with whatever
did succeed. Concurrency cap intentionally omitted: typical
participation count is ≤2.

Tests

- Loader (test/unit): two-participation co-existence, normalization
  shape, one-failing-doesn't-drop-the-other, auth-after-settle,
  empty-map for no pending builds.
- Store (test/react): hydration from response, stale-reset on reload
  without map, preservation across `updateSubmissionProcessing`,
  per-participation deletion via `updateBuildStatus` (with and without
  `payload.participationId`), `clearPendingSubmission` clears all.
- Fixtures in exerciseSubmission.flow and ExerciseDetailView migrated
  to the new shape.

Backward compatibility: extension host and webview ship in the same
VSIX, so the wire-shape change has no version-skew surface.

Closes #168

* fix(webview): use resolved participation id when clearing pending entry

Addresses codex review feedback on PR #181.

`updateBuildStatus` previously prefered `payload.participationId` when
deciding which key to delete from `pendingSubmissionsByParticipationId`,
falling back to the resolved participation only when the payload omitted
it. A mismatched or stale payload (carrying an id that points to a
different participation than the one the result tree resolves to) would
have deleted an unrelated key while mutating the actually-resolved
participation. Always use the resolved owner instead.

Also adds the view-level regression test codex flagged as the main
coverage gap: two participations with concurrent map state, only one
holding a pending build, and the view must surface the indicator only
when its selected participation is the one with the pending entry.
Switching `isPracticeRepo` flips which participation is selected and
thus whether the indicator renders — proving the pre-fix data leak is
gone.
…uards (#186) (#187)

- utilityCommands.ts: drop redundant 'as ExerciseDetailsResponse | undefined'.
  AppStateManager.currentExerciseData already returns exactly that type via
  the discriminated _payload.kind === 'exercise' check; the cast was stale
  leftover from before the appStateManager refactor.

- iconMap.ts: introduce isIconKey() type guard using hasOwnProperty (not
  'in') so inherited names like 'constructor' or 'toString' cannot slip
  through and return a non-LucideIcon at runtime. Removes 'as IconKey'.

- ParticipationActions.tsx: promote ExerciseType union to const-array +
  derived type + isExerciseType() guard. The hand-written union is now
  the single source of truth and the guard rejects unknown server values
  instead of blindly casting them in. ExerciseDetailView consumes the
  guard to replace its '(exercise.type || 'programming') as ExerciseType'
  call site.

- extensionCommands.ts: introduce isWSStatusAction() guard derived from
  Object.values(WS_STATUS_ACTION). The vscode.window.showInformationMessage
  return type loses the WSStatusAction[] info from the items spread, so
  the prior cast was unsound when the user could in principle pick any
  string. The guard is now the single narrowing point.

Closes #186.
…ssionRecorder (#184) (#188)

SessionRecorder had two private getter shims that double-cast through
ObservationRegistry's private fields purely to satisfy sessionRecorder.test.ts
which whiteboxes the per-URI debounce maps. Production code violating
encapsulation only to keep tests happy is a code smell — the test was
dictating the production shape.

Move the whitebox from production to the test:

- src/extension/services/telemetry/recording/sessionRecorder.ts:
  Remove _pendingSelectionPayloads and _pendingVisibleRangePayloads
  forwarding getters and their double-cast.

- test/unit/services/telemetry/recording/sessionRecorder.test.ts:
  Add a single pendingDebounceMaps(recorder) whitebox helper that
  centralizes the unsafe access through _observation. All 13 test
  sites (consent-downgrade discard, Block J per-URI flush/discard,
  visible-range alternation) now go through the helper instead of
  hitting (recorder as any)._pendingSelectionPayloads directly. The
  cast uses 'as unknown as' rather than 'as any' to be explicit about
  the whitebox.

Also fix two stale comments while nearby:
- The trigger-time payload test referenced _flushPendingDebouncesForEnd
  but the code calls endSession(); reworded for clarity.
- The consent-downgrade test mentioned 'via as any' which no longer
  matches the helper-routed access.

No semantic change to the tests — the underlying Map instances are
identical to what the production shim used to forward. Unit tests
835/0 failures, react tests 720/720.

Closes #184.
…ecordedEvent narrowing (#185) (#189)

RecordedEvent is already a properly tagged discriminated union over
'type' (recording/types.ts:389-422 — 33 variants, each with a literal
type discriminator). TypeScript therefore narrows event automatically
after every 'event.type === <literal>' check, so the seven explicit
casts in replayEngine.ts were redundant from day one.

- Drop all seven 'as ConcreteEvent' assertions.
- Drop the five intermediate 'const xEvent = event as ...' aliases that
  existed only to host the casts (saveEvent, buildEvent, diagEvent,
  stateEvent — the body now just uses 'event' directly).
- Remove the five now-unused type imports (BuildResultEvent,
  DiagnosticsEvent, EqSnapshotEvent, SaveEvent, SessionStartEvent).
  EqEngineStateEvent, RecordedEvent and SerializedDiagnostic remain
  as they are still used as parameter / array types.

No behavioral change. Verified via type-check, lint, and the full
replayEngine.test.ts suite (part of the 720/720 react tests passing).

Closes #185.
Predixx and others added 26 commits May 26, 2026 14:48
#207) (#217)

* refactor(websocket): extract pure helpers (buildWebSocketUrl, extractJwtFromHeaders)

Pulls two no-op private helpers off ArtemisWebsocketService into pure
modules with their own unit tests. No behavior change.

Part of #207.

* refactor(websocket): extract buildStompConfig as a pure function

Moves the STOMP config builder out of ArtemisWebsocketService into a
standalone module with its own tests. Generation gating is now a pure
closure capture, identical to the previous behavior. No behavior change.

Part of #207.

* refactor(websocket): extract SubscriptionRegistry from ArtemisWebsocketService

Pulls the subscriptions Map, message-handler list, and per-topic
helpers (personal results/submissions/processing + iris) into a
dedicated class. The service attaches/detaches the STOMP client at
connect/disconnect time. No behavior change.

Part of #207.

* refactor(websocket): extract ConnectionLifecycle from ArtemisWebsocketService

Pulls the state machine, generation token, reconnect counter, deferred
lifecycle, safety timeout, and debounce timer into ConnectionLifecycle.
The service is now a thin orchestrator that owns the STOMP client and
SubscriptionRegistry, routing STOMP callbacks through the lifecycle.

Race-handling behavior is preserved byte-for-byte. Verified by the
existing 60+ behavior tests plus 18 new lifecycle-specific tests.

Closes #207.

* docs(websocket): correct attach-order comment in connect()

Said 'after recordConnected' but the code attaches before. Aligning the
short comment with the long docstring on _onStompConnected.

Part of #207.
…#220)

* fix(iris-chat): distinguish unavailable from disabled, add retry path

Network drops, 5xx responses, and timeouts previously surfaced the "Iris
chat is not enabled for this exercise. Please contact your instructor."
overlay, misleading students into thinking their instructor disabled
Iris. After such a failure, the webview also stayed on "Loading
conversation…" forever — no auto-retry on websocket reconnect, no manual
retry button.

checkAndLoadIrisSettings now returns a discriminated union
(enabled/disabled/unavailable). A 403 from the iris-settings endpoint
maps to disabled (matches existing semantics); 401/4xx/5xx/network/
malformed/timeout map to unavailable. 403 from other endpoints in the
flow (profile probe, exercise-details course resolution) classifies as
unavailable rather than as a disable signal.

A new ShowUnavailableState message drives a transient warning banner
with a Retry button. Manual retry posts ReloadChatSession; auto-retry
fires on websocket reconnect when the current context's last
classification was unavailable. Both share a context-keyed single-flight
guard so a hung reload for one context cannot block recovery of
another. lastAvailability is reset on every context change so a stale
classification from a previous exercise cannot drive retries on the new
one.

The loader is now gated on availability state (disabledMessage or
unavailableMessage), fixing the stuck-spinner symptom: availability
failures are a terminal state, not a history-load failure. The
websocket-disconnected banner is suppressed when the unavailable banner
is shown so the user sees a single Retry affordance.

The send path also flows through the centralized postAvailability
helper, and a captured context is threaded through SendMessageResult so
a slow settings check that returns after a context switch cannot mark
the new context's banner state with the old context's classification.

Closes #218
Closes #219

* docs(changelog): add iris-chat connectivity resilience entry
…221)

* feat(context): add TrackedItemRepository.clearAllWorkspaceFlags

* feat(context): add ContextStore.clearWorkspaceFlag and getWorkspaceExerciseId

* feat(workspace): scaffold wireWorkspaceDetection with initial detection

* feat(workspace): re-detect on workspace folder changes

* feat(workspace): re-detect on courses-loaded events

* feat(workspace): guard register path against stale detections

* feat(workspace): guard clear path against stale detections

* feat(workspace): dispose guard and listener unsubscribe

* feat(chat): inject ContextStore + expose workspace sink methods

* refactor(chat): remove chat-owned workspace detection

* feat(activation): run workspace detection at activation

* fix(recorder): rephrase exercise-context warning

* fix(recorder): read workspace exercise from ContextStore

* style(tests): fix import order and curly braces for workspace tests

* docs(changelog): recorder gate decoupled from Iris chat context

* chore: drop unused workspace exports flagged by knip
… re-PR (#222)

* feat(context): add TrackedItemRepository.clearAllWorkspaceFlags

* feat(context): add ContextStore.clearWorkspaceFlag and getWorkspaceExerciseId

* feat(workspace): scaffold wireWorkspaceDetection with initial detection

* feat(workspace): re-detect on workspace folder changes

* feat(workspace): re-detect on courses-loaded events

* feat(workspace): guard register path against stale detections

* feat(workspace): guard clear path against stale detections

* feat(workspace): dispose guard and listener unsubscribe

* feat(chat): inject ContextStore + expose workspace sink methods

* refactor(chat): remove chat-owned workspace detection

* feat(activation): run workspace detection at activation

* fix(recorder): rephrase exercise-context warning

* fix(recorder): read workspace exercise from ContextStore

* style(tests): fix import order and curly braces for workspace tests

* docs(changelog): recorder gate decoupled from Iris chat context

* chore: drop unused workspace exports flagged by knip
Lets the annotator flag moments where they believe the recorded
student would benefit from Iris interaction. Added as a new
ContextMarker value with hotkey `i` and indigo color, alongside
existing markers like reading / using-ai.

Also replaces the brittle positional `'qwert'[i]` lookup in the
live legend with a reverse-map derived from CONTEXT_KEYS, so the
legend stays in sync when context-marker hotkeys are non-contiguous.
Lets the annotator flag moments where the recorded student is reading
test output (distinct from `reading`, which covers task description /
docs). Added as a ContextMarker value with hotkey `u` and amber color,
following the same pattern as `iris-moment` (#225).
#227)

The live-session counter previously stuck at 5000 once the sliding
window filled because it displayed the buffer length (`live.events.length`)
rather than the cumulative count of events received.

Two changes:
- MAX_LIVE_EVENTS raised from 5_000 to 50_000. Sized to cover realistic
  study-session volumes (worst observed: ~4k events). RAM cost ~20 MB at
  the new cap; the real bound is re-derivation cost in EventStream /
  TrackingTimeline on each RAF flush.
- LiveSessionState gains a `totalReceived` counter that increments per
  successfully-parsed, deduped event. LiveControlBar now takes
  (bufferSize, totalReceived) and shows '5,000 of 12,345 events' when
  the window is trimming, otherwise just '12,345 events'.

Pre-cap dropped events still count toward totalReceived because they
were received from the server, even if a pathological initial-tail
burst kept only the visible tail.
When a WebSocket newResult event arrives for the currently rendered
participation, re-fetch enriched exercise details and re-render the
problem statement so test-case checkmarks reflect the new result.

WebviewSSRCoordinator gains refreshFromServer with last-wins coalescing
(at most one fetch in flight, one pending behind it), state/exerciseId
guards, and disposed-aware scheduleRender. The filter lives in a pure
function so it is unit-testable; today it narrows to the participation
the coordinator renders (studentParticipations[0]). Visibility-change
refresh gets the same post-fetch guard against stale overwrites.
#229)

* feat(problem-statement): differentiated empty states for task feedback

The per-task feedback modal previously collapsed every empty case
("no result yet", "build produced no feedback", "tests defined but not
executed", "result is successful without an inline feedback list")
into a single "No tests in this task." message.

Port the four-state classification the Artemis web client uses
(testStatusForTask) plus a legacy-success fallback and a defensive
no-tests-in-task case into a pure classifyTaskTests function in
exerciseStatus.ts. The TestResultsOverlay accepts the classified state
as a discriminated prop and renders a tailored empty/structured layout
per kind, including a "{N} tests in this task did not run" note when
the latest build skipped some of the task's tests.

The classifier runs in the overlay's JSX at render time so WebSocket
newResult patches reflect in the open modal immediately. The telemetry
payload gains an additive notExecutedTests field; totalTests /
passedTests / failedTests keep their pre-existing matched-tests semantics
so historical analytics are unaffected.

The previously-unused filterTestCasesByIds helper is removed.

* test: integration coverage for task overlay state classification

Add six integration tests that drive the full click→classify→overlay
render pipeline through every TaskTestState branch (no-result,
no-feedbacks, legacy-success, not-executed, fail-with-not-executed),
plus a freshness test that asserts the open modal re-classifies when
exerciseData is patched mid-view (mirroring the WS newResult path).

The existing isolated unit and component tests already covered the
classifier and overlay separately; these add the missing end-to-end
verification that the wiring in ExerciseDetailView dispatches each
state to the right rendered output.
…nd IRR merge CLI (#231)

* feat(viewer): add HMAC-signed ViewerSession cookie module

* fix(viewer): harden cookieSign against torn input and add boundary tests

* feat(viewer): add rater name normalization + opaque id derivation

* test(viewer): tighten raterIdentity message + locale + UTF-16 assertions

* feat(viewer): add per-filepath promise-chain mutex helper

* feat(viewer): add append-only annotation event-log store with materializer

* fix(viewer): tombstone only after materialized delete + validate records

* feat(viewer): add researcher token + session secret config with startup validation

* refactor(viewer): replace token-as-cookie with signed ViewerSession

* feat(viewer): rewire /api/auth/login + /status for rater/researcher roles

* refactor(viewer): role-aware auth gate with 405 on PUT and researcher read-only

* feat(viewer): GET /annotations returns current rater's materialized marks

* feat(viewer): POST /annotations appends to per-rater event log

* feat(viewer): DELETE /annotations tombstones in own file, remove legacy PUT block

* feat(viewer): add GET /annotations/all researcher endpoint with legacy synthetic lane

* feat(viewer): login screen with rater/researcher mode toggle

* test(viewer): delete obsolete legacy single-file annotation tests

* feat(viewer): annotation controller blocks redo on rater identity mismatch

* refactor(viewer): remove bulk-PUT annotation path and offline edit UI

* feat(viewer): researcher mode renders multi-rater lanes

* feat(viewer): add merge-annotations CLI (long-format CSV)

* feat(viewer): add irr-matrix format to merge-annotations CLI

* docs(viewer): document multi-rater study setup and merge CLI

* fix(viewer): validate binMs is a positive integer in irr-matrix

* fix(viewer): synthesize local rater session when no auth tokens configured

* fix(viewer): enable annotation hotkeys for offline raters with playback cursor

* fix(viewer): preserve lane raterName after tombstone + scope listRaterIds to r_*.jsonl

* fix(viewer): render login mode toggle as side-by-side tabs

The Rater/Researcher tabs inherited the full-width blue .login-form
button style, so they stacked vertically and both looked active. Add
dedicated .login-mode-toggle rules (flex row, only .active highlighted,
hover state) and auto-focus the rater name field on the default tab.

Also rename the dev convenience token to artemis_admin.

* feat(viewer): prefill login from url params

Read ?type=, ?name= and ?pw= query params to prefill the login mode,
rater name, and access token. Lets a study operator launch several
pre-configured rater/researcher windows without typing credentials each
time. Fields are only prefilled, not auto-submitted.

* feat(viewer): live-poll researcher lanes during a live session

A researcher watching a live session only saw raters' marks on reload,
because the live event stream carries events but not annotations. Add a
useResearcherLanePolling hook that re-fetches /annotations/all once a
second while a researcher views a live session and updates only the lane
data, leaving video/zoom/scroll untouched.

The poll reads the active session id per tick (so switching sessions
re-targets it), uses an in-flight guard against overlap, and a per-run
cancel flag so a poll resolving after the session ends cannot overwrite
the authoritative final lanes written by the archive reload.

* feat(viewer): mark live sessions in the session list

The LIVE badge was appended after the session id inside an
overflow:hidden cell, so it was clipped off the right edge and never
visible. Move the badge to the front of the id cell, add a 'live' class
to the row, and give live rows a subtle red tint. Adds a SessionList
test covering the indicator and badge placement.
Records VS Code debugger activity in the session recorder and renders it in the recording-viewer: `debugSession` lifecycle (started / terminated / activeChanged) and `breakpointChange` events (added / removed / changed, each breakpoint carrying a stable id, absolute uri, 0-based line/column, enabled, and optional condition / hitCondition / logMessage), plus an initial breakpoint snapshot at recording-session start. Scope is limited to the high-level `vscode.debug.*` APIs (no DebugAdapterTracker, no struggle-detection integration).

Adds runtime validators for the two new event types in `parseRecordedData.ts` and replaces the parser's switch dispatcher with a `satisfies Record<RecordedEvent['type'], EventParser>` table, so adding a future event type without a validator is a compile error (with an own-property guard preserving the unknown-type-to-null behaviour). Includes unit tests (collectors + phase/generation gating), a live breakpoint add/remove e2e, a wiring test for the startup-snapshot contributor, and viewer rendering plus render tests.
* feat(recorder): add submission event type, parser, and validator

* feat(recorder): add recordSubmission stamping exerciseId from active session

* feat(provider): add onDidSubmission event and fireSubmission

* feat(submit): emit submission lifecycle events from handleSubmitExercise

* feat(recorder): wire onDidSubmission to recordSubmission

* feat(viewer): add submission to generated types and event constants

* feat(viewer): render submission events in event stream and timeline

* docs(changelog): note submission recording

* feat(submit): cap recorded commit message and harden submission tests
…d timer (#237)

* feat(viewer): distinct event colors, lane-label clipping, live elapsed timer

Address three UI-polish items from #235:

- Make MARKER_COLORS the single source of truth for event-type colors, consumed by both the canvas dots and a new shared <EventBadge> pill. Every type now has a distinct color (submission, startupPhaseComplete, debugSession and breakpointChange previously rendered colorless, and several types shared a hue). Remove the drift-prone per-type .event-badge.<type> CSS rules.

- Clip the left timeline lane labels to their fixed-width column (ellipsis + title on hover) so long names no longer overflow to the left.

- Show a running elapsed timer in the live control bar during live mode, derived from an authoritative start (metadata.startTime or the sessionStart event) and latched per session so it survives live-buffer trimming; hidden when no start is known.

* feat(viewer): always show all event types, empty ones sorted to the bottom

The event breakdown (top) and the timeline lane labels (left) now list every event type, including those with no events in the current session.

Empty types keep the curated order but are sorted to the bottom and dimmed, via a shared orderTypesActiveFirst helper used by both views.

Part of #235.

* feat(viewer): add toggle to hide empty lanes in the timeline

A button in the timeline controls switches between showing every event type as a lane (default) and showing only the lanes that have events. Part of #235.

* ui(viewer): turn the hide-empty-lanes control into a slider toggle

Replaces the text button (which wrapped awkwardly in the controls row) with a compact labeled switch. Part of #235.

* feat(viewer): complete the 'What do we record?' panel with every event type

Add the 7 missing event types (submission, debugSession, breakpointChange, terminalOpenClose, terminalCommand, consentChange, startupPhaseComplete) under two new categories (Debugging, Terminal). Their badge colors come from MARKER_COLORS via EventBadge, like every other badge, so they are correct automatically. The intro count is derived, so it now reads 36 across 9 categories.

Move the panel data to recordingInfoData.ts (keeps RecordingInfo a component-only module for react-refresh) and add a test asserting the panel covers exactly ALL_EVENT_TYPES with a real color each, so it can't silently go stale again. Part of #235.

* feat(viewer): open the live recording with Space from the session list

New useOpenLiveOnSpace hook: on the session-list view (no session open), pressing Space opens the live recording — only when one exists, so it never hijacks Space otherwise. Ignores auto-repeat and focused interactive controls (inputs, buttons, links, etc.) so Space still activates a focused button. Opens the first session when several are live.

Also reorder the timeline controls to [Hide-empty-lanes toggle] [Reset] [Follow] [+] [-] so the zoom +/- stay pinned to the right edge and don't shift when the conditional Reset/Follow buttons appear. Part of #235.

* ui(viewer): show a 'press Space to open' hint while a live recording exists

Makes the Space shortcut discoverable: a small banner with a red live dot appears above the session list only when at least one recording is live. Part of #235.
The recording-viewer subproject had no CI coverage: every job in this
workflow inherits the top-level working-directory of extension, so
typecheck, lint, test, knip, and build all run against extension only.
The viewer's ~30 test files, its lint, and its build never gated a PR.

Add viewer-lint, viewer-test, and viewer-build jobs that run in
recording-viewer, and require them via the CI gate aggregator. The build
job runs tsc -b && vite build, covering both type checking and bundling,
so a separate typecheck job would only duplicate work.
The project requires exact-pinned dependencies (no ^/~); Renovate handles
updates via PRs. The recording-viewer package.json still used caret/tilde
ranges for 15 dependencies. Pin each to its currently-resolved version.

Two specs move up to the already-installed patch: @vitejs/plugin-react
6.0.0 -> 6.0.1 and typescript-eslint 8.56.1 -> 8.57.0. No installed
version changes; only the lockfile spec references are rewritten.
The video upload handler streamed the request body to a temp file via
fs.createWriteStream but never attached an 'error' listener. The stream's
lazy fd open is asynchronous, so on the magic-bytes reject path (which
calls ws.destroy()) the pending open could fail after the response was
sent — most reliably in tests, where afterEach removes the temp directory
before the open completes. The resulting ENOENT had no listener and
surfaced as an unhandled error that failed the whole test run, and it was
a latent process-crash risk in production (disk errors, etc.).

Attach ws.on('error'): tear down and, if nothing has responded yet,
return 500. Route all three exit points (reject, success, catch) through
a single-response 'respond' guard so the error path can never double-send.
Set rejected on stream error so later data/end events stop writing to the
destroyed stream, and mark abort paths settled so a late error can't reply
to a client that already disconnected.

Reproduced deterministically: the old pattern produced an unhandled ENOENT
on every iteration; with the listener, none.
Delete the unused FreeAnnotationForm component (no importers) and its
orphaned CSS cluster (free-annotation-form, label-bar/divider/quick-btn,
annotation-count, label-selected-badge, label-clear-btn, annotation-bar,
free-annotate-btn, the scoped time-input rule), plus the orphaned
chart-legend and chart-filter-bar/filter-bar/toggle-all rules and a
duplicate EQ Chart section header. Each removed class was confirmed to
have no live references; annotation-label and the base filter-btn are
kept (still used).

Drop the export keyword from four symbols that are only used within their
own module: ANNOTATION_GROUP_PX, ANNOTATION_HIT_PX, MAX_LIVE_EVENTS, and
applyEventLog.
EventStream and TrackingTimeline each carried a 34-case switch over the
same RecordedEvent union: EventStream's EventDetail (full detail row) and
TrackingTimeline's eventSummary (compact tooltip line). Adding an event
type or changing a field meant editing both in lock-step, and they had
already drifted (e.g. truncation lengths, terminalCommand duration).

Move both switches verbatim into one module, src/utils/eventDisplay.tsx,
exporting eventDetail(event) and eventSummary(event, sessionStartTime).
Each component imports and calls its function; the bodies are unchanged.
EventStream's call site goes from <EventDetail event={e}/> to
{eventDetail(e)} (the old EventDetail was a plain function component with
no hooks/state, so output and reconciliation are identical). Trim each
component's format imports to the helpers it still uses.

Now the event-type union is handled in exactly one place.
…223)

dev already replaced the parser's switch with a typed EVENT_PARSERS
dispatch table (satisfies Record<RecordedEvent['type'], EventParser>,
added in #233). This change keeps that table and removes the remaining
drift source flagged by #215: the validator's separately hand-synced
KNOWN_EVENT_TYPES Set.

- parseRecordedData.ts re-exports KNOWN_EVENT_TYPES, derived from the
  dispatch table's keys (typed ReadonlySet<string> so the validator can
  call .has() on an untrusted string read off disk without a cast).
- validate-recording.ts imports that set via the @extension alias and
  drops its own hand-maintained list, so the two can no longer drift.
- adds a unit regression suite guarding the shared set (#215).

Closes #215.
The event-stream detail row (eventDetail) and the timeline tooltip
(eventSummary) in eventDisplay.tsx had drifted on several fields after
#242's verbatim split. Harmonize the accidental differences so the
tooltip surfaces the same information as the detail row:

- intervention: tooltip uppercases the action; detail now uses a pipe
  before EQ (was a space) so both read "ACTION | level | EQ".
- eqEngineState: tooltip shows the confidence (detail already did, and
  eqSnapshot shows it in both views).
- buildResult: tooltip appends "| N test(s) failed" on failures.
- terminalCommand: tooltip shows duration "(Xs)" and the "[truncated]" flag.
- submission: tooltip shows the commit message (truncated to 40 chars;
  the detail row keeps 60).
- selectionChange: tooltip shows the column ("L<line>:<col>").

Intentional differences kept: the tooltip time prefix, styling, the
text-vs-counts split in textChange, bp/breakpoint(s) wording, truncation
lengths, and the eqEngineState field ordering.

Adds eventDisplayDrift.test.tsx asserting both renderers surface each
harmonized field, and omit the conditional ones when absent.

Closes #243.
)

* refactor(telemetry): move uriFilter out of recording/ to decouple EQ

* refactor(telemetry): stop re-exporting recording from telemetry barrel

* feat(dataCollection): add seam interface and no-op implementation

* feat(dataCollection): full seam wiring consent, recorder and recording commands

* build: add @datacollection path mapping and __IRIS_RECORDING__ global

* refactor(activation): route consent + recorder through the dataCollection seam

* refactor(webview): provide recording handlers via seam with fail-closed merge

* refactor(activation): drop recording command registration (moved to seam)

* style: sort imports after dataCollection seam refactor

* build: add openvsx variant (host alias swap + webview define)

* build(webview): gate recording controls behind __IRIS_RECORDING__

* build: add clean-manifest generator for openvsx variant

* build: add staging-dir packaging for the openvsx clean VSIX

* refactor(auth): stop re-exporting ConsentService from auth barrel

* build: add metafile-based clean-bundle verifier

* build: keep esbuild metafiles and packaging staging dir out of the VSIX

* ci: build and verify the clean Open VSX variant

* ci: publish clean variant to Open VSX and full variant to Marketplace

* test(dataCollection): fix fullSeam ConsentService mock path after auth barrel decoupling

* build: exclude reports/ from the VSIX

* fix(test): make clean-bundle verifier test pass full lint (eslint src test)
…162)

Theia auto-clone and git identity setup are now handled by the companion Scorpio extension (wired on startup on Scorpio main). Removes autoCloneIfNeeded/configureGitIdentityFromEnv and drops gitUri/gitUser/gitMail from TheiaEnvironment and the polled bridge vars. Keeps cloneRepositoryProgrammatic (user-initiated clone) and GIT_* in KNOWN_BRIDGE_KEYS for the diagnostic probe.
)

Single 'Use workflow from' selector (drop the redundant ref input) with a main/dev-only guard. Plus edge-case hardening: idempotent GitHub Release (no --clobber, fail-closed asset post-check), single-sourced + validated changelog notes (reject empty/whitespace-only sections and duplicate headers before publish), Marketplace post-publish existence check, overwrite:true on all artifacts, pinned vsce on PATH in package-openvsx.js, and clearer Open VSX recovery guidance.
Bump extension to 0.4.5 and add the 0.4.5 CHANGELOG section (Added/Changed/Fixed/Removed/Internal).
@Predixx
Predixx merged commit 5e47859 into main Jun 1, 2026
25 checks passed
// Append BEFORE constructing the tailer is the analogue of the
// registry → first-poll race: cursor seeded at old size, but
// file has grown by the time we poll.
fs.appendFileSync(filePath, '{"between":1}\n');
await t.pollOnce();
expect(got).toEqual([]);
expect(t.currentLineNo()).toBe(2);
fs.appendFileSync(filePath, '{"new":1}\n');
const stat = fs.statSync(eventsPath, { bigint: true });
initialMtimeNs = stat.mtimeNs;
if (stat.size > 0n) {
const buf = fs.readFileSync(eventsPath);
Comment on lines +17 to +18
return html
.replace(/<script[^>]*katex[^>]*><\/script>/gi, '')
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.

2 participants