diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx index e5271375..e9c079c7 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,13 +70,27 @@ import { type StandingRouteState, type StandingRouteUsage, } from "@/data/standing-routes"; +import { + filterProjectionCommands, + stepCommandIndex, +} from "@/lib/command-palette"; import { cn } from "@/lib/utils"; +import { systemExploreNextAction } from "@/lib/system-explore-next"; +import { + qualifiedBooksTile, + selectedVenueSessionLabel, + 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, workspaceReadModel, 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]; @@ -3892,15 +3906,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}
); @@ -4619,7 +4636,7 @@ function AgentOperationsView() {
- +
@@ -5950,6 +5967,11 @@ function Overview({ }) { const studioProjection = useStudioProjection(); const catalogObservation = studioProjection.ai.catalogObservation; + const discoveryExecution = useDiscoveryExecutionCapability(); + const exploreNext = systemExploreNextAction({ + dispatchEligibility: + discoveryExecution.data?.capability.dispatchEligibility ?? null, + }); const [scoutStatus, setScoutStatus] = useState< "IDLE" | "RUNNING" | "DONE" | "RESTORED" | "FAILED" >("IDLE"); @@ -6067,22 +6089,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. + +
+ )}
@@ -7058,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, ); @@ -7756,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)} > @@ -7852,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 && ( @@ -12752,15 +12799,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) => ( @@ -12884,11 +12935,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 && ( @@ -13502,7 +13553,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", @@ -13968,7 +14019,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 21281c5f..23508c96 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; @@ -4870,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; @@ -4920,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; @@ -4937,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/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`, + }; +} 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; +} 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; +} 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"; +} 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}`; +} 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.", + }); +} 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..72525221 --- /dev/null +++ b/apps/studio/src/lib/system-explore-next.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { systemExploreNextAction } from "./system-explore-next.js"; +import { serializeWorkspaceRoute } from "./workspace-route.js"; + +describe("system overview Explore next", () => { + it("keeps Explore next only when discovery can dispatch", () => { + expect(systemExploreNextAction({ + dispatchEligibility: "ELIGIBLE", + })).toEqual({ kind: "SCOUT", label: "Explore next" }); + }); + + it("does not keep a clickable scout when dispatch is blocked or unknown", () => { + expect(systemExploreNextAction({ + dispatchEligibility: "BLOCKED", + })).toEqual({ + kind: "NEEDS_SETUP", + href: serializeWorkspaceRoute("agents"), + }); + expect(systemExploreNextAction({ + dispatchEligibility: null, + })).toEqual({ + kind: "NEEDS_SETUP", + href: "?view=agents", + }); + }); +}); 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..6d3d0a7c --- /dev/null +++ b/apps/studio/src/lib/system-explore-next.ts @@ -0,0 +1,21 @@ +import { serializeWorkspaceRoute } from "./workspace-route.js"; + +export type SystemExploreNextAction = Readonly< + | { kind: "SCOUT"; label: "Explore next" } + | { kind: "NEEDS_SETUP"; href: string } +>; + +export function systemExploreNextAction(input: { + readonly dispatchEligibility: "ELIGIBLE" | "BLOCKED" | null; +}): SystemExploreNextAction { + // 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 }); + } + 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,