Skip to content

Struggle detection v3 + proactive intervention (client) - #335

Draft
Predixx wants to merge 317 commits into
devfrom
feat/struggle-v3-integration
Draft

Struggle detection v3 + proactive intervention (client)#335
Predixx wants to merge 317 commits into
devfrom
feat/struggle-v3-integration

Conversation

@Predixx

@Predixx Predixx commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

Client side of the proactive struggle-intervention feature, on the reworked v3 detection engine. The extension detects struggle locally, requests an Iris intervention from Artemis, and surfaces it non-intrusively, with student controls and an honest availability state.

This is the integration branch for the whole v3 + proactive-intervention client work (engine rework plus the intervention surfaces/controls). Opening as draft for review/visibility.

Note: this PR supersedes #333, which was squash-merged into dev prematurely and reverted there (3e322890); the branch and its history are unchanged.

What's included

  • Engine v3 integration — data-driven severity (typing + gap), boundary triggers, urgency-threshold decision, behind the @telemetry clean-build seam.
  • Surfaces — ambient lamp, in-editor inline cue (anchor/inlineHint), and the Iris chat bubble for active interventions.
  • Reject + backoff — "Not now"/dismiss affordances and a delivery-layer backoff gate above the throttle.
  • Outcomes — proactive-message outcome persistence + chat-bubble dismiss.
  • Course gate — reads the 202 courseDisabled flag (course-level proactive off).
  • Student controls — AskIris On/Off switch + 3-state badge (On / Auto-paused / Off), client-side per-exercise preference.
  • Availability card — four-state AskIris card (Available / Off-course / Unavailable / Degraded) + the §14 exercise-view banner.

Cross-repo

Pairs with ls1intum/Artemis feature/iris/struggle-intervention-pipeline (server) and ls1intum/edutelligence feat/struggle-intervention-pipeline (Pyris pipeline).

Notes

  • Open VSX clean build excludes the detection engine (verified by scripts/verify-clean-bundle.js).
  • Desktop Cookie auth / Theia Bearer auth unchanged.
  • Verification: vitest + check-types + eslint + clean-bundle green.

Predixx and others added 30 commits June 27, 2026 17:37
…name the dismiss-pause

The proactive on/off switch was a bare "On" pill with no visible purpose, and the auto-pause showed only "Auto-paused" without saying it was caused by dismissing hints. Add a visible "Proactive help" label plus a tooltip, and surface the pause as "Paused after dismissing recent hints". Spec section 12.2 / 5.2 updated to match.
…(re)detection

Reopening VS Code on an already-cloned exercise only ran passive workspace detection (which activated Iris chat) but never started the struggle session, so the engine had no active exercise and stayed silent. Wire the seam-gated coordinator's start/end into the detection callbacks, symmetric with the active webview open flow: start on detect, end on no-match so a stale exercise cannot linger. endExerciseSession added to the IStruggleCoordinator seam + no-op coordinator.
…ventions

An active struggle event carries an optional anchor (file/line/inlineHint), but onServerActive dropped it, so the client showed only the chat bubble plus toast and never the inline in-editor cue. Thread the anchor through the subscription, seam wiring, and orchestrator; render the inline breadcrumb (clearing any standing lamp, since inline and lamp are exclusive surfaces) when the anchor is live, per spec section 6.1. Capped actives still degrade to the lamp.
…bled

When classifyIrisCourseAvailability lands on 'disabled', log which check failed: the profile/module feature not being active (with the actual activeProfiles plus activeModuleFeatures), or iris-settings.enabled being false. Diagnoses a case where the chat reported disabled while the course was actually enabled and proactive worked.
…on chat bubbles

- Collapse a run of consecutive proactive Iris messages into one card with a
  "Show N earlier suggestions" toggle (display-only; detection/backoff untouched).
- Move thumbs + Dismiss into a hover-revealed floating bar at the card's bottom
  edge (reserves no space, keyboard-reachable via :focus-within, no container chrome).
