Skip to content

fix(desktop): derive terminal agent status from host bindings (single source of truth) - #5449

Merged
Kitenite merged 11 commits into
mainfrom
check-agent-binding-decou
Jul 4, 2026
Merged

Kitenite merged 11 commits into
mainfrom
check-agent-binding-decou

Conversation

@Kitenite

@Kitenite Kitenite commented Jul 3, 2026 •

Copy link
Copy Markdown
Member

Links

Summary

  • Terminal agent status (working/permission/idle/review) is now derived from the host's TerminalAgentBinding instead of being replayed into a localStorage-persisted renderer status map. Stale "working" dots after an app restart become structurally impossible — there is no stored copy left to go stale.
  • Host-side, binding visibility is derived from session liveness: listByWorkspace/findActive join terminal_sessions on status = 'active', so agents in dead terminals (kill -9, crash, host downtime) disappear from reads with zero prune bookkeeping. This is the root fix for the leak fix(host-service): prune terminal-agent bindings on pty exit and at startup #5443 patched with compensating layers.
  • Net −378 lines in tracked files; the renderer notification store shrinks to two maps (manualUnread, terminalSeenAt).

Why / Context

The binding store and the renderer status map were both projections of the same agent-hook stream, coupled only at the source (notifications.hook broadcasts the WS event and records the binding in one mutation). The renderer projection only updated while a window was open and connected, and was persisted with no reconciliation — any missed Stop/Detached became permanent drift: phantom "working" dots, workspace indicators lit for terminals that no longer exist.

How It Works

  • working ⇔ binding.lastEventType === "Start", permission ⇔ "PermissionRequest", idle ⇔ "Stop"/"Attached" — a pure function (deriveTerminalAgentStatus) over the binding the renderer already fetches.
  • review = the host recorded a Stop newer than a renderer-persisted per-terminal lastSeenAt. Seen marks are written when a pane is focused, when an event lands while its target is visible, on interrupt/close, and by the sidebar mark-read action. Because it's computed from the host's lastEventAt, review survives the missed-event case (agent finishes while the app is closed → correct green dot on relaunch).
  • Interrupt (Esc/Ctrl+C) POSTs a synthetic Stop to the existing notifications.hook endpoint (agents fire no Stop hook on interrupt), gated on the binding actually being working/permission so vim-style Escape spam doesn't spam the host.
  • The Electron fallback for adopted shells (stale hook URLs) forwards events to the host so the binding — now the source of truth — converges instead of being bypassed.
  • The zustand store's persist version bumps to 2; the migration keeps only manual mark-unread entries and drops all legacy per-source statuses.

Manual QA Checklist

  • Run claude in a v2 workspace terminal, submit a prompt → amber "working" dot on pane tab, sidebar chip, workspace row
  • Trigger a permission prompt → red "permission" dot until answered
  • Switch workspaces before the turn ends → green "review" dot + chime on finish; focusing the pane clears it
  • Stale-state regression: quit the app (Cmd+Q) mid-turn, let the agent finish, relaunch → dot is green "review", NOT amber "working"
  • Interrupt: Esc during a turn → dot clears within ~1s in this window and in a second window
  • Binding leak: kill -9 a bound agent's terminal shell → agent chip disappears from the sidebar without any hook firing
  • localStorage v2-notifications-v1 shows manualUnread/terminalSeenAt, no legacy sources statuses

Testing

  • cd apps/desktop && bun test — 2045 pass (note: must run with cwd apps/desktop so bunfig.toml preloads test-setup.ts)
  • cd packages/host-service && bun test — 750 pass, incl. new persistence.test.ts (in-memory sqlite + migrations) covering the liveness join and deleteDefunct
  • bun run typecheck, bun run lint — clean
  • Derivation tests were mutation-checked (breaking the Stop branch fails 2 tests)
  • Manual QA above not yet performed — needs a human driving the app; checklist mirrors the ExecPlan acceptance

Design Decisions

  • Join, not pruning (host): terminal_sessions.status is the already-maintained liveness source (pty onExit, dispose routes, reaper healing). Reading through it makes dead bindings unrepresentable; every review finding on fix(host-service): prune terminal-agent bindings on pty exit and at startup #5443 was a failure mode of its patch machinery (listener leaks, startup crash, blocking broadcasts).
  • Derived review via lastSeenAt, not stored review: a stored review requires witnessing the event; a derived one is correct even when the app was closed when the agent finished.
  • Deleted rather than reconciled: no sync protocol between two stores — the second projection is simply gone. The store now holds facts about the user (seen timestamps, manual unread), never about the agents.

Known Limitations

  • review no longer survives agent Detached (binding is deleted when the agent process exits). Accepted: the chip disappears with the dot, and the failure is benign.
  • The sidebar "clear status" action can no longer wipe a working/permission dot — those are live host state now, which is the point.
  • Chat pane statuses (previously dead plumbing with zero callers) were deleted; rebuild on the derived model when chat statuses actually ship.

Rollback

Revert the two commits; the persist migration is forward-only but harmless on rollback (old code treats the v2 blob as empty state and rebuilds from live events).


Open in Stage

Summary by cubic

Derives terminal agent status from the host binding as the single source of truth, removing stale “working” dots and auto-hiding agents from dead terminals via session liveness. Adds a 30s cache window so focus/remount refetch self-heals WS outages; dock badge and workspace indicators now use the derived model.

  • Bug Fixes

    • Bindings visibility reads through a session‑liveness join; best‑effort prune runs at startup, and session dispose now broadcasts an exit.
    • Status is derived from bindings: working/permission/idle from lastEventType, review when Stop > seen; seen marks use host time only and are written on focus/visible events; exit prunes seen.
    • Interrupt posts a synthetic Stop only when an agent is active; adopted‑shell events are forwarded to the host so bindings converge.
    • Bindings query uses staleTime: 30s; lifecycle events still invalidate for instant updates.
  • Refactors

    • Replaced the renderer status map with useTerminalAgentStatuses and useV2NotificationStatus; the store now keeps only manualUnread and terminalSeenAt with a v2 persist migration.
    • Deleted status‑transition code and the unused terminalAgents.onWorkspaceChange stream; dock badge/workspace indicators aggregate derived statuses from the react‑query cache.
    • Host persistence adds live reads (listLiveByWorkspace/findLiveActive) and deleteDefunct; derivation and persistence are covered by new tests.

