diff --git a/CHANGELOG.md b/CHANGELOG.md index 2784396..374478f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,30 @@ # Changelog -## 0.7.0 — prepared 2026-10-12 (unpublished) +## 0.7.1 — 2026-11-09 + +- Add Reply, Review approval and Inspect failure actions with the reported + question or request and its age. +- Review completed work with its objective, observed changed files, reported + links and checks. Distinguish finished turns from completed goals and + independent verification. +- Expand observed child agents and show meaningful tool/target summaries. +- Add a since-you-last-checked digest, browser-persisted filters and up to eight + saved dashboard views. +- Refine card spacing, restrained status edges, operation text and compact + expandable results. Preserve the indeterminate running strip and reduced motion. +- Fix timeline scrolling and add incremental earlier-history loading, stable + event merging, preserved scroll position, reconnect gap bridging and retries. +- Add release discovery and a review/confirm flow for BB-eligible updates. + Out-of-range and pinned sources remain advisory; source ranges are never widened. +- Correct stale release and upgrade documentation. + +No storage migration or reset is required. Existing `^0.7.0` selectors include +this release. Earlier-history loading depends on BB retention and source APIs; +optional Fleet and Redteam history remains bounded. See +[daily monitoring](docs/daily-workflow.md) and +[release validation](docs/validation-0.7.1.md). + +## 0.7.0 — 2026-10-12 - Show the latest observed public activity and distinguish recent from quiet reporting, without changing task status or the indeterminate activity strips. @@ -12,7 +36,7 @@ Contain watcher callback/publication errors and surface degraded monitoring. - Reduce empty Needs attention to a compact line; actionable items remain visible. - Add bundled What's New highlights with per-version local dismissal and a - persistent Release notes control. No telemetry or remote release fetching. + persistent Release notes control. This version did not fetch remote releases. - Retain explicit result/check labels before truncating the original output preview. - Qualify cached watcher health when disconnected, a refresh fails or becomes overdue. - Keep terminal duration unknown when the source supplies no completion timing. @@ -27,7 +51,7 @@ No storage migration or settings reset is required. BB >=0.40 and SDK >=0.4.21 remain declared; BB 0.46.0 is recommended. See [upgrade notes](docs/upgrade-0.7.0.md) -and [release convention](docs/release-convention.md). Version 0.7.0 is prepared on the PR branch and is not published. +and [release convention](docs/release-convention.md). Version 0.7.0 was published on 12 October 2026. ## 0.6.1 — first public beta diff --git a/PLUGIN_OVERVIEW.md b/PLUGIN_OVERVIEW.md index 3a0c5e7..87363e5 100644 --- a/PLUGIN_OVERVIEW.md +++ b/PLUGIN_OVERVIEW.md @@ -1,24 +1,32 @@ ## See work as it happens Open the global dashboard for active executions, Needs Attention, and recent -completion. Open a thread's Agent Board action for automatic single-agent focus -or a multi-agent Kanban view. Inspect the bounded execution timeline to see -public operational updates, actual models, and tools as sources report them. +completion. Open a thread's Agent Board action for single-agent focus or a +multi-agent board. Inspect models, tools and public operational updates as +sources report them. Load earlier BB timeline activity while keeping your place. -See how recently an agent reported public activity. Quiet reporting keeps the -source's task status intact. Review completion outcomes, results, failures and -explicitly agent-reported checks, then open the original thread or timeline for -details. Filter and group visible work by project, repository, provider or status. -Needs Attention stays visible across filters. Connection, refresh and watcher -health help you distinguish an observation problem from quiet execution. +## Catch up and act -What's New shows bundled version highlights. Dismiss an announcement once and -reopen its notes from the dashboard whenever needed. +Reply to a reported question, review an approval request, or inspect a failure +in BB's native surface. Review completed work with objectives, observed files, +reported result links and checks; a finished turn does not prove verification. +Expand observed child agents and delegations when source data is available. -## Requirements +Retain filters, grouping and history-window choices in this browser, and save +up to eight named views. A catch-up digest summarizes changed outcomes since +your last visit. Restrained status accents and expandable results keep the +board compact; running strips show activity rather than percentage complete. -Requires BB 0.40 or newer; BB 0.46.0 is recommended. Ollama Fleet 0.6.0 or newer and Redteam's public observability RPC -are optional integrations. Missing integrations leave native BB observation -available. Mobile layouts and reduced-motion preferences are supported. -Hidden reasoning is excluded. System/push notifications are unavailable in -the current public SDK; Agent Board does not implement browser-push workarounds. +What's New shows bundled highlights. An update card checks official GitHub +release metadata without credentials and lets you review and confirm updates +that BB considers eligible. Pinned or out-of-range sources remain advisory. + +## Requirements and limits + +Requires BB 0.40 or newer; a current BB supplies current history and update +APIs. Ollama Fleet 0.6.0 or newer and Redteam's public observability RPC are +optional separate integrations. Native BB observation works without them. +Mobile layouts and reduced motion are supported. Hidden reasoning is excluded. +History depends on source retention; Fleet and Redteam provide bounded +snapshots. Saved views and catch-up state are browser-local. No push +notifications or automatic installation are added. diff --git a/README.md b/README.md index fbbe221..45bbfb5 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ focus and the global dashboard offer other views of the same bounded state. - **Single-Agent Focus:** activity and discrete lifecycle state for one execution. - **Multi-Agent Kanban:** live state for concurrent agents and workers. - **Needs Attention:** structured waiting/failure states and persistent dismissal. -- **Execution Timeline:** bounded operational events, loaded when opened. +- **Execution Timeline:** page through retained BB operational history while keeping your place. - **Activity freshness:** recent or quiet public reporting without inferring a stall. - **Completion summaries:** outcomes, results, failures and agent-reported checks. - **Filters and grouping:** project, repository, provider and status over visible work. @@ -50,10 +50,10 @@ and the distinction between fixture media and live runtime evidence. ## Install from Git -First public beta: **0.6.1**. Install the released Git tag with BB: +Install the current compatible release line with BB: ```sh -bb plugin install git:https://github.com/aaronphifer/bb-plugin-agent-board.git@v0.6.1 +bb plugin install git:https://github.com/aaronphifer/bb-plugin-agent-board.git@^0.7.0 ``` BB's managed Git installer installs runtime dependencies and builds the frontend @@ -69,7 +69,8 @@ bb plugin install . Neither Fleet nor Redteam is required. Declared compatibility is BB >=0.40 and SDK >=0.4.21; the development SDK is pinned to 0.4.34. See the honest [compatibility matrix](RELEASE_AUDIT.md), including historical runtime evidence. -For the prepared, unpublished 0.7.0 source, see [compatibility and upgrade notes](docs/upgrade-0.7.0.md). +See [compatibility and upgrade notes](docs/upgrade-0.7.0.md) and +[0.7.1 validation](docs/validation-0.7.1.md). ## Usage @@ -78,7 +79,8 @@ A scoped board automatically uses **Single-Agent Focus** for one logical active a **Multi-Agent** view for independent active executions or concurrent Fleet workers. Focus shows the current model, tools, public progress updates, and lifecycle state. **Needs Attention** collects structured approvals, input requests, and failures. -Open **Timeline** for bounded start, model, tool, public-update, and terminal events. Dismiss attention items after +Open **Timeline** for start, model, tool, public-update and terminal events; +**Load earlier activity** reads earlier retained BB history incrementally. Dismiss attention items after review; dismissal changes presentation state, not the underlying execution. ```sh @@ -110,7 +112,7 @@ isolated from native BB observation; source capabilities vary. Agent Board uses supported public BB plugin APIs, never direct BB database access or private `@bb/*` imports. It excludes hidden reasoning/thinking and raw -Fleet prompts/results. Timelines and activity are bounded; previews are truncated +Fleet prompts/results. Each timeline request and activity snapshot is bounded; previews are truncated and common credential forms redacted. This is defense in depth: public assistant updates, task titles, paths and configured names can still contain private content. Only share screenshots after reviewing them. @@ -144,16 +146,16 @@ This is a bounded latest view, not a persistent history database. - The current public BB plugin SDK does not expose a supported system/push notification delivery API. Agent Board intentionally does not implement unsupported browser push/service-worker hacks. - Fleet worker visibility requires Fleet >=0.6.0; no integration is mandatory. -- Timeline history is bounded and varies with source capabilities. +- Earlier timeline history depends on BB retention; Fleet and Redteam expose bounded snapshots. - Fleet has no token stream or attempt-history API; observation never invents those details. - Physical Android release verification remains separate from automated DOM tests. ## Development -The prepared, unpublished 0.7.0 source is documented in [vnext launch/testing](docs/vnext-testing.md), +Historical 0.7.0 development is documented in [vnext launch/testing](docs/vnext-testing.md), [design](docs/vnext-design.md) and [release convention](docs/release-convention.md). Use a separate BB data directory and ports for development; keep your stable -installation untouched. `0.7.0` on this PR branch is not a published release. +installation untouched. See the current [release notes](RELEASE_NOTES.md). See [dependency assessment](docs/dependency-security-0.7.0.md) and the PR Release validation checks before approving a merge. @@ -174,3 +176,6 @@ Author page: [getbb.app/marketplace/author/aaronphifer](https://getbb.app/market [MIT](LICENSE), Copyright (c) 2026 Aaron Phifer. Third-party code retains its own [notices and licenses](THIRD_PARTY_NOTICES.md). +## Daily workflow improvements + +Version 0.7.1 adds specific attention actions, completion review evidence, expandable observed agents, a catch-up digest, saved views, earlier timeline paging, subtle visual polish, and an update-available card using BB’s eligible-update installer. See [daily monitoring and update behavior](docs/daily-workflow.md) for limits, browser persistence and delivery to existing installations. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 9cae090..873ebc5 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,8 +1,41 @@ -# Agent Board v0.7.0 — prepared release notes - -Version **0.7.0** is prepared on the development PR branch, unpublished and -awaiting final merge review. No public tag, release, marketplace update or -installed stable-plugin update has occurred. +# Agent Board v0.7.1 + +Catch up on agent work, act on requests, and browse earlier execution history. + +- **Actionable attention:** Reply to questions, Review approval requests, and + Inspect failures in BB's native surface, with the reported request and its age. +- **Completion review:** inspect objectives, observed files, reported result links + and checks. A finished turn or completed goal does not imply independent verification. +- **Catch-up and saved views:** see changed outcomes since your last visit, retain + dashboard filters in this browser, and save up to eight named views. +- **Observed agents:** expand child agents and native delegations with the + status, model and output information the source actually supplies. +- **Subtle polish:** tighter cards, restrained status edges, clearer operations + and compact expandable results. Running strips remain indeterminate. +- **Earlier timeline activity:** scroll through loaded events and select Load + earlier activity for earlier BB pages. Loading preserves your place; live + refreshes retain loaded history and bridge reconnect gaps. Failed loads offer retry. +- **Update available:** discover official GitHub releases and review/confirm + updates eligible under BB's installed source and compatibility rules. + +Existing `^0.7.0` Git installations can update to 0.7.1 through BB. Pinned tags, +commits and local paths stay pinned; `^0.6.1` excludes this release. Existing +clients receive the new update card only after installing this release. There +is no background auto-install, source-range widening or settings reset. + +BB >=0.40 and public SDK >=0.4.21 remain declared; use a current BB for current +history and update APIs. Missing APIs degrade to the source's available view. +Fleet >=0.6.0 and Redteam's public observability RPC remain optional. +Earlier-history browsing covers BB's retained public operational events, not +hidden reasoning or an independent archive. Fleet and Redteam retain their +bounded snapshot limits. Saved views and catch-up state are browser-local. +The update check reads public metadata from this repository's GitHub release +endpoint without credentials; BB controls installation and rollback. + +See [daily monitoring and limits](https://github.com/aaronphifer/bb-plugin-agent-board/blob/v0.7.1/docs/daily-workflow.md) +and [validation](https://github.com/aaronphifer/bb-plugin-agent-board/blob/v0.7.1/docs/validation-0.7.1.md). + +# Agent Board v0.7.0 Agent Board gives you a clearer view of live agent work and completed outcomes. diff --git a/app.css b/app.css index a3e11e1..291b5f2 100644 --- a/app.css +++ b/app.css @@ -22,6 +22,37 @@ box-sizing: border-box; } +/* Calm, state-driven hierarchy. The live strip remains indeterminate. */ +.ab-global-dashboard [data-global-card="active"] { + border-inline-start: 2px solid color-mix(in srgb, var(--primary) 38%, var(--border)); +} +.ab-global-dashboard [data-global-card="active"][data-status="waiting"], +.ab-global-dashboard [data-global-card="attention"] { + border-inline-start: 2px solid color-mix(in srgb, #b78632 45%, var(--border)); +} +.ab-global-dashboard [data-global-card="attention"][data-attention-type="failed"] { + border-inline-start-color: color-mix(in srgb, var(--destructive) 65%, var(--border)); +} +.ab-global-dashboard [data-part="execution-model"] { + margin-top: 3px; + font-size: 11px; +} +.ab-global-dashboard [data-part="execution-activity"] { + margin-top: 10px; + color: var(--foreground); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 12px; +} +.ab-global-dashboard .ab-update-detail[open] { flex-basis: 100%; } +.ab-global-dashboard [data-global-card="recent"] { + border: 0; + border-bottom: 1px solid var(--border); + border-radius: 0; + background: transparent; + padding: 10px 0; +} +.ab-global-dashboard [data-part="recent-title"] { font-size: 13px; } + /* --- 1. Active pulse: the live dot and the active pipeline node breathe slowly (opacity/shadow nuance only — no card ever expands). ------- */ @keyframes ab-breathe { diff --git a/components/board/board.tsx b/components/board/board.tsx index 54d2a2f..478cfee 100644 --- a/components/board/board.tsx +++ b/components/board/board.tsx @@ -18,6 +18,7 @@ import { StatusChip } from "@/components/board/status-chip"; import { Button } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; import { cn } from "@/lib/utils"; +import { UpdateAvailable } from "@/components/global/update-available"; export function Board({ rootThreadId }: { rootThreadId: string }) { const { snapshot, loading, refreshing, error, tick, refetch } = useBoard(rootThreadId); @@ -46,6 +47,7 @@ export function Board({ rootThreadId }: { rootThreadId: string }) { return (
+
{/* Header: root identity, goal, model, live indicator, refresh. */}
@@ -208,4 +210,4 @@ function boardStatusForChip(status: string): Parameters[0]["s default: return "completed"; } -} \ No newline at end of file +} diff --git a/components/board/completion-summary.tsx b/components/board/completion-summary.tsx index 986e3ab..fcbebf6 100644 --- a/components/board/completion-summary.tsx +++ b/components/board/completion-summary.tsx @@ -1,14 +1,23 @@ import type { BoardCard } from "@/contract/rpc"; import { completionSummary } from "@/lib/dashboard-view"; +import { useBbNavigate } from "@get-bb/plugin-sdk/app"; +import { Button } from "@/components/ui/button"; export function CompletionSummary({ card }: { card: BoardCard }) { + const navigate = useBbNavigate(); const summary = completionSummary(card); + const finished = card.status === "completed"; + const outcome = finished && card.source === "bb" ? card.review?.goalStatus === "complete" ? "Goal marked complete — review needed" : "Turn finished — review needed" : summary.outcome; return
-

Outcome: {summary.outcome}

+

Outcome: {outcome}

+ {card.review?.objective ?

Objective: {card.review.objective}

: null} {summary.failure ?

Failure: {summary.failure}

: null}

Result: {summary.result}

Reported verification: {summary.reportedChecks.length ? summary.reportedChecks.join(" · ") : "Not supplied"}

{summary.reportedChecks.length ?

Reported by the agent; not independently verified.

: null} + {card.review ?
Observed file changes ({card.review.changedPaths.length}){card.review.changedPaths.length ?
    {card.review.changedPaths.map(path =>
  • {path}
  • )}
:

No changed paths observed in this bounded timeline.

}
: null} + {card.review?.links?.length ?
{card.review.links.map(link => )}
: null} + {finished && card.source === "bb" ?

A finished turn does not verify the task. Output is the latest source preview and may be an interim update; review the thread and checks.

: null} {summary.originalPreview ?
Original output preview

Source preview: {summary.originalPreview}

Preview is bounded. Use Open for the detailed source output.

: null}
; } diff --git a/components/global/catch-up-summary.tsx b/components/global/catch-up-summary.tsx new file mode 100644 index 0000000..8b08144 --- /dev/null +++ b/components/global/catch-up-summary.tsx @@ -0,0 +1,13 @@ +import { useBbNavigate } from "@get-bb/plugin-sdk/app"; +import type { CatchUpChange } from "@/lib/catch-up"; +import { Button } from "@/components/ui/button"; + +export function CatchUpSummary({ changes, onSeen }: { changes: CatchUpChange[]; onSeen: () => void }) { + const navigate = useBbNavigate(); + if (!changes.length) return null; + return
+

Since you last checked

+
    {changes.map(change =>
  • {change.kind}{change.title}{change.threadId ? : null}
  • )}
+

Changes observed in this bounded snapshot; activity outside it may be missing.

+
; +} diff --git a/components/global/dashboard-controls.tsx b/components/global/dashboard-controls.tsx new file mode 100644 index 0000000..ec4a83c --- /dev/null +++ b/components/global/dashboard-controls.tsx @@ -0,0 +1,37 @@ +import { useState } from "react"; +import type { GlobalExecution } from "@/contract/rpc"; +import { EMPTY_FILTERS, filterOptions, type FilterField } from "@/lib/dashboard-view"; +import { saveView, type DashboardPreferences } from "@/lib/dashboard-preferences"; +import { Button } from "@/components/ui/button"; + +export function DashboardControls({ executions, value, onChange }: { executions: GlobalExecution[]; value: DashboardPreferences; onChange: (value: DashboardPreferences) => void }) { + const [name, setName] = useState(""); + const [selected, setSelected] = useState(""); + const filtered = Object.values(value.filters).some(Boolean) || value.window !== "all"; + return
+ Views and filters{filtered ? " · Filters applied" : ""} +
+
+ {(["project", "repository", "workspace", "provider", "status"] as FilterField[]).map(field => { + const options = filterOptions(executions, field); + const current = value.filters[field]; + return ; + })} + + +
+
+ + {selected ? : null} + + + {filtered ? : null} +
+

Filters narrow the available snapshot, not all historical work. Needs attention covers all observed work. Views are saved in this browser.

+
+
; +} diff --git a/components/global/execution-children.tsx b/components/global/execution-children.tsx new file mode 100644 index 0000000..9aedcd0 --- /dev/null +++ b/components/global/execution-children.tsx @@ -0,0 +1,28 @@ +import { useBbNavigate } from "@get-bb/plugin-sdk/app"; +import type { GlobalExecution } from "@/contract/rpc"; +import { cardElapsedMs, formatElapsed } from "@/lib/board-model"; +import { Button } from "@/components/ui/button"; +import { StatusChip } from "@/components/board/status-chip"; +import { ExecutionTimelineDialog, timelineTargetForCard } from "@/components/timeline/execution-timeline-dialog"; + +export function ExecutionChildren({ execution, now }: { execution: GlobalExecution; now: number }) { + const navigate = useBbNavigate(); + const children = execution.children ?? []; + if (!children.length) return null; + return
+ Observed agents ({children.length}{execution.childCount > children.length ? ` of ${execution.childCount}` : ""}) +
    {children.map(child => { + const elapsed = cardElapsedMs(child, now); + const target = timelineTargetForCard(child, child.key); + return
  • +
    {child.title}
    +

    {child.model ?? "Model unavailable"}{elapsed !== null ? ` · ${formatElapsed(elapsed)}` : ""}

    + {child.activity ?

    {child.activity.text}

    : null} + {child.outputPreview ?
    Output preview

    {child.outputPreview}

    : null} +
    {child.threadId ? : null}{target ? : null}
    + {!child.threadId && child.ownerThreadId ?

    Native subagent · Timeline opens its owning execution.

    : null} +
  • ; + })}
+

Only agents present in the bounded source snapshot are shown.

+
; +} diff --git a/components/global/global-dashboard.tsx b/components/global/global-dashboard.tsx index 830273c..c2242a4 100644 --- a/components/global/global-dashboard.tsx +++ b/components/global/global-dashboard.tsx @@ -1,4 +1,4 @@ -import { useState, type ReactNode } from "react"; +import { type ReactNode } from "react"; import { useBbNavigate } from "@get-bb/plugin-sdk/app"; import type { AttentionItem, GlobalExecution } from "@/contract/rpc"; import { useGlobalDashboard } from "@/hooks/useGlobalDashboard"; @@ -13,11 +13,17 @@ import { ExecutionTimelineDialog, timelineTargetForCard } from "@/components/tim import { ActivityFreshness } from "@/components/board/activity-freshness"; import { CompletionSummary } from "@/components/board/completion-summary"; import { ReleaseAnnouncement } from "./release-announcement"; -import { EMPTY_FILTERS, filterDashboard, filterOptions, groupExecutions, type DashboardFilters, type FilterField } from "@/lib/dashboard-view"; -const FILTER_PLURALS: Record = { project: "projects", repository: "repositories", provider: "providers", status: "statuses" }; +import { filterDashboard, groupExecutions, type FilterField } from "@/lib/dashboard-view"; +import { applyWindow } from "@/lib/dashboard-preferences"; +import { useDashboardPreferences } from "@/hooks/useDashboardPreferences"; +import { useCatchUp } from "@/hooks/useCatchUp"; +import { DashboardControls } from "./dashboard-controls"; +import { CatchUpSummary } from "./catch-up-summary"; +import { ExecutionChildren } from "./execution-children"; +import { UpdateAvailable } from "./update-available"; function ExecutionGroups({ executions, grouping, render }: { executions: GlobalExecution[]; grouping: FilterField | "none"; render: (execution: GlobalExecution) => ReactNode }) { - return groupExecutions(executions, grouping).map(group => group.label === null ? group.executions.map(render) :