- Subtle chevron/link collapse indicator instead of the dashed pill.
- Spec 6.2/6.5/12.1 updated to match.
Both user and assistant bubbles were capped at 70% of the panel width, wasting
horizontal space in the chat sidebar. Cap on min(92%, 70ch) instead: fill a
narrow sidebar but keep line length readable when it is dragged wide.
- Drop the unused nextSensorSeq re-export from the sensing barrel (the function
  is imported directly from ./sequence, which is where it is used).
- Un-export DEFAULT_EGRESS_CAPS and STRUGGLE_EVENT_TOPIC; both are only used
  inside their own modules.
Slice 1 (logging): extract the per-tick formatter into a pure, testable
telemetry/formatTick module and enrich the [Struggle] line with the severity
decomposition, every gate flag, and the warmup/cooldown countdowns derivable
from the tick.

Slice 2a (snapshot): add a separate StruggleDebugSnapshot wire type (not on
TickRecord), ThrottledAlertSink.getThrottleState forwarded by BackoffGate, an
engine grace getter, and StruggleCoordinator.getDebugSnapshot; logging Phase B
appends the throttle / grace / fN2 timers to the same per-tick line.

Slice 2b (dashboard): feed the snapshot through the per-tick
struggleDetectionInit payload (developer mode only) and render a TimersPanel
with an offset-corrected 1 s clock for smooth countdowns between the 10 s ticks.
The panel shows a no-active-session empty state, and the provider refreshes it
on session start/end (ticks stop when a session ends).

The cooldown anchor derives from the firing tick's own alert because the engine
emits onDidTick before onDidAlert. The clean Open VSX build stays leak-free
(snapshot type lives in @shared, the no-op coordinator returns an inert snapshot).
While the engine is still warming up and the bar would otherwise read "armed",
show "$(pulse) Struggle: warm-up M:SS" instead, counting down from tick time
(warmupS - tick.t) so it never hits 0:00 before the first post-warm-up tick.
firing/gated stay untouched (FM/E4 alerts break through warm-up and must remain
visible); every tooltip notes the warm-up time remaining while it lasts.

The display strings move into a pure formatAlertBar() in struggleAlertBarState
(unit-tested, vscode-free); the status bar reads the warm-up length from the
coordinator's debug snapshot caps, so the always-bundled bar never imports
struggle/config and the clean build stays leak-free.
Add a Fullscreen button to the developer struggle view that opens it as a webview
editor panel (which VS Code can natively move to a separate window). Per the chosen
scope the embedded copy shows the dashboard/status/timers only, no live chart, so it
needs no live-feed wiring: the panel is fed the SAME per-tick snapshot the sidebar
uses, refreshed on every tick AND on session start/end so it never freezes on a stale
session. A new `embedded` flag hides the back-link, live chart, and pop-out button in
the panel; the loading state drops the back-link entirely (from the standalone panel
it would mutate the sidebar's global state).

Wiring: React posts toggleStruggleFullscreen -> navigationCommands -> facade delegates
to a provider-supplied opener -> FullscreenPanelManager.openStruggleFullscreen (kept
struggle-agnostic: it takes buildInit + subscribeRefresh closures). The coordinator
access stays in the provider behind the @telemetry seam, so the always-bundled panel
manager never imports the engine and the clean build stays leak-free.

Also fix the status-bar warm-up readout to key off the engine's own inWarmup flag
(t <= warmupS) instead of remaining > 0, so it persists through the final warm-up tick
instead of disappearing one tick early.
… page developer-only

Rework the developer struggle-detection view so the engine's decision is legible,
and gate the whole page behind developer mode.

- Add a decision-flow pipeline (Severity, Candidate, Gates, Outcome) at the top,
  fed by the init snapshot via a shared decision-trace mapper (toLiveDecisionTrace,
  reused by the live feed). The edit path is shown as four stages with the blocking
  stage highlighted; a discrete test-stagnation fire is shown as a separate verdict,
  not a faked all-pass.
- Rewrite the timers panel in plain language (drop the B4/E6/fN2/S/V codes), add a
  "Last delivered" row from the throttle state, and a "waiting for first tick"
  re-arm state. Extract the per-second countdown math into useEngineCountdowns.
- Remove the redundant Status card and the live-section CurrentTickPanel (plus its
  dead CSS); the pipeline covers the verdict, gates and boundary.
- Gate the whole struggle page to developer mode: route guards on
  showStruggleDetection and openStruggleFullscreen, a dashboard entry gate via a
  required dashboardInit.hideDeveloperTools flag, and an in-view backstop.
- Fix getSnapshot to return an inactive zero-state when no session is active, so the
  urgency meter no longer shows stale post-session data.
… entry label

- Make the urgency card compact and move it above the decision-flow pipeline
  (one-line score + status, slim bar; the long explanation moves to a hover tooltip).
- In the pipeline's gate list, distinguish "blocking" (the gate the engine actually
  recorded as the reason this tick) from "engaged" (its condition holds but the flow
  stopped at an earlier stage) and "clear", with a short caption. This removes the
  apparent contradiction where warm-up showed as engaged while the recorded reason was
  "no boundary".
