Skip to content

Commit bfec7bf

Browse files
feat(workspace): one-line attach toast, and a /workspace Status view for the detail
The attach toast carried the whole engine report — every undelivered key grouped by reason, with the engine's detail — and on a workspace with a few gaps it read as noise (review of the first cut, #1311). Numbers belong in the toast; the keys and reasons belong somewhere they can be read again. - The toast says `2 of 9 integration tools available · 7 need attention. Details: /workspace` and nothing more; extension tools a live bridge serves add `· N more via VS Code`. `describeMissing` / `describeExtensionServed` go; `reasonPhrase` keeps the wording for the view. - The overlay keeps an attach snapshot per directory — workspace, engine version, the allowlist, what the engine served, its report — in memory and in `altimate-attach-snapshots.json` under the state directory, because the TUI plugin runs in another process than the overlay (the same reason the binding cache is a file). Bounded to 64 directories; a test seam keeps the suite out of the real state directory. - `status-view.ts`, transport-agnostic like `manage.ts`: the snapshot joined to the workspace's selection and the catalog, one row per integration — served / partial / missing with reasons / idle for an extension without a window — attention first, keys beyond the allowlist as extras, and the headline the toast, the menu row and the sidebar share. - `/workspace` gains a Status row (its description is the headline, read from the snapshot so the menu opens without a network call) that opens the view: one row per integration with counts and the reason, the keys and reasons as the row's footer, and "Open on the web" / "Re-read" / "Done" as action rows. A snapshot from a workspace this project was since re-linked away from is ignored. - The sidebar's Workspace tile shows the headline under the name once a session has attached, with the same staleness guard. Tests: the snapshot file round-trip, cap and corrupt-file recovery; the status view (rows, ordering, name fallback, counts matching the toast, partial and bridged states, reports for integrations no longer selected, no allowlist); the overlay's toast wording, snapshot and persistence; the e2e engine report checked through `reasonPhrase`. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01GHBUvb843k1R7UAGi8Ya9b
1 parent 16284a3 commit bfec7bf

12 files changed

Lines changed: 768 additions & 219 deletions

File tree

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// altimate_change - new file
2+
//
3+
// What the last attach in a directory produced, on disk. The overlay settles
4+
// an attach inside the server process; the TUI plugin (the `/workspace`
5+
// menu, the sidebar tile) runs in another, so the memory the overlay keeps is
6+
// invisible to it — the same reason the binding cache lives in a file. One
7+
// small JSON under the state directory, keyed by project directory, latest
8+
// attach per directory, bounded.
9+
import path from "node:path"
10+
import { chmodSync, existsSync, readFileSync } from "node:fs"
11+
import { Global } from "@/global"
12+
import { Filesystem } from "@/util/filesystem"
13+
import { Log } from "@/altimate/util/log"
14+
import type { Declared, Unfulfilled } from "./engine-types"
15+
16+
const log = Log.create({ service: "altimate-workspace-attach-snapshot" })
17+
18+
export interface AttachSnapshot {
19+
workspace: { id: string; name: string }
20+
engineVersion: string | null
21+
/** The allowlist the workspace declared, split like `Declared`; null when
22+
* the lookup failed and the engine was taken at its word. */
23+
declared: Declared | null
24+
/** Every key the engine served under the workspace key, allowlisted or not. */
25+
present: string[]
26+
/** The engine's full report; undefined when it sent none. */
27+
unfulfilled: Unfulfilled[] | undefined
28+
/** Extension-declared keys a live IDE bridge served. */
29+
extServed: number
30+
at: number
31+
}
32+
33+
interface SnapshotFile {
34+
version: 1
35+
snapshots: Record<string, AttachSnapshot>
36+
}
37+
38+
/** Enough for a machine's worth of projects; the oldest go first. */
39+
const MAX_SNAPSHOTS = 64
40+
41+
export function snapshotPath(): string {
42+
return path.join(Global.Path.state, "altimate-attach-snapshots.json")
43+
}
44+
45+
function readFile(): SnapshotFile | null {
46+
const p = snapshotPath()
47+
if (!existsSync(p)) return null
48+
try {
49+
const raw = JSON.parse(readFileSync(p, "utf8")) as Partial<SnapshotFile> | null
50+
if (!raw || raw.version !== 1 || typeof raw.snapshots !== "object" || raw.snapshots === null) return null
51+
return raw as SnapshotFile
52+
} catch (err) {
53+
log.warn("attach snapshot file is corrupt, discarding", { code: (err as NodeJS.ErrnoException)?.code })
54+
return null
55+
}
56+
}
57+
58+
/** Best-effort, like every write to the state directory: a read-only home
59+
* must not turn a successful attach into a failure. */
60+
export function writeAttachSnapshot(directory: string, snapshot: AttachSnapshot): void {
61+
try {
62+
const file = readFile() ?? { version: 1, snapshots: {} }
63+
file.snapshots[path.resolve(directory)] = snapshot
64+
const entries = Object.entries(file.snapshots)
65+
if (entries.length > MAX_SNAPSHOTS) {
66+
entries.sort((a, b) => a[1].at - b[1].at)
67+
file.snapshots = Object.fromEntries(entries.slice(entries.length - MAX_SNAPSHOTS))
68+
}
69+
const p = snapshotPath()
70+
Filesystem.writeJsonAtomic(p, file)
71+
try {
72+
chmodSync(p, 0o600)
73+
} catch {
74+
// Umask permissions until the next write; the file holds tool keys, not credentials.
75+
}
76+
} catch (err) {
77+
log.warn("could not write the attach snapshot", { err: String(err) })
78+
}
79+
}
80+
81+
export function readAttachSnapshot(directory: string): AttachSnapshot | undefined {
82+
return readFile()?.snapshots[path.resolve(directory)]
83+
}

