Struggle detection v3 + proactive intervention (client) - #335
Draft
Predixx wants to merge 317 commits into
Draft
Conversation
…e + exposes pause/resume
…rence + pause + resume)
…mutators + clean-build no-switch)
…ed by proactive card)
…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.
…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.
… 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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
devprematurely and reverted there (3e322890); the branch and its history are unchanged.What's included
@telemetryclean-build seam.activeinterventions.courseDisabledflag (course-level proactive off).Cross-repo
Pairs with ls1intum/Artemis
feature/iris/struggle-intervention-pipeline(server) and ls1intum/edutelligencefeat/struggle-intervention-pipeline(Pyris pipeline).Notes
scripts/verify-clean-bundle.js).check-types+ eslint + clean-bundle green.