From 182260e2d727354d42e1dcca2f9396c44664f07b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 08:25:30 +0000 Subject: [PATCH 1/7] Give first-run Discover and blocked Findings a next click Discover now shows System ready source health and Refresh catalogs as the first anonymous step. When discovery dispatch is blocked, Findings and Discover no longer lead with Explore next; they point at Agent operations or catalog refresh that does not need a provider key. Co-authored-by: RainMona (cherry picked from commit 83ffaf9d5c965b952e438eab23695350dd8c66b1) --- README.md | 18 +- apps/studio/src/App.tsx | 170 +++++++++++++----- .../src/data/first-operator-next-step.test.ts | 104 +++++++++++ .../src/data/first-operator-next-step.ts | 91 ++++++++++ apps/studio/src/index.css | 37 ++++ apps/studio/src/product-shell.css | 50 +++++- docs/OPERATIONS.md | 6 +- docs/STUDIO.md | 12 +- 8 files changed, 428 insertions(+), 60 deletions(-) create mode 100644 apps/studio/src/data/first-operator-next-step.test.ts create mode 100644 apps/studio/src/data/first-operator-next-step.ts diff --git a/README.md b/README.md index d2ecc597..61802fac 100644 --- a/README.md +++ b/README.md @@ -116,16 +116,20 @@ smoke commands, read [Operations](docs/OPERATIONS.md). ## First use -1. Open Studio and check **Readiness** for the control plane, catalog, storage, - Agent runtime, and credential posture. +1. Open Studio. The default **Discover** page reports **System ready** source + health and a **Refresh catalogs** first step. The same refresh also lives on + **System overview**. 2. Refresh anonymous catalogs. A refresh creates a new immutable corpus; it does not spend model budget or grant trading authority. -3. Open the ontology / discovery workspace and inspect the standing campaigns. - Run a bounded campaign or issue against the retained corpus. -4. Read the effect timeline, exact listing references, counterexamples, and +3. If discovery is blocked (`credential unavailable`), open **Agent operations** + to inspect the existing Codex session. Stay on Discover to keep refreshing + catalogs without DeepSeek or Codex; **Explore next** is not the next click. +4. After catalogs are in view, start a heuristic scan or inspect standing + campaigns against the retained corpus. +5. Read the effect timeline, exact listing references, counterexamples, and token usage. An empty or falsified run is retained research evidence. -5. Move only a grounded multi-listing hypothesis into independent review. -6. Treat economic hints as routing signals until fresh books, fees, depth, and +6. Move only a grounded multi-listing hypothesis into independent review. +7. Treat economic hints as routing signals until fresh books, fees, depth, and the exact verifier all agree. The longer operator walkthrough is in [Studio](docs/STUDIO.md). diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx index e5271375..dcbb64aa 100644 --- a/apps/studio/src/App.tsx +++ b/apps/studio/src/App.tsx @@ -65,6 +65,13 @@ import { } from "@/data/studio-projection"; import { buildOpportunityFrontier } from "@/data/opportunity-frontier"; import { useDiscoveryExecutionCapability } from "@/data/discovery-execution"; +import { + discoverFirstStep, + discoverSpendAction, + findingsPrimaryAction, + inspirationEmptyState, + isCredentialBlockedDispatch, +} from "@/data/first-operator-next-step"; import { useStandingRouteWorkspace, type StandingRouteState, @@ -6945,8 +6952,13 @@ function RealCandidatePreflightView() { ); } -function MarketArchaeologistView() { +function MarketArchaeologistView({ + onNavigate, +}: { + onNavigate: (view: View) => void; +}) { const studioProjection = useStudioProjection(); + const catalogObservation = studioProjection.ai.catalogObservation; const corpus = studioProjection.ai.marketCorpus ?? EMPTY_MARKET_CORPUS; const catalogRefreshScheduler = @@ -7050,6 +7062,9 @@ function MarketArchaeologistView() { const [deepRetryLeaseId, setDeepRetryLeaseId] = useState(null); const [issueAction, setIssueAction] = useState(null); const [issueDiagnostic, setIssueDiagnostic] = useState(null); + const [refreshStatus, setRefreshStatus] = useState< + "IDLE" | "RUNNING" | "READY" | "DEGRADED" | "FAILED" + >("IDLE"); const [newIssueTitle, setNewIssueTitle] = useState(""); const [newIssueQuestion, setNewIssueQuestion] = useState(""); const [newIssueLens, setNewIssueLens] = useState("EQUIVALENCE"); @@ -7058,6 +7073,17 @@ function MarketArchaeologistView() { const discoveryCapability = discoveryExecution.data?.capability; const discoveryRuntime = discoveryExecution.data?.runtime; const discoveryModel = discoveryExecution.data?.model; + const discoveryBlocked = isCredentialBlockedDispatch( + discoveryCapability?.dispatchEligibility, + discoveryCapability?.diagnostic ?? discoveryExecution.diagnostic, + ); + const firstStep = discoverFirstStep({ + healthySourceCount: catalogObservation.healthySourceCount, + sourceCount: catalogObservation.sourceCount, + listingCount: catalogObservation.listingCount, + }); + const spendAction = discoverSpendAction(discoveryBlocked); + const emptyInspiration = inspirationEmptyState(discoveryBlocked); const currentLensRecords = scheduler.records.filter( (record) => record.lease.snapshotIdentity === corpus.snapshotIdentity, ); @@ -7093,6 +7119,15 @@ function MarketArchaeologistView() { } } + async function refreshCatalog(): Promise { + setRefreshStatus("RUNNING"); + try { + setRefreshStatus(await requestCatalogRefresh()); + } catch { + setRefreshStatus("FAILED"); + } + } + async function retryDeep(leaseId: string): Promise { setDeepRetryLeaseId(leaseId); setLeaseDiagnostic(null); @@ -7207,23 +7242,30 @@ function MarketArchaeologistView() { {discoveryExecution.preflightBusy ? : } {discoveryCapability?.observation == null ? "Preflight" : "Recheck"} - + {spendAction.kind === "OPEN_AGENT_OPERATIONS" ? ( + + ) : ( + + )} @@ -7234,6 +7276,29 @@ function MarketArchaeologistView() { )} +
+
+ {firstStep.title} + {firstStep.sourceHealthLabel} +

{firstStep.body}

+
+
+ +
+
+
- No useful detours yet -

When a heuristic scan finds a grounded relation outside its assignment, it appears here instead of being forced into a claim.

+ {emptyInspiration.title} +