‎packages/opencode/src/altimate/workspace/engine-overlay.ts‎

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,6 @@ import {
4444
REPAIRABLE,
4545
TOOL_PREFIX,
4646
clearsFloor,
47-
describeExtensionServed,
48-
describeMissing,
4947
parseUnfulfilled,
5048
reportedMissing,
5149
describeRefusal,
@@ -59,6 +57,7 @@ import {
5957
type Outcome,
6058
type Toast,
6159
} from "./engine-types"
60+
import { readAttachSnapshot, writeAttachSnapshot, type AttachSnapshot } from "./attach-snapshot"
6261

6362
export * from "./engine-types"
6463
export * from "./engine-offer"
@@ -313,6 +312,19 @@ const declaredCache = new Map<string, { value: Declared | null; at: number }>()
313312
/** Verdict signatures a headless process has already printed to stderr. */
314313
const headlessPrinted = new Set<string>()
315314

315+
/** What the last attach in a directory produced, kept for the surfaces that
316+
* describe it after the fact — the sidebar tile and the `/workspace` status
317+
* view. In memory for this process, and on disk for the TUI process, which
318+
* is where those surfaces run (see `attach-snapshot.ts`). */
319+
const lastAttach = new Map<string, AttachSnapshot>()
320+
321+
/** The last attach snapshot for a directory: this process's, else the one on
322+
* disk, else undefined before any session has settled there. */
323+
export function attachSnapshot(directory: string | null = currentDirectory()): AttachSnapshot | undefined {
324+
if (directory === null) return undefined
325+
return lastAttach.get(directory) ?? readAttachSnapshot(directory)
326+
}
327+
316328
function record(sessionID: string, outcome: Outcome): SessionRecord {
317329
const previous = sessions.get(sessionID)
318330
sessions.delete(sessionID)
@@ -705,6 +717,17 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS
705717
...(declared?.extensions?.length ? { extensions: declared.extensions } : {}),
706718
}
707719
const rec = record(sessionID, outcome)
720+
const snapshot: AttachSnapshot = {
721+
workspace: { id: workspace.id, name: workspace.name },
722+
engineVersion: overlayNow.version,
723+
declared,
724+
present: [...present],
725+
unfulfilled,
726+
extServed,
727+
at: now(),
728+
}
729+
lastAttach.set(directory, snapshot)
730+
;(syncInternals.persistSnapshot ?? writeAttachSnapshot)(directory, snapshot)
708731
// Keyed on the workspace too: a re-link with an identical inventory is still
709732
// a new verdict the user should hear.
710733
// extServed is part of what the user hears, so it is part of the signature:
@@ -723,16 +746,42 @@ async function reconcile(sessionID: string, directory: string, state: DirectoryS
723746
unfulfilled,
724747
})
725748
if (isHeadless()) return
726-
const headline = declared
727-
? `${served} of ${declared.keys.length} declared integration tools available.`
728-
: `${outcome.available} integration tools available.`
749+
// Numbers only. The keys and their reasons live in the `/workspace` status
750+
// view, which the toast points at; a toast that tried to carry them read as
751+
// noise (review of the first cut).
729752
await notify({
730753
title: `Workspace "${workspace.name}"`,
731-
message: `${headline}${describeMissing(missingReport ?? [])}${describeExtensionServed(extServed)}`,
754+
message: attachSummary({
755+
served,
756+
declared: declared?.keys.length,
757+
available: outcome.available,
758+
gaps: missingReport?.length ?? 0,
759+
extServed,
760+
}),
732761
variant: missingReport !== undefined && missingReport.length > 0 ? "warning" : "info",
733762
})
734763
}
735764