Written for commit a98ca31. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Desktop terminal agent indicators (working/permission/review/idle) are now derived from host-side lifecycle signals, and workspace attention/unread is computed from derived terminal state plus manual unread, including an OS dock badge count.
  • Bug Fixes
    • Ctrl+C / Escape now records an interruption Stop when an agent turn is active, improving interrupt handling.
    • Visibility and terminal lifecycle now mark items as “seen” consistently and prune stale “seen” data.
    • Lifecycle events are forwarded to the active host so host state remains the source of truth.
  • Tests
    • Added tests for status derivation and terminal-agent persistence “live” reads/pruning, and expanded v2 notification store/migration coverage.

Kitenite added 2 commits July 3, 2026 16:28
…ion liveness

listByWorkspace/findActive now read via a SQL join to terminal_sessions
(status = 'active' and workspace-owned), so bindings for dead terminals
are unrepresentable in reads no matter how the terminal died (kill -9,
crash, host downtime) — no exit listeners, startup drains, or read
guards. A best-effort deleteDefunct() at startup keeps the table small.
Supersedes the prune-layer approach in #5443.
Terminal agent runtime state (working/permission/idle) was tracked in
two unreconciled places: the host's TerminalAgentBinding and a
localStorage-persisted renderer status map replaying the same hook
events. Missed events (app closed, WS disconnected) left phantom
"working" dots forever.

Statuses are now derived: working/permission/idle come from the
binding's lastEventType, and review means the host recorded a Stop
newer than a renderer-persisted per-terminal seen timestamp. The
renderer store shrinks to manualUnread + terminalSeenAt; the whole
source-status system (statusTransitions, setTerminalStatus, chat
plumbing with zero callers) is deleted. Chime and native-notification
logic stay event-driven and untouched.

Interrupt (Esc/Ctrl+C) posts a synthetic Stop to the host since agents
fire no Stop hook on interrupt, and the Electron fallback for adopted
shells forwards events to the host so the binding converges.
@cursor

cursor Bot commented Jul 3, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@coderabbitai

coderabbitai Bot commented Jul 3, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR moves terminal agent runtime status derivation to host binding data, simplifies renderer notification storage to manual unread plus terminal seen timestamps, rewires consumers to derived hooks, and adds host-service liveness-joined reads and cleanup.

Changes

Terminal agent status derivation and notification simplification

