From 8c008a16a5cd5048a290f39546581f7da08a56f2 Mon Sep 17 00:00:00 2001 From: RainMona <316033127+RainMona@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:16:55 +0000 Subject: [PATCH 1/8] Stop labeling idle Evidence claims as RUNNING. Issue #13: a PAUSED desk still showed stage 04 as RUNNING whenever no claim had passed. Idle lanes are WAITING; RUNNING only while claims are in the Agent loop. (cherry picked from commit 917640a38426da4bc5142753307740192816dd0d) --- apps/studio/src/App.tsx | 3 +- .../src/lib/evidence-pipeline-stage.test.ts | 33 +++++++++++++++++++ .../studio/src/lib/evidence-pipeline-stage.ts | 13 ++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 apps/studio/src/lib/evidence-pipeline-stage.test.ts create mode 100644 apps/studio/src/lib/evidence-pipeline-stage.ts diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx index e5271375..189eba18 100644 --- a/apps/studio/src/App.tsx +++ b/apps/studio/src/App.tsx @@ -77,6 +77,7 @@ import { workspaceReadModel, type WorkspaceView, } from "@/lib/workspace-route"; +import { verifiedClaimsPipelineState } from "@/lib/evidence-pipeline-stage"; type View = WorkspaceView; type Opportunity = StudioProjection["opportunities"][number]; @@ -13502,7 +13503,7 @@ function EvidenceView({ label: "Verified claims", value: ruleEvidenceClaims.passedCount, detail: `${ruleEvidenceClaims.pendingCount + ruleEvidenceClaims.activeCount} in Agent loop · ${ruleEvidenceClaims.interruptedLeaseCount} interrupted`, - state: ruleEvidenceClaims.passedCount > 0 ? "INTERPRETED" : "RUNNING", + state: verifiedClaimsPipelineState(ruleEvidenceClaims), }, { step: "05", diff --git a/apps/studio/src/lib/evidence-pipeline-stage.test.ts b/apps/studio/src/lib/evidence-pipeline-stage.test.ts new file mode 100644 index 00000000..713eab8b --- /dev/null +++ b/apps/studio/src/lib/evidence-pipeline-stage.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { verifiedClaimsPipelineState } from "./evidence-pipeline-stage.js"; + +describe("verifiedClaimsPipelineState", () => { + it("is interpreted after a passed claim", () => { + expect(verifiedClaimsPipelineState({ + passedCount: 1, + pendingCount: 0, + activeCount: 0, + })).toBe("INTERPRETED"); + }); + + it("is running only while claims are in the Agent loop", () => { + expect(verifiedClaimsPipelineState({ + passedCount: 0, + pendingCount: 1, + activeCount: 0, + })).toBe("RUNNING"); + expect(verifiedClaimsPipelineState({ + passedCount: 0, + pendingCount: 0, + activeCount: 2, + })).toBe("RUNNING"); + }); + + it("is waiting when the lane is idle so PAUSED desks do not show RUNNING", () => { + expect(verifiedClaimsPipelineState({ + passedCount: 0, + pendingCount: 0, + activeCount: 0, + })).toBe("WAITING"); + }); +}); diff --git a/apps/studio/src/lib/evidence-pipeline-stage.ts b/apps/studio/src/lib/evidence-pipeline-stage.ts new file mode 100644 index 00000000..81918c7f --- /dev/null +++ b/apps/studio/src/lib/evidence-pipeline-stage.ts @@ -0,0 +1,13 @@ +export type VerifiedClaimsCounts = Readonly<{ + passedCount: number; + pendingCount: number; + activeCount: number; +}>; + +export function verifiedClaimsPipelineState( + claims: VerifiedClaimsCounts, +): "INTERPRETED" | "RUNNING" | "WAITING" { + if (claims.passedCount > 0) return "INTERPRETED"; + if (claims.pendingCount + claims.activeCount > 0) return "RUNNING"; + return "WAITING"; +} From a421225e45fd06960947d478807d90db63989e81 Mon Sep 17 00:00:00 2001 From: RainMona <316033127+RainMona@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:53:51 +0000 Subject: [PATCH 2/8] Name the Agents task counts so 150 / 0 / 128 are not one tile. Issue #20: the Tasks / runs headline was a slash pair, with a third "current" count underneath. Idle desks looked stalled. Headline is retained tasks; detail names runnable vs runs. (cherry picked from commit 646f0e5f9f7f20fb0a7457a4a5c6b9b6819ca633) --- apps/studio/src/App.tsx | 3 ++- .../src/lib/agent-task-run-tile.test.ts | 25 +++++++++++++++++++ apps/studio/src/lib/agent-task-run-tile.ts | 19 ++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 apps/studio/src/lib/agent-task-run-tile.test.ts create mode 100644 apps/studio/src/lib/agent-task-run-tile.ts diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx index 189eba18..75920c35 100644 --- a/apps/studio/src/App.tsx +++ b/apps/studio/src/App.tsx @@ -78,6 +78,7 @@ import { type WorkspaceView, } from "@/lib/workspace-route"; import { verifiedClaimsPipelineState } from "@/lib/evidence-pipeline-stage"; +import { agentTaskRunTile } from "@/lib/agent-task-run-tile"; type View = WorkspaceView; type Opportunity = StudioProjection["opportunities"][number]; @@ -4620,7 +4621,7 @@ function AgentOperationsView() {
- +
diff --git a/apps/studio/src/lib/agent-task-run-tile.test.ts b/apps/studio/src/lib/agent-task-run-tile.test.ts new file mode 100644 index 00000000..062ace71 --- /dev/null +++ b/apps/studio/src/lib/agent-task-run-tile.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { agentTaskRunTile } from "./agent-task-run-tile.js"; + +describe("agentTaskRunTile", () => { + it("puts retained tasks in the headline and names the other two counts", () => { + expect(agentTaskRunTile({ + taskCount: 150, + runCount: 0, + runnableCount: 128, + })).toEqual({ + label: "Tasks", + value: "150", + detail: "128 runnable · 0 runs", + }); + }); + + it("does not put a slash pair in the value so idle desks do not look stalled", () => { + const tile = agentTaskRunTile({ + taskCount: 150, + runCount: 0, + runnableCount: 128, + }); + expect(tile.value.includes("/")).toBe(false); + }); +}); diff --git a/apps/studio/src/lib/agent-task-run-tile.ts b/apps/studio/src/lib/agent-task-run-tile.ts new file mode 100644 index 00000000..6915a0f0 --- /dev/null +++ b/apps/studio/src/lib/agent-task-run-tile.ts @@ -0,0 +1,19 @@ +export type AgentTaskRunCounts = Readonly<{ + taskCount: number; + runCount: number; + runnableCount: number; +}>; + +export type AgentTaskRunTile = Readonly<{ + label: string; + value: string; + detail: string; +}>; + +export function agentTaskRunTile(counts: AgentTaskRunCounts): AgentTaskRunTile { + return { + label: "Tasks", + value: String(counts.taskCount), + detail: `${counts.runnableCount} runnable · ${counts.runCount} runs`, + }; +} From 8de8d32647b9d5a32273933a74be6d33041438cf Mon Sep 17 00:00:00 2001 From: RainMona <316033127+RainMona@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:04:31 +0000 Subject: [PATCH 3/8] Name the books tile from the venue sessions on screen. Issue #22: Qualified books 4 still said "three public transports", and the session list read as status rather than a picker. Detail is now N venue sessions; the list heading is "Select a venue session" and the selected row is labeled Showing. (cherry picked from commit 1d6714150d6b874cdee7fc8ec9fb3217b821349f) --- apps/studio/src/App.tsx | 57 ++++++++++++---------- apps/studio/src/index.css | 7 +++ apps/studio/src/lib/book-desk-copy.test.ts | 44 +++++++++++++++++ apps/studio/src/lib/book-desk-copy.ts | 23 +++++++++ 4 files changed, 105 insertions(+), 26 deletions(-) create mode 100644 apps/studio/src/lib/book-desk-copy.test.ts create mode 100644 apps/studio/src/lib/book-desk-copy.ts diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx index 75920c35..c0d49ca3 100644 --- a/apps/studio/src/App.tsx +++ b/apps/studio/src/App.tsx @@ -71,6 +71,11 @@ import { type StandingRouteUsage, } from "@/data/standing-routes"; import { cn } from "@/lib/utils"; +import { + qualifiedBooksTile, + selectedVenueSessionLabel, + VENUE_SESSION_PICKER_HEADING, +} from "@/lib/book-desk-copy"; import { parseWorkspaceRoute, serializeWorkspaceRoute, @@ -12886,11 +12891,7 @@ function BookDeskView() {
- +
- Venue sessions + {VENUE_SESSION_PICKER_HEADING} SSE linked
- {studioProjection.bookDesk.books.map((book) => ( - - ))} + {studioProjection.bookDesk.books.map((book) => { + const selected = selectedBook?.bookId === book.bookId; + const showingLabel = selectedVenueSessionLabel(selected); + return ( + + ); + })}
{selectedBook && ( diff --git a/apps/studio/src/index.css b/apps/studio/src/index.css index 21281c5f..3d9ebaf9 100644 --- a/apps/studio/src/index.css +++ b/apps/studio/src/index.css @@ -4162,6 +4162,13 @@ main { font-size: 13px; } +.book-session-showing { + color: var(--primary); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; +} + .book-session > div span, .book-session small { overflow: hidden; diff --git a/apps/studio/src/lib/book-desk-copy.test.ts b/apps/studio/src/lib/book-desk-copy.test.ts new file mode 100644 index 00000000..3784d4a2 --- /dev/null +++ b/apps/studio/src/lib/book-desk-copy.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { + qualifiedBooksTile, + selectedVenueSessionLabel, + VENUE_SESSION_PICKER_HEADING, +} from "./book-desk-copy.js"; + +const fourVenueSessions = [ + { bookId: "gemini-predictions:yes" }, + { bookId: "limitless:yes" }, + { bookId: "polymarket-global:yes" }, + { bookId: "polymarket-us:yes" }, +] as const; + +describe("qualifiedBooksTile", () => { + it("names the qualified-books tile from the sessions on screen", () => { + expect(qualifiedBooksTile(fourVenueSessions)).toEqual({ + label: "Qualified books", + value: "4", + detail: "4 venue sessions", + }); + }); + + it("does not hardcode a three-transport count", () => { + expect(qualifiedBooksTile([{ bookId: "gemini-predictions:yes" }])).toEqual({ + label: "Qualified books", + value: "1", + detail: "1 venue session", + }); + expect(qualifiedBooksTile([])).toEqual({ + label: "Qualified books", + value: "0", + detail: "0 venue sessions", + }); + }); +}); + +describe("venue session picker copy", () => { + it("labels the list as a selector and the selected row as Showing", () => { + expect(VENUE_SESSION_PICKER_HEADING).toBe("Select a venue session"); + expect(selectedVenueSessionLabel(true)).toBe("Showing"); + expect(selectedVenueSessionLabel(false)).toBeUndefined(); + }); +}); diff --git a/apps/studio/src/lib/book-desk-copy.ts b/apps/studio/src/lib/book-desk-copy.ts new file mode 100644 index 00000000..a491c61a --- /dev/null +++ b/apps/studio/src/lib/book-desk-copy.ts @@ -0,0 +1,23 @@ +export type QualifiedBooksTile = Readonly<{ + label: string; + value: string; + detail: string; +}>; + +export const VENUE_SESSION_PICKER_HEADING = "Select a venue session"; +export const SELECTED_VENUE_SESSION_LABEL = "Showing"; + +export function qualifiedBooksTile( + books: readonly Readonly<{ bookId: string }>[], +): QualifiedBooksTile { + const count = books.length; + return { + label: "Qualified books", + value: String(count), + detail: count === 1 ? "1 venue session" : `${count} venue sessions`, + }; +} + +export function selectedVenueSessionLabel(selected: boolean): string | undefined { + return selected ? SELECTED_VENUE_SESSION_LABEL : undefined; +} From 7ebb011676a96ebfa9831ff248e443f95a524b3c Mon Sep 17 00:00:00 2001 From: RainMona <316033127+RainMona@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:26:58 +0000 Subject: [PATCH 4/8] Filter the command palette to existing projections Wire the Find anything query to the twelve navigation rows, show a no-match empty state, and keep last rows reachable with overflow scroll plus arrow/Enter. No new destinations or spend commands. (cherry picked from commit 9baea1417a3f3d2f4feb3e5b1f88004d47c3fbfd) --- apps/studio/src/App.tsx | 103 ++++++++++++++++---- apps/studio/src/index.css | 24 ++++- apps/studio/src/lib/command-palette.test.ts | 54 ++++++++++ apps/studio/src/lib/command-palette.ts | 22 +++++ 4 files changed, 183 insertions(+), 20 deletions(-) create mode 100644 apps/studio/src/lib/command-palette.test.ts create mode 100644 apps/studio/src/lib/command-palette.ts diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx index c0d49ca3..56a9a779 100644 --- a/apps/studio/src/App.tsx +++ b/apps/studio/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Activity, BadgeCheck, @@ -70,6 +70,10 @@ import { type StandingRouteState, type StandingRouteUsage, } from "@/data/standing-routes"; +import { + filterProjectionCommands, + stepCommandIndex, +} from "@/lib/command-palette"; import { cn } from "@/lib/utils"; import { qualifiedBooksTile, @@ -13975,7 +13979,33 @@ function CommandPalette({ onClose: () => void; onNavigate: (view: View) => void; }) { + const [query, setQuery] = useState(""); + const [selectedIndex, setSelectedIndex] = useState(0); + const resultsRef = useRef(null); + const matches = filterProjectionCommands(navigation, query); + + useEffect(() => { + if (!open) { + setQuery(""); + setSelectedIndex(0); + } + }, [open]); + + useEffect(() => { + resultsRef.current + ?.querySelector("[aria-selected='true']") + ?.scrollIntoView({ block: "nearest" }); + }, [selectedIndex, query]); + if (!open) return null; + + function activate(index: number): void { + const item = matches[index]; + if (item === undefined) return; + onNavigate(item.id); + onClose(); + } + return (
Available projections - {navigation.map((item) => { - const Icon = item.icon; - return ( - - ); - })} +
+ {matches.length === 0 ? ( +

+ No projections match that query. +

+ ) : ( + matches.map((item, index) => { + const Icon = item.icon; + return ( + + ); + }) + )} +
); diff --git a/apps/studio/src/index.css b/apps/studio/src/index.css index 3d9ebaf9..23508c96 100644 --- a/apps/studio/src/index.css +++ b/apps/studio/src/index.css @@ -4877,7 +4877,11 @@ footer span:first-child { .command-palette { position: relative; + display: flex; + flex-direction: column; width: min(540px, calc(100vw - 28px)); + /* Leave the last projection rows reachable instead of clipping them. */ + max-height: calc(100vh - min(18vh, 150px) - 24px); overflow: hidden; border: 1px solid #2b3437; border-radius: 13px; @@ -4927,7 +4931,20 @@ footer span:first-child { text-transform: uppercase; } -.command-palette > button:not(.command-scrim) { +.command-results { + min-height: 0; + overflow-y: auto; + padding-bottom: 6px; +} + +.command-empty { + color: #7b8580; + font-size: 13px; + margin: 0; + padding: 18px 15px 22px; +} + +.command-results > button { display: grid; width: calc(100% - 12px); height: 43px; @@ -4944,12 +4961,13 @@ footer span:first-child { text-align: left; } -.command-palette > button:not(.command-scrim):hover { +.command-results > button.is-active, +.command-results > button:hover { background: rgba(126, 240, 193, 0.07); color: var(--primary); } -.command-palette button small { +.command-results button small { color: #56605b; font-family: inherit; font-size: 12px; diff --git a/apps/studio/src/lib/command-palette.test.ts b/apps/studio/src/lib/command-palette.test.ts new file mode 100644 index 00000000..d1c14693 --- /dev/null +++ b/apps/studio/src/lib/command-palette.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { + filterProjectionCommands, + stepCommandIndex, +} from "./command-palette.js"; + +const projections = [ + { id: "archaeologist", label: "Discover" }, + { id: "scouts", label: "Findings" }, + { id: "budgets", label: "Failure budgets" }, + { id: "lifecycle", label: "Review queue" }, + { id: "preflight", label: "Preflight" }, + { id: "venues", label: "Markets" }, + { id: "evidence", label: "Evidence" }, + { id: "overview", label: "System overview" }, + { id: "agents", label: "Agent operations" }, + { id: "radar", label: "Similarity radar" }, + { id: "cases", label: "Research cases" }, + { id: "books", label: "Order books" }, +] as const; + +describe("command palette projection filter", () => { + it("keeps every existing projection when the query is empty", () => { + expect(filterProjectionCommands(projections, "")).toEqual(projections); + expect(filterProjectionCommands(projections, " ")).toEqual(projections); + }); + + it("filters visible projection labels as the operator types", () => { + expect(filterProjectionCommands(projections, "book").map((item) => item.id)) + .toEqual(["books"]); + expect(filterProjectionCommands(projections, "REVIEW").map((item) => item.label)) + .toEqual(["Review queue"]); + expect(filterProjectionCommands(projections, "radar")).toEqual([ + projections.find((item) => item.id === "radar"), + ]); + }); + + it("returns no destinations when nothing matches", () => { + expect(filterProjectionCommands(projections, "dispatch spend")).toEqual([]); + expect(filterProjectionCommands(projections, "zzz")).toEqual([]); + }); + + it("does not invent commands from internal view ids", () => { + expect(filterProjectionCommands(projections, "scouts")).toEqual([]); + expect(filterProjectionCommands(projections, "lifecycle")).toEqual([]); + }); + + it("wraps keyboard highlight across the filtered rows", () => { + expect(stepCommandIndex(12, 11, 1)).toBe(0); + expect(stepCommandIndex(12, 0, -1)).toBe(11); + expect(stepCommandIndex(1, 0, 1)).toBe(0); + expect(stepCommandIndex(0, 3, 1)).toBe(0); + }); +}); diff --git a/apps/studio/src/lib/command-palette.ts b/apps/studio/src/lib/command-palette.ts new file mode 100644 index 00000000..19ba79dc --- /dev/null +++ b/apps/studio/src/lib/command-palette.ts @@ -0,0 +1,22 @@ +export type ProjectionCommand = Readonly<{ + id: string; + label: string; +}>; + +export function filterProjectionCommands( + items: readonly T[], + query: string, +): readonly T[] { + const needle = query.trim().toLowerCase(); + if (needle.length === 0) return items; + return items.filter((item) => item.label.toLowerCase().includes(needle)); +} + +export function stepCommandIndex( + count: number, + current: number, + delta: 1 | -1, +): number { + if (count <= 0) return 0; + return (current + delta + count) % count; +} From 9dd9decd16d180fdb1d3db0aa6b6b241116d8850 Mon Sep 17 00:00:00 2001 From: RainMona <316033127+RainMona@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:27:58 +0000 Subject: [PATCH 5/8] Stop offering a green Explore next when the System scout cannot run. System overview now shares the same honesty rule as Discover/Findings: if Codex dispatch is blocked and no heuristic worker is ready, the primary action retargets to Agent operations. A remaining heuristic-only path stays clickable and is labeled as heuristic. (cherry picked from commit c3b536114880e05b83b5481714f5c2aaf674007f) --- apps/studio/src/App.tsx | 58 ++++++++++++---- .../src/lib/system-explore-next.test.ts | 68 +++++++++++++++++++ apps/studio/src/lib/system-explore-next.ts | 48 +++++++++++++ apps/studio/src/product-shell.css | 6 ++ 4 files changed, 165 insertions(+), 15 deletions(-) create mode 100644 apps/studio/src/lib/system-explore-next.test.ts create mode 100644 apps/studio/src/lib/system-explore-next.ts diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx index 56a9a779..264f5dc5 100644 --- a/apps/studio/src/App.tsx +++ b/apps/studio/src/App.tsx @@ -75,6 +75,7 @@ import { stepCommandIndex, } from "@/lib/command-palette"; import { cn } from "@/lib/utils"; +import { systemExploreNextAction } from "@/lib/system-explore-next"; import { qualifiedBooksTile, selectedVenueSessionLabel, @@ -5961,6 +5962,12 @@ function Overview({ }) { const studioProjection = useStudioProjection(); const catalogObservation = studioProjection.ai.catalogObservation; + const discoveryExecution = useDiscoveryExecutionCapability(); + const exploreNext = systemExploreNextAction({ + workers: studioProjection.ai.workers, + dispatchEligibility: + discoveryExecution.data?.capability.dispatchEligibility ?? null, + }); const [scoutStatus, setScoutStatus] = useState< "IDLE" | "RUNNING" | "DONE" | "RESTORED" | "FAILED" >("IDLE"); @@ -6078,22 +6085,43 @@ function Overview({

The scheduler chooses a fresh trailhead; the Agent forms claims after inspection.

- + {exploreNext.kind === "SCOUT" ? ( + + ) : ( + + )} + {exploreNext.kind === "NEEDS_SETUP" && ( +
+ + + Scout needs the existing Codex or heuristic session in{" "} + Agent operations. + +
+ )}
diff --git a/apps/studio/src/lib/system-explore-next.test.ts b/apps/studio/src/lib/system-explore-next.test.ts new file mode 100644 index 00000000..fccb43f2 --- /dev/null +++ b/apps/studio/src/lib/system-explore-next.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; + +import { systemExploreNextAction } from "./system-explore-next.js"; +import { serializeWorkspaceRoute } from "./workspace-route.js"; + +const heuristicReady = Object.freeze({ + kind: "HEURISTIC" as const, + status: "READY" as const, +}); +const modelNeedsKey = Object.freeze({ + kind: "MODEL" as const, + status: "NEEDS_KEY" as const, +}); +const modelReady = Object.freeze({ + kind: "MODEL" as const, + status: "READY" as const, +}); + +describe("system overview Explore next", () => { + it("keeps Explore next when the routed discovery profile can dispatch", () => { + expect(systemExploreNextAction({ + workers: [heuristicReady, modelReady], + dispatchEligibility: "ELIGIBLE", + })).toEqual({ kind: "SCOUT", label: "Explore next" }); + }); + + it("labels the remaining heuristic-only path when the model lane needs a key", () => { + expect(systemExploreNextAction({ + workers: [heuristicReady, modelNeedsKey], + dispatchEligibility: "BLOCKED", + })).toEqual({ kind: "SCOUT", label: "Explore next · heuristic" }); + expect(systemExploreNextAction({ + workers: [heuristicReady, modelNeedsKey], + dispatchEligibility: null, + })).toEqual({ kind: "SCOUT", label: "Explore next · heuristic" }); + }); + + it("does not keep a green Explore next when the model looks ready but cannot dispatch", () => { + expect(systemExploreNextAction({ + workers: [heuristicReady, modelReady], + dispatchEligibility: "BLOCKED", + })).toEqual({ + kind: "NEEDS_SETUP", + href: serializeWorkspaceRoute("agents"), + }); + expect(systemExploreNextAction({ + workers: [modelReady], + dispatchEligibility: null, + })).toEqual({ + kind: "NEEDS_SETUP", + href: "?view=agents", + }); + }); + + it("points at Agent operations when no honest scout path exists", () => { + expect(systemExploreNextAction({ + workers: [modelNeedsKey], + dispatchEligibility: "BLOCKED", + })).toEqual({ + kind: "NEEDS_SETUP", + href: "?view=agents", + }); + expect(systemExploreNextAction({ + workers: [], + dispatchEligibility: null, + }).kind).toBe("NEEDS_SETUP"); + }); +}); diff --git a/apps/studio/src/lib/system-explore-next.ts b/apps/studio/src/lib/system-explore-next.ts new file mode 100644 index 00000000..fd8d31ad --- /dev/null +++ b/apps/studio/src/lib/system-explore-next.ts @@ -0,0 +1,48 @@ +import { serializeWorkspaceRoute } from "./workspace-route.js"; + +export type SystemExploreWorker = Readonly<{ + kind: "HEURISTIC" | "MODEL"; + status: "READY" | "NEEDS_KEY" | "NEEDS_PROVIDER"; +}>; + +export type SystemExploreNextAction = Readonly< + | { kind: "SCOUT"; label: "Explore next" | "Explore next · heuristic" } + | { kind: "NEEDS_SETUP"; href: string } +>; + +export function systemExploreNextAction(input: { + readonly workers: readonly SystemExploreWorker[]; + readonly dispatchEligibility: "ELIGIBLE" | "BLOCKED" | null; +}): SystemExploreNextAction { + const heuristicReady = input.workers.some( + (worker) => worker.kind === "HEURISTIC" && worker.status === "READY", + ); + const modelReady = input.workers.some( + (worker) => worker.kind === "MODEL" && worker.status === "READY", + ); + + if (input.dispatchEligibility === "ELIGIBLE") { + return Object.freeze({ kind: "SCOUT", label: "Explore next" as const }); + } + + // A model worker can appear READY while Codex dispatch is still blocked. + // The lease refuses that path, so do not keep a green Explore next. + if (modelReady) { + return Object.freeze({ + kind: "NEEDS_SETUP", + href: serializeWorkspaceRoute("agents"), + }); + } + + if (heuristicReady) { + return Object.freeze({ + kind: "SCOUT", + label: "Explore next · heuristic" as const, + }); + } + + return Object.freeze({ + kind: "NEEDS_SETUP", + href: serializeWorkspaceRoute("agents"), + }); +} diff --git a/apps/studio/src/product-shell.css b/apps/studio/src/product-shell.css index 1f5a8278..f6ca459c 100644 --- a/apps/studio/src/product-shell.css +++ b/apps/studio/src/product-shell.css @@ -3604,6 +3604,12 @@ footer { font-size: 13px; } +.inline-alert a { + color: inherit; + font-weight: 650; + text-decoration: underline; +} + @media (max-width: 900px) { .agent-console-grid, .agent-control-form, From c517b5e0ba307cfeb8f4a09c5110395e857ac381 Mon Sep 17 00:00:00 2001 From: RainMona <316033127+RainMona@users.noreply.github.com> Date: Wed, 19 Aug 2026 07:18:15 +0000 Subject: [PATCH 6/8] Drop the fake Explore next heuristic branch on System overview. Search leases always requireDispatchEligible. A heuristic suffix still called runScout and bounced, so dispatch that is not ELIGIBLE now only offers Open Agent operations. (cherry picked from commit c3246657efb1b49a70619d90b4e80fe0aa6eeee1) --- apps/studio/src/App.tsx | 1 - .../src/lib/system-explore-next.test.ts | 45 +------------------ apps/studio/src/lib/system-explore-next.ts | 35 ++------------- 3 files changed, 6 insertions(+), 75 deletions(-) diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx index 264f5dc5..1e220a2d 100644 --- a/apps/studio/src/App.tsx +++ b/apps/studio/src/App.tsx @@ -5964,7 +5964,6 @@ function Overview({ const catalogObservation = studioProjection.ai.catalogObservation; const discoveryExecution = useDiscoveryExecutionCapability(); const exploreNext = systemExploreNextAction({ - workers: studioProjection.ai.workers, dispatchEligibility: discoveryExecution.data?.capability.dispatchEligibility ?? null, }); diff --git a/apps/studio/src/lib/system-explore-next.test.ts b/apps/studio/src/lib/system-explore-next.test.ts index fccb43f2..72525221 100644 --- a/apps/studio/src/lib/system-explore-next.test.ts +++ b/apps/studio/src/lib/system-explore-next.test.ts @@ -3,66 +3,25 @@ import { describe, expect, it } from "vitest"; import { systemExploreNextAction } from "./system-explore-next.js"; import { serializeWorkspaceRoute } from "./workspace-route.js"; -const heuristicReady = Object.freeze({ - kind: "HEURISTIC" as const, - status: "READY" as const, -}); -const modelNeedsKey = Object.freeze({ - kind: "MODEL" as const, - status: "NEEDS_KEY" as const, -}); -const modelReady = Object.freeze({ - kind: "MODEL" as const, - status: "READY" as const, -}); - describe("system overview Explore next", () => { - it("keeps Explore next when the routed discovery profile can dispatch", () => { + it("keeps Explore next only when discovery can dispatch", () => { expect(systemExploreNextAction({ - workers: [heuristicReady, modelReady], dispatchEligibility: "ELIGIBLE", })).toEqual({ kind: "SCOUT", label: "Explore next" }); }); - it("labels the remaining heuristic-only path when the model lane needs a key", () => { - expect(systemExploreNextAction({ - workers: [heuristicReady, modelNeedsKey], - dispatchEligibility: "BLOCKED", - })).toEqual({ kind: "SCOUT", label: "Explore next · heuristic" }); - expect(systemExploreNextAction({ - workers: [heuristicReady, modelNeedsKey], - dispatchEligibility: null, - })).toEqual({ kind: "SCOUT", label: "Explore next · heuristic" }); - }); - - it("does not keep a green Explore next when the model looks ready but cannot dispatch", () => { + it("does not keep a clickable scout when dispatch is blocked or unknown", () => { expect(systemExploreNextAction({ - workers: [heuristicReady, modelReady], dispatchEligibility: "BLOCKED", })).toEqual({ kind: "NEEDS_SETUP", href: serializeWorkspaceRoute("agents"), }); expect(systemExploreNextAction({ - workers: [modelReady], dispatchEligibility: null, })).toEqual({ kind: "NEEDS_SETUP", href: "?view=agents", }); }); - - it("points at Agent operations when no honest scout path exists", () => { - expect(systemExploreNextAction({ - workers: [modelNeedsKey], - dispatchEligibility: "BLOCKED", - })).toEqual({ - kind: "NEEDS_SETUP", - href: "?view=agents", - }); - expect(systemExploreNextAction({ - workers: [], - dispatchEligibility: null, - }).kind).toBe("NEEDS_SETUP"); - }); }); diff --git a/apps/studio/src/lib/system-explore-next.ts b/apps/studio/src/lib/system-explore-next.ts index fd8d31ad..6d3d0a7c 100644 --- a/apps/studio/src/lib/system-explore-next.ts +++ b/apps/studio/src/lib/system-explore-next.ts @@ -1,46 +1,19 @@ import { serializeWorkspaceRoute } from "./workspace-route.js"; -export type SystemExploreWorker = Readonly<{ - kind: "HEURISTIC" | "MODEL"; - status: "READY" | "NEEDS_KEY" | "NEEDS_PROVIDER"; -}>; - export type SystemExploreNextAction = Readonly< - | { kind: "SCOUT"; label: "Explore next" | "Explore next · heuristic" } + | { kind: "SCOUT"; label: "Explore next" } | { kind: "NEEDS_SETUP"; href: string } >; export function systemExploreNextAction(input: { - readonly workers: readonly SystemExploreWorker[]; readonly dispatchEligibility: "ELIGIBLE" | "BLOCKED" | null; }): SystemExploreNextAction { - const heuristicReady = input.workers.some( - (worker) => worker.kind === "HEURISTIC" && worker.status === "READY", - ); - const modelReady = input.workers.some( - (worker) => worker.kind === "MODEL" && worker.status === "READY", - ); - + // Search leases always requireDispatchEligible. There is no Studio path + // that runs heuristic-fast-1 without that gate, so a heuristic suffix + // would still call runScout and bounce. if (input.dispatchEligibility === "ELIGIBLE") { return Object.freeze({ kind: "SCOUT", label: "Explore next" as const }); } - - // A model worker can appear READY while Codex dispatch is still blocked. - // The lease refuses that path, so do not keep a green Explore next. - if (modelReady) { - return Object.freeze({ - kind: "NEEDS_SETUP", - href: serializeWorkspaceRoute("agents"), - }); - } - - if (heuristicReady) { - return Object.freeze({ - kind: "SCOUT", - label: "Explore next · heuristic" as const, - }); - } - return Object.freeze({ kind: "NEEDS_SETUP", href: serializeWorkspaceRoute("agents"), From 2ed231bc51c1e23a290fe21ca417c87dce180d8d Mon Sep 17 00:00:00 2001 From: RainMona <316033127+RainMona@users.noreply.github.com> Date: Wed, 19 Aug 2026 07:25:33 +0000 Subject: [PATCH 7/8] Stop calling catalog listingCount markets next to Markets. Issue #26: System ready said "580 markets" from catalogObservation.listingCount while ?view=markets is the venue capability matrix, not a listing list. Sidebar now says listings; the Markets page says these are venue adapters and how many listings the catalog currently holds. No listing browser, order, or trade UI. (cherry picked from commit 19e1bfa1e729aabc06f03d63594f89251309bc52) --- apps/studio/src/App.tsx | 26 +++++++++----- .../src/lib/markets-workspace-copy.test.ts | 33 +++++++++++++++++ apps/studio/src/lib/markets-workspace-copy.ts | 35 +++++++++++++++++++ 3 files changed, 85 insertions(+), 9 deletions(-) create mode 100644 apps/studio/src/lib/markets-workspace-copy.test.ts create mode 100644 apps/studio/src/lib/markets-workspace-copy.ts diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx index 1e220a2d..fd8f1a1d 100644 --- a/apps/studio/src/App.tsx +++ b/apps/studio/src/App.tsx @@ -81,6 +81,7 @@ import { selectedVenueSessionLabel, VENUE_SESSION_PICKER_HEADING, } from "@/lib/book-desk-copy"; +import { marketsWorkspaceCopy } from "@/lib/markets-workspace-copy"; import { parseWorkspaceRoute, serializeWorkspaceRoute, @@ -3904,15 +3905,18 @@ async function requestCandidateWatchRefresh(): Promise<"READY" | "DEGRADED"> { function SidebarStatus() { const studioProjection = useStudioProjection(); const observation = studioProjection.ai.catalogObservation; + const copy = marketsWorkspaceCopy({ + healthySourceCount: observation.healthySourceCount, + sourceCount: observation.sourceCount, + listingCount: observation.listingCount, + venueAdapterCount: studioProjection.venues.length, + }); return (
System ready - - {observation.healthySourceCount}/{observation.sourceCount} sources ·{" "} - {observation.listingCount} markets - + {copy.sidebarCatalogLine}
); @@ -12790,15 +12794,19 @@ function ResearchCaseDeskView() { function VenueMatrix() { const studioProjection = useStudioProjection(); + const observation = studioProjection.ai.catalogObservation; + const copy = marketsWorkspaceCopy({ + healthySourceCount: observation.healthySourceCount, + sourceCount: observation.sourceCount, + listingCount: observation.listingCount, + venueAdapterCount: studioProjection.venues.length, + }); return (
Protocol reality -

Venue capability matrix

-

- Each adapter owns its precision, authentication boundary, mechanism, - and qualification evidence. -

+

{copy.pageTitle}

+

{copy.pageDescription}

{studioProjection.venues.map((venue) => ( diff --git a/apps/studio/src/lib/markets-workspace-copy.test.ts b/apps/studio/src/lib/markets-workspace-copy.test.ts new file mode 100644 index 00000000..de92a021 --- /dev/null +++ b/apps/studio/src/lib/markets-workspace-copy.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { marketsWorkspaceCopy } from "./markets-workspace-copy.js"; + +const observed = { + healthySourceCount: 5, + sourceCount: 7, + listingCount: 580, + venueAdapterCount: 7, +} as const; + +describe("marketsWorkspaceCopy", () => { + it("does not call catalog listingCount markets next to the Markets venue matrix", () => { + const copy = marketsWorkspaceCopy(observed); + expect(copy.sidebarCatalogLine).toBe("5/7 sources · 580 listings"); + expect(copy.sidebarCatalogLine.toLowerCase()).not.toContain("market"); + expect(copy.pageTitle).toBe("Venue capability matrix"); + expect(copy.pageDescription).toContain("7 venue adapters"); + expect(copy.pageDescription).toContain("580 listings"); + expect(copy.pageDescription).toContain("not a listing browser"); + }); + + it("uses the same listing count on the Markets page as in the sidebar", () => { + const copy = marketsWorkspaceCopy({ + ...observed, + listingCount: 1, + venueAdapterCount: 1, + }); + expect(copy.sidebarCatalogLine).toBe("5/7 sources · 1 listing"); + expect(copy.pageDescription).toContain("1 venue adapter"); + expect(copy.pageDescription).toContain("1 listing"); + expect(copy.pageDescription).not.toMatch(/market/i); + }); +}); diff --git a/apps/studio/src/lib/markets-workspace-copy.ts b/apps/studio/src/lib/markets-workspace-copy.ts new file mode 100644 index 00000000..204db9af --- /dev/null +++ b/apps/studio/src/lib/markets-workspace-copy.ts @@ -0,0 +1,35 @@ +export type MarketsWorkspaceCounts = Readonly<{ + healthySourceCount: number; + sourceCount: number; + listingCount: number; + venueAdapterCount: number; +}>; + +export type MarketsWorkspaceCopy = Readonly<{ + sidebarCatalogLine: string; + pageTitle: string; + pageDescription: string; +}>; + +export const MARKETS_PAGE_TITLE = "Venue capability matrix"; + +export function marketsWorkspaceCopy( + counts: MarketsWorkspaceCounts, +): MarketsWorkspaceCopy { + const listings = countPhrase(counts.listingCount, "listing", "listings"); + const adapters = countPhrase( + counts.venueAdapterCount, + "venue adapter", + "venue adapters", + ); + return { + sidebarCatalogLine: `${counts.healthySourceCount}/${counts.sourceCount} sources · ${listings}`, + pageTitle: MARKETS_PAGE_TITLE, + pageDescription: + `These are ${adapters}, not a listing browser. The catalog currently holds ${listings}. Each adapter owns its precision, authentication boundary, mechanism, and qualification evidence.`, + }; +} + +function countPhrase(count: number, singular: string, plural: string): string { + return `${count} ${count === 1 ? singular : plural}`; +} From e0037acf5ab34c4e9ebebd1614a1bf15a8524b4a Mon Sep 17 00:00:00 2001 From: RainMona <316033127+RainMona@users.noreply.github.com> Date: Wed, 19 Aug 2026 07:39:09 +0000 Subject: [PATCH 8/8] Disable Discover Run now when discovery cannot dispatch. Search issue Run now POSTs /runs on the discovery route, same as the hero scan. Pause/resume stays local. The scheduler hint no longer claims manual runs work when dispatch is blocked. Automatic PMH_SEARCH_ISSUE_TICK_MS dispatch stays off. (cherry picked from commit 756bf1ae7115850fb33b9ff482d212f7659064d4) --- apps/studio/src/App.tsx | 9 +++++-- .../src/lib/search-issue-run-now.test.ts | 27 +++++++++++++++++++ apps/studio/src/lib/search-issue-run-now.ts | 18 +++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 apps/studio/src/lib/search-issue-run-now.test.ts create mode 100644 apps/studio/src/lib/search-issue-run-now.ts diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx index fd8f1a1d..e9c079c7 100644 --- a/apps/studio/src/App.tsx +++ b/apps/studio/src/App.tsx @@ -82,6 +82,7 @@ import { VENUE_SESSION_PICKER_HEADING, } from "@/lib/book-desk-copy"; import { marketsWorkspaceCopy } from "@/lib/markets-workspace-copy"; +import { searchIssueRunNow } from "@/lib/search-issue-run-now"; import { parseWorkspaceRoute, serializeWorkspaceRoute, @@ -7100,6 +7101,9 @@ function MarketArchaeologistView() { const discoveryCapability = discoveryExecution.data?.capability; const discoveryRuntime = discoveryExecution.data?.runtime; const discoveryModel = discoveryExecution.data?.model; + const searchIssueRun = searchIssueRunNow({ + dispatchEligibility: discoveryCapability?.dispatchEligibility ?? null, + }); const currentLensRecords = scheduler.records.filter( (record) => record.lease.snapshotIdentity === corpus.snapshotIdentity, ); @@ -7798,7 +7802,8 @@ function MarketArchaeologistView() { variant="outline" disabled={ corpus.listingCount === 0 || issueAction !== null || - (issue.supersededByIssueId !== undefined && issue.supersededByIssueId !== null) + (issue.supersededByIssueId !== undefined && issue.supersededByIssueId !== null) || + !searchIssueRun.dispatchEligible } onClick={() => void runIssue(issue.issueId)} > @@ -7894,7 +7899,7 @@ function MarketArchaeologistView() { {!issueScheduler.enabled && (

- Automatic dispatch is installed but intentionally explicit. Set PMH_SEARCH_ISSUE_TICK_MS to 1000–60000 and restart the control plane; manual runs work now. + {searchIssueRun.schedulerHint}

)} {issueDiagnostic !== null && ( diff --git a/apps/studio/src/lib/search-issue-run-now.test.ts b/apps/studio/src/lib/search-issue-run-now.test.ts new file mode 100644 index 00000000..ddd8b797 --- /dev/null +++ b/apps/studio/src/lib/search-issue-run-now.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { searchIssueRunNow } from "./search-issue-run-now.js"; + +describe("search issue Run now", () => { + it("allows Run now only when discovery can dispatch", () => { + expect(searchIssueRunNow({ + dispatchEligibility: "ELIGIBLE", + })).toEqual({ + dispatchEligible: true, + schedulerHint: + "Automatic dispatch is installed but intentionally explicit. Set PMH_SEARCH_ISSUE_TICK_MS to 1000–60000 and restart the control plane; manual runs work now.", + }); + }); + + it("does not claim manual runs work when dispatch is blocked or unknown", () => { + for (const dispatchEligibility of ["BLOCKED", null] as const) { + const result = searchIssueRunNow({ dispatchEligibility }); + expect(result.dispatchEligible).toBe(false); + expect(result.schedulerHint).toBe( + "Automatic dispatch is installed but intentionally explicit. Manual Run now stays blocked until discovery can dispatch.", + ); + expect(result.schedulerHint.toLowerCase()).not.toContain("manual runs work now"); + expect(result.schedulerHint).not.toContain("PMH_SEARCH_ISSUE_TICK_MS"); + } + }); +}); diff --git a/apps/studio/src/lib/search-issue-run-now.ts b/apps/studio/src/lib/search-issue-run-now.ts new file mode 100644 index 00000000..389f0fae --- /dev/null +++ b/apps/studio/src/lib/search-issue-run-now.ts @@ -0,0 +1,18 @@ +export type SearchIssueRunNow = Readonly<{ + dispatchEligible: boolean; + schedulerHint: string; +}>; + +export function searchIssueRunNow(input: { + readonly dispatchEligibility: "ELIGIBLE" | "BLOCKED" | null; +}): SearchIssueRunNow { + // POST /api/v1/search-issues/:id/runs spends the discovery route, same as + // the hero scan. Pause/resume is local via requestSearchIssueEnabled. + const dispatchEligible = input.dispatchEligibility === "ELIGIBLE"; + return Object.freeze({ + dispatchEligible, + schedulerHint: dispatchEligible + ? "Automatic dispatch is installed but intentionally explicit. Set PMH_SEARCH_ISSUE_TICK_MS to 1000–60000 and restart the control plane; manual runs work now." + : "Automatic dispatch is installed but intentionally explicit. Manual Run now stays blocked until discovery can dispatch.", + }); +}