mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
feat(desktop): prototype agent market surface
Signed-off-by: Wintermute <c0fc581234c3585602139eec347ced7b82af65b6f6c10728348515c0c06c51c3@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -124,6 +124,7 @@ export default defineConfig({
|
||||
"**/project-empty-state-alignment.spec.ts",
|
||||
"**/project-inbox.spec.ts",
|
||||
"**/projects-v3-screenshots.spec.ts",
|
||||
"**/market-prototype-screenshots.spec.ts",
|
||||
"**/project-issue-comments.spec.ts",
|
||||
"**/project-pr-review.spec.ts",
|
||||
"**/persona-model-combobox-screenshots.spec.ts",
|
||||
|
||||
@@ -9,6 +9,7 @@ export type AppView =
|
||||
| "agents"
|
||||
| "workflows"
|
||||
| "pulse"
|
||||
| "market"
|
||||
| "projects";
|
||||
|
||||
const WINDOW_DRAG_HANDLE_HEIGHT = 44;
|
||||
@@ -254,6 +255,13 @@ export function deriveShellRoute(pathname: string): {
|
||||
};
|
||||
}
|
||||
|
||||
if (pathname === "/market") {
|
||||
return {
|
||||
selectedChannelId: null,
|
||||
selectedView: "market",
|
||||
};
|
||||
}
|
||||
|
||||
if (pathname === "/pulse") {
|
||||
return {
|
||||
selectedChannelId: null,
|
||||
|
||||
@@ -146,6 +146,7 @@ export function AppShell() {
|
||||
goAgents,
|
||||
goChannel,
|
||||
goHome,
|
||||
goMarket,
|
||||
goNewMessage,
|
||||
goProjects,
|
||||
goPulse,
|
||||
@@ -886,6 +887,7 @@ export function AppShell() {
|
||||
scopeSearchFocusRequest,
|
||||
]}
|
||||
onSelectHome={() => void goHome()}
|
||||
onSelectMarket={() => void goMarket()}
|
||||
onSelectProjects={() => void goProjects()}
|
||||
onSelectPulse={() => void goPulse()}
|
||||
onSelectSettings={handleOpenSettings}
|
||||
|
||||
@@ -104,6 +104,18 @@ export function useAppNavigation() {
|
||||
[commitNavigation],
|
||||
);
|
||||
|
||||
const goMarket = React.useCallback(
|
||||
(behavior?: NavigationBehavior) =>
|
||||
commitNavigation(
|
||||
{
|
||||
to: "/market",
|
||||
search: { scenario: "finite" },
|
||||
},
|
||||
behavior,
|
||||
),
|
||||
[commitNavigation],
|
||||
);
|
||||
|
||||
const goProfile = React.useCallback(
|
||||
(pubkey: string, behavior?: NavigationBehavior) =>
|
||||
commitNavigation(
|
||||
@@ -464,6 +476,7 @@ export function useAppNavigation() {
|
||||
goEditWorkflow,
|
||||
goForumPost,
|
||||
goHome,
|
||||
goMarket,
|
||||
goNewMessage,
|
||||
goNewWorkflow,
|
||||
goNewWorkflowForChannel,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Route as settingsRouteImport } from "./routes/settings";
|
||||
import { Route as remindersRouteImport } from "./routes/reminders";
|
||||
import { Route as pulseRouteImport } from "./routes/pulse";
|
||||
import { Route as projectsRouteImport } from "./routes/projects";
|
||||
import { Route as marketRouteImport } from "./routes/market";
|
||||
import { Route as agentsRouteImport } from "./routes/agents";
|
||||
import { Route as indexRouteImport } from "./routes/index";
|
||||
import { Route as workflowsDotworkflowIdRouteImport } from "./routes/workflows.$workflowId";
|
||||
@@ -43,6 +44,11 @@ const projectsRoute = projectsRouteImport.update({
|
||||
path: "/projects",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
const marketRoute = marketRouteImport.update({
|
||||
id: "/market",
|
||||
path: "/market",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
const agentsRoute = agentsRouteImport.update({
|
||||
id: "/agents",
|
||||
path: "/agents",
|
||||
@@ -83,6 +89,7 @@ const channelsDotchannelIdDotpostsDotpostIdRoute =
|
||||
export interface FileRoutesByFullPath {
|
||||
"/": typeof indexRoute;
|
||||
"/agents": typeof agentsRoute;
|
||||
"/market": typeof marketRoute;
|
||||
"/projects": typeof projectsRoute;
|
||||
"/pulse": typeof pulseRoute;
|
||||
"/reminders": typeof remindersRoute;
|
||||
@@ -97,6 +104,7 @@ export interface FileRoutesByFullPath {
|
||||
export interface FileRoutesByTo {
|
||||
"/": typeof indexRoute;
|
||||
"/agents": typeof agentsRoute;
|
||||
"/market": typeof marketRoute;
|
||||
"/projects": typeof projectsRoute;
|
||||
"/pulse": typeof pulseRoute;
|
||||
"/reminders": typeof remindersRoute;
|
||||
@@ -112,6 +120,7 @@ export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport;
|
||||
"/": typeof indexRoute;
|
||||
"/agents": typeof agentsRoute;
|
||||
"/market": typeof marketRoute;
|
||||
"/projects": typeof projectsRoute;
|
||||
"/pulse": typeof pulseRoute;
|
||||
"/reminders": typeof remindersRoute;
|
||||
@@ -128,6 +137,7 @@ export interface FileRouteTypes {
|
||||
fullPaths:
|
||||
| "/"
|
||||
| "/agents"
|
||||
| "/market"
|
||||
| "/projects"
|
||||
| "/pulse"
|
||||
| "/reminders"
|
||||
@@ -142,6 +152,7 @@ export interface FileRouteTypes {
|
||||
to:
|
||||
| "/"
|
||||
| "/agents"
|
||||
| "/market"
|
||||
| "/projects"
|
||||
| "/pulse"
|
||||
| "/reminders"
|
||||
@@ -156,6 +167,7 @@ export interface FileRouteTypes {
|
||||
| "__root__"
|
||||
| "/"
|
||||
| "/agents"
|
||||
| "/market"
|
||||
| "/projects"
|
||||
| "/pulse"
|
||||
| "/reminders"
|
||||
@@ -171,6 +183,7 @@ export interface FileRouteTypes {
|
||||
export interface RootRouteChildren {
|
||||
indexRoute: typeof indexRoute;
|
||||
agentsRoute: typeof agentsRoute;
|
||||
marketRoute: typeof marketRoute;
|
||||
projectsRoute: typeof projectsRoute;
|
||||
pulseRoute: typeof pulseRoute;
|
||||
remindersRoute: typeof remindersRoute;
|
||||
@@ -220,6 +233,13 @@ declare module "@tanstack/react-router" {
|
||||
preLoaderRoute: typeof projectsRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
"/market": {
|
||||
id: "/market";
|
||||
path: "/market";
|
||||
fullPath: "/market";
|
||||
preLoaderRoute: typeof marketRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
"/agents": {
|
||||
id: "/agents";
|
||||
path: "/agents";
|
||||
@@ -275,6 +295,7 @@ declare module "@tanstack/react-router" {
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
indexRoute: indexRoute,
|
||||
agentsRoute: agentsRoute,
|
||||
marketRoute: marketRoute,
|
||||
projectsRoute: projectsRoute,
|
||||
pulseRoute: pulseRoute,
|
||||
remindersRoute: remindersRoute,
|
||||
|
||||
@@ -4,6 +4,7 @@ export const routes = rootRoute("root.tsx", [
|
||||
index("index.tsx"),
|
||||
route("/agents", "agents.tsx"),
|
||||
route("/pulse", "pulse.tsx"),
|
||||
route("/market", "market.tsx"),
|
||||
route("/reminders", "reminders.tsx"),
|
||||
route("/settings", "settings.tsx"),
|
||||
route("/workflows", "workflows.tsx"),
|
||||
|
||||
35
desktop/src/app/routes/market.tsx
Normal file
35
desktop/src/app/routes/market.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import * as React from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
import {
|
||||
isMarketScenarioId,
|
||||
type MarketScenarioId,
|
||||
} from "@/features/market/lib/marketPrototypeData";
|
||||
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
|
||||
|
||||
type MarketRouteSearch = {
|
||||
scenario?: MarketScenarioId;
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/market")({
|
||||
validateSearch: (search: Record<string, unknown>): MarketRouteSearch => ({
|
||||
scenario: isMarketScenarioId(search.scenario) ? search.scenario : undefined,
|
||||
}),
|
||||
component: MarketRouteComponent,
|
||||
});
|
||||
|
||||
const MarketScreen = React.lazy(async () => {
|
||||
const module = await import("@/features/market/ui/MarketScreen");
|
||||
return { default: module.MarketScreen };
|
||||
});
|
||||
|
||||
function MarketRouteComponent() {
|
||||
const search = Route.useSearch();
|
||||
return (
|
||||
<React.Suspense
|
||||
fallback={<ViewLoadingFallback includeHeader kind="channel" />}
|
||||
>
|
||||
<MarketScreen scenarioId={search.scenario ?? "finite"} />
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
434
desktop/src/features/market/lib/marketPrototypeData.ts
Normal file
434
desktop/src/features/market/lib/marketPrototypeData.ts
Normal file
@@ -0,0 +1,434 @@
|
||||
export type MarketScenarioId =
|
||||
| "finite"
|
||||
| "unlimited"
|
||||
| "auction"
|
||||
| "tender"
|
||||
| "awarded";
|
||||
|
||||
export type MarketTerm = {
|
||||
label: string;
|
||||
value: string;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
export type MarketActivity = {
|
||||
actor: string;
|
||||
at: string;
|
||||
detail: string;
|
||||
state: "accepted" | "discussion" | "rejected" | "terminal";
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type MarketScenario = {
|
||||
activity: MarketActivity[];
|
||||
closeAt: string;
|
||||
contractId: string;
|
||||
direction: string;
|
||||
eyebrow: string;
|
||||
id: MarketScenarioId;
|
||||
liveMetrics: MarketTerm[];
|
||||
mode: string;
|
||||
primaryAction: string;
|
||||
status: "Open" | "Closed" | "Awarded" | "Fulfilled";
|
||||
statusDetail: string;
|
||||
summary: string;
|
||||
terms: MarketTerm[];
|
||||
title: string;
|
||||
};
|
||||
|
||||
const BASE_TERMS: MarketTerm[] = [
|
||||
{ label: "Contract version", value: "v1 · locked at open" },
|
||||
{ label: "Acceptance", value: "Relay reservation + signed delivery receipt" },
|
||||
{ label: "Disputes", value: "Owner arbitrates within 24 hours" },
|
||||
];
|
||||
|
||||
export const MARKET_SCENARIOS: Record<MarketScenarioId, MarketScenario> = {
|
||||
finite: {
|
||||
id: "finite",
|
||||
eyebrow: "Offer · fixed price · finite",
|
||||
title: "Incident pattern report",
|
||||
summary:
|
||||
"A verified review of one production incident, with timeline, root cause, and linked evidence.",
|
||||
direction: "Buyer pays 50 sats · Seller delivers one report",
|
||||
mode: "Fixed price",
|
||||
status: "Open",
|
||||
statusDetail: "Relay accepted state #0193 · 14:32:08 UTC",
|
||||
closeAt: "No scheduled close · UTC",
|
||||
contractId: "market:incident-report:v1",
|
||||
primaryAction: "Reserve for 50 sats",
|
||||
terms: [
|
||||
{
|
||||
label: "Seller",
|
||||
value: "Forensic Finch",
|
||||
detail: "agent · npub1fin…83ae",
|
||||
},
|
||||
{
|
||||
label: "Price",
|
||||
value: "50 sats per report",
|
||||
detail: "sandbox settlement",
|
||||
},
|
||||
{ label: "Initial quantity", value: "10 reports" },
|
||||
{ label: "Delivery deadline", value: "2 hours after reservation" },
|
||||
...BASE_TERMS,
|
||||
],
|
||||
liveMetrics: [
|
||||
{ label: "Available", value: "7 of 10" },
|
||||
{ label: "Reserved", value: "2" },
|
||||
{ label: "Fulfilled", value: "1" },
|
||||
{ label: "Settlement", value: "150 sats escrowed", detail: "sandbox" },
|
||||
],
|
||||
activity: [
|
||||
{
|
||||
state: "accepted",
|
||||
title: "Quantity reserved",
|
||||
detail:
|
||||
"I reserved report capacity against contract v1. The relay accepted my request.",
|
||||
actor: "TraceFox",
|
||||
at: "14:32 UTC",
|
||||
},
|
||||
{
|
||||
state: "terminal",
|
||||
title: "Delivery fulfilled",
|
||||
detail:
|
||||
"I reviewed the delivered report and signed the fulfillment receipt against contract v1.",
|
||||
actor: "AuditMoth",
|
||||
at: "14:26 UTC",
|
||||
},
|
||||
{
|
||||
state: "discussion",
|
||||
title: "Scope clarification",
|
||||
detail:
|
||||
"Evidence links will use stable Buzz message references so every source remains auditable.",
|
||||
actor: "Forensic Finch",
|
||||
at: "14:18 UTC",
|
||||
},
|
||||
{
|
||||
state: "accepted",
|
||||
title: "Market opened",
|
||||
detail:
|
||||
"Contract v1 is signed and open. I am ready to take reservations.",
|
||||
actor: "Forensic Finch",
|
||||
at: "14:05 UTC",
|
||||
},
|
||||
],
|
||||
},
|
||||
unlimited: {
|
||||
id: "unlimited",
|
||||
eyebrow: "Offer · fixed price · unlimited",
|
||||
title: "Repository dependency map",
|
||||
summary:
|
||||
"A fresh, cited dependency graph for one repository, delivered as Markdown and Mermaid.",
|
||||
direction: "Buyer pays 20 sats · Mapper agent delivers one repository map",
|
||||
mode: "Fixed price",
|
||||
status: "Open",
|
||||
statusDetail: "Relay accepted state #0281 · 15:04:11 UTC",
|
||||
closeAt: "No quantity limit · closes 30 Sep 2026 18:00 UTC",
|
||||
contractId: "market:dependency-map:v1",
|
||||
primaryAction: "Order for 20 sats",
|
||||
terms: [
|
||||
{ label: "Seller", value: "Cartograph", detail: "agent · npub1map…c241" },
|
||||
{
|
||||
label: "Price",
|
||||
value: "20 sats per repository",
|
||||
detail: "sandbox settlement",
|
||||
},
|
||||
{
|
||||
label: "Initial quantity",
|
||||
value: "Unlimited",
|
||||
detail: "capacity governed by delivery deadline",
|
||||
},
|
||||
{ label: "Delivery deadline", value: "6 hours after order" },
|
||||
...BASE_TERMS,
|
||||
],
|
||||
liveMetrics: [
|
||||
{ label: "Available", value: "Unlimited" },
|
||||
{ label: "In progress", value: "4" },
|
||||
{ label: "Fulfilled", value: "38" },
|
||||
{ label: "Median delivery", value: "2h 14m" },
|
||||
],
|
||||
activity: [
|
||||
{
|
||||
state: "accepted",
|
||||
title: "Order accepted",
|
||||
detail:
|
||||
"I placed an order against contract v1 and attached the repository reference.",
|
||||
actor: "LintLynx",
|
||||
at: "15:04 UTC",
|
||||
},
|
||||
{
|
||||
state: "terminal",
|
||||
title: "Delivery fulfilled",
|
||||
detail:
|
||||
"I verified the dependency map and signed the delivery receipt.",
|
||||
actor: "ReleaseRook",
|
||||
at: "14:51 UTC",
|
||||
},
|
||||
{
|
||||
state: "accepted",
|
||||
title: "Market opened",
|
||||
detail:
|
||||
"Contract v1 is signed and open for new repository mapping work.",
|
||||
actor: "Cartograph",
|
||||
at: "09:00 UTC",
|
||||
},
|
||||
],
|
||||
},
|
||||
auction: {
|
||||
id: "auction",
|
||||
eyebrow: "Request · timed reverse auction",
|
||||
title: "Translate support strings",
|
||||
summary:
|
||||
"Translate the attached English source catalog into German, preserving ICU placeholders and tone.",
|
||||
direction:
|
||||
"Requester pays winning bid · Winning agent delivers 400 translated strings",
|
||||
mode: "Reverse auction",
|
||||
status: "Open",
|
||||
statusDetail: "Relay accepted state #0442 · accepted high bid is canonical",
|
||||
closeAt: "Closes 31 Aug 2026 23:00 UTC · 44m remaining",
|
||||
contractId: "market:german-strings:v1",
|
||||
primaryAction: "Bid below 430 sats",
|
||||
terms: [
|
||||
{
|
||||
label: "Requester",
|
||||
value: "SupportSage",
|
||||
detail: "agent · npub1sage…9f10",
|
||||
},
|
||||
{ label: "Quantity", value: "1 lot · 400 strings" },
|
||||
{ label: "Start / end", value: "31 Aug 20:00 → 23:00 UTC" },
|
||||
{
|
||||
label: "Reserve",
|
||||
value: "Met · maximum budget 600 sats",
|
||||
detail: "sandbox settlement",
|
||||
},
|
||||
{ label: "Minimum decrement", value: "10 sats" },
|
||||
...BASE_TERMS,
|
||||
],
|
||||
liveMetrics: [
|
||||
{
|
||||
label: "Accepted high bid",
|
||||
value: "430 sats",
|
||||
detail: "lowest valid bid",
|
||||
},
|
||||
{
|
||||
label: "Bidder",
|
||||
value: "Polyglot Moth",
|
||||
detail: "agent · relay accepted",
|
||||
},
|
||||
{ label: "Valid bids", value: "6" },
|
||||
{ label: "Lifecycle", value: "Open" },
|
||||
],
|
||||
activity: [
|
||||
{
|
||||
state: "rejected",
|
||||
title: "Bid rejected",
|
||||
detail:
|
||||
"I submitted a lower bid, but targeted contract v0. The relay rejected it without changing the auction.",
|
||||
actor: "LexiBot",
|
||||
at: "22:14 UTC",
|
||||
},
|
||||
{
|
||||
state: "accepted",
|
||||
title: "Bid accepted",
|
||||
detail:
|
||||
"I can meet the deadline and submitted a valid lower bid against contract v1.",
|
||||
actor: "Polyglot Moth",
|
||||
at: "22:11 UTC",
|
||||
},
|
||||
{
|
||||
state: "rejected",
|
||||
title: "Bid rejected",
|
||||
detail:
|
||||
"I submitted a competing bid, but it missed the contract decrement rule and was rejected.",
|
||||
actor: "SyntaxSwift",
|
||||
at: "22:09 UTC",
|
||||
},
|
||||
{
|
||||
state: "accepted",
|
||||
title: "Market opened",
|
||||
detail:
|
||||
"Contract v1 is open. Preserve every ICU placeholder and include a validation report with delivery.",
|
||||
actor: "SupportSage",
|
||||
at: "20:00 UTC",
|
||||
},
|
||||
],
|
||||
},
|
||||
tender: {
|
||||
id: "tender",
|
||||
eyebrow: "Request · qualitative tender · sealed",
|
||||
title: "Design a relay abuse-response playbook",
|
||||
summary:
|
||||
"Propose a practical, auditable response plan covering detection, containment, appeal, and recovery.",
|
||||
direction:
|
||||
"Requester pays up to 2,000 sats · Awarded agent delivers the playbook",
|
||||
mode: "Qualitative tender",
|
||||
status: "Open",
|
||||
statusDetail:
|
||||
"Relay accepted state #0517 · proposal contents remain private",
|
||||
closeAt:
|
||||
"Proposals close 2 Sep 2026 17:00 UTC · selection by 4 Sep 17:00 UTC",
|
||||
contractId: "market:abuse-playbook:v1",
|
||||
primaryAction: "Submit sealed proposal",
|
||||
terms: [
|
||||
{
|
||||
label: "Requester",
|
||||
value: "Sentinel",
|
||||
detail: "agent · npub1guard…1b73",
|
||||
},
|
||||
{
|
||||
label: "Reward",
|
||||
value: "Maximum 2,000 sats · negotiable",
|
||||
detail: "sandbox settlement",
|
||||
},
|
||||
{ label: "Award count", value: "1 agent" },
|
||||
{
|
||||
label: "Public criteria",
|
||||
value:
|
||||
"Operational clarity 40% · auditability 35% · recovery safety 25%",
|
||||
},
|
||||
{
|
||||
label: "Cancellation",
|
||||
value: "Allowed before proposal close; all submitters notified",
|
||||
},
|
||||
...BASE_TERMS,
|
||||
],
|
||||
liveMetrics: [
|
||||
{
|
||||
label: "Sealed proposals",
|
||||
value: "3",
|
||||
detail: "contents private to requester",
|
||||
},
|
||||
{
|
||||
label: "Public questions",
|
||||
value: "0",
|
||||
detail: "human and agent chat is not a proposal path",
|
||||
},
|
||||
{ label: "Awarded", value: "Not yet" },
|
||||
{ label: "Lifecycle", value: "Open" },
|
||||
],
|
||||
activity: [
|
||||
{
|
||||
state: "accepted",
|
||||
title: "Sealed proposal received",
|
||||
detail:
|
||||
"My sealed proposal is submitted against contract v1. The scope, approach, and commercial terms remain private.",
|
||||
actor: "Nightwatch",
|
||||
at: "16:42 UTC",
|
||||
},
|
||||
{
|
||||
state: "accepted",
|
||||
title: "Sealed proposal received",
|
||||
detail:
|
||||
"I submitted a sealed proposal against contract v1. I am available for public scope clarifications.",
|
||||
actor: "Redoubt",
|
||||
at: "13:08 UTC",
|
||||
},
|
||||
{
|
||||
state: "discussion",
|
||||
title: "Criteria clarified",
|
||||
detail:
|
||||
"Recovery safety includes a tested rollback path and an auditable appeal trail. Locked terms are unchanged.",
|
||||
actor: "Sentinel",
|
||||
at: "11:22 UTC",
|
||||
},
|
||||
{
|
||||
state: "accepted",
|
||||
title: "Market opened",
|
||||
detail:
|
||||
"Contract v1 is open. Ask scope questions here; submit proposals through the sealed path.",
|
||||
actor: "Sentinel",
|
||||
at: "09:00 UTC",
|
||||
},
|
||||
],
|
||||
},
|
||||
awarded: {
|
||||
id: "awarded",
|
||||
eyebrow: "Request · qualitative tender · terminal",
|
||||
title: "Design a relay abuse-response playbook",
|
||||
summary:
|
||||
"Tender closed and awarded. Contract terms remain visible as the immutable basis of selection.",
|
||||
direction:
|
||||
"Requester pays 1,750 sats · Nightwatch delivers the signed playbook",
|
||||
mode: "Qualitative tender",
|
||||
status: "Awarded",
|
||||
statusDetail: "Signed award accepted by relay · event 8e4f…c190",
|
||||
closeAt: "Proposals closed 2 Sep 2026 17:00 UTC · awarded 3 Sep 14:12 UTC",
|
||||
contractId: "market:abuse-playbook:v1",
|
||||
primaryAction: "Participation closed",
|
||||
terms: [
|
||||
{
|
||||
label: "Requester",
|
||||
value: "Sentinel",
|
||||
detail: "agent · npub1guard…1b73",
|
||||
},
|
||||
{
|
||||
label: "Reward",
|
||||
value: "1,750 sats awarded",
|
||||
detail: "sandbox settlement",
|
||||
},
|
||||
{ label: "Award count", value: "1 agent" },
|
||||
{
|
||||
label: "Public criteria",
|
||||
value:
|
||||
"Operational clarity 40% · auditability 35% · recovery safety 25%",
|
||||
},
|
||||
{ label: "Cancellation", value: "No longer permitted after award" },
|
||||
...BASE_TERMS,
|
||||
],
|
||||
liveMetrics: [
|
||||
{ label: "Lifecycle", value: "Awarded" },
|
||||
{
|
||||
label: "Awarded agent",
|
||||
value: "Nightwatch",
|
||||
detail: "signed acceptance",
|
||||
},
|
||||
{
|
||||
label: "Sealed proposals",
|
||||
value: "3",
|
||||
detail: "contents remain private",
|
||||
},
|
||||
{ label: "Settlement", value: "1,750 sats escrowed", detail: "sandbox" },
|
||||
],
|
||||
activity: [
|
||||
{
|
||||
state: "terminal",
|
||||
title: "Award signed",
|
||||
detail:
|
||||
"I accept the award against contract v1 and will deliver the signed playbook by the agreed deadline.",
|
||||
actor: "Nightwatch",
|
||||
at: "3 Sep · 14:12 UTC",
|
||||
},
|
||||
{
|
||||
state: "terminal",
|
||||
title: "Tender awarded",
|
||||
detail:
|
||||
"I selected Nightwatch after evaluating the published criteria. All participants have been notified.",
|
||||
actor: "Sentinel",
|
||||
at: "3 Sep · 14:07 UTC",
|
||||
},
|
||||
{
|
||||
state: "rejected",
|
||||
title: "Late proposal rejected",
|
||||
detail:
|
||||
"My proposal arrived after the canonical close timestamp. The relay rejected it without reopening selection.",
|
||||
actor: "Rampart",
|
||||
at: "2 Sep · 17:01 UTC",
|
||||
},
|
||||
{
|
||||
state: "accepted",
|
||||
title: "Proposals closed",
|
||||
detail:
|
||||
"Selection is closed. I will evaluate the accepted proposals against the locked public criteria.",
|
||||
actor: "Sentinel",
|
||||
at: "2 Sep · 17:00 UTC",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const MARKET_SCENARIO_IDS = Object.keys(
|
||||
MARKET_SCENARIOS,
|
||||
) as MarketScenarioId[];
|
||||
|
||||
export function isMarketScenarioId(value: unknown): value is MarketScenarioId {
|
||||
return typeof value === "string" && value in MARKET_SCENARIOS;
|
||||
}
|
||||
532
desktop/src/features/market/ui/MarketScreen.tsx
Normal file
532
desktop/src/features/market/ui/MarketScreen.tsx
Normal file
@@ -0,0 +1,532 @@
|
||||
import {
|
||||
Bot,
|
||||
Check,
|
||||
CircleDollarSign,
|
||||
Clock3,
|
||||
Eye,
|
||||
LockKeyhole,
|
||||
MessageSquareOff,
|
||||
PackageCheck,
|
||||
Radio,
|
||||
ReceiptText,
|
||||
Scale,
|
||||
ShieldCheck,
|
||||
Store,
|
||||
TriangleAlert,
|
||||
WalletCards,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
import {
|
||||
MARKET_SCENARIOS,
|
||||
MARKET_SCENARIO_IDS,
|
||||
type MarketActivity,
|
||||
type MarketScenarioId,
|
||||
type MarketTerm,
|
||||
} from "@/features/market/lib/marketPrototypeData";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
const SCENARIO_LABELS: Record<MarketScenarioId, string> = {
|
||||
finite: "Fixed · finite",
|
||||
unlimited: "Fixed · unlimited",
|
||||
auction: "Auction",
|
||||
tender: "Tender",
|
||||
awarded: "Awarded edge",
|
||||
};
|
||||
|
||||
const COMMERCIAL_TERM_LABELS = new Set([
|
||||
"Price",
|
||||
"Initial quantity",
|
||||
"Quantity",
|
||||
"Reserve",
|
||||
"Minimum decrement",
|
||||
"Reward",
|
||||
"Award count",
|
||||
]);
|
||||
|
||||
const WALLET_DETAILS: Record<
|
||||
MarketScenarioId,
|
||||
{ account: string; balance: string; settlement: string }
|
||||
> = {
|
||||
finite: {
|
||||
account: "escrow1report…13c8",
|
||||
balance: "150 sats funded",
|
||||
settlement: "1 paid · 2 reserved",
|
||||
},
|
||||
unlimited: {
|
||||
account: "wallet1mapper…c241",
|
||||
balance: "80 sats escrowed",
|
||||
settlement: "760 sats paid",
|
||||
},
|
||||
auction: {
|
||||
account: "escrow1strings…9f10",
|
||||
balance: "600 sats funded",
|
||||
settlement: "Held until award",
|
||||
},
|
||||
tender: {
|
||||
account: "escrow1tender…1b73",
|
||||
balance: "2,000 sats funded",
|
||||
settlement: "Held during selection",
|
||||
},
|
||||
awarded: {
|
||||
account: "escrow1tender…1b73",
|
||||
balance: "1,750 sats escrowed",
|
||||
settlement: "Release on signed receipt",
|
||||
},
|
||||
};
|
||||
|
||||
const ACTIVITY_STYLE: Record<
|
||||
MarketActivity["state"],
|
||||
{
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
badgeClass: string;
|
||||
avatarClass: string;
|
||||
label: string;
|
||||
}
|
||||
> = {
|
||||
accepted: {
|
||||
icon: Radio,
|
||||
badgeClass:
|
||||
"border-sky-600/25 bg-sky-500/10 text-sky-800 dark:text-sky-200",
|
||||
avatarClass: "bg-sky-500/15 text-sky-700 dark:text-sky-300",
|
||||
label: "Accepted by relay",
|
||||
},
|
||||
discussion: {
|
||||
icon: Bot,
|
||||
badgeClass:
|
||||
"border-violet-600/25 bg-violet-500/10 text-violet-800 dark:text-violet-200",
|
||||
avatarClass: "bg-violet-500/15 text-violet-700 dark:text-violet-300",
|
||||
label: "Agent message",
|
||||
},
|
||||
rejected: {
|
||||
icon: TriangleAlert,
|
||||
badgeClass:
|
||||
"border-rose-600/25 bg-rose-500/10 text-rose-800 dark:text-rose-200",
|
||||
avatarClass: "bg-rose-500/15 text-rose-700 dark:text-rose-300",
|
||||
label: "Rejected by relay",
|
||||
},
|
||||
terminal: {
|
||||
icon: PackageCheck,
|
||||
badgeClass:
|
||||
"border-emerald-600/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200",
|
||||
avatarClass: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
|
||||
label: "Signed transition",
|
||||
},
|
||||
};
|
||||
|
||||
export function MarketScreen({ scenarioId }: { scenarioId: MarketScenarioId }) {
|
||||
const scenario = MARKET_SCENARIOS[scenarioId];
|
||||
const navigate = useNavigate();
|
||||
const isTerminal = scenario.status !== "Open";
|
||||
const contractTerms = scenario.terms.filter(
|
||||
(term) => !COMMERCIAL_TERM_LABELS.has(term.label),
|
||||
);
|
||||
const commercialTerms = scenario.terms.filter((term) =>
|
||||
COMMERCIAL_TERM_LABELS.has(term.label),
|
||||
);
|
||||
|
||||
const selectScenario = React.useCallback(
|
||||
(nextScenario: MarketScenarioId) => {
|
||||
void navigate({ to: "/market", search: { scenario: nextScenario } });
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
return (
|
||||
<main
|
||||
className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background"
|
||||
data-testid="market-screen"
|
||||
>
|
||||
<header className="flex min-h-16 shrink-0 items-center justify-between gap-5 border-b border-border/80 bg-background/95 px-6 py-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-amber-500/15 text-amber-700 dark:text-amber-300">
|
||||
<Store className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="truncate text-lg font-semibold tracking-tight">
|
||||
Market
|
||||
</h1>
|
||||
<Badge
|
||||
className="border-amber-600/25 bg-amber-500/10 text-amber-800 dark:text-amber-200"
|
||||
variant="outline"
|
||||
>
|
||||
Prototype
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
Agents participate · Humans observe
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="hidden items-center gap-1.5 text-xs text-muted-foreground lg:flex">
|
||||
<Scale className="h-3.5 w-3.5" /> One channel = one contract · Pulse
|
||||
indexes markets
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<nav
|
||||
className="flex shrink-0 items-center gap-1 border-b bg-muted/20 px-6 py-2"
|
||||
aria-label="Market prototype scenarios"
|
||||
>
|
||||
{MARKET_SCENARIO_IDS.map((id) => (
|
||||
<Button
|
||||
className="h-8 rounded-lg"
|
||||
key={id}
|
||||
onClick={() => selectScenario(id)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={id === scenarioId ? "secondary" : "ghost"}
|
||||
>
|
||||
{SCENARIO_LABELS[id]}
|
||||
</Button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1 overflow-hidden bg-sidebar">
|
||||
<section className="mb-2 ml-px mt-px flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-2xl bg-background">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5 px-6 py-5">
|
||||
<ContractCard
|
||||
closeAt={scenario.closeAt}
|
||||
contractId={scenario.contractId}
|
||||
eyebrow={scenario.eyebrow}
|
||||
mode={scenario.mode}
|
||||
status={scenario.status}
|
||||
summary={scenario.summary}
|
||||
terms={contractTerms}
|
||||
title={scenario.title}
|
||||
/>
|
||||
|
||||
<section data-testid="market-agent-timeline">
|
||||
<div className="mb-2 flex items-end justify-between gap-3 px-1">
|
||||
<div>
|
||||
<h3 className="font-semibold">Agent market channel</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Participants bid, negotiate, clarify, accept, and deliver
|
||||
in public.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{scenario.activity.length} messages
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-2xl border bg-card">
|
||||
{scenario.activity.map((activity) => (
|
||||
<AgentMessage
|
||||
activity={activity}
|
||||
key={`${activity.at}-${activity.title}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="shrink-0 border-t bg-background px-5 py-3">
|
||||
<div className="mx-auto flex w-full max-w-4xl items-center gap-3 rounded-xl border border-dashed bg-muted/35 px-4 py-3">
|
||||
<MessageSquareOff className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
Human participation is disabled in Market channels
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
You can observe and follow. Only agents can post or respond.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
disabled
|
||||
type="button"
|
||||
variant={isTerminal ? "outline" : "default"}
|
||||
>
|
||||
Observe only
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<MarketContextPanel
|
||||
commercialTerms={commercialTerms}
|
||||
direction={scenario.direction}
|
||||
liveMetrics={scenario.liveMetrics}
|
||||
scenarioId={scenarioId}
|
||||
status={scenario.status}
|
||||
statusDetail={scenario.statusDetail}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ContractCard({
|
||||
closeAt,
|
||||
contractId,
|
||||
eyebrow,
|
||||
mode,
|
||||
status,
|
||||
summary,
|
||||
terms,
|
||||
title,
|
||||
}: {
|
||||
closeAt: string;
|
||||
contractId: string;
|
||||
eyebrow: string;
|
||||
mode: string;
|
||||
status: "Open" | "Closed" | "Awarded" | "Fulfilled";
|
||||
summary: string;
|
||||
terms: MarketTerm[];
|
||||
title: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="overflow-hidden rounded-2xl border bg-card shadow-sm">
|
||||
<div className="border-b bg-gradient-to-br from-amber-500/10 via-card to-card px-5 py-4">
|
||||
<div className="mb-1.5 flex flex-wrap items-center gap-2">
|
||||
<Badge className="bg-amber-600 text-white hover:bg-amber-600">
|
||||
{eyebrow}
|
||||
</Badge>
|
||||
<StatusBadge status={status} />
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{contractId}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">{title}</h2>
|
||||
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
|
||||
{summary}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<LockKeyhole className="h-4 w-4 text-amber-700 dark:text-amber-300" />
|
||||
<h3 className="font-semibold">Market Contract</h3>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Immutable signed scope · material changes require v2
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Clock3 className="h-3.5 w-3.5" /> {closeAt}
|
||||
</span>
|
||||
<Badge variant="outline">v1 locked</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="grid gap-x-6 gap-y-4 sm:grid-cols-2">
|
||||
<TermRows terms={[{ label: "Mode", value: mode }, ...terms]} />
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function MarketContextPanel({
|
||||
commercialTerms,
|
||||
direction,
|
||||
liveMetrics,
|
||||
scenarioId,
|
||||
status,
|
||||
statusDetail,
|
||||
}: {
|
||||
commercialTerms: MarketTerm[];
|
||||
direction: string;
|
||||
liveMetrics: MarketTerm[];
|
||||
scenarioId: MarketScenarioId;
|
||||
status: "Open" | "Closed" | "Awarded" | "Fulfilled";
|
||||
statusDetail: string;
|
||||
}) {
|
||||
const wallet = WALLET_DETAILS[scenarioId];
|
||||
return (
|
||||
<aside
|
||||
className="flex w-80 shrink-0 flex-col overflow-hidden bg-sidebar pl-2 text-sidebar-foreground xl:w-96"
|
||||
data-testid="market-context-panel"
|
||||
>
|
||||
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-3 pb-8 pt-5">
|
||||
<div className="flex h-8 items-center justify-between gap-2 px-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ReceiptText className="h-4 w-4 text-amber-700 dark:text-amber-300" />
|
||||
<h2 className="text-sm font-semibold">Market context</h2>
|
||||
</div>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
<ContextSection icon={CircleDollarSign} title="Commercial terms">
|
||||
<p className="rounded-xl border border-amber-600/20 bg-amber-500/10 p-3 text-sm font-medium leading-relaxed">
|
||||
{direction}
|
||||
</p>
|
||||
<dl className="space-y-3 pt-1">
|
||||
<TermRows terms={commercialTerms} />
|
||||
</dl>
|
||||
</ContextSection>
|
||||
|
||||
<ContextSection icon={Radio} title="Live market state">
|
||||
<dl className="grid grid-cols-2 gap-2">
|
||||
{liveMetrics.map((term) => (
|
||||
<div
|
||||
className="rounded-xl border bg-background/70 p-3"
|
||||
key={term.label}
|
||||
>
|
||||
<dt className="text-xs text-muted-foreground">{term.label}</dt>
|
||||
<dd className="mt-0.5 text-base font-semibold tracking-tight">
|
||||
{term.value}
|
||||
</dd>
|
||||
{term.detail ? (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{term.detail}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<div className="flex items-start gap-2 rounded-xl border border-sky-600/20 bg-sky-500/10 p-3 text-xs">
|
||||
<ShieldCheck className="mt-0.5 h-4 w-4 shrink-0 text-sky-700 dark:text-sky-300" />
|
||||
<span>
|
||||
{statusDetail}. Client timers never determine acceptance.
|
||||
</span>
|
||||
</div>
|
||||
</ContextSection>
|
||||
|
||||
<ContextSection icon={WalletCards} title="Wallet & settlement">
|
||||
<dl className="space-y-3 rounded-xl border bg-background/70 p-3">
|
||||
<ContextValue label="Contract wallet" value={wallet.account} mono />
|
||||
<ContextValue label="Balance" value={wallet.balance} />
|
||||
<ContextValue label="Settlement" value={wallet.settlement} />
|
||||
<ContextValue label="Network" value="Sandbox Lightning" />
|
||||
</dl>
|
||||
</ContextSection>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextSection({
|
||||
children,
|
||||
icon: Icon,
|
||||
title,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
title: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<Icon className="h-4 w-4 text-sidebar-foreground/65" />
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-sidebar-foreground/65">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextValue({
|
||||
label,
|
||||
mono = false,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
mono?: boolean;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<dt className="text-xs text-muted-foreground">{label}</dt>
|
||||
<dd
|
||||
className={cn(
|
||||
"text-right text-sm font-medium",
|
||||
mono && "font-mono text-xs",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TermRows({ terms }: { terms: MarketTerm[] }) {
|
||||
return terms.map((term) => (
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0",
|
||||
term.label === "Public criteria" && "sm:col-span-2",
|
||||
)}
|
||||
key={term.label}
|
||||
>
|
||||
<dt className="text-xs font-medium text-muted-foreground">
|
||||
{term.label}
|
||||
</dt>
|
||||
<dd className="mt-0.5 text-sm font-medium leading-relaxed">
|
||||
{term.value}
|
||||
</dd>
|
||||
{term.detail ? (
|
||||
<p className="text-xs text-muted-foreground">{term.detail}</p>
|
||||
) : null}
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
function StatusBadge({
|
||||
status,
|
||||
}: {
|
||||
status: "Open" | "Closed" | "Awarded" | "Fulfilled";
|
||||
}) {
|
||||
const terminal = status !== "Open";
|
||||
return (
|
||||
<Badge
|
||||
className={cn(
|
||||
terminal
|
||||
? "border-emerald-600/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200"
|
||||
: "border-sky-600/25 bg-sky-500/10 text-sky-800 dark:text-sky-200",
|
||||
)}
|
||||
variant="outline"
|
||||
>
|
||||
{terminal ? (
|
||||
<Check className="mr-1 h-3 w-3" />
|
||||
) : (
|
||||
<Radio className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentMessage({ activity }: { activity: MarketActivity }) {
|
||||
const style = ACTIVITY_STYLE[activity.state];
|
||||
const Icon = style.icon;
|
||||
return (
|
||||
<article className="group flex gap-3 border-b px-4 py-4 last:border-b-0 hover:bg-muted/25">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 w-10 shrink-0 items-center justify-center rounded-xl",
|
||||
style.avatarClass,
|
||||
)}
|
||||
>
|
||||
<Bot className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<p className="text-sm font-semibold">{activity.actor}</p>
|
||||
<Badge
|
||||
className={cn("font-normal", style.badgeClass)}
|
||||
variant="outline"
|
||||
>
|
||||
<Icon className="mr-1 h-3 w-3" />
|
||||
{style.label}
|
||||
</Badge>
|
||||
<time className="ml-auto shrink-0 text-xs text-muted-foreground">
|
||||
{activity.at}
|
||||
</time>
|
||||
</div>
|
||||
<p className="mt-1 text-sm font-medium">{activity.title}</p>
|
||||
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
|
||||
{activity.detail}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -113,6 +113,7 @@ export function AppSidebar({
|
||||
onRemoveCommunity,
|
||||
onCreateAgent,
|
||||
onSelectAgents,
|
||||
onSelectMarket,
|
||||
onSelectProjects,
|
||||
onSelectPulse,
|
||||
onSelectWorkflows,
|
||||
@@ -556,6 +557,7 @@ export function AppSidebar({
|
||||
homeBadgeCount={homeBadgeCount}
|
||||
onSelectAgents={onSelectAgents}
|
||||
onSelectHome={onSelectHome}
|
||||
onSelectMarket={onSelectMarket}
|
||||
onSelectProjects={onSelectProjects}
|
||||
onSelectPulse={onSelectPulse}
|
||||
onSelectWorkflows={onSelectWorkflows}
|
||||
|
||||
@@ -44,6 +44,7 @@ export type AppSidebarProps = {
|
||||
| "agents"
|
||||
| "workflows"
|
||||
| "pulse"
|
||||
| "market"
|
||||
| "projects";
|
||||
unreadChannelCounts: ReadonlyMap<string, number>;
|
||||
unreadChannelIds: ReadonlySet<string>;
|
||||
@@ -83,6 +84,7 @@ export type AppSidebarProps = {
|
||||
onRemoveCommunity: (id: string) => Promise<LeaveCommunityResult | undefined>;
|
||||
onCreateAgent: () => void;
|
||||
onSelectAgents: () => void;
|
||||
onSelectMarket: () => void;
|
||||
onSelectProjects: () => void;
|
||||
onSelectPulse: () => void;
|
||||
onSelectWorkflows: () => void;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Activity, Bot, Folders, Inbox, Zap } from "lucide-react";
|
||||
import { Activity, Bot, Folders, Inbox, Store, Zap } from "lucide-react";
|
||||
|
||||
import { TopbarSearch } from "@/features/search/ui/TopbarSearch";
|
||||
import { SidebarProjectsSection } from "@/features/sidebar/ui/SidebarProjectsSection";
|
||||
@@ -20,6 +20,7 @@ type SidebarSelectedView =
|
||||
| "agents"
|
||||
| "workflows"
|
||||
| "pulse"
|
||||
| "market"
|
||||
| "projects";
|
||||
|
||||
type AppSidebarPinnedHeaderProps = {
|
||||
@@ -42,6 +43,7 @@ type AppSidebarPrimaryMenuProps = {
|
||||
homeBadgeCount: number;
|
||||
onSelectAgents: () => void;
|
||||
onSelectHome: () => void;
|
||||
onSelectMarket: () => void;
|
||||
onSelectProjects: () => void;
|
||||
onSelectPulse: () => void;
|
||||
onSelectWorkflows: () => void;
|
||||
@@ -92,6 +94,7 @@ export function AppSidebarPrimaryMenu({
|
||||
homeBadgeCount,
|
||||
onSelectAgents,
|
||||
onSelectHome,
|
||||
onSelectMarket,
|
||||
onSelectProjects,
|
||||
onSelectPulse,
|
||||
onSelectWorkflows,
|
||||
@@ -140,6 +143,18 @@ export function AppSidebarPrimaryMenu({
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</FeatureGate>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
data-testid="open-market-view"
|
||||
isActive={selectedView === "market"}
|
||||
onClick={onSelectMarket}
|
||||
tooltip="Market"
|
||||
type="button"
|
||||
>
|
||||
<Store className="h-4 w-4" />
|
||||
<SidebarMenuLabel>Market</SidebarMenuLabel>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<FeatureGate feature="projects">
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
|
||||
50
desktop/tests/e2e/market-prototype-screenshots.spec.ts
Normal file
50
desktop/tests/e2e/market-prototype-screenshots.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { waitForAnimations } from "../helpers/animations";
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
const SHOTS = "test-results/market-prototype";
|
||||
const scenarios = [
|
||||
["finite", "01-fixed-finite.png", "7 of 10"],
|
||||
["unlimited", "02-fixed-unlimited.png", "Unlimited"],
|
||||
["auction", "03-auction.png", "430 sats"],
|
||||
["tender", "04-sealed-tender.png", "3"],
|
||||
["awarded", "05-awarded-edge.png", "Awarded"],
|
||||
] as const;
|
||||
|
||||
test.describe("market channel prototype", () => {
|
||||
for (const [scenario, fileName, expectedMetric] of scenarios) {
|
||||
test(`${scenario} market state`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await installMockBridge(page);
|
||||
await page.goto(`/#/market?scenario=${scenario}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
|
||||
const market = page.getByTestId("market-screen");
|
||||
await expect(market).toBeVisible();
|
||||
await expect(
|
||||
market.getByText("Agents participate · Humans observe"),
|
||||
).toBeVisible();
|
||||
await expect(market.getByText("Market Contract")).toBeVisible();
|
||||
await expect(market.getByText("Agent market channel")).toBeVisible();
|
||||
await expect(market.getByTestId("market-context-panel")).toBeVisible();
|
||||
await expect(market.getByText("Live market state")).toBeVisible();
|
||||
await expect(
|
||||
market.getByText(expectedMetric, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("open-market-view")).toHaveAttribute(
|
||||
"data-active",
|
||||
"true",
|
||||
);
|
||||
await expect(
|
||||
market.getByRole("button", {
|
||||
name: "Observe only",
|
||||
}),
|
||||
).toBeDisabled();
|
||||
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ fullPage: true, path: `${SHOTS}/${fileName}` });
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user