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 (
The scheduler chooses a fresh trailhead; the Agent forms claims after inspection.
-
@@ -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) => (
-
setSelectedBookId(book.bookId)}
- >
-
-
- {book.venueName}
- {book.instrumentId}
-
- {book.lifecycle}
-
- {book.bidLevelCount} × {book.askLevelCount} levels
-
-
- ))}
+ {studioProjection.bookDesk.books.map((book) => {
+ const selected = selectedBook?.bookId === book.bookId;
+ const showingLabel = selectedVenueSessionLabel(selected);
+ return (
+
setSelectedBookId(book.bookId)}
+ >
+
+
+ {book.venueName}
+ {book.instrumentId}
+ {showingLabel !== undefined && (
+ {showingLabel}
+ )}
+
+ {book.lifecycle}
+
+ {book.bidLevelCount} × {book.askLevelCount} levels
+
+
+ );
+ })}
{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 (
{
+ setQuery(event.target.value);
+ setSelectedIndex(0);
+ }}
+ onKeyDown={(event) => {
+ if (event.key === "ArrowDown") {
+ event.preventDefault();
+ setSelectedIndex((index) => stepCommandIndex(matches.length, index, 1));
+ } else if (event.key === "ArrowUp") {
+ event.preventDefault();
+ setSelectedIndex((index) => stepCommandIndex(matches.length, index, -1));
+ } else if (event.key === "Enter") {
+ event.preventDefault();
+ activate(selectedIndex);
+ }
+ }}
/>
ESC
Available projections
- {navigation.map((item) => {
- const Icon = item.icon;
- return (
- {
- onNavigate(item.id);
- onClose();
- }}
- >
-
- {item.label}
- Open
-
- );
- })}
+
+ {matches.length === 0 ? (
+
+ No projections match that query.
+
+ ) : (
+ matches.map((item, index) => {
+ const Icon = item.icon;
+ return (
+
setSelectedIndex(index)}
+ onClick={() => activate(index)}
+ >
+
+ {item.label}
+ Open
+
+ );
+ })
+ )}
+
);
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,