{group.label} ({group.executions.length})

{group.executions.map(render)}
); + return groupExecutions(executions, grouping).map(group => group.label === null ? group.executions.map(render) :

{group.label} ({group.executions.length})

{group.executions.map(render)}
); } function relativeAge(at: number, now: number): string { @@ -33,7 +39,7 @@ function GlobalExecutionCard({ execution, now, reducedMotion }: { execution: Glo const timelineTarget = timelineTargetForCard(card, execution.key); return (

{card.title}

{executing ? : } + {elapsed !== null ? {formatElapsed(elapsed)} : null}
-

{card.source === "bb" ? "BB" : card.source === "ollama-fleet" ? "Ollama Fleet" : "Redteam"}

+ {card.source === "bb" ? "BB" : card.source === "ollama-fleet" ? "Ollama Fleet" : "Redteam"}
{card.phaseTitle !== null || model !== "" ? ( @@ -72,11 +79,6 @@ function GlobalExecutionCard({ execution, now, reducedMotion }: { execution: Glo {(execution.delegatedWorkerCount ?? 0) > 4 ?

+{execution.delegatedWorkerCount! - 4} more

: null} ) : null} - {card.latestPublicUpdate != null ? ( -

- {card.latestPublicUpdate} -

- ) : null} {card.activity !== null ? (

{card.activity.text} @@ -86,13 +88,19 @@ function GlobalExecutionCard({ execution, now, reducedMotion }: { execution: Glo ) : null} {executing ?

: null} - {execution.projectName || execution.projectId ?

{execution.projectName ?? execution.projectId}{execution.repository ? ` · ${execution.repository}` : ""}

: null} + + {execution.projectName || execution.projectId || execution.workspaceId ?

{execution.projectName ?? execution.projectId ?? "Unknown project"}{execution.repository ? ` · ${execution.repository}` : ""}{execution.workspaceId ? ` · ${execution.workspaceName ?? execution.workspaceId}` : ""}{execution.branch ? ` · ${execution.branch}` : ""}

: null}
- {elapsed !== null ? {formatElapsed(elapsed)} : null} {execution.activeChildCount > 0 ? {execution.activeChildCount} active child{execution.activeChildCount === 1 ? "" : "ren"} : null} {execution.childCount > 0 && execution.activeChildCount === 0 ? {execution.childCount} child{execution.childCount === 1 ? "" : "ren"} : null} {card.toolCalls !== null ? {card.toolCalls} tools : null} {card.tokens !== null ? {card.tokens.toLocaleString()} tok : null} + {card.latestPublicUpdate != null ? (
Latest update +

+ {card.latestPublicUpdate} +

+ ) : null} + {card.threadId !== null ? ( + ) : null} + {destructive && item.threadId ? : null} @@ -163,10 +173,14 @@ function RecentRow({ execution, now }: { execution: GlobalExecution; now: number {card.model !== null ? <> · {card.model} : null} <> · {relativeAge(finishedAt, now)}

- +

+ {card.status === "completed" ? card.source === "bb" ? "Turn finished · review needed" : "Execution finished" : card.status === "failed" ? `Failed${card.errorPreview ? ` · ${card.errorPreview}` : ""}` : card.status} +

+
Result and checks
+
- {card.threadId !== null ? : null} + {(card.reviewThreadId ?? card.threadId) !== null ? : null} {timelineTarget !== null ? : null}
@@ -175,82 +189,57 @@ function RecentRow({ execution, now }: { execution: GlobalExecution; now: number export function GlobalDashboard() { const { snapshot, loading, refreshing, error, refetch, dismissAttention, clearDismissedAttentions, connectionState, lastSignalAt, lastRefreshAt } = useGlobalDashboard(); - const [filters, setFilters] = useState(EMPTY_FILTERS); - const [grouping, setGrouping] = useState("none"); + const { preferences, setPreferences, storageFailed } = useDashboardPreferences(); + const { changes, markSeen } = useCatchUp(snapshot); const executions = snapshot ? [...snapshot.active, ...snapshot.recent] : []; - const visible = snapshot ? filterDashboard(snapshot, filters) : { active: [], recent: [] }; - const filtered = Object.values(filters).some(Boolean); - const reducedMotion = usePrefersReducedMotion(); const now = Date.now(); + const matching = snapshot ? filterDashboard(snapshot, preferences.filters) : { active: [], recent: [] }; + const visible = { active: applyWindow(matching.active, preferences.window, now), recent: applyWindow(matching.recent, preferences.window, now) }; + const filtered = Object.values(preferences.filters).some(Boolean) || preferences.window !== "all"; + const reducedMotion = usePrefersReducedMotion(); const watcherUnconfirmed = connectionState !== "connected" || !!error || lastRefreshAt === null || now - lastRefreshAt > 60_000; return (
-
-
-
-
-
-

Global operations across BB and available observability sources.

-
-
- - {connectionState === "connected" ? "Connected" : `Connection: ${connectionState}`} -
+
+
+

Agent Board

+ {snapshot ? {visible.active.length} active/waiting · {snapshot.attention.length} need attention · {visible.recent.length} recent{filtered ? " · Filtered work" : ""} : null} + + {connectionState === "connected" ? "Connected" : `Connection: ${connectionState}`}
- -
-

{lastRefreshAt === null ? "Waiting for first snapshot" : `Last refresh ${relativeAge(lastRefreshAt, now)}`}{lastRefreshAt !== null && now - lastRefreshAt > 60_000 ? " · Refresh is overdue" : ""}

-

{lastSignalAt === null ? "No change signal received in this view yet" : `Last change signal ${relativeAge(lastSignalAt, now)}`}

- {snapshot?.monitoring ?

{watcherUnconfirmed ? `Watcher health unconfirmed · Last known state: ${snapshot.monitoring.status}` : `Watcher: ${snapshot.monitoring.status}`}{snapshot.monitoring.lastEventAt === null ? " · No relevant source event observed yet" : ` · Source event ${relativeAge(snapshot.monitoring.lastEventAt, now)}`}

: null} -
+ + {snapshot ? : null} + {storageFailed ?

Browser storage is unavailable. Views will last for this visit.

: null} + {snapshot?.monitoring && ["degraded", "unavailable", "disposed"].includes(snapshot.monitoring.status) ?

Event watcher has a problem. Snapshot refreshes may still work. Refresh or reload Agent Board.

: null} - {snapshot ?
-
- {(["project", "repository", "provider", "status"] as FilterField[]).map(field => )} - -
-

Filters apply to this bounded snapshot. Needs attention and summary counts cover all observed work.

- {filtered ? : null} -
: null} - - {snapshot !== null ? ( -
- {[["Active", snapshot.counts.active], ["Waiting", snapshot.counts.waiting], ["Attention", snapshot.counts.attention], ["Recent", snapshot.counts.recent]].map(([label, value]) => ( -

{label}

{value}

- ))} -
- ) : null} - - {error !== null ?
Could not refresh the global dashboard: {error}
: null} - {loading && snapshot === null ?
: null} - - {snapshot !== null ?
+ {error ?
Could not refresh the global dashboard: {error}
: null} + {loading && !snapshot ?

Loading operations…

: null} + {snapshot ?
-

Needs attention

{snapshot.counts.attention > 0 ? {snapshot.counts.attention} : null} - {snapshot.dismissedCount > 0 ? ( - - ) : null} -
- {snapshot.attention.length > 0 ?
{snapshot.attention.map((item) => void dismissAttention(dismissed)} />)}
:

{snapshot.dismissedCount > 0 ? "No open items — dismissed conditions stay hidden until they change." : "Nothing currently requires your attention."}

} +

Needs attention

{snapshot.attention.length ? {snapshot.attention.length} · All observed work : null}{snapshot.dismissedCount > 0 ? : null}
+ {snapshot.attention.length ?
{snapshot.attention.map(item => void dismissAttention(dismissed)} />)}
:

{snapshot.dismissedCount ? "No open items — dismissed conditions stay hidden until they change." : "Nothing currently requires your attention."}

}
-
-

Active work

{visible.active.length}
- {visible.active.length > 0 ?
} />
:

{filtered ? "No active executions match these filters." : "No active executions."}

} +

Active work

+ {visible.active.length ?
} />
:

{filtered ? "No active executions match these filters." : "No active executions."}

}
-
-

Recently completed

{visible.recent.length}
- {visible.recent.length > 0 ?
} />
:

{filtered ? "No recent completions match these filters." : "No recent completions in the bounded window."}

} +

Recent results

+ {visible.recent.length ?
} />
:

{filtered ? "No recent completions match these filters." : "No recent completions in the bounded window."}

}
- {snapshot.unavailableSources.includes("bb") ?

Native BB observation is unavailable. Refresh to retry; optional sources may still report.

: null} - {snapshot.unavailableSources.some(source => source !== "bb") ?

Optional sources unavailable: {snapshot.unavailableSources.filter(source => source !== "bb").map(source => source === "ollama-fleet" ? "Ollama Fleet" : "Redteam").join(", ")}. Missing optional integrations do not prevent BB observation.

: null} - {snapshot.partial ?

The global view is bounded; older or excess executions are not shown.

: null} + {snapshot.partial ?

The global view is bounded; older or excess executions are not shown.

: null}
: null} +
+ Monitoring details{watcherUnconfirmed ? " · Check connection" : ""} +
+

{lastRefreshAt === null ? "Waiting for first snapshot" : `Last refresh ${relativeAge(lastRefreshAt, now)}`}{lastRefreshAt !== null && now - lastRefreshAt > 60_000 ? " · Refresh is overdue" : ""}

+

{lastSignalAt === null ? "No change signal received in this view yet" : `Last change signal ${relativeAge(lastSignalAt, now)}`}

+ {snapshot?.monitoring ?

{watcherUnconfirmed ? `Watcher health unconfirmed · Last known state: ${snapshot.monitoring.status}` : `Watcher: ${snapshot.monitoring.status}`}{snapshot.monitoring.lastEventAt === null ? " · No relevant source event observed yet" : ` · Source event ${relativeAge(snapshot.monitoring.lastEventAt, now)}`}

: null} + {snapshot?.unavailableSources.some(source => source !== "bb") ?

Optional sources unavailable: {snapshot.unavailableSources.filter(source => source !== "bb").map(source => source === "ollama-fleet" ? "Ollama Fleet" : "Redteam").join(", ")}. Missing optional integrations do not prevent BB observation.

: null} +
+ +
); diff --git a/components/global/update-available.tsx b/components/global/update-available.tsx new file mode 100644 index 0000000..c2709c7 --- /dev/null +++ b/components/global/update-available.tsx @@ -0,0 +1,49 @@ +import { useEffect, useState } from "react"; +import { useBbNavigate, useRpc } from "@get-bb/plugin-sdk/app"; +import type { rpcContract } from "@/contract/rpc"; +import type { ReleaseUpdateState } from "@/lib/update-check"; +import { Button } from "@/components/ui/button"; + +export function UpdateAvailable() { + const rpc = useRpc(); + const navigate = useBbNavigate(); + const [update, setUpdate] = useState(null); + const [dismissed, setDismissed] = useState(() => { try { return window.localStorage.getItem("agent-board:update-dismissed"); } catch { return null; } }); + const [confirming, setConfirming] = useState(false); + const [installing, setInstalling] = useState(false); + const [message, setMessage] = useState(null); + useEffect(() => { + let cancelled = false; + const check = () => rpc.call("release_update", {}).then(result => { if (!cancelled) setUpdate(result); }, () => {}); + void check(); + const timer = setInterval(() => void check(), 3_600_000); + return () => { cancelled = true; clearInterval(timer); }; + }, [rpc]); + const identity = update ? `${update.installed}:${update.latest ?? ""}:${update.eligible?.version ?? ""}` : ""; + if (!update || (!update.latest && !update.eligible) || dismissed === identity) return null; + async function install() { + if (!update?.eligible || installing) return; + setInstalling(true); setMessage(null); + try { + const result = await rpc.call("install_update", { expectedVersion: update.eligible.version }); + setMessage(result.applied ? "Update installed. Reopen Agent Board to use the new version." : result.detail ?? `BB update result: ${result.outcome}.`); + setConfirming(false); + } catch (cause) { + setMessage(`Update was not confirmed: ${cause instanceof Error ? cause.message : String(cause)}. Reopen Agent Board to check the installed version before retrying.`); + } finally { setInstalling(false); } + } + return
+

Agent Board {update.latest ?? "update"} available

+

Installed: {update.installed}. {update.eligible ? `BB offers ${update.eligible.display} within your selected update source.` : "BB has not offered an installable update within your selected source. A newer release may require a version-range change or a newer BB."}

+
+ {update.eligible && !confirming ? : null} + {update.releaseUrl ? : null} +
+ {confirming ?
+

Install BB’s eligible Agent Board update? BB handles installation and rollback checks while keeping your settings. BB resolves the eligible release again when installation starts.

+
+
: null} + {message ?

{message}

: null} + {!update.eligible ?

Open BB’s Plugins → Agent Board → check for updates, and see the release notes for upgrade instructions. An out-of-range release cannot be installed by this card on BB versions without range upgrades.

: null} +
; +} diff --git a/components/timeline/execution-timeline-dialog.tsx b/components/timeline/execution-timeline-dialog.tsx index 3ae0e9a..838741a 100644 --- a/components/timeline/execution-timeline-dialog.tsx +++ b/components/timeline/execution-timeline-dialog.tsx @@ -64,6 +64,7 @@ function TimelineRow({ event, reducedMotion }: { event: TimelineEvent; reducedMo return (
  • (null); const following = useRef(true); const [showJump, setShowJump] = useState(false); const lastEventId = timeline?.events.at(-1)?.id ?? null; + const historyAnchor = useRef<{ id: string; offset: number } | null>(null); + + const showEarlier = () => { + const element = scrollRef.current; + if (element) { + const top = element.getBoundingClientRect().top; + const first = Array.from(element.querySelectorAll("[data-event-id]")).find(row => row.getBoundingClientRect().bottom > top); + if (first) historyAnchor.current = { id: first.dataset.eventId!, offset: first.getBoundingClientRect().top - top }; + } + following.current = false; loadEarlier(); + }; + useLayoutEffect(() => { + const element = scrollRef.current, anchor = historyAnchor.current; + if (!element || !anchor || loadingEarlier) return; + const row = Array.from(element.querySelectorAll("[data-event-id]")).find(node => node.dataset.eventId === anchor.id); + if (row) element.scrollTop += row.getBoundingClientRect().top - element.getBoundingClientRect().top - anchor.offset; + historyAnchor.current = null; + }, [timeline?.events.length, loadingEarlier]); const jumpToLatest = () => { const element = scrollRef.current; @@ -141,14 +161,14 @@ export function ExecutionTimelineDialog({ Timeline - +
    Execution timeline {timeline?.active ? : null} {refreshing ? Updating… : null}
    - {target.title} · bounded operational events + {target.title} · operational history
    {error !== null ? ( @@ -160,7 +180,12 @@ export function ExecutionTimelineDialog({ {loading && timeline === null ?
    : null} {timeline !== null ? ( <> - {timeline.truncated ?

    Earlier activity omitted — showing the latest bounded events.

    : null} +
    + {timeline.olderCursor ? : null} + {timeline.events.length} events loaded +
    + {timeline.truncated && !timeline.olderCursor ?

    Earlier activity omitted — this source only exposes its retained snapshot.

    : null} + {earlierError ?

    Earlier history unavailable: {earlierError}. Use Load earlier activity to retry.

    : null} {timeline.sourceWarning !== null ?

    {timeline.sourceWarning}

    : null} {timeline.events.length === 0 ?

    No public operational events are available.

    : (
    ; @@ -247,6 +261,7 @@ export const attentionItemSchema = z.object({ threadId: z.string().nullable(), source: boardCardSourceSchema, scanId: z.string().optional(), + interactionKind: z.enum(["question", "approval"]).optional(), }); export type AttentionItem = z.infer; @@ -322,6 +337,8 @@ export const executionTimelineSchema = z.object({ active: z.boolean(), /** True means older source rows or normalized events were omitted. */ truncated: z.boolean(), + /** Continuation for earlier events. Each response remains bounded. */ + olderCursor: z.string().max(4096).nullable().optional(), /** Non-fatal degradation, e.g. lifecycle rows temporarily unavailable. */ sourceWarning: z.string().max(240).nullable(), fetchedAt: z.number().int().nonnegative(), @@ -340,6 +357,7 @@ const executionTimelineInputSchema = z.discriminatedUnion("source", [ executionKey: z.string().min(1).max(240), threadId: z.string().min(1).max(240), limit: z.number().int().min(1).max(80).optional(), + cursor: z.string().min(1).max(4096).optional(), }), z.object({ source: z.literal("redteam"), @@ -396,6 +414,14 @@ export const boardChangedPayloadSchema = z.object({ }); export const rpcContract = { + release_update: { + input: z.object({}), + output: z.object({ installed: z.string().max(80), latest: z.string().max(80).nullable(), releaseUrl: z.string().max(240).nullable(), eligible: z.object({ version: z.string().max(200), display: z.string().max(240) }).nullable(), discoveryUnavailable: z.boolean() }), + }, + install_update: { + input: z.object({ expectedVersion: z.string().min(1).max(200) }), + output: z.object({ applied: z.boolean(), outcome: z.string(), detail: z.string().max(320).nullable() }), + }, release_announcement: { input: z.object({}), output: z.object({ version: z.string(), title: z.string(), highlights: z.array(z.string()), dismissed: z.boolean() }), diff --git a/docs/daily-workflow.md b/docs/daily-workflow.md new file mode 100644 index 0000000..2d6bd2d --- /dev/null +++ b/docs/daily-workflow.md @@ -0,0 +1,35 @@ +# Daily monitoring and updates + +Agent Board’s global view puts attention and active work before diagnostics. Views and filters remembers project, repository, workspace, provider, status, grouping and recent-history choices in the current browser. Save up to eight named views. A missing filter value remains selectable and resettable when it falls outside the current snapshot. + +## Attention and review + +An explicit question offers **Reply**; an approval offers **Review approval**; a failure offers **Inspect failure** and its timeline. These actions open BB’s native interaction surface. Dismiss only hides the current condition. + +Completed BB turns say **Turn finished — review needed**. An explicit completed goal is labeled separately. Neither means independent verification. Review shows the structured objective when available, observed changed paths, safe reported result links, agent-reported checks and source output. BB’s current output API does not distinguish final answers from interim assistant updates, so previews are labeled accordingly. Changed paths are observations from the bounded timeline, not a complete Git diff. + +**Observed agents** expands children and native delegations with reported status, elapsed time, activity and output. Native delegations open the owning execution timeline; provider references are not treated as BB thread IDs. Model information stays unavailable when the public API does not supply it. + +## Since you last checked + +The browser stores bounded state fingerprints, not a transcript. On a return visit the digest shows newly observed completions, failures, interruptions and requests for input. **Mark seen** acknowledges the current observations. Leaving the view also records the successful observations from that visit. Incomplete snapshots preserve earlier observations so recovered results can still appear. This is a snapshot comparison; events that begin and end outside retained source history cannot be reconstructed. + +## Timeline history + +**Timeline** opens a scrollable execution history. It initially loads the newest page; **Load earlier activity** retrieves earlier BB history while retaining the events already loaded and preserving the visible scroll position. The counter shows how many events are loaded. Each request returns at most 80 normalized events, so long histories are read incrementally rather than downloaded all at once. + +Pagination handles both BB's native source-page cursor and events omitted within a source page. Live updates merge by event identity. A reconnect that introduces a gap first bridges back to known events before resuming the earlier-history cursor. Temporary failures keep the visible history and offer retry. The dialog follows new events only while the user is near the newest activity; **Jump to latest** returns there. + +This browses BB's retained public operational history, not raw model reasoning or an independent archive. Redteam and Ollama Fleet still expose bounded retained snapshots without a historical API; when earlier source data is unavailable, the timeline says so. + +## Update available + +Both global and thread boards check for updates when opened. Public GitHub release discovery uses the fixed official Agent Board repository, no credentials, a five-second deadline and a bounded response. Results are shared across clients and cached for an hour (fifteen minutes after a discovery failure). BB separately decides whether an eligible update exists for the installed source and current compatibility rules. + +- **Review update** shows BB’s eligible candidate and asks for confirmation. +- **Install update** rechecks eligibility and calls BB’s existing update operation. It does not remove or reinstall the plugin. BB owns activation and rollback checks. +- The current SDK cannot bind the install atomically to a reviewed release: BB resolves again at apply time. The confirmation states this limitation. An update request that fails during plugin reload does not pretend success; reopen the board to verify the installed version before retrying. +- Out-of-range or unavailable candidates remain advisory. The card links to official release notes and explains the native limitation; it does not silently widen the selected source. +- Dismissal applies to that update identity in this browser. A different release or eligible candidate appears again. + +Existing installations must receive the notifier before it can announce anything. To reach a `^0.6.1` installation, publish a compatible 0.6.x patch containing the notifier; a `^0.7.0` installation similarly needs a compatible 0.7.x patch. A later major/minor release alone cannot add UI to an older installed build. Patch backports and publication are separate release work; this implementation does not publish or rewrite marketplace ranges. diff --git a/docs/release-convention.md b/docs/release-convention.md index 1f409a1..18b6724 100644 --- a/docs/release-convention.md +++ b/docs/release-convention.md @@ -1,8 +1,8 @@ # Release notes convention -This work is unreleased. `0.7.0` is prepared on the development PR branch after -the RC hardening/CI gates passed. It is not a public tag, release, marketplace or -production update. The changelog preparation date is not a publication date. +The public release line is 0.7.x. Verify current tags and GitHub releases before +choosing a new version; never move an existing release tag. Local preparation +and a changelog date do not by themselves constitute publication. For each approved release: @@ -19,7 +19,8 @@ For each approved release: pre-releases. A repository tag/release or marketplace change requires the maintainer's explicit approval; this convention grants none. -The UI reads only bundled notes, never GitHub or the marketplace. Dismissal is +The What’s New UI reads bundled highlights; the separate update card checks +GitHub release metadata and BB-eligible candidates. Announcement dismissal is stored in plugin KV for the exact version (latest 20 dismissed versions), so reloads retain dismissal, upgrades show new notes, and a recent downgrade retains its old dismissal. Release notes can be opened later from the dashboard. diff --git a/docs/superpowers/plans/2026-11-08-daily-workflow.md b/docs/superpowers/plans/2026-11-08-daily-workflow.md new file mode 100644 index 0000000..2bdaa57 --- /dev/null +++ b/docs/superpowers/plans/2026-11-08-daily-workflow.md @@ -0,0 +1,61 @@ +# Agent Board Daily Workflow Implementation Plan + +> **For agentic workers:** Use superpowers:executing-plans to implement this plan task by task. + +**Goal:** Implement the five approved daily monitoring and review improvements. +**Architecture:** Extend source-normalized card metadata and pure dashboard derivation. Keep persistence and catch-up local to the client, with native navigation for host actions and bounded data everywhere. +**Tech Stack:** TypeScript, React, Zod, Vitest, BB Plugin SDK. +**Spec:** ../specs/2026-11-08-daily-workflow-design.md + +## Global Constraints + +- No new runtime dependencies or publication. +- Preserve current SDK support; inspect live declarations before choosing actions. +- Never equate a turn ending or reported checks with independently verified work. +- Never invent native-agent models or navigate opaque provider child references as BB IDs. +- Keep all previews and client storage bounded and validated. + +## Review Focus + +- First visits, clock skew, failed refreshes and partial snapshots must not manufacture or erase catch-up events. +- Changed activity on an old completion must not look like new completion. +- Blocked/corrupt client storage must leave the board usable. +- Child activity must not replace the parent's final result. +- Filter choices missing from the latest bounded snapshot must remain visible and resettable. + +## Task 1: Source evidence and attention + +Files: contract/rpc.ts, lib/bb-source.ts, lib/board-model.ts, lib/global-model.ts, tests/bb-source.test.ts, tests/global-model.test.ts, tests/dashboard-view.test.ts. +- [x] Add failing assertions for specific command/file targets, question text/time, final-only results, goals/changed paths and native delegation ownership. +- [x] Implement bounded optional card review/interaction metadata; aggregate children while preserving the parent review result. +- [x] Run focused normalization/model tests; fix regressions. + +## Task 2: Saved views and catch-up model + +Files: lib/dashboard-preferences.ts, lib/catch-up.ts, hooks/useDashboardPreferences.ts, hooks/useCatchUp.ts, tests/dashboard-preferences.test.ts, tests/catch-up.test.ts. +- [x] Add failing tests for corrupt/blocked storage, bounded named views, transition fingerprints and partial snapshots. +- [x] Implement pure parsing, filtering windows and catch-up comparison; hooks persist successful snapshots and explicit acknowledgements. +- [x] Run focused tests. + +## Task 3: Dashboard and review UX + +Files: components/global/global-dashboard.tsx, components/global/dashboard-controls.tsx, components/global/catch-up-summary.tsx, components/global/execution-children.tsx, components/board/completion-summary.tsx, tests/global-app.test.tsx. +- [x] Add failing user-flow tests for purposeful attention actions, review provenance, persisted views/catch-up and child expansion. +- [x] Implement compact rows, disclosed controls/diagnostics, catch-up list, safe native actions and child details. +- [x] Run UI tests and address old expectations changed by the approved design. + +## Task 4: Verify and document + +Files: README.md, docs/daily-workflow.md. +- [x] Run full Vitest suite, typecheck, BB build and package checks. +- [x] Review the complete diff and verify desktop/mobile rendering; correct any material defects. +- [x] Record checks and remaining source/API limits. Leave the stable installed plugin intact unless an isolated preview is needed. + +## Execution notes + +- Added an update-awareness card at Aaron’s follow-up request. It checks official GitHub releases with a bounded hourly shared cache and uses BB’s own eligible-update installer only after confirmation. Outside-range releases remain advisory on BB versions without range upgrades. Older installations need an in-range patch containing the notifier before it can alert them. +- Native SDK 0.6.132 has no delegation model field. The feature preserves the existing development SDK pin and supported legacy panel registration; the live host warns about retirement but supports the bundle. +- Baseline: 468 passing tests. Current implementation: 498 passing tests; typecheck and BB frontend/backend build pass. Package verification now validates actual builder metadata instead of requiring a historical hardcoded builder version. +- Preview uses separate plugin ID agent-board-preview; stable agent-board installation remains unchanged. + +- Fresh reviewer identified partial-snapshot catch-up loss, scoped native-agent timeline fallback, and credential query parameters in result links. Each has a regression test observed failing before its fix. The final suite passes all 498 tests. diff --git a/docs/superpowers/plans/2026-11-08-polish-and-history.md b/docs/superpowers/plans/2026-11-08-polish-and-history.md new file mode 100644 index 0000000..2777035 --- /dev/null +++ b/docs/superpowers/plans/2026-11-08-polish-and-history.md @@ -0,0 +1,29 @@ +# Subtle polish and navigable timeline history + +Aaron approved the style preview and requested access to earlier timeline history in thread thr_5nkjvuqs3j. + +## Scope and design + +- Match the approved preview: restrained semantic edges, stronger operation text, elapsed time by the title, compact expandable recent results, a more prominent Reply action, and the existing indeterminate running strip. +- Confirmed reproduction: the installed run timeline returns 50 events, truncated=true, and no continuation. The dialog has only a max-height with a flexible child in a grid, allowing content clipping. +- BB history will use the public beforeAnchorId/beforeAnchorSeq cursor. Normalized events also need a position within each source page, otherwise the 50/80-event cap loses events before the BB page cursor is advanced. +- Each response stays bounded to 80 events. A validated cursor tracks the source page, event position, and lifecycle page. Loading older pages merges by stable event ID without replacing loaded history. Pages refresh independently; older load failures retain all visible history and allow retry. +- Give the dialog a constrained flex column and preserve the top visible event while prepending. Live following only applies when the user remains at the newest events. +- Redteam/Fleet expose retained bounded snapshots rather than a historical cursor; label those limits accurately rather than promising unavailable history. + +## Work + +- [x] Add failing normalization, collector, and UI regressions covering events omitted within one source page, BB cursor boundaries, equal timestamps, target changes, retries and scroll anchoring. +- [x] Implement validated pagination and lazy load-earlier behavior; preserve loaded history during active refreshes. +- [x] Apply the approved visual polish without changing activity-strip semantics. +- [x] Run full tests, typecheck, build and package checks; verify real desktop/mobile scrolling and loading older history. Commit and reload the authorized local test installation. + +No public release, tag, source-range change or marketplace publication. + +## Verification + +- 505 tests passed; TypeScript and BB frontend/backend build passed. +- Package check passed. +- Six real history pages returned 300 distinct events with an earlier cursor still available. +- Review found and regressions fixed disjoint refresh gaps and lifecycle retry loss. +- Desktop/mobile scroll and anchor verification are recorded in thread storage; the existing local test install is reloaded for Aaron. diff --git a/docs/superpowers/specs/2026-11-08-daily-workflow-design.md b/docs/superpowers/specs/2026-11-08-daily-workflow-design.md new file mode 100644 index 0000000..88c6b4b --- /dev/null +++ b/docs/superpowers/specs/2026-11-08-daily-workflow-design.md @@ -0,0 +1,23 @@ +# Agent Board daily workflow + +Aaron approved the five-item priority list in BB thread thr_5nkjvuqs3j for implementation. Build on the existing TypeScript/React plugin, retaining its bounded, read-only observation model and current supported SDK floor. + +## Approach + +Extend the existing normalized cards, collector, and global dashboard. Reuse BB thread navigation and the existing execution timeline for host-owned reply, approval, and review behavior. A separate orchestration system would duplicate BB behavior; a visual redesign without better source data would leave the main usefulness gaps unresolved. + +## Behavior + +1. Needs attention shows the explicit question/approval target, age from its source timestamp, and a purpose-specific native-thread action. Failure items offer the execution timeline. Dismiss remains presentation-only. +2. A finished BB turn is labeled “Turn finished — review needed.” Structured goal completion is separately labeled; neither status nor agent prose is independent verification. Keep objective, observed changed paths, bounded final-only output, reported checks and safe result links available. Missing evidence is explicit. +3. Show operation and target instead of generic presentation labels. Expand observed child threads/native delegations with status, elapsed time, known model, output, and parent execution timeline. Do not assume opaque native child references are BB thread IDs or infer a model from the parent. +4. A per-browser catch-up baseline records bounded execution/attention fingerprints on successful visits. Return visits summarize observed new completions, failures and input requests; unrelated activity is not a new completion. Refreshes do not acknowledge unseen changes; Mark seen explicitly advances the baseline. First visit establishes it without inventing history; failed/partial snapshots do not erase prior observations. +5. Put compact work rows first; hide healthy diagnostics, release details and secondary filters behind disclosure. Persist bounded validated filter/grouping/time-window preferences and up to eight named saved views locally. Explain snapshot/history limits, retain absent selected options, and keep filtered rows/counts consistent while labeling the global attention scope. + +## Constraints + +No publishing, marketplace changes, or upgrade of the installed stable plugin is requested. Keep source changes reviewable on a feature branch. No new runtime dependencies. Redact/bound global summaries. Preserve adapter behavior and existing attention dismissal rules. Current SDK 0.6.132 still lacks a delegation model field; show unknown rather than inventing it. Keep the supported 0.4.34 development surface rather than bundling a separate SDK migration into this feature. + +## Validation + +Focused unit tests cover provenance, attention source timestamps, safe activity targets, catch-up transitions, corrupted/blocked storage and bounded saved views. UI tests cover native navigation, child expansion, compact disclosure and restoration after remount. Run the full existing suite, typecheck and BB build; verify the rendered desktop and narrow layouts where possible. diff --git a/docs/upgrade-0.7.0.md b/docs/upgrade-0.7.0.md index 7f3b62b..6ee1974 100644 --- a/docs/upgrade-0.7.0.md +++ b/docs/upgrade-0.7.0.md @@ -1,8 +1,9 @@ # Agent Board 0.7.0 compatibility and upgrade notes -The current PR source is **0.7.0**, prepared but unpublished. Do not install it -over a stable installation as part of release review. The public 0.6.1 tag remains -the existing released installation until a separately approved publication. +Version 0.7.0 was published on 12 October 2026. Version 0.7.1 continues the +compatible 0.7.x line and adds daily monitoring, timeline paging and update UI. +See [0.7.1 validation](validation-0.7.1.md) for current evidence; the historical +0.7.0 runtime checks below are not new compatibility tests for 0.7.1. ## Compatibility @@ -53,20 +54,24 @@ releases. Missing/malformed dismissal storage shows the announcement again; storage read/write failures are surfaced. Only the latest 20 dismissed versions are retained. Bundled notes can be reopened after dismissal. -After final approval and publication only: +For a new installation: ```sh bb plugin install git:https://github.com/aaronphifer/bb-plugin-agent-board.git@^0.7.0 ``` -Reinstalling the same identity through BB replaces its source while retaining -plugin preferences; do not remove/uninstall first, because removal deletes -plugin storage. Exact tag/commit installations stay pinned. `^0.6.1` permits +Use BB's update action for an existing managed installation. BB can refuse +reinstalling an already registered managed identity; do not uninstall it as an +update step because removal can delete plugin storage. Local path installs are +reloaded from their checkout instead of updated from Git. Exact tag/commit installations stay pinned. `^0.6.1` permits 0.6.x but excludes 0.7.0. Changing the marketplace entry to `^0.7.0` affects new selections, not the selector already stored by an existing installation. BB checks managed Git/npm installations for compatible updates and presents them for an explicit update action. Path/builtin sources are pinned. Stable semver ranges exclude prereleases unless the range explicitly names one. -Agent Board's What's New appears after installing a new version; it does not -discover remote releases, send push notifications or install updates itself. +Agent Board's What's New appears after installing a new version. In 0.7.1, a +separate update card checks public GitHub releases, offers Review update when +BB reports an eligible candidate, and asks for confirmation before calling BB's +update operation. Out-of-range and pinned sources remain advisory. It sends no +push notification and does not install updates automatically. diff --git a/docs/validation-0.7.1.md b/docs/validation-0.7.1.md new file mode 100644 index 0000000..a64cf68 --- /dev/null +++ b/docs/validation-0.7.1.md @@ -0,0 +1,46 @@ +# Agent Board 0.7.1 validation + +## Current local checks + +Validated on Pop!_OS with Node 26.8.1 before release preparation: + +- 505 automated tests passed, zero failures, including timeline pagination, + reconnect gaps, lifecycle retry, saved views, catch-up and update confirmation. +- TypeScript passed. The BB frontend/backend build passed; both generated + manifests identify Agent Board 0.7.1 and their actual builder metadata + (BB 0.43.0, Plugin SDK 0.6.132). +- Actual package inspection passed: matching package/lockfile version, fresh + bundles, runtime/document assets and no test, credential or local evidence files. +- Reloaded the existing local installation in BB 0.49.0. Agent Board reported + version 0.7.1, running, with a compatible frontend; its release RPC returned + the new title and highlights. + +## Feature verification + +The implementation at 87a0229 was exercised in the installed BB before the +version/highlight/documentation preparation: + +- Six timeline requests returned 300 distinct events, with earlier history + still available. Unit tests also cross the native BB source-page boundary. +- Desktop and 390-pixel mobile timelines had independently scrollable content. + Loading 50 earlier events on mobile retained the visible event at the same + offset; the document had no horizontal overflow. +- Desktop/mobile dashboard and history screenshots were inspected. Running + strips retain their indeterminate animation; reduced motion remains supported. +- A fresh review found reconnect-window gaps and lifecycle-read failure handling. + Both were fixed and covered by passing regression tests before this release. + +## Scope and remaining checks + +This is fresh Linux/local-browser evidence. Hosted Ubuntu/Windows release +validation must pass on the release pull request before merge/publication; +historical 0.7.0 Windows checks do not validate the new source. No fresh physical +Android test or nonempty live Fleet/Redteam execution is claimed. + +BB history is limited to retained public operational events. Fleet and Redteam +still expose bounded snapshots. Results and checks are agent-reported, not an +independent certification. Existing package engine floors are retained; the +latest timeline/update APIs are optional and source capabilities may differ. + +Git tags and GitHub releases are the publication authority. Local version +preparation and these checks alone do not mean a release has been published. diff --git a/hooks/useCatchUp.ts b/hooks/useCatchUp.ts new file mode 100644 index 0000000..11b01ab --- /dev/null +++ b/hooks/useCatchUp.ts @@ -0,0 +1,21 @@ +import { useEffect, useRef, useState } from "react"; +import type { GlobalDashboardSnapshot } from "@/contract/rpc"; +import { CATCH_UP_KEY, catchUpChanges, mergeCheckpoint, parseCheckpoint, type Checkpoint } from "@/lib/catch-up"; + +export function useCatchUp(snapshot: GlobalDashboardSnapshot | null) { + const [baseline, setBaseline] = useState(() => { try { return parseCheckpoint(window.localStorage.getItem(CATCH_UP_KEY)); } catch { return null; } }); + const latest = useRef(snapshot); + latest.current = snapshot; + const baselineRef = useRef(baseline); baselineRef.current = baseline; + function persist(value: Checkpoint) { try { window.localStorage.setItem(CATCH_UP_KEY, JSON.stringify(value)); } catch { /* Usable for this visit even without persistence. */ } } + useEffect(() => { + if (!snapshot || baseline !== null || snapshot.unavailableSources.includes("bb")) return; + const initial = mergeCheckpoint(null, snapshot); persist(initial); setBaseline(initial); + }, [snapshot, baseline]); + useEffect(() => () => { + if (latest.current && !latest.current.unavailableSources.includes("bb")) persist(mergeCheckpoint(baselineRef.current, latest.current)); + }, []); + return { changes: snapshot ? catchUpChanges(snapshot, baseline) : [], since: baseline?.at ?? null, + markSeen() { if (snapshot && !snapshot.unavailableSources.includes("bb")) { const next = mergeCheckpoint(baseline, snapshot); persist(next); setBaseline(next); } }, + }; +} diff --git a/hooks/useDashboardPreferences.ts b/hooks/useDashboardPreferences.ts new file mode 100644 index 0000000..14e70c9 --- /dev/null +++ b/hooks/useDashboardPreferences.ts @@ -0,0 +1,11 @@ +import { useEffect, useState } from "react"; +import { DEFAULT_PREFERENCES, readPreferences, writePreferences } from "@/lib/dashboard-preferences"; + +export function useDashboardPreferences() { + const [preferences, setPreferences] = useState(() => { try { return readPreferences(window.localStorage); } catch { return DEFAULT_PREFERENCES; } }); + const [storageFailed, setStorageFailed] = useState(false); + useEffect(() => { + try { setStorageFailed(!writePreferences(preferences, window.localStorage)); } catch { setStorageFailed(true); } + }, [preferences]); + return { preferences, setPreferences, storageFailed }; +} diff --git a/hooks/useExecutionTimeline.ts b/hooks/useExecutionTimeline.ts index 69c324d..f0ce903 100644 --- a/hooks/useExecutionTimeline.ts +++ b/hooks/useExecutionTimeline.ts @@ -9,6 +9,7 @@ import { type ExecutionTimelineInput, type rpcContract, } from "@/contract/rpc"; +import { mergeTimelineEvents } from "@/lib/timeline-view"; export interface ExecutionTimelineState { timeline: ExecutionTimeline | null; @@ -16,6 +17,9 @@ export interface ExecutionTimelineState { refreshing: boolean; error: string | null; refetch: () => void; + loadingEarlier: boolean; + earlierError: string | null; + loadEarlier: () => void; } export function useExecutionTimeline( @@ -27,6 +31,11 @@ export function useExecutionTimeline( const [loading, setLoading] = useState(false); const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); + const [loadingEarlier, setLoadingEarlier] = useState(false); + const [earlierError, setEarlierError] = useState(null); + const olderInflight = useRef(false); + const generation = useRef(0); + const bridges = useRef<{ resumeCursor: string | null; knownIds: Set }[]>([]); const openRef = useRef(open); const timelineRef = useRef(null); const inflight = useRef(false); @@ -42,6 +51,7 @@ export function useExecutionTimeline( const load = useCallback(() => { if (!openRef.current) return; + if (olderInflight.current) { pendingSignal.current = true; return; } if (inflight.current) { pendingSignal.current = true; return; @@ -56,8 +66,15 @@ export function useExecutionTimeline( rpc.call("execution_timeline", rpcInput).then( (result) => { if (id !== requestId.current) return; - timelineRef.current = result; - setTimeline(result); + const current = timelineRef.current; + const overlaps = current?.events.some(event => result.events.some(next => next.id === event.id)) ?? false; + const needsBridge = !!current?.events.length && !overlaps && !!result.olderCursor; + if (needsBridge) bridges.current.push({ resumeCursor: current?.olderCursor ?? null, knownIds: new Set(current!.events.slice(-80).map(event => event.id)) }); + const combined = current ? { ...result, events: mergeTimelineEvents(current.events, result.events), + olderCursor: needsBridge ? result.olderCursor : current.olderCursor ?? (overlaps ? null : result.olderCursor), + truncated: current.olderCursor || overlaps ? current.truncated : result.truncated } : result; + timelineRef.current = combined; + setTimeline(combined); setError(null); }, (cause: unknown) => { @@ -76,9 +93,33 @@ export function useExecutionTimeline( }, [input, rpc]); loadRef.current = load; + const loadEarlier = useCallback(() => { + const cursor = timelineRef.current?.olderCursor; + if (!openRef.current || input.source !== "bb" || !cursor || olderInflight.current || inflight.current) return; + const currentGeneration = generation.current; + olderInflight.current = true; setLoadingEarlier(true); setEarlierError(null); + rpc.call("execution_timeline", { source: "bb", executionKey: input.executionKey, threadId: input.threadId, cursor }).then(result => { + if (generation.current !== currentGeneration || !openRef.current || !timelineRef.current) return; + const current = timelineRef.current; + const bridge = bridges.current.at(-1); + const reachedKnownHistory = bridge && (result.events.some(event => bridge.knownIds.has(event.id)) || !result.olderCursor); + const nextCursor = reachedKnownHistory ? bridges.current.pop()!.resumeCursor : result.olderCursor; + const combined = { ...current, events: mergeTimelineEvents(current.events, result.events), olderCursor: nextCursor, truncated: !!nextCursor || result.truncated, + sourceWarning: result.sourceWarning ?? current.sourceWarning }; + timelineRef.current = combined; setTimeline(combined); + }, (cause: unknown) => { + if (generation.current === currentGeneration && openRef.current) setEarlierError(cause instanceof Error ? cause.message : String(cause)); + }).finally(() => { + if (generation.current !== currentGeneration) return; + olderInflight.current = false; setLoadingEarlier(false); + if (pendingSignal.current && openRef.current && timelineRef.current?.active) { pendingSignal.current = false; queueMicrotask(() => loadRef.current()); } + }); + }, [input, rpc]); + // Target changes discard the old view. Opening is the sole initial-fetch // gate, so global/scoped summary renders add zero timeline requests. useEffect(() => { + generation.current += 1; requestId.current += 1; inflight.current = false; pendingSignal.current = false; @@ -87,7 +128,10 @@ export function useExecutionTimeline( setError(null); setLoading(false); setRefreshing(false); + olderInflight.current = false; setLoadingEarlier(false); setEarlierError(null); + bridges.current = []; if (open) queueMicrotask(() => loadRef.current()); + return () => { generation.current += 1; requestId.current += 1; }; }, [open, targetKey]); useRealtime(BOARD_CHANGED_CHANNEL, () => { @@ -113,5 +157,5 @@ export function useExecutionTimeline( if (connectionState === "connected") everConnected.current = true; }, [connectionState]); - return { timeline, loading, refreshing, error, refetch: load }; + return { timeline, loading, refreshing, error, refetch: load, loadingEarlier, earlierError, loadEarlier }; } diff --git a/lib/bb-source.ts b/lib/bb-source.ts index c7e812a..4ebf1cc 100644 --- a/lib/bb-source.ts +++ b/lib/bb-source.ts @@ -15,7 +15,7 @@ // presentation labels, outputs. // - Statuses are mapped losslessly enough to drive column placement. import type { BbPluginApi } from "@get-bb/plugin-sdk"; -import { reportedCompletion } from "./dashboard-view"; +import { reportedCompletion, resultLinks } from "./dashboard-view"; import type { BoardActivity, BoardActivityFeed, @@ -30,6 +30,7 @@ import { isReasoningEventType, previewText, publicUpdatePreview, + redactOperationalText, } from "./board-model"; // --------------------------------------------------------------------------- @@ -64,6 +65,9 @@ export interface ThreadRowLike { id: string; parentThreadId: string | null; projectId?: string | null; + environmentId?: string | null; + environmentName?: string | null; + environmentBranchName?: string | null; providerId: string | null; title: string | null; titleFallback: string | null; @@ -134,6 +138,13 @@ export function workRows(rows: readonly TimelineRow[]): TimelineWorkRow[] { /** Presentation label for a work row, falling back to per-kind content. */ export function activityTextForWorkRow(row: TimelineWorkRow): string { + // Operation and target are more useful than generic “Ran command” labels. + switch (row.workKind) { + case "command": return `Command: ${row.command}`; + case "file-read": return `Read: ${row.path}`; + case "file-change": return `Edit: ${row.change.path}`; + case "search": return row.mode === "content" ? `Search: ${row.query}` : `Search in ${row.path ?? "workspace"}`; + } // presentation is declared on most (not all) work-row variants. const presentation = ( "presentation" in row ? row.presentation : undefined @@ -144,20 +155,12 @@ export function activityTextForWorkRow(row: TimelineWorkRow): string { if (typeof chosen === "string" && chosen.length > 0) return chosen; } switch (row.workKind) { - case "command": - return row.command; case "tool": return row.toolName; - case "search": - return row.mode === "content" ? `Search: ${row.query}` : `Search in ${row.path ?? ""}`; case "web-search": return `Web search: ${row.queries[0] ?? ""}`; case "web-fetch": return `Fetch: ${row.url}`; - case "file-read": - return row.path; - case "file-change": - return row.change.path; case "image-view": return row.path; case "plan-steps": @@ -182,7 +185,7 @@ export function latestActivity(rows: readonly TimelineRow[]): BoardActivity | nu if (!last) return null; const completedAt = "completedAt" in last ? (last.completedAt ?? null) : null; return { - text: previewText(activityTextForWorkRow(last), BOARD_LIMITS.activityChars) ?? last.workKind, + text: previewText(redactOperationalText(activityTextForWorkRow(last)), BOARD_LIMITS.activityChars) ?? last.workKind, at: completedAt ?? last.startedAt, kind: last.workKind, }; @@ -473,6 +476,11 @@ export function normalizeThread( const errorPreview = status === "failed" ? errorPreviewFor(facts) : null; const waitingOn = status === "waiting" || status === "queued" ? waitingOnText(facts) : null; const interaction = blockingInteractionKind(facts.timeline.rows); + const interactionRow = interaction === null ? null : [...workRows(facts.timeline.rows)].reverse().find(row => + (row.workKind === "question" && (row.lifecycle === "pending" || row.lifecycle === "resolving")) || + (row.workKind === "approval" && (row.lifecycle === "pending" || row.lifecycle === "waiting")) || + ("approvalStatus" in row && row.approvalStatus === "waiting_for_approval")); + const interactionText = interactionRow ? previewText(redactOperationalText(activityTextForWorkRow(interactionRow)), BOARD_LIMITS.activityChars) : null; const attention: BoardCard["attention"] = status === "failed" ? { type: "failed", message: errorPreview ?? "Execution failed" } @@ -481,7 +489,7 @@ export function normalizeThread( : status === "waiting" && (interaction !== null || facts.row.hasPendingInteraction === true) ? { type: "action-required", - message: interaction === "question" ? "Input required" : interaction === "approval" ? "Approval required" : "Waiting for you", + message: interactionText ?? (interaction === "question" ? "Input required" : interaction === "approval" ? "Approval required" : "Waiting for you"), } : status === "waiting" ? { type: "waiting", message: waitingOn ?? "Execution is waiting" } @@ -516,8 +524,15 @@ export function normalizeThread( activity: latestActivity(facts.timeline.rows), lastActivityAt: latestPublicActivityAt(facts.timeline.rows), ...reportedCompletion(facts.output ?? delegation?.output), + review: { + objective: publicUpdatePreview(facts.timeline.goal?.objective, 320), + goalStatus: facts.timeline.goal?.status ?? null, + changedPaths: [...new Set(workRows(facts.timeline.rows).flatMap(row => row.workKind === "file-change" ? [previewText(redactOperationalText(row.change.path), 240)!] : []))].filter(Boolean).slice(0, 12), + links: resultLinks(facts.output), + }, + ...(interaction && interactionRow ? { interaction: { kind: interaction, at: interactionRow.startedAt } } : {}), outputPreview: - previewText(facts.output ?? delegation?.output ?? null, BOARD_LIMITS.outputPreviewChars) ?? + publicUpdatePreview(facts.output ?? delegation?.output ?? null, BOARD_LIMITS.outputPreviewChars) ?? null, latestPublicUpdate: latestPublicUpdate(facts.timeline.rows), errorPreview, @@ -819,7 +834,8 @@ function delegationCards(facts: ThreadFacts, parentKey: string): BoardCard[] { subtitle: delegation.background ? "background delegation" : "delegation", status, column: columnForStatus(status, "delegation"), - threadId: delegation.childRef, + threadId: null, + ownerThreadId: facts.row.id, providerId: null, providerLabel: null, model: null, @@ -831,8 +847,8 @@ function delegationCards(facts: ThreadFacts, parentKey: string): BoardCard[] { : null, phaseTitle: null, promptPreview: null, - activity: null, - outputPreview: previewText(delegation.output, BOARD_LIMITS.outputPreviewChars), + activity: latestActivity(delegation.childRows ?? []), + outputPreview: publicUpdatePreview(delegation.output, BOARD_LIMITS.outputPreviewChars), errorPreview: null, tokens: null, toolCalls: null, diff --git a/lib/board-model.ts b/lib/board-model.ts index 51b29cc..f5bdf23 100644 --- a/lib/board-model.ts +++ b/lib/board-model.ts @@ -183,7 +183,7 @@ export function stripFencedBlocks(text: string): string { */ export function publicUpdatePreview( text: string | null | undefined, - max = BOARD_LIMITS.publicUpdateChars, + max: number = BOARD_LIMITS.publicUpdateChars, ): string | null { if (typeof text !== "string") return null; return previewText(redactOperationalText(stripFencedBlocks(text)), max); diff --git a/lib/catch-up.ts b/lib/catch-up.ts new file mode 100644 index 0000000..a384450 --- /dev/null +++ b/lib/catch-up.ts @@ -0,0 +1,52 @@ +import { z } from "zod"; +import type { GlobalDashboardSnapshot } from "../contract/rpc"; + +export const CATCH_UP_KEY = "agent-board:seen:v1"; +const checkpointSchema = z.object({ at: z.number().finite().nonnegative(), executions: z.record(z.string().max(500), z.object({ status: z.string().max(40), completedAt: z.number().finite().nullable() })), attention: z.record(z.string().max(500), z.string().max(100)) }); +export type Checkpoint = z.infer; +export type CatchUpChange = { key: string; title: string; kind: string; threadId: string | null }; +// Store a fingerprint, never question text or execution output in browser state. +function fingerprint(value: string): string { let n = 2166136261; for (const c of value) n = Math.imul(n ^ c.charCodeAt(0), 16777619); return (n >>> 0).toString(16); } +export function parseCheckpoint(raw: string | null): Checkpoint | null { + try { + if (!raw || raw.length > 100_000) return null; + const value = checkpointSchema.parse(JSON.parse(raw)); + if (value.at > Date.now() + 5_000 || Object.keys(value.executions).length > 128 || Object.keys(value.attention).length > 128) return null; + return value; + } catch { return null; } +} +export function checkpointFor(snapshot: GlobalDashboardSnapshot): Checkpoint { + return { at: snapshot.fetchedAt, + executions: Object.fromEntries([...snapshot.active, ...snapshot.recent].slice(0, 128).map(e => [e.key, { status: e.card.status, completedAt: e.card.completedAt }])), + attention: Object.fromEntries(snapshot.attention.slice(0, 128).map(a => [a.id, fingerprint(`${a.type}:${a.firstObservedAt}:${a.message}`)])), + }; +} +export function mergeCheckpoint(previous: Checkpoint | null, snapshot: GlobalDashboardSnapshot): Checkpoint { + const next = checkpointFor(snapshot); + // The board is always bounded: absence is never evidence of deletion. + return { ...next, at: previous && (snapshot.partial || snapshot.unavailableSources.length > 0) ? previous.at : next.at, + executions: Object.fromEntries(Object.entries({ ...previous?.executions, ...next.executions }).slice(-128)), + attention: Object.fromEntries(Object.entries({ ...previous?.attention, ...next.attention }).slice(-128)), + }; +} +export function catchUpChanges(snapshot: GlobalDashboardSnapshot, previous: Checkpoint | null): CatchUpChange[] { + if (!previous || snapshot.fetchedAt < previous.at) return []; + const changes: CatchUpChange[] = []; + const current = checkpointFor(snapshot); + for (const item of snapshot.attention) { + if (previous.attention[item.id] === current.attention[item.id]) continue; + if (!["action-required", "failed", "interrupted"].includes(item.type)) continue; + changes.push({ key: item.id, title: item.title, kind: item.type === "action-required" ? "Needs your input" : item.type === "failed" ? "Failed" : "Interrupted", threadId: item.threadId }); + } + for (const execution of snapshot.recent) { + if (changes.some(c => c.key.startsWith(`attention:${execution.key}:`))) continue; + const old = previous.executions[execution.key]; + const at = execution.card.completedAt; + if (old?.status === execution.card.status && old.completedAt === at) continue; + // Old list entries first discovered on this visit are not new results. + if (at === null || (!old && at <= previous.at) || at > snapshot.fetchedAt + 5_000) continue; + const kind = execution.card.status === "completed" ? execution.card.source === "bb" ? "Turn finished" : "Execution finished" : execution.card.status === "failed" ? "Failed" : execution.card.status === "interrupted" ? "Interrupted" : null; + if (kind) changes.push({ key: execution.key, title: execution.card.title, kind, threadId: execution.card.threadId }); + } + return changes.slice(0, 12); +} diff --git a/lib/collect.ts b/lib/collect.ts index 124b1d2..81d9e2b 100644 --- a/lib/collect.ts +++ b/lib/collect.ts @@ -36,6 +36,7 @@ import { } from "./global-model"; import { FleetSource, scopedFleetCards, addFleetGroups, fleetTimelineEvents, fleetActive } from "./fleet-source"; import { normalizeBbTimeline } from "./timeline-model"; +import { decodeTimelineCursor, encodeTimelineCursor } from "./timeline-pagination"; /** Remote label only, stripped of credentials/query/fragment; never a link. */ export function safeRepositoryLabel(remote: string | null | undefined): string | null { @@ -546,7 +547,10 @@ export class BoardCollector { }; const groups = await mapWithConcurrency(selected, 4, async (group): Promise => { - const representative = pickGlobalRepresentative(group.rows); + // A newer child result is not the outcome of the parent request. Once + // the group is terminal, enrich the root for review; active groups still + // prioritize the worker actually doing the work. + const representative = group.rows.some(isGlobalNonterminal) ? pickGlobalRepresentative(group.rows) : group.root; const summaryCards = group.rows .filter((row) => row.id !== representative.id) .map((row) => globalSummaryCard(row, row.id === group.root.id, ctx)); @@ -580,6 +584,9 @@ export class BoardCollector { title: globalThreadTitle(group.root), threadId: representative.id, projectId: group.root.projectId ?? representative.projectId ?? null, + workspaceId: representative.environmentId ?? null, + workspaceName: previewText(redactOperationalText(representative.environmentName ?? ""), 240), + branch: previewText(redactOperationalText(representative.environmentBranchName ?? ""), 240), cards: [...summaryCards, ...details], updatedAt: group.updatedAt, }; @@ -612,7 +619,7 @@ export class BoardCollector { BOARD_LIMITS.executionTimelineMaxEvents, ); const target = input.source === "bb" ? input.threadId : input.source === "ollama-fleet" ? input.jobId : input.scanId; - const key = `${input.executionKey}:${input.source}:${target}:${limit}`; + const key = `${input.executionKey}:${input.source}:${target}:${limit}:${input.source === "bb" ? input.cursor ?? "latest" : "latest"}`; const cached = this.executionTimelineCache.get(key); if (cached) { const ttl = cached.value.active ? TTL.executionTimelineActive : TTL.executionTimelineTerminal; @@ -659,20 +666,25 @@ export class BoardCollector { if (input.source === "redteam") { return this.redteam.timeline(input.scanId, input.executionKey, limit); } + const cursor = decodeTimelineCursor(input.cursor, input.executionKey, input.threadId); // Timeline rows supply tools/public updates/turns. A second, tightly // allowlisted event tail supplies explicit interaction resolution, // provisioning, interruption, and proven resolved-model timestamps. // Reasoning event kinds are never requested. const [timelineResult, lifecycleResult] = await Promise.allSettled([ - this.sdk.threads.timeline({ + cursor?.rowsDone ? Promise.resolve({ rows: [], activeWorkflows: [], activeBackgroundCommands: [], maxSeq: 0, + timelinePage: { kind: "older", hasOlderRows: false, olderCursor: null, returnedSegmentCount: 0, segmentLimit: 80 }, + activePromptMode: null, activeThinking: null, goal: null, modelFallback: null, pendingTodos: null } as TimelineResponse) : this.sdk.threads.timeline({ threadId: input.threadId, includeNestedRows: "true", segmentLimit: String(BOARD_LIMITS.executionTimelineSegmentLimit), + ...(cursor?.anchor ? { beforeAnchorId: cursor.anchor.anchorId, beforeAnchorSeq: String(cursor.anchor.anchorSeq) } : {}), }), - this.sdk.threads.events.list({ + cursor?.lifecycleDone ? Promise.resolve([] as ThreadEventRow[]) : this.sdk.threads.events.list({ threadId: input.threadId, order: "desc", limit: String(BOARD_LIMITS.executionTimelineMaxEvents), + ...(cursor?.lifecycleBeforeSeq !== null && cursor?.lifecycleBeforeSeq !== undefined ? { beforeSeq: String(cursor.lifecycleBeforeSeq) } : {}), types: [ "client/turn/requested", "system/interaction/lifecycle", @@ -686,6 +698,8 @@ export class BoardCollector { throw new Error("BB execution timeline is unavailable"); } const lifecycleEvents = lifecycleResult.status === "fulfilled" ? lifecycleResult.value : []; + const fleet = !cursor || (!cursor.anchor && !cursor.rowsDone) ? await this.fleet.collect() : null; + const jobs = fleet?.jobs.filter(j => j.origin?.threadId === input.threadId) ?? []; const normalized = normalizeBbTimeline({ executionKey: input.executionKey, threadId: input.threadId, @@ -695,12 +709,28 @@ export class BoardCollector { sourceWarning: lifecycleResult.status === "rejected" ? "Some lifecycle details are temporarily unavailable." : null, limit, now: this.now(), + before: cursor?.before ?? undefined, + extraEvents: fleetTimelineEvents(jobs, input.executionKey), }); - const fleet = await this.fleet.collect(); - const jobs = fleet.jobs.filter(j => j.origin?.threadId === input.threadId); - const events = [...normalized.events, ...fleetTimelineEvents(jobs, input.executionKey)].sort((a,b) => a.timestamp-b.timestamp); - return { ...normalized, events: events.slice(-limit), active: normalized.active || jobs.some(fleetActive), - truncated: normalized.truncated || fleet.truncated || events.length > limit }; + const page = timelineResult.value.timelinePage; + const nativeNext = page.hasOlderRows ? page.olderCursor ?? null : null; + const lifecycleNext = lifecycleEvents.length >= BOARD_LIMITS.executionTimelineMaxEvents ? Math.min(...lifecycleEvents.map(e => e.seq)) : null; + const retryLifecycle = cursor?.retryLifecycle === true || lifecycleResult.status === "rejected"; + const olderCursor = normalized.nextBefore ? encodeTimelineCursor({ + version: 1, executionKey: input.executionKey, threadId: input.threadId, + anchor: cursor?.anchor ?? null, before: normalized.nextBefore, + lifecycleBeforeSeq: cursor?.lifecycleBeforeSeq ?? null, + rowsDone: cursor?.rowsDone ?? false, lifecycleDone: cursor?.lifecycleDone ?? false, + retryLifecycle, + }) : nativeNext || lifecycleNext !== null || retryLifecycle ? encodeTimelineCursor({ + version: 1, executionKey: input.executionKey, threadId: input.threadId, + anchor: nativeNext, before: null, rowsDone: nativeNext === null, + lifecycleBeforeSeq: retryLifecycle ? cursor?.lifecycleBeforeSeq ?? null : lifecycleNext, + lifecycleDone: !retryLifecycle && lifecycleNext === null, retryLifecycle: false, + }) : null; + const { nextBefore: _before, ...result } = normalized; + return { ...result, olderCursor, active: normalized.active || jobs.some(fleetActive), + truncated: normalized.truncated || fleet?.truncated === true }; } /** Bounded activity tail for the details view (reasoning events excluded). */ diff --git a/lib/dashboard-preferences.ts b/lib/dashboard-preferences.ts new file mode 100644 index 0000000..f90863f --- /dev/null +++ b/lib/dashboard-preferences.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; +import type { GlobalExecution } from "../contract/rpc"; +import { EMPTY_FILTERS } from "./dashboard-view"; + +export const PREFERENCES_KEY = "agent-board:dashboard:v1"; +const filtersSchema = z.object({ project: z.string().max(500), repository: z.string().max(500), workspace: z.string().max(500).default(""), provider: z.string().max(500), status: z.string().max(500) }); +const viewSchema = z.object({ filters: filtersSchema, grouping: z.enum(["none", "project", "repository", "workspace", "provider", "status"]), window: z.enum(["all", "day", "week"]) }); +const preferencesSchema = viewSchema.extend({ saved: z.array(viewSchema.extend({ name: z.string().trim().min(1).max(60) })).max(8) }); +export type DashboardPreferences = z.infer; +export const DEFAULT_PREFERENCES: DashboardPreferences = { filters: EMPTY_FILTERS, grouping: "none", window: "all", saved: [] }; +export interface BrowserStorage { getItem(key: string): string | null; setItem(key: string, value: string): void } +export function parsePreferences(raw: string | null): DashboardPreferences { + try { return preferencesSchema.parse(JSON.parse(raw ?? "null")); } catch { return { ...DEFAULT_PREFERENCES, filters: { ...EMPTY_FILTERS }, saved: [] }; } +} +export function readPreferences(storage: BrowserStorage): DashboardPreferences { + try { return parsePreferences(storage.getItem(PREFERENCES_KEY)); } catch { return parsePreferences(null); } +} +export function writePreferences(value: DashboardPreferences, storage: BrowserStorage): boolean { + try { storage.setItem(PREFERENCES_KEY, JSON.stringify(preferencesSchema.parse(value))); return true; } catch { return false; } +} +export function saveView(value: DashboardPreferences, name: string): DashboardPreferences { + const safeName = name.trim().slice(0, 60); + if (!safeName) return value; + return { ...value, saved: [...value.saved.filter(v => v.name !== safeName), { name: safeName, filters: { ...value.filters }, grouping: value.grouping, window: value.window }].slice(-8) }; +} +export function applyWindow(executions: GlobalExecution[], window: DashboardPreferences["window"], now: number): GlobalExecution[] { + const age = window === "day" ? 86_400_000 : window === "week" ? 604_800_000 : Infinity; + return executions.filter(e => ["running", "starting", "waiting", "queued"].includes(e.card.status) || now - (e.card.completedAt ?? e.card.lastActivityAt ?? e.updatedAt) <= age); +} diff --git a/lib/dashboard-view.ts b/lib/dashboard-view.ts index f0268f1..d159f33 100644 --- a/lib/dashboard-view.ts +++ b/lib/dashboard-view.ts @@ -1,6 +1,20 @@ import type { BoardCard, GlobalExecution, GlobalDashboardSnapshot } from "../contract/rpc"; import { previewText, redactOperationalText } from "./board-model"; +export function resultLinks(text: string | null | undefined): { label: string; url: string }[] { + const links: { label: string; url: string }[] = []; + for (const match of (text?.slice(-16_384) ?? "").matchAll(/\[([^\]\n]{1,80})\]\((https:\/\/[^\s)]+)\)/g)) { + try { + const url = new URL(match[2]!); + const credentialName = /^(?:api[-_]?key|access[-_]?token|auth[-_]?token|password|passwd|secret|token|key|authorization|signature|sig|x-amz-signature|x-amz-credential)$/i; + if (url.username || url.password || url.href.length > 500 || [...url.searchParams.keys()].some(key => credentialName.test(key))) continue; + if (!links.some(link => link.url === url.href)) links.push({ label: redactOperationalText(match[1]!), url: url.href }); + if (links.length === 4) break; + } catch { /* Not an actionable URL. */ } + } + return links; +} + export const QUIET_ACTIVITY_MS = 120_000; export function activityFreshness(card: BoardCard, now: number) { const at = card.lastActivityAt ?? card.activity?.at ?? null; @@ -9,17 +23,18 @@ export function activityFreshness(card: BoardCard, now: number) { return { state: ageMs >= QUIET_ACTIVITY_MS ? "quiet" as const : "recent" as const, at, ageMs }; } -export type FilterField = "project" | "repository" | "provider" | "status"; +export type FilterField = "project" | "repository" | "workspace" | "provider" | "status"; export type DashboardFilters = Record; -export const EMPTY_FILTERS: DashboardFilters = { project: "", repository: "", provider: "", status: "" }; +export const EMPTY_FILTERS: DashboardFilters = { project: "", repository: "", workspace: "", provider: "", status: "" }; // Separate sentinels prevent a real ID/remote from colliding with Unknown. export function filterValue(execution: GlobalExecution, field: FilterField): string { - const value = field === "project" ? execution.projectId : field === "repository" ? execution.repository : field === "provider" ? execution.card.providerId : execution.card.status; + const value = field === "project" ? execution.projectId : field === "repository" ? execution.repository : field === "workspace" ? execution.workspaceId : field === "provider" ? execution.card.providerId : execution.card.status; return value ? `value:${value}` : "unknown"; } export function filterLabel(execution: GlobalExecution, field: FilterField): string { if (field === "project") return execution.projectName ?? execution.projectId ?? "Unknown project"; if (field === "repository") return execution.repository ?? "Unknown repository"; + if (field === "workspace") return execution.workspaceName ?? execution.workspaceId ?? "Unknown workspace"; if (field === "provider") return execution.card.providerLabel ?? execution.card.providerId ?? "Unknown provider"; return execution.card.status; } diff --git a/lib/global-model.ts b/lib/global-model.ts index e35da26..f235354 100644 --- a/lib/global-model.ts +++ b/lib/global-model.ts @@ -24,6 +24,9 @@ export interface GlobalExecutionGroup { title: string; threadId: string | null; projectId: string | null; + workspaceId?: string | null; + workspaceName?: string | null; + branch?: string | null; scanId?: string; cards: BoardCard[]; delegatedWorkers?: BoardCard[]; @@ -81,6 +84,7 @@ export function buildGlobalExecution(group: GlobalExecutionGroup): GlobalExecuti const activeWorkers = workers.filter(c => c.status === "running" || c.status === "starting"); const status = activeWorkers.length ? "running" : executionStatus(group.cards); const primary = representative(group.cards, status) ?? group.cards[0] ?? null; + const reviewCard = status === "completed" ? group.cards.find(c => c.isRoot && c.review !== undefined) ?? primary : primary; const children = group.cards.filter( (card) => !card.isRoot && card.kind !== "plan-step" && card.kind !== "queued-message", ); @@ -122,9 +126,10 @@ export function buildGlobalExecution(group: GlobalExecutionGroup): GlobalExecuti promptPreview: null, activity: primary?.activity ?? null, lastActivityAt: activityTimes.length ? Math.max(...activityTimes) : null, - outputPreview: primary?.outputPreview ?? null, - completionResult: primary?.completionResult ?? null, - ...(primary?.reportedVerification !== undefined ? { reportedVerification: primary.reportedVerification } : {}), + outputPreview: reviewCard?.outputPreview ?? null, + completionResult: reviewCard?.completionResult ?? null, + ...(reviewCard?.reportedVerification !== undefined ? { reportedVerification: reviewCard.reportedVerification } : {}), + ...(reviewCard?.review ? { review: reviewCard.review, reviewThreadId: reviewCard.threadId } : {}), latestPublicUpdate, errorPreview: primary?.errorPreview ?? null, tokens: primary?.tokens ?? null, @@ -143,9 +148,13 @@ export function buildGlobalExecution(group: GlobalExecutionGroup): GlobalExecuti key: group.key, card, projectId: group.projectId, + workspaceId: group.workspaceId ?? null, + workspaceName: group.workspaceName ?? null, + branch: group.branch ?? null, ...(workers.length ? { delegatedWorkers: [...workers].sort((a,b) => Number(b.status === "running" || b.status === "starting") - Number(a.status === "running" || a.status === "starting")).slice(0,4), delegatedWorkerCount: workers.length, activeDelegatedWorkerCount: activeWorkers.length } : {}), childCount: children.length, activeChildCount: activeChildren.length, + children: [...children].sort((a, b) => Number(["running", "starting", "waiting"].includes(b.status)) - Number(["running", "starting", "waiting"].includes(a.status))).slice(0, 12), updatedAt: group.updatedAt, }; } @@ -218,7 +227,7 @@ export function deriveAttentionItems( return atB - atA; })[0]!; const observedTimes = cards.map( - (card) => card.activity?.at ?? card.startedAt ?? group.updatedAt, + (card) => card.interaction?.at ?? card.activity?.at ?? card.startedAt ?? group.updatedAt, ); const message = cards.length === 1 @@ -235,6 +244,7 @@ export function deriveAttentionItems( updatedAt: group.updatedAt, threadId: newest.threadId ?? group.threadId, source: group.source, + ...(newest.interaction ? { interactionKind: newest.interaction.kind } : {}), ...(group.scanId ? { scanId: group.scanId } : {}), }); } diff --git a/lib/releases.ts b/lib/releases.ts index 636c0ce..98832b7 100644 --- a/lib/releases.ts +++ b/lib/releases.ts @@ -3,12 +3,12 @@ import type { KvLike } from "./dismissals"; export const CURRENT_RELEASE = { version: manifest.version, - title: "A clearer view of agent activity", + title: "Catch up, take action, and browse earlier activity", highlights: [ - "See recent or quiet reporting without treating long tasks as stalled.", - "Filter and group visible work by project, repository, provider and status.", - "Review completion results, failures and agent-reported verification.", - "Check connection, refresh and watcher health; empty attention stays compact.", + "Reply to questions, review approvals, and inspect failures from Needs Attention.", + "Catch up on changes, save dashboard views, and review completed work.", + "Load earlier BB timeline activity without losing your place.", + "See available releases and review compatible updates before installing.", ], }; const STORAGE_KEY = "release-dismissals"; diff --git a/lib/timeline-model.ts b/lib/timeline-model.ts index f623d38..25de8b6 100644 --- a/lib/timeline-model.ts +++ b/lib/timeline-model.ts @@ -15,6 +15,7 @@ import { redactOperationalText, } from "./board-model"; import type { ThreadEventRow, TimelineResponse } from "./bb-source"; +import { compareTimelinePosition, type TimelinePosition } from "./timeline-pagination"; type UnknownRecord = Record; @@ -32,7 +33,10 @@ export interface NormalizeBbTimelineInput { sourceWarning?: string | null; limit?: number; now?: number; + before?: TimelinePosition; + extraEvents?: readonly TimelineEvent[]; } +export type NormalizedTimeline = ExecutionTimeline & { nextBefore?: TimelinePosition }; function record(value: unknown): UnknownRecord | null { return value !== null && typeof value === "object" && !Array.isArray(value) @@ -211,8 +215,9 @@ export function finalizeTimeline( sourceTruncated?: boolean; sourceWarning?: string | null; fetchedAt?: number; + before?: TimelinePosition; }, -): ExecutionTimeline { +): NormalizedTimeline { const limit = Math.min( Math.max(options.limit ?? BOARD_LIMITS.executionTimelineDefaultEvents, 1), BOARD_LIMITS.executionTimelineMaxEvents, @@ -230,9 +235,10 @@ export function finalizeTimeline( } const ordered = [...byFingerprint.values()].sort( (a, b) => a.timestamp - b.timestamp || a.sortSequence - b.sortSequence || a.id.localeCompare(b.id), - ); + ).filter(event => !options.before || compareTimelinePosition({ timestamp: event.timestamp, sequence: event.sortSequence, id: event.id }, options.before) < 0); const truncated = options.sourceTruncated === true || ordered.length > limit; const events = ordered.slice(-limit).map(({ fingerprint: _fingerprint, sortSequence: _sort, ...event }) => event); + const first = ordered.at(-limit); return { executionKey, source, @@ -241,6 +247,7 @@ export function finalizeTimeline( truncated, sourceWarning: safeText(options.sourceWarning, 240), fetchedAt: Math.trunc(options.fetchedAt ?? Date.now()), + ...(ordered.length > limit && first ? { nextBefore: { timestamp: first.timestamp, sequence: first.sortSequence, id: first.id } } : {}), }; } @@ -388,10 +395,11 @@ function lifecycleCandidates(input: NormalizeBbTimelineInput, base: Pick(); for (const row of rows) { if (row.kind !== "system" || row.systemKind !== "error") continue; @@ -712,5 +720,6 @@ export function normalizeBbTimeline(input: NormalizeBbTimelineInput): ExecutionT sourceTruncated: page?.hasOlderRows === true || input.lifecycleTruncated === true, sourceWarning: input.sourceWarning, fetchedAt: input.now, + before: input.before, }); } diff --git a/lib/timeline-pagination.ts b/lib/timeline-pagination.ts new file mode 100644 index 0000000..be7dd84 --- /dev/null +++ b/lib/timeline-pagination.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; + +export interface TimelinePosition { timestamp: number; sequence: number; id: string } +const integer = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER); +const cursorSchema = z.object({ + version: z.literal(1), executionKey: z.string().min(1).max(240), threadId: z.string().min(1).max(240), + anchor: z.object({ anchorId: z.string().min(1).max(500), anchorSeq: integer }).nullable(), + before: z.object({ timestamp: integer, sequence: integer, id: z.string().min(1).max(320) }).nullable(), + lifecycleBeforeSeq: integer.nullable(), + rowsDone: z.boolean(), lifecycleDone: z.boolean(), + retryLifecycle: z.boolean().default(false), +}).strict(); +export type TimelineCursor = z.infer; +export function encodeTimelineCursor(cursor: TimelineCursor): string { + return Buffer.from(JSON.stringify(cursorSchema.parse(cursor))).toString("base64url"); +} +export function decodeTimelineCursor(value: string | undefined, executionKey: string, threadId: string): TimelineCursor | null { + if (!value) return null; + try { + if (value.length > 4096) throw new Error(); + const cursor = cursorSchema.parse(JSON.parse(Buffer.from(value, "base64url").toString("utf8"))); + if (cursor.executionKey !== executionKey || cursor.threadId !== threadId) throw new Error(); + return cursor; + } catch { throw new Error("Timeline history cursor is invalid. Close and reopen the timeline."); } +} +export function compareTimelinePosition(a: TimelinePosition, b: TimelinePosition): number { + return a.timestamp - b.timestamp || a.sequence - b.sequence || a.id.localeCompare(b.id); +} diff --git a/lib/timeline-view.ts b/lib/timeline-view.ts new file mode 100644 index 0000000..b599501 --- /dev/null +++ b/lib/timeline-view.ts @@ -0,0 +1,8 @@ +import type { TimelineEvent } from "../contract/rpc"; + +/** Refreshes update known events; history pages never replace the loaded run. */ +export function mergeTimelineEvents(current: readonly TimelineEvent[], incoming: readonly TimelineEvent[]): TimelineEvent[] { + const events = new Map(current.map(event => [event.id, event])); + for (const event of incoming) events.set(event.id, event); + return [...events.values()].sort((a, b) => a.timestamp - b.timestamp || (a.sourceMetadata?.sequence ?? 0) - (b.sourceMetadata?.sequence ?? 0) || a.id.localeCompare(b.id)); +} diff --git a/lib/update-check.ts b/lib/update-check.ts new file mode 100644 index 0000000..3686507 --- /dev/null +++ b/lib/update-check.ts @@ -0,0 +1,78 @@ +import manifest from "../package.json"; + +const REPOSITORY = "https://github.com/aaronphifer/bb-plugin-agent-board"; +type NativeCheck = { outcome: string; candidate?: { version: string; display: string } }; +export interface ReleaseUpdateState { + installed: string; + latest: string | null; + releaseUrl: string | null; + eligible: { version: string; display: string } | null; + discoveryUnavailable: boolean; +} +function stableVersion(value: unknown): string | null { + if (typeof value !== "string" || value.length > 80) return null; + return /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value) ? value.replace(/^v/, "") : null; +} +export function newerStableVersion(tag: unknown, installed: string): string | null { + const next = stableVersion(tag), current = stableVersion(installed); + if (!next || !current) return null; + const a = next.split(".").map(BigInt), b = current.split(".").map(BigInt); + for (let i = 0; i < 3; i++) { if (a[i]! > b[i]!) return next; if (a[i]! < b[i]!) return null; } + return null; +} +/** One public request per hour, coalesced across every mounted client. No install on check. */ +export class ReleaseUpdateChecker { + private cache: { at: number; state: ReleaseUpdateState } | null = null; + private pending: Promise | null = null; + private applying = false; + constructor(private readonly options: { + installed: string; + fetchLatest: () => Promise; + checkNative: () => Promise; + applyNative: () => Promise<{ applied: boolean; outcome: string; detail?: string }>; + now?: () => number; + }) {} + snapshot(): Promise { + const now = (this.options.now ?? Date.now)(); + if (this.cache && now - this.cache.at < (this.cache.state.discoveryUnavailable ? 900_000 : 3_600_000)) return Promise.resolve(this.cache.state); + if (this.pending) return this.pending; + this.pending = this.collect().then(state => { this.cache = { at: now, state }; return state; }).finally(() => { this.pending = null; }); + return this.pending; + } + private async collect(): Promise { + const [release, native] = await Promise.allSettled([this.options.fetchLatest(), this.options.checkNative()]); + const raw = release.status === "fulfilled" && release.value && typeof release.value === "object" ? release.value as Record : null; + const latest = raw && !raw.draft && !raw.prerelease ? newerStableVersion(raw.tag_name, this.options.installed) : null; + const candidate = native.status === "fulfilled" && native.value?.outcome === "update-available" ? native.value.candidate : null; + return { installed: this.options.installed, latest, + releaseUrl: latest ? `${REPOSITORY}/releases/tag/${String(raw!.tag_name)}` : null, + eligible: candidate ? { version: candidate.version.slice(0, 200), display: candidate.display.slice(0, 240) } : null, + discoveryUnavailable: release.status === "rejected", + }; + } + async install(expectedVersion: string) { + if (this.applying) throw new Error("An update is already in progress."); + this.applying = true; + try { + const fresh = await this.options.checkNative(); + if (fresh?.outcome !== "update-available" || fresh.candidate?.version !== expectedVersion) throw new Error("The available update changed. Check again before installing."); + const result = await this.options.applyNative(); + this.cache = null; + return { applied: result.applied, outcome: result.outcome, detail: result.detail?.slice(0, 320) ?? null }; + } finally { this.applying = false; } + } +} + +/** Fixed official endpoint; no credentials and no user-controlled URLs. */ +export async function fetchLatestRelease(): Promise { + const response = await fetch("https://api.github.com/repos/aaronphifer/bb-plugin-agent-board/releases/latest", { + signal: AbortSignal.timeout(5_000), redirect: "error", headers: { Accept: "application/vnd.github+json", "User-Agent": `bb-agent-board/${manifest.version}` }, + }); + if (!response.ok || !response.body) throw new Error("Release discovery unavailable"); + const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let size = 0; + try { + while (true) { const chunk = await reader.read(); if (chunk.done) break; size += chunk.value.byteLength; if (size > 65_536) throw new Error("Release metadata too large"); chunks.push(chunk.value); } + const all = new Uint8Array(size); let offset = 0; for (const chunk of chunks) { all.set(chunk, offset); offset += chunk.byteLength; } + return JSON.parse(new TextDecoder().decode(all)); + } finally { await reader.cancel().catch(() => {}); } +} diff --git a/package-lock.json b/package-lock.json index e24972a..4fe3d3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bb-plugin-agent-board", - "version": "0.7.0", + "version": "0.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bb-plugin-agent-board", - "version": "0.7.0", + "version": "0.7.1", "dependencies": { "@hugeicons/core-free-icons": "^4.1.3", "@hugeicons/react": "^1.1.6", diff --git a/package.json b/package.json index 09991ce..06f70e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bb-plugin-agent-board", - "version": "0.7.0", + "version": "0.7.1", "type": "module", "files": [ "app.tsx", @@ -18,7 +18,9 @@ "PLUGIN_OVERVIEW.md", "CHANGELOG.md", "RELEASE_NOTES.md", - "docs/upgrade-0.7.0.md" + "docs/upgrade-0.7.0.md", + "docs/daily-workflow.md", + "docs/validation-0.7.1.md" ], "scripts": { "test": "vitest run", diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index bf459bd..1687e0f 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -63,7 +63,7 @@ for (const side of ['app', 'server']) { assert.equal(meta.pluginId, 'agent-board'); assert.equal(meta.pluginVersion, pkg.version); assert.equal(meta.artifactFormatVersion, 1); - assert.equal(meta.builtWith.bbVersion, '0.46.0'); + assert.match(meta.builtWith.bbVersion, /^\d+\.\d+\.\d+(?:[-+].*)?$/, 'Build must identify its actual BB builder version'); } const sha256 = createHash('sha256').update(fs.readFileSync(archive)).digest('hex'); console.log(JSON.stringify({ pluginId: 'agent-board', version: pkg.version, files: entries.length, size: pack.size, sha256 })); diff --git a/server.ts b/server.ts index c77f1a1..8848d94 100644 --- a/server.ts +++ b/server.ts @@ -13,6 +13,8 @@ import { FleetActivePoller } from "./lib/fleet-poll"; import { RedteamActivePoller } from "./lib/redteam-poll"; import { DismissalStore, attentionDismissalKey } from "./lib/dismissals"; import { ReleaseAnnouncementStore } from "./lib/releases"; +import { ReleaseUpdateChecker, fetchLatestRelease } from "./lib/update-check"; +import manifest from "./package.json"; export { BOARD_CHANGED_CHANNEL } from "./contract/rpc"; @@ -24,6 +26,12 @@ export default async function plugin(bb: BbPluginApi) { // surviving plugin reloads; never mutates any underlying source state). const dismissals = new DismissalStore(bb.storage.kv); const releases = new ReleaseAnnouncementStore(bb.storage.kv); + const updates = new ReleaseUpdateChecker({ + installed: manifest.version, + fetchLatest: fetchLatestRelease, + checkNative: async () => (await bb.sdk.plugins.checkUpdates({ pluginId: "agent-board", signal: AbortSignal.timeout(8_000) })).find(result => result.id === "agent-board"), + applyNative: () => bb.sdk.plugins.applyUpdate({ pluginId: "agent-board" }), + }); const watcher = new BoardWatcher({ publish: (payload) => { collector.invalidate(); @@ -63,6 +71,8 @@ export default async function plugin(bb: BbPluginApi) { // BB threads/workflows/plans/delegations plus optional adapter cards share // one source-discriminated normalized contract. bb.rpc.register(rpcContract, { + release_update: () => updates.snapshot(), + install_update: ({ expectedVersion }) => updates.install(expectedVersion), release_announcement: () => releases.snapshot(), dismiss_release_announcement: ({ version }) => releases.dismiss(version), board_snapshot: async ({ threadId, fresh }) => { diff --git a/tests/app.test.tsx b/tests/app.test.tsx index f98c33c..492c95c 100644 --- a/tests/app.test.tsx +++ b/tests/app.test.tsx @@ -2,12 +2,14 @@ // Frontend tests: render the threadPanelAction slot through the official // plugin app testing harness — columns/cards, realtime signal refetch, // reconnect reconcile, navigation, loading/error/empty states. -import { describe, expect, it, beforeEach } from "vitest"; +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { cleanup } from "@testing-library/react"; import { loadPluginApp, renderSlot, type CapturedPluginApp, type RenderedSlot } from "@get-bb/plugin-sdk/testing/app"; import type { BoardCard, BoardSnapshot, ExecutionTimeline } from "@/contract/rpc"; import { BOARD_CHANGED_CHANNEL } from "@/contract/rpc"; let app: CapturedPluginApp; +afterEach(cleanup); beforeEach(async () => { app = await loadPluginApp(() => import("../app")); @@ -248,11 +250,11 @@ describe("realtime invalidation", () => { rpc: { board_snapshot: () => snapshotFixture() }, }); await slot.findAllByText("Refactor the auth flow"); - expect(slot.inspection.rpcCalls.length).toBe(1); + expect(slot.inspection.rpcCalls.filter(call => call.method !== "release_update").length).toBe(1); await slot.behavior.emitRealtime(BOARD_CHANGED_CHANNEL, { roots: ["thr_root"] }); await slot.findAllByText("Refactor the auth flow"); - expect(slot.inspection.rpcCalls.length).toBe(2); + expect(slot.inspection.rpcCalls.filter(call => call.method !== "release_update").length).toBe(2); slot.unmount(); }); @@ -265,7 +267,7 @@ describe("realtime invalidation", () => { await slot.behavior.emitRealtime(BOARD_CHANGED_CHANNEL, { roots: ["thr_other"] }); await new Promise((resolve) => setTimeout(resolve, 50)); - expect(slot.inspection.rpcCalls.length).toBe(1); + expect(slot.inspection.rpcCalls.filter(call => call.method !== "release_update").length).toBe(1); slot.unmount(); }); @@ -275,7 +277,7 @@ describe("realtime invalidation", () => { await slot.findAllByText("Refactor the auth flow"); await slot.behavior.emitRealtime(BOARD_CHANGED_CHANNEL, { roots: [], global: true }); await new Promise((resolve) => setTimeout(resolve, 25)); - expect(slot.inspection.rpcCalls.length).toBe(1); + expect(slot.inspection.rpcCalls.filter(call => call.method !== "release_update").length).toBe(1); slot.unmount(); }); @@ -286,7 +288,7 @@ describe("realtime invalidation", () => { await slot.findAllByText("Refactor the auth flow"); await slot.behavior.emitRealtime(BOARD_CHANGED_CHANNEL, { nonsense: true }); - expect(slot.inspection.rpcCalls.length).toBe(2); + expect(slot.inspection.rpcCalls.filter(call => call.method !== "release_update").length).toBe(2); slot.unmount(); }); @@ -297,11 +299,11 @@ describe("realtime invalidation", () => { realtimeConnectionState: "connected", }); await slot.findAllByText("Refactor the auth flow"); - expect(slot.inspection.rpcCalls.length).toBe(1); + expect(slot.inspection.rpcCalls.filter(call => call.method !== "release_update").length).toBe(1); await slot.behavior.setRealtimeConnectionState("reconnecting"); await slot.behavior.setRealtimeConnectionState("connected"); - expect(slot.inspection.rpcCalls.length).toBe(2); + expect(slot.inspection.rpcCalls.filter(call => call.method !== "release_update").length).toBe(2); slot.unmount(); }); diff --git a/tests/bb-source.test.ts b/tests/bb-source.test.ts index 6b8fb7d..41f95c3 100644 --- a/tests/bb-source.test.ts +++ b/tests/bb-source.test.ts @@ -130,6 +130,30 @@ function queuedMessage(fields: Record): QueuedMessageRow { } describe("thread card normalization", () => { + it("uses specific operation targets instead of generic presentation labels", () => { + const row = workRow("command", { command: "npm test", presentation: { label: { completed: "Ran command", pending: "Running command" } } }); + expect(latestActivity([row])?.text).toContain("npm test"); + }); + it("shows the explicit pending question and its timestamp", () => { + const question = workRow("question", { status: "pending", lifecycle: "pending", startedAt: 700, questions: [{ prompt: "Which deployment target?" }] }); + const root = normalizeThread(facts({ timeline: timeline({ rows: [question] }) }), ctx, null)[0]!; + expect(root.attention?.message).toBe("Which deployment target?"); + expect(root.interaction).toMatchObject({ kind: "question", at: 700 }); + }); + it("retains bounded observed review evidence without claiming verified completion", () => { + const change = workRow("file-change", { change: { path: "src/auth.ts", kind: "update" } }); + const root = normalizeThread(facts({ row: threadRow({ status: "idle" }), output: "Done.\nTests: 12 passed", timeline: timeline({ rows: [change], goal: { objective: "Fix auth", status: "complete" } as never }) }), ctx, null)[0]!; + expect(root.review).toMatchObject({ objective: "Fix auth", goalStatus: "complete", changedPaths: ["src/auth.ts"] }); + }); + it("keeps native subagent references out of BB navigation and exposes child activity", () => { + const child = workRow("command", { command: "npm test" }); + const delegated = workRow("delegation", { status: "pending", completedAt: null, description: "Check tests", childRef: "/root/reviewer", output: "", childRows: [child] }); + const worker = normalizeThread(facts({ timeline: timeline({ rows: [delegated] }) }), ctx, null).find(c => c.kind === "delegation")!; + expect(worker.threadId).toBeNull(); + expect(worker.ownerThreadId).toBe("thr_root"); + expect(worker.activity?.text).toContain("npm test"); + expect(worker.model).toBeNull(); + }); it("retains explicit bounded completion labels beyond the preview", () => { const output = "Review details. ".repeat(100) + "\n**Result:** Boundary review completed\n**Tests:** not run (review only)"; const root = normalizeThread(facts({ output }), ctx, null).find(c => c.kind === "thread")!; @@ -229,12 +253,12 @@ describe("status normalization", () => { it("attaches explicit action-required metadata for approvals and questions", () => { const approvalRows = [workRow("command", { command: "deploy", approvalStatus: "waiting_for_approval" })]; const approval = normalizeThread(facts({ timeline: timeline({ rows: approvalRows as never }) }), ctx, null)[0]!; - expect(approval.attention).toEqual({ type: "action-required", message: "Approval required" }); + expect(approval.attention).toEqual({ type: "action-required", message: "Command: deploy" }); expect(approval.waitingOn).toBe("Approval required"); const questionRows = [workRow("question", { lifecycle: "pending", questions: [{ prompt: "Which db?" }] })]; const question = normalizeThread(facts({ timeline: timeline({ rows: questionRows as never }) }), ctx, null)[0]!; - expect(question.attention).toEqual({ type: "action-required", message: "Input required" }); + expect(question.attention).toEqual({ type: "action-required", message: "Which db?" }); expect(question.waitingOn).toBe("Input required"); }); @@ -484,7 +508,8 @@ describe("delegation normalization", () => { const delegations = cards.filter((card) => card.kind === "delegation"); expect(delegations).toHaveLength(2); expect(delegations[0]!.status).toBe("completed"); - expect(delegations[0]!.threadId).toBe("thr_gone"); + expect(delegations[0]!.threadId).toBeNull(); + expect(delegations[0]!.ownerThreadId).toBe("thr_root"); expect(delegations[1]!.status).toBe("interrupted"); expect(delegations[1]!.column).toBe("output"); }); @@ -661,7 +686,7 @@ describe("latestActivity", () => { }), ]; const activity = latestActivity(rows); - expect(activity?.text).toBe("Editing src/auth.ts"); + expect(activity?.text).toBe("Edit: src/auth.ts"); expect(activity?.kind).toBe("file-change"); }); @@ -669,7 +694,7 @@ describe("latestActivity", () => { const cases: Array<[TimelineWorkRow, string]> = [ [workRow("web-search", { queries: ["bb plugin sdk docs"] }), "Web search: bb plugin sdk docs"], [workRow("web-fetch", { url: "https://example.com/api" }), "Fetch: https://example.com/api"], - [workRow("file-read", { path: "package.json" }), "package.json"], + [workRow("file-read", { path: "package.json" }), "Read: package.json"], [workRow("image-view", { path: "shot.png" }), "shot.png"], [workRow("search", { mode: "content", query: "TODO" }), "Search: TODO"], ]; diff --git a/tests/catch-up.test.ts b/tests/catch-up.test.ts new file mode 100644 index 0000000..35d872e --- /dev/null +++ b/tests/catch-up.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { catchUpChanges, checkpointFor, mergeCheckpoint, parseCheckpoint } from "../lib/catch-up"; +import { phaseCard as card, phaseExecution as execution, phaseSnapshot as snapshot } from "./vnext-fixtures"; + +describe("catch-up from observed state", () => { + it("keeps completions recoverable after an incomplete visit advances wall time", () => { + const initial = checkpointFor(snapshot({ active: [execution("a", { card: card({ status: "running", completedAt: null }) })], fetchedAt: 100 })); + const incomplete = mergeCheckpoint(initial, snapshot({ active: [], recent: [], partial: true, fetchedAt: 300 })); + const recovered = snapshot({ active: [], recent: [execution("a", { card: card({ status: "completed", completedAt: 200 }) })], fetchedAt: 400 }); + expect(catchUpChanges(recovered, incomplete)).toHaveLength(1); + expect(incomplete.at).toBe(100); + }); + it("establishes the first visit without making up history", () => { + expect(catchUpChanges(snapshot(), null)).toEqual([]); + expect(parseCheckpoint("bad")).toBeNull(); + }); + it("reports a new completed turn once and ignores unrelated updatedAt changes", () => { + const running = snapshot({ active: [execution("a", { card: card({ status: "running" }) })], fetchedAt: 100 }); + const prior = checkpointFor(running); + const done = snapshot({ active: [], recent: [execution("a", { updatedAt: 200, card: card({ status: "completed", completedAt: 200 }) })], fetchedAt: 300 }); + expect(catchUpChanges(done, prior).map(c => c.kind)).toEqual(["Turn finished"]); + const seen = checkpointFor(done); + expect(catchUpChanges({ ...done, recent: [{ ...done.recent[0]!, updatedAt: 400 }], fetchedAt: 500 }, seen)).toEqual([]); + }); + it("does not erase absent executions in partial snapshots or accept future checkpoints", () => { + const old = checkpointFor(snapshot({ active: [execution("old")], fetchedAt: 100 })); + const merged = mergeCheckpoint(old, snapshot({ active: [], partial: true, fetchedAt: 200 })); + expect(merged.executions.old).toBeDefined(); + expect(parseCheckpoint(JSON.stringify({ ...old, at: Date.now() + 60_000 }))).toBeNull(); + }); + it("distinguishes a rerun finishing from cosmetic changes and bounds saved observations", () => { + const done = execution("a", { card: card({ status: "completed", completedAt: 100 }) }); + const old = checkpointFor(snapshot({ active: [], recent: [done], fetchedAt: 200 })); + expect(catchUpChanges(snapshot({ active: [], recent: [{ ...done, card: { ...done.card, completedAt: 400 } }], fetchedAt: 500 }), old)).toHaveLength(1); + expect(Object.keys(checkpointFor(snapshot({ active: Array.from({ length: 150 }, (_, i) => execution(String(i))) })).executions).length).toBeLessThanOrEqual(128); + }); +}); diff --git a/tests/collect.test.ts b/tests/collect.test.ts index a24acc5..d8d702e 100644 --- a/tests/collect.test.ts +++ b/tests/collect.test.ts @@ -165,7 +165,8 @@ describe("snapshot assembly", () => { expect(delegation).toBeDefined(); expect(delegation!.title).toBe("Fix the tests"); expect(delegation!.outputPreview).toBe("All 12 tests pass."); - expect(delegation!.threadId).toBe("thr_child"); + expect(delegation!.threadId).toBeNull(); + expect(delegation!.ownerThreadId).toBe("thr_root"); }); }); @@ -278,6 +279,52 @@ describe("bounded fetching and caching", () => { }); describe("lazy execution timeline RPC source", () => { + it("retries a lifecycle page after a temporary source failure", async () => { + const overrides = twoThreadOverrides(); let calls = 0; + (overrides.threads as Record).timeline = async () => timelineFixture(); + (overrides.threads as Record).events = { list: async () => { if (++calls === 1) throw Error("offline"); return [{ id: "recovered", seq: 1, createdAt: 1, type: "client/turn/requested", data: { execution: { model: "recovered-model" } } }]; } }; + const { collector } = makeCollector(overrides); + const target = { source: "bb" as const, executionKey: "bb:thr_root", threadId: "thr_root" }; + const first = await collector.executionTimeline(target); + expect(first.olderCursor).toBeTruthy(); + const recovered = await collector.executionTimeline({ ...target, cursor: first.olderCursor! }); + expect(recovered.events.some(e => e.model === "recovered-model")).toBe(true); + }); + it("refuses malformed or cross-thread history cursors before reading source data", async () => { + const { collector, harness } = makeCollector(twoThreadOverrides()); + await expect(collector.executionTimeline({ source: "bb", executionKey: "bb:x", threadId: "x", cursor: "not-a-cursor" })).rejects.toThrow("cursor is invalid"); + expect(harness.callsTo("threads.timeline")).toHaveLength(0); + }); + it("continues lifecycle history after the thread-row pages are exhausted", async () => { + const overrides = twoThreadOverrides(); + const event = (seq: number) => ({ id: `e-${seq}`, seq, createdAt: seq, type: "client/turn/requested", data: { execution: { model: `model-${seq}` } } }); + (overrides.threads as Record).timeline = async () => timelineFixture(); + (overrides.threads as Record).events = { list: async (input: { beforeSeq?: string }) => input.beforeSeq ? [event(1)] : Array.from({ length: 80 }, (_, i) => event(i + 100)) }; + const { collector } = makeCollector(overrides); + const target = { source: "bb" as const, executionKey: "bb:thr_root", threadId: "thr_root" }; + const first = await collector.executionTimeline(target); + const second = await collector.executionTimeline({ ...target, cursor: first.olderCursor! }); + const third = await collector.executionTimeline({ ...target, cursor: second.olderCursor! }); + expect(new Set([...first.events, ...second.events, ...third.events].map(e => e.id)).size).toBe(81); + expect(third.olderCursor).toBeNull(); + }); + it("drains a normalized page before advancing the public BB history cursor", async () => { + const overrides = twoThreadOverrides(); + (overrides.threads as Record).timeline = async (input: TimelineArgsLike) => { + if (input.beforeAnchorId) return timelineFixture({ rows: [delegationRow("older", { id: "old", childRef: null, status: "pending", startedAt: 1, createdAt: 1 })], timelinePage: { hasOlderRows: false, olderCursor: null } }); + return timelineFixture({ rows: Array.from({ length: 110 }, (_, i) => delegationRow(String(i), { id: `new-${i}`, childRef: null, status: "pending", startedAt: i + 100, createdAt: i + 100 })), timelinePage: { hasOlderRows: true, olderCursor: { anchorId: "anchor", anchorSeq: 100 } } }); + }; + const { collector, harness } = makeCollector(overrides); + const target = { source: "bb" as const, executionKey: "bb:thr_root", threadId: "thr_root" }; + const first = await collector.executionTimeline(target); + expect(first.olderCursor).toBeTruthy(); + const second = await collector.executionTimeline({ ...target, cursor: first.olderCursor! }); + const third = await collector.executionTimeline({ ...target, cursor: second.olderCursor! }); + const fourth = await collector.executionTimeline({ ...target, cursor: third.olderCursor! }); + expect(new Set([...first.events, ...second.events, ...third.events, ...fourth.events].map(e => e.id)).size).toBeGreaterThanOrEqual(111); + expect(harness.callsTo("threads.timeline").at(-1)?.[0]).toMatchObject({ beforeAnchorId: "anchor", beforeAnchorSeq: "100" }); + expect(fourth.olderCursor).toBeNull(); + }); it("adds no collection calls until requested and uses one BB timeline plus one lifecycle tail", async () => { const { collector, harness } = makeCollector(twoThreadOverrides()); expect(harness.calls).toHaveLength(0); diff --git a/tests/dashboard-preferences.test.ts b/tests/dashboard-preferences.test.ts new file mode 100644 index 0000000..2e268b8 --- /dev/null +++ b/tests/dashboard-preferences.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_PREFERENCES, parsePreferences, readPreferences, saveView, writePreferences, applyWindow, type DashboardPreferences } from "../lib/dashboard-preferences"; +import { phaseCard as card, phaseExecution as execution } from "./vnext-fixtures"; + +describe("bounded dashboard preferences", () => { + it("recovers from invalid data or blocked browser storage", () => { + expect(parsePreferences("bad")).toEqual(DEFAULT_PREFERENCES); + expect(parsePreferences('{"filters":{"project":42}}')).toEqual(DEFAULT_PREFERENCES); + const blocked = { getItem() { throw Error("blocked"); }, setItem() { throw Error("blocked"); } }; + expect(readPreferences(blocked)).toEqual(DEFAULT_PREFERENCES); + expect(writePreferences(DEFAULT_PREFERENCES, blocked)).toBe(false); + }); + it("round trips filters/grouping/time windows and bounds saved views", () => { + let value: DashboardPreferences = { ...DEFAULT_PREFERENCES, filters: { ...DEFAULT_PREFERENCES.filters, project: "value:p" }, grouping: "project", window: "day" }; + for (let i = 0; i < 10; i++) value = saveView(value, `View ${i}`); + expect(value.saved).toHaveLength(8); + expect(parsePreferences(JSON.stringify(value))).toEqual(value); + expect(saveView(value, " ")).toEqual(value); + }); + it("time windows use completion/activity time and keep ongoing work", () => { + const running = execution("active", { card: card({ status: "running", startedAt: 1 }) }); + const old = execution("old", { updatedAt: 2, card: card({ status: "completed", completedAt: 1 }) }); + expect(applyWindow([running, old], "day", 100_000_000)).toEqual([running]); + }); +}); diff --git a/tests/dashboard-view.test.ts b/tests/dashboard-view.test.ts index c4f577f..1233985 100644 --- a/tests/dashboard-view.test.ts +++ b/tests/dashboard-view.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { activityFreshness, EMPTY_FILTERS, filterDashboard, filterOptions, matchesFilters, groupExecutions, completionSummary, reportedCompletion } from "../lib/dashboard-view"; +import { activityFreshness, EMPTY_FILTERS, filterDashboard, filterOptions, matchesFilters, groupExecutions, completionSummary, reportedCompletion, resultLinks } from "../lib/dashboard-view"; import { buildGlobalExecution } from "../lib/global-model"; import { safeRepositoryLabel } from "../lib/collect"; import { phaseCard as card, phaseExecution as execution, phaseSnapshot as snapshot } from "./vnext-fixtures"; @@ -46,6 +46,14 @@ describe("bounded filtering and grouping", () => { }); }); describe("completion provenance and repository privacy", () => { + it("retains bounded review links while refusing credentials and non-HTTPS targets", () => { + expect(resultLinks("[PR](https://github.com/acme/repo/pull/1) [bad](javascript:alert) [secret](https://user:password@example.com/a) [local](/tmp/out.txt)")).toEqual([{ label: "PR", url: "https://github.com/acme/repo/pull/1" }]); + }); + it("does not expose credential parameters through result links", () => { + for (const parameter of ["access_token", "api-key", "auth%5Ftoken", "password", "secret", "X-Amz-Signature"]) { + expect(resultLinks(`[Artifact](https://example.com/report?${parameter}=private-value)`)).toEqual([]); + } + }); it("omits absent completion metadata on legacy adapters and preserves their preview fallback", () => { const e = buildGlobalExecution({ key: "legacy", source: "bb", title: "legacy", threadId: "legacy", projectId: null, updatedAt: 1, cards: [card({ outputPreview: "Tests: not run", status: "completed" })] }); expect(Object.hasOwn(e.card, "reportedVerification")).toBe(false); diff --git a/tests/global-app.test.tsx b/tests/global-app.test.tsx index b92e9d0..e0242ff 100644 --- a/tests/global-app.test.tsx +++ b/tests/global-app.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { readFileSync } from "node:fs"; +import { cleanup, fireEvent } from "@testing-library/react"; import { loadPluginApp, renderSlot, type CapturedPluginApp, type RenderedSlot } from "@get-bb/plugin-sdk/testing/app"; import type { AttentionItem, BoardCard, ExecutionTimeline, GlobalDashboardSnapshot, GlobalExecution, TimelineEvent } from "@/contract/rpc"; import { BOARD_CHANGED_CHANNEL } from "@/contract/rpc"; @@ -14,8 +15,9 @@ const LONG_ERROR = `Provider error — 400: {"message":"${LONG_TOKEN}","document let app: CapturedPluginApp; -beforeEach(async () => { app = await loadPluginApp(() => import("../app")); }); +beforeEach(async () => { window.localStorage.clear(); app = await loadPluginApp(() => import("../app")); }); afterEach(() => { + cleanup(); vi.useRealTimers(); delete (window as { matchMedia?: typeof window.matchMedia }).matchMedia; }); @@ -72,6 +74,57 @@ describe("global page registration", () => { }); describe("global operations rendering", () => { + it("labels native attention actions and exposes failure inspection", async () => { + const item = attention({ interactionKind: "question" }); + const slot = mountGlobal({ rpc: { global_dashboard: () => snapshot({ attention: [item] }) } }); + (await slot.findByRole("button", { name: "Reply to Auth refactor" })).click(); + expect(slot.inspection.navigateCalls).toContainEqual({ method: "toThread", threadId: "thr_auth" }); + slot.unmount(); + }); + it("keeps healthy diagnostics and filters collapsed while remembering a saved view", async () => { + const run = execution("bb:auth", { projectId: "p1", projectName: "Project One" }); + const options = { rpc: { global_dashboard: () => snapshot({ active: [run] }) } }; + const slot = mountGlobal(options); + await slot.findByText("Auth refactor"); + expect(slot.container.querySelector('[data-part="monitoring-details"]')?.hasAttribute("open")).toBe(false); + fireEvent.change(slot.getByLabelText("Filter by project"), { target: { value: "value:p1" } }); + fireEvent.change(slot.getByLabelText("Saved view name"), { target: { value: "My project" } }); + slot.getByRole("button", { name: "Save view" }).click(); + await slot.findByRole("option", { name: "My project" }); + slot.unmount(); + const next = mountGlobal(options); + await next.findByText("Auth refactor"); + expect((next.getByLabelText("Filter by project") as HTMLSelectElement).value).toBe("value:p1"); + expect(next.getByRole("option", { name: "My project" })).toBeTruthy(); + next.unmount(); + }); + it("expands observed child activity without inventing its model or thread", async () => { + const run = execution("bb:auth", { children: [boardCard({ key: "native", title: "Review tests", kind: "delegation", threadId: null, ownerThreadId: "thr_auth", model: null, activity: { text: "Command: npm test", at: 100, kind: "command" } })] }); + const slot = mountGlobal({ rpc: { global_dashboard: () => snapshot({ active: [run] }) } }); + await slot.findByText("Auth refactor"); + fireEvent.click(slot.getByText(/Observed agents/)); + expect(slot.getByText("Review tests")).toBeTruthy(); + expect(slot.getByText(/Model unavailable/)).toBeTruthy(); + expect(slot.getByText("Command: npm test")).toBeTruthy(); + slot.unmount(); + }); + it("opens a native subagent timeline using its owner through the shared target helper", async () => { + const native = boardCard({ kind: "delegation", title: "Native review", threadId: null, ownerThreadId: "thr_auth" }); + const slot = mountGlobal({ rpc: { global_dashboard: () => snapshot({ active: [execution("native", { card: native })] }), execution_timeline: () => executionTimeline({ events: [] }) } }); + (await slot.findByRole("button", { name: "Open timeline for Native review" })).click(); + await slot.findByText("No public operational events are available."); + expect(slot.inspection.rpcCalls.find(call => call.method === "execution_timeline")?.input).toMatchObject({ threadId: "thr_auth" }); + slot.unmount(); + }); + it("separates finished turns from independently verified work", async () => { + const recent = execution("bb:done", { card: boardCard({ status: "completed", outputPreview: "Done", review: { objective: "Fix auth", goalStatus: null, changedPaths: ["src/auth.ts"] } }) }); + const slot = mountGlobal({ rpc: { global_dashboard: () => snapshot({ active: [], recent: [recent] }) } }); + expect(await slot.findByText("Turn finished — review needed")).toBeTruthy(); + expect(slot.getByText(/Fix auth/)).toBeTruthy(); + expect(slot.getByText("src/auth.ts")).toBeTruthy(); + expect(slot.getByRole("button", { name: "Review Auth refactor" })).toBeTruthy(); + slot.unmount(); + }); it("keeps the box-sizing invariant local instead of hiding page overflow", () => { const css = readFileSync("app.css", "utf8"); expect(css).toMatch(/\.ab-global-dashboard[\s\S]*box-sizing:\s*border-box/); @@ -101,7 +154,7 @@ describe("global operations rendering", () => { const item = attention(); const slot = mountGlobal({ rpc: { global_dashboard: () => snapshot({ attention: [item], counts: { active: 1, waiting: 0, attention: 1, recent: 0, failures: 0 } }) } }); expect(await slot.findByText("Approval required")).toBeTruthy(); - slot.getAllByLabelText("Open Auth refactor")[0]!.click(); + slot.getAllByLabelText("Respond Auth refactor")[0]!.click(); expect(slot.inspection.navigateCalls).toContainEqual({ method: "toThread", threadId: "thr_auth" }); slot.unmount(); }); @@ -218,19 +271,21 @@ describe("global operations rendering", () => { expect(slot.getByTestId("kanban-live-indicator").textContent).toContain("Live"); expect(slot.getByText("Failed")).toBeTruthy(); - expect(slot.getAllByRole("button", { name: `Open ${longTitle}` })).toHaveLength(3); + expect(slot.getAllByRole("button", { name: `Open ${longTitle}` })).toHaveLength(1); + expect(slot.getByRole("button", { name: `Inspect failure ${longTitle}` })).toBeTruthy(); + expect(slot.getByRole("button", { name: `Review ${longTitle}` })).toBeTruthy(); expect(slot.getByRole("button", { name: `Dismiss ${longTitle}` })).toBeTruthy(); expect(slot.getByRole("button", { name: /Clear dismissed \(1\)/ })).toBeTruthy(); - expect(slot.getAllByRole("button", { name: `Open timeline for ${longTitle}` })).toHaveLength(2); + expect(slot.getAllByRole("button", { name: `Open timeline for ${longTitle}` })).toHaveLength(3); slot.unmount(); }); - it("keeps the desktop two-column active grid while using zero-minimum tracks", async () => { + it("keeps compact active rows on zero-minimum tracks", async () => { const slot = mountGlobal({ rpc: { global_dashboard: () => snapshot({ active: [execution("one"), execution("two")] }) } }); await slot.findAllByText("Auth refactor"); const activeGrid = slot.container.querySelector("[data-global-grid='active']"); expect(activeGrid?.className).toContain("grid-cols-[minmax(0,1fr)]"); - expect(activeGrid?.className).toContain("md:grid-cols-[repeat(2,minmax(0,1fr))]"); + expect(activeGrid?.className).not.toContain("md:grid-cols-[repeat(2,minmax(0,1fr))]"); slot.unmount(); }); }); @@ -239,25 +294,27 @@ describe("global refresh behavior", () => { it("refetches on a global invalidation but ignores a scoped-only signal", async () => { const slot = mountGlobal({ rpc: { global_dashboard: () => snapshot() } }); await slot.findByText("Auth refactor"); - expect(slot.inspection.rpcCalls.filter(call => call.method !== "release_announcement")).toHaveLength(1); + expect(slot.inspection.rpcCalls.filter(call => !["release_announcement", "release_update"].includes(call.method))).toHaveLength(1); await slot.behavior.emitRealtime(BOARD_CHANGED_CHANNEL, { roots: ["thr_other"] }); - expect(slot.inspection.rpcCalls.filter(call => call.method !== "release_announcement")).toHaveLength(1); + expect(slot.inspection.rpcCalls.filter(call => !["release_announcement", "release_update"].includes(call.method))).toHaveLength(1); await slot.behavior.emitRealtime(BOARD_CHANGED_CHANNEL, { roots: [], global: true }); - expect(slot.inspection.rpcCalls.filter(call => call.method !== "release_announcement")).toHaveLength(2); + expect(slot.inspection.rpcCalls.filter(call => !["release_announcement", "release_update"].includes(call.method))).toHaveLength(2); slot.unmount(); }); it("uses one slow discovery timer and stops it on unmount", async () => { vi.useFakeTimers(); const slot = mountGlobal({ rpc: { global_dashboard: () => snapshot() } }); - await vi.runOnlyPendingTimersAsync(); - const initial = slot.inspection.rpcCalls.filter(call => call.method !== "release_announcement").length; + // Flush initial work without advancing to unrelated hourly update timers. + await vi.advanceTimersByTimeAsync(0); + const initial = slot.inspection.rpcCalls.filter(call => !["release_announcement", "release_update"].includes(call.method)).length; + expect(initial).toBe(1); await vi.advanceTimersByTimeAsync(GLOBAL_DISCOVERY_INTERVAL_MS); - expect(slot.inspection.rpcCalls.filter(call => call.method !== "release_announcement").length).toBeGreaterThan(initial); - const beforeUnmount = slot.inspection.rpcCalls.filter(call => call.method !== "release_announcement").length; + expect(slot.inspection.rpcCalls.filter(call => !["release_announcement", "release_update"].includes(call.method)).length).toBe(initial + 1); + const beforeUnmount = slot.inspection.rpcCalls.filter(call => !["release_announcement", "release_update"].includes(call.method)).length; slot.unmount(); await vi.advanceTimersByTimeAsync(GLOBAL_DISCOVERY_INTERVAL_MS * 2); - expect(slot.inspection.rpcCalls.filter(call => call.method !== "release_announcement")).toHaveLength(beforeUnmount); + expect(slot.inspection.rpcCalls.filter(call => !["release_announcement", "release_update"].includes(call.method))).toHaveLength(beforeUnmount); }); }); @@ -278,7 +335,7 @@ describe("terminal-state self-correction in the hook", () => { }, }); // The first fetch is in flight; a terminal signal arrives mid-flight. - expect(slot.inspection.rpcCalls.filter(call => call.method !== "release_announcement")).toHaveLength(1); + expect(slot.inspection.rpcCalls.filter(call => !["release_announcement", "release_update"].includes(call.method))).toHaveLength(1); await slot.behavior.emitRealtime(BOARD_CHANGED_CHANNEL, { roots: [], global: true }); await slot.behavior.emitRealtime(BOARD_CHANGED_CHANNEL, { roots: [], global: true }); await slot.behavior.emitRealtime(BOARD_CHANGED_CHANNEL, { roots: [], global: true }); @@ -289,13 +346,13 @@ describe("terminal-state self-correction in the hook", () => { // The in-flight fetch settled, then exactly ONE trailing refetch ran — // the signal burst collapsed into a single retry, not a storm. expect(calls).toBe(2); - expect(slot.inspection.rpcCalls.filter(call => call.method !== "release_announcement")).toHaveLength(2); + expect(slot.inspection.rpcCalls.filter(call => !["release_announcement", "release_update"].includes(call.method))).toHaveLength(2); // Signals after settle go back to the normal immediate path. await slot.behavior.emitRealtime(BOARD_CHANGED_CHANNEL, { roots: [], global: true }); await new Promise((resolve) => setTimeout(resolve, 25)); expect(calls).toBe(3); - expect(slot.inspection.rpcCalls.filter(call => call.method !== "release_announcement")).toHaveLength(3); // no retry loop + expect(slot.inspection.rpcCalls.filter(call => !["release_announcement", "release_update"].includes(call.method))).toHaveLength(3); // no retry loop slot.unmount(); }); }); @@ -336,6 +393,45 @@ describe("latestPublicUpdate excerpt rendering", () => { }); describe("lazy shared execution timeline", () => { + it("fills a disjoint live-refresh gap before resuming the previous older history", async () => { + let refreshed = false; + const history = vi.fn(async (raw: unknown) => { + const input = raw as { cursor?: string }; + if (input.cursor === "bridge") return executionTimeline({ active: true, olderCursor: "bridge-next", events: [timelineEvent("old", 100), timelineEvent("gap", 200, { title: "Missed during reconnect" })] }); + return executionTimeline({ active: true, olderCursor: refreshed ? "bridge" : "older-than-old", events: [refreshed ? timelineEvent("new", 300, { title: "New after reconnect" }) : timelineEvent("old", 100)] }); + }); + const slot = mountGlobal({ rpc: { global_dashboard: () => snapshot(), execution_timeline: history } }); + (await slot.findByRole("button", { name: "Open timeline for Auth refactor" })).click(); + await slot.findByText("Execution started"); + refreshed = true; await slot.behavior.emitRealtime(BOARD_CHANGED_CHANNEL, { roots: [], global: true }); + await slot.findByText("New after reconnect"); + fireEvent.click(slot.getByRole("button", { name: "Load earlier activity" })); + await slot.findByText("Missed during reconnect"); + expect(history).toHaveBeenLastCalledWith({ source: "bb", executionKey: "bb:auth", threadId: "thr_auth", cursor: "bridge" }); + slot.unmount(); + }); + it("loads earlier history, keeps it during live refresh, and retries without losing the list", async () => { + let fail = true; + const history = vi.fn(async (raw: unknown) => { + const input = raw as { cursor?: string }; + if (input.cursor && fail) throw new Error("temporary history failure"); + return input.cursor + ? executionTimeline({ active: true, olderCursor: null, events: [timelineEvent("old", 10, { title: "Earlier command" }), timelineEvent("start", 100)] }) + : executionTimeline({ active: true, olderCursor: "older-page", truncated: true, events: [timelineEvent("start", 100), timelineEvent("new", 200, { title: "Latest command" })] }); + }); + const slot = mountGlobal({ rpc: { global_dashboard: () => snapshot(), execution_timeline: history } }); + (await slot.findByRole("button", { name: "Open timeline for Auth refactor" })).click(); + fireEvent.click(await slot.findByRole("button", { name: "Load earlier activity" })); + await slot.findByText(/temporary history failure/); + expect(slot.getByText("Latest command")).toBeTruthy(); + fail = false; + fireEvent.click(slot.getByRole("button", { name: "Load earlier activity" })); + await slot.findByText("Earlier command"); + await slot.behavior.emitRealtime(BOARD_CHANGED_CHANNEL, { roots: [], global: true }); + expect(slot.getByText("Earlier command")).toBeTruthy(); + expect(document.querySelectorAll('[data-event-kind]').length).toBe(3); + slot.unmount(); + }); it("adds zero initial requests, then renders chronological operational events from one timeline RPC", async () => { const rpcTimeline = vi.fn(async () => executionTimeline({ events: [ @@ -348,7 +444,7 @@ describe("lazy shared execution timeline", () => { })); const slot = mountGlobal({ rpc: { global_dashboard: () => snapshot(), execution_timeline: rpcTimeline } }); await slot.findByText("Auth refactor"); - expect(slot.inspection.rpcCalls.filter(call => call.method !== "release_announcement")).toHaveLength(1); + expect(slot.inspection.rpcCalls.filter(call => !["release_announcement", "release_update"].includes(call.method))).toHaveLength(1); expect(rpcTimeline).not.toHaveBeenCalled(); const trigger = slot.getByRole("button", { name: "Open timeline for Auth refactor" }); @@ -357,7 +453,7 @@ describe("lazy shared execution timeline", () => { trigger.click(); expect(await slot.findByText("The collector remains bounded.")).toBeTruthy(); expect(rpcTimeline).toHaveBeenCalledTimes(1); - expect(slot.inspection.rpcCalls.filter(call => call.method !== "release_announcement").filter((call) => call.method === "execution_timeline")).toHaveLength(1); + expect(slot.inspection.rpcCalls.filter(call => !["release_announcement", "release_update"].includes(call.method)).filter((call) => call.method === "execution_timeline")).toHaveLength(1); const rows = Array.from(document.querySelectorAll("[data-event-kind]")); expect(rows.map((row) => row.getAttribute("data-event-kind"))).toEqual(["execution-start", "model-selected", "tool-complete", "public-update", "execution-complete"]); expect(document.body.textContent).toContain("read_file · src/auth.ts · 1s"); @@ -480,7 +576,7 @@ describe("attention dismissal UI", () => { type: item.type, updatedAt: item.updatedAt, }); - expect(slot.inspection.rpcCalls.filter(call => call.method !== "release_announcement").filter((call) => call.method === "global_dashboard")).toHaveLength(2); + expect(slot.inspection.rpcCalls.filter(call => !["release_announcement", "release_update"].includes(call.method)).filter((call) => call.method === "global_dashboard")).toHaveLength(2); slot.unmount(); }); diff --git a/tests/global-collect.test.ts b/tests/global-collect.test.ts index ce99cde..6ae6910 100644 --- a/tests/global-collect.test.ts +++ b/tests/global-collect.test.ts @@ -47,7 +47,7 @@ function makeCollector(options: { }), timeline: vi.fn(async ({ threadId }: { threadId: string }) => timeline({ rows: threadId.includes("active") || threadId === "thr_child" ? [activeWork()] : [] })), defaultExecutionOptions: vi.fn(async ({ threadId }: { threadId: string }) => threadId === "thr_nomodel" ? null : { model: "qwen3:8b" }), - output: vi.fn(async () => ({ output: "Completed safely" })), + output: vi.fn(async (_input: { threadId: string }) => ({ output: "Completed safely" })), events: { list: vi.fn(async () => [{ id: "e", scope: "thread", threadId: "x", seq: 1, createdAt: 900, type: "system/error", data: { message: "Provisioning failed" } }]) }, queuedMessages: { list: vi.fn(async () => []) }, }, @@ -62,6 +62,13 @@ function makeCollector(options: { } describe("global BB collection", () => { + it("loads the parent review result after a newer child finishes", async () => { + const { collector, sdk } = makeCollector({ rows: [row("root", { updatedAt: 800 }), row("child", { parentThreadId: "root", updatedAt: 1000 })] }); + sdk.threads.output.mockImplementation(async (input: { threadId: string }) => ({ output: input.threadId === "root" ? "Parent result" : "Child result" })); + const result = await collector.globalDashboard(); + expect(result.recent[0]?.card.outputPreview).toBe("Parent result"); + expect(result.recent[0]?.card.reviewThreadId).toBe("root"); + }); it("enriches project metadata once per project and strips remote credentials", async () => { const { collector, sdk } = makeCollector({ rows: [row("a", { status: "active" }), row("b", { status: "active" })], project: async () => ({ name: "Project", gitRemoteUrl: "https://user:secret@github.com/example/repo.git?token=private" }) }); const result = await collector.globalDashboard(); @@ -90,7 +97,7 @@ describe("global BB collection", () => { expect(result.active[0]!.card.title).toBe("Parent operation"); expect(result.active[0]!.card.threadId).toBe("thr_child"); expect(result.active[0]!.card.model).toBe("qwen3:8b"); - expect(result.active[0]!.card.activity?.text).toBe("Reading src/auth.ts"); + expect(result.active[0]!.card.activity?.text).toBe("Read: src/auth.ts"); }); it("groups nested descendants without duplicate execution cards", () => { diff --git a/tests/global-model.test.ts b/tests/global-model.test.ts index c0101a6..753388c 100644 --- a/tests/global-model.test.ts +++ b/tests/global-model.test.ts @@ -60,6 +60,17 @@ function attention(type: AttentionType, message: string, overrides: Partial { + it("does not substitute a child result for an observed parent result", () => { + const root = card({ outputPreview: "Parent result", review: { objective: "Fix auth", goalStatus: null, changedPaths: [] } }); + const child = card({ key: "child", isRoot: false, model: "child-model", outputPreview: "Child result", activity: { text: "Worked", at: 200, kind: "tool" } }); + expect(buildGlobalExecution(group({ cards: [root, child] })).card.outputPreview).toBe("Parent result"); + }); + it("carries source attention timestamps and expandable observed children", () => { + const root = card({ status: "waiting", interaction: { kind: "question", at: 250 }, attention: { type: "action-required", message: "Choose target" } }); + const child = card({ key: "child", isRoot: false, parentKey: root.key, status: "completed" }); + expect(deriveAttentionItems([group({ cards: [root] })], 1000)[0]?.firstObservedAt).toBe(250); + expect(buildGlobalExecution(group({ cards: [root, child] })).children).toEqual([child]); + }); it("returns a truthful zero-work dashboard", () => { const result = buildGlobalDashboard([], { now: 1_000 }); expect(result.active).toEqual([]); diff --git a/tests/releases.test.ts b/tests/releases.test.ts index 818c58f..c267487 100644 --- a/tests/releases.test.ts +++ b/tests/releases.test.ts @@ -7,15 +7,15 @@ function storage(initial: unknown = undefined) { return { kv, values }; } describe("local versioned announcements", () => { - it("shows stable notes after 0.6.1 or an RC while preserving prior dismissals", async () => { - const previous = ["0.6.1", "0.6.2", "0.7.0-rc.1"]; + it("shows stable notes after 0.7.0 or an RC while preserving prior dismissals", async () => { + const previous = ["0.6.1", "0.6.2", "0.7.0-rc.1", "0.7.0"]; const { kv, values } = storage(previous); - expect(CURRENT_RELEASE.version).toBe("0.7.0"); + expect(CURRENT_RELEASE.version).toBe("0.7.1"); const store = new ReleaseAnnouncementStore(kv); expect(await store.snapshot()).toMatchObject({ version: CURRENT_RELEASE.version, dismissed: false }); await store.dismiss(CURRENT_RELEASE.version); expect((await new ReleaseAnnouncementStore(kv).snapshot()).dismissed).toBe(true); - expect(values.get("release-dismissals")).toEqual([...previous, "0.7.0"]); + expect(values.get("release-dismissals")).toEqual([...previous, "0.7.1"]); const rc = new ReleaseAnnouncementStore(kv, { ...CURRENT_RELEASE, version: "0.7.0-rc.1" }); expect((await rc.snapshot()).dismissed).toBe(true); }); diff --git a/tests/timeline-model.test.ts b/tests/timeline-model.test.ts index 2e43646..7e764d4 100644 --- a/tests/timeline-model.test.ts +++ b/tests/timeline-model.test.ts @@ -34,6 +34,16 @@ function normalize(rows: unknown[], lifecycleEvents: unknown[] = [], limit?: num } describe("BB execution timeline normalization", () => { + it("pages every event within a large source page, including identical timestamps", () => { + const rows = Array.from({ length: 130 }, (_, i) => base(`d-${i}`, 100, { sourceSeqEnd: i, kind: "work", workKind: "delegation", status: "pending", childRef: null, description: `Task ${i}` })); + const first = normalizeBbTimeline({ executionKey: "bb:thr", threadId: "thr", timeline: timeline(rows), limit: 50 }); + expect(first.nextBefore).toBeDefined(); + const second = normalizeBbTimeline({ executionKey: "bb:thr", threadId: "thr", timeline: timeline(rows), limit: 50, before: first.nextBefore }); + const third = normalizeBbTimeline({ executionKey: "bb:thr", threadId: "thr", timeline: timeline(rows), limit: 50, before: second.nextBefore }); + expect([first.events.length, second.events.length, third.events.length]).toEqual([50, 50, 30]); + expect(new Set([...first.events, ...second.events, ...third.events].map(e => e.id)).size).toBe(130); + expect(third.nextBefore).toBeUndefined(); + }); it.each([ ["completed", "execution-complete"], ["error", "execution-failed"], diff --git a/tests/update-app.test.tsx b/tests/update-app.test.tsx new file mode 100644 index 0000000..e9d24ea --- /dev/null +++ b/tests/update-app.test.tsx @@ -0,0 +1,44 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent } from "@testing-library/react"; +import { loadPluginApp, renderSlot, type CapturedPluginApp } from "@get-bb/plugin-sdk/testing/app"; +import type { UpdateAvailable as UpdateComponent } from "../components/global/update-available"; + +let app: CapturedPluginApp; +let UpdateAvailable: typeof UpdateComponent; +beforeEach(async () => { window.localStorage.clear(); app = await loadPluginApp(() => import("../app")); UpdateAvailable = (await import("../components/global/update-available")).UpdateAvailable; }); +afterEach(cleanup); +const state = { installed: "0.7.0", latest: "0.8.0", releaseUrl: "https://github.com/aaronphifer/bb-plugin-agent-board/releases/tag/v0.8.0", eligible: { version: "sha-08", display: "Agent Board v0.8.0" }, discoveryUnavailable: false }; +function mount(eligible = true) { + const install = vi.fn(async () => ({ applied: true, outcome: "updated", detail: null })); + const slot = renderSlot({ component: UpdateAvailable }, {}, { rpc: { release_update: () => ({ ...state, eligible: eligible ? state.eligible : null }), install_update: install } }); + return { slot, install }; +} +describe("update card", () => { + it("checks without installing and requires explicit confirmation", async () => { + const { slot, install } = mount(); + fireEvent.click(await slot.findByRole("button", { name: "Review update" })); + expect(install).not.toHaveBeenCalled(); + fireEvent.click(slot.getByRole("button", { name: "Cancel" })); + expect(install).not.toHaveBeenCalled(); + fireEvent.click(slot.getByRole("button", { name: "Review update" })); + fireEvent.click(slot.getByRole("button", { name: "Install update" })); + await slot.findByText(/Update installed/); + expect(install).toHaveBeenCalledWith({ expectedVersion: "sha-08" }); + }); + it("keeps out-of-range upgrades read-only and links directly to release notes", async () => { + const { slot, install } = mount(false); + fireEvent.click(await slot.findByRole("button", { name: "Release notes" })); + expect(slot.queryByRole("button", { name: "Review update" })).toBeNull(); + expect(install).not.toHaveBeenCalled(); + expect(slot.inspection.navigateCalls).toContainEqual({ method: "openUrl", url: state.releaseUrl }); + }); + it("keeps the dismissal for that release across remounts", async () => { + const first = mount(); + fireEvent.click(await first.slot.findByRole("button", { name: "Dismiss update notice" })); + first.slot.unmount(); + const second = mount(); + await new Promise(resolve => setTimeout(resolve, 20)); + expect(second.slot.queryByRole("region", { name: "Agent Board update available" })).toBeNull(); + }); +}); diff --git a/tests/update-check.test.ts b/tests/update-check.test.ts new file mode 100644 index 0000000..9e94e15 --- /dev/null +++ b/tests/update-check.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; +import { ReleaseUpdateChecker, newerStableVersion } from "../lib/update-check"; + +function checker(overrides: Record = {}) { + const fetchLatest = vi.fn(async () => ({ tag_name: "v0.8.0", draft: false, prerelease: false })); + const checkNative = vi.fn(async () => ({ outcome: "current" })); + const applyNative = vi.fn(async () => ({ applied: true, outcome: "updated" })); + return { fetchLatest, checkNative, applyNative, checker: new ReleaseUpdateChecker({ installed: "0.7.0", fetchLatest, checkNative, applyNative, now: () => 1000, ...overrides } as never) }; +} +describe("release awareness and supported updates", () => { + it("only accepts newer stable versions", () => { + expect(newerStableVersion("v0.8.0", "0.7.0")).toBe("0.8.0"); + for (const tag of ["v0.7.0", "v0.6.9", "v0.8.0-rc.1", "weird", "v0.8.0/path"]) expect(newerStableVersion(tag, "0.7.0")).toBeNull(); + }); + it("coalesces requests and advertises out-of-range releases without installing", async () => { + const c = checker(); + const results = await Promise.all([c.checker.snapshot(), c.checker.snapshot()]); + expect(results[0]).toMatchObject({ latest: "0.8.0", eligible: null, releaseUrl: "https://github.com/aaronphifer/bb-plugin-agent-board/releases/tag/v0.8.0" }); + expect(c.fetchLatest).toHaveBeenCalledTimes(1); + expect(c.applyNative).not.toHaveBeenCalled(); + }); + it("refuses an unreviewed or no-longer-eligible update", async () => { + const c = checker(); + await expect(c.checker.install("old-candidate")).rejects.toThrow(); + expect(c.applyNative).not.toHaveBeenCalled(); + }); + it("applies eligible updates through BB without removing the plugin", async () => { + const c = checker({ checkNative: async () => ({ outcome: "update-available", candidate: { version: "sha-08", display: "repo@v0.8.0" } }) }); + expect((await c.checker.snapshot()).eligible?.version).toBe("sha-08"); + await c.checker.install("sha-08"); + expect(c.applyNative).toHaveBeenCalledTimes(1); + }); + it("retains native eligibility if public release discovery is unavailable", async () => { + const c = checker({ fetchLatest: async () => { throw Error("rate limit"); }, checkNative: async () => ({ outcome: "update-available", candidate: { version: "sha-07", display: "repo@v0.7.1" } }) }); + expect(await c.checker.snapshot()).toMatchObject({ latest: null, eligible: { version: "sha-07" }, discoveryUnavailable: true }); + }); +}); diff --git a/tests/vnext-app.test.tsx b/tests/vnext-app.test.tsx index 26bb897..2d8000a 100644 --- a/tests/vnext-app.test.tsx +++ b/tests/vnext-app.test.tsx @@ -6,7 +6,7 @@ import { phaseCard, phaseExecution, phaseSnapshot } from "./vnext-fixtures"; import { CURRENT_RELEASE } from "../lib/releases"; import { BOARD_CHANGED_CHANNEL } from "../contract/rpc"; let app: CapturedPluginApp; -beforeEach(async () => { app = await loadPluginApp(() => import("../app")); }); +beforeEach(async () => { window.localStorage.clear(); app = await loadPluginApp(() => import("../app")); }); afterEach(() => { cleanup(); vi.useRealTimers(); }); function mount(options: Parameters[2] = {}) { return renderSlot({ component: app.navPanels[0]!.component }, { subPath: "" } as never, { ...options, rpc: { global_dashboard: () => phaseSnapshot(), release_announcement: () => ({ ...CURRENT_RELEASE, dismissed: true }), ...options.rpc } }); @@ -79,7 +79,7 @@ describe("Phase 1 dashboard interactions", () => { it("labels reported verification and retains source-output access", async () => { const done = phaseExecution("Done", { card: phaseCard({ title: "Done", status: "completed", column: "output", outputPreview: "Result: UI updated\nTests: 15 passed" }) }); const slot = mount({ rpc: { global_dashboard: () => phaseSnapshot({ active: [], recent: [done] }) } }); - await slot.findByText("Done"); expect(slot.getByText("Reported by the agent; not independently verified.")).toBeTruthy(); expect(slot.getByText("Original output preview")).toBeTruthy(); expect(slot.getByRole("button", { name: "Open Done" })).toBeTruthy(); slot.unmount(); + await slot.findByText("Done"); expect(slot.getByText("Reported by the agent; not independently verified.")).toBeTruthy(); expect(slot.getByText("Original output preview")).toBeTruthy(); expect(slot.getByRole("button", { name: "Review Done" })).toBeTruthy(); slot.unmount(); }); }); describe("release announcements in the dashboard", () => {