diff --git a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx index 3ccabdf753..38b6242768 100644 --- a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx +++ b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx @@ -1,4 +1,4 @@ -import { LogIn, SquareTerminal } from "lucide-react"; +import { LogIn, SquareTerminal, Store } from "lucide-react"; import type * as React from "react"; import { ChatHeader } from "@/features/chat/ui/ChatHeader"; @@ -9,6 +9,7 @@ import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDi import { ChannelGlyph } from "@/features/channels/ui/ChannelGlyph"; import { ChannelHeaderStatusBadge } from "@/features/channels/ui/ChannelHeaderStatusBadge"; import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar"; +import { useMarketChannel } from "@/features/market/lib/MarketChannelContext"; import { DEFAULT_HOVER_PROFILE_STATUS_GEOMETRY, ProfileAvatarWithStatus, @@ -16,6 +17,7 @@ import { } from "@/features/profile/ui/ProfileAvatarWithStatus"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { Button } from "@/shared/ui/button"; +import { Badge } from "@/shared/ui/badge"; import type { Channel, PresenceStatus } from "@/shared/api/types"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { @@ -70,6 +72,7 @@ export function ChannelScreenHeader({ onManageChannel, onToggleMembers, }: ChannelScreenHeaderProps) { + const marketProjection = useMarketChannel(); const isGroupDm = activeChannel?.channelType === "dm" && activeDmHeaderParticipants.length > 1; @@ -143,9 +146,15 @@ export function ChannelScreenHeader({ chromeWrapperRef={chromeWrapperRef} actions={actions} channelType={activeChannel?.channelType} - description={getChannelDescription(activeChannel)} + description={ + marketProjection + ? marketProjection.scenario.summary + : getChannelDescription(activeChannel) + } leadingContent={ - activeChannel?.channelType === "dm" ? ( + marketProjection ? ( + + ) : activeChannel?.channelType === "dm" ? ( isGroupDm ? ( + marketProjection ? ( + + {marketProjection.scenario.status} + + ) : ( + + ) } - title={activeChannelTitle} + title={marketProjection?.scenario.title ?? activeChannelTitle} transparentChrome={transparentChrome} visibility={activeChannel?.visibility} /> diff --git a/desktop/src/features/market/lib/marketProtocol.test.mjs b/desktop/src/features/market/lib/marketProtocol.test.mjs index 3e2f5e2730..693291933f 100644 --- a/desktop/src/features/market/lib/marketProtocol.test.mjs +++ b/desktop/src/features/market/lib/marketProtocol.test.mjs @@ -148,6 +148,7 @@ test("projectMarketChannel folds a channel contract through fake settlement", () "Sandbox settlement complete", ); assert.deepEqual(projection.rejected, []); + assert.deepEqual(projection.wallet, { escrowedSats: 0, settledSats: 50 }); }); test("channel tags and the first top-level contract define the canonical market", () => { @@ -263,6 +264,40 @@ test("reverse auction enforces the minimum decrement", () => { ); }); +test("an awarded but unsettled market escrows the award total", () => { + const projection = projectMarketChannel( + [ + note(LISTING_ID, SELLER, 100, contract({ quantity: 2 })), + note( + RESPONSE_ID, + BUYER, + 101, + lifecycle("response", { + actorName: "Buyer Agent", + quantity: 2, + amountSats: 50, + message: "I reserve two reports.", + }), + ), + note( + AWARD_ID, + SELLER, + 102, + lifecycle("award", { + responseEventId: RESPONSE_ID, + actorName: "Seller Agent", + quantity: 2, + amountSats: 50, + }), + ), + ], + CHANNEL_ID, + ); + + assert.ok(projection); + assert.deepEqual(projection.wallet, { escrowedSats: 100, settledSats: 0 }); +}); + test("a lifecycle envelope cannot be accepted twice under one event id", () => { const sharedEventId = RESPONSE_ID; const projection = projectMarketChannel( diff --git a/desktop/src/features/market/lib/marketProtocol.ts b/desktop/src/features/market/lib/marketProtocol.ts index 9629cf43ef..6405342895 100644 --- a/desktop/src/features/market/lib/marketProtocol.ts +++ b/desktop/src/features/market/lib/marketProtocol.ts @@ -102,6 +102,14 @@ export type MarketProjection = { listingEventId: string; scenario: MarketScenario; rejected: Array<{ eventId: string; reason: string }>; + wallet: MarketWallet; +}; + +export type MarketWallet = { + /** Awarded but not yet settled, in fake sats. */ + escrowedSats: number; + /** Total released by signed settlements, in fake sats. */ + settledSats: number; }; export function parseMarketEnvelope(content: string): MarketEnvelope | null { @@ -511,6 +519,13 @@ export function projectMarketChannel( : awards.size > 0 ? "Awarded" : "Open"; + let escrowedSats = 0; + let settledSats = 0; + for (const award of awards.values()) { + const total = award.envelope.amountSats * award.envelope.quantity; + if (settlements.has(award.note.id)) settledSats += total; + else escrowedSats += total; + } return { channelId, @@ -518,6 +533,7 @@ export function projectMarketChannel( contractAuthorPubkey: listingEntry.note.pubkey, listingEventId, rejected, + wallet: { escrowedSats, settledSats }, scenario: { id: scenarioId, eyebrow: `${listing.listing.direction === "offer" ? "Offer" : "Request"} · ${listing.listing.mechanism.replace("-", " ")} · ${quantity === "unlimited" ? "unlimited" : "finite"}`, diff --git a/desktop/src/features/market/ui/AgentWalletPanel.tsx b/desktop/src/features/market/ui/AgentWalletPanel.tsx index a068ef5a82..6280990a29 100644 --- a/desktop/src/features/market/ui/AgentWalletPanel.tsx +++ b/desktop/src/features/market/ui/AgentWalletPanel.tsx @@ -1,67 +1,35 @@ import { CircleCheck, Radio, WalletCards, Zap } from "lucide-react"; import type * as React from "react"; -import type { MarketScenarioId } from "@/features/market/lib/marketPrototypeData"; +import type { MarketProjection } from "@/features/market/lib/marketProtocol"; import { ProjectContextRail } from "@/features/projects/ui/ProjectContextRail"; import { ProjectHomeColumn } from "@/features/projects/ui/ProjectHomeColumn"; import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth"; import { SIDEBAR_WIDTH_MIN } from "@/shared/layout/sidebarLayout"; +import { truncatePubkey } from "@/shared/lib/pubkey"; const MARKET_WALLET_WIDTH_KEY = "buzz.desktop.market-wallet-width"; -const WALLET_DETAILS: Record< - MarketScenarioId, - { - account: string; - balance: string; - balanceAmount: string; - settlement: string; - } -> = { - finite: { - account: "escrow1report…13c8", - balance: "Funded for 3 reports", - balanceAmount: "150", - settlement: "1 paid · 2 reserved", - }, - unlimited: { - account: "wallet1mapper…c241", - balance: "Available for settlement", - balanceAmount: "80", - settlement: "760 sats paid", - }, - auction: { - account: "escrow1strings…9f10", - balance: "Auction reserve funded", - balanceAmount: "600", - settlement: "Held until award", - }, - tender: { - account: "escrow1tender…1b73", - balance: "Tender reward funded", - balanceAmount: "2,000", - settlement: "Held during selection", - }, - awarded: { - account: "escrow1tender…1b73", - balance: "Award held in escrow", - balanceAmount: "1,750", - settlement: "Release on signed receipt", - }, -}; - export function AgentWalletPanel({ open, - scenarioId, + projection, }: { open: boolean; - scenarioId: MarketScenarioId; + projection: MarketProjection; }) { const width = useThreadPanelWidth(undefined, { minWidthPx: SIDEBAR_WIDTH_MIN, sessionKey: MARKET_WALLET_WIDTH_KEY, }); - const wallet = WALLET_DETAILS[scenarioId]; + const { wallet } = projection; + const settlement = + wallet.settledSats > 0 && wallet.escrowedSats > 0 + ? `${wallet.settledSats} sats paid · ${wallet.escrowedSats} escrowed` + : wallet.settledSats > 0 + ? `${wallet.settledSats} fake sats released on signed receipts` + : wallet.escrowedSats > 0 + ? "Held until fulfillment is settled" + : "No awards yet · nothing escrowed"; return (
- Sandbox balance + Sandbox escrow

- {wallet.balanceAmount} + {wallet.escrowedSats} sats

- {wallet.balance} + {wallet.escrowedSats > 0 + ? "Awarded and held for settlement" + : "Nothing currently escrowed"}

@@ -113,7 +83,7 @@ export function AgentWalletPanel({

Settlement protected

- {wallet.settlement} + {settlement}

@@ -125,9 +95,9 @@ export function AgentWalletPanel({ /> diff --git a/desktop/src/features/market/ui/MarketChannelHome.tsx b/desktop/src/features/market/ui/MarketChannelHome.tsx index dc2dc3f2b1..d4e3ac81e8 100644 --- a/desktop/src/features/market/ui/MarketChannelHome.tsx +++ b/desktop/src/features/market/ui/MarketChannelHome.tsx @@ -1,10 +1,16 @@ +import * as React from "react"; import type { ComponentProps } from "react"; import { ChannelScreen } from "@/features/channels/ui/ChannelScreen"; import { MarketChannelProvider } from "@/features/market/lib/MarketChannelContext"; import { useMarketChannelProjection } from "@/features/market/lib/useMarketChannels"; import type { MarketAnnouncement } from "@/features/market/lib/marketProtocol"; +import { AgentWalletPanel } from "@/features/market/ui/AgentWalletPanel"; import type { Channel } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { DrawerPanelIcon } from "@/shared/ui/DrawerPanelIcon"; +import { useOptionalSidebar } from "@/shared/ui/sidebar"; export function MarketChannelHome({ activeChannel, @@ -15,12 +21,50 @@ export function MarketChannelHome({ announcement: MarketAnnouncement; }) { const projection = useMarketChannelProjection(activeChannel, announcement); + const sidebar = useOptionalSidebar(); + const [walletOpen, setWalletOpen] = React.useState(true); if (!projection) return ; + const walletToggle = ( + + ); + return ( - +
+
+ +
+ +
); } diff --git a/desktop/src/features/market/ui/MarketMessageBody.tsx b/desktop/src/features/market/ui/MarketMessageBody.tsx index 79b4298ae7..8912dfe417 100644 --- a/desktop/src/features/market/ui/MarketMessageBody.tsx +++ b/desktop/src/features/market/ui/MarketMessageBody.tsx @@ -1,8 +1,75 @@ +import { useMarketChannel } from "@/features/market/lib/MarketChannelContext"; +import { presentMarketEvent } from "@/features/market/lib/marketEventPresentation"; import { parseMarketEnvelope } from "@/features/market/lib/marketProtocol"; import { MarketEventCard } from "@/features/market/ui/MarketEventCard"; +import { Badge } from "@/shared/ui/badge"; -export function MarketMessageBody(content: string, authorPubkey?: string) { +export function MarketMessageBody( + content: string, + authorPubkey?: string, + eventId?: string, +) { return parseMarketEnvelope(content) ? ( - + ) : null; } + +/** + * Inside a market channel, lifecycle envelopes read as ordinary chat + * messages: the agent's words as the body, with a small inline badge naming + * the signed transition. Outside market channels (Pulse, forwarded events) + * the full card treatment remains. + */ +function MarketEventMessage({ + authorPubkey, + content, + eventId, +}: { + authorPubkey: string; + content: string; + eventId?: string; +}) { + const projection = useMarketChannel(); + const envelope = parseMarketEnvelope(content); + if (!envelope) return null; + if (!projection) { + return ; + } + + const presentation = presentMarketEvent(envelope); + const rejection = eventId + ? projection.rejected.find((entry) => entry.eventId === eventId) + : undefined; + const body = + envelope.type === "contract" + ? `Opened this market: ${envelope.listing.title}` + : presentation.summary; + + return ( +
+
+ + {presentation.eyebrow} + + {presentation.amount ? ( + + {presentation.amount} + + ) : null} + {rejection ? ( + + Rejected · {rejection.reason} + + ) : null} +
+

{body}

+
+ ); +} diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index a705d91759..cc7ed23e21 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -409,7 +409,11 @@ export const MessageRow = React.memo( /> ); default: { - const marketBody = MarketMessageBody(message.body, message.pubkey); + const marketBody = MarketMessageBody( + message.body, + message.pubkey, + message.id, + ); if (marketBody) return marketBody; const waveMessage = parseWaveMessageContent(message.body); if (waveMessage) {