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) {