From 9c79d27e548761e7c5c69074b982cbcb0949b94a Mon Sep 17 00:00:00 2001 From: "pizzalord.eth" Date: Mon, 10 Aug 2026 09:00:24 -0500 Subject: [PATCH 1/4] feat(desktop): add community magic board Render shared channel canvases as a responsive Board while preserving Stream routing for existing deep links and actions. Co-authored-by: pizzalord.eth Signed-off-by: pizzalord.eth --- desktop/playwright.config.ts | 1 + desktop/src/app/routes/ChannelRouteScreen.tsx | 97 +++++- .../channels.$channelId.posts.$postId.tsx | 1 + .../src/app/routes/channels.$channelId.tsx | 3 + .../channels/lib/canvasBoard.test.mjs | 135 ++++++++ .../src/features/channels/lib/canvasBoard.ts | 168 ++++++++++ .../src/features/channels/ui/ChannelBoard.tsx | 317 ++++++++++++++++++ .../channels/ui/ChannelBoardScreen.tsx | 109 ++++++ .../channels/ui/ChannelScreenHeader.tsx | 10 +- .../channels/ui/ChannelViewModeContext.tsx | 79 +++++ desktop/src/testing/e2eBridge.ts | 30 +- desktop/tests/e2e/magic-board.spec.ts | 185 ++++++++++ desktop/tests/helpers/bridge.ts | 5 + 13 files changed, 1117 insertions(+), 23 deletions(-) create mode 100644 desktop/src/features/channels/lib/canvasBoard.test.mjs create mode 100644 desktop/src/features/channels/lib/canvasBoard.ts create mode 100644 desktop/src/features/channels/ui/ChannelBoard.tsx create mode 100644 desktop/src/features/channels/ui/ChannelBoardScreen.tsx create mode 100644 desktop/src/features/channels/ui/ChannelViewModeContext.tsx create mode 100644 desktop/tests/e2e/magic-board.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 1af27b58ef2..0c72e80c1d6 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -30,6 +30,7 @@ export default defineConfig({ "**/exact-key-profile.spec.ts", "**/key-import-reveal.spec.ts", "**/navigation.spec.ts", + "**/magic-board.spec.ts", "**/channels.spec.ts", "**/channel-shared-header-backdrop.spec.ts", "**/auxiliary-pane-close-visibility.spec.ts", diff --git a/desktop/src/app/routes/ChannelRouteScreen.tsx b/desktop/src/app/routes/ChannelRouteScreen.tsx index 50371bc369f..357559246a8 100644 --- a/desktop/src/app/routes/ChannelRouteScreen.tsx +++ b/desktop/src/app/routes/ChannelRouteScreen.tsx @@ -4,9 +4,15 @@ import { useQueryClient } from "@tanstack/react-query"; import type { SearchHighlightNavigation } from "@/app/navigation/searchHighlightNavigation"; import { getCachedSearchHitEvent } from "@/app/navigation/searchHitEventCache"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; -import { useChannelsQuery } from "@/features/channels/hooks"; +import { useCanvasQuery, useChannelsQuery } from "@/features/channels/hooks"; import { useOpenChannelDirectoryQuery } from "@/features/channels/openChannelDirectory"; +import { + type ChannelViewMode, + resolveChannelViewMode, +} from "@/features/channels/lib/canvasBoard"; +import { ChannelBoardScreen } from "@/features/channels/ui/ChannelBoardScreen"; import { ChannelScreen } from "@/features/channels/ui/ChannelScreen"; +import { ChannelViewModeProvider } from "@/features/channels/ui/ChannelViewModeContext"; import { HuddleStartingView } from "@/features/huddle/components/HuddleStartingView"; import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow"; import { @@ -34,6 +40,7 @@ type ChannelRouteScreenProps = { autoSendDraftKey: string | null; channelId: string; searchHighlight: SearchHighlightNavigation | null | undefined; + hasStreamRouteIntent: boolean; selectedPostId: string | null; targetMessageId: string | null; targetReplyId: string | null; @@ -115,6 +122,7 @@ export function ChannelRouteScreen({ autoSendDraftKey, channelId, searchHighlight, + hasStreamRouteIntent, selectedPostId, targetMessageId, targetReplyId, @@ -159,6 +167,41 @@ export function ChannelRouteScreen({ ); const projectHome = enumeratedProjectHome ?? projectHomeLookupQuery.data ?? null; + const canvasQuery = useCanvasQuery( + activeChannel?.id ?? null, + activeChannel !== null && activeChannel.channelType !== "dm", + ); + const [viewSelection, setViewSelection] = React.useState<{ + channelId: string; + mode: ChannelViewMode; + } | null>(null); + const hasRouteTarget = Boolean( + hasStreamRouteIntent || + searchHighlight || + selectedPostId || + targetMessageId || + targetReplyId || + targetThreadRootId, + ); + const explicitView = + viewSelection?.channelId === channelId ? viewSelection.mode : null; + const channelView = resolveChannelViewMode({ + channelName: activeChannel?.name ?? null, + channelType: activeChannel?.channelType ?? null, + explicitView, + hasCanvas: Boolean(canvasQuery.data?.content?.trim()), + hasRouteTarget, + }); + const handleViewModeChange = React.useCallback( + (mode: ChannelViewMode) => setViewSelection({ channelId, mode }), + [channelId], + ); + + React.useEffect(() => { + if (hasRouteTarget) { + setViewSelection({ channelId, mode: "stream" }); + } + }, [channelId, hasRouteTarget]); const [targetMessageEvents, setTargetMessageEvents] = React.useState< RelayEvent[] >(() => { @@ -309,23 +352,41 @@ export function ChannelRouteScreen({ } return ( - { - void closeForumPost(channelId); - }} - onSelectForumPost={(postId) => { - void goForumPost(channelId, postId); + + > + {channelView.mode === "board" && activeChannel ? ( + + ) : ( + { + void closeForumPost(channelId); + }} + onSelectForumPost={(postId) => { + void goForumPost(channelId, postId); + }} + selectedForumPostId={selectedPostId} + targetForumReplyId={targetReplyId} + targetMessageEvents={targetMessageEvents} + targetMessageId={targetMessageId} + targetSearchMessageId={activeSearchHighlight?.messageId} + targetSearchQuery={activeSearchHighlight?.query} + /> + )} + ); } diff --git a/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx b/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx index 8fcab41817d..6ca9b5ebef7 100644 --- a/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx +++ b/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx @@ -46,6 +46,7 @@ function ForumPostRouteComponent() { autoSendDraftKey={null} channelId={channelId} searchHighlight={searchHighlight} + hasStreamRouteIntent={false} selectedPostId={postId} targetMessageId={null} targetReplyId={search.replyId ?? null} diff --git a/desktop/src/app/routes/channels.$channelId.tsx b/desktop/src/app/routes/channels.$channelId.tsx index 7c9d1cc47cc..14b00bbdffa 100644 --- a/desktop/src/app/routes/channels.$channelId.tsx +++ b/desktop/src/app/routes/channels.$channelId.tsx @@ -80,6 +80,9 @@ function ChannelRouteComponent() { autoSendDraftKey={search.autoSend ?? null} channelId={channelId} searchHighlight={searchHighlight} + hasStreamRouteIntent={Boolean( + search.autoSend || search.agentSession || search.profile, + )} selectedPostId={null} targetMessageId={search.messageId ?? null} targetReplyId={null} diff --git a/desktop/src/features/channels/lib/canvasBoard.test.mjs b/desktop/src/features/channels/lib/canvasBoard.test.mjs new file mode 100644 index 00000000000..8694fee4c8f --- /dev/null +++ b/desktop/src/features/channels/lib/canvasBoard.test.mjs @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + classifyCanvasBoardCard, + parseCanvasBoard, + resolveChannelViewMode, +} from "./canvasBoard.ts"; + +test("parseCanvasBoard turns level-two sections into durable cards", () => { + const board = parseCanvasBoard(`# Dispatch — Open Studio 001 + +Bring one seed and leave with one artifact. + +## This week at Sweet Works + +Open Studio 001 is active. + +## Start here + +1. Read the welcome. +2. Open a Workshop thread. + +## Finished example + +[Open the magic mirror](https://example.com) +`); + + assert.equal(board.title, "Dispatch — Open Studio 001"); + assert.equal( + board.introduction, + "Bring one seed and leave with one artifact.", + ); + assert.deepEqual( + board.cards.map(({ kind, title }) => ({ kind, title })), + [ + { kind: "now", title: "This week at Sweet Works" }, + { kind: "welcome", title: "Start here" }, + { kind: "artifact", title: "Finished example" }, + ], + ); +}); + +test("parseCanvasBoard leaves headings inside fences in card bodies", () => { + const board = parseCanvasBoard(`## Notes + +\`\`\`md +## Not a card +\`\`\` + +## Next action + +Ship the proof. +`); + + assert.equal(board.cards.length, 2); + assert.match(board.cards[0].body, /## Not a card/u); + assert.equal(board.cards[1].kind, "invitation"); +}); + +test("parseCanvasBoard does not close a longer fence with a shorter example fence", () => { + const board = parseCanvasBoard(`## Notes + +\`\`\`\`md +\`\`\`md +## Not a card +\`\`\` +\`\`\`\` + +## Next action + +Ship the proof. +`); + + assert.equal(board.cards.length, 2); + assert.match(board.cards[0].body, /## Not a card/u); + assert.equal(board.cards[1].title, "Next action"); +}); + +test("parseCanvasBoard falls back to one overview card", () => { + const board = parseCanvasBoard( + "# A small room\n\nEverything useful lives here.", + ); + + assert.equal(board.title, "A small room"); + assert.equal(board.introduction, ""); + assert.deepEqual(board.cards, [ + { + body: "Everything useful lives here.", + id: "overview-1", + kind: "welcome", + title: "Overview", + }, + ]); +}); + +test("classifyCanvasBoardCard keeps stewardship language visible", () => { + assert.equal(classifyCanvasBoardCard("People and stewards"), "people"); + assert.equal(classifyCanvasBoardCard("Source and story boundary"), "note"); +}); + +test("resolveChannelViewMode makes Dispatch board-first without hiding targets", () => { + assert.deepEqual( + resolveChannelViewMode({ + channelName: "Dispatch", + channelType: "stream", + explicitView: null, + hasCanvas: true, + hasRouteTarget: false, + }), + { boardAvailable: true, mode: "board" }, + ); + + assert.deepEqual( + resolveChannelViewMode({ + channelName: "Dispatch", + channelType: "stream", + explicitView: "board", + hasCanvas: true, + hasRouteTarget: true, + }), + { boardAvailable: true, mode: "stream" }, + ); + + assert.deepEqual( + resolveChannelViewMode({ + channelName: "The Workshop", + channelType: "stream", + explicitView: null, + hasCanvas: true, + hasRouteTarget: false, + }), + { boardAvailable: true, mode: "stream" }, + ); +}); diff --git a/desktop/src/features/channels/lib/canvasBoard.ts b/desktop/src/features/channels/lib/canvasBoard.ts new file mode 100644 index 00000000000..d732673971b --- /dev/null +++ b/desktop/src/features/channels/lib/canvasBoard.ts @@ -0,0 +1,168 @@ +import type { ChannelType } from "@/shared/api/types"; +import { channelNamesMatch } from "@/features/channels/lib/canonicalChannelName"; + +export type CanvasBoardCardKind = + | "artifact" + | "invitation" + | "note" + | "now" + | "people" + | "welcome"; + +export type CanvasBoardCard = { + body: string; + id: string; + kind: CanvasBoardCardKind; + title: string; +}; + +export type CanvasBoard = { + cards: CanvasBoardCard[]; + introduction: string; + title: string | null; +}; + +export type ChannelViewMode = "board" | "stream"; + +const H1_PATTERN = /^#\s+(.+?)\s*#*\s*$/u; +const H2_PATTERN = /^##\s+(.+?)\s*#*\s*$/u; +const FENCE_OPEN_PATTERN = /^ {0,3}(`{3,}|~{3,})/u; +const FENCE_CLOSE_PATTERN = /^ {0,3}(`{3,}|~{3,})[ \t]*$/u; + +function cardId(title: string, index: number): string { + const slug = title + .toLowerCase() + .replace(/[^a-z0-9]+/gu, "-") + .replace(/^-|-$/gu, ""); + return `${slug || "card"}-${index + 1}`; +} + +export function classifyCanvasBoardCard(title: string): CanvasBoardCardKind { + const normalizedTitle = title.toLowerCase(); + + if (/\b(finished|made|shipped|artifact|showcase)\b/u.test(normalizedTitle)) { + return "artifact"; + } + if ( + /\b(help|join|invitation|next|action|participate)\b/u.test(normalizedTitle) + ) { + return "invitation"; + } + if (/\b(people|member|agent|steward|contributor)\b/u.test(normalizedTitle)) { + return "people"; + } + if (/\b(now|today|week|current|happening|active)\b/u.test(normalizedTitle)) { + return "now"; + } + if (/\b(welcome|start|about|orientation)\b/u.test(normalizedTitle)) { + return "welcome"; + } + return "note"; +} + +/** + * Converts a shared Markdown canvas into a title/introduction plus `##` cards. + * Headings inside fenced code blocks remain body content. + */ +export function parseCanvasBoard(content: string): CanvasBoard { + const introductionLines: string[] = []; + const sections: Array<{ title: string; bodyLines: string[] }> = []; + let title: string | null = null; + let activeSection: { title: string; bodyLines: string[] } | null = null; + let activeFence: { character: "`" | "~"; length: number } | null = null; + + for (const line of content.replace(/\r\n?/gu, "\n").split("\n")) { + const wasInsideFence = activeFence !== null; + if (activeFence) { + const closingFenceMatch = line.match(FENCE_CLOSE_PATTERN); + if ( + closingFenceMatch && + closingFenceMatch[1][0] === activeFence.character && + closingFenceMatch[1].length >= activeFence.length + ) { + activeFence = null; + } + } else { + const openingFenceMatch = line.match(FENCE_OPEN_PATTERN); + if (openingFenceMatch) { + activeFence = { + character: openingFenceMatch[1][0] as "`" | "~", + length: openingFenceMatch[1].length, + }; + } + } + + const isFenceBoundary = wasInsideFence || activeFence !== null; + + if (!isFenceBoundary && !activeSection) { + const h1Match = line.match(H1_PATTERN); + if (h1Match && title === null) { + title = h1Match[1].trim(); + continue; + } + } + + if (!isFenceBoundary) { + const h2Match = line.match(H2_PATTERN); + if (h2Match) { + activeSection = { title: h2Match[1].trim(), bodyLines: [] }; + sections.push(activeSection); + continue; + } + } + + if (activeSection) { + activeSection.bodyLines.push(line); + } else { + introductionLines.push(line); + } + } + + const introduction = introductionLines.join("\n").trim(); + const cards = sections.map((section, index) => ({ + body: section.bodyLines.join("\n").trim(), + id: cardId(section.title, index), + kind: classifyCanvasBoardCard(section.title), + title: section.title, + })); + + if (cards.length === 0 && introduction.length > 0) { + cards.push({ + body: introduction, + id: "overview-1", + kind: "welcome", + title: "Overview", + }); + } + + return { + cards, + introduction: sections.length > 0 ? introduction : "", + title, + }; +} + +export function resolveChannelViewMode(input: { + channelName: string | null; + channelType: ChannelType | null; + explicitView: ChannelViewMode | null; + hasCanvas: boolean; + hasRouteTarget: boolean; +}): { boardAvailable: boolean; mode: ChannelViewMode } { + const isDispatch = + input.channelName !== null && + channelNamesMatch(input.channelName, "Dispatch"); + const boardAvailable = + input.channelType !== null && + input.channelType !== "dm" && + (input.hasCanvas || isDispatch); + + if (input.hasRouteTarget || !boardAvailable) { + return { boardAvailable, mode: "stream" }; + } + + return { + boardAvailable, + mode: input.explicitView ?? (isDispatch ? "board" : "stream"), + }; +} diff --git a/desktop/src/features/channels/ui/ChannelBoard.tsx b/desktop/src/features/channels/ui/ChannelBoard.tsx new file mode 100644 index 00000000000..c8c0603257b --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelBoard.tsx @@ -0,0 +1,317 @@ +import { + Boxes, + Compass, + HandHeart, + MessageSquareText, + PackageCheck, + Settings2, + Sparkles, + StickyNote, + Users, +} from "lucide-react"; +import * as React from "react"; + +import { + type CanvasBoardCard, + type CanvasBoardCardKind, + parseCanvasBoard, +} from "@/features/channels/lib/canvasBoard"; +import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"; +import { cn } from "@/shared/lib/cn"; +import { channelChrome } from "@/shared/layout/chromeLayout"; +import { Button } from "@/shared/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card"; +import { Markdown } from "@/shared/ui/markdown"; +import { PubKey } from "@/shared/ui/PubKey"; + +const CARD_STYLES: Record = { + artifact: "border-emerald-500/25 bg-emerald-500/8", + invitation: "border-rose-500/25 bg-rose-500/8", + note: "border-violet-500/20 bg-violet-500/7", + now: "border-amber-500/30 bg-amber-500/10", + people: "border-sky-500/25 bg-sky-500/8", + welcome: "border-cyan-500/25 bg-cyan-500/8", +}; + +const CARD_LABELS: Record = { + artifact: "Made here", + invitation: "Open invitation", + note: "Shared note", + now: "Happening now", + people: "People", + welcome: "Start here", +}; + +const CARD_ICONS = { + artifact: PackageCheck, + invitation: HandHeart, + note: StickyNote, + now: Sparkles, + people: Users, + welcome: Compass, +} satisfies Record; + +type ChannelBoardProps = { + agentCount: number; + author: string | null; + channelName: string; + content: string | null; + errorMessage?: string; + isLoading: boolean; + memberCount: number; + onManageBoard: () => void; + onOpenMembers: () => void; + onOpenStream: () => void; + updatedAt: number | null; +}; + +function formatCanvasUpdatedAt(updatedAt: number | null): string | null { + if (updatedAt === null) { + return null; + } + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(updatedAt * 1_000)); +} + +function BoardCard({ + card, + channelNames, +}: { + card: CanvasBoardCard; + channelNames: string[]; +}) { + const Icon = CARD_ICONS[card.kind]; + + return ( + + +
+ + {CARD_LABELS[card.kind]} +
+ {card.title} +
+ {card.body ? ( + + + + ) : null} +
+ ); +} + +function LiveBoardCards({ + agentCount, + memberCount, + onOpenMembers, + onOpenStream, +}: { + agentCount: number; + memberCount: number; + onOpenMembers: () => void; + onOpenStream: () => void; +}) { + return ( + + + +
+ + Live from Buzz +
+ People & agents +
+ +

+ {memberCount === 1 ? "1 member" : `${memberCount} members`} ·{" "} + {agentCount === 1 ? "1 agent" : `${agentCount} agents`} tending this + room. +

+ +
+
+ + + +
+ + Contained conversation +
+ Open the room +
+ +

+ Continue into the channel stream without losing this shared board. +

+ +
+
+
+ ); +} + +export function ChannelBoard({ + agentCount, + author, + channelName, + content, + errorMessage, + isLoading, + memberCount, + onManageBoard, + onOpenMembers, + onOpenStream, + updatedAt, +}: ChannelBoardProps) { + const { channels } = useChannelNavigation(); + const channelNames = React.useMemo( + () => + channels + .filter((channel) => channel.channelType !== "dm") + .map((channel) => channel.name), + [channels], + ); + const board = React.useMemo(() => parseCanvasBoard(content ?? ""), [content]); + const updatedLabel = formatCanvasUpdatedAt(updatedAt); + + return ( +
+
+
+
+
+
+ + Shared community board +
+

+ {board.title ?? channelName} +

+ {board.introduction ? ( +
+ +
+ ) : ( +

+ See what matters now, where to join, and what this community + has made. +

+ )} +
+ Snapshot of this channel’s shared canvas + {author ? ( + + · + + by + + + ) : null} + {updatedLabel ? ( + + · + Updated {updatedLabel} + + ) : null} +
+
+
+ + +
+
+
+ + {isLoading ? ( +
+ Loading community board… +
+ ) : errorMessage ? ( +
+ {errorMessage} +
+ ) : board.cards.length === 0 ? ( +
+ +

+ This board is ready for its first note. +

+

+ Add Markdown sections to the channel canvas. Each level-two + heading becomes a shared module here. +

+ +
+ ) : ( +
+ {board.cards.map((card) => ( + + ))} + +
+ )} +
+
+ ); +} diff --git a/desktop/src/features/channels/ui/ChannelBoardScreen.tsx b/desktop/src/features/channels/ui/ChannelBoardScreen.tsx new file mode 100644 index 00000000000..22df07dea0c --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelBoardScreen.tsx @@ -0,0 +1,109 @@ +import * as React from "react"; + +import { + useChannelMembersQuery, + useJoinChannelMutation, +} from "@/features/channels/hooks"; +import { useActiveChannelHeader } from "@/features/channels/useActiveChannelHeader"; +import { ChannelBoard } from "@/features/channels/ui/ChannelBoard"; +import { ChannelManagementSheet } from "@/features/channels/ui/ChannelManagementSheet"; +import { ChannelScreenHeader } from "@/features/channels/ui/ChannelScreenHeader"; +import { MembersSidebar } from "@/features/channels/ui/MembersSidebar"; +import { useChannelViewMode } from "@/features/channels/ui/ChannelViewModeContext"; +import { useCommunities } from "@/features/communities/useCommunities"; +import type { CanvasResponse, Channel } from "@/shared/api/types"; +import { + isRelayUnreachableError, + RELAY_UNREACHABLE_SHORT, +} from "@/shared/lib/relayError"; + +type ChannelBoardScreenProps = { + canvas: CanvasResponse | undefined; + canvasError: unknown; + canvasLoading: boolean; + channel: Channel; + currentPubkey?: string; +}; + +export function ChannelBoardScreen({ + canvas, + canvasError, + canvasLoading, + channel, + currentPubkey, +}: ChannelBoardScreenProps) { + const { activeCommunity } = useCommunities(); + const channelViewMode = useChannelViewMode(); + const membersQuery = useChannelMembersQuery(channel.id); + const joinChannelMutation = useJoinChannelMutation(channel.id); + const members = membersQuery.data ?? []; + const agentCount = members.filter( + (member) => member.isAgent || member.role === "bot", + ).length; + const [isMembersSidebarOpen, setIsMembersSidebarOpen] = React.useState(false); + const [isChannelManagementOpen, setIsChannelManagementOpen] = + React.useState(false); + const [isAddBotOpen, setIsAddBotOpen] = React.useState(false); + const { + activeChannelEphemeralDisplay, + activeChannelTitle, + activeDmAvatarUrl, + activeDmHeaderParticipants, + activeDmPresenceStatus, + } = useActiveChannelHeader(channel, currentPubkey); + const canvasErrorMessage = + canvasError instanceof Error + ? isRelayUnreachableError(canvasError) + ? RELAY_UNREACHABLE_SHORT + : canvasError.message + : undefined; + + return ( + +
+ setIsChannelManagementOpen(true)} + onToggleMembers={() => setIsMembersSidebarOpen((open) => !open)} + /> + setIsChannelManagementOpen(true)} + onOpenMembers={() => setIsMembersSidebarOpen(true)} + onOpenStream={() => channelViewMode.onModeChange("stream")} + updatedAt={canvas?.updatedAt ?? null} + /> +
+ + + +
+ ); +} diff --git a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx index f1c5f5ae42d..f08e0edfc60 100644 --- a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx +++ b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx @@ -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 { ChannelViewModeToggle } from "@/features/channels/ui/ChannelViewModeContext"; import { DEFAULT_HOVER_PROFILE_STATUS_GEOMETRY, ProfileAvatarWithStatus, @@ -19,6 +20,7 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { UserNameIndicators } from "@/features/user-status/ui/UserNameIndicators"; import { Button } from "@/shared/ui/button"; import type { Channel, PresenceStatus } from "@/shared/api/types"; +import { useIsMobile } from "@/shared/hooks/use-mobile"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { toggleTerminalPanel, @@ -72,6 +74,8 @@ export function ChannelScreenHeader({ onManageChannel, onToggleMembers, }: ChannelScreenHeaderProps) { + const isMobile = useIsMobile(); + const resolvedActionsVariant = isMobile ? "compact" : actionsVariant; const isGroupDm = activeChannel?.channelType === "dm" && activeDmHeaderParticipants.length > 1; @@ -90,6 +94,7 @@ export function ChannelScreenHeader({ terminalPanel.mode === "closed" ? "Open Buzz Term" : "Hide Buzz Term" } onClick={toggleTerminalPanel} + className="hidden sm:inline-flex" size="icon" title="Buzz Term (⌘J)" type="button" @@ -121,15 +126,16 @@ export function ChannelScreenHeader({ onAddBotOpenChange={onAddBotOpenChange} onManageChannel={onManageChannel} onToggleMembers={onToggleMembers} - variant={actionsVariant} + variant={resolvedActionsVariant} /> ) ) : ( headerEndActions ); const actions = - terminalButton || channelActions ? ( + activeChannel || terminalButton || channelActions ? (
+ {activeChannel ? : null} {terminalButton} {channelActions}
diff --git a/desktop/src/features/channels/ui/ChannelViewModeContext.tsx b/desktop/src/features/channels/ui/ChannelViewModeContext.tsx new file mode 100644 index 00000000000..d13bc1295b0 --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelViewModeContext.tsx @@ -0,0 +1,79 @@ +import { LayoutDashboard, MessageSquareText } from "lucide-react"; +import * as React from "react"; + +import type { ChannelViewMode } from "@/features/channels/lib/canvasBoard"; +import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs"; + +type ChannelViewModeContextValue = { + boardAvailable: boolean; + mode: ChannelViewMode; + onModeChange: (mode: ChannelViewMode) => void; +}; + +const ChannelViewModeContext = React.createContext< + ChannelViewModeContextValue | undefined +>(undefined); + +export function ChannelViewModeProvider({ + children, + value, +}: { + children: React.ReactNode; + value: ChannelViewModeContextValue; +}) { + return ( + + {children} + + ); +} + +export function ChannelViewModeToggle() { + const context = React.useContext(ChannelViewModeContext); + + if (!context?.boardAvailable) { + return null; + } + + return ( + context.onModeChange(value as ChannelViewMode)} + value={context.mode} + > + + + + Board + + + + Stream + + + + ); +} + +export function useChannelViewMode(): ChannelViewModeContextValue { + const context = React.useContext(ChannelViewModeContext); + if (!context) { + throw new Error( + "useChannelViewMode must be used inside ChannelViewModeProvider", + ); + } + return context; +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index b74d0342080..b4dd9a6badc 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -370,6 +370,11 @@ type E2eConfig = { /** Number of seeded rows in the deep-history fixture. Defaults to 600. */ deepHistoryMessageCount?: number; feedReadError?: string; + canvas?: { + author?: string | null; + content: string | null; + updatedAt?: number | null; + }; canvasReadError?: string; /** Delay (ms) for `apply_workspace` so e2e tests can observe the * community-switch gate. 0/undefined = instant. */ @@ -1508,6 +1513,7 @@ declare global { channelId: string; channelType?: "stream" | "forum" | "dm"; description?: string; + name?: string; removeMemberPubkey?: string; }) => void; /** @@ -11573,6 +11579,7 @@ export function maybeInstallE2eTauriMocks() { channelId, channelType, description, + name, removeMemberPubkey, }) => { const channel = mockChannels.find((ch) => ch.id === channelId); @@ -11583,6 +11590,9 @@ export function maybeInstallE2eTauriMocks() { if (description !== undefined) { channel.description = description; } + if (name !== undefined) { + channel.name = name; + } if (removeMemberPubkey !== undefined) { channel.members = channel.members.filter( (m) => m.pubkey !== removeMemberPubkey, @@ -14659,15 +14669,29 @@ export function maybeInstallE2eTauriMocks() { // The spec only verifies UI state, not the submitted request shape; // returning null mirrors the Rust submit_event success path. return null; - case "set_canvas": + case "set_canvas": { + const input = payload as { content: string }; + if (activeConfig) { + activeConfig.mock ??= {}; + activeConfig.mock.canvas = { + author: identity?.pubkey ?? null, + content: input.content, + updatedAt: Math.floor(Date.now() / 1_000), + }; + } return { ok: true, event_id: mockEventId() }; + } case "get_canvas": { const canvasReadError = activeConfig?.mock?.canvasReadError; if (canvasReadError) { throw new Error(canvasReadError); } - // Return the no-canvas success shape — content null means no canvas set. - return { content: null, updated_at: null, author: null }; + const canvas = activeConfig?.mock?.canvas; + return { + content: canvas?.content ?? null, + updated_at: canvas?.updatedAt ?? null, + author: canvas?.author ?? null, + }; } // ── Local-save archive ────────────────────────────────────────────── // These stubs drive the LocalArchiveSettingsCard in screenshot / UI tests diff --git a/desktop/tests/e2e/magic-board.spec.ts b/desktop/tests/e2e/magic-board.spec.ts new file mode 100644 index 00000000000..218d8abe9cd --- /dev/null +++ b/desktop/tests/e2e/magic-board.spec.ts @@ -0,0 +1,185 @@ +import { expect, test, type Page, type TestInfo } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; + +const DISPATCH_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; +const DISPATCH_CANVAS = `# Dispatch — Open Studio 001 + +Bring one seed. Leave with one small, shareable artifact. + +## This week at Sweet Works + +**Open Studio 001: One Seed, One Artifact** is active now. + +## Start here + +1. Read the welcome. +2. Open a Workshop thread. +3. Name one finish line. + +## Help wanted + +Use 👋 for “I can help,” then name what you can offer. + +## Finished example + +**Ora #5821 — The Smallest Edge of Day** completed the full loop. + +## Next pilot action + +Invite the first 12–20 participants after the example is accessible. +`; + +async function openDispatchRoute(page: Page, search = "") { + await page.goto("/"); + await expect(page.getByTestId("home-inbox")).toBeVisible(); + await page.evaluate( + async ({ channelId, search }) => { + window.__BUZZ_E2E_MUTATE_CHANNEL__?.({ + channelId, + name: "Dispatch", + }); + await window.__BUZZ_E2E_INVALIDATE_CHANNELS__?.(); + window.location.hash = `#/channels/${channelId}${search}`; + }, + { channelId: DISPATCH_CHANNEL_ID, search }, + ); +} + +async function openDispatchBoard(page: Page) { + await openDispatchRoute(page); + await expect(page.getByTestId("channel-magic-board")).toBeVisible(); +} + +async function captureBoard(page: Page, testInfo: TestInfo, name: string) { + await waitForAnimations(page); + await page.screenshot({ + path: testInfo.outputPath(`${name}.png`), + fullPage: true, + }); +} + +test.beforeEach(async ({ page }) => { + await installMockBridge(page, { + canvas: { + author: TEST_IDENTITIES.tyler.pubkey, + content: DISPATCH_CANVAS, + updatedAt: 1_786_336_800, + }, + managedAgents: [ + { + channelIds: [DISPATCH_CHANNEL_ID], + channelNames: ["Dispatch"], + name: "Charlie", + pubkey: TEST_IDENTITIES.charlie.pubkey, + status: "running", + }, + ], + }); +}); + +test("Dispatch opens as a board and keeps the stream one click away", async ({ + page, +}, testInfo) => { + await openDispatchBoard(page); + + await expect(page.getByTestId("channel-view-board")).toHaveAttribute( + "data-state", + "active", + ); + await expect( + page.getByRole("heading", { name: "Dispatch — Open Studio 001" }), + ).toBeVisible(); + await expect(page.getByText("This week at Sweet Works")).toBeVisible(); + await expect(page.getByTestId("magic-board-card-start-here-2")).toBeVisible(); + await expect( + page.getByTestId("magic-board-card-help-wanted-3"), + ).toBeVisible(); + await expect( + page.getByText("Finished example", { exact: true }), + ).toBeVisible(); + await expect( + page.getByText("5 members · 3 agents tending this room."), + ).toBeVisible(); + + await captureBoard(page, testInfo, "magic-board-desktop"); + + await page.getByTestId("channel-view-stream").click(); + await expect(page.getByTestId("channel-magic-board")).toBeHidden(); + await expect(page.getByText("Welcome to #general")).toBeVisible(); + + await page.getByTestId("channel-view-board").click(); + await expect(page.getByTestId("channel-magic-board")).toBeVisible(); +}); + +test("Dispatch board stacks cleanly at a narrow viewport", async ({ + page, +}, testInfo) => { + await page.setViewportSize({ width: 390, height: 844 }); + await openDispatchBoard(page); + + const title = page.getByTestId("chat-title"); + await expect(title).toHaveText("Dispatch"); + await expect + .poll(() => + title.evaluate((element) => element.clientWidth >= element.scrollWidth), + ) + .toBe(true); + + const grid = page.getByTestId("magic-board-grid"); + await expect(grid).toBeVisible(); + const firstCard = page.getByTestId( + "magic-board-card-this-week-at-sweet-works-1", + ); + const secondCard = page.getByTestId("magic-board-card-start-here-2"); + const [firstBox, secondBox] = await Promise.all([ + firstCard.boundingBox(), + secondCard.boundingBox(), + ]); + expect(firstBox).not.toBeNull(); + expect(secondBox).not.toBeNull(); + expect(secondBox?.y ?? 0).toBeGreaterThan( + (firstBox?.y ?? 0) + (firstBox?.height ?? 0), + ); + + await captureBoard(page, testInfo, "magic-board-narrow"); +}); + +test("a Dispatch message deep link opens the stream instead of hiding its target", async ({ + page, +}) => { + await page.goto("/"); + await expect(page.getByTestId("app-sidebar")).toBeVisible(); + await page.evaluate(async (channelId) => { + window.__BUZZ_E2E_MUTATE_CHANNEL__?.({ + channelId, + name: "Dispatch", + }); + await window.__BUZZ_E2E_INVALIDATE_CHANNELS__?.(); + window.location.hash = `#/channels/${channelId}?messageId=mock-general-welcome`; + }, DISPATCH_CHANNEL_ID); + + await expect(page.getByText("Welcome to #general")).toBeVisible(); + await expect(page.getByTestId("channel-magic-board")).toBeHidden(); + await expect(page.getByTestId("channel-view-mode")).toBeHidden(); +}); + +test("Dispatch Stream-owned route intents are never masked by the board", async ({ + page, +}) => { + await openDispatchRoute(page, "?autoSend=channel%3Adispatch-draft"); + await expect(page.getByText("Welcome to #general")).toBeVisible(); + await expect(page.getByTestId("channel-magic-board")).toBeHidden(); + + await openDispatchRoute(page, `?profile=${TEST_IDENTITIES.bob.pubkey}`); + await expect(page.getByTestId("user-profile-panel")).toBeVisible(); + await expect(page.getByTestId("channel-magic-board")).toBeHidden(); + + await openDispatchRoute( + page, + `?agentSession=${TEST_IDENTITIES.charlie.pubkey}`, + ); + await expect(page.getByTestId("agent-session-thread-panel")).toBeVisible(); + await expect(page.getByTestId("channel-magic-board")).toBeHidden(); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index c3f4ed69f4c..1acd6271455 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -287,6 +287,11 @@ type MockBridgeOptions = { /** Number of seeded rows in the deep-history fixture. Defaults to 600. */ deepHistoryMessageCount?: number; feedReadError?: string; + canvas?: { + author?: string | null; + content: string | null; + updatedAt?: number | null; + }; canvasReadError?: string; /** Delay (ms) for `apply_workspace`; see e2eBridge mock config. */ applyCommunityDelayMs?: number; From 47da549c4ce45643f038c9581246941d5fab77a9 Mon Sep 17 00:00:00 2001 From: "pizzalord.eth" Date: Thu, 13 Aug 2026 00:17:41 -0500 Subject: [PATCH 2/4] Fix Dispatch board card repaint Co-authored-by: pizzalord.eth Signed-off-by: pizzalord.eth --- desktop/src/features/channels/ui/ChannelBoard.tsx | 2 +- desktop/tests/e2e/magic-board.spec.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/channels/ui/ChannelBoard.tsx b/desktop/src/features/channels/ui/ChannelBoard.tsx index c8c0603257b..55b53b00537 100644 --- a/desktop/src/features/channels/ui/ChannelBoard.tsx +++ b/desktop/src/features/channels/ui/ChannelBoard.tsx @@ -87,7 +87,7 @@ function BoardCard({ return ( + card.evaluate((element) => getComputedStyle(element).transform), + ) + .toBe("none"); + } + await captureBoard(page, testInfo, "magic-board-desktop"); await page.getByTestId("channel-view-stream").click(); From 6637f92eab418f81edb0ac8c0e68cf0604626abe Mon Sep 17 00:00:00 2001 From: "pizzalord.eth" Date: Sun, 16 Aug 2026 23:53:37 -0500 Subject: [PATCH 3/4] feat(desktop): add Magic Board card lifecycle Let channel stewards create and edit Markdown-backed cards from the Board, reorder them with pointer or keyboard drag, and persist the shared canvas with optimistic rollback and conflict guards. Cover parsing, permissions, narrow layout, and the durable create-edit-drag flow. Co-authored-by: pizzalord.eth Signed-off-by: pizzalord.eth --- .../channels/lib/canvasBoard.test.mjs | 148 ++++++++++++ .../src/features/channels/lib/canvasBoard.ts | 165 ++++++++++++- .../src/features/channels/ui/ChannelBoard.tsx | 227 +++++++++++++++--- .../ui/ChannelBoardCardEditorDialog.tsx | 150 ++++++++++++ .../channels/ui/ChannelBoardScreen.tsx | 154 +++++++++++- desktop/tests/e2e/magic-board.spec.ts | 157 ++++++++++++ 6 files changed, 961 insertions(+), 40 deletions(-) create mode 100644 desktop/src/features/channels/ui/ChannelBoardCardEditorDialog.tsx diff --git a/desktop/src/features/channels/lib/canvasBoard.test.mjs b/desktop/src/features/channels/lib/canvasBoard.test.mjs index 8694fee4c8f..fb756c9416e 100644 --- a/desktop/src/features/channels/lib/canvasBoard.test.mjs +++ b/desktop/src/features/channels/lib/canvasBoard.test.mjs @@ -2,9 +2,13 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + appendCanvasBoardCard, classifyCanvasBoardCard, parseCanvasBoard, + reorderCanvasBoardCard, resolveChannelViewMode, + updateCanvasBoardCard, + validateCanvasBoardCardDraft, } from "./canvasBoard.ts"; test("parseCanvasBoard turns level-two sections into durable cards", () => { @@ -99,6 +103,150 @@ test("classifyCanvasBoardCard keeps stewardship language visible", () => { assert.equal(classifyCanvasBoardCard("Source and story boundary"), "note"); }); +test("appendCanvasBoardCard preserves the board preamble and adds one card", () => { + const content = `# Dispatch + +Shared introduction. + +## Start here + +Read the welcome. +`; + + const updated = appendCanvasBoardCard(content, { + body: "Bring one seed.", + title: "Next action", + }); + const board = parseCanvasBoard(updated); + + assert.equal(board.title, "Dispatch"); + assert.equal(board.introduction, "Shared introduction."); + assert.deepEqual( + board.cards.map(({ body, title }) => ({ body, title })), + [ + { body: "Read the welcome.", title: "Start here" }, + { body: "Bring one seed.", title: "Next action" }, + ], + ); +}); + +test("updateCanvasBoardCard edits only the selected source section", () => { + const content = `# Dispatch + +## Start here + +Old instructions. + +## Finished example + +Keep this intact. +`; + + const updated = updateCanvasBoardCard(content, "start-here-1", { + body: "New **Markdown** instructions.", + title: "Start here now", + }); + assert.ok(updated); + + const board = parseCanvasBoard(updated); + assert.deepEqual( + board.cards.map(({ body, title }) => ({ body, title })), + [ + { + body: "New **Markdown** instructions.", + title: "Start here now", + }, + { body: "Keep this intact.", title: "Finished example" }, + ], + ); +}); + +test("updateCanvasBoardCard turns a fallback overview into a durable section", () => { + const updated = updateCanvasBoardCard( + "# A small room\n\nEverything useful lives here.\n", + "overview-1", + { + body: "The overview is now editable from the Board.", + title: "Start here", + }, + ); + assert.ok(updated); + + const board = parseCanvasBoard(updated); + assert.equal(board.title, "A small room"); + assert.equal(board.introduction, ""); + assert.deepEqual( + board.cards.map(({ body, title }) => ({ body, title })), + [ + { + body: "The overview is now editable from the Board.", + title: "Start here", + }, + ], + ); +}); + +test("reorderCanvasBoardCard moves raw fenced sections without losing content", () => { + const content = `# Dispatch + +## Notes + +\`\`\`\`md +\`\`\`md +## Not a card +\`\`\` +\`\`\`\` + +## Next action + +Ship the proof. + +## Finished example + +Keep this too. +`; + + const updated = reorderCanvasBoardCard( + content, + "finished-example-3", + "notes-1", + ); + assert.ok(updated); + + const board = parseCanvasBoard(updated); + assert.deepEqual( + board.cards.map(({ title }) => title), + ["Finished example", "Notes", "Next action"], + ); + assert.match(board.cards[1].body, /## Not a card/u); + assert.match(board.cards[2].body, /Ship the proof/u); +}); + +test("canvas card draft validation rejects nested level-two card headings", () => { + assert.equal( + validateCanvasBoardCardDraft({ body: "Body", title: "" }), + "Add a card title.", + ); + assert.equal( + validateCanvasBoardCardDraft({ body: "Body", title: "x".repeat(121) }), + "Keep the card title to 120 characters or fewer.", + ); + assert.equal( + validateCanvasBoardCardDraft({ + body: "## Accidental second card", + title: "First card", + }), + "Use level-three headings inside a card. Level-two headings create separate cards.", + ); + assert.equal( + validateCanvasBoardCardDraft({ + body: "```md\n## Safe example\n```", + title: "Code sample", + }), + null, + ); +}); + test("resolveChannelViewMode makes Dispatch board-first without hiding targets", () => { assert.deepEqual( resolveChannelViewMode({ diff --git a/desktop/src/features/channels/lib/canvasBoard.ts b/desktop/src/features/channels/lib/canvasBoard.ts index d732673971b..e6d1d0d6ff1 100644 --- a/desktop/src/features/channels/lib/canvasBoard.ts +++ b/desktop/src/features/channels/lib/canvasBoard.ts @@ -22,6 +22,11 @@ export type CanvasBoard = { title: string | null; }; +export type CanvasBoardCardDraft = { + body: string; + title: string; +}; + export type ChannelViewMode = "board" | "stream"; const H1_PATTERN = /^#\s+(.+?)\s*#*\s*$/u; @@ -29,6 +34,19 @@ const H2_PATTERN = /^##\s+(.+?)\s*#*\s*$/u; const FENCE_OPEN_PATTERN = /^ {0,3}(`{3,}|~{3,})/u; const FENCE_CLOSE_PATTERN = /^ {0,3}(`{3,}|~{3,})[ \t]*$/u; +type CanvasBoardSourceSection = { + bodyLines: string[]; + headingLine: string; + title: string; +}; + +type CanvasBoardSource = { + introductionLines: string[]; + preambleLines: string[]; + sections: CanvasBoardSourceSection[]; + title: string | null; +}; + function cardId(title: string, index: number): string { const slug = title .toLowerCase() @@ -60,15 +78,12 @@ export function classifyCanvasBoardCard(title: string): CanvasBoardCardKind { return "note"; } -/** - * Converts a shared Markdown canvas into a title/introduction plus `##` cards. - * Headings inside fenced code blocks remain body content. - */ -export function parseCanvasBoard(content: string): CanvasBoard { +function parseCanvasBoardSource(content: string): CanvasBoardSource { const introductionLines: string[] = []; - const sections: Array<{ title: string; bodyLines: string[] }> = []; + const preambleLines: string[] = []; + const sections: CanvasBoardSourceSection[] = []; let title: string | null = null; - let activeSection: { title: string; bodyLines: string[] } | null = null; + let activeSection: CanvasBoardSourceSection | null = null; let activeFence: { character: "`" | "~"; length: number } | null = null; for (const line of content.replace(/\r\n?/gu, "\n").split("\n")) { @@ -98,6 +113,7 @@ export function parseCanvasBoard(content: string): CanvasBoard { const h1Match = line.match(H1_PATTERN); if (h1Match && title === null) { title = h1Match[1].trim(); + preambleLines.push(line); continue; } } @@ -105,7 +121,11 @@ export function parseCanvasBoard(content: string): CanvasBoard { if (!isFenceBoundary) { const h2Match = line.match(H2_PATTERN); if (h2Match) { - activeSection = { title: h2Match[1].trim(), bodyLines: [] }; + activeSection = { + bodyLines: [], + headingLine: line, + title: h2Match[1].trim(), + }; sections.push(activeSection); continue; } @@ -114,17 +134,45 @@ export function parseCanvasBoard(content: string): CanvasBoard { if (activeSection) { activeSection.bodyLines.push(line); } else { + preambleLines.push(line); introductionLines.push(line); } } - const introduction = introductionLines.join("\n").trim(); - const cards = sections.map((section, index) => ({ + return { introductionLines, preambleLines, sections, title }; +} + +function serializeCanvasBoardSource(source: CanvasBoardSource): string { + const blocks = [ + source.preambleLines.join("\n").trim(), + ...source.sections.map((section) => { + const body = section.bodyLines.join("\n").trim(); + return body ? `${section.headingLine}\n\n${body}` : section.headingLine; + }), + ].filter((block) => block.length > 0); + + return blocks.length > 0 ? `${blocks.join("\n\n")}\n` : ""; +} + +function canvasBoardCardsFromSource( + source: CanvasBoardSource, +): CanvasBoardCard[] { + return source.sections.map((section, index) => ({ body: section.bodyLines.join("\n").trim(), id: cardId(section.title, index), kind: classifyCanvasBoardCard(section.title), title: section.title, })); +} + +/** + * Converts a shared Markdown canvas into a title/introduction plus `##` cards. + * Headings inside fenced code blocks remain body content. + */ +export function parseCanvasBoard(content: string): CanvasBoard { + const source = parseCanvasBoardSource(content); + const introduction = source.introductionLines.join("\n").trim(); + const cards = canvasBoardCardsFromSource(source); if (cards.length === 0 && introduction.length > 0) { cards.push({ @@ -137,11 +185,104 @@ export function parseCanvasBoard(content: string): CanvasBoard { return { cards, - introduction: sections.length > 0 ? introduction : "", - title, + introduction: source.sections.length > 0 ? introduction : "", + title: source.title, }; } +export function validateCanvasBoardCardDraft( + draft: CanvasBoardCardDraft, +): string | null { + const title = draft.title.trim(); + if (!title) { + return "Add a card title."; + } + if (/\r|\n/u.test(title)) { + return "Keep the card title on one line."; + } + if (title.length > 120) { + return "Keep the card title to 120 characters or fewer."; + } + + const preview = parseCanvasBoard(`## ${title}\n\n${draft.body}`); + if (preview.cards.length !== 1) { + return "Use level-three headings inside a card. Level-two headings create separate cards."; + } + + return null; +} + +export function appendCanvasBoardCard( + content: string, + draft: CanvasBoardCardDraft, +): string { + const source = parseCanvasBoardSource(content); + source.sections.push({ + bodyLines: draft.body.trim().split("\n"), + headingLine: `## ${draft.title.trim()}`, + title: draft.title.trim(), + }); + return serializeCanvasBoardSource(source); +} + +export function updateCanvasBoardCard( + content: string, + cardIdToUpdate: string, + draft: CanvasBoardCardDraft, +): string | null { + const source = parseCanvasBoardSource(content); + if ( + source.sections.length === 0 && + cardIdToUpdate === "overview-1" && + source.introductionLines.join("\n").trim().length > 0 + ) { + source.preambleLines = source.preambleLines.filter((line) => + H1_PATTERN.test(line), + ); + source.introductionLines = []; + source.sections.push({ + bodyLines: draft.body.trim().split("\n"), + headingLine: `## ${draft.title.trim()}`, + title: draft.title.trim(), + }); + return serializeCanvasBoardSource(source); + } + + const cards = canvasBoardCardsFromSource(source); + const sectionIndex = cards.findIndex((card) => card.id === cardIdToUpdate); + const section = source.sections[sectionIndex]; + if (!section) { + return null; + } + + section.bodyLines = draft.body.trim().split("\n"); + section.headingLine = `## ${draft.title.trim()}`; + section.title = draft.title.trim(); + return serializeCanvasBoardSource(source); +} + +export function reorderCanvasBoardCard( + content: string, + activeCardId: string, + overCardId: string, +): string | null { + if (activeCardId === overCardId) { + return content; + } + + const source = parseCanvasBoardSource(content); + const cards = canvasBoardCardsFromSource(source); + const activeIndex = cards.findIndex((card) => card.id === activeCardId); + const overIndex = cards.findIndex((card) => card.id === overCardId); + if (activeIndex === -1 || overIndex === -1) { + return null; + } + + const [movedSection] = source.sections.splice(activeIndex, 1); + source.sections.splice(overIndex, 0, movedSection); + return serializeCanvasBoardSource(source); +} + export function resolveChannelViewMode(input: { channelName: string | null; channelType: ChannelType | null; diff --git a/desktop/src/features/channels/ui/ChannelBoard.tsx b/desktop/src/features/channels/ui/ChannelBoard.tsx index 55b53b00537..6dcbcdbb8ea 100644 --- a/desktop/src/features/channels/ui/ChannelBoard.tsx +++ b/desktop/src/features/channels/ui/ChannelBoard.tsx @@ -1,9 +1,28 @@ +import { + closestCenter, + DndContext, + DragOverlay, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core"; +import { + rectSortingStrategy, + sortableKeyboardCoordinates, + SortableContext, + useSortable, +} from "@dnd-kit/sortable"; import { Boxes, Compass, + GripVertical, HandHeart, MessageSquareText, PackageCheck, + Pencil, + Plus, Settings2, Sparkles, StickyNote, @@ -52,14 +71,20 @@ const CARD_ICONS = { } satisfies Record; type ChannelBoardProps = { + actionErrorMessage?: string; agentCount: number; author: string | null; + canEdit: boolean; channelName: string; content: string | null; errorMessage?: string; isLoading: boolean; + isSaving: boolean; memberCount: number; + onCreateCard: () => void; + onEditCard: (card: CanvasBoardCard) => void; onManageBoard: () => void; + onMoveCard: (activeCardId: string, overCardId: string) => void; onOpenMembers: () => void; onOpenStream: () => void; updatedAt: number | null; @@ -77,34 +102,114 @@ function formatCanvasUpdatedAt(updatedAt: number | null): string | null { function BoardCard({ card, + canEdit, channelNames, + isSaving, + onEdit, }: { card: CanvasBoardCard; + canEdit: boolean; channelNames: string[]; + isSaving: boolean; + onEdit: () => void; }) { const Icon = CARD_ICONS[card.kind]; + const dragDisabled = !canEdit || isSaving; + const { + attributes, + isDragging, + isOver, + listeners, + setActivatorNodeRef, + setNodeRef, + } = useSortable({ + id: card.id, + disabled: dragDisabled, + }); return ( - - + + +
+
+ + {CARD_LABELS[card.kind]} +
+ {canEdit ? ( +
+ + +
+ ) : null} +
+ {card.title} +
+ {card.body ? ( + + + + ) : null} +
+ + ); +} + +function BoardCardDragOverlay({ card }: { card: CanvasBoardCard }) { + const Icon = CARD_ICONS[card.kind]; + + return ( + +
{CARD_LABELS[card.kind]}
- {card.title} + {card.title}
- {card.body ? ( - - - - ) : null}
); } @@ -171,14 +276,20 @@ function LiveBoardCards({ } export function ChannelBoard({ + actionErrorMessage, agentCount, author, + canEdit, channelName, content, errorMessage, isLoading, + isSaving, memberCount, + onCreateCard, + onEditCard, onManageBoard, + onMoveCard, onOpenMembers, onOpenStream, updatedAt, @@ -193,6 +304,28 @@ export function ChannelBoard({ ); const board = React.useMemo(() => parseCanvasBoard(content ?? ""), [content]); const updatedLabel = formatCanvasUpdatedAt(updatedAt); + const [activeCardId, setActiveCardId] = React.useState(null); + const activeCard = activeCardId + ? (board.cards.find((card) => card.id === activeCardId) ?? null) + : null; + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + function handleDragStart(event: DragStartEvent) { + setActiveCardId(String(event.active.id)); + } + + function handleDragEnd(event: DragEndEvent) { + setActiveCardId(null); + if (!event.over || event.active.id === event.over.id) { + return; + } + onMoveCard(String(event.active.id), String(event.over.id)); + } return (
+ {canEdit ? ( + + ) : null}
+ {actionErrorMessage ? ( +

+ {actionErrorMessage} +

+ ) : null} + {isLoading ? (
Loading community board… @@ -292,24 +446,43 @@ export function ChannelBoard({
) : ( -
setActiveCardId(null)} + onDragEnd={handleDragEnd} + onDragStart={handleDragStart} + sensors={sensors} > - {board.cards.map((card) => ( - - ))} - -
+ card.id)} + strategy={rectSortingStrategy} + > +
+ {board.cards.map((card) => ( + onEditCard(card)} + /> + ))} + +
+
+ + {activeCard ? : null} + + )} diff --git a/desktop/src/features/channels/ui/ChannelBoardCardEditorDialog.tsx b/desktop/src/features/channels/ui/ChannelBoardCardEditorDialog.tsx new file mode 100644 index 00000000000..f3ddc265282 --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelBoardCardEditorDialog.tsx @@ -0,0 +1,150 @@ +import * as React from "react"; + +import { + type CanvasBoardCard, + type CanvasBoardCardDraft, + validateCanvasBoardCardDraft, +} from "@/features/channels/lib/canvasBoard"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; +import { Textarea } from "@/shared/ui/textarea"; + +type ChannelBoardCardEditorDialogProps = { + card: CanvasBoardCard | null; + errorMessage: string | null; + isSaving: boolean; + onOpenChange: (open: boolean) => void; + onSave: (draft: CanvasBoardCardDraft) => Promise; + open: boolean; +}; + +export function ChannelBoardCardEditorDialog({ + card, + errorMessage, + isSaving, + onOpenChange, + onSave, + open, +}: ChannelBoardCardEditorDialogProps) { + const titleId = React.useId(); + const bodyId = React.useId(); + const [title, setTitle] = React.useState(""); + const [body, setBody] = React.useState(""); + const draft = React.useMemo(() => ({ body, title }), [body, title]); + const validationError = validateCanvasBoardCardDraft(draft); + + React.useEffect(() => { + if (!open) { + return; + } + setTitle(card?.title ?? ""); + setBody(card?.body ?? ""); + }, [card?.body, card?.title, open]); + + async function handleSave(event: React.FormEvent) { + event.preventDefault(); + if (validationError || isSaving) { + return; + } + + try { + await onSave({ body: body.trim(), title: title.trim() }); + } catch { + // The mutation error stays visible in the dialog for a safe retry. + } + } + + const isEditing = card !== null; + + return ( + { + if (!isSaving) { + onOpenChange(nextOpen); + } + }} + open={open} + > + + + {isEditing ? "Edit card" : "Create card"} + + Cards are shared Markdown sections. Use level-three headings inside + the body; level-two headings start separate cards. + + + +
+
+ + setTitle(event.target.value)} + placeholder="What should people notice?" + value={title} + /> +
+ +
+ +