- Label the dashboard "Struggle Detection" entry with a small "Dev" badge, since the
  page is developer-only.
…pipeline

The pipeline derived the Severity stage's "over/below threshold" label from the stop
position. The engine checks the candidate (boundary) BEFORE the threshold (see
alertStateMachine: 'no-candidate' is recorded first, 'below-threshold' second), so when
the recorded reason was "no boundary" a below-theta urgency (e.g. 0.16, 0.59) was wrongly
labelled "over threshold" and shown green.

Each stage now reflects its OWN factual condition (Severity: urgency vs theta; Candidate:
boundary present), while the engine's recorded reason marks the decisive blocker (red). A
stage that is factually not-ok but not the recorded reason is shown neutral with its true
label, so the Severity box no longer claims "over threshold" when urgency is below theta.
…it payload

The decision-flow pipeline rendered below-threshold twice (as the Severity
stage and as a row in the gate list), so the Gates stage box stayed neutral
while the gate-list row showed a red "blocking" state for the same condition.
The urgency threshold is now shown only as the Severity stage; the gate list
carries the five real delivery gates (B2, B4, D1, cooldown, re-arm). Updated
the list copy ("Delivery gates this tick") and the stale gate-order comment to
the engine's actual order.

Also trim isStruggling/v/s/primaryBoundary/lastAlertT from the
struggleDetectionInit message and StruggleData: nothing renders them since the
Status card was removed. The Urgency card keeps urgency; the engine-side
StruggleSnapshot is unchanged.
…me wording

Bug A: the "Delivery gates this tick" list rendered amber "engaged" from the raw
tick-time gate flags. On a FIRED tick the flow stops nowhere, yet warm-up/grace
flags can still be true (FM/E4 break through warm-up, FM/FM+ survive the grace
filter), so those rows showed "engaged" while the Gates stage box was green
"all clear" and the verdict was "Alert fired". Guard engaged with
reason !== 'fired' so every gate row reads "clear" on a fire.

Bug B: the Outcome sub said "nudge sent", but a fired edit decision is upstream
of delivery (coordinator gate, backoff, throttle caps) and may be dropped, which
also contradicted the delivery counters in the same panel. Reword to the
decision-level "alert raised"; "Alert fired" and the verdict stay.

