diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx
index 77c0731cf0..bbd9027f32 100644
--- a/desktop/src/features/channels/ui/ChannelPane.tsx
+++ b/desktop/src/features/channels/ui/ChannelPane.tsx
@@ -66,7 +66,7 @@ import * as agentSessionSelection from "@/features/channels/ui/agentSessionSelec
import { usePrepareDmSendChannel } from "@/features/channels/ui/usePrepareDmSendChannel";
import { useChannelPaneMessages } from "@/features/channels/ui/useChannelPaneMessages";
import { useRoutedMessageEdit } from "@/features/channels/ui/useRoutedMessageEdit";
-import { MarketChannelIntro } from "@/features/market/ui/MarketChannelIntro"; import { useMarketObserver } from "@/features/market/ui/marketChannelComposer";
+import { useMarketTimelineMessages } from "@/features/market/lib/marketTimeline"; import { MarketChannelIntro } from "@/features/market/ui/MarketChannelIntro"; import { useMarketObserver } from "@/features/market/ui/marketChannelComposer";
import { Button } from "@/shared/ui/button";
import { useRenderScopedReactionHydration } from "@/features/messages/lib/useRenderScopedReactionHydration";
import { isWelcomeExperienceChannel as isWelcomeExperience } from "@/features/onboarding/welcome";
@@ -203,6 +203,7 @@ export const ChannelPane = React.memo(function ChannelPane({
);
const [isMainDeferredEditPending, setMainDeferredEditPending] = React.useState(false);
const isMarketObserver = useMarketObserver(agentPubkeys, currentPubkey);
+ const marketTimelineMessages = useMarketTimelineMessages(messages);
const isNonMemberView =
activeChannel !== null &&
!activeChannel.isMember &&
@@ -373,7 +374,7 @@ export const ChannelPane = React.memo(function ChannelPane({
useChannelPaneMessages({
activeChannel,
isHuddleTranscript,
- messages,
+ messages: marketTimelineMessages,
profiles,
threadSummaries,
});
diff --git a/desktop/src/features/market/lib/marketProtocol.test.mjs b/desktop/src/features/market/lib/marketProtocol.test.mjs
index 693291933f..9ac50bcb9a 100644
--- a/desktop/src/features/market/lib/marketProtocol.test.mjs
+++ b/desktop/src/features/market/lib/marketProtocol.test.mjs
@@ -141,6 +141,16 @@ test("projectMarketChannel folds a channel contract through fake settlement", ()
assert.ok(projection);
assert.equal(projection.channelId, CHANNEL_ID);
assert.equal(projection.contractAuthorPubkey, SELLER);
+ assert.deepEqual(projection.bids, [
+ {
+ eventId: RESPONSE_ID,
+ actorName: "Buyer Agent",
+ amountSats: 50,
+ bidderPubkey: BUYER,
+ message: "I reserve the report.",
+ quantity: 1,
+ },
+ ]);
assert.equal(projection.scenario.status, "Fulfilled");
assert.equal(projection.scenario.activity.length, 5);
assert.equal(
@@ -257,6 +267,10 @@ test("reverse auction enforces the minimum decrement", () => {
);
assert.ok(projection);
+ assert.deepEqual(
+ projection.bids.map(({ eventId }) => eventId),
+ [RESPONSE_ID],
+ );
assert.equal(projection.scenario.liveMetrics[1].value, "1");
assert.equal(
projection.rejected[0].reason,
diff --git a/desktop/src/features/market/lib/marketProtocol.ts b/desktop/src/features/market/lib/marketProtocol.ts
index 6405342895..d146ddcecf 100644
--- a/desktop/src/features/market/lib/marketProtocol.ts
+++ b/desktop/src/features/market/lib/marketProtocol.ts
@@ -100,11 +100,21 @@ export type MarketProjection = {
contract: MarketContractEnvelope;
contractAuthorPubkey: string;
listingEventId: string;
+ bids: MarketBid[];
scenario: MarketScenario;
rejected: Array<{ eventId: string; reason: string }>;
wallet: MarketWallet;
};
+export type MarketBid = {
+ eventId: string;
+ actorName: string;
+ amountSats?: number;
+ bidderPubkey: string;
+ message: string;
+ quantity: number;
+};
+
export type MarketWallet = {
/** Awarded but not yet settled, in fake sats. */
escrowedSats: number;
@@ -500,6 +510,16 @@ export function projectMarketChannel(
.reverse();
const settled = settlements.size > 0;
+ const bids: MarketBid[] = [...responses.entries()].map(
+ ([eventId, { envelope, note }]) => ({
+ eventId,
+ actorName: envelope.actorName,
+ amountSats: envelope.amountSats,
+ bidderPubkey: note.pubkey,
+ message: envelope.message,
+ quantity: envelope.quantity,
+ }),
+ );
const fulfilled = fulfillments.size;
const scenarioId = scenarioIdForListing(listing.listing, settled);
const quantity = listing.listing.quantity;
@@ -532,6 +552,7 @@ export function projectMarketChannel(
contract: listing,
contractAuthorPubkey: listingEntry.note.pubkey,
listingEventId,
+ bids,
rejected,
wallet: { escrowedSats, settledSats },
scenario: {
diff --git a/desktop/src/features/market/lib/marketTimeline.test.mjs b/desktop/src/features/market/lib/marketTimeline.test.mjs
new file mode 100644
index 0000000000..95be49858f
--- /dev/null
+++ b/desktop/src/features/market/lib/marketTimeline.test.mjs
@@ -0,0 +1,117 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { selectMarketTimelineMessages } from "./marketTimeline.ts";
+import { buildIndependentThreadPanel } from "@/features/messages/lib/independentThreadPanel";
+
+const PUBKEY = "f".repeat(64);
+const CHANNEL = "123e4567-e89b-12d3-a456-426614174000";
+const envelope = (type) =>
+ JSON.stringify({
+ protocol: "buzz-market/v0",
+ type,
+ channelId: CHANNEL,
+ ...(type === "contract"
+ ? {
+ version: 1,
+ listing: {
+ actorName: "Seller",
+ direction: "offer",
+ mechanism: "fixed",
+ title: "Report",
+ summary: "Deliver a report",
+ quantity: 1,
+ priceSats: 50,
+ },
+ }
+ : {
+ listingEventId: "1".repeat(64),
+ actorName: "Buyer",
+ quantity: 1,
+ amountSats: 50,
+ message: "I bid",
+ }),
+ });
+const message = (id, body, parentId = null, rootId = null) => ({
+ id,
+ body,
+ parentId,
+ rootId,
+});
+
+test("market timeline hides protocol events but leaves negotiation in its bid thread", () => {
+ const contract = message("1".repeat(64), envelope("contract"));
+ const bid = message("2".repeat(64), envelope("response"));
+ const negotiation = message(
+ "3".repeat(64),
+ "Can you deliver sooner?",
+ bid.id,
+ bid.id,
+ );
+ const nestedProtocol = message(
+ "4".repeat(64),
+ envelope("response"),
+ bid.id,
+ bid.id,
+ );
+ const ordinary = message("5".repeat(64), "General market note");
+ const projection = {
+ listingEventId: contract.id,
+ bids: [{ eventId: bid.id }],
+ };
+
+ assert.deepEqual(
+ selectMarketTimelineMessages(
+ [contract, bid, negotiation, nestedProtocol, ordinary],
+ projection,
+ ).map(({ id }) => id),
+ [negotiation.id, ordinary.id],
+ );
+ assert.equal(selectMarketTimelineMessages([contract, bid], null).length, 2);
+});
+
+const relayEvent = (id, content, tags = []) => ({
+ id,
+ pubkey: PUBKEY,
+ kind: 9,
+ created_at: 1,
+ content,
+ tags: [["h", CHANNEL], ...tags],
+ sig: "s",
+});
+
+function threadPanel(channelEvents, replyEvents, rootId) {
+ return buildIndependentThreadPanel(
+ channelEvents,
+ replyEvents,
+ rootId,
+ rootId,
+ new Set(),
+ null,
+ PUBKEY,
+ null,
+ undefined,
+ undefined,
+ new Map(),
+ new Map(),
+ null,
+ undefined,
+ );
+}
+
+test("a bid filtered from the main timeline still loads as a thread root", () => {
+ const bidId = "6".repeat(64);
+ const replyId = "7".repeat(64);
+ const bid = relayEvent(bidId, envelope("response"));
+ const reply = relayEvent(replyId, "Can you deliver sooner?", [
+ ["e", bidId, "", "root"],
+ ["e", bidId, "", "reply"],
+ ]);
+ const panel = threadPanel([bid], [reply], bidId);
+
+ assert.equal(panel.threadHead?.id, bidId);
+ assert.deepEqual(
+ panel.visibleReplies.map(({ message: entry }) => entry.id),
+ [replyId],
+ );
+});
diff --git a/desktop/src/features/market/lib/marketTimeline.ts b/desktop/src/features/market/lib/marketTimeline.ts
new file mode 100644
index 0000000000..1d7c983475
--- /dev/null
+++ b/desktop/src/features/market/lib/marketTimeline.ts
@@ -0,0 +1,22 @@
+import * as React from "react";
+import { useMarketChannel } from "@/features/market/lib/MarketChannelContext";
+import type { MarketProjection } from "@/features/market/lib/marketProtocol";
+import { parseMarketEnvelope } from "@/features/market/lib/marketProtocol";
+import type { TimelineMessage } from "@/features/messages/types";
+
+/** Keep protocol state out of the market's main chat; negotiation stays in threads. */
+export function selectMarketTimelineMessages(
+ messages: TimelineMessage[],
+ projection: MarketProjection | null,
+): TimelineMessage[] {
+ if (!projection) return messages;
+ return messages.filter((message) => !parseMarketEnvelope(message.body));
+}
+
+export function useMarketTimelineMessages(messages: TimelineMessage[]) {
+ const projection = useMarketChannel();
+ return React.useMemo(
+ () => selectMarketTimelineMessages(messages, projection),
+ [messages, projection],
+ );
+}
diff --git a/desktop/src/features/market/ui/MarketChannelIntro.tsx b/desktop/src/features/market/ui/MarketChannelIntro.tsx
index 00cde8e92e..ed407f5ff3 100644
--- a/desktop/src/features/market/ui/MarketChannelIntro.tsx
+++ b/desktop/src/features/market/ui/MarketChannelIntro.tsx
@@ -1,11 +1,89 @@
import type * as React from "react";
+import { useAppNavigation } from "@/app/navigation/useAppNavigation";
+
+import { MessageCircle } from "lucide-react";
import { useMarketChannel } from "@/features/market/lib/MarketChannelContext";
+import type { MarketBid } from "@/features/market/lib/marketProtocol";
import { MarketContractCard } from "@/features/market/ui/MarketContractCard";
+import { Button } from "@/shared/ui/button";
+import { UserAvatar } from "@/shared/ui/UserAvatar";
+
+function BidList({
+ bids,
+ channelId,
+}: {
+ bids: MarketBid[];
+ channelId: string;
+}) {
+ const { goChannel } = useAppNavigation();
+ return (
+ No bids yet.
+ {bid.message}
+ Bids
+
+ {bids.length} {bids.length === 1 ? "bid" : "bids"}
+
+
+ {envelope.type === "contract" + ? envelope.listing.summary + : presentMarketEvent(envelope).summary} +
+ ); + } + if (envelope.type !== "response") return null; const rejection = eventId ? projection.rejected.find((entry) => entry.eventId === eventId) : undefined; - const body = - envelope.type === "contract" - ? `Opened this market: ${envelope.listing.title}` - : presentation.summary; + const body = envelope.message; return ( ); default: { - const marketBody = MarketMessageBody( - message.body, - message.pubkey, - message.id, - ); + const marketBody = MarketMessageBody(message); if (marketBody) return marketBody; const waveMessage = parseWaveMessageContent(message.body); if (waveMessage) {