765+
/** The one line a settled attach is announced with: counts, then where the
766+
* detail is. `declared` undefined means no allowlist was readable, so only
767+
* what the engine serves can be counted. */
768+
export function attachSummary(input: {
769+
served: number
770+
declared: number | undefined
771+
available: number
772+
gaps: number
773+
extServed: number
774+
}): string {
775+
const parts = [
776+
input.declared === undefined
777+
? `${input.available} integration tools available`
778+
: `${input.served} of ${input.declared} integration tools available`,
779+
]
780+
if (input.gaps > 0) parts.push(`${input.gaps} need attention`)
781+
if (input.extServed > 0) parts.push(`${input.extServed} more via VS Code`)
782+
return `${parts.join(" · ")}. Details: /workspace`
783+
}
784+
736785
/** Tell the session about a refusal, once per unchanged verdict.
737786
*
738787
* The substitution point for the install offer: when installing would help
@@ -802,6 +851,7 @@ export function isRepairable(outcome: Outcome | undefined): boolean {
802851

803852
/** Test-only: forget everything this process learned. */
804853
export function resetForTests(): void {
854+
lastAttach.clear()
805855
directories.clear()
806856
probeMemo = null
807857
sessions.clear()

‎packages/opencode/src/altimate/workspace/engine-seams.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Flag as CoreFlag } from "@opencode-ai/core/flag/flag"
66
import { Instance } from "@/project/instance"
77
import { Log } from "@/altimate/util/log"
88
import type { CachedBinding } from "./state"
9+
import type { AttachSnapshot } from "./attach-snapshot"
910
import type { Declared, LocalMcpConfig, McpEntry, McpStatus, Toast } from "./engine-types"
1011
import type { EngineOffer, InstallResult } from "./engine-offer"
1112

@@ -45,6 +46,8 @@ export const syncInternals: {
4546
headless?: () => boolean
4647
serve?: () => boolean
4748
now?: () => number
49+
/** Tests keep the attach snapshot out of the real state directory. */
50+
persistSnapshot?: (directory: string, snapshot: AttachSnapshot) => void
4851
mcp?: {
4952
status: () => Promise<McpStatus>
5053
add: (name: string, cfg: LocalMcpConfig | McpEntry) => Promise<unknown>

‎packages/opencode/src/altimate/workspace/engine-types.ts‎

Lines changed: 4 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -294,48 +294,10 @@ const REASON_PHRASE: Record<UnfulfilledReason, string> = {
294294
"no-bridge": "needs a VS Code window",
295295
}
296296

297-
const MISSING_SHOWN = 5
298-
const DETAIL_CHARS = 60
299-
300-
/** The gaps, grouped by reason AND integration in report order, at most
301-
* `MISSING_SHOWN` keys across the groups; a group's first detail (the engine's
302-
* error text, e.g. `spawn docker ENOENT`) stands for the group. Grouped per
303-
* integration so one integration's error is never printed as another's — two
304-
* servers that both failed to start failed for their own reasons. (multi-model review) */
305-
export function describeMissing(missing: Unfulfilled[]): string {
306-
if (missing.length === 0) return ""
307-
const groups = new Map<string, { reason: string; keys: string[]; detail?: string }>()
308-
for (const u of missing) {
309-
const id = `${u.reason}${u.integrationId}`
310-
const group = groups.get(id) ?? { reason: u.reason, keys: [] }
311-
group.keys.push(u.key)
312-
if (group.detail === undefined && u.detail) group.detail = u.detail
313-
groups.set(id, group)
314-
}
315-
let budget = MISSING_SHOWN
316-
const parts: string[] = []
317-
for (const { reason, ...group } of groups.values()) {
318-
if (budget <= 0) break
319-
const shown = group.keys.slice(0, budget)
320-
budget -= shown.length
321-
const phrase = (REASON_PHRASE as Record<string, string>)[reason] ?? reason
322-
const detail = group.detail === undefined ? "" : ` (${truncate(group.detail, DETAIL_CHARS)})`
323-
parts.push(`${phrase}${detail}: ${shown.join(", ")}`)
324-
}
325-
const more = missing.length > MISSING_SHOWN ? ` (+${missing.length - MISSING_SHOWN} more)` : ""
326-
return ` Declared but not available — ${parts.join("; ")}${more}.`
327-
}
328-
329-
function truncate(text: string, max: number): string {
330-
return text.length <= max ? text : `${text.slice(0, max - 1)}…`
331-
}
332-
333-
/** Extension-declared tools a connected IDE bridge is actually serving. Zero
334-
* is the normal no-IDE case and says nothing — absent extension tools are
335-
* expected, not missing, so they never join `describeMissing`. */
336-
export function describeExtensionServed(count: number): string {
337-
if (count === 0) return ""
338-
return ` Plus ${count} extension tool${count === 1 ? "" : "s"} via the connected VS Code window.`
297+
/** A reason in the user's words. An unknown reason (a newer engine) is shown
298+
* verbatim rather than dropped. */
299+
export function reasonPhrase(reason: string): string {
300+
return (REASON_PHRASE as Record<string, string>)[reason] ?? reason
339301
}
340302

341303
/** What each outcome MEANS, as tables over the whole union: a new variant

0 commit comments

Comments
 (0)