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
125 changes: 125 additions & 0 deletions packages/app/src/pages/session/v2/lineage-ledger-panel.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/* Unified lineage-ledger panel (amicode#1082). Geometry + color come only from
* design-system tokens, so both themes are equal citizens and the status color
* is redundant to the icon + text label (color is never the only signal). */

[data-component="lineage-ledger"] {
display: flex;
flex-direction: column;
gap: var(--space-2, 8px);
}

[data-component="lineage-ledger"] [data-slot="lineage-ledger-capability"] {
display: flex;
flex-direction: column;
gap: var(--space-1, 4px);
padding: var(--space-3, 12px);
border: 1px solid var(--v2-border-border-base);
border-radius: var(--radius-lg, 12px);
background: var(--v2-background-bg-layer-01);
}

[data-component="lineage-ledger"] [data-slot="lineage-ledger-capability"][data-mode="legacy"],
[data-component="lineage-ledger"] [data-slot="lineage-ledger-capability"][data-mode="partial"] {
border-color: var(--v2-border-border-warning);
}

[data-component="lineage-ledger"] [data-slot="lineage-ledger-filter"] {
padding: var(--space-1, 4px) var(--space-2, 8px);
border: 1px solid var(--v2-border-border-base);
border-radius: var(--radius-md, 8px);
background: var(--v2-background-bg-layer-02);
color: var(--v2-text-text-base);
}

[data-component="lineage-ledger"] [data-slot="lineage-ledger-filter"]:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: 1px;
}

[data-component="lineage-ledger"] [data-slot="lineage-ledger-resources"] {
display: flex;
flex-direction: column;
gap: var(--space-1, 4px);
}

[data-component="lineage-ledger"] [data-slot="lineage-ledger-resource-row"] {
display: flex;
align-items: center;
gap: var(--space-2, 8px);
width: 100%;
padding: var(--space-1, 4px) var(--space-2, 8px);
border-radius: var(--radius-md, 8px);
background: transparent;
text-align: left;
cursor: pointer;
}

[data-component="lineage-ledger"] [data-slot="lineage-ledger-resource-row"]:hover {
background: var(--v2-overlay-simple-overlay-hover);
}

[data-component="lineage-ledger"] [data-slot="lineage-ledger-resource-row"]:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: -1px;
}

[data-component="lineage-ledger"] [data-slot="lineage-ledger-path"] {
flex: 1 1 auto;
min-width: 0;
}

[data-component="lineage-ledger"] [data-slot="lineage-ledger-status"] {
display: inline-flex;
align-items: center;
gap: var(--space-1, 4px);
}

[data-component="lineage-ledger"] [data-slot="lineage-ledger-status"][data-tone="success"] {
color: var(--v2-state-fg-success);
}
[data-component="lineage-ledger"] [data-slot="lineage-ledger-status"][data-tone="danger"] {
color: var(--v2-state-fg-danger);
}
[data-component="lineage-ledger"] [data-slot="lineage-ledger-status"][data-tone="warning"] {
color: var(--v2-state-fg-warning);
}
[data-component="lineage-ledger"] [data-slot="lineage-ledger-status"][data-tone="neutral"] {
color: var(--v2-text-text-muted);
}

[data-component="lineage-ledger"] [data-slot="lineage-ledger-history"] {
display: flex;
flex-direction: column;
gap: var(--space-1, 4px);
padding: var(--space-1, 4px) var(--space-2, 8px) var(--space-2, 8px) var(--space-5, 20px);
}

[data-component="lineage-ledger"] [data-slot="lineage-ledger-unknown"] {
display: flex;
flex-direction: column;
gap: var(--space-1, 4px);
padding: var(--space-2, 8px);
border: 1px solid var(--v2-border-border-warning);
border-radius: var(--radius-lg, 12px);
}

[data-component="lineage-ledger"] [data-slot="lineage-ledger-unknown-item"] {
display: flex;
align-items: center;
gap: var(--space-2, 8px);
}

[data-component="lineage-ledger"] [data-slot="lineage-ledger-more"] {
align-self: flex-start;
padding: var(--space-1, 4px) var(--space-2, 8px);
border: 1px solid var(--v2-border-border-base);
border-radius: var(--radius-md, 8px);
background: var(--v2-background-bg-layer-01);
color: var(--v2-text-text-base);
cursor: pointer;
}

[data-component="lineage-ledger"] [data-slot="lineage-ledger-more"]:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: 1px;
}
71 changes: 71 additions & 0 deletions packages/app/src/pages/session/v2/lineage-ledger-panel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, test } from "bun:test"
import { readFileSync } from "node:fs"
import { resolve } from "node:path"

