Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 37 additions & 5 deletions packages/app/src/pages/session/context-tree-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@
// the real file (project files in a session tab, vault files in the Vault
// panel). Hovering a tool row in the log still glances at its node here via
// the same amicode:brain-hover event the strip used.
import { createEffect, createMemo, createSignal, onCleanup, onMount, For, Show } from "solid-js"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, For, Show } from "solid-js"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { useSync } from "@/context/sync"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useServer } from "@/context/server"
import { amicodeGet } from "@/utils/amicode-fetch"
import { amicoBrainRef } from "@opencode-ai/ui/brain-ref"
import {
createContextTreeEngine,
Expand Down Expand Up @@ -66,8 +68,33 @@ function ContextTreeFrame(props: { sessionID: string }) {
const sync = useSync()
const file = useFile()
const language = useLanguage()
const server = useServer()
const { tabs, view } = useSessionLayout()

// per-mount browsability from GET /amicode/vaults (`browsable`, stamped by
// the server's fail-closed law) — proprietary mounts mark their nodes
// locked upfront instead of dead-ending in a Vault-panel refusal on click
const [vaultsRaw] = createResource(
() => server.current,
(conn) => amicodeGet(conn, "/amicode/vaults").catch(() => undefined),
)
const browsableMounts = createMemo<Map<string, boolean | undefined> | undefined>(() => {
const raw = vaultsRaw() as { mounts?: { id?: string; browsable?: boolean }[] } | undefined
if (!raw || !Array.isArray(raw.mounts)) return undefined
return new Map(
raw.mounts.filter((m) => typeof m?.id === "string").map((m) => [m.id as string, m.browsable]),
)
})
const vaultLocked = (mount: string) => {
const map = browsableMounts()
// list unavailable → status quo (no lock claims we can't back);
// a mount the server doesn't list can't be browsed → locked;
// `browsable` absent (older server) → unknown, again no lock claim
if (!map) return false
if (!map.has(mount)) return true
return map.get(mount) === false
}

const messages = createMemo(() => sync.data.message[props.sessionID] ?? [])
const getParts = (msgId: string) => sync.data.part[msgId] ?? []
const busy = createMemo(() => (sync.data.session_status[props.sessionID]?.type ?? "idle") !== "idle")
Expand Down Expand Up @@ -134,7 +161,7 @@ function ContextTreeFrame(props: { sessionID: string }) {
setActive: (tab) => tabs().setActive(tab),
})
const onSelect = (node: ContextTreeSelection) => {
if (!node.path) return
if (!node.path || node.locked) return
const vaultRef = vaultRefFromPath(node.path)
if (vaultRef) {
vaultPanel.open({ mount: vaultRef.mount, path: vaultRef.rel })
Expand All @@ -161,7 +188,7 @@ function ContextTreeFrame(props: { sessionID: string }) {
window.addEventListener("amicode:brain-hover", onToolHover)
onCleanup(() => window.removeEventListener("amicode:brain-hover", onToolHover))

const tree = createMemo(() => buildContextTree(turns()))
const tree = createMemo(() => buildContextTree(turns(), { vaultLocked }))
createEffect(() => {
const brain = engine()
if (!brain) return
Expand All @@ -173,7 +200,8 @@ function ContextTreeFrame(props: { sessionID: string }) {
const flatNodes = createMemo(() => {
const out: ContextTreeSelection[] = []
const walk = (n: ContextTreeNodeInput) => {
if (n.kind !== "root") out.push({ id: n.id, label: n.label, kind: n.kind, path: n.path, vault: n.vault })
if (n.kind !== "root")
out.push({ id: n.id, label: n.label, kind: n.kind, path: n.path, vault: n.vault, locked: n.locked })
for (const c of n.children ?? []) walk(c)
}
walk(tree())
Expand All @@ -188,7 +216,11 @@ function ContextTreeFrame(props: { sessionID: string }) {
setKbIndex(next)
const node = list[next]
engine()?.focus(node.id)
setAnnounce(`${node.label} — ${node.kind}${node.path ? ", press Enter to open" : ""}`)
setAnnounce(
`${node.label} — ${node.kind}${
node.locked ? ", locked — this vault does not allow browsing" : node.path ? ", press Enter to open" : ""
}`,
)
}
const onCanvasKeyDown = (e: KeyboardEvent) => {
const list = flatNodes()
Expand Down
28 changes: 27 additions & 1 deletion packages/opencode/src/server/amicode/vaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, symlinkSync, writeFil
import { homedir } from "node:os"
import path from "node:path"
import { run } from "@/util/process"
import { browseAllowed, mountBrowseRefusal, mountDir } from "@/server/amicode/vault-browser"

const TIMEOUT_MS = 8_000
const CACHE_MS = 10_000
Expand Down Expand Up @@ -65,9 +66,34 @@ function resolveCli(): string | undefined {

let cache: { at: number; body: string } | undefined

/** Stamp each mount with `browsable`, computed by the vault-browser's
* fail-closed law (deployment gate + per-mount kind/marker rules), so the
* app can mark proprietary context locked UPFRONT — e.g. grey out context
* tree nodes — instead of discovering the refusal on click. Additive to the
* relayed wire shape; an unparseable body passes through untouched. */
export function annotateBrowsable(
body: string,
root: string = vaultsRoot(),
env: Record<string, string | undefined> = process.env,
): string {
try {
const parsed = JSON.parse(body) as { mounts?: { id?: unknown; browsable?: boolean }[] }
if (!Array.isArray(parsed.mounts)) return body
const allowed = browseAllowed(env)
for (const m of parsed.mounts) {
if (typeof m?.id !== "string") continue
const dir = allowed ? mountDir(m.id, root) : undefined
m.browsable = !!dir && !mountBrowseRefusal(m.id, dir, env)
}
return JSON.stringify(parsed)
} catch {
return body
}
}

export async function status(): Promise<string> {
if (cache && Date.now() - cache.at < CACHE_MS) return cache.body
const body = await statusUncached().catch((err) => synthesize("bad_output", String(err)))
const body = annotateBrowsable(await statusUncached().catch((err) => synthesize("bad_output", String(err))))
cache = { at: Date.now(), body }
return body
}
Expand Down
46 changes: 45 additions & 1 deletion packages/opencode/test/server/amicode-vaults.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@ import { describe, expect, test } from "bun:test"
import { mkdirSync, mkdtempSync, writeFileSync, existsSync, lstatSync } from "node:fs"
import { tmpdir, homedir } from "node:os"
import path from "node:path"
import { candidates, synthesize, normalizeRef, sanitizeVaultName, attachVault, scanMounts } from "@/server/amicode/vaults"
import {
annotateBrowsable,
candidates,
synthesize,
normalizeRef,
sanitizeVaultName,
attachVault,
scanMounts,
} from "@/server/amicode/vaults"

describe("synthesize", () => {
test("emits the plural failure shape the UI parser expects", () => {
Expand Down Expand Up @@ -118,3 +126,39 @@ describe("scanMounts (CLI-less fallback)", () => {
expect(out.mounts[1]).toMatchObject({ id: "armonissima", kind: "team", writable: false })
})
})

describe("annotateBrowsable", () => {
const root = mkdtempSync(path.join(tmpdir(), "vaults-annotate-"))
const mk = (name: string, marker: string) => {
mkdirSync(path.join(root, name), { recursive: true })
writeFileSync(path.join(root, name, ".amico-vault.toml"), marker)
}
mk("personal-v", 'kind = "personal"\nname = "personal-v"\n')
mk("team-dark", 'kind = "team"\nname = "team-dark"\n')
mk("team-open", 'kind = "team"\nname = "team-open"\nbrowse = true\n')
mk("personal-off", 'kind = "personal"\nname = "personal-off"\nbrowse = false\n')
const env = { AMICO_VAULT_BROWSER: "1" }

test("stamps browsable per the fail-closed law (kind default + browse override)", () => {
const out = JSON.parse(annotateBrowsable(scanMounts(root), root, env)) as {
mounts: { id: string; browsable: boolean }[]
}
const by = Object.fromEntries(out.mounts.map((m) => [m.id, m.browsable]))
expect(by["personal-v"]).toBe(true)
expect(by["team-dark"]).toBe(false)
expect(by["team-open"]).toBe(true)
expect(by["personal-off"]).toBe(false)
})
test("a mount the browser can't resolve is not browsable; junk passes through", () => {
const body = JSON.stringify({ ok: true, mounts: [{ id: "ghost", kind: "personal" }], error: null })
const out = JSON.parse(annotateBrowsable(body, root, env)) as { mounts: { browsable: boolean }[] }
expect(out.mounts[0].browsable).toBe(false)
expect(annotateBrowsable("not json", root, env)).toBe("not json")
})
test("deployment gate off (AMICO_VAULT_BROWSER=0) darkens every mount", () => {
const out = JSON.parse(annotateBrowsable(scanMounts(root), root, { AMICO_VAULT_BROWSER: "0" })) as {
mounts: { browsable: boolean }[]
}
for (const m of out.mounts) expect(m.browsable).toBe(false)
})
})
16 changes: 16 additions & 0 deletions packages/ui/src/amicode/context-tree-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,22 @@ describe("buildContextTree", () => {
])
expect(tree.children![0].children![0].vault).toBe(true)
})
test("vaultLocked marks non-browsable vault leaves locked; others untouched", () => {
const tree = buildContextTree(
[
turn("m1", [
{ label: "STRATEGY.md", type: "note", path: "/u/.amico/vaults/armonissima/STRATEGY.md" },
{ label: "notes.md", type: "note", path: "/u/.amico/vaults/armonia-kate/notes.md" },
{ label: "solve.jl", type: "package", path: "/p/solve.jl" },
]),
],
{ vaultLocked: (mount) => mount === "armonissima" },
)
const [team, personal, project] = tree.children![0].children!
expect(team.locked).toBe(true)
expect(personal.locked).toBe(false)
expect(project.locked).toBeUndefined() // not a vault file — predicate never consulted
})
test("marathon sessions fold old turns into one earlier branch", () => {
const turns = Array.from({ length: 30 }, (_, i) =>
turn(`m${i}`, [{ label: `f${i}.md`, type: "note", path: `/p/f${i}.md` }]),
Expand Down
16 changes: 12 additions & 4 deletions packages/ui/src/amicode/context-tree-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,14 @@ export function vaultRefFromPath(path: string): { mount: string; rel: string } |
const dedupKey = (ref: ContextRef, kind: ContextTreeKind) =>
ref.path ? `p:${ref.path}` : `${kind}:${ref.label.toLowerCase()}`

export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: string } = {}): ContextTreeNodeInput {
export type ContextTreeOpts = {
rootLabel?: string
/** true when the mount refuses browsing (proprietary data/software) — its
* leaves render locked: dimmed, padlocked, not openable */
vaultLocked?: (mount: string) => boolean
}

export function buildContextTree(turns: ContextTurn[], opts: ContextTreeOpts = {}): ContextTreeNodeInput {
const root: ContextTreeNodeInput = {
id: "root",
label: opts.rootLabel ?? "amico",
Expand Down Expand Up @@ -95,7 +102,7 @@ export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: strin
const key = dedupKey(ref, kind)
if (seen.has(key)) continue
seen.set(key, `ctx-${seen.size}`)
earlier.children!.push(leafOf(ref, kind, seen.get(key)!))
earlier.children!.push(leafOf(ref, kind, seen.get(key)!, opts))
}
}
const seen = seenOf(root)
Expand Down Expand Up @@ -123,7 +130,7 @@ export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: strin
}
const id = `ctx-${seen.size}`
seen.set(key, id)
const leaf = leafOf(ref, kind, id)
const leaf = leafOf(ref, kind, id, opts)
node.children!.push(leaf)
lastLeaf = leaf
}
Expand All @@ -138,14 +145,15 @@ export function buildContextTree(turns: ContextTurn[], opts: { rootLabel?: strin
return root
}

function leafOf(ref: ContextRef, kind: ContextTreeKind, id: string): ContextTreeNodeInput {
function leafOf(ref: ContextRef, kind: ContextTreeKind, id: string, opts: ContextTreeOpts): ContextTreeNodeInput {
const vault = ref.path ? vaultRefFromPath(ref.path) : undefined
return {
id,
label: ref.label.slice(0, 32),
kind,
path: ref.path,
vault: !!vault,
locked: vault && opts.vaultLocked ? opts.vaultLocked(vault.mount) : undefined,
}
}

Expand Down
43 changes: 35 additions & 8 deletions packages/ui/src/amicode/context-tree-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ export type ContextTreeNodeInput = {
path?: string
/** the file lives in a vault mount (open via the Vault panel, not a tab) */
vault?: boolean
/** the vault refuses browsing (proprietary data/software) — the node renders
* dimmed with a padlock and is NOT openable, path or not */
locked?: boolean
/** where the agent currently works — wears the thought-color cursor */
active?: boolean
children?: ContextTreeNodeInput[]
Expand All @@ -57,6 +60,7 @@ export type ContextTreeSelection = {
kind: ContextTreeKind
path?: string
vault?: boolean
locked?: boolean
}

export interface ContextTreeEngineOptions {
Expand Down Expand Up @@ -181,6 +185,7 @@ interface TNode {
kind: ContextTreeKind
path?: string
vault?: boolean
locked?: boolean
active: boolean
depth: number
// world targets (tidy layout) and animated positions
Expand Down Expand Up @@ -333,6 +338,7 @@ export function createContextTreeEngine(
n.kind = input.kind
n.path = input.path
n.vault = input.vault
n.locked = input.locked
n.active = !!input.active
n.depth = depth
n.half = HALF[input.kind] ?? 4
Expand Down Expand Up @@ -456,7 +462,11 @@ export function createContextTreeEngine(
kind: n.kind,
path: n.path,
vault: n.vault,
locked: n.locked,
})
// openable = the click actually goes somewhere; everything else (turns,
// skills, agents, actions, locked vault files) must not wear the pointer
const openable = (n: TNode) => !!n.path && !n.locked
let dragging = false
let dragMoved = false
let lastPX = 0,
Expand Down Expand Up @@ -499,7 +509,8 @@ export function createContextTreeEngine(
const n = id ? (byId.get(id) ?? null) : null
if (n !== hovered) {
hovered = n
if (canvas.style) canvas.style.cursor = n && (n.path || n.kind === "turn") ? "pointer" : "grab"
// pointer only where a click opens something; a locked node says so
if (canvas.style) canvas.style.cursor = n && openable(n) ? "pointer" : n?.locked ? "not-allowed" : "grab"
opts.onHover?.(n ? selection(n) : null)
}
}
Expand All @@ -510,7 +521,9 @@ export function createContextTreeEngine(
const p = local(e)
const id = pick(p.x, p.y)
const n = id ? byId.get(id) : undefined
if (n) {
// only openable nodes acknowledge the click — a ring on a dead-end node
// would promise an action that never comes
if (n && openable(n)) {
n.ringT = beatNow
opts.onSelect?.(selection(n))
}
Expand Down Expand Up @@ -647,9 +660,11 @@ export function createContextTreeEngine(
ctx.lineWidth = 1
ctx.stroke()
} else {
ctx.fillStyle = rgba(color, hoveredNow ? 0.95 : 0.75)
// locked (non-browsable vault) leaves read clearly non-interactive:
// reduced emphasis, plus the padlock by the label (shape, not color)
ctx.fillStyle = rgba(color, n.locked ? 0.3 : hoveredNow ? 0.95 : 0.75)
ctx.fill()
ctx.strokeStyle = rgba(color, 0.9)
ctx.strokeStyle = rgba(color, n.locked ? 0.45 : 0.9)
ctx.lineWidth = 1
ctx.stroke()
}
Expand Down Expand Up @@ -690,13 +705,25 @@ export function createContextTreeEngine(
isRoot || n.kind === "turn" ? 0.85 : hoveredNow || glancedNow || n.active ? 1 : nearHover ? 0.85 : 0.6
ctx.font = `${n.kind === "turn" || isRoot ? "600 " : ""}10px JuliaMono, ui-monospace, SFMono-Regular, Menlo, monospace`
const text = n.label.length > 30 ? n.label.slice(0, 29) + "…" : n.label
const lockW = n.locked ? 10 : 0
const tw = ctx.measureText(text).width
const lx = x - tw / 2,
const lx = x - (tw + lockW) / 2,
ly = y + half + 10
ctx.fillStyle = css.labelHalo
ctx.fillRect(lx - 2, ly - 7, tw + 4, 14)
ctx.fillStyle = rgba(css.fg, Math.min(la, 1) * n.alpha)
ctx.fillText(text, lx, ly)
ctx.fillRect(lx - 2, ly - 7, tw + lockW + 4, 14)
const inkA = Math.min(la, 1) * n.alpha
if (n.locked) {
// padlock: shackle arc over a body — the non-color "cannot open" signal
ctx.strokeStyle = rgba(css.fg, inkA)
ctx.lineWidth = 1
ctx.beginPath()
ctx.arc(lx + 3, ly - 1.5, 2, Math.PI, 0)
ctx.stroke()
ctx.fillStyle = rgba(css.fg, inkA)
ctx.fillRect(lx, ly - 1.5, 6, 5)
}
ctx.fillStyle = rgba(css.fg, inkA)
ctx.fillText(text, lx + lockW, ly)
ctx.globalAlpha = 1
}

Expand Down
Loading