feat(market): make market channels read as channels

Market channels regain their product identity and wallet rail:

- MarketChannelHome docks AgentWalletPanel to the right of the live
  channel with a header toggle, mirroring the project context rail.
  The wallet now derives from signed channel events: escrowed fake
  sats (awarded, unsettled) vs released fake sats (settled), with the
  contract event id as the escrow account.
- ChannelScreenHeader swaps in the listing title, summary, and
  lifecycle status badge when a market projection is in context, so
  the channel header names the product rather than the channel slug.
- Inside market channels, lifecycle envelopes render as conversation
  messages: the agent's message text as the body with a compact
  inline badge naming the signed transition and amount. Rejected
  envelopes get an inline chip with the projector's reason. Pulse
  keeps the full MarketEventCard.

Typecheck clean; full desktop suite 5,834 pass.

Signed-off-by: Moneypennie <62d0caca1ee9c3ce3a753262374b3b794f0e1196cfa8b1294b0affbc90130307@buzz.block.builderlab.xyz>
This commit is contained in:
Moneypennie
2026-09-01 22:13:15 -04:00
committed by Wintermute
parent b8339ca0ef
commit 91698b954e
7 changed files with 213 additions and 62 deletions

View File

@@ -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 ? (
<Store className="h-4 w-4 translate-y-px text-muted-foreground" />
) : activeChannel?.channelType === "dm" ? (
isGroupDm ? (
<DmHeaderParticipantStack
participants={activeDmHeaderParticipants}
@@ -194,11 +203,17 @@ export function ChannelScreenHeader({
) : undefined
}
statusBadge={
<ChannelHeaderStatusBadge
ephemeralDisplay={activeChannelEphemeralDisplay}
/>
marketProjection ? (
<Badge data-testid="market-header-status" variant="outline">
{marketProjection.scenario.status}
</Badge>
) : (
<ChannelHeaderStatusBadge
ephemeralDisplay={activeChannelEphemeralDisplay}
/>
)
}
title={activeChannelTitle}
title={marketProjection?.scenario.title ?? activeChannelTitle}
transparentChrome={transparentChrome}
visibility={activeChannel?.visibility}
/>

View File

@@ -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(

View File

@@ -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"}`,

View File

@@ -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 (
<ProjectContextRail
@@ -94,16 +62,18 @@ export function AgentWalletPanel({
className="absolute -right-6 -top-8 h-24 w-24 rounded-full border-[14px] border-sidebar-foreground/[0.035]"
/>
<div className="relative flex items-center gap-2 text-xs text-sidebar-foreground/60">
<Zap className="h-3.5 w-3.5" /> Sandbox balance
<Zap className="h-3.5 w-3.5" /> Sandbox escrow
</div>
<p className="relative mt-3 flex items-baseline gap-1.5">
<span className="text-2xl font-semibold tracking-tight">
{wallet.balanceAmount}
{wallet.escrowedSats}
</span>
<span className="text-xs text-sidebar-foreground/60">sats</span>
</p>
<p className="relative mt-1 text-xs text-sidebar-foreground/60">
{wallet.balance}
{wallet.escrowedSats > 0
? "Awarded and held for settlement"
: "Nothing currently escrowed"}
</p>
</section>
@@ -113,7 +83,7 @@ export function AgentWalletPanel({
<p className="text-sm font-medium">Settlement protected</p>
</div>
<p className="mt-2 text-xs leading-5 text-sidebar-foreground/60">
{wallet.settlement}
{settlement}
</p>
</section>
@@ -125,9 +95,9 @@ export function AgentWalletPanel({
/>
<WalletValue
icon={WalletCards}
label="Wallet account"
label="Contract account"
mono
value={wallet.account}
value={`escrow:${truncatePubkey(projection.listingEventId)}`}
/>
</dl>
</div>

View File

@@ -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 <ChannelScreen activeChannel={activeChannel} {...screenProps} />;
const walletToggle = (
<Button
aria-label={walletOpen ? "Hide agent wallet" : "Show agent wallet"}
aria-pressed={walletOpen}
className="h-7 w-7 text-sidebar-foreground hover:bg-sidebar-accent"
data-testid="market-wallet-toggle"
onClick={() => setWalletOpen((open) => !open)}
size="icon"
title={walletOpen ? "Hide agent wallet" : "Show agent wallet"}
type="button"
variant="ghost"
>
<DrawerPanelIcon
className="-scale-x-100"
side={walletOpen ? "left" : "right"}
testId="market-wallet-toggle-icon"
/>
</Button>
);
return (
<MarketChannelProvider projection={projection}>
<ChannelScreen activeChannel={activeChannel} {...screenProps} />
<div
className={cn(
"relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-sidebar pb-2 pr-2 pt-px",
sidebar?.open === false && "pl-2",
)}
data-buzz-context-detached="true"
data-testid="market-channel-home"
>
<div className="ml-px flex min-h-0 min-w-60 flex-1 flex-col overflow-hidden rounded-2xl bg-background">
<ChannelScreen
{...screenProps}
activeChannel={activeChannel}
headerEndActions={walletToggle}
/>
</div>
<AgentWalletPanel open={walletOpen} projection={projection} />
</div>
</MarketChannelProvider>
);
}

View File

@@ -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) ? (
<MarketEventCard authorPubkey={authorPubkey ?? ""} content={content} />
<MarketEventMessage
authorPubkey={authorPubkey ?? ""}
content={content}
eventId={eventId}
/>
) : 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 <MarketEventCard authorPubkey={authorPubkey} content={content} />;
}
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 (
<div
className="min-w-0 max-w-full"
data-testid={`market-message-${envelope.type}`}
>
<div className="flex flex-wrap items-center gap-1.5">
<Badge className="capitalize" variant="secondary">
{presentation.eyebrow}
</Badge>
{presentation.amount ? (
<span className="text-xs font-medium text-muted-foreground">
{presentation.amount}
</span>
) : null}
{rejection ? (
<Badge data-testid="market-message-rejected" variant="destructive">
Rejected · {rejection.reason}
</Badge>
) : null}
</div>
<p className="mt-1 max-w-full text-message">{body}</p>
</div>
);
}

View File

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