// The lineage-ledger panel is authored in Solid JSX; bun's test transpiler does
// not run Solid's dom-expressions transform, so (as elsewhere in this repo —
// see vscode-explorer-file-icon.test.tsx) the component's behavioral substance
// lives in pure, directly-tested functions (lineage-ledger-view.test.ts) and
// this test proves the component wires the accessibility contract those
// functions require. The Work Column design contract (amicode#1082 AC8) is:
// Tab reaches each filter/row/continuation/unknown item, Enter/Space toggles a
// resource, Escape returns focus to its row, and each status has an accessible
// name plus a non-color signal.
const source = readFileSync(resolve(__dirname, "lineage-ledger-panel.tsx"), "utf8")

describe("LineageLedgerPanel — render + accessibility contract", () => {
test("renders from the projected view-model only (no tool/watcher inference — AC1)", () => {
expect(source).toContain("./lineage-ledger-view")
expect(source).toContain("props.view()")
expect(source).not.toMatch(/accumulate-diffs|toolDiffs|externalFileStatus|watcher/i)
})

test("surfaces the capability label as accessible status text (AC7)", () => {
expect(source).toContain('data-slot="lineage-ledger-capability"')
expect(source).toContain('role="status"')
expect(source).toContain("capability().label")
expect(source).toContain("capability().description")
})

test("each canonical resource is one focusable, toggleable row (AC2/AC8)", () => {
expect(source).toContain('data-slot="lineage-ledger-resource"')
expect(source).toContain("aria-expanded")
expect(source).toContain("aria-controls")
// a real <button> is natively Tab-reachable and Enter/Space-activatable
expect(source).toMatch(/<button[\s\S]*?onKeyDown/)
})

test("status carries an accessible name and a non-color signal (AC5)", () => {
expect(source).toContain('data-slot="lineage-ledger-status"')
expect(source).toContain("status.accessibleName")
expect(source).toContain("data-status={")
expect(source).toContain("data-tone={")
// icon + text label accompany the tone — color is never the only signal
expect(source).toContain("status.icon")
expect(source).toContain("status.label")
})

test("expanded history is a chronological region keyed to its row (AC3)", () => {
expect(source).toContain('data-slot="lineage-ledger-history"')
expect(source).toContain("history")
expect(source).toContain("assessments")
})

test("unknown mutation receipts render in a dedicated uncertainty group (AC4)", () => {
expect(source).toContain('data-slot="lineage-ledger-unknown"')
expect(source).toContain("Unknown Mutation Receipts")
expect(source).toContain("view().unknown")
})

test("keyboard: applyLedgerKeyDown drives toggle/collapse and Escape refocuses the row (AC8)", () => {
expect(source).toContain("applyLedgerKeyDown")
expect(source).toContain('"toggle"')
expect(source).toContain('"collapse"')
expect(source).toContain("focus()")
})

test("paging exposes a Tab-reachable continuation control (AC6)", () => {
expect(source).toContain('data-slot="lineage-ledger-more"')
expect(source).toContain("nextCursor")
})
})
193 changes: 193 additions & 0 deletions packages/app/src/pages/session/v2/lineage-ledger-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { createMemo, createSignal, For, Show, type JSX } from "solid-js"
import { Icon } from "@opencode-ai/ui/icon"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { applyLedgerKeyDown, type LedgerResourceRow, type LedgerView } from "./lineage-ledger-view"
import "./lineage-ledger-panel.css"

export type LineageLedgerPanelProps = {
// The already-projected, capability-gated view model. The panel renders THIS
// and nothing else — it receives no tool metadata and no filesystem-observer
// input, so it cannot infer ownership (amicode#1082 AC1).
view: () => LedgerView
filter: () => string
onFilterChange: (value: string) => void
// Advances the resource page cursor across a large root ledger (AC6).
onLoadMore?: () => void
title?: JSX.Element
}

