From 0a67c4f129d608ee5a881ea2b051d2074c547d3b Mon Sep 17 00:00:00 2001 From: Lain Date: Mon, 10 Aug 2026 22:59:23 +0200 Subject: [PATCH 001/141] docs(backlog): add per-agent tabs with their own transcripts The largest UI change since 4.0.0, requested after a session running agents under agents plus many background tasks made the single transcript unusable: consecutive "Thought process" rows from different agents, interleaved, unfollowable. Records what the protocol gives (task_started carries tool_use_id and skip_transcript, whose doc comment explicitly anticipates a tasks panel; every assistant/user message carries parent_tool_use_id, and TranscriptModel already keeps parentOf/isDescendantOf) and, more importantly, what it does not: task_started has no parent field, so the chain is our reconstruction, and background_tasks_changed carries no parent or tool_use_id at all, so the owning agent is genuinely unknown for a task never seen in a task_started. Also marks entry 1 done, with the two things the build found that the entry had not anticipated. --- docs/BACKLOG.md | 80 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 73 insertions(+), 7 deletions(-) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 682ad023..34d9beba 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -9,8 +9,12 @@ Ordered by value, not by effort. ## 1. Surface the plan's usage limits in the session dashboard -**Status: NOT backlog — scheduled, and being built now.** Kept here because the evidence below is the useful -part and belongs next to the other protocol findings. +**Status: DONE — shipped in 5.1.0 and 5.1.1.** Kept here because the evidence below is the useful part and +belongs next to the other protocol findings. Two things the entry did not anticipate and the build found: +per-model windows arrive in `rate_limits.model_scoped`, which the binary *synthesises* behind a remote config +and therefore often omits (so the raw `rate_limits.limits[]` array is read too), and a failed usage fetch +degrades to a header-seeded reply carrying only `five_hour`/`seven_day`, which is why a refresh is merged into +the previous one rather than replacing it. The web and desktop Claude apps show, at a glance: current session usage with a reset countdown, weekly usage across all models, weekly usage per model, and the extra-credit balance. The plugin shows a single quota bar @@ -59,7 +63,69 @@ every few minutes is a cost with no user visible in it. --- -## 2. Use `get_workspace_diff` for a session-wide review +## 2. Give every running agent its own tab and its own transcript + +**Status: requested, evidence gathered, not started.** The largest UI change since 4.0.0. + +Today every subagent's output lands in the one transcript of the chat that spawned it. On an ordinary session +that is fine. On a session running agents under agents plus a pile of background tasks — the case this came +from — it stops being usable: the main transcript fills with consecutive "Thought process" rows belonging to +different agents, interleaved, with no way to follow any single one of them, and the panel visibly strains. + +### What it should become + +- **A row of agent tabs under the chat tabs**, one per running agent of the **selected chat**, each with its + own transcript: its thinking, its tool calls, its output. Switching chats swaps the row for that chat's + agents — an agent belongs to its chat, and nothing from another chat is ever shown. +- **Nesting**: an agent that spawns subagents gets a further row listing them, same treatment, recursively. +- **The main transcript stops mixing.** A Task/Agent call renders as a **link to that agent's tab** and its + output no longer interleaves. This is the trade that pays for the feature: the main transcript becomes + readable again precisely because the detail moved somewhere it can be read. +- **The session dashboard states ownership**: for each agent, which chat and which agent chain it runs under, + with a link to it; same for each **background task** — which chat, under which agent(s). + +### What the protocol gives (verified against `sdk.d.ts` @ 0.3.226 and the plugin's models) + +- `system/task_started` carries `task_id`, **`tool_use_id`**, `description`, `subagent_type`, `task_type`, + `workflow_name`, `prompt` and `skip_transcript`. That last field is the SDK explicitly anticipating this + feature: *"Ambient/housekeeping task. Consumers should hide this from the inline transcript; it may still + appear in a tasks panel."* +- Every `assistant`/`user` message carries **`parent_tool_use_id`**, and the plugin already models the + relation: `TranscriptModel` keeps `parentOf`, plus `isDescendantOf` and `insertionIndexFor`, and + `TranscriptReconciler.addSubagentText` already routes subagent text by that id. The routing primitive + exists; what is missing is a place to route it *to*. + +### What the protocol does NOT give — and what that costs + +- **`task_started` has no parent field.** The chain is not given: it has to be reconstructed by joining + `tool_use_id` → the messages carrying that `parent_tool_use_id` → the `task_started` events born inside + them. Derivable to N levels, but it is *our* reconstruction, so it must be built to degrade into a flat + list rather than into a wrong tree. +- **`system/background_tasks_changed` carries only `task_id`, `task_type`, `description`** — no parent, no + `tool_use_id`, no agent — and has REPLACE semantics. The chat is known (it is the session that received the + event); **the owning agent is not**. Where a `task_id` was seen earlier in a `task_started` the chain can be + recovered; where it was not, the dashboard must say *"no known agent"* rather than invent a chain. Stating + this up front so nobody promises the full ownership line for every background task. + +### Probe before building (needs a live heavy session) + +1. Does a second-level `task_started` arrive with the **subagent's** `tool_use_id` (chains) or with the main + turn's (everything flat)? This decides whether nesting is real or cosmetic. +2. Do each subagent's text and tool calls carry their **own** `parent_tool_use_id`, deep enough to route every + block to the right tab? + +Instrument it the way the `get_usage` reply was instrumented — one temporary INFO line printing the wire — +and read it against a session that is actually running agents under agents. + +### Suggested order + +The part that hurts today does not depend on the answers: **stop interleaving subagent output in the main +transcript and leave a link in its place**. That is shippable on its own and immediately makes the heavy +session readable. Deep nesting, dashboard ownership lines and background-task attribution follow, with data. + +--- + +## 3. Use `get_workspace_diff` for a session-wide review **Status:** probed, returns `{"diff": null}` on a clean tree — the request works, we have simply never sent it. @@ -71,7 +137,7 @@ Natural home: a button in the session dashboard, next to Diff History (which is --- -## 3. Surface the active plan with `get_plan` +## 4. Surface the active plan with `get_plan` **Status:** probed, returns `{"exists": false}` when there is none. @@ -80,7 +146,7 @@ In plan mode the plan is visible only as the transcript card that proposed it; s --- -## 4. Deliberately NOT worth doing +## 5. Deliberately NOT worth doing Recorded so nobody re-investigates them. @@ -93,7 +159,7 @@ Recorded so nobody re-investigates them. --- -## 5. Split `ClaudeSession` (carried over from the 5.0.0 static-analysis pass) +## 6. Split `ClaudeSession` (carried over from the 5.0.0 static-analysis pass) **Status:** the two remaining `config/detekt/baseline.xml` entries. @@ -105,7 +171,7 @@ why raising the thresholds instead would be worse. --- -## 6. Tighten the coverage gates when Kover allows it +## 7. Tighten the coverage gates when Kover allows it `KoverVerifyRule` in Kover 0.9.2 has no per-rule filter (verified against the plugin jar), so the per-package thresholds in `docs/RELEASE_CHECKLIST.md` §Coverage policy are enforced today as a floor plus an aggregate From aa4a145b555b81bcb78cb91a9c580c9157f1c663 Mon Sep 17 00:00:00 2001 From: Lain Date: Tue, 11 Aug 2026 00:57:18 +0200 Subject: [PATCH 002/141] docs(map): add PROJECTMAP.md as the repository index Step 0 of the 5.5.0 plan, and what stops every session re-deriving the same repo with the same greps. Built per the project-map skill: the "I want to change -> go to" table is the load-bearing part, directories and entry points are indexed rather than dumped, and no code content is copied, so it cannot drift into fiction. Commands are recorded as verified because they were run today, including the two local quirks that cost time to rediscover: node needs OPENSSL_CONF=/dev/null here, and claude is a system install at /usr/bin/claude while checkDrift defaults to ~/.local/bin/claude. The minefields section carries what has actually bitten: declaration order in ClaudeSession (which the compiler does not catch and InitOrderContractTest scans for), SensitiveGuard walking every string leaf as a path candidate, the hash-pinned CSP in JcefHost, and release.yml publishing on a merge to main. Doctrine stays in CLAUDE.md: this file says where things are, not how work is done. --- PROJECTMAP.md | 107 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 PROJECTMAP.md diff --git a/PROJECTMAP.md b/PROJECTMAP.md new file mode 100644 index 00000000..904aa903 --- /dev/null +++ b/PROJECTMAP.md @@ -0,0 +1,107 @@ +# Map of Claude Code Native (JetBrains plugin) + +> Generated 2026-08-11 against `0daffe7` (branch `feature/release_5.5.0`, clean tree). If anything here does +> not match the repo, **the repo wins**: fix the line and move on. Maintained per the `project-map` skill. +> +> This file says **where** things are. How work is done here — protocol invariants, release rules, the +> "never mirror raw CLI output" principle — lives in [`CLAUDE.md`](CLAUDE.md) and is not repeated. + +## I want to change… → go to… + +| To… | Go to | Note | +|---|---|---| +| Handle a new binary→host event | `protocol/ClaudeEvent.kt` (model + `typed(...)` registry) then `session/ClaudeSession.kt` `onEvent` | Triage the subtype into `ProtocolSurface.KNOWN_SUBTYPES` or `checkDrift` goes red | +| Send a host→binary control request | `protocol/ControlProtocol.kt` (builder) + `session/SessionControlClient.kt` (correlation, watchdog) | Every request is correlated by `request_id`; never block on the reply | +| Change how a turn is orchestrated | `session/ClaudeSession.kt` | **Thin orchestrator**: new behaviour goes to a collaborator, not here | +| Change transcript rows / streaming | `session/TranscriptReconciler.kt` + `session/TranscriptModel.kt` | Assumes EDT. `parentOf`/`isDescendantOf` model subagent nesting | +| Change what the chat renders | `resources/jcef/app-transcript.js` (rows), `app-composer.js` (input + readout), `app-permissions.js` (cards), `app-session.js` (dashboard) | Inlined ES2019, no bundler. CSS class names are a tested contract | +| Add a field to the web payload | `ui/jcef/JcefState.kt` (composer state) or `ui/jcef/JcefSessionData.kt` (dashboard) | kotlinx `buildJsonObject`; null-safe so a card omits cleanly | +| Add a web→host message | `ui/jcef/JcefBridge.kt` (pure parse) + `ui/JcefChatPanel.kt` (dispatch) | `JcefBridge` has no IDE deps so it unit-tests on plain JVM | +| Change tabs / tool window | `ui/ClaudeToolWindowFactory.kt` + `ui/ChatTabsPanel.kt` | One tool-window content holds the whole `JBTabs` strip | +| Change permission behaviour | `permission/PermissionBroker.kt`; hard rules in `permission/SensitiveGuard.kt` | `SensitiveGuard` runs **before** any auto-approval | +| Change what a diff shows / writes | `diff/DiffPresenter.kt`, `diff/HunkSelection.kt`, `session/DiffLifecycleManager.kt` | The **binary** writes the file; the IDE only reviews and refreshes VFS | +| Add a setting | `settings/ClaudeSettings.kt` + `ui/ClaudeSettingsConfigurable.kt` | Persisted in `claude-code.xml`; `applyTo(session)` seeds launch options | +| Change launch flags | `session/SessionLauncher.kt` (`buildArgs`) | Immutable `LaunchOptions` snapshot; `--print` is mandatory | +| Touch auth / credentials | `process/CredentialsVault.kt`, `process/AuthCli.kt`, `session/LoginCoordinator.kt`, `settings/SecretStore.kt` | Credentials reach the binary **by env only**, never argv, never logs | +| Read a past session | `session/SessionStore.kt` (paths, traversal guard) + `session/SessionTranscriptReader.kt` (JSONL → entries) | The binary's files are the source of truth; the plugin persists no transcripts | +| Change CI or the release | `.github/workflows/ci.yml`, `release.yml`; policy in `docs/RELEASE_PROCEDURE.md` | Merging to `main` publishes to Marketplace | + +## Structure + +- `src/main/kotlin/dev/lain/claudejb/` + - `process/` — locating, launching and authenticating the `claude` binary. + - `protocol/` — kotlinx models + NDJSON parser + control-frame builders. **No IDE dependencies.** + - `session/` — one `ClaudeSession` per chat tab plus its single-responsibility collaborators + (`TokenAccountant`, `TaskTracker`, `TranscriptReconciler`, `DiffLifecycleManager`, + `SessionControlClient`, `PermissionCardManager`, `HookBroker`, `HookActivityNarrator`, + `LoginCoordinator`), and the session-history readers. + - `permission/` — the `can_use_tool` broker and the deterministic sensitive-data lock. + - `diff/` — native diff presentation, hunk selection, edit snapshots, rollback. + - `ui/` — tool window, tab strip, settings, dialogs; `ui/jcef/` is the host↔web bridge. + - `context/`, `actions/`, `settings/`, `util/`. +- `src/main/resources/jcef/` — the inlined web app: `shell.html`, `app-*.js`, `app.css`, vendored + `marked`/`purify`/`highlight`. Served under a hash-pinned CSP. +- `src/test/kotlin/` — unit + `headless/` (`BasePlatformTestCase`) + `integration/` (drives `bin/fake-claude`). +- `src/test/frontend/` — vitest + jsdom over the **real** `resources/jcef/*.js`. +- `src/uiTest/` — RemoteRobot, off by default (`-PuiTest.enabled=true`). +- `docs/` — release, branching, compat, threat model and ADRs. `docs/BACKLOG.md` is probed, not guessed. + +## Entry points + +- **Plugin**: `src/main/resources/META-INF/plugin.xml` → tool window `Claude Code` → + `ui/ClaudeToolWindowFactory.kt` → `ui/ChatTabsPanel.kt` → one `ui/JcefChatPanel.kt` per chat. +- **Session**: `session/ClaudeSession.start()` → `session/SessionLauncher.buildArgs` → + `process/ClaudeProcess.kt` (stdio) → `ProtocolParser` (an object inside `protocol/ClaudeEvent.kt`) → + `ClaudeSession.onEvent`. +- **Web app**: `ui/jcef/JcefHost.kt` serves `resources/jcef/shell.html` and injects the CSP; the page boots + `app-core.js` and registers `window.cc.*`. +- **Test stand-in for the binary**: `bin/fake-claude` (Python) with fixtures in `src/test/resources/fixtures/`. + +## Commands + +| What | Command | Verified | +|---|---|---| +| Build the plugin zip | `JAVA_HOME=~/.jdks/jbr-21.0.11 ./gradlew buildPlugin` → `build/distributions/` | 2026-08-11 | +| Full JVM gate | `./gradlew clean test koverVerify detekt spotlessCheck verifyPlugin buildPlugin --rerun-tasks` | 2026-08-11 | +| Fix formatting | `./gradlew spotlessApply` | 2026-08-11 | +| Frontend tests | `npm test` (vitest + jsdom) | 2026-08-11 | +| Frontend lint/format | `npm run lint` · `npm run format:check` · `npm run format` | 2026-08-11 | +| Production dependency audit | `npm audit --omit=dev` | 2026-08-11 | +| Protocol drift | `./gradlew checkDrift -PclaudeBinary=/usr/bin/claude` | 2026-08-11 | +| Verify against local IDEs | `./gradlew verifyPlugin -PlocalIdePath=[,…]` | unverified here | +| Run a sandbox IDE | `./gradlew runIde` | unverified here | + +On this machine only: `node` needs `OPENSSL_CONF=/dev/null`, and `claude` is a system install at +`/usr/bin/claude` (the drift task defaults to `~/.local/bin/claude`). + +## Conventions and invariants + +- **Where new behaviour goes**: a `ClaudeSession` collaborator, a JS module or a JSON builder — never back + into `ClaudeSession` or `JcefChatPanel`, which are an orchestrator and an assembler. +- Threading: I/O and parsing off-EDT; every UI mutation on the EDT via the session's `edt {}` dispatcher. +- `protocol/` stays free of IDE classes so it unit-tests on a plain JVM. Same for `ui/jcef/JcefBridge.kt`. +- Frontend: no bundler, no CDN; a new CSS class used from JS needs a real rule or `css-contract.test.js` + fails. +- Tests live under the mirrored package path; headless and integration tests must run inside the `test` + task (the platform runtime is only wired there). + +## Minefields + +- `session/ClaudeSession.kt` (~2.8k lines, most-churned source file) — property/`init` **declaration order + matters**: `InitOrderContractTest` scans the sources because the compiler only catches the direct case. +- `permission/SensitiveGuard.kt` — walks every string leaf of a tool input as a path candidate. Two live + false positives came from that (`// comment` read as UNC; `$` in a shell value read as a regex + replacement). Change `pathCandidates`/`foreignHome` with tests first. +- `ui/jcef/JcefHost.kt` — the CSP is hash-pinned over the exact bytes of each inline script. Editing + `shell.html`'s inline blocks changes the hash; anything that would need `unsafe-inline` is a no. +- `.github/workflows/release.yml` — merging to `main` publishes. The `guard` job refuses to re-release a + version whose tag already exists; the tagging step is deliberately idempotent. +- `session/SessionTranscriptReader.kt` — restoring a transcript from the binary's JSONL. Synthetic `user` + lines (``, caveats) are not the user speaking; `isMeta`/`isSidechain` are on the wire. +- `bin/fake-claude` + `src/test/resources/fixtures/` — the integration tests' contract. A fixture edited + without its test is a green suite that proves nothing. + +## Out of the map + +`node_modules/` (the SDK there is **protocol reference only**, never shipped), `build/`, `.gradle/`, +`.idea/`, `build/distributions/*.zip`. From f116f8b85024f765597928e25c057677410f109e Mon Sep 17 00:00:00 2001 From: Lain Date: Tue, 11 Aug 2026 01:00:53 +0200 Subject: [PATCH 003/141] feat(agents): model the binary's subagent tree and who owns it Backend for the per-agent tabs. The binary already writes everything, one pair of files per subagent under /subagents/: agent-.jsonl plus agent-.meta.json carrying agentType, description, toolUseId, parentAgentId and spawnDepth. So the parent chain, the depth to indent at, the tab title and the link back to the spawning card are DATA, not inference -- system/task_started carries no parent at all, and the alternative was reconstructing the tree by joining events. The agent transcript is parsed by SessionTranscriptReader.parseEntries, the reader the session restore already uses, because that file is in the same format. One code path for live and restored: two paths for the same thing is what produced the duplicated thought-process bug in 4.0.4. Admission is the load-bearing rule. A session id can be resumed from the terminal, so that directory mixes agents this plugin spawned with agents it never saw -- 84 in one real session. An agent is ours if we observed its Task call, if a previous plugin run recorded it, or if its parent is ours. The last rule is not a convenience: a nested agent is spawned inside another agent's turn, so its task_started never reaches the main stream, and without inheritance everything below depth 1 is invisible. It is applied as a fixpoint because depth is not bounded. PluginAgentIndex persists admissions and tab state in workspace.xml, so a tab the user closed stays closed and nothing is lost by closing it -- the transcript is the binary's file and the card reopens the tab. It stores IDS AND TWO BOOLEANS only: no prompt, description or transcript goes into .idea, which is shared and effectively published. AgentIndexPrivacyTest pins that. --- .../dev/lain/claudejb/session/AgentMeta.kt | 77 +++++++++ .../lain/claudejb/session/AgentRegistry.kt | 153 ++++++++++++++++++ .../lain/claudejb/session/PluginAgentIndex.kt | 144 +++++++++++++++++ .../dev/lain/claudejb/session/SessionStore.kt | 27 ++++ .../claudejb/session/AgentIndexPrivacyTest.kt | 50 ++++++ .../lain/claudejb/session/AgentMetaTest.kt | 65 ++++++++ .../claudejb/session/AgentRegistryTest.kt | 135 ++++++++++++++++ 7 files changed, 651 insertions(+) create mode 100644 src/main/kotlin/dev/lain/claudejb/session/AgentMeta.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/session/AgentRegistry.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/session/PluginAgentIndex.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/session/AgentIndexPrivacyTest.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/session/AgentMetaTest.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/session/AgentRegistryTest.kt diff --git a/src/main/kotlin/dev/lain/claudejb/session/AgentMeta.kt b/src/main/kotlin/dev/lain/claudejb/session/AgentMeta.kt new file mode 100644 index 00000000..79ba9dd3 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/session/AgentMeta.kt @@ -0,0 +1,77 @@ +package dev.lain.claudejb.session + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * What the binary itself records about one subagent, read from `subagents/agent-.meta.json`. + * + * **This is the reason the agent tree is data rather than inference.** Verified against `claude` 2.1.226: + * next to every `agent-.jsonl` the binary writes a sidecar carrying + * `{agentType, description, toolUseId, parentAgentId, spawnDepth}`. So the parent chain + * ([parentAgentId]), the depth to indent at ([spawnDepth]), the tab's title ([description], the model's own + * summary of the task) and the link back to the card that spawned it ([toolUseId]) all come from the binary. + * Nothing here is reconstructed by joining events, which is what an earlier design would have had to do — + * `system/task_started` carries no parent at all. + * + * Every field is optional on purpose: a sidecar from a newer binary must never fail to parse, and a missing + * `description` costs a generic tab label, not a dropped agent. + */ +data class AgentMeta( + /** `agent-` — the id in the file name, and the identity used everywhere else. */ + val agentId: String, + /** The registered agent type (`general-purpose`, a custom agent…), shown as the tab's tooltip. */ + val agentType: String? = null, + /** The model-generated task summary; the tab's title. */ + val description: String? = null, + /** The `tool_use_id` of the Task call that spawned it — the anchor of the transcript card. */ + val toolUseId: String? = null, + /** The parent agent's id, or null for an agent spawned directly by the main turn. */ + val parentAgentId: String? = null, + /** 1 for an agent of the main turn, 2 for an agent of that agent, and so on. */ + val spawnDepth: Int = 1, +) { + /** What the tab shows: the model's own description, else the type, else the raw id. */ + fun label(): String = + description?.takeIf { it.isNotBlank() } + ?: agentType?.takeIf { it.isNotBlank() } + ?: agentId + + companion object { + private val JSON = Json { ignoreUnknownKeys = true; isLenient = true } + + /** File-name prefix and `.jsonl`/`.meta.json` suffixes the binary uses inside `subagents/`. */ + const val FILE_PREFIX = "agent-" + const val META_SUFFIX = ".meta.json" + const val TRANSCRIPT_SUFFIX = ".jsonl" + + /** + * Parses a `meta.json` body for [agentId]. Pure and tolerant — a corrupt or partial sidecar yields + * null rather than throwing, and the caller simply does not admit that agent. + */ + fun parse(agentId: String, body: String): AgentMeta? { + val obj = runCatching { JSON.parseToJsonElement(body).jsonObject }.getOrNull() ?: return null + return AgentMeta( + agentId = agentId, + agentType = obj.str("agentType"), + description = obj.str("description"), + toolUseId = obj.str("toolUseId"), + parentAgentId = obj.str("parentAgentId"), + // Absent depth is treated as top level: better a flat row than a wrong indent. + spawnDepth = (obj["spawnDepth"]?.jsonPrimitive?.intOrNull ?: 1).coerceAtLeast(1), + ) + } + + /** `agent-abc.meta.json` → `agent-abc`; null for anything that is not one of the binary's sidecars. */ + fun agentIdOfMetaFile(fileName: String): String? = + fileName.takeIf { it.startsWith(FILE_PREFIX) && it.endsWith(META_SUFFIX) } + ?.removeSuffix(META_SUFFIX) + + private fun JsonObject.str(key: String): String? = + this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/session/AgentRegistry.kt b/src/main/kotlin/dev/lain/claudejb/session/AgentRegistry.kt new file mode 100644 index 00000000..2afbbf8f --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/session/AgentRegistry.kt @@ -0,0 +1,153 @@ +package dev.lain.claudejb.session + +import java.nio.file.Files +import java.nio.file.Path + +/** Lifecycle of one agent, as far as the plugin can honestly tell. */ +enum class AgentStatus { RUNNING, COMPLETED, FAILED, STOPPED } + +/** + * One agent as the UI needs it: what the binary says about it ([meta]), how it ended ([status]) and its + * reconstructed transcript ([entries]). + */ +data class AgentNode( + val meta: AgentMeta, + val status: AgentStatus = AgentStatus.RUNNING, + val entries: List = emptyList(), +) { + val agentId: String get() = meta.agentId + val parentAgentId: String? get() = meta.parentAgentId + val depth: Int get() = meta.spawnDepth +} + +/** + * The agents of one chat: which ones may be shown, their tree, their status and their transcripts. + * + * **Where the data comes from.** The binary already writes everything, one pair of files per subagent under + * `/subagents/`: `agent-.meta.json` (see [AgentMeta]) and `agent-.jsonl`, the latter in the + * very same format as a session transcript — so it is read with [SessionTranscriptReader.parseEntries], the + * parser that already exists, and a restored agent and a live one go through exactly one code path. That is + * deliberate: the duplicated-thinking bug of 4.0.4 came from having two. + * + * **What may be shown, and why it is not "whatever is in the directory".** A session id can be resumed from + * the terminal, so that directory mixes agents this plugin spawned with agents it never saw — in one real + * session, 84 of them. An agent is admitted only if either: + * - its `toolUseId` matches a Task call this plugin **observed** ([observeSpawn]), or + * - its `parentAgentId` is an already-admitted agent. + * + * The second rule is not a convenience: a nested agent is spawned *inside* another agent's turn, so its + * `task_started` never reaches the main stream. Without inheriting admission, everything below depth 1 would + * be invisible. Admissions are remembered by [PluginAgentIndex], which is what lets a past plugin session's + * agents come back after a restart while a terminal-spawned one never appears. + * + * Threading: [scan] does blocking IO and must run off the EDT; [nodes] is a snapshot, safe to read anywhere. + */ +class AgentRegistry( + private val subagentsDir: () -> Path?, + private val onAdmitted: (agentId: String) -> Unit = {}, +) { + /** `tool_use_id`s of Task calls seen in this session — the seed of the admission rule. */ + private val observedToolUse = LinkedHashSet() + + /** Terminal status per `tool_use_id`, from `task_notification`. Absent means still running. */ + private val statusByToolUse = HashMap() + + /** Agent ids admitted by an outside authority (the persisted index), so a restart keeps them. */ + private val preAdmitted = LinkedHashSet() + + @Volatile + private var snapshot: Map = emptyMap() + + /** Current agents, keyed by agent id. Ordered by depth then discovery, so parents precede children. */ + val nodes: Map get() = snapshot + + /** Children of [parentId] (null = the agents of the main turn), in stable order. */ + fun children(parentId: String?): List = + snapshot.values.filter { it.parentAgentId == parentId } + + /** A Task call was seen in this session: any agent whose sidecar names it becomes ours. */ + fun observeSpawn(toolUseId: String?) { + if (!toolUseId.isNullOrBlank()) observedToolUse += toolUseId + } + + /** A `task_notification` settled a Task call — the agent's tab keeps its transcript, marked with this. */ + fun observeSettled(toolUseId: String?, status: AgentStatus) { + if (!toolUseId.isNullOrBlank()) statusByToolUse[toolUseId] = status + } + + /** Re-admits agents recorded by a previous plugin run (see [PluginAgentIndex]). */ + fun preAdmit(agentIds: Collection) { + preAdmitted += agentIds + } + + /** + * Re-reads the subagents directory and rebuilds the snapshot. Blocking IO — call off the EDT. + * + * Returns the agent ids admitted for the FIRST time in this call, so the caller can raise a tab, blink it + * and notify without having to diff the snapshot itself. + */ + fun scan(): List { + val dir = subagentsDir() ?: return emptyList() + val metas = readMetas(dir) + val admitted = admissibleIds(metas) + val previous = snapshot + val next = LinkedHashMap() + for (id in admitted.sortedWith(compareBy({ metas[it]?.spawnDepth ?: 1 }, { it }))) { + val meta = metas[id] ?: continue + next[id] = AgentNode( + meta = meta, + status = statusByToolUse[meta.toolUseId] ?: AgentStatus.RUNNING, + entries = readTranscript(dir, id), + ) + } + snapshot = next + val fresh = next.keys - previous.keys + fresh.forEach(onAdmitted) + return fresh.toList() + } + + /** + * Admission, applied until it stops growing: an agent is ours if the plugin saw its Task call, if a + * previous plugin run recorded it, or if its parent is already ours. The fixpoint loop is what carries + * admission down an arbitrarily deep chain in one pass — depth is not bounded by the protocol, and a + * single pass would only ever admit one level below what it started with. + */ + private fun admissibleIds(metas: Map): Set { + val admitted = metas.values + .filter { it.agentId in preAdmitted || (it.toolUseId != null && it.toolUseId in observedToolUse) } + .mapTo(HashSet()) { it.agentId } + var grew = true + while (grew) { + grew = false + for (meta in metas.values) { + val parent = meta.parentAgentId ?: continue + if (meta.agentId !in admitted && parent in admitted) { + admitted += meta.agentId + grew = true + } + } + } + return admitted + } + + private fun readMetas(dir: Path): Map = runCatching { + Files.newDirectoryStream(dir, "*${AgentMeta.META_SUFFIX}").use { stream -> + stream.mapNotNull { path -> + val id = AgentMeta.agentIdOfMetaFile(path.fileName.toString()) ?: return@mapNotNull null + val body = runCatching { Files.readString(path) }.getOrNull() ?: return@mapNotNull null + AgentMeta.parse(id, body)?.let { id to it } + }.toMap() + } + }.getOrDefault(emptyMap()) + + /** + * The agent's own transcript, parsed by the SAME reader the session restore uses. A sidecar that is not + * there yet (the binary writes the meta first) simply yields an empty transcript, and the next scan + * fills it — no error, no placeholder row. + */ + private fun readTranscript(dir: Path, agentId: String): List { + val file = dir.resolve("$agentId${AgentMeta.TRANSCRIPT_SUFFIX}") + val lines = runCatching { Files.readAllLines(file) }.getOrNull() ?: return emptyList() + return SessionTranscriptReader.parseEntries(lines) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/session/PluginAgentIndex.kt b/src/main/kotlin/dev/lain/claudejb/session/PluginAgentIndex.kt new file mode 100644 index 00000000..7fb8ee7c --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/session/PluginAgentIndex.kt @@ -0,0 +1,144 @@ +package dev.lain.claudejb.session + +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.StoragePathMacros +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.util.xmlb.XmlSerializerUtil +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * Which subagents belong to a **plugin** session, and what the user did with their tabs. + * + * **Why this exists at all.** The binary keeps every subagent of a session in one directory + * (`/subagents/`), and the same session id can be resumed from the terminal — so the directory + * mixes agents this plugin spawned with agents it never saw. The filesystem cannot tell them apart, and + * showing all of them would mean reopening a heavy session and getting dozens of tabs for work the plugin + * never ran. So the plugin writes down what it witnessed: an agent is admitted only if its spawn was seen + * here, and that record is what survives a restart. + * + * It also carries the tab state, which is the other half of the contract: a tab the user **closed stays + * closed** across restarts. Nothing is destroyed by closing it — the agent's transcript is the binary's file + * on disk, and the card that spawned it is still in the main transcript, so clicking that card reopens the + * tab. Closing is a view decision, not a delete. + * + * Stored in `workspace.xml` next to [SessionHistory] — same reasoning: this is per-user UI state, not + * something to commit. + * + * **Ids and two booleans, nothing else, and that is a hard invariant** (`AgentIndexPrivacyTest`). No prompt, + * no description, no transcript and no tool output ever goes into `.idea/`: those live in the binary's own + * files under `~/.claude`, which is the single source of truth the whole plugin already relies on. The + * project directory is shared, sometimes committed by accident and routinely synced, so anything written + * there is effectively published — an agent's description alone can leak what the user is working on. + */ +@Service(Service.Level.PROJECT) +@State(name = "ClaudeCodeAgentIndex", storages = [Storage(StoragePathMacros.WORKSPACE_FILE)]) +class PluginAgentIndex : PersistentStateComponent { + + class State { + /** `sessionId -> [AgentRecord]`, as one JSON string (same shape trick [SessionHistory] uses). */ + @JvmField var agentsJson: String = "" + } + + /** One admitted agent. [open] is the tab state; [closedByUser] is what makes a close stick. */ + @Serializable + data class AgentRecord( + val agentId: String, + val open: Boolean = true, + val closedByUser: Boolean = false, + ) + + private var state = State() + private val cache = LinkedHashMap>() + private var loaded = false + + override fun getState(): State = state + override fun loadState(s: State) { + XmlSerializerUtil.copyBean(s, state) + loaded = false + } + + /** + * Records that this plugin saw [agentId] spawn in [sessionId]. Idempotent: re-admitting an agent the + * user had closed does NOT reopen its tab, because a re-admission is just the same agent being seen + * again, not a new intent from the user. + */ + @Synchronized + fun admit(sessionId: String, agentId: String) { + val list = records(sessionId) + if (list.none { it.agentId == agentId }) { + list += AgentRecord(agentId) + flush() + } + } + + /** Whether [agentId] was spawned under a plugin session — the admission gate for [AgentRegistry]. */ + @Synchronized + fun isAdmitted(sessionId: String, agentId: String): Boolean = + records(sessionId).any { it.agentId == agentId } + + /** Admitted agents of [sessionId] whose tab should be reopened on restore, in admission order. */ + @Synchronized + fun openAgents(sessionId: String): List = + records(sessionId).filter { it.open && !it.closedByUser }.map { it.agentId } + + /** + * The user closed (or reopened) an agent's tab. A close is remembered as **theirs**, so restore leaves + * it closed; reopening from the transcript card clears that, which is the documented way back. + */ + @Synchronized + fun setTabOpen(sessionId: String, agentId: String, open: Boolean) { + val list = records(sessionId) + val i = list.indexOfFirst { it.agentId == agentId } + if (i < 0) { + list += AgentRecord(agentId, open = open, closedByUser = !open) + } else { + list[i] = list[i].copy(open = open, closedByUser = !open) + } + flush() + } + + /** Drops everything known about [sessionId] — used when its chat is closed for good. */ + @Synchronized + fun forget(sessionId: String) { + if (load().remove(sessionId) != null) flush() + } + + private fun records(sessionId: String): MutableList = + load().getOrPut(sessionId) { mutableListOf() } + + private fun load(): LinkedHashMap> { + if (!loaded) { + cache.clear() + cache.putAll(decode(state.agentsJson).mapValues { it.value.toMutableList() }) + loaded = true + } + return cache + } + + private fun flush() { + state.agentsJson = encode(cache) + } + + companion object { + private val JSON = Json { ignoreUnknownKeys = true } + + fun getInstance(project: Project): PluginAgentIndex = project.service() + + /** Serializes the whole index. Pure — unit-testable without a project. */ + fun encode(map: Map>): String = + runCatching { JSON.encodeToString(map) }.getOrDefault("") + + /** Parses the index back; blank or corrupt input yields an empty map rather than throwing. */ + fun decode(text: String): Map> { + if (text.isBlank()) return emptyMap() + return runCatching { JSON.decodeFromString>>(text) } + .getOrDefault(emptyMap()) + } + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/session/SessionStore.kt b/src/main/kotlin/dev/lain/claudejb/session/SessionStore.kt index 60e20a9a..f3703064 100644 --- a/src/main/kotlin/dev/lain/claudejb/session/SessionStore.kt +++ b/src/main/kotlin/dev/lain/claudejb/session/SessionStore.kt @@ -63,6 +63,33 @@ internal object SessionStore { fun readLines(sessionId: String): List? = locate(sessionId)?.let { runCatching { Files.readAllLines(it) }.getOrNull() } + /** + * The binary's per-session sidecar directory, `/` next to `.jsonl`. + * + * Verified against `claude` 2.1.226: alongside the session transcript the binary keeps a directory of the + * same name holding `subagents/` (one `agent-.jsonl` plus an `agent-.meta.json` per subagent) and + * `tool-results/`. Returns null when the directory does not exist — a session that never spawned an agent + * simply has none. + */ + fun sessionDir(sessionId: String): Path? { + val transcript = locate(sessionId) ?: return null + val dir = transcript.resolveSibling(sessionId) + return if (Files.isDirectory(dir)) dir else null + } + + /** + * `/subagents/`, the directory holding one transcript + metadata pair per subagent. + * + * Note this returns the DIRECTORY, not its contents: which of those agents may be shown is not a + * filesystem question. The same session id can be resumed from the terminal, so the directory mixes + * agents this plugin spawned with agents it never saw — [PluginAgentIndex] is what tells them apart. + */ + fun subagentsDir(sessionId: String): Path? = + sessionDir(sessionId)?.resolve(SUBAGENTS)?.takeIf { Files.isDirectory(it) } + + /** Directory name the binary uses for per-subagent transcripts inside the session's sidecar dir. */ + private const val SUBAGENTS = "subagents" + /** Session transcript files for the project at [basePath], newest-first. Empty if the dir is absent. */ fun listFiles(basePath: String): List { val dir = projectDir(basePath) ?: return emptyList() diff --git a/src/test/kotlin/dev/lain/claudejb/session/AgentIndexPrivacyTest.kt b/src/test/kotlin/dev/lain/claudejb/session/AgentIndexPrivacyTest.kt new file mode 100644 index 00000000..0a024ba8 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/session/AgentIndexPrivacyTest.kt @@ -0,0 +1,50 @@ +package dev.lain.claudejb.session + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * What [PluginAgentIndex] is allowed to write into `.idea/workspace.xml`, pinned as a contract. + * + * The project directory is shared, gets committed by accident and is routinely synced, so anything written + * there is effectively published. An agent's description alone ("Translate erp-sap-standards") says what the + * user is working on; a prompt or a transcript says far more. All of that already lives in the binary's files + * under `~/.claude`, which is the source of truth the plugin reads anyway — so the index carries **ids and + * two booleans**, and this test exists to keep a future "just add the title so the tab restores faster" from + * quietly turning workspace state into a data leak. + */ +class AgentIndexPrivacyTest { + + @Test + fun `the persisted form carries ids and flags only`() { + val encoded = PluginAgentIndex.encode( + mapOf( + "5f2b-session" to listOf( + PluginAgentIndex.AgentRecord("agent-a1", open = true), + PluginAgentIndex.AgentRecord("agent-b2", open = false, closedByUser = true), + ), + ), + ) + // Exactly the three fields of AgentRecord, and no room for a fourth to sneak in unnoticed. + assertTrue(encoded.contains("agent-a1")) + assertTrue(encoded.contains("closedByUser")) + setOf("description", "prompt", "text", "transcript", "title", "summary", "content") + .forEach { assertFalse(encoded.contains(it), "persisted index must not carry '$it'") } + } + + @Test + fun `a round trip preserves the tab state and nothing more`() { + val original = mapOf( + "s1" to listOf(PluginAgentIndex.AgentRecord("agent-a", open = false, closedByUser = true)), + ) + assertEquals(original, PluginAgentIndex.decode(PluginAgentIndex.encode(original))) + } + + @Test + fun `corrupt or blank state never throws`() { + assertTrue(PluginAgentIndex.decode("").isEmpty()) + assertTrue(PluginAgentIndex.decode("{not json").isEmpty()) + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/session/AgentMetaTest.kt b/src/test/kotlin/dev/lain/claudejb/session/AgentMetaTest.kt new file mode 100644 index 00000000..9da4c235 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/session/AgentMetaTest.kt @@ -0,0 +1,65 @@ +package dev.lain.claudejb.session + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +/** + * [AgentMeta] against the shape the binary really writes. + * + * The fixture below is a verbatim `subagents/agent-*.meta.json` from `claude` 2.1.226 — the sidecar that + * makes the agent tree data instead of inference. + */ +class AgentMetaTest { + + private val real = """ + { + "agentType": "general-purpose", + "description": "Translate erp-sap-standards", + "toolUseId": "toolu_01SDykjceHBHhGmLKVokVziu", + "parentAgentId": "afdee29b28705b1c9", + "spawnDepth": 2 + } + """.trimIndent() + + @Test + fun `parses every field the binary writes`() { + val meta = requireNotNull(AgentMeta.parse("agent-a8bbd2f22", real)) + assertEquals("general-purpose", meta.agentType) + assertEquals("Translate erp-sap-standards", meta.description) + assertEquals("toolu_01SDykjceHBHhGmLKVokVziu", meta.toolUseId) + // The parent chain and the depth are the whole point: system/task_started carries no parent at all, + // so without these two fields the nesting would have to be reconstructed by joining events. + assertEquals("afdee29b28705b1c9", meta.parentAgentId) + assertEquals(2, meta.spawnDepth) + } + + @Test + fun `the tab label falls back rather than going blank`() { + assertEquals("Translate erp-sap-standards", AgentMeta.parse("agent-x", real)!!.label()) + assertEquals("general-purpose", AgentMeta("agent-x", agentType = "general-purpose").label()) + // Last resort is the id: an unlabelled tab is still navigable, an empty one is not. + assertEquals("agent-x", AgentMeta("agent-x").label()) + } + + @Test + fun `a partial or unknown sidecar still parses`() { + // A newer binary adding fields must never cost us the agent, and a missing depth means top level. + val meta = requireNotNull(AgentMeta.parse("agent-y", """{"description":"x","futureField":{"a":1}}""")) + assertEquals(1, meta.spawnDepth) + assertNull(meta.parentAgentId) + } + + @Test + fun `corrupt json yields null instead of throwing`() { + assertNull(AgentMeta.parse("agent-z", "{not json")) + assertNull(AgentMeta.parse("agent-z", "")) + } + + @Test + fun `only the binary's own sidecar names are recognised`() { + assertEquals("agent-abc", AgentMeta.agentIdOfMetaFile("agent-abc.meta.json")) + assertNull(AgentMeta.agentIdOfMetaFile("agent-abc.jsonl")) + assertNull(AgentMeta.agentIdOfMetaFile("notes.meta.json")) + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/session/AgentRegistryTest.kt b/src/test/kotlin/dev/lain/claudejb/session/AgentRegistryTest.kt new file mode 100644 index 00000000..ffdd3317 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/session/AgentRegistryTest.kt @@ -0,0 +1,135 @@ +package dev.lain.claudejb.session + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path + +/** + * [AgentRegistry] against a real `subagents/` directory laid out the way the binary lays it out. + * + * The rule under test is the one that matters: **which agents are ours**. The same session id can be resumed + * from the terminal, so the directory mixes agents this plugin spawned with agents it never saw — one real + * session had 84 — and getting this wrong means either dozens of phantom tabs or an invisible agent tree. + */ +class AgentRegistryTest { + + @TempDir + lateinit var dir: Path + + private fun agent(id: String, toolUseId: String? = null, parent: String? = null, depth: Int = 1, text: String? = null) { + val meta = buildString { + append("""{"agentType":"general-purpose","description":"Task $id"""") + toolUseId?.let { append(""","toolUseId":"$it"""") } + parent?.let { append(""","parentAgentId":"$it"""") } + append(""","spawnDepth":$depth}""") + } + Files.writeString(dir.resolve("$id${AgentMeta.META_SUFFIX}"), meta) + if (text != null) { + val line = """{"type":"assistant","message":{"content":[{"type":"text","text":"$text"}]}}""" + Files.writeString(dir.resolve("$id${AgentMeta.TRANSCRIPT_SUFFIX}"), line) + } + } + + private fun registry() = AgentRegistry(subagentsDir = { dir }) + + @Test + fun `an agent whose Task call we never saw is not shown`() { + // THE POINT: these files exist on disk and belong to a terminal run. Showing "whatever is in the + // directory" is what would reopen a heavy session with dozens of tabs the plugin never spawned. + agent("agent-foreign", toolUseId = "toolu_terminal") + val reg = registry() + assertTrue(reg.scan().isEmpty()) + assertTrue(reg.nodes.isEmpty()) + } + + @Test + fun `an agent whose Task call we saw is admitted, with its label and transcript`() { + agent("agent-mine", toolUseId = "toolu_ours", text = "hello from the agent") + val reg = registry() + reg.observeSpawn("toolu_ours") + assertEquals(listOf("agent-mine"), reg.scan()) + val node = reg.nodes.getValue("agent-mine") + assertEquals("Task agent-mine", node.meta.label()) + assertEquals(AgentStatus.RUNNING, node.status) + // Parsed by the same reader the session restore uses — one code path for live and restored. + assertEquals(1, node.entries.size) + assertEquals("hello from the agent", node.entries.first().text) + } + + @Test + fun `admission is inherited down the chain, however deep`() { + // A nested agent is spawned INSIDE another agent's turn, so its task_started never reaches the main + // stream: there is no tool_use_id of its own for us to have observed. Without inheritance every + // level below the first would be invisible, which is exactly the tree the user asked to see. + agent("agent-1", toolUseId = "toolu_ours", depth = 1) + agent("agent-2", parent = "agent-1", depth = 2) + agent("agent-3", parent = "agent-2", depth = 3) + agent("agent-4", parent = "agent-3", depth = 4) + val reg = registry() + reg.observeSpawn("toolu_ours") + reg.scan() + assertEquals(setOf("agent-1", "agent-2", "agent-3", "agent-4"), reg.nodes.keys) + assertEquals(listOf("agent-2"), reg.children("agent-1").map { it.agentId }) + assertEquals(listOf("agent-1"), reg.children(null).map { it.agentId }) + } + + @Test + fun `a foreign subtree stays out even when ours is present`() { + agent("agent-mine", toolUseId = "toolu_ours") + agent("agent-foreign", toolUseId = "toolu_terminal") + agent("agent-foreign-child", parent = "agent-foreign", depth = 2) + val reg = registry() + reg.observeSpawn("toolu_ours") + reg.scan() + assertEquals(setOf("agent-mine"), reg.nodes.keys) + } + + @Test + fun `agents recorded by a previous plugin run come back without a fresh Task call`() { + // This is what makes a restart show yesterday's finished agents while still excluding terminal ones. + agent("agent-old", toolUseId = "toolu_yesterday") + val reg = registry() + reg.preAdmit(listOf("agent-old")) + assertEquals(listOf("agent-old"), reg.scan()) + } + + @Test + fun `a settled agent keeps its tab and gains its status`() { + agent("agent-mine", toolUseId = "toolu_ours", text = "work") + val reg = registry() + reg.observeSpawn("toolu_ours") + reg.scan() + reg.observeSettled("toolu_ours", AgentStatus.FAILED) + reg.scan() + // It stays: reading WHY an agent failed is the case this whole feature came from. + assertEquals(AgentStatus.FAILED, reg.nodes.getValue("agent-mine").status) + } + + @Test + fun `scan reports only newly admitted agents`() { + agent("agent-1", toolUseId = "toolu_ours") + val reg = registry() + reg.observeSpawn("toolu_ours") + assertEquals(listOf("agent-1"), reg.scan()) + // Nothing new on a re-scan: the caller uses this to blink and notify exactly once per agent. + assertTrue(reg.scan().isEmpty()) + agent("agent-2", parent = "agent-1", depth = 2) + assertEquals(listOf("agent-2"), reg.scan()) + } + + @Test + fun `a missing transcript or directory is not an error`() { + agent("agent-mine", toolUseId = "toolu_ours") // meta written, jsonl not yet + val reg = registry() + reg.observeSpawn("toolu_ours") + reg.scan() + assertTrue(reg.nodes.getValue("agent-mine").entries.isEmpty()) + assertFalse(reg.nodes.isEmpty()) + // A session that never spawned an agent has no directory at all. + assertTrue(AgentRegistry(subagentsDir = { null }).scan().isEmpty()) + } +} From 9c0a236e90513e6240fc1bc7be4729d07fbc49f5 Mon Sep 17 00:00:00 2001 From: Lain Date: Tue, 11 Aug 2026 01:02:30 +0200 Subject: [PATCH 004/141] refactor(agents): keep the agent index in ~/.claude, not in .idea The index was a PersistentStateComponent in workspace.xml. The project directory is shared, gets committed by accident and is routinely synced, so anything written there is effectively published -- and which agents a session spawned already hints at what the user is working on. It now lives in ~/.claude/ide/claude-code-native/agent-index.json: private to the user, and the same place this conversation's data already is, so there is no second location to reason about. Its own namespaced directory keeps it from ever being mistaken for one of the binary's files. Contents are unchanged and stay minimal: ids and two booleans. Titles and transcripts are read from the binary's files on demand, so copying them would only create a second thing to leak or go stale. homeOverride follows the rule CredentialsVault set after a test JVM harvested and deleted real credentials: a test must be able to point this away from the developer's home. --- .../lain/claudejb/session/PluginAgentIndex.kt | 74 ++++++++++++------- .../claudejb/session/AgentIndexPrivacyTest.kt | 24 ++++-- 2 files changed, 63 insertions(+), 35 deletions(-) diff --git a/src/main/kotlin/dev/lain/claudejb/session/PluginAgentIndex.kt b/src/main/kotlin/dev/lain/claudejb/session/PluginAgentIndex.kt index 7fb8ee7c..ef219d89 100644 --- a/src/main/kotlin/dev/lain/claudejb/session/PluginAgentIndex.kt +++ b/src/main/kotlin/dev/lain/claudejb/session/PluginAgentIndex.kt @@ -1,16 +1,14 @@ package dev.lain.claudejb.session -import com.intellij.openapi.components.PersistentStateComponent import com.intellij.openapi.components.Service -import com.intellij.openapi.components.State -import com.intellij.openapi.components.Storage -import com.intellij.openapi.components.StoragePathMacros import com.intellij.openapi.components.service import com.intellij.openapi.project.Project -import com.intellij.util.xmlb.XmlSerializerUtil import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths /** * Which subagents belong to a **plugin** session, and what the user did with their tabs. @@ -27,23 +25,22 @@ import kotlinx.serialization.json.Json * on disk, and the card that spawned it is still in the main transcript, so clicking that card reopens the * tab. Closing is a view decision, not a delete. * - * Stored in `workspace.xml` next to [SessionHistory] — same reasoning: this is per-user UI state, not - * something to commit. + * **Stored under `~/.claude`, deliberately NOT in the project's `.idea/`.** The project directory is shared, + * gets committed by accident and is routinely synced, so anything written there is effectively published — + * and an agent's identity alone hints at what the user is working on. `~/.claude` is where this + * conversation's data already lives, is private to the user, and is the source of truth the plugin reads + * anyway. The file sits in its own namespaced directory so nothing of ours can ever be mistaken for one of + * the binary's own files: `~/.claude/ide/claude-code-native/agent-index.json`. * - * **Ids and two booleans, nothing else, and that is a hard invariant** (`AgentIndexPrivacyTest`). No prompt, - * no description, no transcript and no tool output ever goes into `.idea/`: those live in the binary's own - * files under `~/.claude`, which is the single source of truth the whole plugin already relies on. The - * project directory is shared, sometimes committed by accident and routinely synced, so anything written - * there is effectively published — an agent's description alone can leak what the user is working on. + * Even there it records **ids and two booleans** (`AgentIndexPrivacyTest`): titles, prompts and transcripts + * are read from the binary's files on demand, so duplicating them buys nothing and creates a second copy to + * leak or go stale. + * + * IO is best-effort and tolerant: an unreadable or corrupt file behaves as an empty index rather than + * throwing, and a failed write costs the tab layout of the next restart, nothing else. */ @Service(Service.Level.PROJECT) -@State(name = "ClaudeCodeAgentIndex", storages = [Storage(StoragePathMacros.WORKSPACE_FILE)]) -class PluginAgentIndex : PersistentStateComponent { - - class State { - /** `sessionId -> [AgentRecord]`, as one JSON string (same shape trick [SessionHistory] uses). */ - @JvmField var agentsJson: String = "" - } +class PluginAgentIndex { /** One admitted agent. [open] is the tab state; [closedByUser] is what makes a close stick. */ @Serializable @@ -53,16 +50,9 @@ class PluginAgentIndex : PersistentStateComponent { val closedByUser: Boolean = false, ) - private var state = State() private val cache = LinkedHashMap>() private var loaded = false - override fun getState(): State = state - override fun loadState(s: State) { - XmlSerializerUtil.copyBean(s, state) - loaded = false - } - /** * Records that this plugin saw [agentId] spawn in [sessionId]. Idempotent: re-admitting an agent the * user had closed does NOT reopen its tab, because a re-admission is just the same agent being seen @@ -82,6 +72,10 @@ class PluginAgentIndex : PersistentStateComponent { fun isAdmitted(sessionId: String, agentId: String): Boolean = records(sessionId).any { it.agentId == agentId } + /** Every agent this plugin has ever admitted for [sessionId], in admission order. */ + @Synchronized + fun admittedAgents(sessionId: String): List = records(sessionId).map { it.agentId } + /** Admitted agents of [sessionId] whose tab should be reopened on restore, in admission order. */ @Synchronized fun openAgents(sessionId: String): List = @@ -115,19 +109,43 @@ class PluginAgentIndex : PersistentStateComponent { private fun load(): LinkedHashMap> { if (!loaded) { cache.clear() - cache.putAll(decode(state.agentsJson).mapValues { it.value.toMutableList() }) + val body = indexFile()?.let { f -> runCatching { Files.readString(f) }.getOrNull() }.orEmpty() + cache.putAll(decode(body).mapValues { it.value.toMutableList() }) loaded = true } return cache } private fun flush() { - state.agentsJson = encode(cache) + val file = indexFile() ?: return + runCatching { + Files.createDirectories(file.parent) + Files.writeString(file, encode(cache)) + } } + /** `~/.claude/ide/claude-code-native/agent-index.json`, or null when the JVM reports no home. */ + private fun indexFile(): Path? = homeOverride?.let { Paths.get(it) } + ?.let { it.resolve(DIR_IDE).resolve(DIR_PLUGIN).resolve(FILE) } + companion object { private val JSON = Json { ignoreUnknownKeys = true } + private const val DIR_IDE = "ide" + private const val DIR_PLUGIN = "claude-code-native" + private const val FILE = "agent-index.json" + + /** + * The `~/.claude` directory to write into. Overridable **for tests only**, following the same rule + * `CredentialsVault.homeOverride` established: a test JVM must never write into the developer's real + * home, which is how an earlier test run harvested and deleted live credentials. + */ + @Volatile + var homeOverride: String? = defaultHome() + + private fun defaultHome(): String? = + System.getProperty("user.home")?.takeIf { it.isNotBlank() }?.let { "$it/.claude" } + fun getInstance(project: Project): PluginAgentIndex = project.service() /** Serializes the whole index. Pure — unit-testable without a project. */ diff --git a/src/test/kotlin/dev/lain/claudejb/session/AgentIndexPrivacyTest.kt b/src/test/kotlin/dev/lain/claudejb/session/AgentIndexPrivacyTest.kt index 0a024ba8..3eb17f57 100644 --- a/src/test/kotlin/dev/lain/claudejb/session/AgentIndexPrivacyTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/session/AgentIndexPrivacyTest.kt @@ -6,14 +6,17 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test /** - * What [PluginAgentIndex] is allowed to write into `.idea/workspace.xml`, pinned as a contract. + * What [PluginAgentIndex] is allowed to persist, pinned as a contract. * - * The project directory is shared, gets committed by accident and is routinely synced, so anything written - * there is effectively published. An agent's description alone ("Translate erp-sap-standards") says what the - * user is working on; a prompt or a transcript says far more. All of that already lives in the binary's files - * under `~/.claude`, which is the source of truth the plugin reads anyway — so the index carries **ids and - * two booleans**, and this test exists to keep a future "just add the title so the tab restores faster" from - * quietly turning workspace state into a data leak. + * Two rules, and both came from the user. **Nothing goes into the project's `.idea/`**: it is shared, gets + * committed by accident and is routinely synced, so anything there is effectively published — the index + * lives under `~/.claude`, private to the user and where this data already is. And even there it carries + * **ids and two booleans**: an agent's description ("Translate erp-sap-standards") already says what the + * user is working on, and a prompt or transcript says far more. Titles and transcripts are read from the + * binary's own files on demand, so copying them buys nothing and creates a second thing to leak or go stale. + * + * This test exists to stop a future "just cache the title so the tab restores faster" from quietly turning + * an index into a data store. */ class AgentIndexPrivacyTest { @@ -47,4 +50,11 @@ class AgentIndexPrivacyTest { assertTrue(PluginAgentIndex.decode("").isEmpty()) assertTrue(PluginAgentIndex.decode("{not json").isEmpty()) } + + @Test + fun `the index lives under the user's claude home, never in the project`() { + // The location IS the privacy decision, so it is pinned rather than left to a comment. + val home = PluginAgentIndex.homeOverride + assertTrue(home != null && home.endsWith("/.claude"), "expected ~/.claude, got $home") + } } From ee3eec968f3a3e7d8537b0f1bb43b3882b90673e Mon Sep 17 00:00:00 2001 From: Lain Date: Tue, 11 Aug 2026 01:08:48 +0200 Subject: [PATCH 005/141] feat(agents): route subagent output out of the main transcript The main transcript stops carrying other agents' work: a subagent's text, its tool calls and their results no longer land here. That interleaving is what made a session running agents under agents unreadable -- consecutive blocks from different agents with no way to follow any single one. Nothing is lost. Each agent's transcript is the binary's own per-agent file, read by AgentRegistry, and the Task card stays in the main transcript as the link to it. What still happens for a subagent's call is everything about the filesystem: the pre-write snapshot is captured and the VFS refreshed, because the binary writes files for whoever asked. Task events feed the admission rule -- task_started seeds it, task_progress too (a resumed session can reattach mid-flight and miss the start), and task_notification records how the agent ended so its tab can say so while keeping the transcript. The scan is coalesced: on a heavy session dozens of agents spawn at once, and one directory walk per event is a walk per event. Admissions are persisted after each scan rather than at task_started, because the tool_use_id to agent-id mapping only exists once the binary has written the sidecar. On init, a restored session pre-admits what a previous plugin run recorded, which is the whole of "restore the agent tabs". TranscriptReconciler.addSubagentText is deleted rather than left warm: a helper that anchors agent output under an Agent card is how the interleaving would come back. Its coverage moved to AgentRegistryTest. Named runningAgents because `agents` already means the catalog of agent types the initialize reply offers -- what you can spawn, not what is running. --- .../lain/claudejb/session/ClaudeSession.kt | 116 ++++++++++++++++-- .../lain/claudejb/session/SessionListener.kt | 10 ++ .../claudejb/session/TranscriptReconciler.kt | 11 +- .../headless/TranscriptReconcilerTest.kt | 15 +-- 4 files changed, 120 insertions(+), 32 deletions(-) diff --git a/src/main/kotlin/dev/lain/claudejb/session/ClaudeSession.kt b/src/main/kotlin/dev/lain/claudejb/session/ClaudeSession.kt index 70229c8f..57ff1a6b 100644 --- a/src/main/kotlin/dev/lain/claudejb/session/ClaudeSession.kt +++ b/src/main/kotlin/dev/lain/claudejb/session/ClaudeSession.kt @@ -206,6 +206,20 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : /** Observable map of subagent tasks keyed by task_id (task_started/progress/updated/notification). */ val subagentTasks: Map get() = taskTracker.tasks + /** + * The agents of this chat: their tree, their status and their own transcripts. + * + * Fed from the binary's own per-subagent files (see [AgentRegistry]) rather than from the event stream, + * so a live agent and a restored one are the same thing read the same way. The scan is IO and runs off + * the EDT ([scanAgents]); listeners hear about it through [SessionListener.onAgentsChanged]. + */ + // NB named `runningAgents`, not `agents`: `agents` is already the CATALOG of agent types the binary + // offers in its initialize reply. Two different things — what you can spawn, and what is running. + val runningAgents = AgentRegistry(subagentsDir = { sessionId?.let { SessionStore.subagentsDir(it) } }) + + /** Guards against piling scans on top of each other while one is already walking the directory. */ + private val agentScanInFlight = java.util.concurrent.atomic.AtomicBoolean(false) + /** * The live background-task set from `system/background_tasks_changed` — a LEVEL signal (REPLACE semantics), * deliberately independent of [subagentTasks] (the SDK forbids correlating the level with the edge stream). @@ -1954,19 +1968,21 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : } is ClaudeEvent.AssistantText -> edt { - // Subagent text arrives finalized with a parent id: anchor it under its Agent without touching - // the top-level live stream. Top-level text keeps the existing streaming reconciliation. - if (event.parentToolUseId != null) { - reconciler.addSubagentText(event.text, event.parentToolUseId) - } else { - reconciler.finalizeAssistant(event.text) - } + // A subagent's text belongs to ITS tab, not here. It used to be anchored under the Agent card + // in this transcript, which is exactly what made a session running agents under agents + // unreadable: consecutive blocks from different agents, interleaved, with no way to follow + // any single one. The agent's own transcript is read from the binary's file by AgentRegistry, + // so nothing is lost by dropping it — and the Agent card links to that tab. + if (event.parentToolUseId == null) reconciler.finalizeAssistant(event.text) } } } private fun onInit(event: ClaudeEvent.Init) { sessionId = event.info.sessionId + // The id is what locates this session's agent directory, so this is the earliest point a restored + // session can bring its previously-admitted agents back. Off-EDT, and a no-op when there are none. + restoreAdmittedAgents() if (model == null && event.info.model.isNotBlank()) model = event.info.model if (event.info.outputStyle.isNotBlank()) outputStyle = event.info.outputStyle // The plugin is the source of truth for permissionMode. system/init re-arrives every turn and @@ -1986,11 +2002,17 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : } private fun onToolUse(event: ClaudeEvent.ToolUse) = edt { - // Only break the top-level live stream for top-level tool calls; a subagent's tool call must not - // cut a top-level paragraph that may continue after the Agent finishes. - if (event.parentToolUseId == null) { - reconciler.onMessageBoundary() + // A subagent's tool call belongs to its own tab, read from the binary's per-agent transcript. Keeping + // it here is what buried the main conversation under other agents' work. The snapshot capture below + // still has to happen for it, though: the binary writes the file whoever asked for it, so the diff + // must be captured for a subagent's Edit exactly as for a top-level one. + if (event.parentToolUseId != null) { + if (event.name in DiffPresenter.REVIEWABLE_TOOLS) { + diffs.captureForReview(event.name, event.input, event.id) + } + return@edt } + reconciler.onMessageBoundary() transcript.add( Speaker.TOOL, formatToolUse(event.name, event.input, workingDir), @@ -2044,6 +2066,10 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : } else { null } + // A subagent's result goes to its own tab, like its call. Everything above still ran for it — the + // VFS refresh and the snapshot bookkeeping are about files on disk, not about which transcript + // shows the row. + if (event.parentToolUseId != null) return@edt if (diff != null) { transcript.addToolOutput(event.toolUseId, diff, parentToolUseId = event.parentToolUseId, meta = "diff") } else { @@ -2130,9 +2156,18 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : // progress wins); here we only keep the state and fire so the UI refreshes. --- private fun onTask(event: ClaudeEvent.Task) { when (event) { - is ClaudeEvent.TaskStarted -> edt { if (taskTracker.onStarted(event.info)) fireState() } + is ClaudeEvent.TaskStarted -> edt { + // The admission seed: this Task call is ours, so the agent whose sidecar names it — and + // everything it spawns below — may be shown. See AgentRegistry's admission rule. + runningAgents.observeSpawn(event.info.toolUseId) + scanAgents() + if (taskTracker.onStarted(event.info)) fireState() + } is ClaudeEvent.TaskProgress -> edt { + // Also an admission seed, deliberately: a task_started can be missed (a resumed session + // reattaches mid-flight), and progress carries the same tool_use_id. + runningAgents.observeSpawn(event.info.toolUseId) taskTracker.onProgress(event.info) fireState() } @@ -2143,7 +2178,10 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : } is ClaudeEvent.TaskNotification -> edt { - // Settled: drop from the live map (TaskTracker); surface a discreet notice unless asked to skip it. + // Settled: the tab KEEPS its transcript and gains a status — reading why an agent failed is + // the case this feature came from. Only the live task map drops it. + runningAgents.observeSettled(event.info.toolUseId, agentStatusOf(event.info.status)) + scanAgents() if (taskTracker.onNotification(event.info)) { val label = event.info.summary.ifBlank { "Subagent ${event.info.status}" } systemNotice("Subagent ${event.info.status}: $label") @@ -2483,6 +2521,58 @@ class ClaudeSession(private val project: Project, @Volatile var title: String) : private fun systemNotice(message: String) = edt { transcript.add(Speaker.SYSTEM, message) } + /** + * Re-reads the agent directory off the EDT and tells the UI, if anything came of it. + * + * Coalesced rather than queued: while a scan is walking the directory, further requests are dropped — + * a burst of task events on a heavy session (dozens of agents spawning at once, which is the case this + * feature exists for) would otherwise queue one directory walk per event. + */ + fun scanAgents() { + if (!agentScanInFlight.compareAndSet(false, true)) return + ApplicationManager.getApplication().executeOnPooledThread { + val fresh = runCatching { runningAgents.scan() }.getOrDefault(emptyList()) + // Persist what was admitted, so a later run of the plugin still counts these as ours while a + // terminal-spawned agent in the same directory never does. Done here rather than at task_started + // because the tool_use_id → agent id mapping only exists once the binary has written the sidecar. + sessionId?.let { id -> + runCatching { + val index = PluginAgentIndex.getInstance(project) + runningAgents.nodes.keys.forEach { index.admit(id, it) } + } + } + agentScanInFlight.set(false) + edt { fireAgents(fresh) } + } + } + + /** + * Brings back the agents a previous run of the plugin admitted for this session id, then scans. + * + * This is the whole of "restore the agent tabs": the transcripts are the binary's files, still on disk, + * and the index says which of the agents in that directory were ever ours. An agent spawned from the + * terminal is in the same directory and is never in the index, so it stays invisible. + */ + private fun restoreAdmittedAgents() { + val id = sessionId ?: return + ApplicationManager.getApplication().executeOnPooledThread { + runCatching { PluginAgentIndex.getInstance(project).admittedAgents(id) } + .getOrDefault(emptyList()) + .takeIf { it.isNotEmpty() } + ?.let { runningAgents.preAdmit(it) } + scanAgents() + } + } + + /** `task_notification`'s status string → the agent lifecycle the tab shows. */ + private fun agentStatusOf(status: String): AgentStatus = when (status.lowercase()) { + "completed" -> AgentStatus.COMPLETED + "failed" -> AgentStatus.FAILED + else -> AgentStatus.STOPPED + } + + private fun fireAgents(fresh: List) = listeners.forEach { it.onAgentsChanged(fresh) } + private fun fireState() = listeners.forEach { it.onStateChanged() } private fun fireMetadata() = listeners.forEach { it.onMetadataChanged() } private fun firePermissions() = listeners.forEach { it.onPermissionsChanged() } diff --git a/src/main/kotlin/dev/lain/claudejb/session/SessionListener.kt b/src/main/kotlin/dev/lain/claudejb/session/SessionListener.kt index 5158a679..a3269fd6 100644 --- a/src/main/kotlin/dev/lain/claudejb/session/SessionListener.kt +++ b/src/main/kotlin/dev/lain/claudejb/session/SessionListener.kt @@ -19,4 +19,14 @@ interface SessionListener { /** The session title changed (the binary generated/renamed it); the tab should relabel. Fired on the EDT. */ fun onTitleChanged() {} + + /** + * The agent tree changed: a scan of the binary's per-subagent files finished. + * + * [freshlyAdmitted] are the agents seen for the FIRST time in this scan, which is what the UI uses to + * open a tab, blink it and notify **once** — deriving that by diffing snapshots in the panel would put + * the same bookkeeping in every listener. An empty list still means "re-read the tree": an existing + * agent's transcript or status may have moved. Fired on the EDT. + */ + fun onAgentsChanged(freshlyAdmitted: List) {} } diff --git a/src/main/kotlin/dev/lain/claudejb/session/TranscriptReconciler.kt b/src/main/kotlin/dev/lain/claudejb/session/TranscriptReconciler.kt index ad8da197..dd841d2c 100644 --- a/src/main/kotlin/dev/lain/claudejb/session/TranscriptReconciler.kt +++ b/src/main/kotlin/dev/lain/claudejb/session/TranscriptReconciler.kt @@ -96,11 +96,8 @@ class TranscriptReconciler(private val transcript: TranscriptModel) { settledThinking = null } - /** - * Anchors a subagent's finalized assistant text under its Agent's tool_use id without touching the top-level - * live stream. Subagent text arrives finalized with a parent id; top-level text keeps the streaming path. - */ - fun addSubagentText(text: String, parentToolUseId: String) { - transcript.add(Speaker.ASSISTANT, text, parentToolUseId = parentToolUseId) - } + // NB `addSubagentText` lived here until 5.5.0. A subagent's text no longer belongs in this transcript at + // all: it goes to that agent's own tab, read from the binary's per-agent file by AgentRegistry. Deleted + // rather than left warm — a helper that still anchors agent output under an Agent card is exactly how + // the interleaving this release removes would come back. } diff --git a/src/test/kotlin/dev/lain/claudejb/headless/TranscriptReconcilerTest.kt b/src/test/kotlin/dev/lain/claudejb/headless/TranscriptReconcilerTest.kt index e9dcd9dd..89a8a3a4 100644 --- a/src/test/kotlin/dev/lain/claudejb/headless/TranscriptReconcilerTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/headless/TranscriptReconcilerTest.kt @@ -117,16 +117,7 @@ class TranscriptReconcilerTest : BasePlatformTestCase() { assertEquals("two", entries()[1].text) } - fun `test addSubagentText anchors under parent without breaking live stream`() { - reconciler.appendAssistant("top-level streaming ") - reconciler.addSubagentText("subagent reply", parentToolUseId = "agent-1") - // The live top-level entry must still be growable after the subagent insert. - reconciler.appendAssistant("continues") - - val top = entries().first { it.parentToolUseId == null && it.speaker == Speaker.ASSISTANT } - val sub = entries().first { it.parentToolUseId == "agent-1" } - assertEquals("top-level streaming continues", top.text) - assertEquals(Speaker.ASSISTANT, sub.speaker) - assertEquals("subagent reply", sub.text) - } + // NB the `addSubagentText` test lived here until 5.5.0, when subagent output stopped going into this + // transcript at all — it belongs to that agent's own tab now, and the coverage moved to + // AgentRegistryTest, which reads the binary's per-agent file the way the UI does. } From 00bef7aa83dcb5215031bdae588133ef877871c7 Mon Sep 17 00:00:00 2001 From: Lain Date: Tue, 11 Aug 2026 01:12:11 +0200 Subject: [PATCH 006/141] feat(agents): add the Agents and Subagents tab strips Two rows under the chat tabs, following one rule: each row shows the children of the row above's selection. Agents shows the selected chat's agents, so switching chat swaps the row; Subagents shows what the selected agent spawned. Both stay visible when empty, so the transcript below does not jump as agents come and go -- on a session that spawns them constantly a row that appears and disappears is worse than an empty one. The strips are header-only: their tabs own no content, because the transcript is painted by the chat's single JCEF browser, which switches which transcript it shows. That is what keeps eighty agents affordable -- one Chromium per chat, not one per agent. Labels carry the tree the user asked for: "|_ description", one connector per level within the strip, capped so a deep chain stops indenting rather than losing its label. The full description, the agentType and how the agent ended go in the tooltip -- a kept tab has to say whether its agent failed, since reading why is the point of keeping it. That logic is a pure object (AgentTabLabels) so it is testable without Swing, and it is tested. A newly-spawned agent's tab pulses orange twice and returns to normal: peripheral vision without noise, since a permanent colour on a session spawning agents constantly is just noise. A rebuild mutes the selection listener and restores the selection: a scan can add, remove and re-parent agents at once, and without that every scan would look like the user had clicked a tab and would repaint underneath. Closing a tab reports it so the caller can remember it; the agent is not deleted -- its transcript is the binary's file and its card is the way back. --- .../dev/lain/claudejb/ui/AgentStripPanel.kt | 186 ++++++++++++++++++ .../dev/lain/claudejb/ui/AgentTabLabels.kt | 61 ++++++ .../dev/lain/claudejb/ui/AgentTabsPanel.kt | 111 +++++++++++ .../lain/claudejb/ui/AgentTabLabelsTest.kt | 57 ++++++ 4 files changed, 415 insertions(+) create mode 100644 src/main/kotlin/dev/lain/claudejb/ui/AgentStripPanel.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/ui/AgentTabLabels.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/ui/AgentTabsPanel.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/ui/AgentTabLabelsTest.kt diff --git a/src/main/kotlin/dev/lain/claudejb/ui/AgentStripPanel.kt b/src/main/kotlin/dev/lain/claudejb/ui/AgentStripPanel.kt new file mode 100644 index 00000000..38e9ba37 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/ui/AgentStripPanel.kt @@ -0,0 +1,186 @@ +package dev.lain.claudejb.ui + +import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.project.Project +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBPanel +import com.intellij.ui.tabs.JBTabs +import com.intellij.ui.tabs.JBTabsFactory +import com.intellij.ui.tabs.TabInfo +import com.intellij.ui.tabs.TabsListener +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.TimedDeadzone +import dev.lain.claudejb.session.AgentNode +import java.awt.BorderLayout +import java.awt.Color +import java.awt.Dimension +import javax.swing.JPanel +import javax.swing.Timer + +/** + * One row of agent tabs — the `Agents` strip under the chats, or the `Subagents` strip under it. + * + * **Header only.** Unlike the chat strip, these tabs own no content: the transcript is painted by the chat's + * single JCEF browser, which simply switches which transcript it shows. That is what keeps a session with + * eighty agents affordable — one Chromium per chat, not one per agent. So every [TabInfo] carries an empty + * placeholder component and the panel reports only the height of the tab labels. + * + * Selecting a tab reports the agent id; closing one reports it too, and the caller persists that (a closed + * tab stays closed across restarts, and the transcript card is the way back). The strip is **always + * visible**, empty or not, so the layout does not jump as agents come and go. + */ +internal class AgentStripPanel( + project: Project, + parent: Disposable, + /** `Agents` / `Subagents` — drawn at the left so a row is identifiable when it is empty. */ + private val title: String, +) : JBPanel(BorderLayout()) { + + private val tabs: JBTabs = JBTabsFactory.createTabs(project, parent) + + /** agentId → its tab, so the caller can select, relabel, blink or close one by id. */ + private val tabOf = LinkedHashMap() + + private var onSelected: (String?) -> Unit = {} + private var onClosed: (String) -> Unit = {} + + /** Suppresses the selection callback while the strip is being rebuilt from a scan. */ + private var rebuilding = false + + init { + add(JBLabel(title).apply { border = JBUI.Borders.empty(0, 8, 0, 6) }, BorderLayout.WEST) + add(tabs.component, BorderLayout.CENTER) + // Same presentation as the chat strip, for the "same format as the chat tabs" the design asks for: + // a single scrolling row whose close buttons are always drawn. + tabs.presentation.setSingleRow(true) + tabs.presentation.setTabLabelActionsAutoHide(false) + tabs.presentation.setTabLabelActionsMouseDeadzone(TimedDeadzone.NULL) + tabs.presentation.setSupportsCompression(false) + tabs.addListener( + object : TabsListener { + override fun selectionChanged(oldSelection: TabInfo?, newSelection: TabInfo?) { + if (rebuilding) return + newSelection?.setIcon(null) + onSelected(agentIdOf(newSelection)) + } + }, + ) + tabs.addTabMouseListener( + object : java.awt.event.MouseAdapter() { + override fun mousePressed(e: java.awt.event.MouseEvent) { + if (e.button == java.awt.event.MouseEvent.BUTTON2) tabs.findInfo(e)?.let { closeTab(it) } + } + }, + ) + } + + fun onEvents(selected: (String?) -> Unit, closed: (String) -> Unit) { + onSelected = selected + onClosed = closed + } + + /** The agent whose tab is selected, or null when the strip is empty. */ + val selectedAgentId: String? get() = agentIdOf(tabs.selectedInfo) + + /** + * Rebuilds the strip to show exactly [nodes], keeping the selection when that agent is still there. + * + * Rebuilding rather than diffing is deliberate: a scan can add, remove and re-parent agents at once on a + * heavy session, and a strip of at most a few dozen labels is cheap to lay out. What must NOT be lost is + * the user's selection, so it is restored explicitly and the listener is muted meanwhile — otherwise + * every scan would look like the user had clicked a tab and would repaint the transcript underneath. + */ + fun render(nodes: List, depthOf: (AgentNode) -> Int = { 1 }) { + val keepSelected = selectedAgentId + rebuilding = true + try { + tabs.removeAllTabs() + tabOf.clear() + for (node in nodes) { + val info = TabInfo(JPanel()) + .setText(AgentTabLabels.tab(node, depthOf(node))) + .setObject(node.agentId) + info.setTabLabelActions(DefaultActionGroup(CloseAgentTabAction(info)), TAB_ACTION_PLACE) + tabs.addTab(info) + (tabs.getTabLabel(info) as? javax.swing.JComponent)?.toolTipText = AgentTabLabels.tooltip(node) + tabOf[node.agentId] = info + } + tabOf[keepSelected]?.let { tabs.select(it, false) } + } finally { + rebuilding = false + } + revalidate() + repaint() + } + + /** Selects the tab of [agentId], transferring focus like a manual click. No-op when it is not there. */ + fun select(agentId: String) { + tabOf[agentId]?.let { tabs.select(it, true) } + } + + fun has(agentId: String): Boolean = agentId in tabOf + + /** + * Two soft orange pulses on a newly-spawned agent's tab, then back to normal. + * + * The point is peripheral vision: on a session spawning agents constantly, a permanent colour would be + * noise and a notification per agent would be a storm (they are batched elsewhere). Two pulses say + * "something appeared here" and then get out of the way. + */ + fun blink(agentId: String) { + val info = tabOf[agentId] ?: return + var remaining = BLINK_PULSES * 2 + val timer = Timer(BLINK_INTERVAL_MS, null) + timer.addActionListener { + info.setTabColor(if (remaining % 2 == 0) BLINK_COLOR else null) + remaining-- + if (remaining < 0) { + info.setTabColor(null) + timer.stop() + } + } + timer.isRepeats = true + timer.start() + } + + private fun closeTab(info: TabInfo) { + val id = agentIdOf(info) ?: return + tabs.removeTab(info) + tabOf.remove(id) + onClosed(id) + } + + private fun agentIdOf(info: TabInfo?): String? = info?.`object` as? String + + /** Header height only: these tabs have no content of their own (see the class doc). */ + override fun getPreferredSize(): Dimension { + val height = tabs.selectedInfo?.let { tabs.getTabLabel(it)?.preferredSize?.height } + ?: JBUI.scale(DEFAULT_STRIP_HEIGHT) + return Dimension(super.getPreferredSize().width, height + JBUI.scale(STRIP_PADDING)) + } + + override fun getMaximumSize(): Dimension = Dimension(Int.MAX_VALUE, preferredSize.height) + + private inner class CloseAgentTabAction(private val info: TabInfo) : + AnAction("Close ${title.dropLast(1)} Tab", "Close this tab — the transcript stays on disk", com.intellij.icons.AllIcons.Actions.Close) { + override fun actionPerformed(e: AnActionEvent) = closeTab(info) + + /** EDT for the same reason the chat strip's close action declares it: BGT actions are dropped. */ + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT + } + + private companion object { + const val TAB_ACTION_PLACE = "ClaudeAgentTabs" + const val DEFAULT_STRIP_HEIGHT = 26 + const val STRIP_PADDING = 4 + const val BLINK_PULSES = 2 + const val BLINK_INTERVAL_MS = 260 + + /** Soft orange — visible against both light and dark themes without shouting. */ + val BLINK_COLOR: Color = Color(0xE8, 0x8C, 0x30) + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/ui/AgentTabLabels.kt b/src/main/kotlin/dev/lain/claudejb/ui/AgentTabLabels.kt new file mode 100644 index 00000000..518a4d95 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/ui/AgentTabLabels.kt @@ -0,0 +1,61 @@ +package dev.lain.claudejb.ui + +import dev.lain.claudejb.session.AgentNode +import dev.lain.claudejb.session.AgentStatus + +/** + * How an agent is written on a tab, in the tree view the user asked for. + * + * Pure on purpose: this is the part of the agent strips that has rules worth pinning (indentation, status, + * truncation, what happens to a nameless agent), and keeping it out of the Swing class is what makes those + * rules testable without a UI. + * + * The shape is `|_ Translate the SAP standards`, with one `|_` per level below the strip's own root, so a + * subagent of a subagent reads as `|_ |_ …`. It is deliberately the same idiom in the tab strips and in the + * dashboard lists: one visual language for "this hangs off that". + */ +object AgentTabLabels { + + /** Max characters on a tab before ellipsis — mirrors the chat tabs' own cap so the strips look alike. */ + const val TAB_TITLE_MAX = 22 + + /** The tree connector repeated per level of depth below the strip's root. */ + private const val CONNECTOR = "|_ " + + /** + * The tab's text: tree connector, then the agent's own label, truncated. + * + * [relativeDepth] is depth **within the strip**, not the absolute `spawnDepth` — the Subagents strip + * shows children of the selected agent, so its first level is one connector, not three. + */ + fun tab(node: AgentNode, relativeDepth: Int = 1): String = + CONNECTOR.repeat(relativeDepth.coerceIn(1, MAX_CONNECTORS)) + truncate(node.meta.label()) + + /** + * The tooltip: the full label, the agent type, and how it ended. + * + * Everything the tab had to drop for width goes here — the full description, and the `agentType`, which + * is what tells a `general-purpose` agent from a custom one when six tabs share a similar title. + */ + fun tooltip(node: AgentNode): String = buildString { + append(node.meta.label()) + node.meta.agentType?.takeIf { it.isNotBlank() }?.let { append(" · ").append(it) } + append(" · ").append(statusText(node.status)) + } + + /** Human wording for a status. A finished agent keeps its tab, so the tab has to say how it finished. */ + fun statusText(status: AgentStatus): String = when (status) { + AgentStatus.RUNNING -> "running" + AgentStatus.COMPLETED -> "completed" + AgentStatus.FAILED -> "failed" + AgentStatus.STOPPED -> "stopped" + } + + private fun truncate(s: String): String { + val clean = s.trim().ifBlank { "Agent" } + return if (clean.length <= TAB_TITLE_MAX) clean else clean.take(TAB_TITLE_MAX - 1) + "…" + } + + /** Beyond this the connectors would eat the whole label; deep chains stop indenting, not the tree. */ + private const val MAX_CONNECTORS = 4 +} diff --git a/src/main/kotlin/dev/lain/claudejb/ui/AgentTabsPanel.kt b/src/main/kotlin/dev/lain/claudejb/ui/AgentTabsPanel.kt new file mode 100644 index 00000000..7a95a0d7 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/ui/AgentTabsPanel.kt @@ -0,0 +1,111 @@ +package dev.lain.claudejb.ui + +import com.intellij.openapi.Disposable +import com.intellij.openapi.project.Project +import com.intellij.ui.components.JBPanel +import dev.lain.claudejb.session.AgentNode +import dev.lain.claudejb.session.AgentRegistry +import java.awt.BorderLayout +import javax.swing.BoxLayout + +/** + * The two agent rows under the chat tabs: `Agents`, then `Subagents`. + * + * The rule the whole thing follows is "each row shows the children of the row above's selection": + * - `Agents` shows the agents of the **selected chat**, so switching chat swaps the row. + * - `Subagents` shows the agents spawned by the **selected agent**, so it changes as you move along the row + * above, and an agent that spawned nothing leaves it empty. + * + * Both rows are **always visible**, empty or not, so the transcript below never jumps up and down as agents + * come and go — on a session that spawns them constantly, a row that appears and disappears is worse than an + * empty one. + * + * Selection reports **which transcript to paint** ([onShowTranscript]): the chat's own transcript when + * nothing is selected in either row, otherwise that agent's. One browser, many transcripts — see + * [AgentStripPanel]. + */ +internal class AgentTabsPanel(project: Project, parent: Disposable) : JBPanel(BorderLayout()) { + + private val agents = AgentStripPanel(project, parent, "Agents") + private val subagents = AgentStripPanel(project, parent, "Subagents") + + /** Told which agent's transcript to show — null means the chat's own. */ + var onShowTranscript: (String?) -> Unit = {} + + /** Told that the user closed an agent's tab, so the close can be remembered across restarts. */ + var onTabClosed: (String) -> Unit = {} + + /** The registry currently rendered, so a selection can re-derive the children rows. */ + private var registry: AgentRegistry? = null + + init { + val rows = JBPanel>().apply { layout = BoxLayout(this, BoxLayout.Y_AXIS) } + rows.add(agents) + rows.add(subagents) + add(rows, BorderLayout.CENTER) + + agents.onEvents( + selected = { id -> + renderSubagentsOf(id) + onShowTranscript(id) + }, + closed = { id -> + onTabClosed(id) + // Its children have no row to live in once the parent is gone, and the transcript falls back + // to the chat's own — otherwise the browser would keep painting a tab that is not there. + renderSubagentsOf(agents.selectedAgentId) + onShowTranscript(agents.selectedAgentId) + }, + ) + subagents.onEvents( + selected = { id -> onShowTranscript(id ?: agents.selectedAgentId) }, + closed = { id -> + onTabClosed(id) + onShowTranscript(subagents.selectedAgentId ?: agents.selectedAgentId) + }, + ) + } + + /** + * Re-renders both rows from [registry], hiding the agents whose tab the user closed ([hidden]). + * + * A closed tab is not a deleted agent: its transcript is the binary's file and its card is still in the + * main transcript, which is how it comes back. This method only stops drawing it. + */ + fun render(registry: AgentRegistry, hidden: Set = emptySet()) { + this.registry = registry + val roots = registry.children(null).filterNot { it.agentId in hidden } + agents.render(roots) + renderSubagentsOf(agents.selectedAgentId, hidden) + } + + /** Opens (or re-selects) [agentId]'s tab, wherever in the tree it sits. Used by the transcript card. */ + fun reveal(agentId: String) { + val reg = registry ?: return + val node = reg.nodes[agentId] ?: return + val parent = node.parentAgentId + if (parent == null) { + agents.select(agentId) + return + } + // A subagent: select its parent first so the Subagents row is showing the right family, then it. + agents.select(parent) + renderSubagentsOf(parent) + subagents.select(agentId) + } + + /** Two orange pulses on whichever row now carries [agentId]. */ + fun blink(agentId: String) { + if (agents.has(agentId)) agents.blink(agentId) else subagents.blink(agentId) + } + + private fun renderSubagentsOf(parentId: String?, hidden: Set = emptySet()) { + val reg = registry + val children: List = if (reg == null || parentId == null) { + emptyList() + } else { + reg.children(parentId).filterNot { it.agentId in hidden } + } + subagents.render(children) + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/ui/AgentTabLabelsTest.kt b/src/test/kotlin/dev/lain/claudejb/ui/AgentTabLabelsTest.kt new file mode 100644 index 00000000..02acf3ed --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/ui/AgentTabLabelsTest.kt @@ -0,0 +1,57 @@ +package dev.lain.claudejb.ui + +import dev.lain.claudejb.session.AgentMeta +import dev.lain.claudejb.session.AgentNode +import dev.lain.claudejb.session.AgentStatus +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** The tree labelling of agent tabs — the rules worth pinning, kept out of Swing so they can be. */ +class AgentTabLabelsTest { + + private fun node( + label: String = "Translate the SAP standards", + type: String? = "general-purpose", + status: AgentStatus = AgentStatus.RUNNING, + ) = AgentNode(AgentMeta("agent-1", agentType = type, description = label), status = status) + + @Test + fun `a tab shows one connector per level below its strip`() { + assertEquals("|_ Translate the SAP…", AgentTabLabels.tab(node(), relativeDepth = 1)) + assertTrue(AgentTabLabels.tab(node(), relativeDepth = 2).startsWith("|_ |_ ")) + // Depth within the STRIP, not spawnDepth: the Subagents strip shows children of the selected agent, + // so its first level is one connector however deep that agent sits in the whole tree. + assertTrue(AgentTabLabels.tab(node(), relativeDepth = 0).startsWith("|_ ")) + } + + @Test + fun `a very deep chain stops indenting instead of losing the label`() { + val deep = AgentTabLabels.tab(node("Short"), relativeDepth = 12) + assertTrue(deep.endsWith("Short")) + assertTrue(deep.count { it == '_' } <= 4, "connectors must be capped, got: $deep") + } + + @Test + fun `the label is truncated on the tab and complete in the tooltip`() { + val n = node("A description far longer than any tab is ever going to be") + assertTrue(AgentTabLabels.tab(n).length <= AgentTabLabels.TAB_TITLE_MAX + 4) + assertTrue(AgentTabLabels.tab(n).endsWith("…")) + assertTrue(AgentTabLabels.tooltip(n).contains("far longer than any tab")) + } + + @Test + fun `the tooltip carries the agent type and how it ended`() { + // Six agents with similar descriptions are told apart by their type, and a kept tab has to say + // whether the agent finished or died -- reading why one failed is the point of keeping it. + val t = AgentTabLabels.tooltip(node(status = AgentStatus.FAILED)) + assertTrue(t.contains("general-purpose")) + assertTrue(t.contains("failed")) + } + + @Test + fun `a nameless agent still gets a navigable tab`() { + val anonymous = AgentNode(AgentMeta("agent-xyz")) + assertEquals("|_ agent-xyz", AgentTabLabels.tab(anonymous)) + } +} From 7faf9b555fd5990838364460f61b347e5c6585bd Mon Sep 17 00:00:00 2001 From: Lain Date: Tue, 11 Aug 2026 01:21:53 +0200 Subject: [PATCH 007/141] feat(agents): show each agent's transcript in its own tab Wires the strips to the chat and gives every agent a place to be read. The rows live INSIDE each chat panel, which is both what puts them below that chat's tab (JBTabs owns the space between its header and its content, so a wrapper would have put them above) and what makes "only the selected chat's agents" free: each chat carries its own rows, so there is no shared state that could ever show another chat's agents. One browser paints every transcript. showTranscript(agentId) clears and re-sends; while an agent is shown the chat's live rows keep accumulating in the model but are not pushed, because the frontend upserts by row id and letting both streams write would interleave a live chat row into an agent's transcript -- the exact mixing this release removes. Switching back re-sends the chat whole. The Agent/Task card is now a link, not an expander: its work is not in this transcript any more, so expanding would open an empty box. It sends its tool_use_id and the host resolves which agent that spawned, because the pairing lives in the binary's sidecar and the card never sees an agent id. Clicking a card whose tab was closed REOPENS it -- closing hides a view, it never destroys anything, and this is the documented way back. Spawns blink the new tab twice in orange and raise ONE grouped notification per burst, suppressed when the chat is already on screen: this session spawns agents in waves, and a popup per agent is a storm that teaches you to ignore the tool window. The dashboard gains the Agents / Subagents / Background tasks windows under Session, each row carrying the ownership chain (Chat |_ Agent A |_ Agent B) and linking to its tab. For a background task the chat is always known but the owning agent often is not -- background_tasks_changed carries no parent and no tool_use_id -- so the row says "no known agent" rather than inventing a chain. The chain walk is cycle-guarded: a malformed parent link must degrade to a shorter chain, never hang. --- .../dev/lain/claudejb/ui/JcefChatPanel.kt | 177 ++++++++++++++++++ .../dev/lain/claudejb/ui/jcef/JcefBridge.kt | 48 +++++ .../lain/claudejb/ui/jcef/JcefSessionData.kt | 66 +++++++ src/main/resources/jcef/app-session.js | 145 ++++++++------ src/main/resources/jcef/app-transcript.js | 17 +- src/main/resources/jcef/app.css | 31 +++ 6 files changed, 428 insertions(+), 56 deletions(-) diff --git a/src/main/kotlin/dev/lain/claudejb/ui/JcefChatPanel.kt b/src/main/kotlin/dev/lain/claudejb/ui/JcefChatPanel.kt index 068f5b07..acd6bf41 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/JcefChatPanel.kt +++ b/src/main/kotlin/dev/lain/claudejb/ui/JcefChatPanel.kt @@ -17,8 +17,12 @@ import com.intellij.ui.components.JBPanel import dev.lain.claudejb.context.Attachment import dev.lain.claudejb.context.EditorContextProvider import dev.lain.claudejb.context.FilePickerHelper +import com.intellij.notification.NotificationAction +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType import dev.lain.claudejb.diff.DiffPresenter import dev.lain.claudejb.protocol.mergedOver +import dev.lain.claudejb.session.PluginAgentIndex import dev.lain.claudejb.session.AttentionReason import dev.lain.claudejb.session.ClaudeSession import dev.lain.claudejb.session.SessionListener @@ -121,9 +125,47 @@ class JcefChatPanel(private val project: Project, val session: ClaudeSession) : /** The two onboarding cards' host side (install-the-binary + sign-in), kept OFF this class on purpose. */ private val onboarding = OnboardingController(project, session, host::exec) + /** + * The `Agents` / `Subagents` rows, at the top of THIS panel. + * + * They live inside the chat rather than beside the chat strip because that is the only place they can sit + * *below* the chat's own tab and still belong to it: `JBTabs` owns the space between its header and its + * content, so a wrapper around the strip would have put the agent rows above the chat tabs. Being + * per-chat also makes "show the agents of the selected chat" free — nothing has to be swapped when the + * user switches tab, because each chat carries its own rows. + */ + private val agentTabs = AgentTabsPanel(project, this) + + // Declared ABOVE `init`, which assigns them. Kotlin runs initializers and init blocks in declaration + // order, so a field declared below would still be null when the constructor writes it — the defect that + // killed every chat tab in 5.0.0 and the reason InitOrderContractTest scans this file. + /** Wired in `init` to [onAgentsScanned]; a field so a test or a future owner can substitute it. */ + var onAgentsUpdated: (List) -> Unit = {} + + /** Wired in `init` to [revealAgent]. */ + var onRevealAgent: (String) -> Unit = {} + + /** Agents whose tab the user closed. Not a delete — see [PluginAgentIndex]; the card reopens them. */ + private val hiddenAgents = HashSet() + init { background = ChatTheme.BG + add(agentTabs, BorderLayout.NORTH) add(host.component, BorderLayout.CENTER) + agentTabs.onShowTranscript = ::showTranscript + agentTabs.onTabClosed = { agentId -> + hiddenAgents += agentId + session.sessionId?.let { PluginAgentIndex.getInstance(project).setTabOpen(it, agentId, false) } + renderAgentRows() + } + onAgentsUpdated = ::onAgentsScanned + onRevealAgent = ::revealAgent + // What the user closed in an earlier run stays closed: the index is read once here, before the first + // scan can render anything, so a restored chat never flashes a tab the user had dismissed. + session.sessionId?.let { id -> + val index = PluginAgentIndex.getInstance(project) + hiddenAgents += index.admittedAgents(id) - index.openAgents(id).toSet() + } livePanels.add(this) session.transcript.addListener(this) @@ -151,6 +193,48 @@ class JcefChatPanel(private val project: Project, val session: ClaudeSession) : // ── TranscriptModel.Listener ───────────────────────────────────────────────────────────────────────── + /** + * Which transcript this browser is painting: null = the chat's own, otherwise that agent's. + * + * One browser, many transcripts. Spawning a JCEF per agent tab would mean a Chromium process per agent, + * and the session this feature exists for runs dozens at once. + */ + private var shownAgentId: String? = null + + /** + * Paints [agentId]'s transcript (null → the chat's own), replacing whatever is on screen. + * + * While an agent is shown, the chat's live rows are still tracked in the model but not pushed: the + * frontend upserts by row id, so letting both streams write would interleave a live chat row into an + * agent's transcript — the very mixing this release removes. Switching back re-sends the chat in full. + */ + fun showTranscript(agentId: String?) { + if (shownAgentId == agentId) return + shownAgentId = agentId + dirty.clear() + host.exec("window.cc.clear && window.cc.clear()") + if (agentId == null) { + structural = true + ensureTimer() + } else { + pushAgentTranscript(agentId) + } + } + + /** Re-sends the shown agent's transcript after a scan; a no-op when the chat's own is on screen. */ + private fun refreshShownAgent() { + val id = shownAgentId ?: return + pushAgentTranscript(id) + } + + private fun pushAgentTranscript(agentId: String) { + val entries = session.runningAgents.nodes[agentId]?.entries.orEmpty() + host.exec("window.cc.clear && window.cc.clear()") + if (entries.isNotEmpty()) { + host.exec("window.cc.batch && window.cc.batch(" + JcefBridge.agentBatchJson(entries) + ")") + } + } + override fun onAdded(entry: TranscriptEntry, index: Int) { // Append-at-tail (the common streaming case) leaves every existing row's order unchanged, so we only need // to send the NEW row (the dirty path, same as a streaming text update) instead of re-serializing the @@ -179,6 +263,14 @@ class JcefChatPanel(private val project: Project, val session: ClaudeSession) : /** Coalescer tick (EDT): one `cc.batch` frame — all rows on a structural change, else just the dirty ones. */ private fun onTick() { + // An agent's transcript is on screen: keep coalescing the chat's rows into the model, but do not + // paint them over the agent's. They are re-sent whole when the user switches back. + if (shownAgentId != null) { + dirty.clear() + structural = true + timer.stop() + return + } val entries = session.transcript.entries val items: List> = if (structural) { structural = false @@ -200,6 +292,84 @@ class JcefChatPanel(private val project: Project, val session: ClaudeSession) : // ── SessionListener ────────────────────────────────────────────────────────────────────────────────── + /** + * The agent tree moved: repaint the strips, refresh the shown agent's transcript, and let whoever owns + * the tab strips (the tool window factory) blink and notify for the newly-admitted ones. + */ + override fun onAgentsChanged(freshlyAdmitted: List) { + onAgentsUpdated(freshlyAdmitted) + refreshShownAgent() + pushSession() + } + + /** Repaints both rows from the registry, minus whatever the user has closed. */ + private fun renderAgentRows() = agentTabs.render(session.runningAgents, hiddenAgents) + + /** + * A scan finished. Repaint the rows, then blink the tabs of agents seen for the first time and raise ONE + * grouped notification for the burst — on a session spawning dozens at once, one popup per agent is a + * storm, and the blink is what carries "this one is new" without interrupting. + */ + private fun onAgentsScanned(freshlyAdmitted: List) { + renderAgentRows() + val fresh = freshlyAdmitted.filterNot { it in hiddenAgents } + if (fresh.isEmpty()) return + fresh.forEach { agentTabs.blink(it) } + notifyAgentsSpawned(fresh) + } + + /** + * One IDE notification per burst of spawns, with a link into the tool window. + * + * Grouped rather than one-per-agent because the session this exists for spawns them in waves: dozens of + * popups say nothing except "stop looking at the IDE". Suppressed entirely when this chat is the one on + * screen — the blinking tab has already said it, and a popup for what you are looking at is noise. + */ + private fun notifyAgentsSpawned(fresh: List) { + val tw = ToolWindowManager.getInstance(project).getToolWindow(CLAUDE_TOOL_WINDOW) + if (tw != null && tw.isVisible && isShowing) return + val names = fresh.mapNotNull { session.runningAgents.nodes[it]?.meta?.label() } + val text = when { + names.size == 1 -> "Agent started in \"${session.title}\": ${names.first()}" + names.isNotEmpty() -> "${names.size} agents started in \"${session.title}\"" + else -> return + } + NotificationGroupManager.getInstance().getNotificationGroup("Claude Code") + .createNotification("Claude Code", text, NotificationType.INFORMATION) + .addAction( + NotificationAction.createSimpleExpiring("Open") { + fresh.firstOrNull()?.let { revealAgent(it) } + ToolWindowManager.getInstance(project).getToolWindow(CLAUDE_TOOL_WINDOW)?.activate(null) + }, + ) + .notify(project) + } + + /** + * Reveals an agent's tab, reopening it when the user had closed it. + * + * This is what makes closing a tab safe: the agent, its transcript and its card are all still there, so + * "closed" only ever meant "not currently shown". + */ + private fun revealAgent(agentId: String) { + if (hiddenAgents.remove(agentId)) { + session.sessionId?.let { PluginAgentIndex.getInstance(project).setTabOpen(it, agentId, true) } + renderAgentRows() + } + agentTabs.reveal(agentId) + } + + /** + * The agent a `revealAgent` names: its id when the sender had one, else the agent spawned by that + * `tool_use_id`. Null when nothing matches — a card whose agent the binary never wrote a sidecar for + * (or one belonging to a terminal run) simply does nothing, rather than opening someone else's tab. + */ + private fun resolveAgentId(m: JcefBridge.Msg.RevealAgent): String? { + m.agentId.takeIf { it.isNotBlank() }?.let { return it } + val tool = m.toolUseId.takeIf { it.isNotBlank() } ?: return null + return session.runningAgents.nodes.values.firstOrNull { it.meta.toolUseId == tool }?.agentId + } + override fun onStateChanged() { pushMetaState() pushSession() @@ -644,6 +814,10 @@ class JcefChatPanel(private val project: Project, val session: ClaudeSession) : is JcefBridge.Msg.StopTask -> session.stopTask(m.taskId) + // The transcript card (and the dashboard lists) asking to go to an agent's tab. Reopens it when the + // user had closed it: closing hides a view, it never removes the agent or its transcript. + is JcefBridge.Msg.RevealAgent -> resolveAgentId(m)?.let { onRevealAgent(it) } + // Everything the two onboarding cards send (install / binary path / sign-in / logout) lives in // its own collaborator — see OnboardingController. `handle` returns false only for messages that // are not onboarding's, and every remaining SessionControl IS handled above, so falling through @@ -924,6 +1098,9 @@ class JcefChatPanel(private val project: Project, val session: ClaudeSession) : /** How many recently-opened files the attach menu offers before the user has to search. */ private const val RECENT_FILES_LIMIT = 14 + /** The tool window this plugin registers; used to tell "am I on screen?" from a notification. */ + private const val CLAUDE_TOOL_WINDOW = "Claude Code" + /** Period of the plan-limits poll, visible or not — a window reset happens on wall-clock time. */ private const val USAGE_POLL_MS = 30_000 diff --git a/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefBridge.kt b/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefBridge.kt index fa80db26..4b901ca8 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefBridge.kt +++ b/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefBridge.kt @@ -3,6 +3,7 @@ package dev.lain.claudejb.ui.jcef import dev.lain.claudejb.permission.ElicitationCard import dev.lain.claudejb.permission.PendingPermission import dev.lain.claudejb.protocol.AskQuestion +import dev.lain.claudejb.session.EntryDTO import dev.lain.claudejb.session.TranscriptEntry import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray @@ -69,6 +70,38 @@ object JcefBridge { fun batchJson(items: List>): String = JsonArray(items.map { (e, order) -> entryJson(e, order) }).toString() + /** + * The same row shape, built from a **reconstructed** entry rather than a live one. + * + * An agent's transcript is read back from the binary's own per-agent file (as is a restored session's), + * so it arrives as [dev.lain.claudejb.session.EntryDTO] with no live tool state and no row ids. Ids are + * synthesised from the position, which is all the frontend needs — it upserts by id and repositions to + * `order`, and a reconstructed transcript is replaced wholesale rather than patched row by row. + * + * Tool rows are marked FINISHED: whatever the agent was doing when it wrote that file, it is not doing + * it now in a way this row can track, and a card left spinning forever is a lie the UI tells by omission. + */ + fun agentBatchJson(entries: List): String = + JsonArray( + entries.mapIndexed { index, dto -> + buildJsonObject { + put("id", index.toLong()) + put("order", index) + put("speaker", dto.speaker) + put("text", dto.text) + dto.meta?.let { put("meta", it) } + dto.toolUseId?.let { put("toolUseId", it) } + dto.filePath?.let { put("filePath", it) } + dto.commandText?.let { put("command", it) } + put("state", "FINISHED") + put("elapsed", 0) + if (dto.speaker == "TOOL" && dto.toolUseId != null && dto.meta in REVIEWABLE_TOOLS) { + put("reviewable", true) + } + } + }, + ).toString() + /** One pending permission as a card the frontend renders (Accept/Reject/View-diff, plan, or AskUserQuestion). */ fun permissionJson(p: PendingPermission, diff: String? = null): JsonObject = buildJsonObject { put("id", p.requestId) @@ -236,6 +269,19 @@ object JcefBridge { data class McpToggle(val name: String, val enabled: Boolean) : SessionControl data class StopTask(val taskId: String) : SessionControl + /** + * Go to an agent's tab: sent by the Agent/Task card in the transcript and by the dashboard lists. + * + * Two ways to name the agent, because the two senders know different things. The dashboard has the + * [agentId]; a transcript card only ever knew its [toolUseId], and the pairing between them comes + * from the binary's own sidecar, which the host reads — so the card sends what it has and the host + * resolves. Exactly one of the two is non-blank. + * + * Also the documented way back to a tab the user closed: closing hides a view, it never destroys + * anything, so revealing it again just re-opens a window onto a file that is still there. + */ + data class RevealAgent(val agentId: String, val toolUseId: String) : SessionControl + // The "Claude Code was not found" boot card: run an official installer in the IDE terminal, // validate a user-typed binary path, or re-check after an install finished. data class InstallClaude(val method: String) : SessionControl @@ -370,6 +416,8 @@ object JcefBridge { "stopTask" -> Msg.StopTask(f.text("taskId")) + "revealAgent" -> Msg.RevealAgent(f.text("agentId"), f.text("toolUseId")) + // The "Claude Code was not found" boot card. "installClaude" -> Msg.InstallClaude(f.text("method")) diff --git a/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefSessionData.kt b/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefSessionData.kt index 432fec8d..1786946b 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefSessionData.kt +++ b/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefSessionData.kt @@ -61,6 +61,9 @@ object JcefSessionData { put("account", accountJson(session) ?: JsonNull) put("subagents", subagentsJson(session)) put("backgroundTasks", backgroundTasksJson(session)) + // The tree behind the Agents / Subagents windows: every agent with the chain it hangs off, so a + // row can say "Chat |_ Agent A |_ Agent B" and link straight to that tab. + put("agentTree", agentTreeJson(session)) // Always emit a friendly model label (even on a default session where session.model is null) // and the known working dir, so the Session card is never empty — the prior nulls made the // whole dashboard collapse to "No session data yet" on a fresh/idle session. @@ -261,6 +264,55 @@ object JcefSessionData { internal fun firstPresent(vararg candidates: String?): String? = candidates.firstOrNull { !it.isNullOrBlank() } + /** + * The agent tree for the Agents / Subagents windows: + * `[{ agentId, label, type, status, depth, parent, chain, running }]`, empty when this chat has none. + * + * [chain] is the ownership line the user asked to see — `Chat |_ Agent A |_ Agent B` — built here rather + * than in the frontend because the parentage is a property of the data, not of how it is drawn, and the + * same string is what the Background tasks window shows for the task's owner. + * + * Every row carries its `agentId`, which is what the window's link sends back to jump to that tab. + */ + private fun agentTreeJson(session: ClaudeSession) = buildJsonArray { + val nodes = session.runningAgents.nodes + nodes.values.forEach { node -> + addJsonObject { + put("agentId", node.agentId) + put("label", node.meta.label()) + put("type", node.meta.agentType) + put("status", node.status.name.lowercase()) + put("depth", node.depth) + put("parent", node.parentAgentId) + put("chain", ownershipChain(session.title, node.agentId, nodes)) + put("running", node.status == dev.lain.claudejb.session.AgentStatus.RUNNING) + } + } + } + + /** + * `Chat |_ Agent A |_ Agent B` for [agentId], walking up `parentAgentId`. + * + * Guarded against a cycle by construction: the walk stops at the first id it has already seen. The binary + * writes these parent links, and a malformed one must degrade to a shorter chain, never to a hang. + */ + private fun ownershipChain( + chatTitle: String, + agentId: String, + nodes: Map, + ): String { + val parts = ArrayDeque() + val seen = HashSet() + var current: String? = agentId + while (current != null && seen.add(current)) { + val node = nodes[current] ?: break + parts.addFirst(node.meta.label()) + current = node.parentAgentId + } + parts.addFirst(chatTitle) + return parts.joinToString(" |_ ") + } + /** One row per subagent task: `{ id, desc, type, status, tokens, tools }`; empty array when none. */ private fun subagentsJson(session: ClaudeSession) = buildJsonArray { session.subagentTasks.values.forEach { task -> @@ -281,11 +333,25 @@ object JcefSessionData { * missed edge the way the subagent list can, and it is deliberately not correlated with it. */ private fun backgroundTasksJson(session: ClaudeSession) = buildJsonArray { + val nodes = session.runningAgents.nodes session.backgroundTasks.forEach { task -> + // Which agent owns it, when that is knowable at all. `background_tasks_changed` carries only + // {task_id, task_type, description} — no parent, no tool_use_id — so the owner can only be + // recovered when the same task_id was seen earlier on the edge stream, which is where the + // tool_use_id lives. When it cannot be, the row says the chat and stops there: an invented + // chain would be worse than an honest gap. + val owner = session.subagentTasks[task.taskId]?.toolUseId + ?.let { tool -> nodes.values.firstOrNull { it.meta.toolUseId == tool } } addJsonObject { put("id", task.taskId) put("desc", task.description) put("type", task.taskType) + put("agentId", owner?.agentId) + put( + "chain", + owner?.let { ownershipChain(session.title, it.agentId, nodes) } + ?: "${session.title} · no known agent", + ) } } } diff --git a/src/main/resources/jcef/app-session.js b/src/main/resources/jcef/app-session.js index 20ba4bf8..f192f321 100644 --- a/src/main/resources/jcef/app-session.js +++ b/src/main/resources/jcef/app-session.js @@ -305,52 +305,69 @@ return card('Session', rows); } - function buildSubagentsCard(subs) { - if (!Array.isArray(subs) || !subs.length) return null; - var rows = []; - for (var i = 0; i < subs.length; i++) { - var s = subs[i] || {}; - var id = s.id; - var desc = s.desc != null ? String(s.desc) : ''; - var type = s.type != null ? String(s.type) : ''; - var status = s.status != null ? String(s.status) : ''; - var tokens = fmtInt(s.tokens); - - var metaBits = []; - if (type) metaBits.push(type); - if (status) metaBits.push(status); - if (tokens != null) metaBits.push(tokens + ' tok'); - - var stopBtn = h('span', { - class: 'btn', - attrs: { role: 'button', tabindex: '0' }, - text: 'Stop', + /** + * One row of the Agents / Subagents windows. + * + * The whole row is the link: clicking it goes to that agent's tab, reopening it when the user had closed + * it. The ownership chain (`Chat |_ Agent A |_ Agent B`) is built by the host, because parentage is a + * property of the data rather than of how it is drawn — and it is the same string the Background tasks + * window shows, so "where does this hang from" reads identically everywhere. + */ + function agentRow(a) { + var label = a.label != null ? String(a.label) : 'Agent'; + var metaBits = []; + if (a.type) metaBits.push(String(a.type)); + if (a.status) metaBits.push(String(a.status)); + var depth = typeof a.depth === 'number' ? a.depth : 1; + return h( + 'div', + { + class: 'subagent-row agent-row' + (a.running ? ' running' : ''), + attrs: { role: 'button', tabindex: '0', title: a.chain || label }, on: { - click: (function (taskId) { - return function (ev) { - ev.preventDefault(); - ev.stopPropagation(); - if (taskId != null) send({ type: 'stopTask', taskId: taskId }); + click: (function (agentId) { + return function () { + if (agentId) send({ type: 'revealAgent', agentId: agentId }); }; - })(id), + })(a.agentId), }, - }); + }, + h( + 'div', + { class: 'subagent-main' }, + h('span', { class: 'subagent-desc', text: treePrefix(depth) + label }), + h('span', { class: 'subagent-meta', text: metaBits.join(' · ') }), + a.chain ? h('span', { class: 'agent-chain', text: a.chain }) : null + ) + ); + } - rows.push( - h( - 'div', - { class: 'subagent-row' }, - h( - 'div', - { class: 'subagent-main' }, - h('span', { class: 'subagent-desc', text: desc || type || 'Subagent' }), - metaBits.length ? h('span', { class: 'subagent-meta', text: metaBits.join(' · ') }) : null - ), - stopBtn - ) - ); - } - return card('Subagents', rows, true); + /** `|_ ` per level, capped — the same tree idiom the tab strips use, so one visual language for hanging off. */ + function treePrefix(depth) { + var n = Math.max(0, Math.min(4, depth - 1)); + var out = ''; + for (var i = 0; i < n; i++) out += '|_ '; + return out + '|_ '; + } + + /** Agents spawned directly by this chat's turns. */ + function buildAgentsCard(tree) { + if (!Array.isArray(tree)) return null; + var roots = tree.filter(function (a) { + return a && !a.parent; + }); + if (!roots.length) return null; + return card('Agents', roots.map(agentRow), true); + } + + /** Agents spawned BY another agent, at any depth — the window that answers "who launched this?". */ + function buildSubagentsCard(tree) { + if (!Array.isArray(tree)) return null; + var nested = tree.filter(function (a) { + return a && a.parent; + }); + if (!nested.length) return null; + return card('Subagents', nested.map(agentRow), true); } // Live background tasks, from the `background_tasks_changed` LEVEL signal: the host always sends the CURRENT @@ -380,19 +397,36 @@ }, }); - rows.push( + // Where it runs. The chat is always known -- it is the session that reported the task -- but the + // OWNING AGENT often is not: `background_tasks_changed` carries only id, type and description, with + // no parent and no tool_use_id. When the host could not resolve one it says so, because a made-up + // chain would be worse than an honest gap. + var chain = t.chain != null ? String(t.chain) : ''; + var row = h( + 'div', + { + class: 'subagent-row' + (t.agentId ? ' agent-row' : ''), + attrs: t.agentId ? { role: 'button', tabindex: '0', title: chain } : { title: chain }, + on: t.agentId + ? { + click: (function (agentId) { + return function () { + send({ type: 'revealAgent', agentId: agentId }); + }; + })(t.agentId), + } + : null, + }, h( 'div', - { class: 'subagent-row' }, - h( - 'div', - { class: 'subagent-main' }, - h('span', { class: 'subagent-desc', text: desc || type || 'Background task' }), - type ? h('span', { class: 'subagent-meta', text: type }) : null - ), - stopBtn - ) + { class: 'subagent-main' }, + h('span', { class: 'subagent-desc', text: desc || type || 'Background task' }), + type ? h('span', { class: 'subagent-meta', text: type }) : null, + chain ? h('span', { class: 'agent-chain', text: chain }) : null + ), + stopBtn ); + rows.push(row); } return card('Background tasks', rows, true); } @@ -523,9 +557,12 @@ buildCostCard(s.cost), buildAccountCard(s.account), buildEnvCard(s), - buildSubagentsCard(s.subagents), - buildBackgroundTasksCard(s.backgroundTasks), buildMcpCard(lastMcp), + // Session first, then the three windows the agent work moved into, in the order the user asked for: + // Session · Agents · Subagents · Background tasks. + buildAgentsCard(s.agentTree), + buildSubagentsCard(s.agentTree), + buildBackgroundTasksCard(s.backgroundTasks), ]; var any = false; diff --git a/src/main/resources/jcef/app-transcript.js b/src/main/resources/jcef/app-transcript.js index d65a41b1..be8d9ed7 100644 --- a/src/main/resources/jcef/app-transcript.js +++ b/src/main/resources/jcef/app-transcript.js @@ -319,6 +319,15 @@ // distinct from this tool's own routed output (.tool-out). var children = el('div', { class: 'tool-children' }); head.addEventListener('click', function () { + // An Agent/Task card is a LINK to that agent's tab, not an expander. Its work no longer lives in this + // transcript -- it has its own tab with its own transcript -- so expanding would open an empty box, + // and the card is also the documented way back to a tab the user closed. + // The host maps this tool_use_id to the agent it spawned (AgentRegistry knows the pairing from the + // binary's own sidecar), so the card does not have to carry an id it never sees. + if (node.__isAgentCard && node.__toolUseId) { + safeSend({ type: 'revealAgent', toolUseId: node.__toolUseId }); + return; + } node.classList.toggle('open'); }); node.appendChild(head); @@ -569,8 +578,12 @@ rec.outNode = rec.el.__outNode || rec.el.querySelector('.tool-out'); rec.el.__toolUseId = entry.toolUseId; toolCards.set(entry.toolUseId, rec.el); - // All tool cards (incl. Agent/Task) start collapsed and toggle on click — predictable, and - // never auto-expanded (which read as "stuck open"). Expand an Agent to see its nested activity. + // A tool card starts collapsed and toggles on click. An Agent/Task card is the exception: since 5.5.0 + // the agent's work lives in its own tab, so the card LINKS there instead of expanding onto nothing. + if (entry.meta === 'Task' || entry.meta === 'Agent') { + rec.el.__isAgentCard = true; + rec.el.classList.add('agent-link'); + } } if (entry.speaker === 'TOOL') { var icNode = rec.el.querySelector('.ic'); diff --git a/src/main/resources/jcef/app.css b/src/main/resources/jcef/app.css index c0fb8620..0f0f4da8 100644 --- a/src/main/resources/jcef/app.css +++ b/src/main/resources/jcef/app.css @@ -532,6 +532,37 @@ details.fold .fold-body p:first-child { and at message speed a burst of them reads as sluggish rather than smooth. */ animation: rise 0.22s var(--ease) both; } +/* A row of the Agents / Subagents / Background tasks windows that links to a tab. The whole row is the + target, so it says so on hover; the ownership chain sits under the label, dimmer, because it answers + "where does this hang from" rather than "what is this". */ +.agent-row { + cursor: pointer; +} +.agent-row:hover { + background: var(--surface2); +} +.agent-chain { + display: block; + font-size: 11px; + opacity: 0.65; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* An Agent/Task card is a link to that agent's tab, not an expander: its work lives in its own transcript + now. It says so by pointing rather than by looking different — a second card style in a transcript that is + mostly tool cards would be noise, and the chevron is already hidden for it below. */ +.tool.agent-link > .tool-head { + cursor: pointer; +} +.tool.agent-link > .tool-head:hover .name { + text-decoration: underline; +} +.tool.agent-link > .tool-head .chev { + visibility: hidden; +} + /* state borders use status hues — NOT the accent. --info = sky blue (loading/running). */ /* Active (loading until a result lands, or running on a heartbeat) → fade sky-blue ↔ amber + spin. Most tools never emit a tool_progress (so never hit RUNNING), so loading shares the fade — that's From fc2dda1c066ff3a77251fd67de3da82a64e0ec20 Mon Sep 17 00:00:00 2001 From: Lain Date: Tue, 11 Aug 2026 01:22:47 +0200 Subject: [PATCH 008/141] build: bump the version to 5.5.0 The agent tabs and windows are a feature release, and the artifact should say so while it is being validated: a zip still called 5.1.1 is indistinguishable from the version already on the Marketplace, which is exactly the confusion an install-from-disk test does not need. --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 0401e086..67f49296 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -28,7 +28,7 @@ plugins { } group = "dev.lain" -version = "5.1.1" +version = "5.5.0" repositories { mavenCentral() From 6103f3c104110525f77ca64ef0e14becf0e9555d Mon Sep 17 00:00:00 2001 From: Lain Date: Tue, 11 Aug 2026 01:31:19 +0200 Subject: [PATCH 009/141] fix(agents): rows only when they exist, as a real tree, at every level Four defects from the first look at it on screen. Rows appeared on a chat that had never spawned anything: two empty bars above every fresh conversation, asking a question nobody had asked. A row is now drawn only when it has something in it. That reverses the original "always visible" (which was mine to keep the layout still) because seeing it settled the argument. The rows are drawn the way tree(1) draws a directory -- fork, last child, and a trunk continuing past each opened level -- recomputed on every render because it depends on which rows are visible right now. A fork pointing at a row that is no longer drawn is worse than no tree at all. They are now a STACK OF LEVELS, not a fixed Agents+Subagents pair. An agent spawns agents, and so does each of those, so selecting at any level opens the level below it and selecting elsewhere collapses it. Two fixed rows could only ever show two levels of a tree the protocol does not bound. Background tasks were missing entirely, and they belong at EVERY level: the chat has its own, and so does each agent. A task's tab is a pointer rather than a transcript -- it shows the transcript of whoever runs it -- and it is not closable, because the plugin does not own a background task's lifetime; stopping one is a deliberate act with its own button. Finally the dashboard: Session was the only view button. There are now four stacked, one per window, each scrolling the panel to its own card and lighting up to say where you are. They are views of ONE panel rather than four panels: the data is a single payload, and splitting it would mean four things to keep in sync. --- .../dev/lain/claudejb/ui/AgentStripPanel.kt | 60 ++++- .../dev/lain/claudejb/ui/AgentTabsPanel.kt | 237 +++++++++++++----- .../dev/lain/claudejb/ui/JcefChatPanel.kt | 24 +- src/main/resources/jcef/app-session.js | 91 +++++-- src/main/resources/jcef/app.css | 19 +- 5 files changed, 334 insertions(+), 97 deletions(-) diff --git a/src/main/kotlin/dev/lain/claudejb/ui/AgentStripPanel.kt b/src/main/kotlin/dev/lain/claudejb/ui/AgentStripPanel.kt index 38e9ba37..047cd75d 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/AgentStripPanel.kt +++ b/src/main/kotlin/dev/lain/claudejb/ui/AgentStripPanel.kt @@ -30,14 +30,21 @@ import javax.swing.Timer * placeholder component and the panel reports only the height of the tab labels. * * Selecting a tab reports the agent id; closing one reports it too, and the caller persists that (a closed - * tab stays closed across restarts, and the transcript card is the way back). The strip is **always - * visible**, empty or not, so the layout does not jump as agents come and go. + * tab stays closed across restarts, and the transcript card is the way back). + * + * **A row appears only when it has something in it.** Standing rows on a chat that has never spawned an + * agent are two lines of chrome asking a question nobody asked; the design started with them always visible + * to keep the layout still, and seeing it proved the opposite — an empty `Agents` row above every fresh chat + * reads as broken UI. The header carries the tree connector of its depth, so when a row does appear it says + * what it hangs off. */ internal class AgentStripPanel( project: Project, parent: Disposable, - /** `Agents` / `Subagents` — drawn at the left so a row is identifiable when it is empty. */ + /** `Agents` / `Subagents` / `Background tasks` — the row's own name, drawn at the left. */ private val title: String, + /** How deep this row hangs: 1 = off the chat, 2 = off the selected agent. Drawn as `|_` connectors. */ + private val depth: Int = 1, ) : JBPanel(BorderLayout()) { private val tabs: JBTabs = JBTabsFactory.createTabs(project, parent) @@ -51,9 +58,13 @@ internal class AgentStripPanel( /** Suppresses the selection callback while the strip is being rebuilt from a scan. */ private var rebuilding = false + /** The row's own header, whose text carries the tree branch (`├─ Agents`, `│ └─ Subagents`). */ + private val header = JBLabel(title).apply { border = JBUI.Borders.empty(0, 8, 0, 6) } + init { - add(JBLabel(title).apply { border = JBUI.Borders.empty(0, 8, 0, 6) }, BorderLayout.WEST) + add(header, BorderLayout.WEST) add(tabs.component, BorderLayout.CENTER) + isVisible = false // nothing in it yet; `render` decides // Same presentation as the chat strip, for the "same format as the chat tabs" the design asks for: // a single scrolling row whose close buttons are always drawn. tabs.presentation.setSingleRow(true) @@ -86,37 +97,60 @@ internal class AgentStripPanel( /** The agent whose tab is selected, or null when the strip is empty. */ val selectedAgentId: String? get() = agentIdOf(tabs.selectedInfo) + /** One tab of a strip: an agent, or a background task. Both are "things that hang off this row". */ + data class Item(val id: String, val label: String, val tooltip: String, val closable: Boolean = true) + /** - * Rebuilds the strip to show exactly [nodes], keeping the selection when that agent is still there. + * Rebuilds the strip to show exactly [items], keeping the selection when that item is still there, and + * **hides the whole row when there is nothing in it**. * * Rebuilding rather than diffing is deliberate: a scan can add, remove and re-parent agents at once on a * heavy session, and a strip of at most a few dozen labels is cheap to lay out. What must NOT be lost is * the user's selection, so it is restored explicitly and the listener is muted meanwhile — otherwise * every scan would look like the user had clicked a tab and would repaint the transcript underneath. */ - fun render(nodes: List, depthOf: (AgentNode) -> Int = { 1 }) { + fun render(items: List) { val keepSelected = selectedAgentId rebuilding = true try { tabs.removeAllTabs() tabOf.clear() - for (node in nodes) { - val info = TabInfo(JPanel()) - .setText(AgentTabLabels.tab(node, depthOf(node))) - .setObject(node.agentId) - info.setTabLabelActions(DefaultActionGroup(CloseAgentTabAction(info)), TAB_ACTION_PLACE) + for (item in items) { + val info = TabInfo(JPanel()).setText(item.label).setObject(item.id) + if (item.closable) { + info.setTabLabelActions(DefaultActionGroup(CloseAgentTabAction(info)), TAB_ACTION_PLACE) + } tabs.addTab(info) - (tabs.getTabLabel(info) as? javax.swing.JComponent)?.toolTipText = AgentTabLabels.tooltip(node) - tabOf[node.agentId] = info + (tabs.getTabLabel(info) as? javax.swing.JComponent)?.toolTipText = item.tooltip + tabOf[item.id] = info } tabOf[keepSelected]?.let { tabs.select(it, false) } } finally { rebuilding = false } + isVisible = items.isNotEmpty() revalidate() repaint() } + /** + * Sets the tree branch drawn before the row's name, e.g. `├─ ` or `│ └─ `. + * + * Computed by the owner rather than fixed here, because a branch depends on which rows are **currently + * visible** — the last one drawn ends the tree with `└─`, and rows come and go as agents spawn and + * finish. A row that decided its own branch would draw a `├─` pointing at nothing. + */ + fun setBranch(branch: String) { + header.text = branch + title + } + + /** Convenience for the agent rows: [Item]s built from the registry's nodes. */ + fun renderAgents(nodes: List, relativeDepth: Int = 1) = render( + nodes.map { + Item(it.agentId, AgentTabLabels.tab(it, relativeDepth), AgentTabLabels.tooltip(it)) + }, + ) + /** Selects the tab of [agentId], transferring focus like a manual click. No-op when it is not there. */ fun select(agentId: String) { tabOf[agentId]?.let { tabs.select(it, true) } diff --git a/src/main/kotlin/dev/lain/claudejb/ui/AgentTabsPanel.kt b/src/main/kotlin/dev/lain/claudejb/ui/AgentTabsPanel.kt index 7a95a0d7..21b180d0 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/AgentTabsPanel.kt +++ b/src/main/kotlin/dev/lain/claudejb/ui/AgentTabsPanel.kt @@ -3,31 +3,46 @@ package dev.lain.claudejb.ui import com.intellij.openapi.Disposable import com.intellij.openapi.project.Project import com.intellij.ui.components.JBPanel -import dev.lain.claudejb.session.AgentNode +import dev.lain.claudejb.protocol.BackgroundTaskInfo import dev.lain.claudejb.session.AgentRegistry import java.awt.BorderLayout import javax.swing.BoxLayout /** - * The two agent rows under the chat tabs: `Agents`, then `Subagents`. + * The rows under a chat's tab, as a **stack of levels** rather than a fixed pair of rows. * - * The rule the whole thing follows is "each row shows the children of the row above's selection": - * - `Agents` shows the agents of the **selected chat**, so switching chat swaps the row. - * - `Subagents` shows the agents spawned by the **selected agent**, so it changes as you move along the row - * above, and an agent that spawned nothing leaves it empty. + * Drilling down is the whole point: a chat spawns agents, an agent spawns agents of its own, and so can + * each of those. So every level you select opens the level below it — * - * Both rows are **always visible**, empty or not, so the transcript below never jumps up and down as agents - * come and go — on a session that spawns them constantly, a row that appears and disappears is worse than an - * empty one. + * ``` + * Chat 1 + * ├─ Agents [ A ][ B ] ← agents of the chat + * ├─ Background tasks [ npm run dev ] ← background tasks of the chat + * │ ├─ Subagents [ A1 ][ A2 ] ← agents of A, because A is selected + * │ └─ Background tasks [ tail -f log ] ← background tasks of A + * │ └─ Subagents [ A1a ] ← agents of A1, because A1 is selected + * ``` * - * Selection reports **which transcript to paint** ([onShowTranscript]): the chat's own transcript when - * nothing is selected in either row, otherwise that agent's. One browser, many transcripts — see - * [AgentStripPanel]. + * — and selecting elsewhere collapses everything below it. A fixed "Agents + Subagents" pair could only ever + * show two levels of a tree the protocol does not bound, and it put background tasks nowhere except the top. + * + * **A row is drawn only when it has something in it**, so the stack is exactly as tall as the work is deep. + * Standing empty rows turned every fresh chat into bars asking a question nobody asked. */ -internal class AgentTabsPanel(project: Project, parent: Disposable) : JBPanel(BorderLayout()) { +internal class AgentTabsPanel( + private val project: Project, + private val parent: Disposable, +) : JBPanel(BorderLayout()) { + + /** One level of the drill-down: the agents hanging off [parentId], and the background tasks that do. */ + private class Level( + val parentId: String?, + val agents: AgentStripPanel, + val background: AgentStripPanel, + ) - private val agents = AgentStripPanel(project, parent, "Agents") - private val subagents = AgentStripPanel(project, parent, "Subagents") + private val rows = JBPanel>().apply { layout = BoxLayout(this, BoxLayout.Y_AXIS) } + private val levels = ArrayList() /** Told which agent's transcript to show — null means the chat's own. */ var onShowTranscript: (String?) -> Unit = {} @@ -35,77 +50,171 @@ internal class AgentTabsPanel(project: Project, parent: Disposable) : JBPanel Unit = {} - /** The registry currently rendered, so a selection can re-derive the children rows. */ private var registry: AgentRegistry? = null + private var tasks: List = emptyList() + private var ownerOfTask: (String) -> String? = { null } + private var hidden: Set = emptySet() init { - val rows = JBPanel>().apply { layout = BoxLayout(this, BoxLayout.Y_AXIS) } - rows.add(agents) - rows.add(subagents) add(rows, BorderLayout.CENTER) + } - agents.onEvents( + /** + * Re-renders the stack. + * + * [hiddenAgents] are tabs the user closed — hidden, not deleted: the transcript is the binary's file and + * the card in the main transcript reopens it. [ownerOf] maps a background task to the agent running it, + * when that is knowable at all; `background_tasks_changed` carries no parent, so it often is not, and + * those tasks stay at the chat's level rather than being guessed into someone's row. + */ + fun render( + registry: AgentRegistry, + backgroundTasks: List, + hiddenAgents: Set = emptySet(), + ownerOf: (String) -> String? = { null }, + ) { + this.registry = registry + this.tasks = backgroundTasks + this.ownerOfTask = ownerOf + this.hidden = hiddenAgents + if (levels.isEmpty()) levels += newLevel(null) + // A level whose agent is gone (finished and closed, or never ours) takes its descendants with it. + while (levels.size > 1 && levels.last().parentId?.let { registry.nodes.containsKey(it) } == false) { + dropLevelsBelow(levels.size - 2) + } + levels.forEach(::fill) + drawBranches() + } + + /** Opens (or re-selects) [agentId]'s tab, expanding the levels needed to reach it. */ + fun reveal(agentId: String) { + val reg = registry ?: return + val chain = ancestryOf(agentId, reg) ?: return + // Walk down from the chat, selecting each ancestor so its level exists before reaching for the next. + chain.forEachIndexed { index, id -> + val level = levels.getOrNull(index) ?: return + level.agents.select(id) + if (index < chain.lastIndex) openLevelFor(index, id) + } + } + + /** Two orange pulses on whichever level's row carries [agentId]. */ + fun blink(agentId: String) { + levels.firstOrNull { it.agents.has(agentId) }?.agents?.blink(agentId) + } + + // ── levels ─────────────────────────────────────────────────────────────────────────────────────────── + + private fun newLevel(parentId: String?): Level { + // The first level's agents hang off the chat, so it is "Agents"; every level below hangs off an + // agent, so it is "Subagents" — the word the user reads should say what the row is relative to. + val agentsRow = AgentStripPanel(project, parent, if (parentId == null) "Agents" else "Subagents") + val backgroundRow = AgentStripPanel(project, parent, "Background tasks") + val level = Level(parentId, agentsRow, backgroundRow) + agentsRow.onEvents( selected = { id -> - renderSubagentsOf(id) - onShowTranscript(id) + val index = levels.indexOf(level) + if (id == null) { + dropLevelsBelow(index) + } else { + openLevelFor(index, id) + } + onShowTranscript(id ?: level.parentId) }, closed = { id -> onTabClosed(id) - // Its children have no row to live in once the parent is gone, and the transcript falls back - // to the chat's own — otherwise the browser would keep painting a tab that is not there. - renderSubagentsOf(agents.selectedAgentId) - onShowTranscript(agents.selectedAgentId) + dropLevelsBelow(levels.indexOf(level)) + onShowTranscript(level.parentId) }, ) - subagents.onEvents( - selected = { id -> onShowTranscript(id ?: agents.selectedAgentId) }, - closed = { id -> - onTabClosed(id) - onShowTranscript(subagents.selectedAgentId ?: agents.selectedAgentId) + // A background task has no transcript of its own, so its tab is a POINTER: it shows the transcript of + // whoever runs it — the owning agent when the binary let us work that out, else this level's owner. + backgroundRow.onEvents( + selected = { taskId -> onShowTranscript(taskId?.let(ownerOfTask) ?: level.parentId) }, + closed = { }, + ) + rows.add(agentsRow) + rows.add(backgroundRow) + return level + } + + /** Ensures the level below [index] exists and belongs to [agentId], dropping whatever was there. */ + private fun openLevelFor(index: Int, agentId: String) { + if (levels.getOrNull(index + 1)?.parentId == agentId) { + fill(levels[index + 1]) + drawBranches() + return + } + dropLevelsBelow(index) + val level = newLevel(agentId) + levels += level + fill(level) + drawBranches() + } + + /** Removes every level deeper than [index] — selecting elsewhere collapses the drill-down below it. */ + private fun dropLevelsBelow(index: Int) { + while (levels.size > index + 1) { + val dropped = levels.removeAt(levels.size - 1) + rows.remove(dropped.agents) + rows.remove(dropped.background) + } + rows.revalidate() + rows.repaint() + } + + private fun fill(level: Level) { + val reg = registry ?: return + level.agents.renderAgents(reg.children(level.parentId).filterNot { it.agentId in hidden }) + val mine = tasks.filter { ownerOfTask(it.taskId) == level.parentId } + level.background.render( + mine.map { + AgentStripPanel.Item( + id = it.taskId, + label = it.description.ifBlank { it.taskType }, + tooltip = "${it.description} · ${it.taskType}", + // Not closable: the plugin does not own a background task's lifetime. Stopping one is a + // deliberate act with its own button in the dashboard, not a tab close. + closable = false, + ) }, ) } /** - * Re-renders both rows from [registry], hiding the agents whose tab the user closed ([hidden]). + * Draws the visible rows the way `tree` draws a directory: `├─` while more rows follow at that level, + * `└─` for the last, and `│` continuing the trunk past every level already opened. * - * A closed tab is not a deleted agent: its transcript is the binary's file and its card is still in the - * main transcript, which is how it comes back. This method only stops drawing it. + * Recomputed on every render because it depends on which rows are visible **right now** — rows appear + * and vanish as agents spawn and finish, and a `├─` pointing at a row that is no longer drawn is worse + * than no tree at all. */ - fun render(registry: AgentRegistry, hidden: Set = emptySet()) { - this.registry = registry - val roots = registry.children(null).filterNot { it.agentId in hidden } - agents.render(roots) - renderSubagentsOf(agents.selectedAgentId, hidden) - } - - /** Opens (or re-selects) [agentId]'s tab, wherever in the tree it sits. Used by the transcript card. */ - fun reveal(agentId: String) { - val reg = registry ?: return - val node = reg.nodes[agentId] ?: return - val parent = node.parentAgentId - if (parent == null) { - agents.select(agentId) - return + private fun drawBranches() { + val visible = levels.flatMap { listOf(it.agents, it.background) }.filter { it.isVisible } + visible.forEachIndexed { i, row -> + val depth = levels.indexOfFirst { it.agents === row || it.background === row } + val connector = if (i == visible.lastIndex) LAST else FORK + row.setBranch(TRUNK.repeat(depth) + connector) } - // A subagent: select its parent first so the Subagents row is showing the right family, then it. - agents.select(parent) - renderSubagentsOf(parent) - subagents.select(agentId) } - /** Two orange pulses on whichever row now carries [agentId]. */ - fun blink(agentId: String) { - if (agents.has(agentId)) agents.blink(agentId) else subagents.blink(agentId) + /** The chain of agent ids from the chat down to [agentId], or null when it is not in the tree. */ + private fun ancestryOf(agentId: String, reg: AgentRegistry): List? { + val chain = ArrayDeque() + val seen = HashSet() + var current: String? = agentId + while (current != null && seen.add(current)) { + val node = reg.nodes[current] ?: return null + chain.addFirst(node.agentId) + current = node.parentAgentId + } + return chain.toList() } - private fun renderSubagentsOf(parentId: String?, hidden: Set = emptySet()) { - val reg = registry - val children: List = if (reg == null || parentId == null) { - emptyList() - } else { - reg.children(parentId).filterNot { it.agentId in hidden } - } - subagents.render(children) + private companion object { + /** `tree`'s own glyphs: a fork, a last child, and the trunk that passes an opened level. */ + const val FORK = "├─ " + const val LAST = "└─ " + const val TRUNK = "│ " } } diff --git a/src/main/kotlin/dev/lain/claudejb/ui/JcefChatPanel.kt b/src/main/kotlin/dev/lain/claudejb/ui/JcefChatPanel.kt index acd6bf41..62df3d0f 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/JcefChatPanel.kt +++ b/src/main/kotlin/dev/lain/claudejb/ui/JcefChatPanel.kt @@ -302,8 +302,24 @@ class JcefChatPanel(private val project: Project, val session: ClaudeSession) : pushSession() } - /** Repaints both rows from the registry, minus whatever the user has closed. */ - private fun renderAgentRows() = agentTabs.render(session.runningAgents, hiddenAgents) + /** + * Repaints the row stack from the registry, minus whatever the user has closed. + * + * The owner of a background task is resolved through the edge stream: `background_tasks_changed` carries + * no parent, but the same `task_id` seen earlier as a subagent task does carry the `tool_use_id` that + * names an agent. When that lookup fails the task stays at the chat's level rather than being guessed + * into somebody's row. + */ + private fun renderAgentRows() = agentTabs.render( + registry = session.runningAgents, + backgroundTasks = session.backgroundTasks, + hiddenAgents = hiddenAgents, + ownerOf = { taskId -> + session.subagentTasks[taskId]?.toolUseId?.let { tool -> + session.runningAgents.nodes.values.firstOrNull { it.meta.toolUseId == tool }?.agentId + } + }, + ) /** * A scan finished. Repaint the rows, then blink the tabs of agents seen for the first time and raise ONE @@ -373,6 +389,10 @@ class JcefChatPanel(private val project: Project, val session: ClaudeSession) : override fun onStateChanged() { pushMetaState() pushSession() + // Background tasks arrive on this path (`background_tasks_changed` is a level signal that fires + // state), not through the agent scan — so their rows would otherwise only refresh when an agent + // happened to change. + renderAgentRows() drainPendingUntilReady() // A RESTART is a new process, so everything that is only asked once per process has to be asked // again. [whenReady] fires once in the constructor and never again, so after a sign-out/sign-in the diff --git a/src/main/resources/jcef/app-session.js b/src/main/resources/jcef/app-session.js index f192f321..4f1c9265 100644 --- a/src/main/resources/jcef/app-session.js +++ b/src/main/resources/jcef/app-session.js @@ -78,7 +78,11 @@ // `wide` cards span the whole grid row (.dash-card.wide { grid-column: 1 / -1 }). Use it for anything with rows // that need horizontal room — the context legend, and the server/task lists whose name column would otherwise // collapse to an ellipsis inside a 260px column. - function card(title, body, wide) { + /** + * A dashboard card. [anchor], when given, tags the card so a view button can scroll straight to it — + * the four buttons are views of one panel, not four panels, so navigation is a scroll, not a rebuild. + */ + function card(title, body, wide, anchor) { // body may be a node, an array of nodes, or empty. Hide when nothing renders. var children = []; if (Array.isArray(body)) { @@ -90,7 +94,9 @@ } if (!children.length) return null; var head = h('div', { class: 'dash-title', text: title }); - return h('div', { class: 'dash-card' + (wide ? ' wide' : '') }, head, children); + var props = { class: 'dash-card' + (wide ? ' wide' : '') }; + if (anchor) props.attrs = { 'data-card': anchor }; + return h('div', props, head, children); } // --------------------------------------------------------------------------- @@ -357,7 +363,7 @@ return a && !a.parent; }); if (!roots.length) return null; - return card('Agents', roots.map(agentRow), true); + return card('Agents', roots.map(agentRow), true, 'agents'); } /** Agents spawned BY another agent, at any depth — the window that answers "who launched this?". */ @@ -367,7 +373,7 @@ return a && a.parent; }); if (!nested.length) return null; - return card('Subagents', nested.map(agentRow), true); + return card('Subagents', nested.map(agentRow), true, 'subagents'); } // Live background tasks, from the `background_tasks_changed` LEVEL signal: the host always sends the CURRENT @@ -428,7 +434,7 @@ ); rows.push(row); } - return card('Background tasks', rows, true); + return card('Background tasks', rows, true, 'background'); } // status → mcp-dot class. Defensive: unknown maps to nothing extra. @@ -587,6 +593,53 @@ } // --------------------------------------------------------------------------- + /** + * One of the four view buttons. [anchor] is the card to scroll to once the panel is open (null = the top, + * i.e. the Session view). Opening an already-open panel on the same button closes it, so a button toggles + * its own view rather than trapping the user in the dashboard. + */ + function viewButton(label, anchor) { + return h('button', { + class: 'dash-toggle', + attrs: { type: 'button', 'data-anchor': anchor || 'session' }, + text: label, + on: { + click: function (ev) { + ev.preventDefault(); + if (shown && currentAnchor === (anchor || 'session')) { + toggle(); + return; + } + currentAnchor = anchor || 'session'; + if (!shown) toggle(); + scrollToAnchor(); + markActiveButton(); + }, + }, + }); + } + + /** Which view the last button press asked for; drives the scroll and the active-button highlight. */ + var currentAnchor = 'session'; + + function scrollToAnchor() { + if (!panel) return; + if (currentAnchor === 'session') { + panel.scrollTop = 0; + return; + } + var card = panel.querySelector('[data-card="' + currentAnchor + '"]'); + if (card && card.scrollIntoView) card.scrollIntoView({ block: 'start' }); + } + + function markActiveButton() { + var all = document.querySelectorAll('.dash-toggle'); + for (var i = 0; i < all.length; i++) { + var isActive = shown && all[i].getAttribute('data-anchor') === currentAnchor; + all[i].classList.toggle('active', isActive); + } + } + // Build the toggle + panel once. Idempotent. // --------------------------------------------------------------------------- function build() { @@ -606,18 +659,19 @@ root.appendChild(panel); } - toggleBtn = h('button', { - class: 'dash-toggle', - attrs: { type: 'button' }, - text: 'Session', - on: { - click: function (ev) { - ev.preventDefault(); - toggle(); - }, - }, - }); - root.appendChild(toggleBtn); + // Four buttons, stacked: Session, then the three windows the agent work moved into. Each opens the + // dashboard scrolled to its own card, so they are views of one panel rather than four panels -- the + // data is the same payload and splitting it would mean four things to keep in sync. + toggleBtn = viewButton('Session', null); + var stack = h( + 'div', + { class: 'dash-toggles' }, + toggleBtn, + viewButton('Agents', 'agents'), + viewButton('Subagents', 'subagents'), + viewButton('Background tasks', 'background') + ); + root.appendChild(stack); applyVisibility(); render(); @@ -631,6 +685,8 @@ panel.classList.add('open'); // Hide the transcript while the dashboard fills the conversation area — the dock (composer) stays visible. if (conv) conv.setAttribute('hidden', ''); + // The first button doubles as the way OUT: with the panel open it reads "Chat". The other three keep + // their names and only light up, so the stack always says both where you are and how to leave. toggleBtn.textContent = 'Chat'; toggleBtn.classList.add('active'); } else { @@ -640,6 +696,7 @@ toggleBtn.textContent = 'Session'; toggleBtn.classList.remove('active'); } + markActiveButton(); } function toggle() { diff --git a/src/main/resources/jcef/app.css b/src/main/resources/jcef/app.css index 0f0f4da8..b4304a96 100644 --- a/src/main/resources/jcef/app.css +++ b/src/main/resources/jcef/app.css @@ -2095,10 +2095,20 @@ mark.cc-hit.active { SESSION DASHBOARD — overlay panel + cards (Sprint 2) ════════════════════════════════════════════════════════════════════════════ */ /* the toggle pill, fixed top-right of the view */ -.dash-toggle { +/* The four view buttons, stacked top-right: Session, Agents, Subagents, Background tasks. The stack is what + is fixed to the viewport now; each button is an ordinary element inside it, so adding or removing one + cannot leave the others overlapping the way four independently-positioned buttons would. */ +.dash-toggles { position: fixed; top: 12px; right: 14px; + z-index: 90; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 5px; +} +.dash-toggle { z-index: 90; display: inline-flex; align-items: center; @@ -2122,6 +2132,13 @@ mark.cc-hit.active { box-shadow 0.14s, background 0.14s; } +/* The view you are looking at. Without this the four buttons say nothing about where the panel is scrolled, + which is the one question a stack of view buttons has to answer. */ +.dash-toggle.active { + color: var(--text); + border-color: color-mix(in srgb, var(--accent) 70%, var(--border)); + background: var(--surface2); +} .dash-toggle:hover { color: var(--text); border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); From 947e930aa3a25ca6e44cfb107b05cf59650a623a Mon Sep 17 00:00:00 2001 From: Lain Date: Tue, 11 Aug 2026 01:35:20 +0200 Subject: [PATCH 010/141] refactor(dashboard): drop agent data from the Session view The Session view no longer carries agents at all. Its `subagents` list was built from the task event stream, while the new Agents / Subagents windows read the real tree from the binary's per-agent sidecars -- two views of the same agents, from different sources, which is precisely how they end up disagreeing on screen. Session keeps what is genuinely about the session: usage, context, cost, account, environment and MCP. Agents, subagents and background tasks live in their own windows, each row carrying the chain it hangs off. ClaudeSession.subagentTasks stays in use -- it is what resolves a background task's owning agent, since background_tasks_changed carries no parent -- but it is no longer something the dashboard draws. --- .../lain/claudejb/ui/jcef/JcefSessionData.kt | 36 +++++++++---------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefSessionData.kt b/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefSessionData.kt index 1786946b..c80e0672 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefSessionData.kt +++ b/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefSessionData.kt @@ -23,15 +23,15 @@ import kotlinx.serialization.json.put * context: { categories:[{name, tokens}], used, max, pct } | null, * cost: { usd:Number|null, input, output, cacheWrite, cacheRead } | null, * account: { email, org, plan, provider } | null, - * subagents:[{ id, desc, type, status, tokens, tools }], - * backgroundTasks:[{ id, desc, type }], + * agentTree:[{ agentId, label, type, status, depth, parent, chain, running }], + * backgroundTasks:[{ id, desc, type, agentId|null, chain }], * model: String|null, * cwd: String|null, * version: String|null * } * * - * Every card is null-safe: absent data emits JSON `null` (objects) or `[]` (subagents/backgroundTasks). The + * Every card is null-safe: absent data emits JSON `null` (objects) or `[]` (agentTree/backgroundTasks). The * dashboard frontend hides any card whose data is null/empty, so a partially-populated session renders cleanly. * * Sources: @@ -39,9 +39,11 @@ import kotlinx.serialization.json.put * - cost ← [ClaudeSession.lastSessionCost] (raw `get_session_cost` JsonObject); the per-component token * tally is decoded from an `apiUsage` block when present and the USD figure from a cost field; * - account ← [ClaudeSession.account]; - * - subagents← [ClaudeSession.subagentTasks] (edge-derived: task_started/progress/updated/notification); - * - backgroundTasks ← [ClaudeSession.backgroundTasks] (the `background_tasks_changed` LEVEL signal — always the - * current set, so it cannot wedge on a missed edge; deliberately NOT correlated with `subagents`); + * - agentTree← [ClaudeSession.runningAgents] (the binary's own per-agent sidecars: parentage, depth and the + * model-written label, so the Agents/Subagents windows draw a tree rather than a flat task list); + * - backgroundTasks ← [ClaudeSession.backgroundTasks] (the `background_tasks_changed` LEVEL signal — always + * the current set, so it cannot wedge on a missed edge). Its owning agent is resolved through + * [ClaudeSession.subagentTasks] when the same task_id was seen there, and left unclaimed when it was not; * - model ← [ClaudeSession.model]; * - cwd/version: [ClaudeSession] exposes no synchronous getter for either (cwd arrives only ephemerally on * the `system/init` event and the binary version only via an async control request), so both are emitted @@ -59,7 +61,9 @@ object JcefSessionData { put("context", contextJson(session) ?: JsonNull) put("cost", costJson(session) ?: JsonNull) put("account", accountJson(session) ?: JsonNull) - put("subagents", subagentsJson(session)) + // NB no `subagents` key any more. It was the edge-derived task list, and the Agents / Subagents + // windows replaced it with the real tree (`agentTree`) — two lists of the same thing, built from + // different sources, is how they end up disagreeing on screen. put("backgroundTasks", backgroundTasksJson(session)) // The tree behind the Agents / Subagents windows: every agent with the chain it hangs off, so a // row can say "Chat |_ Agent A |_ Agent B" and link straight to that tab. @@ -313,19 +317,11 @@ object JcefSessionData { return parts.joinToString(" |_ ") } - /** One row per subagent task: `{ id, desc, type, status, tokens, tools }`; empty array when none. */ - private fun subagentsJson(session: ClaudeSession) = buildJsonArray { - session.subagentTasks.values.forEach { task -> - addJsonObject { - put("id", task.taskId) - put("desc", task.description) - put("type", task.subagentType) - put("status", task.status) - put("tokens", task.usage.totalTokens) - put("tools", task.usage.toolUses) - } - } - } + // NB `subagentsJson` lived here until 5.5.0. The Session view no longer carries agent data at all: the + // Agents / Subagents windows read the real tree from the binary's per-agent files, and keeping a second + // list built from the task event stream would have meant two views of the same agents that can disagree. + // `ClaudeSession.subagentTasks` is still used — it is what resolves a background task's owning agent — + // but it is no longer a thing the dashboard draws. /** * One row per live background task: `{ id, desc, type }`; empty array when none. Sourced from the From a00186ac45d8d8fc1ed6a12014c07ee407bdccc2 Mon Sep 17 00:00:00 2001 From: Lain Date: Tue, 11 Aug 2026 10:11:11 +0200 Subject: [PATCH 011/141] fix(restore): the binary's synthetic lines are not the user speaking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A restored transcript put `` blocks, caveats and command output in the user's own voice: `parseUser` mapped every `text` block of a `user` line to an `EntryDTO("USER", …)`, and the binary writes all of that on `user` lines. The wire already distinguishes them — `isMeta`, `isCompactSummary`, `isVisibleInTranscriptOnly`, `isSidechain` and the `toolUseResult` object — and none of it was read. `SyntheticUserText` is that one predicate, tested on its own, so the rule lives in a single place instead of being re-derived per call site. --- .../session/SessionTranscriptReader.kt | 92 ++++++++++++++- .../claudejb/session/SyntheticUserText.kt | 105 ++++++++++++++++++ .../SessionTranscriptReaderParseTest.kt | 32 ++++++ .../claudejb/session/SyntheticUserTextTest.kt | 103 +++++++++++++++++ 4 files changed, 327 insertions(+), 5 deletions(-) create mode 100644 src/main/kotlin/dev/lain/claudejb/session/SyntheticUserText.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/session/SyntheticUserTextTest.kt diff --git a/src/main/kotlin/dev/lain/claudejb/session/SessionTranscriptReader.kt b/src/main/kotlin/dev/lain/claudejb/session/SessionTranscriptReader.kt index da54032e..8d464fa0 100644 --- a/src/main/kotlin/dev/lain/claudejb/session/SessionTranscriptReader.kt +++ b/src/main/kotlin/dev/lain/claudejb/session/SessionTranscriptReader.kt @@ -30,6 +30,23 @@ data class EntryDTO( /** For a command-executing tool: the raw command, so a restored card renders the same copyable code block a * live one does (see [dev.lain.claudejb.permission.SensitiveGuard.commandText]). Null on every other row. */ val commandText: String? = null, + /** + * A tool call that has no result yet — it was still in flight when this transcript was read. + * + * The binary writes the `tool_use` when the call starts and its `tool_result` when it comes back, so a + * call with no matching result is simply still running. Without this every reconstructed card was drawn + * as FINISHED, so a Bash the agent is running RIGHT NOW sat there green and still instead of fading like + * its live counterpart. + */ + val inFlight: Boolean = false, + /** + * The call came back as an error. + * + * The JSONL marks the failure on the RESULT row, not on the call, so a reconstructed card had no way to + * know: a Bash that exited non-zero was drawn green like any other finished call, and the red header the + * live transcript gives it never appeared. + */ + val failed: Boolean = false, ) /** @@ -97,7 +114,38 @@ object SessionTranscriptReader { } } } - return capTail(tagCommandOutputs(out), maxEntries) + return capTail(markInFlight(tagCommandOutputs(out)), maxEntries) + } + + /** + * Marks every TOOL call that has no TOOL_OUTPUT as still running. + * + * The binary writes the `tool_use` when a call starts and its `tool_result` when it returns, so a call + * with no matching result was in flight at the moment this file was read. Nothing else in the transcript + * says so, which is why a reconstructed card used to be drawn FINISHED unconditionally: a Bash an agent + * was running RIGHT NOW came back green and still, while the identical card in the live chat faded. + * + * A pass over the finished list for the same reason [tagCommandOutputs] is one — the result arrives in a + * later message than the call, so at parse time the call's own line cannot know. + */ + private fun markInFlight(entries: List): List { + val answered = HashSet() + val failed = HashSet() + for (e in entries) { + if (e.speaker != "TOOL_OUTPUT") continue + val id = e.toolUseId ?: continue + answered += id + // The failure is recorded on the RESULT ("error" in its meta), so the CALL has to be told. + if (e.meta != null && e.meta.contains("error")) failed += id + } + return entries.map { e -> + if (e.speaker != "TOOL" || e.toolUseId == null) return@map e + when { + e.toolUseId in failed -> e.copy(failed = true) + e.toolUseId !in answered -> e.copy(inFlight = true) + else -> e + } + } } /** @@ -143,17 +191,51 @@ object SessionTranscriptReader { private fun parseUser(obj: JsonObject, out: MutableList) { val content = (obj["message"] as? JsonObject)?.get("content") ?: return + // The line's own flags, which the binary sets on the scaffolding it injects. `isCompactSummary` + // marks the summary a compaction leaves behind: real content, but not something the user said. + val isMeta = obj["isMeta"]?.jsonPrimitive?.booleanOrNull == true + val isCompactSummary = obj["isCompactSummary"]?.jsonPrimitive?.booleanOrNull == true when (content) { - is JsonPrimitive -> content.contentOrNull?.takeIf { it.isNotBlank() }?.let { out += EntryDTO("USER", it) } - is JsonArray -> content.mapNotNull { it as? JsonObject }.forEach { parseUserBlock(it, out) } + is JsonPrimitive -> content.contentOrNull?.let { addUserText(it, isMeta, isCompactSummary, out) } + + is JsonArray -> content.mapNotNull { it as? JsonObject } + .forEach { parseUserBlock(it, isMeta, isCompactSummary, out) } + else -> Unit } } + /** + * Turns one `text` block of a `user` line into the row it really is. + * + * **Not every `text` block on a `user` line is the user.** The binary records its own scaffolding there — + * the local-command caveat, the slash command the user ran, a settled subagent's notification — and + * restoring them verbatim, styled as prompts, showed people paragraphs they had never written. See + * [SyntheticUserText] for the closed tag set and why it is closed. + */ + private fun addUserText(text: String, isMeta: Boolean, isCompactSummary: Boolean, out: MutableList) { + if (isCompactSummary) { + // The live path narrates a compaction as a system row; a restored one says the same thing. + out += EntryDTO("SYSTEM", "Conversation compacted.") + return + } + when (val kind = SyntheticUserText.classify(text, isMeta)) { + is SyntheticUserText.Kind.Prompt -> out += EntryDTO("USER", kind.text) + is SyntheticUserText.Kind.Command -> out += EntryDTO("USER", kind.text) + is SyntheticUserText.Kind.SystemNote -> out += EntryDTO("SYSTEM", kind.text) + SyntheticUserText.Kind.Hidden -> Unit + } + } + /** One content block of a `user` line: the user's own text, or a tool_result the binary attributed to them. */ - private fun parseUserBlock(block: JsonObject, out: MutableList) { + private fun parseUserBlock( + block: JsonObject, + isMeta: Boolean, + isCompactSummary: Boolean, + out: MutableList, + ) { when (block["type"]?.jsonPrimitive?.contentOrNull) { - "text" -> block.text()?.takeIf { it.isNotBlank() }?.let { out += EntryDTO("USER", it) } + "text" -> block.text()?.let { addUserText(it, isMeta, isCompactSummary, out) } "tool_result" -> { val text = toolResultText(block["content"]) diff --git a/src/main/kotlin/dev/lain/claudejb/session/SyntheticUserText.kt b/src/main/kotlin/dev/lain/claudejb/session/SyntheticUserText.kt new file mode 100644 index 00000000..c8dfbbd2 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/session/SyntheticUserText.kt @@ -0,0 +1,105 @@ +package dev.lain.claudejb.session + +/** + * Tells the user's own words apart from the binary's, inside a `user` line of a session transcript. + * + * **The bug this exists for.** Restoring a conversation showed, attributed to the user and styled as their + * prompt, things they never typed: `Caveat: The messages below were generated by the + * user while running local commands. DO NOT respond to these…`, `/compact`, + * `…`. They arrive as ordinary `text` blocks on a `user` line, so the reader — which + * mapped every such block to a USER row — could not tell them apart. Reading your own transcript back and + * finding paragraphs you never wrote is the kind of defect that makes the whole view untrustworthy. + * + * **The tag set is closed, and deliberately so.** Only the wrappers the binary is known to emit are treated + * as synthetic (inventoried from real transcripts: `task-notification`, `local-command-caveat`, + * `command-name`, `command-message`, `local-command-stdout`). A generic "anything starting with a tag is not + * the user" rule would silently eat a prompt that legitimately starts with XML — and someone pasting markup + * into a chat about markup is not an edge case here. + * + * Pure and side-effect free, so the rules are testable without a session, a file or an IDE. + */ +internal object SyntheticUserText { + + /** What a `user` line's text block turns out to be. */ + sealed interface Kind { + /** The user's own words: rendered as their prompt, unchanged. */ + data class Prompt(val text: String) : Kind + + /** A slash command they ran (`/compact`): still theirs, shown the way they typed it. */ + data class Command(val text: String) : Kind + + /** The binary talking: rendered as a system note, the same as the live path does. */ + data class SystemNote(val text: String) : Kind + + /** Internal scaffolding with nothing to show — dropped. */ + data object Hidden : Kind + } + + /** + * Classifies one `text` block. [isMeta] is the line's own `isMeta` flag, which the binary sets on the + * scaffolding it injects; it is honoured because it is the binary's own statement about the line, and it + * catches wrappers this list has not met yet. + */ + fun classify(text: String, isMeta: Boolean = false): Kind { + val body = text.trim() + if (body.isEmpty()) return Kind.Hidden + val tag = leadingTag(body) + return when { + // A `/compact` the user ran: their action, so it stays theirs — shown as the command they typed + // rather than as the three XML blocks the binary records it in. + tag == COMMAND_NAME || tag == COMMAND_MESSAGE -> commandOf(body)?.let(Kind::Command) ?: Kind.Hidden + + // The binary's own voice. The live path renders these as system notices, so a restored + // transcript renders them the same way instead of putting them in the user's mouth. + tag == LOCAL_COMMAND_STDOUT -> inner(body, LOCAL_COMMAND_STDOUT) + ?.let(Kind::SystemNote) ?: Kind.Hidden + + tag == TASK_NOTIFICATION -> taskNotice(body)?.let(Kind::SystemNote) ?: Kind.Hidden + + // An instruction aimed at the model, never at the reader. + tag == LOCAL_COMMAND_CAVEAT -> Kind.Hidden + + // The binary flagged the line as its own scaffolding; believe it. + isMeta -> Kind.Hidden + + else -> Kind.Prompt(body) + } + } + + /** `/compact … x` → `/compact x`. */ + private fun commandOf(body: String): String? { + val name = inner(body, COMMAND_NAME) ?: inner(body, COMMAND_MESSAGE) ?: return null + val args = inner(body, COMMAND_ARGS).orEmpty() + val command = if (name.startsWith("/")) name else "/$name" + return listOf(command, args).filter { it.isNotBlank() }.joinToString(" ") + } + + /** + * A settled subagent, condensed to its summary — the same shape the live path shows. + * + * The raw block carries ids, an output path and a note aimed at the model; none of that is a sentence a + * human wants in their transcript, and the summary is the one line that says what happened. + */ + private fun taskNotice(body: String): String? { + val summary = inner(body, "summary")?.takeIf { it.isNotBlank() } ?: return null + val status = inner(body, "status")?.takeIf { it.isNotBlank() } + return if (status == null) summary else "Subagent $status: $summary" + } + + /** The first `` of [body], or null when it does not start with one. */ + private fun leadingTag(body: String): String? = + LEADING_TAG.find(body)?.groupValues?.get(1) + + /** The text inside `…`, trimmed; null when the tag is absent. */ + private fun inner(body: String, tag: String): String? = + Regex("<$tag>(.*?)", RegexOption.DOT_MATCHES_ALL).find(body)?.groupValues?.get(1)?.trim() + + private val LEADING_TAG = Regex("^<([a-z][a-z0-9-]*)>") + + private const val TASK_NOTIFICATION = "task-notification" + private const val LOCAL_COMMAND_CAVEAT = "local-command-caveat" + private const val LOCAL_COMMAND_STDOUT = "local-command-stdout" + private const val COMMAND_NAME = "command-name" + private const val COMMAND_MESSAGE = "command-message" + private const val COMMAND_ARGS = "command-args" +} diff --git a/src/test/kotlin/dev/lain/claudejb/session/SessionTranscriptReaderParseTest.kt b/src/test/kotlin/dev/lain/claudejb/session/SessionTranscriptReaderParseTest.kt index 58826933..10645138 100644 --- a/src/test/kotlin/dev/lain/claudejb/session/SessionTranscriptReaderParseTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/session/SessionTranscriptReaderParseTest.kt @@ -49,6 +49,38 @@ class SessionTranscriptReaderParseTest { assertEquals(listOf("THINKING" to "weighing it up", "ASSISTANT" to "the answer"), entries.map { it.speaker to it.text }) } + /** + * A restored card knows whether its call is STILL RUNNING or FAILED — the two states the JSONL records + * on the result row rather than on the call. + * + * Without this every reconstructed card was FINISHED: a Bash an agent was running right now came back + * green and still instead of fading like its live twin, and one that had exited in error came back green + * too, with the failure visible only if you expanded its output. + */ + @Test + fun `a call with no result is in flight, and one with a failed result is marked failed`() { + val entries = SessionTranscriptReader.parseEntries( + listOf( + """{"type":"assistant","message":{"role":"assistant","content":[ + {"type":"tool_use","id":"live","name":"Bash","input":{"command":"sleep 60"}}]}}""".replace("\n", ""), + """{"type":"assistant","message":{"role":"assistant","content":[ + {"type":"tool_use","id":"boom","name":"Bash","input":{"command":"false"}}]}}""".replace("\n", ""), + """{"type":"user","message":{"role":"user","content":[ + {"type":"tool_result","tool_use_id":"boom","is_error":true,"content":"exit 1"}]}}""".replace("\n", ""), + """{"type":"assistant","message":{"role":"assistant","content":[ + {"type":"tool_use","id":"ok","name":"Read","input":{"file_path":"/tmp/x"}}]}}""".replace("\n", ""), + """{"type":"user","message":{"role":"user","content":[ + {"type":"tool_result","tool_use_id":"ok","content":"contents"}]}}""".replace("\n", ""), + ), + ) + val calls = entries.filter { it.speaker == "TOOL" }.associateBy { it.toolUseId } + assertTrue(calls["live"]!!.inFlight, "a call with no result is still running") + assertTrue(!calls["live"]!!.failed) + assertTrue(calls["boom"]!!.failed, "a call whose result is an error has failed") + assertTrue(!calls["boom"]!!.inFlight) + assertTrue(!calls["ok"]!!.inFlight && !calls["ok"]!!.failed, "a call that returned is simply done") + } + @Test fun `a tool_result is attributed to TOOL_OUTPUT and carries its error flag`() { val entries = SessionTranscriptReader.parseEntries( diff --git a/src/test/kotlin/dev/lain/claudejb/session/SyntheticUserTextTest.kt b/src/test/kotlin/dev/lain/claudejb/session/SyntheticUserTextTest.kt new file mode 100644 index 00000000..b8c7a4be --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/session/SyntheticUserTextTest.kt @@ -0,0 +1,103 @@ +package dev.lain.claudejb.session + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * Telling the user's words from the binary's, inside a `user` line. + * + * Every fixture here is copied from a real transcript on this machine. The bug: restoring a session showed + * these blocks attributed to the user and styled as their prompts — people read their own conversation back + * and found paragraphs they had never written. + */ +class SyntheticUserTextTest { + + private val caveat = + "Caveat: The messages below were generated by the user while running " + + "local commands. DO NOT respond to these messages or otherwise consider them in your response " + + "unless the user explicitly asks you to." + + private val slashCommand = + "/compact\n" + + " compact\n" + + " " + + private val taskNotification = + "\na7a048c567855914a\n" + + "toolu_019SKo64Svw7vPa5wc9FkGwq\n" + + "/tmp/claude-1000/x/tasks/a7a048c567855914a.output\n" + + "failed\n" + + "Agent \"Traducción lote 16\" failed: session limit\n" + + "A task-notification fires each time this agent stops.\n" + + "Only one slot was available.\n" + + @Test + fun `the caveat is not the user speaking and is not shown at all`() { + // Pure instruction to the model. It has no reader-facing content, so there is nothing to render. + assertEquals(SyntheticUserText.Kind.Hidden, SyntheticUserText.classify(caveat)) + } + + @Test + fun `a slash command stays the user's, shown the way they typed it`() { + // The user really did run /compact: the action is theirs. What is not theirs is the three XML + // blocks the binary records it in. + val kind = SyntheticUserText.classify(slashCommand) + assertTrue(kind is SyntheticUserText.Kind.Command, "expected a Command, got $kind") + assertEquals("/compact", (kind as SyntheticUserText.Kind.Command).text) + } + + @Test + fun `a slash command keeps its arguments`() { + val kind = SyntheticUserText.classify( + "/renamenew title", + ) + assertEquals("/rename new title", (kind as SyntheticUserText.Kind.Command).text) + } + + @Test + fun `a task notification becomes the same system note the live path shows`() { + val kind = SyntheticUserText.classify(taskNotification) + val text = (kind as SyntheticUserText.Kind.SystemNote).text + assertEquals("Subagent failed: Agent \"Traducción lote 16\" failed: session limit", text) + // The ids, the output path and the note aimed at the model are not sentences a human wants to read. + assertTrue(!text.contains("toolu_")) + assertTrue(!text.contains("/tmp/")) + } + + @Test + fun `local command output is the binary's voice, not the user's`() { + val kind = SyntheticUserText.classify("build ok") + assertEquals("build ok", (kind as SyntheticUserText.Kind.SystemNote).text) + } + + @Test + fun `an ordinary prompt is untouched`() { + val kind = SyntheticUserText.classify(" arregla el bug del restore ") + assertEquals("arregla el bug del restore", (kind as SyntheticUserText.Kind.Prompt).text) + } + + @Test + fun `a prompt that merely starts with a tag is still the user's`() { + // THE REASON THE TAG SET IS CLOSED. "Anything starting with markup is not the user" would eat this, + // and someone pasting XML into a chat about XML is not an edge case in this repository. + val xml = "\n demo\n\n\n¿por qué falla este pom?" + assertTrue(SyntheticUserText.classify(xml) is SyntheticUserText.Kind.Prompt) + val html = "
hola
¿esto es válido?" + assertTrue(SyntheticUserText.classify(html) is SyntheticUserText.Kind.Prompt) + } + + @Test + fun `the binary's own isMeta flag hides a wrapper this list has not met`() { + // Forward compatibility without guessing: a new wrapper we do not know yet is still dropped when + // the binary itself says the line is scaffolding. + val unknown = "internal" + assertTrue(SyntheticUserText.classify(unknown, isMeta = false) is SyntheticUserText.Kind.Prompt) + assertEquals(SyntheticUserText.Kind.Hidden, SyntheticUserText.classify(unknown, isMeta = true)) + } + + @Test + fun `blank content is dropped rather than rendered as an empty bubble`() { + assertEquals(SyntheticUserText.Kind.Hidden, SyntheticUserText.classify(" \n ")) + } +} From 3eb3b96791d44c4ed0982c75c6c7fcd7d68cfec7 Mon Sep 17 00:00:00 2001 From: Lain Date: Tue, 11 Aug 2026 10:12:14 +0200 Subject: [PATCH 012/141] feat(tasks): keep a background task, and its output, after it ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `background_tasks_changed` is a LEVEL signal: a task that finishes stops being listed, so its row, its tab and everything it had printed vanished at the exact moment there was something to read. `BackgroundTaskRegistry` keeps every task this session has seen and uses the level for what it actually says — presence means running, absence means finished. The level NEVER creates an entry. Its ids do not always match the `backgroundTaskId` a `tool_result` reports, and adopting the strangers produced a second, contentless copy of every task sitting next to the real one. The structured tool output is what makes a task ours and gives it its owner, its card and its command. The output is a real file — the binary names it in `system/task_notification.output_file`, modelled since 3.0.0 and never read — so `LiveOutputTail` tails it by offset (each poll costs only what is new) and `BackgroundTaskReplay` rebuilds it from the session JSONL after a restart, since nothing of it survives in memory. A task with nothing reported says so rather than showing a plausible blank. --- .../dev/lain/claudejb/protocol/ClaudeEvent.kt | 55 +++++- .../session/BackgroundTaskRegistry.kt | 187 ++++++++++++++++++ .../claudejb/session/BackgroundTaskReplay.kt | 136 +++++++++++++ .../lain/claudejb/session/LiveOutputTail.kt | 76 +++++++ .../lain/claudejb/session/TaskOutputFile.kt | 50 +++++ .../lain/claudejb/ui/BackgroundTaskView.kt | 70 +++++++ .../protocol/ProtocolParserToolOutputTest.kt | 106 ++++++++++ .../session/BackgroundTaskRegistryTest.kt | 125 ++++++++++++ .../claudejb/session/LiveOutputTailTest.kt | 73 +++++++ 9 files changed, 875 insertions(+), 3 deletions(-) create mode 100644 src/main/kotlin/dev/lain/claudejb/session/BackgroundTaskRegistry.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/session/BackgroundTaskReplay.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/session/LiveOutputTail.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/session/TaskOutputFile.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/ui/BackgroundTaskView.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/protocol/ProtocolParserToolOutputTest.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/session/BackgroundTaskRegistryTest.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/session/LiveOutputTailTest.kt diff --git a/src/main/kotlin/dev/lain/claudejb/protocol/ClaudeEvent.kt b/src/main/kotlin/dev/lain/claudejb/protocol/ClaudeEvent.kt index 87157093..c69c4dec 100644 --- a/src/main/kotlin/dev/lain/claudejb/protocol/ClaudeEvent.kt +++ b/src/main/kotlin/dev/lain/claudejb/protocol/ClaudeEvent.kt @@ -72,8 +72,36 @@ sealed interface ClaudeEvent { val content: String, val isError: Boolean, val parentToolUseId: String?, + /** The tool's STRUCTURED output, when it carried anything this plugin acts on. See [ToolOutputInfo]. */ + val output: ToolOutputInfo? = null, ) : Conversation + /** + * The fields of `tool_use_result` — the tool's own Output object — that the plugin actually uses. + * + * The SDK is explicit that this is the thing to render from: *"Structured tool output — the tool's full + * Output object, not the string content sent to the model […] render from it instead of parsing the + * tool_result text"* (`SDKUserMessageReplay.tool_use_result`, SDK 0.3.223). It is also the ONLY place the + * link between a background task and the tool call that started it exists: `background_tasks_changed` + * carries `{task_id, task_type, description}` and nothing else, and the SDK forbids pairing that level + * signal with the edge stream. Without [backgroundTaskId] a background task has no owner, no card to jump + * to and no output to show — which is exactly how its tabs behaved before 5.5.0 wired this up. + * + * Only these four fields are lifted, deliberately: the object is per-tool and open-ended, and carrying it + * whole would be state nothing reads. + */ + data class ToolOutputInfo( + /** `Bash` with `run_in_background`, or any call the user backgrounded: the task's id. */ + val backgroundTaskId: String? = null, + /** A backgrounded agent's progress file (`AgentOutput.outputFile`) — the one tailable live output. */ + val outputFile: String? = null, + val stdout: String? = null, + val stderr: String? = null, + ) { + fun isEmpty(): Boolean = + backgroundTaskId == null && outputFile == null && stdout.isNullOrEmpty() && stderr.isNullOrEmpty() + } + // ── Stream ──────────────────────────────────────────────────────────────────────────────────────────── /** Incremental text delta from --include-partial-messages (live streaming preview). */ @@ -472,8 +500,12 @@ object ProtocolParser { val message = root["message"] as? JsonObject ?: return emptyList() val content = message["content"] as? JsonArray ?: return emptyList() val parentToolUseId = root.str("parent_tool_use_id") - return content.filterIsInstance().mapNotNull { block -> - if (block.str("type") != "tool_result") return@mapNotNull null + val blocks = content.filterIsInstance().filter { it.str("type") == "tool_result" } + // `tool_use_result` sits at the line's ROOT and describes ONE tool call, so it is only attached when + // this message carries exactly one result. With two, there is no field saying which one it belongs to, + // and attaching it to both would invent a background-task link that the protocol never stated. + val output = blocks.singleOrNull()?.let { parseToolOutput(root["tool_use_result"] as? JsonObject) } + return blocks.mapNotNull { block -> val toolUseId = block.str("tool_use_id") ?: return@mapNotNull null val isError = (block["is_error"] as? JsonPrimitive)?.booleanOrNull ?: false val text = when (val c = block["content"]) { @@ -481,10 +513,27 @@ object ProtocolParser { is JsonArray -> c.filterIsInstance().mapNotNull { it.str("text") }.joinToString("\n") else -> "" } - ClaudeEvent.ToolResult(toolUseId, unwrapToolError(text), isError, parentToolUseId) + ClaudeEvent.ToolResult(toolUseId, unwrapToolError(text), isError, parentToolUseId, output) } } + /** + * Lifts the handful of `tool_use_result` fields the plugin acts on; null when there is nothing in it. + * + * The field name differs between the two places this object appears — `tool_use_result` on the stream, + * `toolUseResult` in the session's own JSONL (verified against `claude` 2.1.226) — so the caller passes + * whichever it has and this stays about the contents. + */ + internal fun parseToolOutput(obj: JsonObject?): ClaudeEvent.ToolOutputInfo? { + if (obj == null) return null + return ClaudeEvent.ToolOutputInfo( + backgroundTaskId = obj.str("backgroundTaskId"), + outputFile = obj.str("outputFile"), + stdout = obj.str("stdout"), + stderr = obj.str("stderr"), + ).takeUnless { it.isEmpty() } + } + private fun parseControlRequest(root: JsonObject): List { val requestId = root.str("request_id") ?: return emptyList() val request = root["request"] as? JsonObject ?: return emptyList() diff --git a/src/main/kotlin/dev/lain/claudejb/session/BackgroundTaskRegistry.kt b/src/main/kotlin/dev/lain/claudejb/session/BackgroundTaskRegistry.kt new file mode 100644 index 00000000..c76a5868 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/session/BackgroundTaskRegistry.kt @@ -0,0 +1,187 @@ +package dev.lain.claudejb.session + +import dev.lain.claudejb.protocol.BackgroundTaskInfo +import dev.lain.claudejb.protocol.ClaudeEvent +import java.util.concurrent.ConcurrentHashMap + +/** + * Every background task this session has seen: what it is, who started it, and whatever output came back. + * + * **Why the plugin keeps its own record instead of rendering the binary's set.** + * `system/background_tasks_changed` is a LEVEL signal with REPLACE semantics — it lists what is live *right + * now*. Rendering it directly is correct for "is anything running", and wrong for a tab: the moment a task + * finished it vanished from the payload, so its row and its tab disappeared with it and there was no way to + * read what it had done. A finished agent keeps its tab; so does a finished task. The level is still the only + * source of truth for *liveness* — presence means running, absence means finished — which is exactly how it + * is used here, without ever pairing it with the edge stream the SDK says not to pair it with. + * + * **Where the rest comes from.** The level carries `{task_id, task_type, description}` and nothing else: no + * parent, no tool call, no output. That link lives in the STRUCTURED tool output (`tool_use_result`), where a + * backgrounded call reports `backgroundTaskId` (verified in a real session's transcript, `claude` 2.1.226; + * typed in the SDK as `BashOutput.backgroundTaskId` and `AgentOutput.outputFile`). Joining on it gives: + * - the **owner** — the starting call's `parent_tool_use_id`, i.e. the agent whose turn ran it, or the chat + * when there is none. Stored raw and resolved by the caller, since an agent may be admitted later; + * - the **card** — [Task.toolUseId], the transcript row that started it; + * - the **output** — a backgrounded agent publishes a progress file ([Task.outputFile]) that can be tailed; + * a backgrounded shell command publishes no file, so what is shown is what the binary actually reported: + * the initial result plus each later query of it. A task with nothing reported says so rather than + * showing a plausible blank. + * + * Agents are excluded ([AGENT_TASK_TYPE]): to the binary a running agent IS a background task, and it already + * has its own rows, its own tabs and its own transcripts. Keeping it here too is how a "Background tasks" row + * ended up holding an agent — of itself. + * + * Threading: written from the EDT, read from the UI and from the pooled scan; concurrent for the same reason + * [TaskTracker]'s map is. + */ +class BackgroundTaskRegistry { + + /** One background task. [running] comes from the level signal: present means live, absent means done. */ + data class Task( + val taskId: String, + val description: String = "", + val taskType: String = "", + val running: Boolean = true, + val toolUseId: String? = null, + val ownerToolUseId: String? = null, + val outputFile: String? = null, + val output: String = "", + /** The command that launched it, when the transcript carried one — the most useful label there is. */ + val command: String? = null, + ) { + fun label(): String = + description.ifBlank { command?.lineSequence()?.firstOrNull().orEmpty() } + .ifBlank { taskType } + .ifBlank { taskId } + } + + private val tasks = ConcurrentHashMap() + + /** Insertion order, so a row does not reshuffle under the pointer every time the level signal fires. */ + private val order = java.util.concurrent.CopyOnWriteArrayList() + + /** Every task ever seen this process, live ones and finished ones, in the order they appeared. */ + val all: List get() = order.mapNotNull { tasks[it] } + + fun taskOf(taskId: String): Task? = tasks[taskId] + + /** + * Applies the level signal: everything in [live] is running, everything previously seen and absent from + * it is finished. Returns true when anything changed. + */ + fun seed(replayed: List): Boolean { + var changed = false + replayed.forEach { r -> + if (tasks.containsKey(r.taskId)) return@forEach + order += r.taskId + tasks[r.taskId] = Task( + taskId = r.taskId, + running = false, + toolUseId = r.toolUseId, + ownerToolUseId = r.ownerToolUseId, + outputFile = r.outputFile, + // The chunks when there are any; otherwise what the binary SAID about the task — for most + // backgrounded commands the launching result's prose is the only output there is. + output = r.output.ifBlank { r.notes }.takeLast(MAX_OUTPUT), + command = r.command, + ) + changed = true + } + return changed + } + + fun observeLevel(live: List): Boolean { + var changed = false + val liveIds = live.filterNot { it.taskType == AGENT_TASK_TYPE }.associateBy { it.taskId } + liveIds.forEach { (id, info) -> + // UPDATE ONLY — never create. This signal reports the binary's whole live set, and its ids do + // not always match the `backgroundTaskId` a tool_result gave us: adopting the strangers produced + // a second, contentless copy of every task ("Background Task (background)", no command, no + // output) sitting next to the real one. The tool_result is what makes a task OURS and gives it + // its command; this only says whether it is still running. + val previous = tasks[id] ?: return@forEach + val next = previous.copy( + description = info.description.ifBlank { previous.description }, + taskType = info.taskType.ifBlank { previous.taskType }, + running = true, + ) + if (next != previous) { + tasks[id] = next + changed = true + } + } + tasks.forEach { (id, task) -> + if (task.running && id !in liveIds) { + tasks[id] = task.copy(running = false) + changed = true + } + } + return changed + } + + /** + * Records where a task's output is being written, from `system/task_notification`'s `output_file`. + * + * The structured source, and the one that matters: with it the task's output is a file the plugin can + * tail, live and after a restart, instead of the "no output was reported" the view used to show for a + * command that had plainly produced some. + */ + fun observeOutputFile(taskId: String, outputFile: String?): Boolean { + val path = outputFile?.takeIf { it.isNotBlank() } ?: return false + val previous = tasks[taskId] + if (previous?.outputFile == path) return false + if (previous == null) order += taskId + tasks[taskId] = (previous ?: Task(taskId)).copy(outputFile = path) + return true + } + + fun observe(event: ClaudeEvent.ToolResult): Boolean { + val out = event.output ?: return false + val taskId = out.backgroundTaskId ?: return false + val chunk = listOfNotNull(out.stdout, out.stderr).filter { it.isNotBlank() }.joinToString("\n") + val previous = tasks[taskId] + val next = (previous ?: Task(taskId)).copy( + toolUseId = event.toolUseId, + // The FIRST sighting owns the attribution: a later query of the task's output can come from a + // different turn or a different agent, and letting that overwrite it would re-parent the task to + // whoever last looked at it. + ownerToolUseId = previous?.ownerToolUseId ?: event.parentToolUseId, + // The launching result NAMES the file the output is going to ("Output is being written to: …"), + // which is what makes a running task's output readable before it settles — until then there is no + // `task_notification` to carry the structured field. + outputFile = previous?.outputFile ?: out.outputFile ?: TaskOutputFile.parse(event.content), + output = (previous?.output.orEmpty() + if (chunk.isBlank()) "" else "$chunk\n").takeLast(MAX_OUTPUT), + ) + if (next == previous) return false + if (previous == null) order += taskId + tasks[taskId] = next + return true + } + + /** Appends text read from a task's progress file (see [Task.outputFile]). True when it changed anything. */ + fun appendTailedOutput(taskId: String, text: String): Boolean { + if (text.isBlank()) return false + val previous = tasks[taskId] ?: return false + val merged = (previous.output + text).takeLast(MAX_OUTPUT) + if (merged == previous.output) return false + tasks[taskId] = previous.copy(output = merged) + return true + } + + /** Per-process state, like the task set itself: a restarted binary re-announces whatever is still alive. */ + fun clear() { + tasks.clear() + order.clear() + } + + companion object { + /** The `task_type` the binary uses for a running agent — it has its own rows; see the class doc. */ + const val AGENT_TASK_TYPE = "local_agent" + + /** + * Cap on retained output per task. A backgrounded `tail -f` is unbounded by nature, and this is a + * view of a running thing, not an archive — the file on disk is the archive. + */ + private const val MAX_OUTPUT = 200_000 + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/session/BackgroundTaskReplay.kt b/src/main/kotlin/dev/lain/claudejb/session/BackgroundTaskReplay.kt new file mode 100644 index 00000000..a76fe8ec --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/session/BackgroundTaskReplay.kt @@ -0,0 +1,136 @@ +package dev.lain.claudejb.session + +import dev.lain.claudejb.protocol.ProtocolParser +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * Rebuilds a session's background tasks — and their output — from the binary's own transcript file. + * + * **Why this exists.** A background task only lives in memory: `background_tasks_changed` is a level signal + * that stops mentioning a task the moment it ends, and nothing in it is written down by the plugin. So a + * restarted IDE came back with the agents (their files are on disk) and with no tasks at all — the tabs, the + * commands and every line they had produced were simply gone. + * + * **Why not persist them ourselves.** Everything needed is already written, once, by the binary: the session + * JSONL carries the `tool_use` that launched the task (its command) and the `toolUseResult` that names it + * (`backgroundTaskId`) together with whatever `stdout`/`stderr` came back. Copying that into a file of ours + * would be a second store to keep in sync, go stale and leak — the same reasoning that keeps the agent index + * down to ids. + * + * A replayed task is reported as **finished**: the transcript records what happened, not what is happening, + * and the level signal re-announces anything still alive within a second of the process starting. + * + * Pure: takes lines, returns tasks. Everything it reads is per-line and tolerant — a malformed line is + * skipped, never fatal. + */ +object BackgroundTaskReplay { + + /** One task recovered from the transcript, in the shape [BackgroundTaskRegistry] seeds from. */ + data class Replayed( + val taskId: String, + val toolUseId: String, + val ownerToolUseId: String?, + val command: String?, + val outputFile: String?, + val output: String, + /** + * What the binary answered when the task was launched or queried — the `tool_result` text. + * + * Kept because for most backgrounded commands it is the ONLY thing there is: `stdout` in the + * structured result is empty at launch (verified across a real session's transcript), so a view built + * from `stdout` alone showed an empty box for a task the binary had in fact reported on. + */ + val notes: String = "", + ) + + private val JSON = Json { + ignoreUnknownKeys = true + isLenient = true + } + + /** + * Every background task named anywhere in [lines], in the order they first appear. + * + * The join is on `backgroundTaskId`, and both halves come from the same file: the launching `tool_use` + * block (for the command text) and the `toolUseResult` object on the `user` line that answers it. + */ + fun parse(lines: List): List { + val commands = HashMap() // tool_use_id → command text + val tasks = LinkedHashMap() + + for (line in lines) { + if (line.isBlank()) continue + val obj = runCatching { JSON.parseToJsonElement(line).jsonObject }.getOrNull() ?: continue + when (obj.str("type")) { + "assistant" -> collectCommands(obj, commands) + "user" -> collectTask(obj, commands, tasks) + else -> Unit + } + } + return tasks.values.toList() + } + + /** Remembers the command of every `tool_use` block, so a task can be labelled by what it actually runs. */ + private fun collectCommands(line: JsonObject, into: MutableMap) { + val content = (line["message"] as? JsonObject)?.get("content") as? JsonArray ?: return + content.filterIsInstance().forEach { block -> + if (block.str("type") != "tool_use") return@forEach + val id = block.str("id") ?: return@forEach + val input = block["input"] as? JsonObject ?: return@forEach + // `command` is Bash's; `script` covers the PowerShell/MCP shapes the guard already knows about. + val command = input.str("command") ?: input.str("script") ?: return@forEach + into[id] = command + } + } + + private fun collectTask( + line: JsonObject, + commands: Map, + into: MutableMap, + ) { + // The stream spells it `tool_use_result`; the transcript file spells it `toolUseResult`. Same object. + val resultObj = (line["toolUseResult"] ?: line["tool_use_result"]) as? JsonObject ?: return + val output = ProtocolParser.parseToolOutput(resultObj) ?: return + val taskId = output.backgroundTaskId ?: return + val block = resultBlock(line) ?: return + val toolUseId = block.str("tool_use_id") ?: return + val chunk = listOfNotNull(output.stdout, output.stderr).filter { it.isNotBlank() }.joinToString("\n") + val note = noteText(block) + val previous = into[taskId] + into[taskId] = Replayed( + taskId = taskId, + toolUseId = previous?.toolUseId ?: toolUseId, + // The FIRST sighting owns the attribution, exactly as in the live path: a later query of the same + // task can come from another turn or another agent. + ownerToolUseId = previous?.ownerToolUseId ?: line.str("parent_tool_use_id"), + command = previous?.command ?: commands[toolUseId], + // The path is in the transcript twice: the launching result says it in prose, and the + // `` block repeats it as ``. A replay has no events to listen to, + // so this text IS the structured source here. + outputFile = previous?.outputFile ?: output.outputFile ?: TaskOutputFile.parse(note), + output = (previous?.output.orEmpty() + if (chunk.isBlank()) "" else "$chunk\n"), + notes = listOf(previous?.notes.orEmpty(), note).filter { it.isNotBlank() }.joinToString("\n"), + ) + } + + /** The `tool_result` block on a `user` line — the half of the join that names the call. */ + private fun resultBlock(line: JsonObject): JsonObject? = + ((line["message"] as? JsonObject)?.get("content") as? JsonArray) + ?.filterIsInstance() + ?.firstOrNull { it.str("type") == "tool_result" } + + /** The block's text, whichever of the two content shapes it arrived in. */ + private fun noteText(block: JsonObject): String = when (val body = block["content"]) { + is kotlinx.serialization.json.JsonPrimitive -> body.contentOrNull.orEmpty() + is JsonArray -> body.filterIsInstance().mapNotNull { it.str("text") }.joinToString("\n") + else -> "" + }.trim() + + private fun JsonObject.str(key: String): String? = + this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } +} diff --git a/src/main/kotlin/dev/lain/claudejb/session/LiveOutputTail.kt b/src/main/kotlin/dev/lain/claudejb/session/LiveOutputTail.kt new file mode 100644 index 00000000..4e70aca1 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/session/LiveOutputTail.kt @@ -0,0 +1,76 @@ +package dev.lain.claudejb.session + +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.ConcurrentHashMap + +/** + * Reads the part of a progress file that has appeared since the last read. + * + * **Why a tail and not a re-read.** A backgrounded agent's `outputFile` grows for as long as the agent runs; + * re-reading it whole on every poll would be O(size) per poll and would hand the UI the same text over and + * over. Keeping the offset means each poll costs only what is new, which is what makes polling a running task + * affordable at all. + * + * **Why polling and not a watcher.** The file is written by another process, often on a filesystem where + * `WatchService` degrades to polling anyway (and, on macOS, does so with a delay measured in seconds). The + * caller already has a scan loop; this just answers "what is new" when asked. + * + * Pure enough to test: it takes paths and returns strings, holds only offsets, and never touches the UI. IO + * is blocking — call it off the EDT. Every failure is answered with an empty string rather than an exception: + * the file may not exist yet, may be being rewritten, or may be gone. None of that is worth breaking a scan. + */ +class LiveOutputTail { + + /** path → how many bytes of it have already been handed out. */ + private val offsets = ConcurrentHashMap() + + /** + * The bytes appended to [path] since the last call, decoded as UTF-8. Empty when there is nothing new. + * + * A file that SHRANK is treated as a new file and read from the start: that is what a rotation or a + * rewrite looks like from here, and continuing from a stale offset would read from the middle of the new + * content and hand back a fragment. + */ + fun readNew(path: Path): String { + val key = path.toString() + return runCatching { + if (!Files.isRegularFile(path)) return@runCatching "" + val size = Files.size(path) + val from = offsets[key]?.takeIf { it <= size } ?: 0L + if (size <= from) { + offsets[key] = size + return@runCatching "" + } + val length = (size - from).coerceAtMost(MAX_CHUNK) + val buffer = ByteArray(length.toInt()) + Files.newByteChannel(path).use { channel -> + channel.position(size - length) + var read = 0 + while (read < buffer.size) { + val n = channel.read(java.nio.ByteBuffer.wrap(buffer, read, buffer.size - read)) + if (n <= 0) break + read += n + } + offsets[key] = size + String(buffer, 0, read, Charsets.UTF_8) + } + }.getOrDefault("") + } + + /** Forgets [path]'s offset, so the next read starts from the beginning. */ + fun forget(path: Path) { + offsets.remove(path.toString()) + } + + fun clear() = offsets.clear() + + private companion object { + /** + * Most that is handed over in one poll. A task can write megabytes between two polls (a build log, + * a `tail -f`), and the point of this view is what is happening now — so a burst is truncated to its + * tail rather than being pushed whole into a web view row. + */ + const val MAX_CHUNK = 64L * 1024 + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/session/TaskOutputFile.kt b/src/main/kotlin/dev/lain/claudejb/session/TaskOutputFile.kt new file mode 100644 index 00000000..478b5de1 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/session/TaskOutputFile.kt @@ -0,0 +1,50 @@ +package dev.lain.claudejb.session + +/** + * Where a backgrounded command's output actually lives — for the moments the STRUCTURED field is not there. + * + * **Structured first, always.** `system/task_notification` carries `output_file` and the plugin has modelled + * it since 3.0.0 ([TaskNotificationInfo]) without ever reading it; that is now the primary source and this + * object is the fallback for the window BEFORE a task settles, where the only place the path appears is the + * launching `tool_result`'s prose — and, when replaying a past session from its transcript, inside the + * `` block, because a replay has no events to listen to. + * + * **The finding behind all of it.** The plugin showed "this task reported no output" for tasks that had + * clearly produced some, on the reasoning that a backgrounded shell command publishes nothing until the model + * queries it. That was wrong. The binary writes every background task's output to a file and says so: + * + * ``` + * Command running in background with ID: b3zr2hxpp. Output is being written to: + * /tmp/claude-1000///tasks/b3zr2hxpp.output. + * You will be notified when it completes. To check interim output, use Read on that file path. + * ``` + * + * and, when it settles, inside the `` block as ``. Verified against `claude` + * 2.1.226 on a real session: the directory exists, one `.output` file per task, with the content. + * + * So the output is tailable exactly like a backgrounded agent's, it is live, and — because the file outlives + * the IDE — it comes back after a restart. The parse is deliberately anchored on the binary's own wording and + * on the `` tag; anything else yields null and the caller says it has nothing rather than + * guessing a path. + */ +object TaskOutputFile { + + /** `Output is being written to: .` — the sentence the launching tool_result carries. */ + private val PROSE = Regex("""Output is being written to:\s*(\S+?)\.?(?:\s|$)""") + + /** `…` — the same path inside a ``. */ + private val TAG = Regex("""\s*(.+?)\s*""", RegexOption.DOT_MATCHES_ALL) + + /** The output file [text] names, or null when it names none. Pure. */ + fun parse(text: String?): String? { + if (text.isNullOrBlank()) return null + TAG.find(text)?.groupValues?.getOrNull(1)?.takeIf { it.isNotBlank() }?.let { return it } + return PROSE.find(text)?.groupValues?.getOrNull(1)?.takeIf { it.isNotBlank() } + } + + /** The task id a `` refers to, so a settled task can be matched to its row. */ + private val TASK_ID = Regex("""\s*(.+?)\s*""") + + fun taskId(text: String?): String? = + text?.let { TASK_ID.find(it)?.groupValues?.getOrNull(1)?.takeIf { id -> id.isNotBlank() } } +} diff --git a/src/main/kotlin/dev/lain/claudejb/ui/BackgroundTaskView.kt b/src/main/kotlin/dev/lain/claudejb/ui/BackgroundTaskView.kt new file mode 100644 index 00000000..68345937 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/ui/BackgroundTaskView.kt @@ -0,0 +1,70 @@ +package dev.lain.claudejb.ui + +import dev.lain.claudejb.session.BackgroundTaskRegistry +import dev.lain.claudejb.session.ClaudeSession +import dev.lain.claudejb.session.EntryDTO + +/** + * What a background task's tab shows, as transcript rows. + * + * A background task is a process, not a conversation: there is no transcript to read. What there IS, is what + * the binary reported — the task's own description and type, the tool call that started it, and its output. + * Rendering that through the same rows the agent tabs use keeps one renderer for everything the browser + * paints, instead of a second layout that has to be kept looking like the first. + * + * **The honest gap is part of the design.** A backgrounded shell command publishes no progress file: its + * output only exists once the binary is asked for it, and the plugin cannot ask. So a task with nothing + * reported yet says exactly that — rather than an empty box that reads as "it produced nothing". + * + * Pure: takes a session, returns rows. Testable without a browser. + */ +internal object BackgroundTaskView { + + fun entries(session: ClaudeSession, taskId: String): List { + val task = session.backgroundTaskRegistry.taskOf(taskId) + ?: return listOf(EntryDTO("SYSTEM", "This background task is no longer known to this session.")) + val command = task.toolUseId?.let { session.transcript.commandTextOf(it) } + return buildList { + add(EntryDTO("SYSTEM", header(session, task))) + // What was launched, as its own copyable code block — the same card a Bash call gets in the + // chat. For a shell task this is the most useful thing there is to show, and it is known from + // the moment the task exists. + command?.takeIf { it.isNotBlank() }?.let { + add(EntryDTO("TOOL", "", meta = "Command", toolUseId = task.toolUseId, commandText = it)) + } + val output = task.output.trim() + if (output.isNotEmpty()) { + add(EntryDTO("TOOL_OUTPUT", output, meta = "command", toolUseId = task.toolUseId)) + } else { + add(EntryDTO("SYSTEM", noOutputNote(task))) + } + } + } + + private fun header(session: ClaudeSession, task: BackgroundTaskRegistry.Task): String = buildString { + append("**").append(task.label()).append("**\n\n") + append("· State: ").append(if (task.running) "running" else "finished").append('\n') + if (task.taskType.isNotBlank()) append("· Type: ").append(task.taskType).append('\n') + append("· Started by: ").append(ownerLabel(session, task)).append('\n') + task.outputFile?.takeIf { it.isNotBlank() }?.let { append("· Output file: ").append(it).append('\n') } + } + + /** The owning agent's label, or the chat's own name when the task was started by the main turn. */ + private fun ownerLabel(session: ClaudeSession, task: BackgroundTaskRegistry.Task): String { + val agentId = session.ownerAgentOfTask(task.taskId) ?: return session.title + return session.runningAgents.nodes[agentId]?.meta?.label() ?: session.title + } + + private fun noOutputNote(task: BackgroundTaskRegistry.Task): String = when { + !task.running -> "This task finished without reporting any output." + + task.outputFile != null -> "Waiting for the first output to be written." + + // The protocol-level gap, stated rather than papered over: `tool_progress` is a heartbeat with no + // payload, and a backgrounded command's output reaches the stream only when the binary is queried + // for it — which the agent does, and the host cannot. + else -> + "No output has been reported yet. A backgrounded command publishes its output only when it " + + "is queried, so this fills in as the agent checks on it." + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/protocol/ProtocolParserToolOutputTest.kt b/src/test/kotlin/dev/lain/claudejb/protocol/ProtocolParserToolOutputTest.kt new file mode 100644 index 00000000..54625479 --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/protocol/ProtocolParserToolOutputTest.kt @@ -0,0 +1,106 @@ +package dev.lain.claudejb.protocol + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +/** + * `tool_use_result` — the tool's STRUCTURED output — as it reaches the stream. + * + * The SDK is explicit that this is the thing to read from: *"the tool's full Output object, not the string + * content sent to the model […] render from it instead of parsing the tool_result text"* + * (`SDKUserMessageReplay.tool_use_result`, SDK 0.3.223). It is also the ONLY place a background task's id + * appears next to the tool call that started it — `background_tasks_changed` carries ids and nothing else — + * so without this parse a background task has no owner, no card and no output. + * + * The fixture mirrors a real line from a session transcript (`claude` 2.1.226), where the same object is + * spelled `toolUseResult`; on the stream it is `tool_use_result`. + */ +class ProtocolParserToolOutputTest { + + private fun line(body: String) = ProtocolParser.parse(body).filterIsInstance() + + @Test + fun `a backgrounded command carries its task id`() { + val events = line( + """ + {"type":"user","parent_tool_use_id":"toolu_agent", + "message":{"role":"user","content":[ + {"type":"tool_result","tool_use_id":"toolu_bash","content":"Running in the background"}]}, + "tool_use_result":{"stdout":"","stderr":"","interrupted":false,"backgroundTaskId":"beaz86nuu"}} + """.trimIndent(), + ) + val result = events.single() + assertEquals("beaz86nuu", result.output?.backgroundTaskId) + // The owner comes from the line, not from the structured object: it is the agent whose turn ran it. + assertEquals("toolu_agent", result.parentToolUseId) + } + + @Test + fun `a backgrounded agent carries its progress file`() { + val result = line( + """ + {"type":"user","parent_tool_use_id":null, + "message":{"role":"user","content":[ + {"type":"tool_result","tool_use_id":"toolu_task","content":"backgrounded"}]}, + "tool_use_result":{"status":"backgrounded","outputFile":"/tmp/agent-1.out","description":"x"}} + """.trimIndent(), + ).single() + assertEquals("/tmp/agent-1.out", result.output?.outputFile) + assertNull(result.output?.backgroundTaskId) + } + + @Test + fun `a result with nothing we act on reports no structured output`() { + val result = line( + """ + {"type":"user","parent_tool_use_id":null, + "message":{"role":"user","content":[ + {"type":"tool_result","tool_use_id":"toolu_read","content":"file contents"}]}, + "tool_use_result":{"type":"text","file":{"filePath":"/tmp/a"}}} + """.trimIndent(), + ).single() + // Not an empty object: "nothing here for us" and "a task with blank fields" are different claims. + assertNull(result.output) + } + + @Test + fun `two results in one message get no structured output at all`() { + val results = line( + """ + {"type":"user","parent_tool_use_id":null, + "message":{"role":"user","content":[ + {"type":"tool_result","tool_use_id":"toolu_a","content":"a"}, + {"type":"tool_result","tool_use_id":"toolu_b","content":"b"}]}, + "tool_use_result":{"backgroundTaskId":"bxxx"}} + """.trimIndent(), + ) + assertEquals(2, results.size) + // The object sits at the line's root and describes ONE call, with no field saying which. Attaching it + // to both would invent a background-task link the protocol never stated. + results.forEach { assertNull(it.output) } + } + + @Test + fun `a missing or malformed tool_use_result never costs the result`() { + val plain = line( + """ + {"type":"user","parent_tool_use_id":null, + "message":{"role":"user","content":[ + {"type":"tool_result","tool_use_id":"toolu_x","content":"ok"}]}} + """.trimIndent(), + ).single() + assertNull(plain.output) + assertEquals("ok", plain.content) + + val weird = line( + """ + {"type":"user","parent_tool_use_id":null, + "message":{"role":"user","content":[ + {"type":"tool_result","tool_use_id":"toolu_x","content":"ok"}]}, + "tool_use_result":"not an object"} + """.trimIndent(), + ).single() + assertNull(weird.output) + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/session/BackgroundTaskRegistryTest.kt b/src/test/kotlin/dev/lain/claudejb/session/BackgroundTaskRegistryTest.kt new file mode 100644 index 00000000..5fa4cf1d --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/session/BackgroundTaskRegistryTest.kt @@ -0,0 +1,125 @@ +package dev.lain.claudejb.session + +import dev.lain.claudejb.protocol.BackgroundTaskInfo +import dev.lain.claudejb.protocol.ClaudeEvent +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * [BackgroundTaskRegistry] — the record that survives the level signal. + * + * Two user-reported failures are pinned here. A finished task **vanished** from the rows, the tabs and the + * dashboard the moment it ended, because `background_tasks_changed` lists only what is live. And a task's tab + * led nowhere, because the level signal carries no owner and no tool call — that link only exists in the + * structured tool output. + */ +class BackgroundTaskRegistryTest { + + private fun level(vararg tasks: Pair) = + tasks.map { (id, type) -> BackgroundTaskInfo(taskId = id, taskType = type, description = "desc $id") } + + private fun result( + toolUseId: String, + taskId: String, + parent: String? = null, + stdout: String? = null, + outputFile: String? = null, + ) = ClaudeEvent.ToolResult( + toolUseId = toolUseId, + content = "", + isError = false, + parentToolUseId = parent, + output = ClaudeEvent.ToolOutputInfo(backgroundTaskId = taskId, outputFile = outputFile, stdout = stdout), + ) + + @Test + fun `a task that stops being listed is kept, marked finished`() { + val reg = BackgroundTaskRegistry() + // The tool_result is what makes a task OURS; the level only reports liveness. + assertTrue(reg.observe(result("toolu_1", "t1"))) + assertTrue(reg.observeLevel(level("t1" to "local_bash"))) + assertTrue(reg.taskOf("t1")!!.running) + // THE BUG: absence from the level means "finished", not "never existed". Dropping it here is what + // made the row, the tab and the output disappear at the exact moment there was something to read. + assertTrue(reg.observeLevel(emptyList())) + assertEquals(1, reg.all.size) + assertFalse(reg.taskOf("t1")!!.running) + } + + @Test + fun `an agent is not a background task`() { + val reg = BackgroundTaskRegistry() + // To the binary a running agent IS a background task (`local_agent`). Kept here, every agent showed + // up a second time in the Background tasks row — and, resolving its owner through its own Task call, + // as a background task OF ITSELF. + reg.observe(result("toolu_1", "t1")) + reg.observeLevel(level("agent1" to BackgroundTaskRegistry.AGENT_TASK_TYPE, "t1" to "local_bash")) + assertEquals(listOf("t1"), reg.all.map { it.taskId }) + } + + @Test + fun `the structured tool output is what gives a task its owner, its card and its output`() { + val reg = BackgroundTaskRegistry() + // The level alone creates NOTHING: its ids do not always match `backgroundTaskId`, and adopting the + // strangers produced a second, contentless copy of every task. + reg.observeLevel(level("t1" to "local_bash")) + assertNull(reg.taskOf("t1")) + assertTrue(reg.observe(result("toolu_1", "t1", parent = "toolu_agent", stdout = "line one"))) + val task = reg.taskOf("t1")!! + assertEquals("toolu_1", task.toolUseId) + assertEquals("toolu_agent", task.ownerToolUseId) + assertTrue(task.output.contains("line one")) + } + + @Test + fun `a later query of the output appends and never re-parents the task`() { + val reg = BackgroundTaskRegistry() + reg.observe(result("toolu_1", "t1", parent = "toolu_agent", stdout = "first")) + // A later look at the same task can come from a different turn, or a different agent. Letting that + // overwrite the owner would re-parent the task to whoever last looked at it. + reg.observe(result("toolu_2", "t1", parent = "toolu_other", stdout = "second")) + val task = reg.taskOf("t1")!! + assertEquals("toolu_agent", task.ownerToolUseId) + assertTrue(task.output.contains("first")) + assertTrue(task.output.contains("second")) + } + + @Test + fun `a tool result with no background task id changes nothing`() { + val reg = BackgroundTaskRegistry() + assertFalse(reg.observe(ClaudeEvent.ToolResult("toolu_1", "ok", false, null, null))) + assertTrue(reg.all.isEmpty()) + } + + @Test + fun `tailed output is appended to a known task only`() { + val reg = BackgroundTaskRegistry() + reg.observe(result("toolu_1", "t1", outputFile = "/tmp/agent.out")) + assertTrue(reg.appendTailedOutput("t1", "progress\n")) + assertTrue(reg.taskOf("t1")!!.output.contains("progress")) + assertFalse(reg.appendTailedOutput("unknown", "x")) + assertFalse(reg.appendTailedOutput("t1", " ")) + } + + @Test + fun `order is stable, so a row does not jump under the pointer`() { + val reg = BackgroundTaskRegistry() + reg.observe(result("toolu_1", "t1")) + reg.observe(result("toolu_2", "t2")) + reg.observeLevel(level("t2" to "local_bash")) // t1 finished + reg.observe(result("toolu_3", "t3")) + reg.observeLevel(level("t2" to "local_bash", "t3" to "local_bash")) + assertEquals(listOf("t1", "t2", "t3"), reg.all.map { it.taskId }) + } + + @Test + fun `clear drops everything, because the state is per-process`() { + val reg = BackgroundTaskRegistry() + reg.observe(result("toolu_1", "t1")) + reg.clear() + assertTrue(reg.all.isEmpty()) + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/session/LiveOutputTailTest.kt b/src/test/kotlin/dev/lain/claudejb/session/LiveOutputTailTest.kt new file mode 100644 index 00000000..f6456b1c --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/session/LiveOutputTailTest.kt @@ -0,0 +1,73 @@ +package dev.lain.claudejb.session + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardOpenOption + +/** + * [LiveOutputTail] — "what is new since I last looked", which is what makes polling a growing file affordable. + */ +class LiveOutputTailTest { + + @TempDir + lateinit var dir: Path + + private fun append(file: Path, text: String) { + Files.writeString(file, text, StandardOpenOption.CREATE, StandardOpenOption.APPEND) + } + + @Test + fun `only the new bytes come back`() { + val file = dir.resolve("agent.out") + val tail = LiveOutputTail() + append(file, "first\n") + assertEquals("first\n", tail.readNew(file)) + assertEquals("", tail.readNew(file)) + append(file, "second\n") + assertEquals("second\n", tail.readNew(file)) + } + + @Test + fun `a file that shrank is read from the start again`() { + val file = dir.resolve("agent.out") + val tail = LiveOutputTail() + append(file, "aaaa\n") + tail.readNew(file) + // Rotation or a rewrite: continuing from the old offset would hand back a fragment of the new + // content starting mid-line, which reads as corruption. + Files.writeString(file, "b\n") + assertEquals("b\n", tail.readNew(file)) + } + + @Test + fun `a missing file, a directory or an unreadable path is not an error`() { + val tail = LiveOutputTail() + assertEquals("", tail.readNew(dir.resolve("does-not-exist"))) + assertEquals("", tail.readNew(dir)) + } + + @Test + fun `a huge burst is truncated to its tail rather than pushed whole`() { + val file = dir.resolve("big.out") + val tail = LiveOutputTail() + // 300 KB in one go: a build log between two polls. The view is of a running thing, not an archive. + append(file, "x".repeat(300_000) + "END") + val read = tail.readNew(file) + assertTrue(read.length < 300_000, "expected a truncated chunk, got ${read.length}") + assertTrue(read.endsWith("END"), "the TAIL is what matters, not the head") + } + + @Test + fun `forgetting a path rewinds it`() { + val file = dir.resolve("agent.out") + val tail = LiveOutputTail() + append(file, "hello\n") + tail.readNew(file) + tail.forget(file) + assertEquals("hello\n", tail.readNew(file)) + } +} From 1d07de61f602d02f05591db644ffb75fdf122270 Mon Sep 17 00:00:00 2001 From: Lain Date: Tue, 11 Aug 2026 10:12:44 +0200 Subject: [PATCH 013/141] feat(settings): move the configuration into the IDE's password safe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit They lived in `.idea/claude-code.xml`: per project, plaintext, and committable. The env block belongs to the settings, and an env block is where an API key, a credentialed proxy URL or a registry token ends up — so the configuration people commit contained secrets. The safe is the same store the OAuth credential already uses, and application-wide is also the scope these settings actually have. Three things learned the hard way and pinned by tests: - A failed READ is not an empty configuration. One unreachable keyring at startup used to become a permanent overwrite on the next save, which reads as "the settings broke on their own"; `readFailed` refuses that write. - The legacy file is deleted only after the safe accepts the copy. It was adopted and removed while the write failed a millisecond later (`secret_password_store_sync error code 36`), which `set` reports to nobody, and the file was the only copy. - Every mutation goes through `update {}`. Nothing persists for us any more, so a bare `state.x = y` is a change that silently does not survive a restart — which is exactly what the three "Always allow" mutators were doing. The document IS the `@Serializable` state, so a field cannot be forgotten by the serialiser; a test asserts that against the class rather than against a hand-kept list. --- .../lain/claudejb/process/CredentialsVault.kt | 12 +- .../claudejb/session/LegacySessionHistory.kt | 45 ++++ .../lain/claudejb/session/SessionHistory.kt | 109 +++++++-- .../claudejb/settings/AlwaysAllowTools.kt | 37 ++++ .../lain/claudejb/settings/ClaudeSettings.kt | 118 ++++++---- .../settings/LegacyProjectSettings.kt | 74 +++++++ .../dev/lain/claudejb/settings/SafeAlarm.kt | 55 +++++ .../dev/lain/claudejb/settings/SecretStore.kt | 51 ++++- .../lain/claudejb/settings/SettingsStore.kt | 209 ++++++++++++++++++ .../settings/SettingsStoreTestAccess.kt | 18 ++ .../headless/ClaudeSettingsHeadlessTest.kt | 46 ++-- .../headless/SessionHistoryHeadlessTest.kt | 50 ++++- .../headless/SettingsStoreHeadlessTest.kt | 126 +++++++++++ .../process/NoFileDeletionContractTest.kt | 31 ++- .../settings/ClaudeSettingsParseEnvTest.kt | 2 +- 15 files changed, 888 insertions(+), 95 deletions(-) create mode 100644 src/main/kotlin/dev/lain/claudejb/session/LegacySessionHistory.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/settings/AlwaysAllowTools.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/settings/LegacyProjectSettings.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/settings/SafeAlarm.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/settings/SettingsStore.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/settings/SettingsStoreTestAccess.kt create mode 100644 src/test/kotlin/dev/lain/claudejb/headless/SettingsStoreHeadlessTest.kt diff --git a/src/main/kotlin/dev/lain/claudejb/process/CredentialsVault.kt b/src/main/kotlin/dev/lain/claudejb/process/CredentialsVault.kt index 11f13756..1ff9735a 100644 --- a/src/main/kotlin/dev/lain/claudejb/process/CredentialsVault.kt +++ b/src/main/kotlin/dev/lain/claudejb/process/CredentialsVault.kt @@ -302,7 +302,17 @@ object CredentialsVault { log.warn("credentials file present but unreadable/empty — leaving it alone") return false } - SecretStore.set(SecretStore.CREDENTIALS_JSON, text) + // VERIFIED, not fire-and-forget. `PasswordSafe.set` returns Unit and throws nothing when the OS + // store rejects the write — on this machine the IDE logged + // `secret_password_store_sync error code 36 — Can't find session …` as its own SEVERE, after our + // call had returned. Harvesting is a MOVE: it deletes the file straight afterwards, so a write that + // silently did nothing destroyed the user's only credential and the plugin asked them to sign in + // again with no idea why. Reading it back is the only honest confirmation available. + if (!SecretStore.setVerified(SecretStore.CREDENTIALS_JSON, text)) { + log.warn("the password safe did not keep the credential — leaving the file where it is") + dev.lain.claudejb.settings.SafeAlarm.storeFailed() + return false + } // Overwrite before unlinking: on a journalling filesystem the blocks may survive a bare delete, // and this content is a bearer credential. runCatching { diff --git a/src/main/kotlin/dev/lain/claudejb/session/LegacySessionHistory.kt b/src/main/kotlin/dev/lain/claudejb/session/LegacySessionHistory.kt new file mode 100644 index 00000000..c6711b87 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/session/LegacySessionHistory.kt @@ -0,0 +1,45 @@ +package dev.lain.claudejb.session + +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.StoragePathMacros +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.util.xmlb.XmlSerializerUtil + +/** + * Reads the open-chat list where it used to live: `workspace.xml`, under the component name + * `ClaudeCodeSessionHistory`. + * + * **This exists only to migrate.** [SessionHistory] moved that list to `~/.claude` in 5.5.0 so the plugin + * writes it itself instead of waiting for the platform's save cycle. Without reading the old value once, + * everyone upgrading would lose their open tabs on the first start after the update — the very thing the + * move was meant to make more reliable. + * + * The component name and storage are deliberately identical to the old ones, because that is what makes the + * platform hand us the XML that is already on disk. Nothing writes through here: migration is one-way, and + * the old entry is simply left where it is, inert. + */ +@Service(Service.Level.PROJECT) +@State(name = "ClaudeCodeSessionHistory", storages = [Storage(StoragePathMacros.WORKSPACE_FILE)]) +internal class LegacySessionHistory : PersistentStateComponent { + + class State { + /** The old field: a JSON array of session ids, as one string. */ + @JvmField var openJson: String = "" + } + + private var state = State() + + override fun getState(): State = state + override fun loadState(s: State) = XmlSerializerUtil.copyBean(s, state) + + /** The ids recorded by a pre-5.5.0 version, or empty when there are none (a fresh install). */ + fun openSessions(): List = SessionHistory.decodeIds(state.openJson) + + companion object { + fun getInstance(project: Project): LegacySessionHistory = project.service() + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/session/SessionHistory.kt b/src/main/kotlin/dev/lain/claudejb/session/SessionHistory.kt index 5c2aad42..e29bfc31 100644 --- a/src/main/kotlin/dev/lain/claudejb/session/SessionHistory.kt +++ b/src/main/kotlin/dev/lain/claudejb/session/SessionHistory.kt @@ -1,55 +1,116 @@ package dev.lain.claudejb.session -import com.intellij.openapi.components.PersistentStateComponent import com.intellij.openapi.components.Service -import com.intellij.openapi.components.State -import com.intellij.openapi.components.Storage -import com.intellij.openapi.components.StoragePathMacros import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.project.Project -import com.intellij.util.xmlb.XmlSerializerUtil import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths /** - * Pure UI state: the ordered list of session ids of the tabs that were open, so they can be reopened on next - * startup. No transcripts are persisted — those are reconstructed from the `claude` binary's own session files. - * Stored in `workspace.xml` (not committed by convention), as a single JSON string field. + * Which chats were open, so they can be reopened on the next start. + * + * **Written by us, into `~/.claude`, and that is the point.** This lived in `workspace.xml` as a + * `PersistentStateComponent`, which means the *platform* decides when it reaches disk — on its own save + * cycle, at exit, if the exit is orderly. Reinstalling the plugin and restarting straight afterwards is + * exactly the case where that write has not happened yet, and the symptom is the honest one: the last chat + * you opened is not restored, while an older list is. Our own file is written the moment the set changes. + * + * Two more reasons it belongs here rather than in the project directory: `.idea/` is shared, synced and + * committed by accident, and this is the user's conversation history; and the agent index + * ([PluginAgentIndex]) already lives here, so restore reads one place instead of two that can disagree. + * + * **Ids only.** No transcript, no title, no prompt: the binary's own session files are the source of truth + * and are re-read on restore. Keyed by the project's path, encoded the way [SessionStore] encodes it, so + * several projects coexist in one file without knowing about each other. */ @Service(Service.Level.PROJECT) -@State(name = "ClaudeCodeSessionHistory", storages = [Storage(StoragePathMacros.WORKSPACE_FILE)]) -class SessionHistory : PersistentStateComponent { +class SessionHistory(private val project: Project) { - class State { - /** Session ids of the tabs that were open, in tab order, as a JSON array string. */ - @JvmField var openJson: String = "" - } - - private var state = State() - - override fun getState(): State = state - override fun loadState(s: State) = XmlSerializerUtil.copyBean(s, state) + private val log = logger() /** Records the currently-open tabs' session ids (in tab order) so they can be reopened on next startup. */ @Synchronized fun setOpenSessions(ids: List) { - state.openJson = encodeIds(ids.filter { it.isNotBlank() }) + val key = projectKey() ?: return + val all = readAll().toMutableMap() + all[key] = ids.filter { it.isNotBlank() } + write(all) } - /** Session ids of the tabs open at last save, in the stored order. */ + /** + * Session ids of the tabs open at last save, in the stored order. + * + * Migrates on first read: a project that has nothing in our file but has the old `workspace.xml` entry + * gets that list adopted and written here, once. Without it, everyone upgrading to 5.5.0 would lose + * their open tabs on the first start — the opposite of what moving the file was for. + */ @Synchronized - fun openSessions(): List = decodeIds(state.openJson) + fun openSessions(): List { + val key = projectKey() ?: return emptyList() + readAll()[key]?.let { return it } + val legacy = runCatching { LegacySessionHistory.getInstance(project).openSessions() } + .getOrDefault(emptyList()) + if (legacy.isNotEmpty()) { + log.info("migrating ${legacy.size} open chat(s) from workspace.xml to ~/.claude") + setOpenSessions(legacy) + } + return legacy + } + + private fun projectKey(): String? = project.basePath?.takeIf { it.isNotBlank() }?.let(SessionStore::encodePath) + + private fun readAll(): Map> { + val file = indexFile() ?: return emptyMap() + val body = runCatching { Files.readString(file) }.getOrNull().orEmpty() + return decode(body) + } + + private fun write(all: Map>) { + val file = indexFile() ?: return + runCatching { + Files.createDirectories(file.parent) + Files.writeString(file, encode(all)) + }.onFailure { + // Best-effort: the cost is the next start opening the wrong set of tabs, not lost data — the + // conversations themselves are the binary's files. Logged because "my chats stopped coming + // back" is otherwise unexplainable. + log.warn("could not persist the open-chat list to ${file.parent}", it) + } + } + + /** `~/.claude/ide/claude-code-native/open-chats.json`, or null when there is no home to write into. */ + private fun indexFile(): Path? = PluginAgentIndex.homeDir()?.let { Paths.get(it) } + ?.resolve(DIR_IDE)?.resolve(DIR_PLUGIN)?.resolve(FILE) companion object { private val JSON = Json { ignoreUnknownKeys = true } + private const val DIR_IDE = "ide" + private const val DIR_PLUGIN = "claude-code-native" + private const val FILE = "open-chats.json" + fun getInstance(project: Project): SessionHistory = project.service() - /** Serializes the open-session id list to a JSON string. Pure — unit-testable. */ + /** Serializes the whole map (project → open session ids). Pure — unit-testable. */ + fun encode(all: Map>): String = + runCatching { JSON.encodeToString(all) }.getOrDefault("") + + /** Parses it back; blank or corrupt input yields an empty map rather than throwing. */ + fun decode(text: String): Map> { + if (text.isBlank()) return emptyMap() + return runCatching { JSON.decodeFromString>>(text) } + .getOrDefault(emptyMap()) + } + + /** Serializes an id list. Kept for the existing tests that pin the encoding. */ fun encodeIds(list: List): String = runCatching { JSON.encodeToString(list) }.getOrDefault("") - /** Parses a JSON string back to an id list; tolerates blank/corrupt input (→ empty, never throws). */ + /** Parses an id list; tolerates blank/corrupt input (→ empty, never throws). */ fun decodeIds(text: String): List { if (text.isBlank()) return emptyList() return runCatching { JSON.decodeFromString>(text) }.getOrDefault(emptyList()) diff --git a/src/main/kotlin/dev/lain/claudejb/settings/AlwaysAllowTools.kt b/src/main/kotlin/dev/lain/claudejb/settings/AlwaysAllowTools.kt new file mode 100644 index 00000000..9c02eb9c --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/settings/AlwaysAllowTools.kt @@ -0,0 +1,37 @@ +package dev.lain.claudejb.settings + +/** + * The tools the user chose to auto-approve — the "Always allow" set, kept as one CSV field. + * + * Keyed by tool NAME only. Path containment for reviewable writes is enforced independently by the broker + * (`DiffPresenter.isWithinRoot`), so a remembered write outside the project root still falls through to a + * manual card, and a remembered tool never widens where it may write. + * + * Its own class rather than five methods on [ClaudeSettings]: it is one subject with one representation, and + * every mutation must persist — a bare `state.alwaysAllowTools = …` is a change that silently does not + * survive a restart, which is exactly the bug these went through [ClaudeSettings.update] to fix. + */ +class AlwaysAllowTools(private val settings: ClaudeSettings) { + + /** Trimmed, non-empty, de-duplicated, order-stable. */ + fun all(): List = + settings.state.alwaysAllowTools.split(',').map { it.trim() }.filter { it.isNotEmpty() }.distinct() + + operator fun contains(toolName: String): Boolean = toolName.isNotBlank() && toolName in all() + + /** Idempotent. */ + fun remember(toolName: String) { + if (toolName.isBlank() || toolName in this) return + replace(all() + toolName) + } + + fun forget(toolName: String) { + val target = toolName.trim() + if (target.isEmpty()) return + replace(all().filterNot { it == target }) + } + + fun replace(tools: List) = settings.update { + it.alwaysAllowTools = tools.map { t -> t.trim() }.filter { t -> t.isNotEmpty() }.distinct().joinToString(",") + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/settings/ClaudeSettings.kt b/src/main/kotlin/dev/lain/claudejb/settings/ClaudeSettings.kt index d18308f2..740a46c7 100644 --- a/src/main/kotlin/dev/lain/claudejb/settings/ClaudeSettings.kt +++ b/src/main/kotlin/dev/lain/claudejb/settings/ClaudeSettings.kt @@ -29,14 +29,27 @@ import kotlinx.serialization.json.jsonPrimitive * The no-arg constructor exists for the project service and for plain unit tests; [project] is null * in tests so the trust-flag helpers degrade gracefully (treat the project as untrusted). */ +// NB no longer a PersistentStateComponent, and no longer `.idea/claude-code.xml`. The settings are GLOBAL +// (one set for every project) and live in ~/.claude — see SettingsStore for the three reasons, the one that +// matters most being that `envVars` is stored in the clear and was sitting in a file people commit. +// LegacyProjectSettings reads the old file once so nobody loses their configuration on upgrade. @Service(Service.Level.PROJECT) -@State(name = "ClaudeCodeSettings", storages = [Storage("claude-code.xml")]) -class ClaudeSettings(private val project: Project? = null) : PersistentStateComponent { +class ClaudeSettings(private val project: Project? = null) { + // Serializable because SettingsStore's JSON document IS this class, field for field: an unknown key + // from a newer version is ignored, a missing key falls back to the property's default. + @kotlinx.serialization.Serializable class State { @JvmField var model: String = ClaudeSession.DEFAULT_MODEL - @JvmField var effort: String = "medium" + /** + * Reasoning effort on a fresh install (or when no configuration could be read): **high**. + * + * The pinned model is the top Opus tier, and pairing it with a middling effort is choosing the + * expensive model and then asking it not to think. A user who wants cheaper answers changes one + * combo; a user who never opens Settings gets the tier they are paying for. + */ + @JvmField var effort: String = "high" @JvmField var permissionMode: String = "default" @@ -275,10 +288,59 @@ class ClaudeSettings(private val project: Project? = null) : PersistentStateComp return if (fixture.isNotBlank()) mapOf("FAKE_FIXTURE" to fixture) else emptyMap() } - private var state = State() + /** + * The settings, loaded once from `~/.claude/ide/claude-code-native/settings.json`. + * + * **Global, not per project, and written by us.** They used to be a `PersistentStateComponent` in + * `.idea/claude-code.xml`, which had three problems the move fixes: the platform decided when it reached + * disk, deleting `.idea` (or a fresh clone) lost them, and `envVars` — which the settings UI itself warns + * is stored in the clear — sat in a file people commit. One model, one permission mode, one set of + * allowed tools for every project is also what the user asked for. + */ + private var loaded: State? = null + + val state: State + @Synchronized get() = loaded ?: run { + // Migration runs before the first read, so an upgrading user never sees defaults: the old + // project file is adopted (or dropped, if another project already won) and then removed. + project?.let { runCatching { LegacyProjectSettings.getInstance(it).migrate(it) } } + SettingsStore.load().also { loaded = it } + } - override fun getState(): State = state - override fun loadState(s: State) = XmlSerializerUtil.copyBean(s, state) + /** + * Replaces the in-memory settings without touching disk. + * + * For tests, which need a known starting point on a project service the light fixture reuses across + * methods. It does NOT save, so a test that wants persistence has to ask for it — and should point + * [PluginAgentIndex.homeOverride] at a temp directory first, for the reason `CredentialsVault` learned + * the hard way. + */ + @org.jetbrains.annotations.TestOnly + @Synchronized + fun replaceState(s: State) { + loaded = s + } + + /** + * Mutates the settings and persists them, in one call. + * + * **This is the only way to change a setting, and that is deliberate.** Nothing saves for us since the + * settings became the plugin's own file, so a bare `state.x = y` is a change that silently does not + * survive a restart — and six such sites already existed the moment the persistence changed. Making the + * mutation and the write one operation removes that failure mode instead of relying on everyone + * remembering. + */ + fun update(block: (State) -> Unit) { + block(state) + save() + } + + /** Persists the current settings. Prefer [update]; this is for the settings form, which edits in bulk. */ + fun save() = SettingsStore.save(state) + + // NB no explicit `getState()`: the `state` property already generates one with that exact JVM + // signature, so declaring both is a platform clash. Callers that used `getState()` keep working — + // it is the property's own getter. /** Seeds the session's launch options from persisted defaults (call before start()). */ fun applyTo(session: ClaudeSession) { @@ -304,19 +366,17 @@ class ClaudeSettings(private val project: Project? = null) : PersistentStateComp ) } - // --- "Always allow" per tool ---------------------------------------------------------------- - // Remembers tool names the user opted to auto-approve. Keyed by tool name only; path containment - // for reviewable writes is enforced independently by the broker (isWithinRoot), so a remembered - // write outside the project root still falls through to a manual card. The [input] param is kept - // for future-proofing (e.g. per-command/per-path rules) even though it is currently unused. - - private fun alwaysAllowSet(): Set = - state.alwaysAllowTools.split(',').map { it.trim() }.filter { it.isNotEmpty() }.toSet() + /** The remembered "Always allow" tool names — see [AlwaysAllowTools], which owns the whole subject. */ + val alwaysAllow = AlwaysAllowTools(this) - /** True when [toolName] was previously marked "Always allow". */ + /** + * True when [toolName] was previously marked "Always allow". + * + * [input] is kept for future-proofing (per-command / per-path rules) even though it is unused: the + * broker's callback signature is the place a narrower rule would arrive. + */ @Suppress("UNUSED_PARAMETER") - fun isToolAlwaysAllowed(toolName: String, input: JsonObject): Boolean = - toolName.isNotBlank() && toolName in alwaysAllowSet() + fun isToolAlwaysAllowed(toolName: String, input: JsonObject): Boolean = toolName in alwaysAllow /** The active sensitive-path globs: the built-in blacklist **plus** the user's extras (additive, never less). */ fun sensitiveGlobs(): List { @@ -354,30 +414,6 @@ class ClaudeSettings(private val project: Project? = null) : PersistentStateComp ) } - /** Adds [toolName] to the remembered "Always allow" set (idempotent) and persists. */ - fun rememberToolAlwaysAllow(toolName: String) { - if (toolName.isBlank()) return - val current = alwaysAllowSet() - if (toolName in current) return - state.alwaysAllowTools = (current + toolName).joinToString(",") - } - - /** The remembered "Always allow" tool names: trimmed, non-empty, de-duplicated, order-stable. */ - fun alwaysAllowedTools(): List = - state.alwaysAllowTools.split(',').map { it.trim() }.filter { it.isNotEmpty() }.distinct() - - /** Replaces the remembered "Always allow" set with [tools] (trimmed, non-empty, de-duplicated) and persists. */ - fun setAlwaysAllowedTools(tools: List) { - state.alwaysAllowTools = tools.map { it.trim() }.filter { it.isNotEmpty() }.distinct().joinToString(",") - } - - /** Removes [toolName] from the remembered "Always allow" set and persists. */ - fun forgetToolAlwaysAllow(toolName: String) { - val target = toolName.trim() - if (target.isEmpty()) return - state.alwaysAllowTools = alwaysAllowSet().filterNot { it == target }.joinToString(",") - } - // --- Trust gate (trust-on-open) ------------------------------------------------------------- // Lightweight, non-blocking consent flag for potentially dangerous execution coming from // project-persisted settings (sourceScript / custom stdio MCP servers). These helpers only read diff --git a/src/main/kotlin/dev/lain/claudejb/settings/LegacyProjectSettings.kt b/src/main/kotlin/dev/lain/claudejb/settings/LegacyProjectSettings.kt new file mode 100644 index 00000000..96fee94e --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/settings/LegacyProjectSettings.kt @@ -0,0 +1,74 @@ +package dev.lain.claudejb.settings + +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.project.Project +import com.intellij.util.xmlb.XmlSerializerUtil +import java.nio.file.Files +import java.nio.file.Paths + +/** + * Reads the settings where they used to live — `.idea/claude-code.xml` — and hands them over exactly once. + * + * **Migrate, then clean up, in that order.** [SettingsStore] holds the settings now, globally, and without + * this class every user upgrading to 5.5.0 would open the IDE with default settings: no model, no permission + * mode, no allowed tools, no binary path. So the old component is still declared (same name, same storage, + * which is what makes the platform hand us the file that is on disk) and read once. + * + * **The first project to migrate wins.** Settings are global now, so if several projects each carry their + * own `claude-code.xml`, only the first one adopted becomes the global set; the rest are removed without + * being adopted. That is the user's decision, made knowingly: one model, one mode, one set of tools. + * + * Nothing is deleted until the new location holds the data — [migrate] removes the project file only after + * [SettingsStore] reports that it exists. + */ +@Service(Service.Level.PROJECT) +@State(name = "ClaudeCodeSettings", storages = [Storage("claude-code.xml")]) +internal class LegacyProjectSettings : PersistentStateComponent { + + private val log = logger() + private var state = ClaudeSettings.State() + + override fun getState(): ClaudeSettings.State = state + override fun loadState(s: ClaudeSettings.State) = XmlSerializerUtil.copyBean(s, state) + + /** + * Adopts this project's old settings when nothing has been written globally yet, then removes the old + * file either way. + * + * **Once the global settings exist, they are THE settings.** A project's leftover `claude-code.xml` is + * not a second opinion to be reconciled at every start — it is a file from a previous version, and + * leaving it around means every reinstall gets another chance to let it speak. So it goes, adopted or + * not. + * + * That is safe because of what [SettingsStore.migrateFrom] refuses to do: it never overwrites an + * existing global file, and it never creates one from a legacy state that carries nothing. The way a + * configuration actually got lost was not the delete — it was a state of pure defaults being written as + * if it were a migration, which made the real one, in a project opened later, arrive too late to matter. + */ + fun migrate(project: Project) { + val adopted = SettingsStore.migrateFrom(state) + if (!adopted && !SettingsStore.exists()) return // nothing written anywhere yet: keep it, try later + deleteProjectFile(project, adopted) + } + + private fun deleteProjectFile(project: Project, adopted: Boolean) { + val base = project.basePath ?: return + val file = Paths.get(base, ".idea", "claude-code.xml") + if (!Files.exists(file)) return + runCatching { Files.delete(file) } + .onSuccess { + val why = if (adopted) "after adopting it globally" else "the global settings already exist" + log.info("removed the legacy $file — $why") + } + .onFailure { log.warn("could not remove the legacy settings file $file", it) } + } + + companion object { + fun getInstance(project: Project): LegacyProjectSettings = project.service() + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/settings/SafeAlarm.kt b/src/main/kotlin/dev/lain/claudejb/settings/SafeAlarm.kt new file mode 100644 index 00000000..d91af43c --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/settings/SafeAlarm.kt @@ -0,0 +1,55 @@ +package dev.lain.claudejb.settings + +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.diagnostic.logger +import dev.lain.claudejb.session.ClaudeSession +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Says out loud that the IDE's password safe would not keep what we asked it to keep. + * + * **Why this exists.** The plugin puts everything it must remember into `PasswordSafe` — the OAuth + * credential, the API keys, and since this release the settings — and `PasswordSafe.set` reports failure to + * nobody: it returns `Unit`, throws nothing, and the backend logs its own SEVERE afterwards, if at all. On + * this machine that was + * `secret_password_store_sync error code 36 — Can't find session /org/freedesktop/secrets/session/928`: an + * expired Secret Service session, with libsecret in use and KWallet never even asked (which is why its + * "an application wants access" prompt never appeared and its wallets stayed empty). + * + * From the outside the plugin then looks like it is losing things at random: the settings do not survive a + * restart, and the login is gone. Neither is something the user can guess, and both are things only they can + * fix — unlock the keyring, or point Settings ▸ Appearance & Behavior ▸ System Settings ▸ Passwords at a + * store that works. So it is a notification, not a log line. + * + * Once per IDE run: a failing store fails on every write, and one warning is information while twenty is + * noise people learn to dismiss. + */ +internal object SafeAlarm { + + private val log = logger() + private val warned = AtomicBoolean(false) + + fun storeFailed() { + log.warn("the IDE password safe refused to store a value (it did not read back)") + if (!warned.compareAndSet(false, true)) return + val app = ApplicationManager.getApplication() ?: return + if (app.isUnitTestMode || app.isHeadlessEnvironment) return + app.invokeLater { + NotificationGroupManager.getInstance() + .getNotificationGroup(ClaudeSession.NOTIFICATION_GROUP) + .createNotification( + "Claude Code cannot store your settings securely", + "The IDE's password safe would not keep them: a value written to it could not be read " + + "back. Until this is fixed, your settings and your sign-in will not survive a " + + "restart — nothing is being written in plain text instead.

" + + "Check Settings ▸ Appearance & Behavior ▸ System Settings ▸ Passwords, " + + "and on Linux that the keyring (GNOME Keyring / KWallet) is running and unlocked. " + + "The IDE log records the store's own error.", + NotificationType.ERROR, + ) + .notify(null) + } + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/settings/SecretStore.kt b/src/main/kotlin/dev/lain/claudejb/settings/SecretStore.kt index 7b1d90d1..79df9d0c 100644 --- a/src/main/kotlin/dev/lain/claudejb/settings/SecretStore.kt +++ b/src/main/kotlin/dev/lain/claudejb/settings/SecretStore.kt @@ -61,10 +61,41 @@ object SecretStore { */ const val AUTH_STATUS = "CLAUDE_AUTH_STATUS" + /** + * The user's own environment variables for the child process, as the `KEY=value` block they typed. + * + * **They are secrets by construction**: an env block is where people put an API key, a proxy with + * credentials in the URL, or a token for a private registry. They used to sit in plaintext in + * `.idea/claude-code.xml` — a file that gets committed — and the settings UI warned about exactly that + * instead of fixing it. Held here, so they land in the OS keychain like every other secret the plugin + * keeps, and so the settings file that replaced that XML never contains them. + * + * Not in [EXCLUSIVE] (it is not an auth mode and must never evict a credential) and not in [ENV_NAMES] + * (the block is parsed by `ClaudeSettings.parseEnv`, which decides how it reaches the child). + */ + const val ENV_VARS = "CLAUDE_ENV_VARS" + + /** + * The plugin's whole configuration, as the JSON document [dev.lain.claudejb.settings.SettingsStore] + * builds. + * + * **Why the settings live in the safe and not in a file.** They carry the user's environment block, and + * an env block is where an API key, a credentialed proxy URL or a private-registry token ends up. That + * was the reason they left `.idea/claude-code.xml` in the first place — a plaintext file people commit — + * and writing them to `~/.claude/ide/claude-code-native/settings.json` instead only moved the plaintext + * somewhere else. Here the whole document lands in the OS keychain (Keychain / KWallet / DPAPI / + * encrypted file, whatever the IDE is configured with), like every other secret the plugin holds, and + * there is no settings file on disk at all. + * + * It is one entry rather than a field-per-entry because the settings are read and written as a whole, + * and a partial save is a configuration nobody chose. + */ + const val SETTINGS_JSON = "CLAUDE_SETTINGS_JSON" + /** Auth modes: mutually exclusive by construction — setting one clears the others. */ private val EXCLUSIVE = listOf(OAUTH_TOKEN, CREDENTIALS_JSON) - private val NAMES = EXCLUSIVE + ACCOUNT_PROFILE + AUTH_STATUS + private val NAMES = EXCLUSIVE + ACCOUNT_PROFILE + AUTH_STATUS + ENV_VARS + SETTINGS_JSON /** The subset that is injected into the child environment — [CREDENTIALS_JSON] is file-shaped, not env. */ private val ENV_NAMES = listOf(OAUTH_TOKEN) @@ -88,6 +119,24 @@ object SecretStore { if (name in EXCLUSIVE) EXCLUSIVE.filter { it != name }.forEach { clear(it) } } + /** + * Stores [value] and READS IT BACK. Returns false when the safe did not actually keep it. + * + * **The read-back is the whole point, and it is not paranoia.** `PasswordSafe.set` returns `Unit` and + * throws nothing when the OS store rejects the write: on this very machine the IDE logged + * `secret_password_store_sync error code 36 — Can't find session /org/freedesktop/secrets/session/928` + * (an expired Secret Service session) as a SEVERE of its own, *after* our call had returned normally. + * A caller that then deleted the file it had just "migrated" — which is what both this store and + * [dev.lain.claudejb.process.CredentialsVault] do — destroyed the only copy in existence. That is how a + * configuration and a login disappeared on a reinstall, with every line of our code behaving as designed. + * + * So: nothing that deletes an original may call [set]. It must call this, and believe the answer. + */ + fun setVerified(name: String, value: String): Boolean = runCatching { + set(name, value) + get(name) == value + }.getOrElse { false } + fun clear(name: String) { PasswordSafe.instance.set(attributes(name), null) } diff --git a/src/main/kotlin/dev/lain/claudejb/settings/SettingsStore.kt b/src/main/kotlin/dev/lain/claudejb/settings/SettingsStore.kt new file mode 100644 index 00000000..45e60193 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/settings/SettingsStore.kt @@ -0,0 +1,209 @@ +package dev.lain.claudejb.settings + +import com.intellij.openapi.diagnostic.logger +import dev.lain.claudejb.session.PluginAgentIndex +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.StandardCopyOption.REPLACE_EXISTING + +/** + * Where the plugin's settings live: **the IDE's PasswordSafe**, as one JSON document. There is no settings + * file on disk. + * + * **Why not a file.** They started in `.idea/claude-code.xml` — per project, plaintext, committable — and + * the reason for moving them was that the env block belongs to them, and an env block is where an API key, + * a credentialed proxy URL or a registry token ends up. Moving that to + * `~/.claude/ide/claude-code-native/settings.json` fixed the "committable" half and kept the plaintext, so + * it was half a fix. The safe is the same store the OAuth credential and the API keys already use: the OS + * keychain (Keychain, KWallet, DPAPI, or the IDE's encrypted file), application-wide, which is also exactly + * the scope these settings have. + * + * A file that predates this is read once and deleted — see [load]. + * + * The document is the compile-time-generated serialization of [ClaudeSettings.State] — the contract is the + * class: an unknown key from a newer version is ignored, and a missing or damaged key falls back to the + * property's default rather than to null. + */ +internal object SettingsStore { + + private val log = logger() + private val JSON = Json { + ignoreUnknownKeys = true + isLenient = true + prettyPrint = true + encodeDefaults = true + coerceInputValues = true + } + + /** + * Reads the settings from the safe, adopting a pre-existing settings FILE the first time. + * + * **A failed read is not an empty configuration.** If the safe cannot be reached (a locked KWallet, a + * keychain that is not up yet) this returns defaults — but records that it failed, so [save] refuses to + * write over what is in there. Without that, one bad read at startup became a permanent overwrite on the + * next save: the settings "breaking" with nobody having touched them. + */ + fun load(): ClaudeSettings.State { + val stored = runCatching { SecretStore.get(SecretStore.SETTINGS_JSON) } + readFailed = stored.isFailure + stored.onFailure { log.warn("could not read the settings from the password safe", it) } + stored.getOrNull()?.let { body -> + val obj = runCatching { JSON.parseToJsonElement(body).jsonObject }.getOrNull() + if (obj != null) return decode(obj) + log.warn("the stored settings are not readable JSON; using defaults") + readFailed = true // do not let the next save consolidate defaults over something unreadable + return ClaudeSettings.State() + } + if (stored.isFailure) return ClaudeSettings.State() + return adoptFile() + } + + /** + * Adopts `~/.claude/ide/claude-code-native/settings.json` — written by 5.5.0 before the settings moved + * into the safe — and removes it. + * + * Removed rather than left behind: it is plaintext, it holds what the user configured, and leaving a + * stale copy of a configuration around is how the next version ends up reading the wrong one. + */ + private fun adoptFile(): ClaudeSettings.State { + val file = file() ?: return ClaudeSettings.State() + val body = runCatching { Files.readString(file) }.getOrNull() + if (body.isNullOrBlank()) return ClaudeSettings.State() + val obj = runCatching { JSON.parseToJsonElement(body).jsonObject }.getOrNull() + ?: return ClaudeSettings.State().also { keepUnreadable(file) } + val state = decode(obj) + // The env block was already in the safe under its own name; keep it, and fold it into the document. + state.envVars = runCatching { SecretStore.get(SecretStore.ENV_VARS) }.getOrNull().orEmpty() + // DELETE ONLY ON A CONFIRMED SAVE. This exact sequence lost a configuration on this machine: the + // file was adopted and removed, and the safe's write failed a millisecond later + // (`secret_password_store_sync error code 36 — Can't find session …`), which `set` reports to + // nobody. The file was the only copy. Now the copy has to be readable back before the original goes. + if (!save(state)) { + log.warn("keeping $file: the password safe did not accept the settings") + return state + } + runCatching { Files.delete(file) } + .onSuccess { log.info("adopted the settings from $file into the password safe and removed the file") } + .onFailure { log.warn("could not remove the migrated settings file $file", it) } + return state + } + + /** + * Whether the last read of the settings FAILED, as opposed to finding nothing. + * + * The distinction is load-bearing: [save] must not write defaults over a configuration it simply could + * not read this run. + */ + @Volatile + private var readFailed = false + + /** + * Moves an unparseable settings file aside instead of letting the next [save] overwrite it. + * + * Falling back to defaults is the only thing a reader can do, but the file it could not read is the + * user's whole configuration — and the very next save would write defaults over it, turning a bad read + * into permanent loss. Kept as `settings.json.unreadable`, which costs nothing and leaves the evidence + * on disk. Writes are atomic now (see [save]), so this should never trigger; it is the net under it. + */ + private fun keepUnreadable(file: Path) { + log.warn("settings file is not readable JSON; using defaults and keeping it as ${file.fileName}.unreadable") + runCatching { + Files.move(file, file.resolveSibling(file.fileName.toString() + ".unreadable"), REPLACE_EXISTING) + }.onFailure { log.warn("could not set the unreadable settings file aside", it) } + } + + /** + * Writes the whole configuration into the OS credential store, as one document. + * + * ONE destination, and no file: the env block travels inside the document, so an API key or a + * credentialed proxy URL typed into Settings is encrypted at rest exactly like the OAuth credential + * beside it — the same [SecretStore], the same PasswordSafe, the same keychain. + * + * Refuses to write when this run could not READ the settings ([readFailed]). Saving then would replace a + * configuration we never saw with the defaults we fell back to, which is a data loss caused entirely by + * a transient safe. + */ + fun save(state: ClaudeSettings.State): Boolean { + if (readFailed) { + log.warn("not saving the settings: they could not be read this run, and defaults must not replace them") + return false + } + val document = JSON.encodeToString(JsonObject.serializer(), encode(state)) + val stored = SecretStore.setVerified(SecretStore.SETTINGS_JSON, document) + if (!stored) { + // LOUD, not a log line. The IDE's own store failing is invisible from the outside — the settings + // simply do not come back next time — and the user is the only one who can fix it (unlock the + // keyring, or point Settings ▸ Appearance & Behavior ▸ System Settings ▸ Passwords elsewhere). + SafeAlarm.storeFailed() + return false + } + // The env block used to be its own entry. It rides inside the document now, so the old one is + // dropped — but only once the document is verifiably in the safe. + runCatching { SecretStore.clear(SecretStore.ENV_VARS) } + return true + } + + /** + * Adopts a legacy per-project state and writes it here, once. + * + * Returns true when it actually migrated, which is the caller's signal to remove the old file — the + * order matters: nothing is deleted until the new location holds the data. + * + * **Two things it refuses to do, and both were real ways to lose a configuration.** + * + * It never touches an existing file: once these settings live here, THIS is the configuration, and a + * project's leftover XML is history, not an input. + * + * And it never creates the file out of a legacy state that carries nothing. The legacy component is + * declared with the old name and storage, so the platform hands us `claude-code.xml` when the project + * has one — and a state of pure defaults when it does not. Writing that was indistinguishable from a + * real migration: the first project opened after a reinstall could CREATE the global file full of + * factory values and mark the migration done, so the actual settings (in another project's XML, opened + * later) were never adopted and the user saw a plugin reset to defaults. Nothing to migrate is now + * exactly that — nothing — and the next project that does carry an XML still gets its turn. + */ + fun migrateFrom(legacy: ClaudeSettings.State): Boolean { + if (exists()) return false // already migrated, or already configured here + if (encode(legacy) == encode(ClaudeSettings.State())) { + log.info("no legacy settings to migrate (the project carries none)") + return false + } + save(legacy) + log.info("migrated plugin settings from the project's claude-code.xml into the password safe") + return exists() + } + + /** Whether a configuration has ever been stored. */ + fun exists(): Boolean = runCatching { SecretStore.get(SecretStore.SETTINGS_JSON) != null }.getOrDefault(false) + + /** + * The settings FILE 5.5.0 used to write, for [adoptFile] to read once and delete. Nothing writes here. + * + * `homeDir()`, never `homeOverride`: in a test JVM that has not named a directory of its own it returns + * null. That is the fix for the worst bug of this release — the tests were reading and writing the + * developer's real configuration, which is how `haiku` and `some-unlisted-model`, both of them test + * fixtures, ended up in a live install. + */ + private fun file(): Path? = PluginAgentIndex.homeDir()?.let { Paths.get(it) } + ?.resolve("ide")?.resolve("claude-code-native")?.resolve("settings.json") + + // The document IS the @Serializable State, field for field. `encodeDefaults` keeps every key present + // (the document is the contract); `ignoreUnknownKeys`/`coerceInputValues` make a newer or damaged key + // fall back to the property's default instead of breaking the read. The env block rides INSIDE the + // document: it used to be excluded because the document was a plaintext file — moot now that the + // document itself lives in the OS credential store. + private fun encode(s: ClaudeSettings.State): JsonObject = + JSON.encodeToJsonElement(ClaudeSettings.State.serializer(), s).jsonObject + + private fun decode(o: JsonObject): ClaudeSettings.State = + runCatching { JSON.decodeFromJsonElement(ClaudeSettings.State.serializer(), o) } + .getOrElse { + log.warn("stored settings did not decode; using defaults", it) + readFailed = true // do not let the next save consolidate defaults over this + ClaudeSettings.State() + } +} diff --git a/src/main/kotlin/dev/lain/claudejb/settings/SettingsStoreTestAccess.kt b/src/main/kotlin/dev/lain/claudejb/settings/SettingsStoreTestAccess.kt new file mode 100644 index 00000000..70e9fe53 --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/settings/SettingsStoreTestAccess.kt @@ -0,0 +1,18 @@ +package dev.lain.claudejb.settings + +import org.jetbrains.annotations.TestOnly + +/** + * Test-only door onto [SettingsStore], which is `internal` to this package. + * + * The store is deliberately not public: everything in production goes through [ClaudeSettings], so that the + * settings have one owner and one place that decides when they are written. The tests, however, need to + * exercise the store itself — the safe round-trip, the field coverage, the migration rules — without a + * project service in the way. + */ +@TestOnly +object SettingsStoreTestAccess { + fun load(): ClaudeSettings.State = SettingsStore.load() + fun save(state: ClaudeSettings.State) = SettingsStore.save(state) + fun migrateFrom(legacy: ClaudeSettings.State): Boolean = SettingsStore.migrateFrom(legacy) +} diff --git a/src/test/kotlin/dev/lain/claudejb/headless/ClaudeSettingsHeadlessTest.kt b/src/test/kotlin/dev/lain/claudejb/headless/ClaudeSettingsHeadlessTest.kt index 854a04c9..e206f2e1 100644 --- a/src/test/kotlin/dev/lain/claudejb/headless/ClaudeSettingsHeadlessTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/headless/ClaudeSettingsHeadlessTest.kt @@ -3,6 +3,8 @@ package dev.lain.claudejb.headless import com.intellij.testFramework.fixtures.BasePlatformTestCase import dev.lain.claudejb.session.ClaudeSession import dev.lain.claudejb.settings.ClaudeSettings +import dev.lain.claudejb.settings.SecretStore +import dev.lain.claudejb.settings.SettingsStoreTestAccess import kotlinx.serialization.json.JsonObject /** Headless: the [ClaudeSettings] project service holds launch defaults and the "Always allow" tool set. */ @@ -14,7 +16,16 @@ class ClaudeSettingsHeadlessTest : BasePlatformTestCase() { override fun setUp() { super.setUp() // The light-fixture project service is reused across methods; restore the defaults under test. - settings.loadState(ClaudeSettings.State()) + settings.replaceState(ClaudeSettings.State()) + } + + /** Some of these tests persist (that IS the behaviour under test); the safe must not carry it forward. */ + override fun tearDown() { + try { + SecretStore.clear(SecretStore.SETTINGS_JSON) + } finally { + super.tearDown() + } } fun `test getInstance returns the project service`() { @@ -49,12 +60,11 @@ class ClaudeSettingsHeadlessTest : BasePlatformTestCase() { assertTrue(policy.enforceForeignNetworkMounts) } - fun `test state mutation survives getState loadState round-trip`() { - settings.state.model = "sonnet" - val saved = settings.getState() - val reloaded = ClaudeSettings() - reloaded.loadState(saved) - assertEquals("sonnet", reloaded.state.model) + fun `test a replaced state is what the settings then report`() { + // The persistence itself is covered by SettingsStoreCoverageTest, which points the home at a temp + // directory. Here the object contract is enough: what you put in is what the rest of the plugin reads. + settings.replaceState(ClaudeSettings.State().apply { model = "sonnet" }) + assertEquals("sonnet", settings.state.model) } fun `test parseEnv reads KEY VALUE lines`() { @@ -67,17 +77,23 @@ class ClaudeSettingsHeadlessTest : BasePlatformTestCase() { fun `test remember and forget always-allow tool`() { assertFalse(settings.isToolAlwaysAllowed("Bash", emptyInput)) - settings.rememberToolAlwaysAllow("Bash") + settings.alwaysAllow.remember("Bash") assertTrue(settings.isToolAlwaysAllowed("Bash", emptyInput)) - assertTrue("Bash" in settings.alwaysAllowedTools()) - settings.forgetToolAlwaysAllow("Bash") + assertTrue("Bash" in settings.alwaysAllow.all()) + settings.alwaysAllow.forget("Bash") assertFalse(settings.isToolAlwaysAllowed("Bash", emptyInput)) - assertFalse("Bash" in settings.alwaysAllowedTools()) + assertFalse("Bash" in settings.alwaysAllow.all()) + } + + fun `test remembering a tool is idempotent`() { + settings.alwaysAllow.remember("Edit") + settings.alwaysAllow.remember("Edit") + assertEquals(listOf("Edit"), settings.alwaysAllow.all()) } - fun `test rememberToolAlwaysAllow is idempotent`() { - settings.rememberToolAlwaysAllow("Edit") - settings.rememberToolAlwaysAllow("Edit") - assertEquals(listOf("Edit"), settings.alwaysAllowedTools()) + /** The mutation and the write are one operation: a remembered tool must survive a restart. */ + fun `test remembering a tool persists`() { + settings.alwaysAllow.remember("Write") + assertTrue("Write" in SettingsStoreTestAccess.load().alwaysAllowTools) } } diff --git a/src/test/kotlin/dev/lain/claudejb/headless/SessionHistoryHeadlessTest.kt b/src/test/kotlin/dev/lain/claudejb/headless/SessionHistoryHeadlessTest.kt index a5a4dd37..f15cfa29 100644 --- a/src/test/kotlin/dev/lain/claudejb/headless/SessionHistoryHeadlessTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/headless/SessionHistoryHeadlessTest.kt @@ -1,18 +1,46 @@ package dev.lain.claudejb.headless import com.intellij.testFramework.fixtures.BasePlatformTestCase +import dev.lain.claudejb.session.PluginAgentIndex import dev.lain.claudejb.session.SessionHistory +import java.nio.file.Files +import java.nio.file.Path -/** Headless: the [SessionHistory] persistent component round-trips the open-tab id list through workspace.xml. */ +/** + * Headless: the open-chat list round-trips through the plugin's own file under `~/.claude`. + * + * It used to be a `PersistentStateComponent` in `workspace.xml`, and the reason it is not any more is the + * failure this suite cannot reproduce but the user hit: the platform decides when that file reaches disk, so + * reinstalling the plugin and restarting straight afterwards restored an older list and dropped the last + * chat opened. What this test CAN pin is the contract that replaced it. + * + * **The home is redirected to a temp directory for the whole class.** A test JVM writing into the + * developer's real `~/.claude` is not hypothetical here: it is exactly how an earlier run harvested and + * deleted live credentials, which is why `CredentialsVault` grew the same override. + */ class SessionHistoryHeadlessTest : BasePlatformTestCase() { + private lateinit var tempHome: Path + private var previousHome: String? = null + private val history get() = SessionHistory.getInstance(project) override fun setUp() { super.setUp() + previousHome = PluginAgentIndex.homeOverride + tempHome = Files.createTempDirectory("claude-home-test") + PluginAgentIndex.homeOverride = tempHome.toString() history.setOpenSessions(emptyList()) } + override fun tearDown() { + try { + PluginAgentIndex.homeOverride = previousHome + } finally { + super.tearDown() + } + } + fun `test getInstance returns the project service`() { assertNotNull(history) assertSame(history, SessionHistory.getInstance(project)) @@ -23,17 +51,25 @@ class SessionHistoryHeadlessTest : BasePlatformTestCase() { assertEquals(listOf("a", "b"), history.openSessions()) } - fun `test getState loadState round-trip survives a simulated reload`() { + fun `test the list survives a fresh service reading the same file`() { history.setOpenSessions(listOf("x", "y", "z")) - val saved = history.getState() - // Simulate the platform reloading persisted state into a fresh component. - val reloaded = SessionHistory() - reloaded.loadState(saved) - assertEquals(listOf("x", "y", "z"), reloaded.openSessions()) + // The persistence IS the file, so "reload" means reading it again rather than replaying a state + // object the platform hands back. + assertEquals(listOf("x", "y", "z"), SessionHistory.getInstance(project).openSessions()) + val file = tempHome.resolve("ide/claude-code-native/open-chats.json") + assertTrue("the plugin must write its own file", Files.exists(file)) + assertTrue(Files.readString(file).contains("\"x\"")) } fun `test blank ids are filtered out`() { history.setOpenSessions(listOf("a", "", " ", "b")) assertEquals(listOf("a", "b"), history.openSessions()) } + + fun `test a corrupt file reads as empty instead of throwing`() { + val file = tempHome.resolve("ide/claude-code-native/open-chats.json") + Files.createDirectories(file.parent) + Files.writeString(file, "{not json") + assertEquals(emptyList(), history.openSessions()) + } } diff --git a/src/test/kotlin/dev/lain/claudejb/headless/SettingsStoreHeadlessTest.kt b/src/test/kotlin/dev/lain/claudejb/headless/SettingsStoreHeadlessTest.kt new file mode 100644 index 00000000..cf5a58bc --- /dev/null +++ b/src/test/kotlin/dev/lain/claudejb/headless/SettingsStoreHeadlessTest.kt @@ -0,0 +1,126 @@ +package dev.lain.claudejb.headless + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import dev.lain.claudejb.settings.ClaudeSettings +import dev.lain.claudejb.settings.SecretStore +import dev.lain.claudejb.settings.SettingsStoreTestAccess + +/** + * The settings persist into the IDE's PasswordSafe — the OS credential store — and nowhere else. + * + * **Headless rather than plain-JVM, and that is the point.** The store talks to `PasswordSafe.instance`, + * which only exists inside a platform fixture. The version of these tests that ran on a bare JVM wrote to + * `~/.claude/ide/claude-code-native/settings.json` — the DEVELOPER'S OWN configuration — because nothing + * stopped it: a real install was found holding `some-unlisted-model` and, before that, `haiku`, both of them + * literals out of these very tests. It read as the plugin corrupting itself on reinstall, since the + * reinstall always followed a `./gradlew test`. + * + * Two rules come out of that and are pinned below: the settings live in the safe, and a read that FAILS is + * not an empty configuration. + */ +class SettingsStoreHeadlessTest : BasePlatformTestCase() { + + override fun tearDown() { + try { + SecretStore.clear(SecretStore.SETTINGS_JSON) + } finally { + super.tearDown() + } + } + + fun `test every state field survives a round trip through the safe`() { + val saved = ClaudeSettings.State().apply { + model = "claude-opus-5[1m]" + permissionMode = "acceptEdits" + maxTurns = 7 + maxBudgetUsd = 12.5 + addDirs = "/tmp/a\n/tmp/b" + strictMcpConfig = true + securityBlockForeignWslMounts = false + envVars = "FOO=bar\nTOKEN=shhh" + } + SettingsStoreTestAccess.save(saved) + val loaded = SettingsStoreTestAccess.load() + assertEquals("claude-opus-5[1m]", loaded.model) + assertEquals("acceptEdits", loaded.permissionMode) + assertEquals(7, loaded.maxTurns) + assertEquals(12.5, loaded.maxBudgetUsd) + assertEquals("/tmp/a\n/tmp/b", loaded.addDirs) + assertTrue(loaded.strictMcpConfig) + assertFalse(loaded.securityBlockForeignWslMounts) + // The env block rides inside the document now — the whole thing is encrypted at rest, so there is no + // longer a reason to keep it in a second entry. + assertEquals("FOO=bar\nTOKEN=shhh", loaded.envVars) + } + + /** + * Every field of [ClaudeSettings.State] is actually persisted — checked against the class, not against a + * list someone maintains by hand. + * + * The hand-written serialiser missed nine fields on its first pass. A settings store that silently drops + * one is worse than one that fails: it works until the next restart, and nothing says why it reverted. + */ + fun `test no state field is silently dropped`() { + // Instance fields only: a setting is an instance field. `@Serializable` adds a static `Companion` + // (the generated serializer's holder), which is not a setting and has nothing to persist. + val fields = ClaudeSettings.State::class.java.declaredFields + .filterNot { java.lang.reflect.Modifier.isStatic(it.modifiers) } + .map { it.name } + .filterNot { it.startsWith("$") } + SettingsStoreTestAccess.save(ClaudeSettings.State()) + val stored = SecretStore.get(SecretStore.SETTINGS_JSON).orEmpty() + val missing = fields.filterNot { stored.contains("\"$it\"") } + assertTrue("these settings are never persisted: $missing", missing.isEmpty()) + } + + /** With nothing stored, the plugin starts on the pinned tier, asking every time, thinking hard. */ + fun `test the defaults are Opus, ask each time, high effort`() { + SecretStore.clear(SecretStore.SETTINGS_JSON) + val fresh = SettingsStoreTestAccess.load() + assertEquals(dev.lain.claudejb.session.ClaudeSession.DEFAULT_MODEL, fresh.model) + assertEquals("opus[1m]", fresh.model) + assertEquals("default", fresh.permissionMode) // PermissionMode.DEFAULT = "Ask each time" + assertEquals("high", fresh.effort) + } + + fun `test an unknown key from a newer version does not break an older one`() { + SecretStore.set(SecretStore.SETTINGS_JSON, """{"model":"x","somethingFromTheFuture":{"a":1}}""") + assertEquals("x", SettingsStoreTestAccess.load().model) + } + + /** Once a configuration is stored it IS the configuration: a legacy project file cannot overwrite it. */ + fun `test an existing configuration is never overwritten by a legacy one`() { + SettingsStoreTestAccess.save(ClaudeSettings.State().apply { model = "the-one-in-use" }) + assertFalse( + SettingsStoreTestAccess.migrateFrom(ClaudeSettings.State().apply { model = "from-an-old-project" }), + ) + assertEquals("the-one-in-use", SettingsStoreTestAccess.load().model) + } + + /** + * A project that carries NO settings must not create one out of factory values. + * + * The legacy component is declared with the old name and storage, so the platform hands it a state of + * pure defaults when the project has no `claude-code.xml`. Adopting that looked exactly like a real + * migration and marked the job done, so a genuine configuration sitting in another project's file was + * never adopted — the plugin came up "reset" and nothing had failed. + */ + fun `test a legacy state carrying nothing is not a migration`() { + SecretStore.clear(SecretStore.SETTINGS_JSON) + assertFalse(SettingsStoreTestAccess.migrateFrom(ClaudeSettings.State())) + assertNull(SecretStore.get(SecretStore.SETTINGS_JSON)) + } + + fun `test a legacy state that carries something is adopted`() { + SecretStore.clear(SecretStore.SETTINGS_JSON) + assertTrue( + SettingsStoreTestAccess.migrateFrom( + ClaudeSettings.State().apply { + model = "from-the-old-file" + claudePath = "/usr/bin/claude" + }, + ), + ) + assertEquals("from-the-old-file", SettingsStoreTestAccess.load().model) + } +} diff --git a/src/test/kotlin/dev/lain/claudejb/process/NoFileDeletionContractTest.kt b/src/test/kotlin/dev/lain/claudejb/process/NoFileDeletionContractTest.kt index 2c384b92..ed25712e 100644 --- a/src/test/kotlin/dev/lain/claudejb/process/NoFileDeletionContractTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/process/NoFileDeletionContractTest.kt @@ -54,9 +54,30 @@ class NoFileDeletionContractTest { "deleteOnExit", ) - /** The one file permitted to delete, and the one thing it is permitted to delete. */ + /** + * The files permitted to delete, and the one thing each is permitted to delete. + * + * `CredentialsVault` removes the plaintext credentials file it has just harvested into the IDE's + * PasswordSafe — that removal IS the feature. + * + * `LegacyProjectSettings` removes the plugin's OWN leftovers in the project's `.idea/` once their + * contents have been migrated to `~/.claude` (5.5.0), which the user asked for explicitly: a migration + * that leaves the old file behind means the next reader has two sources and no way to tell which is + * current. It deletes a file the plugin itself wrote, never a conversation and never anything the user + * authored. + * + * `SettingsStore` removes `~/.claude/ide/claude-code-native/settings.json` after adopting it into the + * PasswordSafe — the same shape as the vault's: harvest first, delete second, and only ever the file the + * plugin wrote itself. The settings moved into the OS credential store because the env block belongs to + * them and an env block holds secrets; leaving the plaintext copy behind would defeat the move. + */ private companion object { - const val ALLOWED = "CredentialsVault.kt" + val ALLOWED = setOf( + "CredentialsVault.kt", + "LegacyProjectSettings.kt", + "LegacySessionHistory.kt", + "SettingsStore.kt", + ) } @Test @@ -73,11 +94,11 @@ class NoFileDeletionContractTest { @Test fun `only CredentialsVault deletes a file`() { - val offenders = ktFiles().filter { it.name != ALLOWED }.flatMap { file -> + val offenders = ktFiles().filterNot { it.name in ALLOWED }.flatMap { file -> hits(file, single).map { "${file.name}:${it.first}: ${it.second}" } } assertTrue(offenders.isEmpty()) { - "Only $ALLOWED may delete a file, and only the plaintext credentials file it harvested. " + + "Only ${ALLOWED.joinToString()} may delete a file, each for the one purpose documented there. " + "Everything else on the user's disk — conversations above all — is theirs.\n" + offenders.joinToString("\n") } @@ -85,7 +106,7 @@ class NoFileDeletionContractTest { @Test fun `the one permitted deletion targets the credentials file and nothing else`() { - val vault = ktFiles().first { it.name == ALLOWED } + val vault = ktFiles().first { it.name == "CredentialsVault.kt" } // Every deleting line in the vault must act on a `file` resolved from credentialsFile(). Pinned by // reading the receiver rather than trusting the filename: the allowlist is per-FILE, so without this // the vault would be a hole big enough to delete anything from. diff --git a/src/test/kotlin/dev/lain/claudejb/settings/ClaudeSettingsParseEnvTest.kt b/src/test/kotlin/dev/lain/claudejb/settings/ClaudeSettingsParseEnvTest.kt index 30406b8d..b87a4e69 100644 --- a/src/test/kotlin/dev/lain/claudejb/settings/ClaudeSettingsParseEnvTest.kt +++ b/src/test/kotlin/dev/lain/claudejb/settings/ClaudeSettingsParseEnvTest.kt @@ -11,7 +11,7 @@ import org.junit.jupiter.api.Test class ClaudeSettingsParseEnvTest { private fun settingsWithEnv(env: String): ClaudeSettings = - ClaudeSettings().also { it.getState().envVars = env } + ClaudeSettings().also { it.state.envVars = env } @Test fun `parses simple KEY=VALUE`() { From 155e5c7122cb6a19bfd80c57796e59f17ac297fc Mon Sep 17 00:00:00 2001 From: Lain Date: Tue, 11 Aug 2026 10:13:11 +0200 Subject: [PATCH 014/141] feat(agents): draw the agent tabs in the page, not in Swing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The strips were built twice in Swing — `JBTabs`, then a Java2D `PillTabsStrip` — and thrown away both times. A strip above the page cannot share the page's accent, its type scale, its transitions or its SVG, so every attempt ends up approximating one in the other by hand, which is exactly what it looked like. The chat UI has been a JCEF page since 4.0.0 and the bar belongs to it. One browser per chat, transcripts SWITCHED rather than one JCEF per agent: the session this feature came from runs dozens at once, and a Chromium process each is not a design. The host sends what EXISTS (the chat list, the agent tree flat, the tasks with their owner) and the page owns what is SHOWN — which single subtab is open. Round-tripping a click through the host would cost a repaint of the host's model to change a selection. Two things about identity, both of which cost a day. The bare agent id is the identity: the file is `agent-.jsonl` but the sidecar says `parentAgentId: ""` unprefixed, and taking the filename as the id collapsed the whole tree into one level. And admission is what separates our agents from the ones a terminal `--resume` leaves in the same directory — the plugin saw the Task call, a previous run recorded it, or its parent is already ours, applied as a fixpoint because a nested agent's `task_started` never reaches the main stream. --- .../dev/lain/claudejb/session/AgentMeta.kt | 37 +- .../lain/claudejb/session/AgentRegistry.kt | 96 ++- .../lain/claudejb/session/PluginAgentIndex.kt | 351 ++++++-- .../dev/lain/claudejb/ui/AgentStripPanel.kt | 220 ----- .../dev/lain/claudejb/ui/AgentTabLabels.kt | 21 +- .../dev/lain/claudejb/ui/AgentTabsPanel.kt | 220 ----- .../dev/lain/claudejb/ui/ChatTabsPanel.kt | 285 ++++--- .../claudejb/ui/ClaudeToolWindowFactory.kt | 14 +- .../dev/lain/claudejb/ui/jcef/JcefTabsData.kt | 144 ++++ src/main/resources/jcef/app-tabs.js | 778 ++++++++++++++++++ src/test/frontend/tabs.test.js | 223 +++++ .../claudejb/session/AgentIndexPrivacyTest.kt | 93 ++- .../lain/claudejb/session/AgentMetaTest.kt | 10 +- .../claudejb/session/AgentRegistryTest.kt | 93 ++- .../session/PluginAgentIndexMigrationTest.kt | 138 ++++ .../lain/claudejb/ui/AgentTabLabelsTest.kt | 20 +- 16 files changed, 2037 insertions(+), 706 deletions(-) delete mode 100644 src/main/kotlin/dev/lain/claudejb/ui/AgentStripPanel.kt delete mode 100644 src/main/kotlin/dev/lain/claudejb/ui/AgentTabsPanel.kt create mode 100644 src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefTabsData.kt create mode 100644 src/main/resources/jcef/app-tabs.js create mode 100644 src/test/frontend/tabs.test.js create mode 100644 src/test/kotlin/dev/lain/claudejb/session/PluginAgentIndexMigrationTest.kt diff --git a/src/main/kotlin/dev/lain/claudejb/session/AgentMeta.kt b/src/main/kotlin/dev/lain/claudejb/session/AgentMeta.kt index 79ba9dd3..be555ee5 100644 --- a/src/main/kotlin/dev/lain/claudejb/session/AgentMeta.kt +++ b/src/main/kotlin/dev/lain/claudejb/session/AgentMeta.kt @@ -22,7 +22,16 @@ import kotlinx.serialization.json.jsonPrimitive * `description` costs a generic tab label, not a dropped agent. */ data class AgentMeta( - /** `agent-` — the id in the file name, and the identity used everywhere else. */ + /** + * The agent's id, **without the `agent-` prefix**. + * + * The binary writes the same id in two shapes, and getting this wrong collapsed the whole tree: the + * file is `agent-a6798878f17f074e4.jsonl`, while the sidecar's `parentAgentId` is the bare + * `a6798878f17f074e4`. Keeping the file-name form as the identity meant no parent ever matched a node, + * so every nested agent hung off something that did not exist: they appeared in no row, their ownership + * chain stopped at the chat, and clicking one went nowhere. The bare id is the identity; the prefix + * belongs to the file name and is added back when reading it ([transcriptFile]). + */ val agentId: String, /** The registered agent type (`general-purpose`, a custom agent…), shown as the tab's tooltip. */ val agentType: String? = null, @@ -42,7 +51,10 @@ data class AgentMeta( ?: agentId companion object { - private val JSON = Json { ignoreUnknownKeys = true; isLenient = true } + private val JSON = Json { + ignoreUnknownKeys = true + isLenient = true + } /** File-name prefix and `.jsonl`/`.meta.json` suffixes the binary uses inside `subagents/`. */ const val FILE_PREFIX = "agent-" @@ -66,10 +78,29 @@ data class AgentMeta( ) } - /** `agent-abc.meta.json` → `agent-abc`; null for anything that is not one of the binary's sidecars. */ + /** + * `agent-abc.meta.json` → `abc`; null for anything that is not one of the binary's sidecars. + * + * The prefix is stripped so the id matches the `parentAgentId` the sidecars themselves use. + */ fun agentIdOfMetaFile(fileName: String): String? = fileName.takeIf { it.startsWith(FILE_PREFIX) && it.endsWith(META_SUFFIX) } ?.removeSuffix(META_SUFFIX) + ?.removePrefix(FILE_PREFIX) + + /** The transcript file name for [agentId] — the prefix lives here, not in the identity. */ + fun transcriptFile(agentId: String): String = "$FILE_PREFIX$agentId$TRANSCRIPT_SUFFIX" + + /** + * The canonical (bare) form of an agent id, whichever shape it arrives in. + * + * Needed because an id in the **prefixed** shape outlived the code that produced it: the persisted + * agent index of 5.5.0's first builds recorded `agent-`, and after the identity became the bare + * id those records matched nothing — a restored chat came back with its agents on disk, in the index, + * and not one tab, because every admitted id was compared against a node key it could not equal. + * Normalising on the way in and out of the index migrates those records instead of stranding them. + */ + fun bareAgentId(raw: String): String = raw.removePrefix(FILE_PREFIX) private fun JsonObject.str(key: String): String? = this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } diff --git a/src/main/kotlin/dev/lain/claudejb/session/AgentRegistry.kt b/src/main/kotlin/dev/lain/claudejb/session/AgentRegistry.kt index 2afbbf8f..aa732c98 100644 --- a/src/main/kotlin/dev/lain/claudejb/session/AgentRegistry.kt +++ b/src/main/kotlin/dev/lain/claudejb/session/AgentRegistry.kt @@ -2,6 +2,7 @@ package dev.lain.claudejb.session import java.nio.file.Files import java.nio.file.Path +import java.util.concurrent.ConcurrentHashMap /** Lifecycle of one agent, as far as the plugin can honestly tell. */ enum class AgentStatus { RUNNING, COMPLETED, FAILED, STOPPED } @@ -18,6 +19,19 @@ data class AgentNode( val agentId: String get() = meta.agentId val parentAgentId: String? get() = meta.parentAgentId val depth: Int get() = meta.spawnDepth + + /** + * What to call this agent on a card: `Agent` when the chat started it, `Subagent` when another agent did. + * + * The binary makes no such distinction — to it everything is an `agent`, and the plugin followed suit, so + * a transcript with four levels of nesting was four rows all saying `Agent (…)`. The word is ours and it + * is the ONE place it is decided, so the transcript, the tab bar's diagram and the Workloads diagram + * cannot end up disagreeing about what to call the same thing. + * + * Parentage, not [depth]: `spawnDepth` is the binary's own counter and a restored agent can carry a value + * that means nothing to us, while "who spawned it" is a link we read from the sidecar and admit agents by. + */ + val kindLabel: String get() = if (parentAgentId != null) "Subagent" else "Agent" } /** @@ -46,14 +60,19 @@ class AgentRegistry( private val subagentsDir: () -> Path?, private val onAdmitted: (agentId: String) -> Unit = {}, ) { + // The three seed collections are CONCURRENT, and that is not defensive dressing: they are written from + // the EDT (the task events arrive there) and read from a pooled thread (that is where `scan` walks the + // directory). With plain collections the worst case is not a stale label, it is a + // ConcurrentModificationException in the middle of an admission pass. Same reasoning, same choice, as + // TaskTracker's backing map. /** `tool_use_id`s of Task calls seen in this session — the seed of the admission rule. */ - private val observedToolUse = LinkedHashSet() + private val observedToolUse: MutableSet = ConcurrentHashMap.newKeySet() /** Terminal status per `tool_use_id`, from `task_notification`. Absent means still running. */ - private val statusByToolUse = HashMap() + private val statusByToolUse = ConcurrentHashMap() /** Agent ids admitted by an outside authority (the persisted index), so a restart keeps them. */ - private val preAdmitted = LinkedHashSet() + private val preAdmitted: MutableSet = ConcurrentHashMap.newKeySet() @Volatile private var snapshot: Map = emptyMap() @@ -75,9 +94,30 @@ class AgentRegistry( if (!toolUseId.isNullOrBlank()) statusByToolUse[toolUseId] = status } - /** Re-admits agents recorded by a previous plugin run (see [PluginAgentIndex]). */ + /** + * Re-admits agents recorded by a previous plugin run (see [PluginAgentIndex]). + * + * Ids are normalised on the way in: a record written before the identity became the bare id carries the + * `agent-` prefix, and comparing that against a node key silently admits nobody — a restored chat with + * every file on disk and not one tab. [AgentMeta.bareAgentId] carries the full account. + */ fun preAdmit(agentIds: Collection) { - preAdmitted += agentIds + preAdmitted += agentIds.map { AgentMeta.bareAgentId(it) } + } + + /** + * This chat was RESTORED: everything in its subagents directory is its own, admit it. + * + * Set once, by [dev.lain.claudejb.session.ClaudeSession.restore], and never cleared — a chat that came + * back from disk keeps its history for as long as it is open, and the agents it spawns afterwards are + * admitted by the ordinary rules anyway. + */ + @Volatile + var restoring: Boolean = false + private set + + fun markRestored() { + restoring = true } /** @@ -92,11 +132,12 @@ class AgentRegistry( val admitted = admissibleIds(metas) val previous = snapshot val next = LinkedHashMap() + // Shallowest first, so a child is always resolved AFTER the parent it inherits its ending from. for (id in admitted.sortedWith(compareBy({ metas[it]?.spawnDepth ?: 1 }, { it }))) { val meta = metas[id] ?: continue next[id] = AgentNode( meta = meta, - status = statusByToolUse[meta.toolUseId] ?: AgentStatus.RUNNING, + status = statusOf(meta, next), entries = readTranscript(dir, id), ) } @@ -106,6 +147,30 @@ class AgentRegistry( return fresh.toList() } + /** + * How this agent ended, in order of evidence. + * + * 1. Its own `task_notification`, when the plugin saw the Task call that started it. + * 2. **Its parent's ending.** A NESTED agent has no `toolUseId` of its own — it was spawned inside + * another agent's turn, so no Task call of ours ever named it — and rule 1 can therefore never + * settle it: every subagent below the first level stayed RUNNING for ever, pulsing away in the tab + * bar and the diagram long after its work was done. It cannot outlive the turn that spawned it, so + * once the parent has an ending, that ending is the child's too. + * 3. Otherwise RUNNING — but only while there is a process that could be running it. In a RESTORED chat + * there is not: those agents belong to a previous run of the binary, so whatever they were doing was + * cut off. Calling them running showed a dead tree as live and fired the "agents are running" + * notification on startup for work that ended hours ago. + * + * [resolved] holds the agents already built by this scan, parents first — see the sort in [scan]. + */ + private fun statusOf(meta: AgentMeta, resolved: Map): AgentStatus { + meta.toolUseId?.let { statusByToolUse[it] }?.let { return it } + meta.parentAgentId?.let { resolved[it] } + ?.takeIf { it.status != AgentStatus.RUNNING } + ?.let { return it.status } + return if (restoring) AgentStatus.STOPPED else AgentStatus.RUNNING + } + /** * Admission, applied until it stops growing: an agent is ours if the plugin saw its Task call, if a * previous plugin run recorded it, or if its parent is already ours. The fixpoint loop is what carries @@ -114,7 +179,21 @@ class AgentRegistry( */ private fun admissibleIds(metas: Map): Set { val admitted = metas.values - .filter { it.agentId in preAdmitted || (it.toolUseId != null && it.toolUseId in observedToolUse) } + .filter { + it.agentId in preAdmitted || + (it.toolUseId != null && it.toolUseId in observedToolUse) || + // RESTORED CHATS. Nobody observed a `Task` in a chat that came back from disk — the + // spawns happened in a previous run — so the two rules above can only ever admit what the + // index remembered. When the index is thin (and it is: sessions on this machine carry + // subagents whose level-1 parent was never recorded), the whole tree stays invisible and + // the chat comes back with no agents at all. + // + // The directory itself is the missing evidence: `/subagents/` is namespaced by + // session, so every sidecar in it belongs to THIS chat. The rule the filtering exists for + // — not adopting agents from a run started in the terminal — is about a session id we + // never had; it was never about hiding our own past work from us. + restoring + } .mapTo(HashSet()) { it.agentId } var grew = true while (grew) { @@ -146,7 +225,8 @@ class AgentRegistry( * fills it — no error, no placeholder row. */ private fun readTranscript(dir: Path, agentId: String): List { - val file = dir.resolve("$agentId${AgentMeta.TRANSCRIPT_SUFFIX}") + // The id is the bare one; the `agent-` prefix belongs to the file name (see AgentMeta.agentId). + val file = dir.resolve(AgentMeta.transcriptFile(agentId)) val lines = runCatching { Files.readAllLines(file) }.getOrNull() ?: return emptyList() return SessionTranscriptReader.parseEntries(lines) } diff --git a/src/main/kotlin/dev/lain/claudejb/session/PluginAgentIndex.kt b/src/main/kotlin/dev/lain/claudejb/session/PluginAgentIndex.kt index ef219d89..413ac852 100644 --- a/src/main/kotlin/dev/lain/claudejb/session/PluginAgentIndex.kt +++ b/src/main/kotlin/dev/lain/claudejb/session/PluginAgentIndex.kt @@ -2,6 +2,7 @@ package dev.lain.claudejb.session import com.intellij.openapi.components.Service import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.project.Project import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString @@ -11,30 +12,37 @@ import java.nio.file.Path import java.nio.file.Paths /** - * Which subagents belong to a **plugin** session, and what the user did with their tabs. + * What belongs to a **plugin** session: every agent, subagent and background task it started, each with its + * parent and its children, plus what the user did with its tab. * - * **Why this exists at all.** The binary keeps every subagent of a session in one directory - * (`/subagents/`), and the same session id can be resumed from the terminal — so the directory - * mixes agents this plugin spawned with agents it never saw. The filesystem cannot tell them apart, and - * showing all of them would mean reopening a heavy session and getting dozens of tabs for work the plugin - * never ran. So the plugin writes down what it witnessed: an agent is admitted only if its spawn was seen - * here, and that record is what survives a restart. + * **Why it exists.** The binary keeps every subagent of a session in one directory + * (`/subagents/`), and the same session id can be resumed from the terminal — so that directory + * mixes agents this plugin spawned with agents it never saw (84, in one real session). The filesystem cannot + * tell them apart. This file is the plugin's own record of what it witnessed, and it is what survives a + * restart. Background tasks have no sidecar at all, so for them it is the ONLY record there is. * - * It also carries the tab state, which is the other half of the contract: a tab the user **closed stays - * closed** across restarts. Nothing is destroyed by closing it — the agent's transcript is the binary's file - * on disk, and the card that spawned it is still in the main transcript, so clicking that card reopens the - * tab. Closing is a view decision, not a delete. + * **The shape is the point.** It held one id per agent, on the reasoning that the binary's sidecars carry the + * parent and the type anyway. True, and they still are where the CONTENT comes from — but a record that + * cannot be read on its own cannot be checked, cannot be debugged from the file, and says nothing about a + * task. So each node now states what it is, what it hangs off, and what hangs off it: * - * **Stored under `~/.claude`, deliberately NOT in the project's `.idea/`.** The project directory is shared, - * gets committed by accident and is routinely synced, so anything written there is effectively published — - * and an agent's identity alone hints at what the user is working on. `~/.claude` is where this - * conversation's data already lives, is private to the user, and is the source of truth the plugin reads - * anyway. The file sits in its own namespaced directory so nothing of ours can ever be mistaken for one of - * the binary's own files: `~/.claude/ide/claude-code-native/agent-index.json`. + * ```json + * { "type": "subagent", "id": "a2f…", "parent": { "type": "agent", "id": "a1c…" }, + * "childs": [ { "type": "backgroundtask", "id": "b0k…" } ] } + * ``` * - * Even there it records **ids and two booleans** (`AgentIndexPrivacyTest`): titles, prompts and transcripts - * are read from the binary's files on demand, so duplicating them buys nothing and creates a second copy to - * leak or go stale. + * `childs` is DERIVED from the parents when the file is written, so the two can never disagree: it is there + * to be read, not to be maintained. + * + * **What it deliberately does NOT carry** (`AgentIndexPrivacyTest`): descriptions, prompts, transcripts, + * command text. An agent's description ("Translate the SAP standards") already says what the user is working + * on. Those live in the binary's own files and are read on demand, so a copy here would buy nothing and + * create a second thing to leak or to go stale. + * + * **Stored under `~/.claude`, deliberately NOT in the project's `.idea/`.** That directory is shared, gets + * committed by accident and is routinely synced, so anything written there is effectively published. + * `~/.claude` is private to the user and is where this data already lives. The file sits in its own + * namespaced directory: `~/.claude/ide/claude-code-native/agent-index.json`. * * IO is best-effort and tolerant: an unreadable or corrupt file behaves as an empty index rather than * throwing, and a failed write costs the tab layout of the next restart, nothing else. @@ -42,76 +50,182 @@ import java.nio.file.Paths @Service(Service.Level.PROJECT) class PluginAgentIndex { - /** One admitted agent. [open] is the tab state; [closedByUser] is what makes a close stick. */ + private val log = logger() + + /** What a node is. The chat is only ever a PARENT — it is the session itself, not a row in the list. */ + object Kind { + const val AGENT = "agent" + const val SUBAGENT = "subagent" + const val TASK = "backgroundtask" + const val CHAT = "chat" + } + + /** A reference to another node: what it is and which one. */ @Serializable - data class AgentRecord( - val agentId: String, + data class Ref(val type: String, val id: String) + + /** + * One node of the session's tree. + * + * [open]/[closedByUser] are the tab state — a close is remembered as the user's, so a restore leaves it + * closed. [agentType] is the registered agent type (`general-purpose`, a custom agent) and [toolUseId] + * is, for a task, the call that launched it: the card to jump back to and the join key against the + * binary's transcript when the task's output is replayed ([BackgroundTaskReplay]). + */ + @Serializable + data class Node( + val type: String, + val id: String, + val parent: Ref? = null, + val childs: List = emptyList(), + val agentType: String? = null, + val toolUseId: String? = null, val open: Boolean = true, val closedByUser: Boolean = false, ) - private val cache = LinkedHashMap>() + @Serializable + data class SessionRecord(val nodes: List = emptyList()) + + /** The file's whole contents. [version] is what lets a future shape change be a migration, not a loss. */ + @Serializable + data class Index( + val version: Int = FORMAT_VERSION, + val sessions: Map = emptyMap(), + ) + + private val cache = LinkedHashMap() private var loaded = false + // ── agents ─────────────────────────────────────────────────────────────────────────────────────────── + /** - * Records that this plugin saw [agentId] spawn in [sessionId]. Idempotent: re-admitting an agent the - * user had closed does NOT reopen its tab, because a re-admission is just the same agent being seen - * again, not a new intent from the user. + * Records that this plugin saw [node] spawn in [sessionId], or updates its shape if it has changed. + * + * Idempotent, and re-admitting an agent the user had closed does NOT reopen its tab: a re-admission is + * the same agent being seen again, not a new intent from the user. */ @Synchronized - fun admit(sessionId: String, agentId: String) { - val list = records(sessionId) - if (list.none { it.agentId == agentId }) { - list += AgentRecord(agentId) - flush() + fun admit(sessionId: String, node: AgentNode) { + val id = AgentMeta.bareAgentId(node.agentId) + val parentId = node.parentAgentId?.let { AgentMeta.bareAgentId(it) } + upsert( + sessionId, + id, + ) { existing -> + Node( + // An agent of the chat's own turn is an `agent`; one spawned inside another is a `subagent`. + type = if (parentId == null) Kind.AGENT else Kind.SUBAGENT, + id = id, + parent = parentId?.let { Ref(parentTypeOf(sessionId, it), it) } ?: Ref(Kind.CHAT, sessionId), + agentType = node.meta.agentType, + open = existing?.open ?: true, + closedByUser = existing?.closedByUser ?: false, + ) } } /** Whether [agentId] was spawned under a plugin session — the admission gate for [AgentRegistry]. */ @Synchronized fun isAdmitted(sessionId: String, agentId: String): Boolean = - records(sessionId).any { it.agentId == agentId } + agents(sessionId).any { it.id == AgentMeta.bareAgentId(agentId) } /** Every agent this plugin has ever admitted for [sessionId], in admission order. */ @Synchronized - fun admittedAgents(sessionId: String): List = records(sessionId).map { it.agentId } + fun admittedAgents(sessionId: String): List = agents(sessionId).map { it.id } /** Admitted agents of [sessionId] whose tab should be reopened on restore, in admission order. */ @Synchronized fun openAgents(sessionId: String): List = - records(sessionId).filter { it.open && !it.closedByUser }.map { it.agentId } + agents(sessionId).filter { it.open && !it.closedByUser }.map { it.id } + + /** The whole recorded tree of [sessionId] — agents, subagents and tasks, with parents and children. */ + @Synchronized + fun nodes(sessionId: String): List = session(sessionId).nodes /** - * The user closed (or reopened) an agent's tab. A close is remembered as **theirs**, so restore leaves - * it closed; reopening from the transcript card clears that, which is the documented way back. + * The user closed (or reopened) an agent's tab. A close is remembered as **theirs**, so restore leaves it + * closed; reopening from the transcript card clears that, which is the documented way back. */ @Synchronized fun setTabOpen(sessionId: String, agentId: String, open: Boolean) { - val list = records(sessionId) - val i = list.indexOfFirst { it.agentId == agentId } - if (i < 0) { - list += AgentRecord(agentId, open = open, closedByUser = !open) - } else { - list[i] = list[i].copy(open = open, closedByUser = !open) + val id = AgentMeta.bareAgentId(agentId) + upsert(sessionId, id) { existing -> + (existing ?: Node(type = Kind.AGENT, id = id, parent = Ref(Kind.CHAT, sessionId))) + .copy(open = open, closedByUser = !open) } - flush() } + // ── background tasks ───────────────────────────────────────────────────────────────────────────────── + + /** + * Records a background task of [sessionId], or fills in its owner once that becomes known. + * + * Tasks are dropped by the binary's level signal the moment they end, so this is the only place the + * plugin can say "this task was mine, and this agent ran it". Their OUTPUT is not copied here: it is + * replayed from the binary's transcript ([BackgroundTaskReplay]). + */ + @Synchronized + fun recordTask(sessionId: String, taskId: String, toolUseId: String?, ownerAgentId: String?) { + val owner = ownerAgentId?.let { AgentMeta.bareAgentId(it) } + upsert(sessionId, taskId) { existing -> + Node( + type = Kind.TASK, + id = taskId, + parent = owner?.let { Ref(parentTypeOf(sessionId, it), it) } + ?: existing?.parent + ?: Ref(Kind.CHAT, sessionId), + toolUseId = toolUseId ?: existing?.toolUseId, + open = existing?.open ?: true, + closedByUser = existing?.closedByUser ?: false, + ) + } + } + + /** Every background task recorded for [sessionId], in the order they were first seen. */ + @Synchronized + fun taskIds(sessionId: String): List = + session(sessionId).nodes.filter { it.type == Kind.TASK }.map { it.id } + + // ── lifecycle ──────────────────────────────────────────────────────────────────────────────────────── + /** Drops everything known about [sessionId] — used when its chat is closed for good. */ @Synchronized fun forget(sessionId: String) { if (load().remove(sessionId) != null) flush() } - private fun records(sessionId: String): MutableList = - load().getOrPut(sessionId) { mutableListOf() } + private fun agents(sessionId: String): List = + session(sessionId).nodes.filter { it.type == Kind.AGENT || it.type == Kind.SUBAGENT } + + /** An agent's own kind, so a child can name its parent correctly without re-deriving the tree. */ + private fun parentTypeOf(sessionId: String, parentId: String): String = + session(sessionId).nodes.firstOrNull { it.id == parentId }?.type ?: Kind.AGENT + + private fun upsert(sessionId: String, id: String, build: (Node?) -> Node) { + val session = session(sessionId) + val nodes = session.nodes.toMutableList() + val i = nodes.indexOfFirst { it.id == id } + val next = build(nodes.getOrNull(i)) + if (i >= 0 && nodes[i] == next) return + if (i >= 0) nodes[i] = next else nodes += next + cache[sessionId] = SessionRecord(nodes) + flush() + } + + private fun session(sessionId: String): SessionRecord = + load().getOrPut(sessionId) { SessionRecord() } - private fun load(): LinkedHashMap> { + private fun load(): LinkedHashMap { if (!loaded) { cache.clear() val body = indexFile()?.let { f -> runCatching { Files.readString(f) }.getOrNull() }.orEmpty() - cache.putAll(decode(body).mapValues { it.value.toMutableList() }) + cache.putAll(decode(body)) loaded = true + // Rewrite once whenever the file was not already in the current shape — a legacy `agent-` id, + // or an older layout. The migration is then paid on the first read and never again, and the file + // on disk stops disagreeing with what is compared against it. + if (body.isNotBlank() && !body.contains("\"version\":$FORMAT_VERSION")) flush() } return cache } @@ -121,15 +235,36 @@ class PluginAgentIndex { runCatching { Files.createDirectories(file.parent) Files.writeString(file, encode(cache)) + }.onFailure { + // Best-effort by design — a failed write costs the next restart's tab layout and nothing else — + // but it is logged, because "my agent tabs come back sometimes" is otherwise unexplainable. + log.warn("could not persist the agent index to ${file.parent}", it) } } - /** `~/.claude/ide/claude-code-native/agent-index.json`, or null when the JVM reports no home. */ - private fun indexFile(): Path? = homeOverride?.let { Paths.get(it) } + /** `~/.claude/ide/claude-code-native/agent-index.json`, or null when there is no home to write into. */ + private fun indexFile(): Path? = homeDir()?.let { Paths.get(it) } ?.let { it.resolve(DIR_IDE).resolve(DIR_PLUGIN).resolve(FILE) } companion object { - private val JSON = Json { ignoreUnknownKeys = true } + // encodeDefaults ON: the file is meant to be READ — by a person debugging a restore, and by the + // version check below. A record that omits every default is a record where "not set" and "false" + // look identical, and where `version` disappears the moment it equals the current one. + private val JSON = Json { + ignoreUnknownKeys = true + prettyPrint = true + encodeDefaults = true + } + + /** + * Bumped when the on-disk shape changes. + * + * v1 was `{sessionId: [{agentId, open, closedByUser}]}` — ids and two flags, with the tree implicit in + * the binary's sidecars and background tasks not recorded at all. v3 is a list of typed nodes, each + * naming its parent and its children. A v1 file is migrated rather than discarded: the ids in it are + * what admits those agents, and the rest fills itself in on the first scan. + */ + const val FORMAT_VERSION = 3 private const val DIR_IDE = "ide" private const val DIR_PLUGIN = "claude-code-native" @@ -146,17 +281,115 @@ class PluginAgentIndex { private fun defaultHome(): String? = System.getProperty("user.home")?.takeIf { it.isNotBlank() }?.let { "$it/.claude" } + /** + * The directory to actually use — null in a test JVM that has not pointed [homeOverride] somewhere + * of its own. + * + * **This is not belt-and-braces, it is a defect that shipped.** Everything under `~/.claude/ide/` is + * written through here: the agent index, the open-chat list and — since the settings moved out of + * `.idea` — the user's whole configuration. A headless test that constructs the settings page and + * calls `apply()` therefore wrote to the DEVELOPER'S OWN home, and it did exactly that: a real + * `settings.json` on this machine was found holding `some-unlisted-model` and, before it, `haiku` — + * both of them string literals from test cases, landing in a live configuration and looking for all + * the world like the plugin corrupting itself on reinstall. The correlation was the give-away: the + * config "broke on reinstall" because the reinstall followed a `./gradlew test`. + * + * `CredentialsVault.inertHere()` learned this same lesson about credentials; this is the same rule + * for the same reason, one directory up. A test that WANTS to exercise persistence still can — it + * just has to say where, and every one of them already does. + */ + internal fun homeDir(): String? { + homeOverride?.let { if (it != defaultHome()) return it } + // A null Application is a plain-JVM unit test, which is no place to be writing into a home + // either — the same reading `CredentialsVault.inertHere()` makes. + val app = com.intellij.openapi.application.ApplicationManager.getApplication() + return if (app == null || app.isUnitTestMode) null else homeOverride + } + fun getInstance(project: Project): PluginAgentIndex = project.service() - /** Serializes the whole index. Pure — unit-testable without a project. */ - fun encode(map: Map>): String = - runCatching { JSON.encodeToString(map) }.getOrDefault("") + /** + * Serializes the whole index, DERIVING each node's `childs` from the parents. + * + * Derived rather than stored-and-updated so the two halves of the tree cannot drift: a child list + * maintained by hand is a second source of truth, and the first bug it produces is a node that + * claims a child which no longer exists. + */ + fun encode(sessions: Map): String { + val withChildren = sessions.mapValues { (_, rec) -> + SessionRecord( + rec.nodes.map { node -> + node.copy( + childs = rec.nodes + .filter { it.parent?.id == node.id } + .map { Ref(it.type, it.id) }, + ) + }, + ) + } + return runCatching { JSON.encodeToString(Index(FORMAT_VERSION, withChildren)) }.getOrDefault("") + } + + /** + * Parses the index back, accepting the current shape and the v1 one. + * + * A v1 file (a bare `{sessionId: [{agentId,…}]}` map) becomes agents hanging off the chat — which is + * exactly what it knew — and is rewritten in the current shape on the first save. Blank or corrupt + * input yields an empty index rather than throwing. + */ + fun decode(text: String): LinkedHashMap { + val out = LinkedHashMap() + if (text.isBlank()) return out + runCatching { JSON.decodeFromString(text) }.getOrNull()?.let { index -> + if (index.sessions.isNotEmpty()) { + index.sessions.forEach { (id, rec) -> out[id] = rec.normalised(id) } + return out + } + } + runCatching { JSON.decodeFromString>>(text) }.getOrNull() + ?.forEach { (id, legacy) -> + out[id] = SessionRecord( + legacy.map { + Node( + type = Kind.AGENT, + id = AgentMeta.bareAgentId(it.agentId), + parent = Ref(Kind.CHAT, id), + open = it.open, + closedByUser = it.closedByUser, + ) + }, + ).normalised(id) + } + return out + } - /** Parses the index back; blank or corrupt input yields an empty map rather than throwing. */ - fun decode(text: String): Map> { - if (text.isBlank()) return emptyMap() - return runCatching { JSON.decodeFromString>>(text) } - .getOrDefault(emptyMap()) + /** The v1 record, kept only so a file written by 5.5.0's first builds can still be read. */ + @Serializable + private data class LegacyRecord( + val agentId: String, + val open: Boolean = true, + val closedByUser: Boolean = false, + ) + + /** + * Canonical ids, no duplicates, every parent resolvable. + * + * The prefixed shape (`agent-`) outlived the code that produced it: 5.5.0's first builds took the + * file name as the identity, and once the identity became the bare id — the shape the sidecars' own + * `parentAgentId` uses — those records matched nothing. A restored chat had its agents on disk, in + * the index, and not one tab. The FIRST record of a duplicate wins: it carries the user's decision + * about that tab. + */ + private fun SessionRecord.normalised(sessionId: String): SessionRecord { + val seen = LinkedHashMap() + nodes.forEach { n -> + val id = if (n.type == Kind.TASK) n.id else AgentMeta.bareAgentId(n.id) + val parent = n.parent?.let { + if (it.type == Kind.CHAT) Ref(Kind.CHAT, sessionId) else Ref(it.type, AgentMeta.bareAgentId(it.id)) + } + seen.putIfAbsent(id, n.copy(id = id, parent = parent ?: Ref(Kind.CHAT, sessionId))) + } + return SessionRecord(seen.values.toList()) } } } diff --git a/src/main/kotlin/dev/lain/claudejb/ui/AgentStripPanel.kt b/src/main/kotlin/dev/lain/claudejb/ui/AgentStripPanel.kt deleted file mode 100644 index 047cd75d..00000000 --- a/src/main/kotlin/dev/lain/claudejb/ui/AgentStripPanel.kt +++ /dev/null @@ -1,220 +0,0 @@ -package dev.lain.claudejb.ui - -import com.intellij.openapi.Disposable -import com.intellij.openapi.actionSystem.ActionUpdateThread -import com.intellij.openapi.actionSystem.AnAction -import com.intellij.openapi.actionSystem.AnActionEvent -import com.intellij.openapi.actionSystem.DefaultActionGroup -import com.intellij.openapi.project.Project -import com.intellij.ui.components.JBLabel -import com.intellij.ui.components.JBPanel -import com.intellij.ui.tabs.JBTabs -import com.intellij.ui.tabs.JBTabsFactory -import com.intellij.ui.tabs.TabInfo -import com.intellij.ui.tabs.TabsListener -import com.intellij.util.ui.JBUI -import com.intellij.util.ui.TimedDeadzone -import dev.lain.claudejb.session.AgentNode -import java.awt.BorderLayout -import java.awt.Color -import java.awt.Dimension -import javax.swing.JPanel -import javax.swing.Timer - -/** - * One row of agent tabs — the `Agents` strip under the chats, or the `Subagents` strip under it. - * - * **Header only.** Unlike the chat strip, these tabs own no content: the transcript is painted by the chat's - * single JCEF browser, which simply switches which transcript it shows. That is what keeps a session with - * eighty agents affordable — one Chromium per chat, not one per agent. So every [TabInfo] carries an empty - * placeholder component and the panel reports only the height of the tab labels. - * - * Selecting a tab reports the agent id; closing one reports it too, and the caller persists that (a closed - * tab stays closed across restarts, and the transcript card is the way back). - * - * **A row appears only when it has something in it.** Standing rows on a chat that has never spawned an - * agent are two lines of chrome asking a question nobody asked; the design started with them always visible - * to keep the layout still, and seeing it proved the opposite — an empty `Agents` row above every fresh chat - * reads as broken UI. The header carries the tree connector of its depth, so when a row does appear it says - * what it hangs off. - */ -internal class AgentStripPanel( - project: Project, - parent: Disposable, - /** `Agents` / `Subagents` / `Background tasks` — the row's own name, drawn at the left. */ - private val title: String, - /** How deep this row hangs: 1 = off the chat, 2 = off the selected agent. Drawn as `|_` connectors. */ - private val depth: Int = 1, -) : JBPanel(BorderLayout()) { - - private val tabs: JBTabs = JBTabsFactory.createTabs(project, parent) - - /** agentId → its tab, so the caller can select, relabel, blink or close one by id. */ - private val tabOf = LinkedHashMap() - - private var onSelected: (String?) -> Unit = {} - private var onClosed: (String) -> Unit = {} - - /** Suppresses the selection callback while the strip is being rebuilt from a scan. */ - private var rebuilding = false - - /** The row's own header, whose text carries the tree branch (`├─ Agents`, `│ └─ Subagents`). */ - private val header = JBLabel(title).apply { border = JBUI.Borders.empty(0, 8, 0, 6) } - - init { - add(header, BorderLayout.WEST) - add(tabs.component, BorderLayout.CENTER) - isVisible = false // nothing in it yet; `render` decides - // Same presentation as the chat strip, for the "same format as the chat tabs" the design asks for: - // a single scrolling row whose close buttons are always drawn. - tabs.presentation.setSingleRow(true) - tabs.presentation.setTabLabelActionsAutoHide(false) - tabs.presentation.setTabLabelActionsMouseDeadzone(TimedDeadzone.NULL) - tabs.presentation.setSupportsCompression(false) - tabs.addListener( - object : TabsListener { - override fun selectionChanged(oldSelection: TabInfo?, newSelection: TabInfo?) { - if (rebuilding) return - newSelection?.setIcon(null) - onSelected(agentIdOf(newSelection)) - } - }, - ) - tabs.addTabMouseListener( - object : java.awt.event.MouseAdapter() { - override fun mousePressed(e: java.awt.event.MouseEvent) { - if (e.button == java.awt.event.MouseEvent.BUTTON2) tabs.findInfo(e)?.let { closeTab(it) } - } - }, - ) - } - - fun onEvents(selected: (String?) -> Unit, closed: (String) -> Unit) { - onSelected = selected - onClosed = closed - } - - /** The agent whose tab is selected, or null when the strip is empty. */ - val selectedAgentId: String? get() = agentIdOf(tabs.selectedInfo) - - /** One tab of a strip: an agent, or a background task. Both are "things that hang off this row". */ - data class Item(val id: String, val label: String, val tooltip: String, val closable: Boolean = true) - - /** - * Rebuilds the strip to show exactly [items], keeping the selection when that item is still there, and - * **hides the whole row when there is nothing in it**. - * - * Rebuilding rather than diffing is deliberate: a scan can add, remove and re-parent agents at once on a - * heavy session, and a strip of at most a few dozen labels is cheap to lay out. What must NOT be lost is - * the user's selection, so it is restored explicitly and the listener is muted meanwhile — otherwise - * every scan would look like the user had clicked a tab and would repaint the transcript underneath. - */ - fun render(items: List) { - val keepSelected = selectedAgentId - rebuilding = true - try { - tabs.removeAllTabs() - tabOf.clear() - for (item in items) { - val info = TabInfo(JPanel()).setText(item.label).setObject(item.id) - if (item.closable) { - info.setTabLabelActions(DefaultActionGroup(CloseAgentTabAction(info)), TAB_ACTION_PLACE) - } - tabs.addTab(info) - (tabs.getTabLabel(info) as? javax.swing.JComponent)?.toolTipText = item.tooltip - tabOf[item.id] = info - } - tabOf[keepSelected]?.let { tabs.select(it, false) } - } finally { - rebuilding = false - } - isVisible = items.isNotEmpty() - revalidate() - repaint() - } - - /** - * Sets the tree branch drawn before the row's name, e.g. `├─ ` or `│ └─ `. - * - * Computed by the owner rather than fixed here, because a branch depends on which rows are **currently - * visible** — the last one drawn ends the tree with `└─`, and rows come and go as agents spawn and - * finish. A row that decided its own branch would draw a `├─` pointing at nothing. - */ - fun setBranch(branch: String) { - header.text = branch + title - } - - /** Convenience for the agent rows: [Item]s built from the registry's nodes. */ - fun renderAgents(nodes: List, relativeDepth: Int = 1) = render( - nodes.map { - Item(it.agentId, AgentTabLabels.tab(it, relativeDepth), AgentTabLabels.tooltip(it)) - }, - ) - - /** Selects the tab of [agentId], transferring focus like a manual click. No-op when it is not there. */ - fun select(agentId: String) { - tabOf[agentId]?.let { tabs.select(it, true) } - } - - fun has(agentId: String): Boolean = agentId in tabOf - - /** - * Two soft orange pulses on a newly-spawned agent's tab, then back to normal. - * - * The point is peripheral vision: on a session spawning agents constantly, a permanent colour would be - * noise and a notification per agent would be a storm (they are batched elsewhere). Two pulses say - * "something appeared here" and then get out of the way. - */ - fun blink(agentId: String) { - val info = tabOf[agentId] ?: return - var remaining = BLINK_PULSES * 2 - val timer = Timer(BLINK_INTERVAL_MS, null) - timer.addActionListener { - info.setTabColor(if (remaining % 2 == 0) BLINK_COLOR else null) - remaining-- - if (remaining < 0) { - info.setTabColor(null) - timer.stop() - } - } - timer.isRepeats = true - timer.start() - } - - private fun closeTab(info: TabInfo) { - val id = agentIdOf(info) ?: return - tabs.removeTab(info) - tabOf.remove(id) - onClosed(id) - } - - private fun agentIdOf(info: TabInfo?): String? = info?.`object` as? String - - /** Header height only: these tabs have no content of their own (see the class doc). */ - override fun getPreferredSize(): Dimension { - val height = tabs.selectedInfo?.let { tabs.getTabLabel(it)?.preferredSize?.height } - ?: JBUI.scale(DEFAULT_STRIP_HEIGHT) - return Dimension(super.getPreferredSize().width, height + JBUI.scale(STRIP_PADDING)) - } - - override fun getMaximumSize(): Dimension = Dimension(Int.MAX_VALUE, preferredSize.height) - - private inner class CloseAgentTabAction(private val info: TabInfo) : - AnAction("Close ${title.dropLast(1)} Tab", "Close this tab — the transcript stays on disk", com.intellij.icons.AllIcons.Actions.Close) { - override fun actionPerformed(e: AnActionEvent) = closeTab(info) - - /** EDT for the same reason the chat strip's close action declares it: BGT actions are dropped. */ - override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT - } - - private companion object { - const val TAB_ACTION_PLACE = "ClaudeAgentTabs" - const val DEFAULT_STRIP_HEIGHT = 26 - const val STRIP_PADDING = 4 - const val BLINK_PULSES = 2 - const val BLINK_INTERVAL_MS = 260 - - /** Soft orange — visible against both light and dark themes without shouting. */ - val BLINK_COLOR: Color = Color(0xE8, 0x8C, 0x30) - } -} diff --git a/src/main/kotlin/dev/lain/claudejb/ui/AgentTabLabels.kt b/src/main/kotlin/dev/lain/claudejb/ui/AgentTabLabels.kt index 518a4d95..20436f8d 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/AgentTabLabels.kt +++ b/src/main/kotlin/dev/lain/claudejb/ui/AgentTabLabels.kt @@ -19,17 +19,19 @@ object AgentTabLabels { /** Max characters on a tab before ellipsis — mirrors the chat tabs' own cap so the strips look alike. */ const val TAB_TITLE_MAX = 22 - /** The tree connector repeated per level of depth below the strip's root. */ - private const val CONNECTOR = "|_ " - /** - * The tab's text: tree connector, then the agent's own label, truncated. + * The tab's text: just the agent's label, truncated. + * + * **No tree connector here, and that is the fix rather than the omission.** The row's own header already + * draws the branch (`├─ Agents`, `│ └─ Subagents`), so repeating a connector on every tab drew the tree + * twice in two different styles — `|_ Mapa de tests` sitting inside a row headed `├─ Agents`. The tabs + * are siblings inside their row; what they hang off is what the header says. * - * [relativeDepth] is depth **within the strip**, not the absolute `spawnDepth` — the Subagents strip - * shows children of the selected agent, so its first level is one connector, not three. + * [relativeDepth] is kept in the signature because callers know it and a future compact mode may want + * it, but it deliberately does not change the text today. */ - fun tab(node: AgentNode, relativeDepth: Int = 1): String = - CONNECTOR.repeat(relativeDepth.coerceIn(1, MAX_CONNECTORS)) + truncate(node.meta.label()) + @Suppress("UNUSED_PARAMETER") + fun tab(node: AgentNode, relativeDepth: Int = 1): String = truncate(node.meta.label()) /** * The tooltip: the full label, the agent type, and how it ended. @@ -55,7 +57,4 @@ object AgentTabLabels { val clean = s.trim().ifBlank { "Agent" } return if (clean.length <= TAB_TITLE_MAX) clean else clean.take(TAB_TITLE_MAX - 1) + "…" } - - /** Beyond this the connectors would eat the whole label; deep chains stop indenting, not the tree. */ - private const val MAX_CONNECTORS = 4 } diff --git a/src/main/kotlin/dev/lain/claudejb/ui/AgentTabsPanel.kt b/src/main/kotlin/dev/lain/claudejb/ui/AgentTabsPanel.kt deleted file mode 100644 index 21b180d0..00000000 --- a/src/main/kotlin/dev/lain/claudejb/ui/AgentTabsPanel.kt +++ /dev/null @@ -1,220 +0,0 @@ -package dev.lain.claudejb.ui - -import com.intellij.openapi.Disposable -import com.intellij.openapi.project.Project -import com.intellij.ui.components.JBPanel -import dev.lain.claudejb.protocol.BackgroundTaskInfo -import dev.lain.claudejb.session.AgentRegistry -import java.awt.BorderLayout -import javax.swing.BoxLayout - -/** - * The rows under a chat's tab, as a **stack of levels** rather than a fixed pair of rows. - * - * Drilling down is the whole point: a chat spawns agents, an agent spawns agents of its own, and so can - * each of those. So every level you select opens the level below it — - * - * ``` - * Chat 1 - * ├─ Agents [ A ][ B ] ← agents of the chat - * ├─ Background tasks [ npm run dev ] ← background tasks of the chat - * │ ├─ Subagents [ A1 ][ A2 ] ← agents of A, because A is selected - * │ └─ Background tasks [ tail -f log ] ← background tasks of A - * │ └─ Subagents [ A1a ] ← agents of A1, because A1 is selected - * ``` - * - * — and selecting elsewhere collapses everything below it. A fixed "Agents + Subagents" pair could only ever - * show two levels of a tree the protocol does not bound, and it put background tasks nowhere except the top. - * - * **A row is drawn only when it has something in it**, so the stack is exactly as tall as the work is deep. - * Standing empty rows turned every fresh chat into bars asking a question nobody asked. - */ -internal class AgentTabsPanel( - private val project: Project, - private val parent: Disposable, -) : JBPanel(BorderLayout()) { - - /** One level of the drill-down: the agents hanging off [parentId], and the background tasks that do. */ - private class Level( - val parentId: String?, - val agents: AgentStripPanel, - val background: AgentStripPanel, - ) - - private val rows = JBPanel>().apply { layout = BoxLayout(this, BoxLayout.Y_AXIS) } - private val levels = ArrayList() - - /** Told which agent's transcript to show — null means the chat's own. */ - var onShowTranscript: (String?) -> Unit = {} - - /** Told that the user closed an agent's tab, so the close can be remembered across restarts. */ - var onTabClosed: (String) -> Unit = {} - - private var registry: AgentRegistry? = null - private var tasks: List = emptyList() - private var ownerOfTask: (String) -> String? = { null } - private var hidden: Set = emptySet() - - init { - add(rows, BorderLayout.CENTER) - } - - /** - * Re-renders the stack. - * - * [hiddenAgents] are tabs the user closed — hidden, not deleted: the transcript is the binary's file and - * the card in the main transcript reopens it. [ownerOf] maps a background task to the agent running it, - * when that is knowable at all; `background_tasks_changed` carries no parent, so it often is not, and - * those tasks stay at the chat's level rather than being guessed into someone's row. - */ - fun render( - registry: AgentRegistry, - backgroundTasks: List, - hiddenAgents: Set = emptySet(), - ownerOf: (String) -> String? = { null }, - ) { - this.registry = registry - this.tasks = backgroundTasks - this.ownerOfTask = ownerOf - this.hidden = hiddenAgents - if (levels.isEmpty()) levels += newLevel(null) - // A level whose agent is gone (finished and closed, or never ours) takes its descendants with it. - while (levels.size > 1 && levels.last().parentId?.let { registry.nodes.containsKey(it) } == false) { - dropLevelsBelow(levels.size - 2) - } - levels.forEach(::fill) - drawBranches() - } - - /** Opens (or re-selects) [agentId]'s tab, expanding the levels needed to reach it. */ - fun reveal(agentId: String) { - val reg = registry ?: return - val chain = ancestryOf(agentId, reg) ?: return - // Walk down from the chat, selecting each ancestor so its level exists before reaching for the next. - chain.forEachIndexed { index, id -> - val level = levels.getOrNull(index) ?: return - level.agents.select(id) - if (index < chain.lastIndex) openLevelFor(index, id) - } - } - - /** Two orange pulses on whichever level's row carries [agentId]. */ - fun blink(agentId: String) { - levels.firstOrNull { it.agents.has(agentId) }?.agents?.blink(agentId) - } - - // ── levels ─────────────────────────────────────────────────────────────────────────────────────────── - - private fun newLevel(parentId: String?): Level { - // The first level's agents hang off the chat, so it is "Agents"; every level below hangs off an - // agent, so it is "Subagents" — the word the user reads should say what the row is relative to. - val agentsRow = AgentStripPanel(project, parent, if (parentId == null) "Agents" else "Subagents") - val backgroundRow = AgentStripPanel(project, parent, "Background tasks") - val level = Level(parentId, agentsRow, backgroundRow) - agentsRow.onEvents( - selected = { id -> - val index = levels.indexOf(level) - if (id == null) { - dropLevelsBelow(index) - } else { - openLevelFor(index, id) - } - onShowTranscript(id ?: level.parentId) - }, - closed = { id -> - onTabClosed(id) - dropLevelsBelow(levels.indexOf(level)) - onShowTranscript(level.parentId) - }, - ) - // A background task has no transcript of its own, so its tab is a POINTER: it shows the transcript of - // whoever runs it — the owning agent when the binary let us work that out, else this level's owner. - backgroundRow.onEvents( - selected = { taskId -> onShowTranscript(taskId?.let(ownerOfTask) ?: level.parentId) }, - closed = { }, - ) - rows.add(agentsRow) - rows.add(backgroundRow) - return level - } - - /** Ensures the level below [index] exists and belongs to [agentId], dropping whatever was there. */ - private fun openLevelFor(index: Int, agentId: String) { - if (levels.getOrNull(index + 1)?.parentId == agentId) { - fill(levels[index + 1]) - drawBranches() - return - } - dropLevelsBelow(index) - val level = newLevel(agentId) - levels += level - fill(level) - drawBranches() - } - - /** Removes every level deeper than [index] — selecting elsewhere collapses the drill-down below it. */ - private fun dropLevelsBelow(index: Int) { - while (levels.size > index + 1) { - val dropped = levels.removeAt(levels.size - 1) - rows.remove(dropped.agents) - rows.remove(dropped.background) - } - rows.revalidate() - rows.repaint() - } - - private fun fill(level: Level) { - val reg = registry ?: return - level.agents.renderAgents(reg.children(level.parentId).filterNot { it.agentId in hidden }) - val mine = tasks.filter { ownerOfTask(it.taskId) == level.parentId } - level.background.render( - mine.map { - AgentStripPanel.Item( - id = it.taskId, - label = it.description.ifBlank { it.taskType }, - tooltip = "${it.description} · ${it.taskType}", - // Not closable: the plugin does not own a background task's lifetime. Stopping one is a - // deliberate act with its own button in the dashboard, not a tab close. - closable = false, - ) - }, - ) - } - - /** - * Draws the visible rows the way `tree` draws a directory: `├─` while more rows follow at that level, - * `└─` for the last, and `│` continuing the trunk past every level already opened. - * - * Recomputed on every render because it depends on which rows are visible **right now** — rows appear - * and vanish as agents spawn and finish, and a `├─` pointing at a row that is no longer drawn is worse - * than no tree at all. - */ - private fun drawBranches() { - val visible = levels.flatMap { listOf(it.agents, it.background) }.filter { it.isVisible } - visible.forEachIndexed { i, row -> - val depth = levels.indexOfFirst { it.agents === row || it.background === row } - val connector = if (i == visible.lastIndex) LAST else FORK - row.setBranch(TRUNK.repeat(depth) + connector) - } - } - - /** The chain of agent ids from the chat down to [agentId], or null when it is not in the tree. */ - private fun ancestryOf(agentId: String, reg: AgentRegistry): List? { - val chain = ArrayDeque() - val seen = HashSet() - var current: String? = agentId - while (current != null && seen.add(current)) { - val node = reg.nodes[current] ?: return null - chain.addFirst(node.agentId) - current = node.parentAgentId - } - return chain.toList() - } - - private companion object { - /** `tree`'s own glyphs: a fork, a last child, and the trunk that passes an opened level. */ - const val FORK = "├─ " - const val LAST = "└─ " - const val TRUNK = "│ " - } -} diff --git a/src/main/kotlin/dev/lain/claudejb/ui/ChatTabsPanel.kt b/src/main/kotlin/dev/lain/claudejb/ui/ChatTabsPanel.kt index 5120e269..6e1289e6 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/ChatTabsPanel.kt +++ b/src/main/kotlin/dev/lain/claudejb/ui/ChatTabsPanel.kt @@ -1,97 +1,80 @@ package dev.lain.claudejb.ui -import com.intellij.icons.AllIcons import com.intellij.openapi.Disposable -import com.intellij.openapi.actionSystem.ActionUpdateThread -import com.intellij.openapi.actionSystem.AnAction -import com.intellij.openapi.actionSystem.AnActionEvent -import com.intellij.openapi.actionSystem.DefaultActionGroup -import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer import com.intellij.ui.components.JBPanel -import com.intellij.ui.tabs.JBTabs -import com.intellij.ui.tabs.JBTabsFactory -import com.intellij.ui.tabs.TabInfo -import com.intellij.ui.tabs.TabsListener -import com.intellij.util.ui.TimedDeadzone +import dev.lain.claudejb.ui.jcef.JcefSessionData +import dev.lain.claudejb.ui.jcef.JcefTabsData import java.awt.BorderLayout -import javax.swing.Icon +import java.awt.CardLayout import javax.swing.JComponent +import javax.swing.JPanel /** - * The chat tab strip, owned by the plugin instead of by the tool window. + * Holds the chats and switches between them. **It draws nothing.** * - * **Why not the tool window's own tabs.** The platform lays tool-window content tabs out with - * `TabContentLayout`, which does not scroll: once the labels no longer fit it simply stops drawing the - * earliest ones and buries them behind a `⌄` popup. With a handful of chats open the first ones vanish, which - * is the bug this class exists to fix. `JBTabs` — the same widget the editor uses — does scroll: its - * `createRowLayout()` returns a `ScrollableSingleRowLayout` whenever the tab list is single-row (verified - * against the platform, IU-262). So the tool window now holds ONE content, and every chat is a [TabInfo] in - * here. + * The tab bar itself is part of the web app ([JcefChatPanel] → `app-tabs.js`), which is where the whole chat + * UI has lived since 4.0.0. A Swing strip above the page cannot share its accent, type scale, transitions or + * SVG, so keeping one meant approximating the page's look by hand in another toolkit — and looking like it. + * What remains here is the part that genuinely is not UI: which chats exist, which one is on screen, and the + * disposal contract. * - * The surface is deliberately the small subset of `ContentManager` the tool window factory actually used - * (add / select / selected / list / listen), so the factory's logic — restore, attention badges, rename, - * fork — reads exactly as it did before and only the object it talks to changed. + * Every chat's page renders the whole chat list and marks its own entry ([pushChats]); a click comes back as + * a `selectChat` message and lands in [selectById]. Switching swaps browsers, and because both pages draw + * the same bar the swap is invisible. + * + * The content is switched with a [CardLayout] rather than by adding and removing components: a chat's JCEF + * browser stays in the hierarchy for the whole life of its tab, which is the cheapest possible answer to + * "does switching tabs disturb Chromium". * * Disposal: each tab's panel is disposed when its tab is closed, and all of them when this panel is * ([Disposable] — registered as the single content's disposer). */ -internal class ChatTabsPanel(project: Project, parent: Disposable) : - JBPanel(BorderLayout()), Disposable { +internal class ChatTabsPanel : JBPanel(BorderLayout()), Disposable { + + /** One chat: its stable id, its component, its title and whatever must be disposed with it. */ + internal class ChatTab( + val id: String, + val component: JComponent, + var title: String, + var tooltip: String, + val disposer: Disposable?, + ) { + /** The attention badge, as a flag rather than an icon — the page decides how to draw it. */ + var attention: Boolean = false - private val tabs: JBTabs = JBTabsFactory.createTabs(project, parent) + /** + * What this tab is PINNED to: an agent id, a background-task id, or neither (an ordinary chat). + * + * Selecting a chat means "show me the chat", so [select] resets its panel to the chat's own + * transcript — which would immediately undo a pin. This is what makes the exception explicit rather + * than making the reset conditional on something the tab does not know. + */ + var pinnedAgent: String? = null + var pinnedTask: String? = null + } - /** What to run when a tab is closed by the user — the factory drops the session there. */ - private var onClosed: (TabInfo) -> Unit = {} + private val cards = CardLayout() + private val content = JPanel(cards) - private var onSelected: (TabInfo?) -> Unit = {} + private val tabs = ArrayList() + private var selectedTab: ChatTab? = null + private var seq = 0 - val selected: TabInfo? get() = tabs.selectedInfo + private var onClosed: (ChatTab) -> Unit = {} + private var onSelected: (ChatTab?) -> Unit = {} + + val selected: ChatTab? get() = selectedTab /** The selected tab's chat panel, or null when the selected tab is not a chat (e.g. Diff History). */ - val selectedChat: JcefChatPanel? get() = tabs.selectedInfo?.component as? JcefChatPanel + val selectedChat: JcefChatPanel? get() = selectedTab?.component as? JcefChatPanel init { - // NB no Disposer.register here: this panel is the single content's disposer - // (`Content.setDisposer`), and registering it under the tool window as well would give one object two - // parents. [parent] is only what the tab widget itself is tied to. - add(tabs.component, BorderLayout.CENTER) - tabs.presentation.setSingleRow(true) // the scrolling layout; see the class doc - // The close button, ALWAYS drawn. `JBTabs` hides per-tab actions until the pointer is over the label by - // default, which on a chat strip reads as "the tabs have no close button" — you have to already know it - // is there to find it. The editor's own tabs show theirs unconditionally; so do these. - tabs.presentation.setTabLabelActionsAutoHide(false) - tabs.presentation.setTabLabelActionsMouseDeadzone(TimedDeadzone.NULL) - tabs.presentation.setTabDraggingEnabled(true) - // Compression OFF so a full strip cannot take the button away: `TabLabelLayout` drops the EAST component - // — which IS the action panel — whenever it has to fit a label into less than its preferred width - // (`layoutCompressible` bounds it to 0×0). Without compression the single-row layout scrolls instead, - // which is the whole reason this class uses `JBTabs`; see the class doc. - tabs.presentation.setSupportsCompression(false) - tabs.addListener( - object : TabsListener { - override fun selectionChanged(oldSelection: TabInfo?, newSelection: TabInfo?) { - // A selected chat has no badge to show, and the keyboard focus belongs in its composer. - // The ContentManager used to do both as part of the selection; here it is explicit. - newSelection?.setIcon(null) - (newSelection?.component as? JcefChatPanel)?.focusInput() - onSelected(newSelection) - } - }, - ) - // Middle-click closes, the way every other tab strip in the IDE behaves. - tabs.addTabMouseListener( - object : java.awt.event.MouseAdapter() { - override fun mousePressed(e: java.awt.event.MouseEvent) { - if (e.button != java.awt.event.MouseEvent.BUTTON2) return - tabs.findInfo(e)?.let { close(it) } - } - }, - ) + add(content, BorderLayout.CENTER) } /** Registers the selection/close callbacks. Called once, by the factory, right after construction. */ - fun onEvents(selected: (TabInfo?) -> Unit, closed: (TabInfo) -> Unit) { + fun onEvents(selected: (ChatTab?) -> Unit, closed: (ChatTab) -> Unit) { onSelected = selected onClosed = closed } @@ -102,71 +85,141 @@ internal class ChatTabsPanel(project: Project, parent: Disposable) : * [disposer] is disposed when the tab is closed — the same contract as `Content.setDisposer`, and the * reason a closed chat's JCEF browser and session actually go away instead of leaking. */ - fun add(component: JComponent, title: String, tooltip: String, disposer: Disposable?): TabInfo { - val info = TabInfo(component).setText(title) - info.setObject(disposer) - info.setTabLabelActions(DefaultActionGroup(CloseTabAction(info)), TAB_ACTION_PLACE) - tabs.addTab(info) - applyTooltip(info, tooltip) - return info + fun add(component: JComponent, title: String, tooltip: String, disposer: Disposable?): ChatTab { + val tab = ChatTab("chat-${seq++}", component, title, tooltip, disposer) + tabs += tab + content.add(component, tab.id) + pushChats() + return tab } - /** Selects [info], moving the keyboard focus into it (the selection listener does the focus transfer). */ - fun select(info: TabInfo) { - tabs.select(info, true) + /** Selects [tab], shows its component and moves the keyboard focus into it. */ + fun select(tab: ChatTab) { + if (tab !in tabs) return + selectedTab = tab + tab.attention = false + cards.show(content, tab.id) + (tab.component as? JcefChatPanel)?.let { + when { + // A PINNED tab is that agent's (or task's) tab: selecting it shows what it is pinned to. + tab.pinnedAgent != null -> it.showTranscript(tab.pinnedAgent) + + tab.pinnedTask != null -> it.showBackgroundTask(tab.pinnedTask!!) + + // Selecting a chat means "show me this chat" — including when an agent's transcript is what + // is currently painted in it. Without this there is NO WAY BACK from an agent tab. + else -> it.showTranscript(null) + } + it.focusInput() + } + pushChats() + onSelected(tab) } - /** Closes [info]: removes the tab, fires the close callback and disposes whatever it carried. */ - fun close(info: TabInfo) { - tabs.removeTab(info) - onClosed(info) - (info.`object` as? Disposable)?.let { Disposer.dispose(it) } + /** + * Opens [agentId] (or [taskId]) as a tab of its own, on the SAME session, and selects it. + * + * Same session on purpose: an agent is not a separate conversation, it is part of this one — it shares + * the process, the credentials and the transcript store. What the new tab owns is a second view of it, + * pinned so that selecting the tab always lands on that transcript. [panel] is built by the caller, which + * is the only place that holds the `Project` a JCEF panel needs. + * + * Pinning the same thing twice just selects the tab that already exists; two tabs showing one agent + * would be two things to close and no way to tell them apart. + */ + fun pin(panel: JcefChatPanel, agentId: String?, taskId: String?, title: String): ChatTab { + tabs.firstOrNull { it.pinnedAgent == agentId && it.pinnedTask == taskId && (agentId ?: taskId) != null } + ?.let { + select(it) + return it + } + val tab = add(panel, title, title, panel) + tab.pinnedAgent = agentId + tab.pinnedTask = taskId + select(tab) + return tab } - fun all(): List = tabs.tabs + /** The chat panel behind tab [id], for a message that names the chat it belongs to (Workloads does). */ + fun panelOf(id: String): JcefChatPanel? = + tabs.firstOrNull { it.id == id }?.component as? JcefChatPanel - fun relabel(info: TabInfo, title: String, tooltip: String) { - info.setText(title) - applyTooltip(info, tooltip) + fun selectById(id: String) { + tabs.firstOrNull { it.id == id }?.let { select(it) } } - /** - * The full title, on the tab's own label rather than through `TabInfo.setTooltipText`. - * - * That setter has two overloads and neither is usable across the supported range: the `String` one is - * DEPRECATED from 262, and the `HtmlChunk` one does not exist at the 251 floor — calling it would be a - * `NoSuchMethodError` on the oldest IDEs we claim to support. `TabLabel` falls through to - * `JPanel.getToolTipText`, so setting the label's own tooltip is the same result by a supported route. - */ - private fun applyTooltip(info: TabInfo, tooltip: String) { - (tabs.getTabLabel(info) as? JComponent)?.toolTipText = tooltip + fun closeById(id: String) { + tabs.firstOrNull { it.id == id }?.let { close(it) } } - /** The attention badge. Ignored for the tab that is already on screen — it has nothing to catch up on. */ - fun badge(info: TabInfo, icon: Icon?) { - if (info !== tabs.selectedInfo) info.setIcon(icon) + /** Closes [tab]: removes it, fires the close callback and disposes whatever it carried. */ + fun close(tab: ChatTab) { + if (!tabs.remove(tab)) return + onClosed(tab) + content.remove(tab.component) + tab.disposer?.let { Disposer.dispose(it) } + if (selectedTab === tab) { + selectedTab = null + // Never leave the area blank: show whatever is left. + tabs.firstOrNull()?.let { select(it) } ?: pushChats() + } else { + pushChats() + } + content.revalidate() + content.repaint() } - override fun dispose() { - tabs.tabs.forEach { info -> (info.`object` as? Disposable)?.let { Disposer.dispose(it) } } + fun all(): List = tabs.toList() + + fun relabel(tab: ChatTab, title: String, tooltip: String) { + tab.title = title + tab.tooltip = tooltip + pushChats() } - private inner class CloseTabAction(private val info: TabInfo) : - AnAction("Close Chat", "Close this conversation", AllIcons.Actions.Close) { - override fun actionPerformed(e: AnActionEvent) = close(info) + /** The attention badge. Ignored for the tab already on screen — it has nothing to catch up on. */ + fun badge(tab: ChatTab, attention: Boolean) { + if (tab === selectedTab) return + tab.attention = attention + pushChats() + } - /** - * EDT, and NOT because this action is slow: `ActionPanel` — the thing that turns a tab's action group - * into the little button — builds its buttons through a traverser that - * `filter { it.actionUpdateThread == ActionUpdateThread.EDT }`. `AnAction` answers `BGT` by default, so - * an action that does not say this is dropped on the floor and the tab is simply drawn without a close - * button, at any width, hovered or not. The platform's own editor-tab `CloseTab` declares it too. - */ - override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT + /** + * Pushes the chat list into EVERY chat's page. + * + * All of them, not just the selected one: a page that is off screen now is the page that will be on + * screen the moment the user switches to it, and re-rendering it only then would show a stale bar for a + * frame — or, if the switch is what changed the list, the wrong bar entirely. + */ + private fun pushChats() { + val list = tabs.map { + JcefTabsData.Chat(it.id, it.title, it === selectedTab, it.attention, it.pinnedAgent) + } + tabs.forEach { (it.component as? JcefChatPanel)?.setChats(list) } + } + + /** + * Every open chat with the session behind it, for the dashboard's Workloads diagram. + * + * Workloads is about what is RUNNING, and what is running does not belong to the chat you happen to be + * looking at: agents and background tasks keep going in the other tabs, and a view that showed only the + * selected one answered "what is running?" with a fraction of the truth. The bar's own popup is the + * per-chat view; this is the whole picture. + * + * Ordered as the tabs are, so the diagram reads in the same order as the bar above it. + * + * EVERY tab, pinned ones included: the tab bar needs each tab's tree to answer its own ⋮. The diagram + * is the one that must not draw the same chat twice — [pin] adds a second tab over the SAME panel, a + * VIEW of one agent rather than another workload — and that is deduplicated by session where it is + * drawn ([JcefSessionData.sessionJson]). + */ + fun workloads(): List = tabs.mapNotNull { tab -> + (tab.component as? JcefChatPanel)?.let { panel -> + JcefSessionData.Workload(tab.id, tab.title, tab === selectedTab, panel.session) + } } - private companion object { - /** Action place for the per-tab close button; any stable, plugin-owned string will do. */ - const val TAB_ACTION_PLACE = "ClaudeChatTabs" + override fun dispose() { + tabs.forEach { tab -> tab.disposer?.let { Disposer.dispose(it) } } } } diff --git a/src/main/kotlin/dev/lain/claudejb/ui/ClaudeToolWindowFactory.kt b/src/main/kotlin/dev/lain/claudejb/ui/ClaudeToolWindowFactory.kt index ae48b550..72fabc7d 100644 --- a/src/main/kotlin/dev/lain/claudejb/ui/ClaudeToolWindowFactory.kt +++ b/src/main/kotlin/dev/lain/claudejb/ui/ClaudeToolWindowFactory.kt @@ -20,7 +20,6 @@ import com.intellij.openapi.wm.ToolWindowFactory import com.intellij.openapi.wm.ToolWindowManager import com.intellij.ui.SimpleListCellRenderer import com.intellij.ui.content.ContentFactory -import com.intellij.ui.tabs.TabInfo import dev.lain.claudejb.session.AttentionReason import dev.lain.claudejb.session.ChatSessionManager import dev.lain.claudejb.session.ClaudeSession @@ -50,7 +49,7 @@ class ClaudeToolWindowFactory : ToolWindowFactory, DumbAware { // projects. The tool window, however, must be resolved per-project on demand ([resolveToolWindow]) — caching // it in a field would make a second project's window overwrite the first's and misdirect attention checks. /** Maps each live session to its tab, so a background session can target its own badge/notification. */ - private val tabOf = HashMap() + private val tabOf = HashMap() /** Per-session throttle for attention notifications (badge is never throttled). */ private val lastNotified = HashMap() @@ -59,11 +58,11 @@ class ClaudeToolWindowFactory : ToolWindowFactory, DumbAware { val manager = ChatSessionManager.getInstance(project) val cm = toolWindow.contentManager - val tabs = ChatTabsPanel(project, toolWindow.disposable) + val tabs = ChatTabsPanel() tabs.onEvents( - selected = { info -> (info?.component as? JcefChatPanel)?.let { manager.setActive(it.session) } }, - closed = { info -> - (info.component as? JcefChatPanel)?.let { + selected = { tab -> (tab?.component as? JcefChatPanel)?.let { manager.setActive(it.session) } }, + closed = { tab -> + (tab.component as? JcefChatPanel)?.let { manager.remove(it.session) tabOf.remove(it.session) lastNotified.remove(it.session) @@ -139,7 +138,8 @@ class ClaudeToolWindowFactory : ToolWindowFactory, DumbAware { val onScreen = tw != null && tw.isVisible && tabs.selected === tab if (onScreen) return - tabs.badge(tab, AllIcons.General.Modified) + // A flag, not an icon: the bar is drawn by the page now, which shows it as a dot on the chat's pill. + tabs.badge(tab, true) val now = System.currentTimeMillis() if (now - (lastNotified[session] ?: 0L) <= NOTIFY_THROTTLE_MS) return diff --git a/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefTabsData.kt b/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefTabsData.kt new file mode 100644 index 00000000..c20cea4a --- /dev/null +++ b/src/main/kotlin/dev/lain/claudejb/ui/jcef/JcefTabsData.kt @@ -0,0 +1,144 @@ +package dev.lain.claudejb.ui.jcef + +import dev.lain.claudejb.session.AgentStatus +import dev.lain.claudejb.session.BackgroundTaskRegistry +import dev.lain.claudejb.session.ClaudeSession +import kotlinx.serialization.json.addJsonObject +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +/** + * The tab bar's payload: the chats, this chat's agent tree, and its background tasks. + * + * **The bar is drawn by the web app, not by Swing.** The chat UI has been a JCEF page since 4.0.0 and this + * belongs to it: a Swing strip cannot share the page's type scale, its accent, its transitions or its SVG, + * so every attempt to make the two look like one product ends up approximating the other by hand — which is + * exactly what it looked like. + * + * **Every chat's page draws the whole chat list.** There is one browser per chat, so no single page owns the + * bar; each renders the same list and marks its own entry. Switching chats swaps browsers, and because both + * pages paint the same bar the swap is invisible. + * + * The tree is sent FLAT — `{agentId, parent, label, status}` — and the levels are derived in the page from + * whichever agent is selected. That is deliberate: which levels are open is a view state that changes on + * every click, and round-tripping it through the host would make a click cost a repaint of the host's own + * model. The host owns what EXISTS; the page owns what is SHOWN. + */ +object JcefTabsData { + + /** + * One chat in the bar. [id] is the strip's own handle, opaque to the page. + * + * [pinnedAgent] is set on a tab that was pinned to a subagent: the tab shows that agent's transcript, so + * its ⋮ must open THAT agent's subtree, not the whole chat's. Without it the page has no way to tell a + * pinned tab from an ordinary chat and shows the global tree — which is what a pinned tab is not about. + */ + data class Chat( + val id: String, + val title: String, + val selected: Boolean, + val attention: Boolean = false, + val pinnedAgent: String? = null, + ) + + fun tabsJson( + session: ClaudeSession, + chats: List, + hiddenAgents: Set, + others: Map = emptyMap(), + ): String = buildTabs(session, chats, hiddenAgents, others).toString() + + private fun buildTabs( + session: ClaudeSession, + chats: List, + hiddenAgents: Set, + others: Map, + ) = buildJsonObject { + put( + "chats", + buildJsonArray { + chats.forEach { chat -> + addJsonObject { + put("id", chat.id) + put("title", chat.title) + put("selected", chat.selected) + put("attention", chat.attention) + // A tab pinned to a subagent roots its own ⋮ at that agent, not at the chat. + chat.pinnedAgent?.let { put("pinned", it) } + // EVERY chat carries its own tree, not just the selected one: hovering a tab you are + // not in has to show what THAT chat started. Its work does not pause because you are + // reading a different tab, and having to select a chat to find out what it is doing + // is the opposite of what a tab bar is for. + // + // `hiddenAgents` is deliberately NOT applied here — it is this panel's own record of + // what the user dismissed in ITS chat, and it says nothing about anyone else's. + others[chat.id]?.let { s -> + put("tree", treeJson(s, if (s === session) hiddenAgents else emptySet())) + put("tasks", tasksJson(s)) + } + } + } + }, + ) + // The selected chat's tree, kept at the top level: it is what the bar's own rows are built from. + put("tree", treeJson(session, hiddenAgents)) + put("tasks", tasksJson(session)) + } + + private fun treeJson(session: ClaudeSession, hiddenAgents: Set) = buildJsonArray { + session.runningAgents.nodes.values + .filterNot { it.agentId in hiddenAgents } + .forEach { node -> + addJsonObject { + put("id", node.agentId) + put("parent", node.parentAgentId) + put("label", node.meta.label()) + put("type", node.meta.agentType) + put("status", JcefStatus.of(node.status)) + put("running", node.status == AgentStatus.RUNNING) + } + } + } + + /** + * The background tasks, each with the agent that started it (null = this chat's own turns). + * + * From the plugin's own registry rather than the binary's live set: that set is a level signal, so a + * finished task stops being listed and its tab would vanish at the moment its output is worth reading. + */ + private fun tasksJson(session: ClaudeSession) = buildJsonArray { + session.backgroundTaskRegistry.all.forEach { task -> + addJsonObject { + put("id", task.taskId) + put("label", task.label()) + put("type", task.taskType) + put("running", task.running) + // ONE state vocabulary for everything the page colours (see [JcefStatus]): the JS used to + // translate a boolean into `done` here and `completed` in the dashboard, so the same task + // was two different colours depending on which view you read it in. + put("status", JcefStatus.of(task.running)) + put("owner", session.ownerAgentOfTask(task.taskId)) + } + } + } + + /** Convenience for a page that has no session yet: an empty bar rather than a missing one. */ + fun emptyJson(chats: List): String = buildJsonObject { + put( + "chats", + buildJsonArray { + chats.forEach { chat -> + addJsonObject { + put("id", chat.id) + put("title", chat.title) + put("selected", chat.selected) + put("attention", chat.attention) + } + } + }, + ) + put("tree", buildJsonArray { }) + put("tasks", buildJsonArray { }) + }.toString() +} diff --git a/src/main/resources/jcef/app-tabs.js b/src/main/resources/jcef/app-tabs.js new file mode 100644 index 00000000..4fbe5f0d --- /dev/null +++ b/src/main/resources/jcef/app-tabs.js @@ -0,0 +1,778 @@ +/** + * The tab bar: the chats, and the one subtab you are reading — with the whole tree of subtabs a hover away. + * + * **Why it lives here and not in Swing.** The chat UI is this page; a Swing strip above it cannot share the + * page's accent, type scale or transitions, so making the two look like one product means approximating one + * in the other by hand — which is what it looked like. Here every control is a ` + - - - -