{emptyInspiration.body}

) : ( @@ -11388,8 +11453,10 @@ function StandingRouteMemory({ revision }: { revision: string }) { function ScoutInboxView({ onOpenReview, + onNavigate, }: { onOpenReview: (proposalIds: readonly string[]) => void; + onNavigate: (view: View) => void; }) { const studioProjection = useStudioProjection(); const scheduler = studioProjection.ai.searchLeaseScheduler ?? EMPTY_SEARCH_LEASE_SCHEDULER; @@ -11430,6 +11497,10 @@ function ScoutInboxView({ const discoveryCapability = discoveryExecution.data?.capability; const discoveryRuntime = discoveryExecution.data?.runtime; const discoveryModel = discoveryExecution.data?.model; + const findingsAction = findingsPrimaryAction({ + dispatchEligibility: discoveryCapability?.dispatchEligibility, + diagnostic: discoveryCapability?.diagnostic ?? discoveryExecution.diagnostic, + }); const liveContextEligible = catalogMode === "VERIFIED_FIXTURES" || selectedVenueIds.every( @@ -11545,34 +11616,44 @@ function ScoutInboxView({ {discoveryExecution.preflightBusy ? : } {discoveryCapability?.observation == null ? "Preflight" : "Recheck"} - + {findingsAction.kind === "OPEN_AGENT_OPERATIONS" ? ( + + ) : ( + + )} {(discoveryExecution.diagnostic !== null || discoveryCapability?.dispatchEligibility === "BLOCKED") && (
- {discoveryExecution.diagnostic ?? `Discovery is blocked before model spend: ${discoveryCapability?.diagnostic ?? "run a capability preflight"}`} +
+

{discoveryExecution.diagnostic ?? `Discovery is blocked before model spend: ${discoveryCapability?.diagnostic ?? "run a capability preflight"}`}

+ {findingsAction.helper !== "" &&

{findingsAction.helper}

} +
)} @@ -14073,7 +14154,9 @@ function StudioShell({ projectionSync }: { projectionSync: ProjectionSyncState }
{view === "overview" && } {view === "agents" && } - {view === "archaeologist" && } + {view === "archaeologist" && ( + navigate(nextView)} /> + )} {view === "lifecycle" && ( navigate("lifecycle", proposalIds)} + onNavigate={(nextView) => navigate(nextView)} /> )} {view === "budgets" && ( diff --git a/apps/studio/src/data/first-operator-next-step.test.ts b/apps/studio/src/data/first-operator-next-step.test.ts new file mode 100644 index 00000000..c6db5230 --- /dev/null +++ b/apps/studio/src/data/first-operator-next-step.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; + +import { + catalogHealthLabel, + discoverFirstStep, + discoverSpendAction, + findingsPrimaryAction, + inspirationEmptyState, + isCredentialBlockedDispatch, +} from "./first-operator-next-step"; + +const emptyCatalog = Object.freeze({ + healthySourceCount: 0, + sourceCount: 7, + listingCount: 0, +}); + +const populatedCatalog = Object.freeze({ + healthySourceCount: 6, + sourceCount: 7, + listingCount: 600, +}); + +describe("first-operator next step", () => { + it("uses the sidebar System ready words for source health", () => { + expect(catalogHealthLabel(populatedCatalog)).toBe( + "System ready · 6/7 sources · 600 markets", + ); + }); + + it("makes Refresh catalogs the Discover first step on an empty desk", () => { + const step = discoverFirstStep(emptyCatalog); + expect(step.title).toBe("First step"); + expect(step.primaryAction).toBe("REFRESH_CATALOGS"); + expect(step.primaryLabel).toBe("Refresh catalogs"); + expect(step.sourceHealthLabel).toContain("0/7 sources"); + expect(step.body).toMatch(/Refresh anonymous catalogs/); + expect(step.body).not.toMatch(/Readiness/); + }); + + it("keeps Refresh catalogs visible after catalogs already exist", () => { + const step = discoverFirstStep(populatedCatalog); + expect(step.primaryLabel).toBe("Refresh catalogs"); + expect(step.sourceHealthLabel).toBe( + "System ready · 6/7 sources · 600 markets", + ); + expect(step.body).toMatch(/Refresh catalogs/); + expect(step.body).toMatch(/heuristic scan/); + }); + + it("tells an empty inspiration inbox what to do next", () => { + expect(inspirationEmptyState(false)).toEqual({ + title: "No useful detours yet", + body: "Refresh catalogs on this page first, then start a heuristic scan. Cross-lens inspirations appear here after a scan finds a grounded relation outside its assignment.", + }); + expect(inspirationEmptyState(true).body).toMatch(/Agent operations/); + expect(inspirationEmptyState(true).body).not.toMatch(/Readiness/); + }); + + it("treats BLOCKED and credential-unavailable diagnostics as blocked dispatch", () => { + expect(isCredentialBlockedDispatch("BLOCKED", "runtime unavailable")).toBe(true); + expect(isCredentialBlockedDispatch("ELIGIBLE", "credential unavailable")).toBe(false); + expect(isCredentialBlockedDispatch(undefined, "Discovery is blocked before model spend: credential unavailable")).toBe(true); + expect(isCredentialBlockedDispatch("ELIGIBLE", "ready")).toBe(false); + expect(isCredentialBlockedDispatch(undefined, null)).toBe(false); + }); + + it("replaces Explore next as the Findings primary when dispatch is blocked", () => { + const action = findingsPrimaryAction({ + dispatchEligibility: "BLOCKED", + diagnostic: "credential unavailable", + }); + expect(action.exploreNextPrimary).toBe(false); + expect(action.kind).toBe("OPEN_AGENT_OPERATIONS"); + expect(action.label).toBe("Open Agent operations"); + expect(action.helper).toMatch(/Codex OAuth session/); + expect(action.helper).toMatch(/Discover/); + expect(action.helper).not.toMatch(/Readiness/); + }); + + it("keeps Explore next as the Findings primary when dispatch is eligible", () => { + const action = findingsPrimaryAction({ + dispatchEligibility: "ELIGIBLE", + diagnostic: "ready", + }); + expect(action).toEqual({ + kind: "EXPLORE_NEXT", + label: "Explore next", + helper: "", + exploreNextPrimary: true, + }); + }); + + it("points Discover spend at Agent operations when credentials are blocked", () => { + expect(discoverSpendAction(true)).toEqual({ + kind: "OPEN_AGENT_OPERATIONS", + label: "Open Agent operations", + }); + expect(discoverSpendAction(false)).toEqual({ + kind: "HEURISTIC_SCAN", + label: "Start heuristic scan", + }); + }); +}); diff --git a/apps/studio/src/data/first-operator-next-step.ts b/apps/studio/src/data/first-operator-next-step.ts new file mode 100644 index 00000000..2d22f382 --- /dev/null +++ b/apps/studio/src/data/first-operator-next-step.ts @@ -0,0 +1,91 @@ +export type DiscoveryDispatchEligibility = "ELIGIBLE" | "BLOCKED"; + +export type CatalogHealth = Readonly<{ + healthySourceCount: number; + sourceCount: number; + listingCount: number; +}>; + +export function catalogHealthLabel(health: CatalogHealth): string { + return `System ready · ${health.healthySourceCount}/${health.sourceCount} sources · ${health.listingCount} markets`; +} + +export function isCredentialBlockedDispatch( + eligibility: DiscoveryDispatchEligibility | undefined, + diagnostic: string | null | undefined, +): boolean { + if (eligibility === "ELIGIBLE") return false; + if (eligibility === "BLOCKED") return true; + return (diagnostic ?? "").toLowerCase().includes("credential unavailable"); +} + +export function discoverFirstStep(health: CatalogHealth): Readonly<{ + title: "First step"; + sourceHealthLabel: string; + body: string; + primaryAction: "REFRESH_CATALOGS"; + primaryLabel: "Refresh catalogs"; +}> { + return Object.freeze({ + title: "First step", + sourceHealthLabel: catalogHealthLabel(health), + body: health.listingCount === 0 + ? "Refresh anonymous catalogs to see which sources are healthy. This does not spend model budget or grant trading authority." + : "Anonymous catalogs are already in view. Refresh catalogs to take a new immutable corpus, then start a heuristic scan. Refresh does not spend model budget.", + primaryAction: "REFRESH_CATALOGS", + primaryLabel: "Refresh catalogs", + }); +} + +export function inspirationEmptyState(blocked: boolean): Readonly<{ + title: "No useful detours yet"; + body: string; +}> { + return Object.freeze({ + title: "No useful detours yet", + body: blocked + ? "Refresh catalogs here without a provider key, or open Agent operations to inspect the existing Codex session. Model spend stays blocked until that session is available." + : "Refresh catalogs on this page first, then start a heuristic scan. Cross-lens inspirations appear here after a scan finds a grounded relation outside its assignment.", + }); +} + +export function discoverSpendAction(blocked: boolean): Readonly<{ + kind: "HEURISTIC_SCAN" | "OPEN_AGENT_OPERATIONS"; + label: string; +}> { + return blocked + ? Object.freeze({ + kind: "OPEN_AGENT_OPERATIONS", + label: "Open Agent operations", + }) + : Object.freeze({ + kind: "HEURISTIC_SCAN", + label: "Start heuristic scan", + }); +} + +export function findingsPrimaryAction(input: Readonly<{ + dispatchEligibility: DiscoveryDispatchEligibility | undefined; + diagnostic: string | null | undefined; +}>): Readonly<{ + kind: "EXPLORE_NEXT" | "OPEN_AGENT_OPERATIONS"; + label: string; + helper: string; + exploreNextPrimary: boolean; +}> { + if (isCredentialBlockedDispatch(input.dispatchEligibility, input.diagnostic)) { + return Object.freeze({ + kind: "OPEN_AGENT_OPERATIONS", + label: "Open Agent operations", + helper: + "Attach the existing Codex OAuth session in Agent operations, or stay on Discover to refresh catalogs without DeepSeek or Codex.", + exploreNextPrimary: false, + }); + } + return Object.freeze({ + kind: "EXPLORE_NEXT", + label: "Explore next", + helper: "", + exploreNextPrimary: true, + }); +} diff --git a/apps/studio/src/index.css b/apps/studio/src/index.css index 21281c5f..cbf98df0 100644 --- a/apps/studio/src/index.css +++ b/apps/studio/src/index.css @@ -2344,6 +2344,38 @@ main { margin: 3px 0 0; } +.first-operator-step { + align-items: flex-start; + background: var(--card); + border: 1px solid var(--border); + border-radius: 10px; + display: flex; + gap: 18px; + justify-content: space-between; + margin: 0 0 18px; + padding: 16px; +} + +.first-operator-step strong { + display: block; + font-size: 14px; + margin-top: 2px; +} + +.first-operator-step p { + color: var(--muted-foreground); + font-size: 13px; + line-height: 1.5; + margin: 4px 0 0; +} + +.first-operator-step-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + .inspiration-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -7600,6 +7632,11 @@ footer span:first-child { justify-content: flex-start; } + .first-operator-step { + align-items: stretch; + flex-direction: column; + } + .archaeology-pipeline { grid-template-columns: 1fr; } diff --git a/apps/studio/src/product-shell.css b/apps/studio/src/product-shell.css index 1f5a8278..6bd118bf 100644 --- a/apps/studio/src/product-shell.css +++ b/apps/studio/src/product-shell.css @@ -2584,7 +2584,8 @@ footer { .scout-heading, .ai-rack-header, .ai-rack-footer, - .ai-runtime-panel-heading { + .ai-runtime-panel-heading, + .first-operator-step { align-items: stretch; flex-direction: column; } @@ -3595,7 +3596,7 @@ footer { .inline-alert { display: flex; - align-items: center; + align-items: flex-start; gap: 8px; border: 1px solid color-mix(in srgb, var(--destructive) 45%, var(--border)); border-radius: 10px; @@ -3604,6 +3605,48 @@ footer { font-size: 13px; } +.inline-alert p { + margin: 0; +} + +.inline-alert p + p { + color: var(--muted-foreground); + margin-top: 4px; +} + +.first-operator-step { + align-items: flex-start; + background: var(--card); + border: 1px solid var(--border); + border-radius: 10px; + display: flex; + gap: 18px; + justify-content: space-between; + margin: 12px 0 16px; + padding: 16px; +} + +.first-operator-step strong { + display: block; + font-size: 14px; + margin-top: 2px; +} + +.first-operator-step p { + color: var(--muted-foreground); + font-size: 13px; + line-height: 1.5; + margin: 4px 0 0; +} + +.first-operator-step-actions { + display: flex; + flex-shrink: 0; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + @media (max-width: 900px) { .agent-console-grid, .agent-control-form, @@ -4732,7 +4775,8 @@ footer { .scout-heading, .ai-rack-header, .ai-rack-footer, - .ai-runtime-panel-heading { + .ai-runtime-panel-heading, + .first-operator-step { align-items: stretch; flex-direction: column; } diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 87a95091..92899ea3 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -150,8 +150,10 @@ Then verify in Studio: printed by the process. - **Studio says offline:** check `http://127.0.0.1:4100/health` and the control plane terminal output. -- **Model unavailable:** inspect the runtime/credential posture in Readiness. - The system should fail visibly; do not assume a hidden fallback. +- **Model unavailable:** inspect the runtime/credential posture in **Agent + operations**. The Discover sidebar reports **System ready** source health, not + a Readiness panel. The system should fail visibly; do not assume a hidden + fallback. - **DeepSeek setting appears ignored:** the SQLite configuration saved after first startup overrides environment seed values. Change it in Studio or use a fresh operational store intentionally. diff --git a/docs/STUDIO.md b/docs/STUDIO.md index d87468f5..f4326f85 100644 --- a/docs/STUDIO.md +++ b/docs/STUDIO.md @@ -289,8 +289,10 @@ dashboard together. Vite starts at `127.0.0.1:5173` and automatically advances to the next free port. The dashboard uses a same-origin development proxy for the control plane; always follow the URL printed by Vite. -On first use, check readiness and storage posture before refreshing catalogs or -starting a campaign. Catalog refresh is anonymous and does not call a model. -Agent work begins only through an explicit action or an enabled durable -scheduler. The selected provider, runtime capability, model, reasoning effort, -campaign state, and usage lineage remain visible in Studio. +On first use, open **Discover**. Check **System ready** source health, then +**Refresh catalogs**. Catalog refresh is anonymous and does not call a model. +If discovery dispatch is blocked, open **Agent operations** for the existing +Codex session; do not expect **Explore next** to be the next click. Agent work +begins only through an explicit action or an enabled durable scheduler. The +selected provider, runtime capability, model, reasoning effort, campaign state, +and usage lineage remain visible in Studio. From 565c25a28081ec66798e049576bbe82090b1c317 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 08:31:41 +0000 Subject: [PATCH 2/7] Rename Studio Live data / Live markets copy The top-bar LIVE badge and catalog listing metrics used "live" in a way a first user can read as a tradable feed. Rename them to SSE connected, Observed catalog, and Catalog listings without changing what they measure or the analysis-only authority boundary. Co-authored-by: RainMona (cherry picked from commit 7abf5a98d77aae50f945a968b828c1ef57330118) --- PLANS.md | 2 +- apps/studio/src/App.tsx | 8 ++++---- plans/studio-invalidation-stream.md | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/PLANS.md b/PLANS.md index d4102812..f947598c 100644 --- a/PLANS.md +++ b/PLANS.md @@ -1113,7 +1113,7 @@ invalidation; the 2.27 MB bounded view is built on demand, shared per revision, and served with an exact ETag. On retained local state, a matching conditional read returned 304 with zero bytes in 0.000789 seconds, and the observed idle control-plane sample fell from the old 52–55% projection-fanout workload to -0.2%. Studio retains its last good view and reports Live data, Updating, or +0.2%. Studio retains its last good view and reports SSE connected, Updating, or Reconnecting instead of silently replacing megabytes of state. The product bottleneck has moved from discovery volume to opportunity delivery. diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx index dcbb64aa..542d61af 100644 --- a/apps/studio/src/App.tsx +++ b/apps/studio/src/App.tsx @@ -4060,7 +4060,7 @@ function Topbar({ ? `${Math.floor(staleAge / 60_000)}m old` : `${Math.floor(staleAge / 3_600_000)}h old`; const syncLabel = projectionSync.status === "LIVE" - ? "Live data" + ? "SSE connected" : projectionSync.status === "STALE_REVALIDATING" ? `Last known · ${staleAgeLabel} · revalidating` : projectionSync.status === "REFRESHING" @@ -6037,7 +6037,7 @@ function Overview({ {studioProjection.identity.stateHash.slice(0, 22)}…
- Live data + Observed catalog {studioProjection.identity.mode} · {studioProjection.identity.view}
@@ -6045,7 +6045,7 @@ function Overview({
@@ -7301,7 +7301,7 @@ function MarketArchaeologistView({
diff --git a/plans/studio-invalidation-stream.md b/plans/studio-invalidation-stream.md index c610018a..046d47da 100644 --- a/plans/studio-invalidation-stream.md +++ b/plans/studio-invalidation-stream.md @@ -92,7 +92,7 @@ scheduling for the same event loop as evidence accumulates. invalidation-only fanout and a per-revision cache, an idle sample fell to 0.2%; projection cost is now paid on operator demand instead of every effect. - Studio retains the last good projection, allows at most one request in flight, - collapses invalidations into one follow-up read, and exposes Live data, + collapses invalidations into one follow-up read, and exposes SSE connected, Updating, Connecting, or Reconnecting in the product shell. - Focused server/stream tests passed 28/28 and Studio projection tests passed 11/11. Full workspace type checks, all 576 tests, and the production build From 26fa32d4b544ba0f240ccb838eec07f1b127a247 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 08:39:24 +0000 Subject: [PATCH 3/7] Name unhealthy catalog sources from the sidebar status. The System ready row always looked green and truncated 6/7 sources, so the missing venue was only visible on System overview. Hover or expand the existing status to name unhealthy sources and say whether catalog refresh is a useful next step. Co-authored-by: RainMona (cherry picked from commit 08ca1c0fd7d83a41385a1b8d4100044fd0e4ada0) --- apps/studio/src/App.tsx | 40 +++-- apps/studio/src/index.css | 68 +++++++- .../src/lib/sidebar-catalog-status.test.ts | 163 ++++++++++++++++++ apps/studio/src/lib/sidebar-catalog-status.ts | 163 ++++++++++++++++++ apps/studio/src/product-shell.css | 22 ++- 5 files changed, 442 insertions(+), 14 deletions(-) create mode 100644 apps/studio/src/lib/sidebar-catalog-status.test.ts create mode 100644 apps/studio/src/lib/sidebar-catalog-status.ts diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx index 542d61af..1963d3de 100644 --- a/apps/studio/src/App.tsx +++ b/apps/studio/src/App.tsx @@ -77,6 +77,7 @@ import { type StandingRouteState, type StandingRouteUsage, } from "@/data/standing-routes"; +import { describeSidebarCatalogStatus } from "@/lib/sidebar-catalog-status"; import { cn } from "@/lib/utils"; import { parseWorkspaceRoute, @@ -3898,18 +3899,37 @@ async function requestCandidateWatchRefresh(): Promise<"READY" | "DEGRADED"> { function SidebarStatus() { const studioProjection = useStudioProjection(); - const observation = studioProjection.ai.catalogObservation; + const status = describeSidebarCatalogStatus( + studioProjection.ai.catalogObservation, + ); return ( -
- -
- System ready - - {observation.healthySourceCount}/{observation.sourceCount} sources ·{" "} - {observation.listingCount} markets - +
+ + +
+ {status.heading} + {status.countLabel} +
+
+
+ {status.unhealthySources.length === 0 ? ( +

All catalog sources are current.

+ ) : ( +
    + {status.unhealthySources.map((source) => ( +
  • + {source.venueId} + {source.statusLabel} + {source.diagnostic !== null && ( + {source.diagnostic} + )} +
  • + ))} +
+ )} +

{status.refreshHint}

-
+ ); } diff --git a/apps/studio/src/index.css b/apps/studio/src/index.css index cbf98df0..bf15e880 100644 --- a/apps/studio/src/index.css +++ b/apps/studio/src/index.css @@ -297,6 +297,25 @@ pre, padding: 14px 8px 2px; } +details.sidebar-status { + flex-direction: column; + align-items: stretch; + gap: 8px; +} + +.sidebar-status-summary { + display: flex; + min-width: 0; + align-items: center; + gap: 10px; + list-style: none; + cursor: pointer; +} + +.sidebar-status-summary::-webkit-details-marker { + display: none; +} + .sidebar-status-dot { width: 8px; height: 8px; @@ -305,7 +324,17 @@ pre, background: var(--primary); } -.sidebar-status > div { +.sidebar-status.is-degraded .sidebar-status-dot { + background: var(--warning); +} + +.sidebar-status.is-idle .sidebar-status-dot, +.sidebar-status.is-refreshing .sidebar-status-dot { + background: var(--muted-foreground); +} + +.sidebar-status > div, +.sidebar-status-summary > div { display: flex; min-width: 0; flex-direction: column; @@ -314,7 +343,8 @@ pre, .sidebar-status strong { font-size: 13px; } -.sidebar-status span:last-child { +.sidebar-status > div > span:last-child, +.sidebar-status-summary span:last-child { overflow: hidden; color: var(--muted-foreground); font-size: 12px; @@ -322,6 +352,40 @@ pre, white-space: nowrap; } +.sidebar-status-detail { + min-width: 0; + padding-left: 18px; +} + +.sidebar-status-detail p, +.sidebar-status-detail li { + margin: 0; + overflow-wrap: anywhere; + color: var(--muted-foreground); + font-size: 12px; + line-height: 1.4; +} + +.sidebar-status-detail ul { + display: flex; + flex-direction: column; + gap: 6px; + margin: 0 0 8px; + padding: 0; + list-style: none; +} + +.sidebar-status-detail li { + display: flex; + flex-direction: column; + gap: 1px; +} + +.sidebar-status-detail li strong { + color: var(--foreground); + font-size: 12px; +} + .venue-pulse { border: 1px solid var(--border); border-radius: 10px; diff --git a/apps/studio/src/lib/sidebar-catalog-status.test.ts b/apps/studio/src/lib/sidebar-catalog-status.test.ts new file mode 100644 index 00000000..764ffe44 --- /dev/null +++ b/apps/studio/src/lib/sidebar-catalog-status.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from "vitest"; +import { + describeSidebarCatalogStatus, + type SidebarCatalogObservationInput, + type SidebarCatalogSourceInput, +} from "./sidebar-catalog-status.js"; + +function source( + venueId: string, + status: SidebarCatalogSourceInput["status"], + diagnostic: string | null = null, +): SidebarCatalogSourceInput { + return { venueId, status, diagnostic }; +} + +function observation( + patch: Partial & + Pick, +): SidebarCatalogObservationInput { + const healthySourceCount = patch.sources.filter( + (item) => item.status === "CURRENT", + ).length; + return { + healthySourceCount: patch.healthySourceCount ?? healthySourceCount, + sourceCount: patch.sourceCount ?? patch.sources.length, + listingCount: patch.listingCount ?? 600, + ...patch, + }; +} + +describe("sidebar catalog status", () => { + it("keeps the ready heading when every source is current", () => { + const status = describeSidebarCatalogStatus( + observation({ + status: "READY", + listingCount: 600, + sources: [ + source("polymarket-global", "CURRENT"), + source("kalshi", "CURRENT"), + ], + }), + ); + + expect(status).toMatchObject({ + heading: "System ready", + tone: "ready", + countLabel: "2/2 sources · 600 markets", + refreshUseful: false, + refreshHint: "Catalog refresh is not required for source health.", + unhealthySources: [], + }); + expect(status.hoverLabel).toContain("All catalog sources are current."); + expect(status.hoverLabel).toContain("2/2 sources · 600 markets"); + }); + + it("names the missing source and says refresh may help", () => { + const status = describeSidebarCatalogStatus( + observation({ + status: "DEGRADED", + listingCount: 600, + sources: [ + source("polymarket-global", "CURRENT"), + source("polymarket-us", "CURRENT"), + source("kalshi", "CURRENT"), + source("gemini-predictions", "FAILED", "timed out"), + source("opinion", "CURRENT"), + source("myriad", "CURRENT"), + source("limitless", "CURRENT"), + ], + }), + ); + + expect(status.heading).toBe("Sources degraded"); + expect(status.tone).toBe("degraded"); + expect(status.countLabel).toBe("6/7 sources · 600 markets"); + expect(status.unhealthySources).toEqual([ + { + venueId: "gemini-predictions", + status: "FAILED", + statusLabel: "failed", + diagnostic: "timed out", + }, + ]); + expect(status.refreshUseful).toBe(true); + expect(status.refreshHint).toBe( + "Catalog refresh on System overview may recover this source.", + ); + expect(status.hoverLabel).toContain( + "Unhealthy: gemini-predictions (failed).", + ); + expect(status.hoverLabel).toContain( + "Catalog refresh on System overview may recover this source.", + ); + }); + + it("names every unhealthy source when more than one is down", () => { + const status = describeSidebarCatalogStatus( + observation({ + status: "DEGRADED", + listingCount: 412, + sources: [ + source("kalshi", "STALE_AFTER_FAILURE", "502"), + source("limitless", "NEVER_REFRESHED"), + source("opinion", "CURRENT"), + ], + }), + ); + + expect(status.unhealthySources.map((item) => item.venueId)).toEqual([ + "kalshi", + "limitless", + ]); + expect(status.refreshHint).toBe( + "Catalog refresh on System overview may recover these sources.", + ); + expect(status.hoverLabel).toContain( + "Unhealthy: kalshi (stale after failure), limitless (never refreshed).", + ); + }); + + it("treats an idle catalog as refresh-next without claiming readiness", () => { + const status = describeSidebarCatalogStatus( + observation({ + status: "IDLE", + listingCount: 0, + sources: [ + source("polymarket-global", "NEVER_REFRESHED"), + source("kalshi", "NEVER_REFRESHED"), + ], + }), + ); + + expect(status).toMatchObject({ + heading: "Catalog idle", + tone: "idle", + countLabel: "0/2 sources · 0 markets", + refreshUseful: true, + refreshHint: "Catalog refresh on System overview is the next step.", + }); + expect(status.hoverLabel).not.toContain("System ready"); + }); + + it("does not recommend another refresh while one is already running", () => { + const status = describeSidebarCatalogStatus( + observation({ + status: "REFRESHING", + listingCount: 600, + sources: [ + source("kalshi", "CURRENT"), + source("gemini-predictions", "FAILED", "connection reset"), + ], + }), + ); + + expect(status).toMatchObject({ + heading: "Refreshing catalogs", + tone: "refreshing", + refreshUseful: false, + refreshHint: "Catalog refresh is already running.", + }); + expect(status.unhealthySources[0]?.venueId).toBe("gemini-predictions"); + }); +}); diff --git a/apps/studio/src/lib/sidebar-catalog-status.ts b/apps/studio/src/lib/sidebar-catalog-status.ts new file mode 100644 index 00000000..9a139027 --- /dev/null +++ b/apps/studio/src/lib/sidebar-catalog-status.ts @@ -0,0 +1,163 @@ +export type SidebarCatalogSourceStatus = + | "NEVER_REFRESHED" + | "CURRENT" + | "STALE_AFTER_FAILURE" + | "FAILED"; + +export type SidebarCatalogObservationStatus = + | "IDLE" + | "REFRESHING" + | "READY" + | "DEGRADED"; + +export type SidebarCatalogSourceInput = Readonly<{ + venueId: string; + status: SidebarCatalogSourceStatus; + diagnostic: string | null; +}>; + +export type SidebarCatalogObservationInput = Readonly<{ + status: SidebarCatalogObservationStatus; + healthySourceCount: number; + sourceCount: number; + listingCount: number; + sources: readonly SidebarCatalogSourceInput[]; +}>; + +export type SidebarCatalogTone = "ready" | "degraded" | "refreshing" | "idle"; + +export type SidebarUnhealthySource = Readonly<{ + venueId: string; + status: Exclude; + statusLabel: string; + diagnostic: string | null; +}>; + +export type SidebarCatalogStatus = Readonly<{ + heading: string; + tone: SidebarCatalogTone; + sourceCount: number; + healthySourceCount: number; + listingCount: number; + countLabel: string; + hoverLabel: string; + unhealthySources: readonly SidebarUnhealthySource[]; + refreshUseful: boolean; + refreshHint: string; +}>; + +const SOURCE_STATUS_LABEL: Readonly< + Record, string> +> = Object.freeze({ + NEVER_REFRESHED: "never refreshed", + STALE_AFTER_FAILURE: "stale after failure", + FAILED: "failed", +}); + +export function sourceStatusLabel( + status: Exclude, +): string { + return SOURCE_STATUS_LABEL[status]; +} + +export function describeSidebarCatalogStatus( + observation: SidebarCatalogObservationInput, +): SidebarCatalogStatus { + const unhealthySources = Object.freeze( + observation.sources.flatMap((source): SidebarUnhealthySource[] => { + if (source.status === "CURRENT") { + return []; + } + return [ + Object.freeze({ + venueId: source.venueId, + status: source.status, + statusLabel: sourceStatusLabel(source.status), + diagnostic: source.diagnostic, + }), + ]; + }), + ); + const tone = catalogTone(observation.status); + const heading = catalogHeading(observation.status); + const countLabel = + `${observation.healthySourceCount}/${observation.sourceCount} sources · ` + + `${observation.listingCount} markets`; + const refreshUseful = + observation.status !== "REFRESHING" && + (observation.status === "IDLE" || unhealthySources.length > 0); + const refreshHint = catalogRefreshHint({ + status: observation.status, + unhealthyCount: unhealthySources.length, + refreshUseful, + }); + const hoverLabel = [ + countLabel, + unhealthySources.length === 0 + ? "All catalog sources are current." + : `Unhealthy: ${unhealthySources + .map((source) => `${source.venueId} (${source.statusLabel})`) + .join(", ")}.`, + refreshHint, + ].join(" "); + + return Object.freeze({ + heading, + tone, + sourceCount: observation.sourceCount, + healthySourceCount: observation.healthySourceCount, + listingCount: observation.listingCount, + countLabel, + hoverLabel, + unhealthySources, + refreshUseful, + refreshHint, + }); +} + +function catalogTone( + status: SidebarCatalogObservationStatus, +): SidebarCatalogTone { + if (status === "READY") { + return "ready"; + } + if (status === "REFRESHING") { + return "refreshing"; + } + if (status === "IDLE") { + return "idle"; + } + return "degraded"; +} + +function catalogHeading(status: SidebarCatalogObservationStatus): string { + if (status === "REFRESHING") { + return "Refreshing catalogs"; + } + if (status === "IDLE") { + return "Catalog idle"; + } + if (status === "DEGRADED") { + return "Sources degraded"; + } + return "System ready"; +} + +function catalogRefreshHint(input: { + status: SidebarCatalogObservationStatus; + unhealthyCount: number; + refreshUseful: boolean; +}): string { + if (input.status === "REFRESHING") { + return "Catalog refresh is already running."; + } + if (input.status === "IDLE") { + return "Catalog refresh on System overview is the next step."; + } + if (!input.refreshUseful) { + return "Catalog refresh is not required for source health."; + } + return input.unhealthyCount === 1 + ? "Catalog refresh on System overview may recover this source." + : "Catalog refresh on System overview may recover these sources."; +} diff --git a/apps/studio/src/product-shell.css b/apps/studio/src/product-shell.css index 6bd118bf..0b34d0ff 100644 --- a/apps/studio/src/product-shell.css +++ b/apps/studio/src/product-shell.css @@ -772,12 +772,23 @@ pre, font-weight: 550; } -.sidebar-status span:last-child, +.sidebar-status > div > span:last-child, +.sidebar-status-summary span:last-child, .authority-note span { color: #737c79; font-size: 12px; } +.sidebar-status-detail { + padding-left: 18px; +} + +.sidebar-status-detail p, +.sidebar-status-detail li { + color: #737c79; + font-size: 12px; +} + .topbar { height: 64px; border-color: rgba(40, 45, 51, 0.85); @@ -4225,12 +4236,19 @@ body { font-weight: 550; } -.sidebar-status span:last-child, +.sidebar-status > div > span:last-child, +.sidebar-status-summary span:last-child, .authority-note span { color: #6f7874; font-size: 13px; } +.sidebar-status-detail p, +.sidebar-status-detail li { + color: #6f7874; + font-size: 12px; +} + .topbar { height: 64px; border-bottom: 1px solid rgba(37, 42, 47, 0.9); From 7dbeda557c561ef92b4af00e737cbc4868ab83f2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 08:54:12 +0000 Subject: [PATCH 4/7] Rename Radar copy that reads as live trading or profit Refresh live radar implied a live feed; the button still only refreshes anonymous catalogs. Positive gross hints on Candidate pairs implied expected return; the count is still the same catalog-price filter. Co-authored-by: RainMona (cherry picked from commit 5e5f4eeeb9f238d5cc12fea5f689e7eaf70fa93a) --- apps/studio/src/App.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx index 1963d3de..0a62f9fe 100644 --- a/apps/studio/src/App.tsx +++ b/apps/studio/src/App.tsx @@ -10956,7 +10956,7 @@ function OpportunityRadarView() { ? "Sources refreshed" : refreshStatus === "FAILED" ? "Retry refresh" - : "Refresh live radar"} + : "Refresh catalogs"}
@@ -10974,7 +10974,7 @@ function OpportunityRadarView() { candidate.indicativeEconomics.status === "POSITIVE_GROSS_HINT").length} positive gross hints`} + detail={`${radar.candidates.filter((candidate) => candidate.indicativeEconomics.status === "POSITIVE_GROSS_HINT").length} catalog-price overlap hints · not executable`} /> Date: Tue, 18 Aug 2026 09:00:11 +0000 Subject: [PATCH 5/7] Demote radar pair spend CTAs from primary to budgeted actions. Filled "Triage with fast scouts" next to "no auto spend" could be read as a free next click. Both pair-row buttons are now outline, name scout/pi spend, and keep the page from firing until the operator clicks. Co-authored-by: RainMona (cherry picked from commit 0e5e7cee0f31177f898c60995ea9723b7f1252f7) --- apps/studio/src/App.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx index 0a62f9fe..22373e1a 100644 --- a/apps/studio/src/App.tsx +++ b/apps/studio/src/App.tsx @@ -11106,10 +11106,13 @@ function OpportunityRadarView() { Exact two-listing context · proposal only · no auto spend + · a click spends scout or pi budget
From e357ebe20723aac66e906c4ec3b44e0cf57a796e Mon Sep 17 00:00:00 2001 From: RainMona <316033127+RainMona@users.noreply.github.com> Date: Tue, 18 Aug 2026 09:18:36 +0000 Subject: [PATCH 6/7] Mark Markets ORDER_GATEWAY chips as inert coverage. Studio venue cards listed ORDER_GATEWAY next to inert sandbox/demo gateway pills, so the capability read as armed trading. When live execution is disabled, the chip now uses the same inert treatment. Fixes #12 (cherry picked from commit 37b71e72c9e4f655b6eaa54a29acde81f259e1c1) --- apps/studio/src/App.tsx | 13 ++++++-- .../studio/src/data/studio-projection.test.ts | 30 +++++++++++++++++ apps/studio/src/index.css | 6 ++++ .../src/lib/venue-capability-chip.test.ts | 20 ++++++++++++ apps/studio/src/lib/venue-capability-chip.ts | 32 +++++++++++++++++++ 5 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 apps/studio/src/lib/venue-capability-chip.test.ts create mode 100644 apps/studio/src/lib/venue-capability-chip.ts diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx index 22373e1a..6640c7aa 100644 --- a/apps/studio/src/App.tsx +++ b/apps/studio/src/App.tsx @@ -79,6 +79,7 @@ import { } from "@/data/standing-routes"; import { describeSidebarCatalogStatus } from "@/lib/sidebar-catalog-status"; import { cn } from "@/lib/utils"; +import { presentVenueCapabilityChips } from "@/lib/venue-capability-chip"; import { parseWorkspaceRoute, serializeWorkspaceRoute, @@ -12905,8 +12906,16 @@ function VenueMatrix() {
- {venue.capabilities.map((capability) => ( - {capability} + {presentVenueCapabilityChips( + venue.capabilities, + venue.liveExecutionEnabled, + ).map((chip) => ( + + {chip.label} + ))}
diff --git a/apps/studio/src/data/studio-projection.test.ts b/apps/studio/src/data/studio-projection.test.ts index 0340c7f0..cae6f816 100644 --- a/apps/studio/src/data/studio-projection.test.ts +++ b/apps/studio/src/data/studio-projection.test.ts @@ -5,6 +5,7 @@ import { RealCandidatePreflightDesk, ReplayBookDesk, } from "@pmh/control-plane"; +import { presentVenueCapabilityChips } from "../lib/venue-capability-chip.js"; import { parseStartupReadiness, parseProjectionInvalidation, @@ -384,6 +385,35 @@ describe("Studio projection safety", () => { expect(inertVenues.every((venue) => !venue.liveExecutionEnabled)).toBe( true, ); + expect( + inertVenues.flatMap((venue) => + presentVenueCapabilityChips( + venue.capabilities, + venue.liveExecutionEnabled, + ), + ).filter((chip) => chip.key === "ORDER_GATEWAY"), + ).toEqual([ + { + key: "ORDER_GATEWAY", + label: "ORDER_GATEWAY · INERT", + inert: true, + }, + { + key: "ORDER_GATEWAY", + label: "ORDER_GATEWAY · INERT", + inert: true, + }, + ]); + expect( + studioProjection.venues + .flatMap((venue) => + presentVenueCapabilityChips( + venue.capabilities, + venue.liveExecutionEnabled, + ), + ) + .some((chip) => chip.key === "ORDER_GATEWAY" && !chip.inert), + ).toBe(false); }); it("labels every displayed opportunity as exact fixture evidence", () => { diff --git a/apps/studio/src/index.css b/apps/studio/src/index.css index bf15e880..fc1e38aa 100644 --- a/apps/studio/src/index.css +++ b/apps/studio/src/index.css @@ -1375,6 +1375,12 @@ main { padding: 4px 6px; } +.capability-chips span.is-inert { + border-color: rgba(255, 199, 142, 0.22); + background: rgba(255, 199, 142, 0.04); + color: #c99e75; +} + .scout-heading { padding-bottom: 32px; } diff --git a/apps/studio/src/lib/venue-capability-chip.test.ts b/apps/studio/src/lib/venue-capability-chip.test.ts new file mode 100644 index 00000000..391f0c62 --- /dev/null +++ b/apps/studio/src/lib/venue-capability-chip.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { presentVenueCapabilityChip } from "./venue-capability-chip.js"; + +describe("Markets venue capability chips", () => { + it("marks ORDER_GATEWAY as inert coverage when live execution is disabled", () => { + expect(presentVenueCapabilityChip("ORDER_GATEWAY", false)).toEqual({ + key: "ORDER_GATEWAY", + label: "ORDER_GATEWAY · INERT", + inert: true, + }); + }); + + it("does not restyle catalog coverage as an order surface", () => { + expect(presentVenueCapabilityChip("MARKET_CATALOG", false)).toEqual({ + key: "MARKET_CATALOG", + label: "MARKET_CATALOG", + inert: false, + }); + }); +}); diff --git a/apps/studio/src/lib/venue-capability-chip.ts b/apps/studio/src/lib/venue-capability-chip.ts new file mode 100644 index 00000000..a0daf328 --- /dev/null +++ b/apps/studio/src/lib/venue-capability-chip.ts @@ -0,0 +1,32 @@ +export type VenueCapabilityChip = Readonly<{ + key: string; + label: string; + inert: boolean; +}>; + +export function presentVenueCapabilityChip( + capability: string, + liveExecutionEnabled: boolean, +): VenueCapabilityChip { + if (capability === "ORDER_GATEWAY" && liveExecutionEnabled !== true) { + return { + key: capability, + label: "ORDER_GATEWAY · INERT", + inert: true, + }; + } + return { + key: capability, + label: capability, + inert: false, + }; +} + +export function presentVenueCapabilityChips( + capabilities: readonly string[], + liveExecutionEnabled: boolean, +): readonly VenueCapabilityChip[] { + return capabilities.map((capability) => + presentVenueCapabilityChip(capability, liveExecutionEnabled), + ); +} From 87fe3f34f2f6edf719b1bfd631ab495c33b50406 Mon Sep 17 00:00:00 2001 From: RainMona <316033127+RainMona@users.noreply.github.com> Date: Tue, 18 Aug 2026 09:31:18 +0000 Subject: [PATCH 7/7] Point review-queue NEEDS KEY badges at Agent operations. The fail-closed badges were a dead end; the next path is the existing session, not a key form. (cherry picked from commit af50707a7ee6bf29f46a4c9fe103f4471eb0261d) --- apps/studio/src/App.tsx | 16 ++++++++ .../src/lib/review-queue-needs-key.test.ts | 37 +++++++++++++++++++ apps/studio/src/lib/review-queue-needs-key.ts | 11 ++++++ apps/studio/src/product-shell.css | 6 +++ 4 files changed, 70 insertions(+) create mode 100644 apps/studio/src/lib/review-queue-needs-key.test.ts create mode 100644 apps/studio/src/lib/review-queue-needs-key.ts diff --git a/apps/studio/src/App.tsx b/apps/studio/src/App.tsx index 6640c7aa..d6394e52 100644 --- a/apps/studio/src/App.tsx +++ b/apps/studio/src/App.tsx @@ -80,6 +80,7 @@ import { import { describeSidebarCatalogStatus } from "@/lib/sidebar-catalog-status"; import { cn } from "@/lib/utils"; import { presentVenueCapabilityChips } from "@/lib/venue-capability-chip"; +import { reviewQueueNeedsKeyPath } from "@/lib/review-queue-needs-key"; import { parseWorkspaceRoute, serializeWorkspaceRoute, @@ -8875,6 +8876,10 @@ function OpportunityLifecycleView({ 1, ...recentUsageHours.map((bucket) => Number(bucket.invocationCount)), ); + const needsKeyPath = reviewQueueNeedsKeyPath({ + reviewerConfigured: semanticReview.configured, + estimatorsConfigured: probabilityEstimation.configured, + }); return (
@@ -8903,6 +8908,17 @@ function OpportunityLifecycleView({ + {needsKeyPath !== null && ( +
+ + + The review lane is idle until the existing{" "} + Agent operations + {" "}session exists. + +
+ )} + {focusedProposalIds.length > 0 && (
diff --git a/apps/studio/src/lib/review-queue-needs-key.test.ts b/apps/studio/src/lib/review-queue-needs-key.test.ts new file mode 100644 index 00000000..7ff70549 --- /dev/null +++ b/apps/studio/src/lib/review-queue-needs-key.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { reviewQueueNeedsKeyPath } from "./review-queue-needs-key.js"; +import { serializeWorkspaceRoute } from "./workspace-route.js"; + +describe("review queue NEEDS KEY path", () => { + it("returns null when reviewer and estimators are configured", () => { + expect(reviewQueueNeedsKeyPath({ + reviewerConfigured: true, + estimatorsConfigured: true, + })).toBeNull(); + }); + + it("points at Agent operations when the reviewer is not configured", () => { + expect(reviewQueueNeedsKeyPath({ + reviewerConfigured: false, + estimatorsConfigured: true, + })).toBe(serializeWorkspaceRoute("agents")); + expect(reviewQueueNeedsKeyPath({ + reviewerConfigured: false, + estimatorsConfigured: true, + })).toBe("?view=agents"); + }); + + it("points at Agent operations when estimators are not configured", () => { + expect(reviewQueueNeedsKeyPath({ + reviewerConfigured: true, + estimatorsConfigured: false, + })).toBe("?view=agents"); + }); + + it("points at Agent operations when neither lane is configured", () => { + expect(reviewQueueNeedsKeyPath({ + reviewerConfigured: false, + estimatorsConfigured: false, + })).toBe("?view=agents"); + }); +}); diff --git a/apps/studio/src/lib/review-queue-needs-key.ts b/apps/studio/src/lib/review-queue-needs-key.ts new file mode 100644 index 00000000..f0939b5f --- /dev/null +++ b/apps/studio/src/lib/review-queue-needs-key.ts @@ -0,0 +1,11 @@ +import { serializeWorkspaceRoute } from "./workspace-route.js"; + +export function reviewQueueNeedsKeyPath(input: { + readonly reviewerConfigured: boolean; + readonly estimatorsConfigured: boolean; +}): string | null { + if (input.reviewerConfigured && input.estimatorsConfigured) { + return null; + } + return serializeWorkspaceRoute("agents"); +} diff --git a/apps/studio/src/product-shell.css b/apps/studio/src/product-shell.css index 0b34d0ff..4bcf279a 100644 --- a/apps/studio/src/product-shell.css +++ b/apps/studio/src/product-shell.css @@ -3625,6 +3625,12 @@ footer { margin-top: 4px; } +.inline-alert a { + color: inherit; + font-weight: 650; + text-decoration: underline; +} + .first-operator-step { align-items: flex-start; background: var(--card);