Robustness: derive GATE_REASONS from GATES so the stage box and the rows cannot
drift, and cap the in-view live-tick buffer at 600 (mirroring the feed's cap) so
a very long session does not grow the array and the chart markers without bound.
…ncy card state

The test-stagnation entry claimed tests were "stuck at the same number" and fired
when the count "has not increased" for N builds. The tracker actually fires when
N consecutive builds fail to beat the best passing-test count seen so far, folding
flat, regressed AND failed builds into "no progress". Reword text + tooltip to match
(keeps the "Tests are stuck" prefix). Also fix two boundary-wording inaccuracies:
theta "must rise above" to "must reach or exceed" (engine fires at urgency >= theta),
and d1-warmup "a failed build" to "a build that failed without improving" (warm-up
admits only FM and E4, not the improved-but-still-failing FM+).

The Urgency card was the only panel without a no-session state: with no active
exercise session the engine reports urgency 0, so the card showed a calm green
"0.00 / Below alert threshold" while the pipeline and timers showed their empty
states. Guard the card on debug.sessionActive and show an explicit empty state.
…fix chart width

The decision-flow stage boxes use fixed dark backgrounds but read their text from
VS Code foreground tokens, which turn dark in a light theme, so the stage values
and labels rendered dark-on-dark and were unreadable (the boxes are intentionally
always-dark filled chips, so their text is now fixed light). The gate rows sit on
the theme-adaptive card surface, so their engaged/blocking colors and the idle dot
move from dark-tuned hex to VS Code semantic tokens (errorForeground /
editorWarning-foreground / widget-border) with hex fallbacks.

The live chart was permanently stuck at its 600px fallback width: the ResizeObserver
was set up in a mount-only effect, but the measured .chartFrame only mounts once
ticks arrive, so the observer never attached and the chart was clipped to the
narrower sidebar (newest ticks cut off). Use a callback ref that attaches the
observer exactly when the frame mounts and disconnects on unmount.

Also soften the chart grid (mid-gray at low opacity, subtle on both themes) and
draw a dot for the single-tick case where a line has no segment to render.
Predixx and others added 5 commits July 19, 2026 21:33
…ming

Brings PR #356 (Iris chat sidebar redesign + response streaming, which
replaced the stages system with activities/run-UI) onto the struggle branch
while preserving all proactive-intervention features.

Resolution notes:
- Adopted the run-UI/streaming model; removed the dead stages plumbing
  (IrisStageDTO, updateIrisStages).
- Proactive WS pushes are classified first and bypass the run state machine;
  they forward activities/final and always reach the session recorder.
- AddMessage now carries the mandatory localSessionId from every proactive
  producer (WS proactive path, postOptimisticBubble, postOfferBubble).
- Kept suppressedIds and live-episode bookkeeping in the store's applyCommit
  (the sole AddMessage path in the webview).
- MessageBubble combines the streaming trail/timestamp/feedback layout with
  the proactive caption/collapse/offer/dismiss controls.
…es fullscreen description, #358) (#359)

* refactor(webview): share init builders and broadcast pushes across sidebar and fullscreen

The exercise/course detail pages run as two independent webviews (the sidebar
WebviewView and the fullscreen WebviewPanel). Each built its own init payload
and wired its own push listeners, so they had drifted apart.