/**
* Files Changed, rendered as the unified lineage ledger (amicode#1082): one
* canonical-resource row per resource with expandable receipt + assessment
* history, a dedicated Unknown Mutation Receipts group, an explicit capability
* label, and full keyboard operation across both themes.
*/
export function LineageLedgerPanel(props: LineageLedgerPanelProps) {
const view = createMemo(() => props.view())
const capability = () => view().capability
const [expanded, setExpanded] = createSignal<ReadonlySet<string>>(new Set())
const rowRefs = new Map<string, HTMLButtonElement>()

const isExpanded = (id: string) => expanded().has(id)
const toggle = (id: string) =>
setExpanded((current) => {
const next = new Set(current)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
const collapse = (id: string) =>
setExpanded((current) => {
if (!current.has(id)) return current
const next = new Set(current)
next.delete(id)
return next
})

const query = () => props.filter().trim().toLowerCase()
const resources = createMemo(() => {
const value = query()
if (!value) return view().resources
return view().resources.filter(
(row) => row.displayPath.toLowerCase().includes(value) || row.aliases.some((alias) => alias.toLowerCase().includes(value)),
)
})

const onRowKeyDown = (event: KeyboardEvent, row: LedgerResourceRow) => {
const action = applyLedgerKeyDown(event, { focusKind: "row", id: row.id, expanded: isExpanded(row.id) })
if (action.type === "toggle") toggle(action.id)
if (action.type === "collapse") {
collapse(action.id)
rowRefs.get(action.refocus)?.focus()
}
}

const historyID = (id: string) => `lineage-ledger-history-${id}`

return (
<div data-component="lineage-ledger">
<div data-slot="lineage-ledger-capability" role="status" data-mode={capability().mode}>
<span data-slot="lineage-ledger-capability-label" class="text-12-medium text-text-base">
{capability().label}
</span>
<span data-slot="lineage-ledger-capability-description" class="text-12-medium text-text-muted">
{capability().description}
</span>
</div>

<Show when={props.title}>{props.title}</Show>

<input
data-slot="lineage-ledger-filter"
class="text-12-medium text-text-base"
type="text"
value={props.filter()}
placeholder="Filter changed resources"
aria-label="Filter changed resources"
onInput={(event) => props.onFilterChange(event.currentTarget.value)}
/>

<div data-slot="lineage-ledger-resources" role="list">
<For each={resources()}>
{(row) => (
<div data-slot="lineage-ledger-resource" role="listitem" data-status={row.status.kind}>
<button
type="button"
data-slot="lineage-ledger-resource-row"
ref={(element) => rowRefs.set(row.id, element)}
aria-expanded={isExpanded(row.id)}
aria-controls={historyID(row.id)}
onKeyDown={(event) => onRowKeyDown(event, row)}
onClick={() => toggle(row.id)}
>
<Icon name={isExpanded(row.id) ? "chevron-down" : "chevron-right"} size="small" />
<span class="filetree-iconpair size-4">
<FileIcon node={{ path: row.displayPath, type: "file" }} class="size-4" />
</span>
<span data-slot="lineage-ledger-path" class="text-12-medium text-text-base truncate">
{row.displayPath}
</span>
<Show when={row.aliases.length}>
<span data-slot="lineage-ledger-aliases" class="text-12-medium text-text-faint truncate">
{`was ${row.aliases.join(", ")}`}
</span>
</Show>
<span
data-slot="lineage-ledger-status"
data-status={row.status.kind}
data-tone={row.status.tone}
aria-label={row.status.accessibleName}
>
<Icon name={row.status.icon} size="small" />
<span class="text-12-medium">{row.status.label}</span>
</span>
<span data-slot="lineage-ledger-count" class="text-12-medium text-text-faint">
{`${row.receiptCount} receipt${row.receiptCount === 1 ? "" : "s"}`}
</span>
<Show when={row.origins.length}>
<span data-slot="lineage-ledger-origins" class="text-12-medium text-text-faint">
{row.origins.join(", ")}
</span>
</Show>
<Show when={row.sources.length}>
<span data-slot="lineage-ledger-sources" class="text-12-medium text-text-faint">
{`from ${row.sources.join(", ")}`}
</span>
</Show>
</button>

<Show when={isExpanded(row.id)}>
<div id={historyID(row.id)} data-slot="lineage-ledger-history" role="region" aria-label={`History for ${row.displayPath}`}>
<For each={row.history}>
{(entry) => (
<div data-slot="lineage-ledger-history-entry">
<div data-slot="lineage-ledger-receipt" class="text-12-medium text-text-base">
{`#${entry.sequence} ${entry.operation ?? "operation"} → ${entry.outcome ?? "unknown"}`}
<Show when={entry.resource && entry.resource !== row.displayPath}>
<span class="text-text-faint">{` (${entry.resource})`}</span>
</Show>
</div>
<For each={entry.assessments}>
{(assessment) => (
<div data-slot="lineage-ledger-assessment" class="text-12-medium text-text-muted">
{`rev ${assessment.revision}: ${assessment.netState ?? "unknown"} · evidence ${assessment.evidenceState ?? "unavailable"}`}
</div>
)}
</For>
</div>
)}
</For>
</div>
</Show>
</div>
)}
</For>
</div>

<Show when={view().unknown.length}>
<section data-slot="lineage-ledger-unknown" role="list" aria-label="Unknown Mutation Receipts">
<div data-slot="lineage-ledger-unknown-heading" class="text-12-medium text-text-muted">
{"Unknown Mutation Receipts"}
</div>
<For each={view().unknown}>
{(item) => (
<div data-slot="lineage-ledger-unknown-item" role="listitem" class="text-12-medium text-text-base">
<Icon name="glasses" size="small" />
<span>{`#${item.sequence} ${item.operation ?? "operation"} → ${item.outcome ?? "opaque"}`}</span>
<Show when={item.origin}>
<span class="text-text-faint">{item.origin}</span>
</Show>
</div>
)}
</For>
</section>
</Show>

<Show when={view().page.nextCursor !== undefined}>
<button data-slot="lineage-ledger-more" type="button" class="text-12-medium text-text-base" onClick={() => props.onLoadMore?.()}>
{"Show more"}
</button>
</Show>
</div>
)
}
Loading
Loading