Layer / File(s) Summary
Implementation plan document
apps/desktop/plans/done/20260703-1507-derive-terminal-agent-status-from-host-binding.md
Adds the living plan document with milestones, decisions, and validation criteria for the status derivation change.
deriveTerminalAgentStatus and useTerminalAgentStatuses
apps/desktop/src/renderer/hooks/host-service/useTerminalAgentStatuses/*
Adds the pure derivation helper, the memoized terminal-status hook, tests, and directory re-exports.
v2-notifications store simplification and migration
apps/desktop/src/renderer/stores/v2-notifications/store.ts, store.test.ts, index.ts
Replaces the stored status map with manualUnread and terminalSeenAt, adds migration and seen-mark helpers, and removes the old selector API surface.
Derived notification hooks and consumer rewiring
apps/desktop/src/renderer/hooks/host-service/useV2NotificationStatus/*, DashboardSidebarWorkspaceItem.tsx, V2NotificationStatusIndicator.tsx, DockBadgeController.tsx, useDashboardSidebarWorkspaceRunningAgents.ts, useDashboardSidebarWorkspaceItemActions.ts
Adds derived pane/workspace unread/status hooks, attention counting, and seen-marking, then rewires sidebar and badge consumers to the new hook entry points.
Lifecycle controller and interrupt handling switched to seen-marking
useClearActivePaneAttention.ts, useTerminalInterruptClear.ts, usePaneRegistry.tsx, V2NotificationController.tsx, lifecycleEvents.ts, HostNotificationSubscriber.tsx
Changes pane clearing, terminal interrupt, notification controller, and lifecycle event handling to mark terminals seen, synthesize host stops, and forward lifecycle events to host service.
Host-service terminal-agent liveness joins and cleanup
packages/host-service/src/terminal-agents/*, packages/host-service/src/app.ts, packages/host-service/src/terminal/terminal.ts
Adds live-query persistence reads, shared row conversion, startup pruning of defunct bindings, terminal session exit broadcasting, and integration tests for binding liveness.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

  • superset-sh/superset#4614: Both PRs modify the v2 notification lifecycle flow in V2NotificationController and lifecycleEvents.ts.
  • superset-sh/superset#4703: Both PRs change the lifecycle/event handling path that threads terminal lifecycle data through Electron and host-service notification handling.
  • superset-sh/superset#5349: Both PRs change dashboard sidebar workspace attention/clear-status behavior and v2 notifications store wiring.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: deriving terminal agent status from host bindings as the single source of truth.
Description check ✅ Passed The description is detailed and covers the core sections' information, though it doesn't follow the template headings exactly.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch check-agent-binding-decou

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@ghost

ghost commented Jul 3, 2026 •

Copy link
Copy Markdown

@greptile-apps

greptile-apps Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Terminal agent status indicators (working / permission / idle / review) are now derived from the host's TerminalAgentBinding rather than a localStorage-persisted renderer status map, eliminating the phantom "working" dots that appeared after restart. The host-side liveness join on terminal_sessions.status = 'active' means dead-terminal bindings are structurally unrepresentable in reads with no prune bookkeeping.

  • Host (persistence.ts): adds listLiveByWorkspace / findLiveActive (active-session join) and best-effort deleteDefunct hygiene at startup; TerminalAgentStore routes list/find through these, keeping the in-memory map only for the get() fresh-launch path.
  • Renderer store (store.ts): shrinks from a full status-entry map to two facts-about-the-user maps — manualUnread and terminalSeenAt; persist version bumps to 2 with a migration that preserves only manual-unread marks.
  • Interrupt clear (useTerminalInterruptClear.ts): now POSTs a synthetic Stop to the host (gated on working/permission binding state) and marks the terminal seen locally; adopted-shell events are forwarded to the host from V2NotificationController so bindings converge.

Confidence Score: 4/5

Safe to merge; the architectural change is structurally sound with good test coverage and no data-loss paths.

The refactor correctly eliminates the stale-state bug by removing the second projection entirely. Host-side liveness join is well-tested with the new in-memory SQLite suite. The two remaining concerns are both shallow: a dead workspaceId parameter that TypeScript callers must supply but the function ignores, and a brief "review" flash on interrupt that self-heals within one WebSocket round-trip when useClearActivePaneAttention or markSeenIfTargetVisible fires. Neither affects data integrity or correctness for the happy path.

lifecycleEvents.ts (dead parameter) and useTerminalInterruptClear.ts (seen-mark / synthetic-Stop ordering) warrant a second look before merging.

Important Files Changed

Filename Overview
packages/host-service/src/terminal-agents/persistence.ts Adds liveness-joined reads (listLiveByWorkspace, findLiveActive) and a best-effort deleteDefunct hygiene sweep; logic is clean and well-tested.
packages/host-service/src/terminal-agents/persistence.test.ts New in-memory SQLite tests cover liveness filtering, store routing, multi-binding findLiveActive ordering, and deleteDefunct — good coverage of the new paths.
packages/host-service/src/terminal-agents/store.ts Routes listByWorkspace/findActive through optional persistence-layer live joins; in-memory map kept as fallback for get() only.
packages/host-service/src/app.ts Adds startup deleteDefunct call wrapped in try/catch so failures don't block launch; ordering (deleteDefunct before TerminalAgentStore construction) is correct.
apps/desktop/src/renderer/stores/v2-notifications/store.ts Notification store shrunk to manualUnread/terminalSeenAt maps with version bump to 2; migration correctly preserves only manual-unread entries from v1 manual: source keys.
apps/desktop/src/renderer/hooks/host-service/useTerminalAgentStatuses/deriveTerminalAgentStatus.ts Pure derivation function mapping lastEventType + seen-at comparison to UI status; straightforward and mutation-tested.
apps/desktop/src/renderer/hooks/host-service/useV2NotificationStatus/useV2NotificationStatus.ts Replaces store-selector functions with hooks that compose useTerminalAgentStatuses + manualUnread; coverage of workspace/pane/sources queries looks complete.
apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/lifecycleEvents.ts Simplified to seen-marking + chime; dead workspaceId param on handleV2TerminalLifecycleEvent is a leftover (declared in type, not destructured).
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useTerminalInterruptClear/useTerminalInterruptClear.ts Now posts a synthetic Stop to the host on Esc/Ctrl+C; markTerminalSeenNow runs before the host confirms the Stop, which can produce a brief "review" flash (self-healing).
apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/V2NotificationController.tsx Adds host-forwarding for adopted-shell lifecycle events so the host binding stays in sync; only seen-marking is done locally now.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Agent as Claude Agent
    participant Host as Host Service
    participant WS as WebSocket
    participant Renderer as Renderer
    participant Store as v2-notifications Store

    Agent->>Host: "POST /notifications/hook {Start}"
    Host->>Host: "upsert binding (lastEventType=Start)"
    Host->>WS: broadcast lifecycle event
    WS->>Renderer: handleV2AgentLifecycleEvent
    Renderer->>Renderer: markSeenIfTargetVisible (if pane visible)

    Note over Renderer: useTerminalAgentStatuses derives status from binding.lastEventType

    Agent->>Host: "POST /notifications/hook {Stop}"
    Host->>Host: "upsert binding (lastEventType=Stop)"
    Host->>WS: broadcast lifecycle event
    WS->>Renderer: handleV2AgentLifecycleEvent → chime
    Renderer->>Store: markTerminalSeen(terminalId, occurredAt) if visible
    Note over Store,Renderer: lastSeenAt≥lastEventAt → idle / lastSeenAt<lastEventAt → review

    Note over Renderer: Interrupt (Esc/Ctrl+C)
    Renderer->>Store: markTerminalSeenNow(terminalId)
    Renderer->>Host: "POST /notifications/hook {Stop} synthetic"
    Host->>Host: "upsert binding (lastEventType=Stop)"
    Host->>WS: broadcast
    WS->>Renderer: markSeenIfTargetVisible (clears transient review)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Agent as Claude Agent
    participant Host as Host Service
    participant WS as WebSocket
    participant Renderer as Renderer
    participant Store as v2-notifications Store

    Agent->>Host: "POST /notifications/hook {Start}"
    Host->>Host: "upsert binding (lastEventType=Start)"
    Host->>WS: broadcast lifecycle event
    WS->>Renderer: handleV2AgentLifecycleEvent
    Renderer->>Renderer: markSeenIfTargetVisible (if pane visible)

    Note over Renderer: useTerminalAgentStatuses derives status from binding.lastEventType

    Agent->>Host: "POST /notifications/hook {Stop}"
    Host->>Host: "upsert binding (lastEventType=Stop)"
    Host->>WS: broadcast lifecycle event
    WS->>Renderer: handleV2AgentLifecycleEvent → chime
    Renderer->>Store: markTerminalSeen(terminalId, occurredAt) if visible
    Note over Store,Renderer: lastSeenAt≥lastEventAt → idle / lastSeenAt<lastEventAt → review

    Note over Renderer: Interrupt (Esc/Ctrl+C)
    Renderer->>Store: markTerminalSeenNow(terminalId)
    Renderer->>Host: "POST /notifications/hook {Stop} synthetic"
    Host->>Host: "upsert binding (lastEventType=Stop)"
    Host->>WS: broadcast
    WS->>Renderer: markSeenIfTargetVisible (clears transient review)
Loading
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/lifecycleEvents.ts:92-100
**Dead `workspaceId` parameter**

`workspaceId` is declared in the parameter object type but never destructured, so it is silently ignored at runtime. TypeScript enforces that callers pass it, creating a false impression that it affects behavior. If `workspaceId` is no longer needed (the only remaining action is `pruneTerminalSeen` keyed on `terminalId`), remove it from the interface; if future callers will need it, destructure it with an `_` prefix to make the intent explicit.

### Issue 2 of 2
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useTerminalInterruptClear/useTerminalInterruptClear.ts:32-45
**Transient "review" dot on interrupt due to client/host clock ordering**

`markTerminalSeenNow` stamps `lastSeenAt = Date.now()` on the client (T1) *before* the synthetic Stop is sent to the host. The host records `lastEventAt` when it processes the mutation (T2 ≥ T1 due to network round-trip). Until the resulting binding update propagates back over the WebSocket, `deriveTerminalAgentStatus` sees `lastEventAt (T2) > lastSeenAt (T1)` and briefly renders a "review" dot — the opposite of what an interrupt is supposed to do.

The window self-heals: when the WebSocket binding update arrives, `markSeenIfTargetVisible` runs (if the pane is visible) and clears the dot, or `useClearActivePaneAttention` catches the transient "review" state. A simple fix is to pass the client timestamp as `occurredAt` to the hook so the host uses it directly, or to update `lastSeenAt` after the host call resolves.

Reviews (1): Last reviewed commit: "fix(desktop): derive terminal agent stat..." | Re-trigger Greptile

Comment on lines 92 to 100
export function handleV2TerminalLifecycleEvent({
workspaceId,
payload,
}: {
workspaceId: string;
payload: TerminalLifecyclePayload;
}): void {
if (payload.eventType !== "exit") return;
clearSources(workspaceId, [
getV2TerminalNotificationSource(payload.terminalId),
]);
}

function updatePaneStatus(
workspaceId: string,
payload: AgentLifecyclePayload,
target: V2NotificationTarget,
paneLayout: WorkspaceState<PaneViewerData> | null | undefined,
): void {
const store = useV2NotificationStore.getState();
const targetVisible = isV2NotificationTargetVisible({
currentWorkspaceId: getCurrentWorkspaceId(),
paneLayout,
target,
});
const transition = resolveV2AgentStatusTransition({
workspaceId,
payload,
statuses: store.sources,
targetVisible,
});

clearSources(workspaceId, transition.clearSources);
if (transition.setStatus) {
store.setSourceStatus(
transition.setStatus.source,
workspaceId,
transition.setStatus.status,
payload.occurredAt,
);
}
useV2NotificationStore.getState().pruneTerminalSeen(payload.terminalId);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Dead workspaceId parameter

workspaceId is declared in the parameter object type but never destructured, so it is silently ignored at runtime. TypeScript enforces that callers pass it, creating a false impression that it affects behavior. If workspaceId is no longer needed (the only remaining action is pruneTerminalSeen keyed on terminalId), remove it from the interface; if future callers will need it, destructure it with an _ prefix to make the intent explicit.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/lifecycleEvents.ts
Line: 92-100

Comment:
**Dead `workspaceId` parameter**

`workspaceId` is declared in the parameter object type but never destructured, so it is silently ignored at runtime. TypeScript enforces that callers pass it, creating a false impression that it affects behavior. If `workspaceId` is no longer needed (the only remaining action is `pruneTerminalSeen` keyed on `terminalId`), remove it from the interface; if future callers will need it, destructure it with an `_` prefix to make the intent explicit.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +32 to +45
const binding = useTerminalAgentBinding(workspaceId, terminalId);

const recordInterrupt = useEffectEvent(() => {
markTerminalSeenNow(terminalId);
const agentActive =
binding?.lastEventType === "Start" ||
binding?.lastEventType === "PermissionRequest";
if (!agentActive || !hostUrl) return;
getHostServiceClientByUrl(hostUrl)
.notifications.hook.mutate({ terminalId, eventType: "Stop" })
.catch((error) => {
console.warn(
"[terminal] failed to record synthetic agent stop:",
error,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Transient "review" dot on interrupt due to client/host clock ordering

markTerminalSeenNow stamps lastSeenAt = Date.now() on the client (T1) before the synthetic Stop is sent to the host. The host records lastEventAt when it processes the mutation (T2 ≥ T1 due to network round-trip). Until the resulting binding update propagates back over the WebSocket, deriveTerminalAgentStatus sees lastEventAt (T2) > lastSeenAt (T1) and briefly renders a "review" dot — the opposite of what an interrupt is supposed to do.

The window self-heals: when the WebSocket binding update arrives, markSeenIfTargetVisible runs (if the pane is visible) and clears the dot, or useClearActivePaneAttention catches the transient "review" state. A simple fix is to pass the client timestamp as occurredAt to the hook so the host uses it directly, or to update lastSeenAt after the host call resolves.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useTerminalInterruptClear/useTerminalInterruptClear.ts
Line: 32-45

Comment:
**Transient "review" dot on interrupt due to client/host clock ordering**

`markTerminalSeenNow` stamps `lastSeenAt = Date.now()` on the client (T1) *before* the synthetic Stop is sent to the host. The host records `lastEventAt` when it processes the mutation (T2 ≥ T1 due to network round-trip). Until the resulting binding update propagates back over the WebSocket, `deriveTerminalAgentStatus` sees `lastEventAt (T2) > lastSeenAt (T1)` and briefly renders a "review" dot — the opposite of what an interrupt is supposed to do.

The window self-heals: when the WebSocket binding update arrives, `markSeenIfTargetVisible` runs (if the pane is visible) and clears the dot, or `useClearActivePaneAttention` catches the transient "review" state. A simple fix is to pass the client timestamp as `occurredAt` to the hook so the host uses it directly, or to update `lastSeenAt` after the host call resolves.

How can I resolve this? If you propose a fix, please make it concise.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 issues found across 26 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useTerminalInterruptClear/useTerminalInterruptClear.ts">

<violation number="1" location="apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useTerminalInterruptClear/useTerminalInterruptClear.ts:36">
P2: Interrupts can still leave a stale working/permission indicator when they occur before binding data is hydrated. This guard depends on renderer `binding.lastEventType`, so `binding === undefined` returns early and skips the synthetic host `Stop`; consider treating unknown binding as active (or sending Stop whenever `hostUrl` exists) and letting host-side logic no-op when nothing is active.</violation>
</file>

<file name="apps/desktop/src/renderer/hooks/host-service/useTerminalAgentStatuses/useTerminalAgentStatuses.ts">

<violation number="1" location="apps/desktop/src/renderer/hooks/host-service/useTerminalAgentStatuses/useTerminalAgentStatuses.ts:19">
P3: Any terminal seen update now rerenders and recomputes terminal-agent statuses for every mounted workspace/sidebar consumer, not just the workspace or terminal that changed. Consider narrowing the Zustand subscription to the bound terminal IDs (or using an equality-checked derived selector) so unrelated terminalSeenAt entries do not churn all status maps.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Agent as Agent Process
    participant HostDB as Host SQLite<br/>(terminal_sessions + bindings)
    participant HostPersistence as SqliteTerminalAgentBinding<br/>Persistence
    participant HostStore as TerminalAgentStore
    participant EventBus as EventBus / WS
    participant RendererWS as Renderer WebSocket
    participant NotifController as V2NotificationController
    participant NotifStore as Zustand Store<br/>(terminalSeenAt only)
    participant StatusHook as useTerminalAgentStatuses
    participant UI as UI (Sidebar / Pane Tab)
    participant UserKeyboard as User (Ctrl+C / Esc)

    Note over Agent,HostDB: 1. Normal agent lifecycle event

    Agent->>HostStore: POST /notifications.hook (Start / Stop / PermissionRequest)
    HostStore->>HostPersistence: recordEvent()
    HostPersistence->>HostDB: NEW: upsert (binding row)
    HostStore-->>EventBus: emit lifecycle event

    EventBus-->>RendererWS: broadcast agent lifecycle event

    RendererWS->>NotifController: handleV2AgentLifecycleEvent()
    alt CHANGED: target isn't visible & event is Stop
        NotifController->>NotifController: play chime (unchanged)
    end
    opt target pane is visible
        NotifController->>NotifStore: markTerminalSeen(terminalId, now)
    end

    Note over UI,StatusHook: 2. React reads derived status

    UI->>StatusHook: useTerminalAgentStatuses(workspaceId)
    StatusHook->>HostStore: NEW: listLiveByWorkspace(workspaceId)
    HostStore->>HostPersistence: listLiveByWorkspace()
    HostPersistence->>HostDB: NEW: SELECT bindings JOIN terminal_sessions<br/>WHERE status='active' AND origin IS NOT NULL
    HostDB-->>HostPersistence: live bindings
    HostPersistence-->>HostStore: TerminalAgentBinding[]
    HostStore-->>StatusHook: bindings
    StatusHook->>NotifStore: read terminalSeenAt
    NotifStore-->>StatusHook: { terminalId: timestamp }
    StatusHook->>StatusHook: NEW: deriveTerminalAgentStatus(lastEventType, lastEventAt, lastSeenAt)
    StatusHook-->>UI: Map<terminalId, PaneStatus>

    Note over UI: 3. Interrupt (Ctrl+C / Esc)

    UserKeyboard->>UI: Ctrl+C in terminal
    UI->>UI: useTerminalInterruptClear hook
    UI->>NotifStore: NEW: markTerminalSeenNow(terminalId)
    UI->>HostStore: NEW: notifications.hook.mutate({ terminalId, eventType: "Stop" })
    HostStore->>HostPersistence: recordEvent (synthetic Stop)
    HostPersistence->>HostDB: upsert binding with lastEventType='Stop', lastEventAt=now
    HostStore-->>EventBus: broadcast Stop event --> renderer sees new binding

    Note over Agent,HostDB: 4. Agent killed (kill -9) – no hook

    Agent->>Agent: process dies
    HostDB->>HostDB: terminal_session status set to 'exited'<br/>(by reaper / dispose route)
    Note over HostPersistence,HostDB: Subsequent reads join on 'active' only → binding excluded

    Note over HostStore: 5. Host startup – best-effort prune

    HostStore->>HostPersistence: NEW: deleteDefunct()
    HostPersistence->>HostDB: DELETE bindings WHERE session is missing/exited/disposed/orphan
    HostDB-->>HostPersistence: rows removed (non-critical)

    Note over Agent,HostDB: 6. Electron fallback for adopted shells

    Agent->>UI: POST to Electron express server
    UI->>NotifController: handleElectronAgentLifecycle (adopted shell)
    NotifController->>HostStore: NEW: GET host URL, forward event to notifications.hook
    HostStore-->>NotifController: binding updated (converges)
    NotifController->>NotifStore: markV2AgentLifecycleTargetSeen (seen if visible)
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment on lines +36 to +39
const agentActive =
binding?.lastEventType === "Start" ||
binding?.lastEventType === "PermissionRequest";
if (!agentActive || !hostUrl) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Interrupts can still leave a stale working/permission indicator when they occur before binding data is hydrated. This guard depends on renderer binding.lastEventType, so binding === undefined returns early and skips the synthetic host Stop; consider treating unknown binding as active (or sending Stop whenever hostUrl exists) and letting host-side logic no-op when nothing is active.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useTerminalInterruptClear/useTerminalInterruptClear.ts, line 36:

<comment>Interrupts can still leave a stale working/permission indicator when they occur before binding data is hydrated. This guard depends on renderer `binding.lastEventType`, so `binding === undefined` returns early and skips the synthetic host `Stop`; consider treating unknown binding as active (or sending Stop whenever `hostUrl` exists) and letting host-side logic no-op when nothing is active.</comment>

<file context>
@@ -13,17 +16,37 @@ interface UseTerminalInterruptClearOptions {
+
+	const recordInterrupt = useEffectEvent(() => {
+		markTerminalSeenNow(terminalId);
+		const agentActive =
+			binding?.lastEventType === "Start" ||
+			binding?.lastEventType === "PermissionRequest";
</file context>
Suggested change
const agentActive =
binding?.lastEventType === "Start" ||
binding?.lastEventType === "PermissionRequest";
if (!agentActive || !hostUrl) return;
const agentActive =
binding === undefined ||
binding.lastEventType === "Start" ||
binding.lastEventType === "PermissionRequest";
if (!agentActive || !hostUrl) return;

): Map<string, PaneStatus> {
const bindings = useTerminalAgentBindings(workspaceId, options);
const terminalSeenAt = useV2NotificationStore(
(state) => state.terminalSeenAt,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Any terminal seen update now rerenders and recomputes terminal-agent statuses for every mounted workspace/sidebar consumer, not just the workspace or terminal that changed. Consider narrowing the Zustand subscription to the bound terminal IDs (or using an equality-checked derived selector) so unrelated terminalSeenAt entries do not churn all status maps.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/hooks/host-service/useTerminalAgentStatuses/useTerminalAgentStatuses.ts, line 19:

<comment>Any terminal seen update now rerenders and recomputes terminal-agent statuses for every mounted workspace/sidebar consumer, not just the workspace or terminal that changed. Consider narrowing the Zustand subscription to the bound terminal IDs (or using an equality-checked derived selector) so unrelated terminalSeenAt entries do not churn all status maps.</comment>

<file context>
@@ -0,0 +1,36 @@
+): Map<string, PaneStatus> {
+	const bindings = useTerminalAgentBindings(workspaceId, options);
+	const terminalSeenAt = useV2NotificationStore(
+		(state) => state.terminalSeenAt,
+	);
+
</file context>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts (1)

4-7: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Logic looks correct — combining clearManualUnread + markWorkspaceTerminalsSeen in clearWorkspaceAttention is necessary since useV2WorkspaceIsUnread treats manual and derived "review" status as equivalent; without marking terminals seen, toggling "read" would be a no-op whenever unread was review-driven.

One scalability note worth verifying: useV2WorkspaceIsUnread and useMarkWorkspaceTerminalsSeen both resolve through useTerminalAgentBindings(workspaceId) (a live-query subscription), and this hook is mounted unconditionally for every rendered DashboardSidebarWorkspaceItem in the sidebar. By contrast, the sibling useDashboardSidebarWorkspaceRunningAgents explicitly threads an enabled flag into the same underlying primitives for lazy loading. Previously, isUnread/attention-clearing read a plain Zustand object (cheap); now every sidebar row establishes a live binding query regardless of visibility, which could scale poorly with many workspaces.

Please confirm whether useTerminalAgentBindings/useTerminalAgentStatuses subscriptions are cheap enough (cache-first, no per-subscription network cost) to mount unconditionally per sidebar row, or whether useV2WorkspaceIsUnread/useMarkWorkspaceTerminalsSeen should also accept an enabled option for consistency with the running-agents hook.

♻️ Possible direction: thread `enabled` through the unread/seen hooks
-export function useV2WorkspaceIsUnread(workspaceId: string): boolean {
-	const statuses = useTerminalAgentStatuses(workspaceId);
+export function useV2WorkspaceIsUnread(
+	workspaceId: string,
+	options?: { enabled?: boolean },
+): boolean {
+	const statuses = useTerminalAgentStatuses(workspaceId, options);

Also applies to: 17-17, 41-49, 65-65, 150-162

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts`
around lines 4 - 7, The unread/seen hooks used by
useDashboardSidebarWorkspaceItemActions are currently always subscribing through
useTerminalAgentBindings for every DashboardSidebarWorkspaceItem, which may be
too expensive at sidebar scale. Thread an enabled option through
useV2WorkspaceIsUnread and useMarkWorkspaceTerminalsSeen (and their underlying
useTerminalAgentBindings/useTerminalAgentStatuses calls) so they can be mounted
lazily like useDashboardSidebarWorkspaceRunningAgents, and update
useDashboardSidebarWorkspaceItemActions to pass that flag where appropriate.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/`$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useTerminalInterruptClear/useTerminalInterruptClear.ts:
- Around line 34-48: The interrupt clear flow in useTerminalInterruptClear
currently calls markTerminalSeenNow before recording the synthetic Stop in
recordInterrupt, which can leave terminalSeenAt behind the Stop event and keep
the pane flagged. Move the seen-update to happen only after
getHostServiceClientByUrl(hostUrl).notifications.hook.mutate succeeds, or update
it using the Stop’s occurredAt so the timestamps stay consistent.

---

Nitpick comments:
In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts`:
- Around line 4-7: The unread/seen hooks used by
useDashboardSidebarWorkspaceItemActions are currently always subscribing through
useTerminalAgentBindings for every DashboardSidebarWorkspaceItem, which may be
too expensive at sidebar scale. Thread an enabled option through
useV2WorkspaceIsUnread and useMarkWorkspaceTerminalsSeen (and their underlying
useTerminalAgentBindings/useTerminalAgentStatuses calls) so they can be mounted
lazily like useDashboardSidebarWorkspaceRunningAgents, and update
useDashboardSidebarWorkspaceItemActions to pass that flag where appropriate.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a80f2e6a-0d37-49c9-a2ae-d04c67c0565e

📥 Commits

Reviewing files that changed from the base of the PR and between 58a759d and ecd44ff.

📒 Files selected for processing (26)
  • apps/desktop/plans/done/20260703-1507-derive-terminal-agent-status-from-host-binding.md
  • apps/desktop/src/renderer/hooks/host-service/useTerminalAgentStatuses/deriveTerminalAgentStatus.test.ts
  • apps/desktop/src/renderer/hooks/host-service/useTerminalAgentStatuses/deriveTerminalAgentStatus.ts
  • apps/desktop/src/renderer/hooks/host-service/useTerminalAgentStatuses/index.ts
  • apps/desktop/src/renderer/hooks/host-service/useTerminalAgentStatuses/useTerminalAgentStatuses.ts
  • apps/desktop/src/renderer/hooks/host-service/useV2NotificationStatus/index.ts
  • apps/desktop/src/renderer/hooks/host-service/useV2NotificationStatus/useV2NotificationStatus.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceDetails/hooks/useDashboardSidebarWorkspaceRunningAgents/useDashboardSidebarWorkspaceRunningAgents.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2NotificationStatusIndicator/V2NotificationStatusIndicator.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useClearActivePaneAttention/useClearActivePaneAttention.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useTerminalInterruptClear/useTerminalInterruptClear.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx
  • apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/V2NotificationController.tsx
  • apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/lifecycleEvents.ts
  • apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/statusTransitions.test.ts
  • apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/statusTransitions.ts
  • apps/desktop/src/renderer/stores/v2-notifications/index.ts
  • apps/desktop/src/renderer/stores/v2-notifications/store.test.ts
  • apps/desktop/src/renderer/stores/v2-notifications/store.ts
  • packages/host-service/src/app.ts
  • packages/host-service/src/terminal-agents/index.ts
  • packages/host-service/src/terminal-agents/persistence.test.ts
  • packages/host-service/src/terminal-agents/persistence.ts
  • packages/host-service/src/terminal-agents/store.ts
💤 Files with no reviewable changes (2)
  • apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/statusTransitions.test.ts
  • apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/statusTransitions.ts

@github-actions

github-actions Bot commented Jul 4, 2026 •

Copy link
Copy Markdown
Contributor

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ⚠️ Neon database branch

Thank you for your contribution! 🎉

@cursor

cursor Bot commented Jul 4, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@Kitenite

Kitenite commented Jul 4, 2026

Copy link
Copy Markdown
Member Author

CDP end-to-end verification

Drove the dev desktop app over CDP (RENDERER_REMOTE_DEBUG_PORT=9222), POSTed real lifecycle events at host-service's notifications.hook, and asserted at three layers: host SQLite, the live-join reads, and the rendered DOM (with screenshots).

Verified working end-to-end:

  • Hook → terminal_agent_bindings transitions (Start → PermissionRequest → Stop) recorded host-side
  • working renders as the amber dotted-spinner icon on the sidebar workspace row
  • permission renders as the red badge dot
  • Stop while the workspace isn't focused → green review dot
  • Review survives a full renderer reload — the headline fix; the dot is recomputed from the host binding + persisted terminalSeenAt, no stored status involved
  • Clicking the workspace row clears review (markWorkspaceTerminalsSeen)
  • SessionEnd/Detached deletes the binding (dot and chip gone)
  • Startup deleteDefunct() ran clean; every binding on the dev host joins to an active, workspace-owned session

Not coverable over CDP in this environment (the dev workspace has a pane-layout sync bug — panes vanish from the layout seconds after creation, so no mounted TerminalPane and no pty input path):

  • visible-Stop → seen-marked → idle (needs the pane present in the layout for target resolution)
  • interrupt → synthetic Stop (needs a mounted pane's onKey subscription)
  • exit-leak UI reaction (the join semantics are unit-tested in persistence.test.ts)

Those three remain on the manual QA checklist.

Kitenite added 2 commits July 3, 2026 17:40
#5351 added selectV2AttentionWorkspaceCount to the status-entry store this
branch deleted. Reimplemented as useV2AttentionWorkspaceCount in the
composite module: distinct workspaces with a derived permission/review
terminal status (aggregated from cached bindings queries) or a manual
unread mark; working excluded, matching the original semantics.
- Interrupt: re-mark the terminal seen after the synthetic Stop resolves,
  so the host-stamped Stop can't transiently derive as an unseen review
  (greptile/cubic/coderabbit).
- Seen marks clamp to the binding's host-clock lastEventAt in
  useClearActivePaneAttention and useMarkWorkspaceTerminalsSeen, so a host
  clock ahead of the renderer can't leave an unclearable review.
- Drop the unused workspaceId param from handleV2TerminalLifecycleEvent.
- Cover deleteDefunct's missing-session branch in persistence.test.
@cursor

cursor Bot commented Jul 4, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/desktop/src/renderer/hooks/host-service/useV2NotificationStatus/useV2NotificationStatus.ts (1)

94-109: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use the host timestamp directly here.

binding.lastEventAt is already host-clock data. Math.max(Date.now(), binding.lastEventAt) can store a renderer-side timestamp that stays ahead of later host Stop events on non-local host connections, which can keep a real review state hidden. Use binding.lastEventAt here; only fall back to Date.now() when no host timestamp is available.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/desktop/src/renderer/hooks/host-service/useV2NotificationStatus/useV2NotificationStatus.ts`
around lines 94 - 109, The timestamp handling in useMarkWorkspaceTerminalsSeen
is clamping host data with Date.now(), which can hide later host-driven terminal
reviews. Update the callback in useMarkWorkspaceTerminalsSeen to pass
binding.lastEventAt directly into markTerminalSeen, and only use Date.now() as a
fallback when a host timestamp is missing; keep the fix localized around
useTerminalAgentBindings and markTerminalSeen.
🧹 Nitpick comments (1)
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useClearActivePaneAttention/useClearActivePaneAttention.ts (1)

33-44: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Same clamp pattern duplicated — and same clock-mixing caveat applies.

This repeats the Math.max(Date.now(), lastEventAt) clamp from useMarkWorkspaceTerminalsSeen (see comment in useV2NotificationStatus.ts). Once that's resolved to use the host-clock lastEventAt directly, consider extracting a small shared helper (e.g. clampTerminalSeenAt(lastEventAt?: number)) to avoid drift between the two call sites.

🛠️ Suggested fix
 			if (source.type !== "terminal") continue;
-			// Clamp to the binding's host-clock lastEventAt so a host clock
-			// ahead of the renderer can't leave an unclearable review.
-			const lastEventAt = bindings.get(source.id)?.lastEventAt ?? 0;
-			markTerminalSeen(source.id, Math.max(Date.now(), lastEventAt));
+			const lastEventAt = bindings.get(source.id)?.lastEventAt;
+			markTerminalSeen(source.id, lastEventAt ?? Date.now());
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/`$workspaceId/hooks/useClearActivePaneAttention/useClearActivePaneAttention.ts
around lines 33 - 44, The `useClearActivePaneAttention` effect is duplicating
the same terminal-seen timestamp clamp and still mixes renderer time with the
host clock. Update the `markTerminalSeen` call to use the host-provided
`lastEventAt` directly for terminal sources, then extract the shared timestamp
handling into a small helper like `clampTerminalSeenAt` so this hook and
`useMarkWorkspaceTerminalsSeen` stay consistent and don’t drift.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In
`@apps/desktop/src/renderer/hooks/host-service/useV2NotificationStatus/useV2NotificationStatus.ts`:
- Around line 94-109: The timestamp handling in useMarkWorkspaceTerminalsSeen is
clamping host data with Date.now(), which can hide later host-driven terminal
reviews. Update the callback in useMarkWorkspaceTerminalsSeen to pass
binding.lastEventAt directly into markTerminalSeen, and only use Date.now() as a
fallback when a host timestamp is missing; keep the fix localized around
useTerminalAgentBindings and markTerminalSeen.

---

Nitpick comments:
In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/`$workspaceId/hooks/useClearActivePaneAttention/useClearActivePaneAttention.ts:
- Around line 33-44: The `useClearActivePaneAttention` effect is duplicating the
same terminal-seen timestamp clamp and still mixes renderer time with the host
clock. Update the `markTerminalSeen` call to use the host-provided `lastEventAt`
directly for terminal sources, then extract the shared timestamp handling into a
small helper like `clampTerminalSeenAt` so this hook and
`useMarkWorkspaceTerminalsSeen` stay consistent and don’t drift.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a44652fe-a7b4-4b5c-ac00-2f2e3c925fc6

📥 Commits

Reviewing files that changed from the base of the PR and between 35da993 and ff6a56a.

📒 Files selected for processing (8)
  • apps/desktop/src/renderer/hooks/host-service/useV2NotificationStatus/index.ts
  • apps/desktop/src/renderer/hooks/host-service/useV2NotificationStatus/useV2NotificationStatus.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useClearActivePaneAttention/useClearActivePaneAttention.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useTerminalInterruptClear/useTerminalInterruptClear.ts
  • apps/desktop/src/renderer/routes/_authenticated/components/DockBadgeController/DockBadgeController.tsx
  • apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/components/HostNotificationSubscriber/HostNotificationSubscriber.tsx
  • apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/lifecycleEvents.ts
  • packages/host-service/src/terminal-agents/persistence.test.ts
💤 Files with no reviewable changes (1)
  • apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/lifecycleEvents.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • apps/desktop/src/renderer/hooks/host-service/useV2NotificationStatus/index.ts
  • packages/host-service/src/terminal-agents/persistence.test.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useTerminalInterruptClear/useTerminalInterruptClear.ts

Zero consumers, and its deltas fired only on hook-event store changes —
not on session-liveness changes — so any future adopter would inherit
stale snapshots. If a push channel is wanted later, it should ride the
WS event bus with snapshot-on-connect semantics instead.
@cursor

cursor Bot commented Jul 4, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

Replaces the onReconnect bus API + useWorkspaceEventBusReconnect with
staleTime: 30s on the bindings query (window-focus refetch re-enabled by
default). Events still invalidate for instant updates; staleness from a
WS outage now self-heals on the next focus/remount via react-query's
built-ins instead of custom protocol surface. workspace-client is back
to matching main.
@cursor

cursor Bot commented Jul 4, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@cursor

cursor Bot commented Jul 4, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@Kitenite
Kitenite merged commit b69f640 into main Jul 4, 2026
17 checks passed
@Kitenite
Kitenite deleted the check-agent-binding-decou branch July 4, 2026 07:52

This branch was previously deployed

1 inactive deployment
preview — a98ca318 Deployed Jul 4, 2026 by Kitenite via Deploy Marketing #6176
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