- Add buildExerciseDetailInit/buildCourseDetailInit on ViewInitDataService,
  used by both transports (mirrors the existing buildStruggleDetectionInit).
  The fullscreen panel no longer rebuilds init inline, so it now receives the
  cached server-rendered problem statement and the same developer-tools gating
  as the sidebar. This fixes the fullscreen exercise description never loading
  (#358).
- Add a small WebviewBroadcaster that fans global push signals (proactive
  consent, .noai, server-rendered problem statement) out to every open webview.
  The panel's duplicate consent/.noai listeners are removed and the producers
  move to provider lifetime, broadcasting once.
- Tag problemStatementRendered with its exerciseId and filter it per view, so a
  broadcast only paints the exercise that view is showing.

* docs(changelog): note the fullscreen exercise-description fix
…) (#360)

The activity-bar badge marks an outstanding proactive hint, but it was only
cleared on the level-Off and consent/session-reset paths. After an episode
closed as solved (RECOVERED) or timed out (ABANDONED) the "1" stayed visible.

Clear it in _clearEpisodeRuntime, the single terminal chokepoint that already
retires the other episode-scoped surfaces (inline cue, lamp, banner, offer), so
every terminal outcome clears the badge in one place.
…offer banner (#344) (#361)

The active-announcement banner's "Show me" already opened the chat, but the
follow-up offer banner's accept button ("Show me" / "I need more help") did not:
the extension handler only focused the chat for the legacy active payload and
excluded every offer payload.

Extract a small pure predicate (bannerActionOpensChat) that treats both the
active "Show me" and the offer "accept" as the student's request to see the
hint, and reuse it in the onDidNudgeBannerAction handler. The dev mock banner
and the decline/dismiss/timeout actions still never open the chat.
Predixx added 6 commits July 22, 2026 22:12
… stripMarkdown tests (#345)

The paren-in-URL test only checked not-throw and toContain, which passed
trivially because a paren inside the URL makes the link pattern not match
at all, leaving the markup raw. Assert the real output instead, correct
the doc-comment overclaim about the fenced-code pass, and add a linearity
test for large fenced-code opener runs.
Predixx and others added 7 commits July 25, 2026 02:23
Brings in the reconnect reconciliation for Iris streaming (#355 via #362), the
one commit dev was ahead by.

Conflict resolutions:
- chatSessionService: dev extracted the message mapping into formatIrisMessages,
  which did not carry the proactive fields. Kept the refactor and moved
  origin/proactiveOutcome/proactiveEpisodeId into the helper, so both the live
  load and the new reconnect fetch keep them. Without this every proactive hint
  would come back from history as a plain assistant message and lose the episode
  id the reveal flow keys on.
- chatMessageService: took dev's return values (sentMessageId, generation) but
  not its struggleContext parameter, which this branch removed and which
  SendMessageInput no longer carries.
- extensionMessages, useChatStore, IrisChatView, useChatStore.test: both sides
  added entries at the same spot, unioned.
- chatWebviewProviderReconnect.test: dropped the telemetryManager argument, that
  constructor parameter was removed on this branch in 8fb446a.

Verification: check-types clean, eslint clean, vitest 1823/1823 passing (156
files, +21 from the merged dev tests). test:unit could not run in this
environment: the vscode-test harness aborts before any test code with
"listen EINVAL" because the user-data socket path exceeds the 103 character
unix socket limit. It fails identically without this merge.
They were roughly 10k of the PR's added lines (about a quarter of the diff) and
are working documents, not deliverables. The files stay on disk and are listed
in .git/info/exclude so they remain available locally without being tracked.

The one spec dev already tracks (2026-06-24-ws-statusbar-button-design.md) is
untouched, removing it here would show up as a deletion in the PR.
Completes the docs removal. Unlike the other 23 this one exists on dev, so the
PR now carries it as a deletion rather than simply not adding it. The file stays
on disk and is covered by the same .git/info/exclude entry as the rest.
… list (#366) (#367)

The collapsed proactive-episode row sat far below the conversation it belonged
to, and the list read airy throughout. Both had the same root cause: vertical
space was reserved by the children rather than owned by their container, and a
large part of it existed only to host chrome that is invisible at rest.

- The fold line, the "N earlier hints" summary and the timeline card each
  carried `margin: 8px 16px`. Margins do not collapse inside a flex container,
  so that stacked on the list's own gap and on the preceding item's padding:
  ~34px above a 12px row, ~46px after an open episode card. The containers now
  own the vertical rhythm through `gap`; the children keep only the horizontal
  16px inset, which is load-bearing (it aligns them with the bubble text).

- Every message reserved space twice for chrome the student cannot see: a
  feedback row inside the bubble (`opacity: 0`, but always in flow) and a
  timestamp positioned absolutely at `bottom: -18px`, which forced the list gap
  to 18px so it had somewhere to live. Both now share one always-mounted footer
  row per message, so the space is reserved once and the list gap can drop to
  8px. Hover changes colour only, never layout.

  The row recedes via `--vscode-descriptionForeground` rather than opacity:
  opacity composites the text into whatever is behind it and cannot guarantee a
  contrast ratio, and a user row has no focusable child, so an opacity-based
  rest state would never lift for keyboard users. For the same reason the row
  sits outside the bubble, which `.proactiveDismissed` dims with `opacity: 0.6`.

- Feedback buttons follow the Artemis web client, whose rate buttons are
  permanently visible: borderless, `padding: 4px 6px`, selection carried by
  colour and the filled icon instead of a border.

- A proactive card floats its action bar 14px below its own bottom edge. The
  tighter rhythm no longer leaves room for that by accident, so cards that
  actually render a bar reserve the clearance explicitly, between the bubble and
  the footer row where the collision is.

Body text drops to `line-height: 1.5` with 10px paragraphs, matching the client.
…led offer covering its error (#368) (#370)

Two geometry problems left over from #367, both hover chrome that occupies
layout space or overhangs it instead of being reserved once.

**The episode timeline moved the whole list on hover.** `.foot` animated
`max-height` from 0 to 28px plus a `margin-top`, which is real flow, so hovering
any row grew it and pushed every following row and every following message down
by ~34px. It is now taken out of flow into the spacer `.body` already reserves,
so revealing it costs no height at all and only opacity animates. The grace
window survives unchanged in intent: the transition delay still sits on the
resting state, so the fade-out lingers ~0.4s and the pointer can still reach
Dismiss.

The last row has no spacer to borrow (`.rowLast .body` has no bottom padding),
so an out-of-flow foot would have covered its own text. It stays in flow and is
instead always present, receding by colour like every other footer in the chat
after #367. `.footPersistent` is pinned to `position: static` defensively: today
every actionable foot is on the latest row, but a bar with real buttons must
never float over the row above it if that changes.

The spacer is 16px, not the previous 14px, and the foot pins the same 16px line
box. At 14px the 11px timestamp overflowed by 1.4px, because the inherited
line-height is 1.4, and grew upward into the row's text. `.time` also stops
wrapping: out of flow it has no height budget beyond that one line.

**A failed proactive offer covered its own error row.** `showOfferButtons` did
not exclude `isFailed`, unlike `showDismiss` directly above it, so a send that
never reached the server still rendered its floating action bar, 14px below the
bubble, over an error footer starting 4px below it. There is nothing to answer
in that state anyway.
* feat(iris-chat): refresh preserves a still-valid explicit session selection (#364)

* feat(iris-chat): provider reveal-navigation; reveal owns focus (#364)

* feat(struggle): reveal persists then navigates to the hint's exercise on confirmed persist (#364)

* style(iris-chat): replace em dashes in #364 comments (#364)

* refactor(struggle): drop reveal dead code orphaned by #364 (#364)

generateLocalId and postRevealBubble lost their last callers when the
parked-reveal path switched to a deterministic localId and stopped
posting an optimistic bubble. Remove both from the telemetry engine-deps
contract, StruggleInterventionDeps, the adapter, the extension.ts wiring,
and all fakes.

* feat(struggle): notify the student when a reveal permanently fails to persist (#364)

A parked-hint reveal that gives up (permanent 4xx or retry cap reached)
previously left the slot DELIVERED with no bubble, no navigation, and no
feedback, so the student saw nothing and could not re-reveal. Surface a
short warning on both give-up branches, guarded by the same consent-epoch
check as the rest of the reveal path so a mid-flight consent revoke stays
silent.
…forever (#371) (#372)

* fix(iris-chat): stop a stale selection from pinning the chat context forever (#371)

Opening a workspace for exercise "Graph Traversal" showed the chat bound to
"Struggle Test Course". Workspace detection had run and succeeded; the override
was simply refused.

The active context is persisted together with its `source`, and
`source === 'user-selected'` was treated as an absolute veto over workspace
detection. A selection made three days and several windows earlier therefore
came back as an explicit user choice on every start and pinned the chat, no
matter which exercise was actually open. The evidence: detection logged its hit,
`setActiveContext` never logged at startup (so the context came from
persistence), and the override's own log line never appeared.

The veto is right within a session — background re-detection must not yank the
chat away while the student works — and wrong across one. It is now scoped by an
in-memory marker on the manager, set whenever a selection is made through the
picker. A context restored from persistence carries `user-selected` but no
marker, so detection may take it over exactly once, on startup.

The marker is deliberately not a `selectedAt` comparison against activation
time: both are wall-clock, so a backward clock step could void a selection the
student had just made, which is worse than the bug being fixed. It is armed at
the top of `handleContextSelection`, before the same-context early return, since
confirming an already-active restored context by clicking its row is a real
choice even though nothing else has to happen.

Also fixes a latent defect in the same guard: it compared ids without comparing
type, so an active COURSE with id N suppressed the override of exercise N.

`_autoSelectFromSnapshot` now logs its pick. It was the only path that chooses a
context with no explicit signal, and its silence made the log unable to
distinguish "auto-picked something" from "chose nothing at all" while this bug
was being tracked down.

* fix(iris-chat): drop the unused export on the workspace-override policy (#371)

It was exported to allow a direct unit test, but the tests ended up going
through the manager's public API instead, which is the better level anyway.
knip flagged it as an unused export.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant