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-tauri/src/commands/canvas.rs b/desktop/src-tauri/src/commands/canvas.rs index 191fafe60d8..4fd374beda9 100644 --- a/desktop/src-tauri/src/commands/canvas.rs +++ b/desktop/src-tauri/src/commands/canvas.rs @@ -46,10 +46,25 @@ pub async fn get_canvas( pub async fn set_canvas( channel_id: String, content: String, + enforce_revision: Option, + expected_event_id: Option, state: State<'_, AppState>, ) -> Result { let uuid = uuid::Uuid::parse_str(&channel_id) .map_err(|_| format!("invalid channel UUID: {channel_id}"))?; + if enforce_revision.unwrap_or(false) { + let events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [40100], + "#h": [channel_id], + "limit": 1 + })], + ) + .await?; + let current_event_id = events.first().map(|event| event.id.to_hex()); + ensure_canvas_revision(expected_event_id.as_deref(), current_event_id.as_deref())?; + } let builder = events::build_set_canvas(uuid, &content)?; let result = submit_event(builder, &state).await?; @@ -58,3 +73,28 @@ pub async fn set_canvas( "event_id": result.event_id, })) } + +fn ensure_canvas_revision(expected: Option<&str>, current: Option<&str>) -> Result<(), String> { + if expected == current { + return Ok(()); + } + Err("Canvas revision conflict: the board changed before this save.".to_owned()) +} + +#[cfg(test)] +mod tests { + use super::ensure_canvas_revision; + + #[test] + fn canvas_revision_accepts_matching_existing_or_empty_state() { + assert!(ensure_canvas_revision(Some("abc"), Some("abc")).is_ok()); + assert!(ensure_canvas_revision(None, None).is_ok()); + } + + #[test] + fn canvas_revision_rejects_stale_or_unexpected_state() { + assert!(ensure_canvas_revision(Some("old"), Some("new")).is_err()); + assert!(ensure_canvas_revision(None, Some("new")).is_err()); + assert!(ensure_canvas_revision(Some("old"), None).is_err()); + } +} diff --git a/desktop/src/app/routes/ChannelRouteScreen.tsx b/desktop/src/app/routes/ChannelRouteScreen.tsx index 50371bc369f..b4c723eff32 100644 --- a/desktop/src/app/routes/ChannelRouteScreen.tsx +++ b/desktop/src/app/routes/ChannelRouteScreen.tsx @@ -4,9 +4,21 @@ 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, + useCanvasSubscription, + useChannelsQuery, +} from "@/features/channels/hooks"; import { useOpenChannelDirectoryQuery } from "@/features/channels/openChannelDirectory"; +import { + type ChannelViewMode, + readStoredChannelViewMode, + resolveChannelViewMode, + writeStoredChannelViewMode, +} 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 +46,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 +128,7 @@ export function ChannelRouteScreen({ autoSendDraftKey, channelId, searchHighlight, + hasStreamRouteIntent, selectedPostId, targetMessageId, targetReplyId, @@ -159,6 +173,44 @@ export function ChannelRouteScreen({ ); const projectHome = enumeratedProjectHome ?? projectHomeLookupQuery.data ?? null; + const canvasQuery = useCanvasQuery( + activeChannel?.id ?? null, + activeChannel !== null && activeChannel.channelType !== "dm", + ); + useCanvasSubscription( + 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 + : readStoredChannelViewMode(channelId); + 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 }); + writeStoredChannelViewMode(channelId, mode); + }, + [channelId], + ); const [targetMessageEvents, setTargetMessageEvents] = React.useState< RelayEvent[] >(() => { @@ -309,23 +361,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/hooks.ts b/desktop/src/features/channels/hooks.ts index 18eb2699d2e..716aa5b3493 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -27,11 +27,14 @@ import { unarchiveChannel, updateChannel, } from "@/shared/api/tauri"; +import { relayClient } from "@/shared/api/relayClient"; import type { AddChannelMembersInput, + CanvasResponse, Channel, ChannelDetail, CreateChannelInput, + RelayEvent, SetChannelPurposeInput, SetChannelTopicInput, UpdateChannelInput, @@ -976,15 +979,94 @@ export function useCanvasQuery(channelId: string | null, enabled = true) { }); } +export function useCanvasSubscription( + channelId: string | null, + enabled = true, +) { + const queryClient = useQueryClient(); + + React.useEffect(() => { + if (!enabled || !channelId) { + return; + } + let isDisposed = false; + let cleanup: (() => void) | undefined; + const applyCanvasEvent = (event: RelayEvent) => { + queryClient.setQueryData( + ["channel-canvas", channelId], + (current) => { + if (current?.updatedAt && current.updatedAt > event.created_at) { + return current; + } + return { + author: event.pubkey, + content: event.content, + eventId: event.id, + updatedAt: event.created_at, + }; + }, + ); + }; + void relayClient + .subscribeLive( + { + "#h": [channelId], + kinds: [40100], + limit: 0, + }, + applyCanvasEvent, + ) + .then((dispose) => { + if (isDisposed) { + dispose(); + return; + } + cleanup = dispose; + }) + .catch((error) => { + console.error( + "Failed to subscribe to channel canvas", + channelId, + error, + ); + }); + const disposeReconnect = relayClient.subscribeToReconnects(() => { + void queryClient.invalidateQueries({ + queryKey: ["channel-canvas", channelId], + }); + }); + return () => { + isDisposed = true; + cleanup?.(); + disposeReconnect(); + }; + }, [channelId, enabled, queryClient]); +} + export function useSetCanvasMutation(channelId: string | null) { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (content: string) => { + mutationFn: ( + input: + | string + | { + content: string; + enforceRevision?: boolean; + expectedEventId?: string | null; + }, + ) => { if (!channelId) { return Promise.reject(new Error("No channel selected")); } - return setCanvas({ channelId, content }); + return setCanvas({ + channelId, + content: typeof input === "string" ? input : input.content, + enforceRevision: + typeof input === "string" ? false : input.enforceRevision, + expectedEventId: + typeof input === "string" ? undefined : input.expectedEventId, + }); }, onSuccess: () => { if (channelId) { 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..992b5280867 --- /dev/null +++ b/desktop/src/features/channels/lib/canvasBoard.test.mjs @@ -0,0 +1,378 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + appendCanvasBoardCard, + buildCanvasBoardCardConversationOpener, + canvasBoardCardConversationMarker, + classifyCanvasBoardCard, + classifyCanvasBoardCardType, + parseCanvasBoard, + reorderCanvasBoardCard, + resolveChannelViewMode, + updateCanvasBoardCard, + updateCanvasBoardCardMetadata, + validateCanvasBoardCardDraft, +} 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.map(({ body, id, kind, status, threadId, title, type }) => ({ + body, + id, + kind, + status, + threadId, + title, + type, + })), + [ + { + body: "Everything useful lives here.", + id: "overview-1", + kind: "welcome", + status: "backlog", + threadId: null, + title: "Overview", + type: "note", + }, + ], + ); +}); + +test("classifyCanvasBoardCard keeps stewardship language visible", () => { + assert.equal(classifyCanvasBoardCard("People and stewards"), "people"); + assert.equal(classifyCanvasBoardCard("Source and story boundary"), "note"); +}); + +test("classifyCanvasBoardCardType recognizes native workflow cards", () => { + assert.equal( + classifyCanvasBoardCardType("Decision: use one source"), + "decision", + ); + assert.equal(classifyCanvasBoardCardType("Ora Mirror project"), "project"); + assert.equal(classifyCanvasBoardCardType("Agent: Fizz"), "agent"); + assert.equal(classifyCanvasBoardCardType("A plain thought"), "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, { + author: "a".repeat(64), + body: "Bring one seed.", + id: "fresh-card-id", + status: "doing", + title: "Next action", + type: "task", + }); + 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" }, + ], + ); + assert.match(updated, / + +Keep the visible body clean. +`; + + const [card] = parseCanvasBoard(content).cards; + assert.equal(card.id, "card-123"); + assert.equal(card.type, "task"); + assert.equal(card.status, "doing"); + assert.equal(card.threadId, threadId); + assert.equal(card.author, "author-pubkey"); + assert.equal(card.body, "Keep the visible body clean."); + assert.equal(card.hasExplicitMetadata, true); +}); + +test("metadata updates preserve the human Markdown and materialize legacy cards", () => { + const threadId = "c".repeat(64); + const content = `# Dispatch + +## Next action + +Ship the proof. +`; + const updated = updateCanvasBoardCardMetadata(content, "next-action-1", { + status: "done", + threadId, + type: "decision", + }); + assert.ok(updated); + + const [card] = parseCanvasBoard(updated).cards; + assert.equal(card.id, "next-action-1"); + assert.equal(card.type, "decision"); + assert.equal(card.status, "done"); + assert.equal(card.threadId, threadId); + assert.equal(card.body, "Ship the proof."); + assert.match(updated, /\s*$/u; +const CARD_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/u; +const EVENT_ID_PATTERN = /^[0-9a-f]{64}$/u; + +const CARD_TYPES = new Set([ + "agent", + "artifact", + "conversation", + "decision", + "note", + "person", + "project", + "task", +]); +const CARD_STATUSES = new Set([ + "backlog", + "doing", + "done", +]); + +type CanvasBoardCardMetadata = { + author: string | null; + id: string | null; + status: CanvasBoardCardStatus | null; + threadId: string | null; + type: CanvasBoardCardType | null; +}; + +type ParsedCanvasBoardSection = { + body: string; + hasExplicitMetadata: boolean; + metadata: CanvasBoardCardMetadata; +}; + +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() + .replace(/[^a-z0-9]+/gu, "-") + .replace(/^-|-$/gu, ""); + return `${slug || "card"}-${index + 1}`; +} + +function isStringRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function optionalString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; +} + +function parseCardMetadataLine(line: string): CanvasBoardCardMetadata | null { + const match = line.match(CARD_METADATA_PATTERN); + if (!match) { + return null; + } + + try { + const parsed: unknown = JSON.parse(match[1]); + if (!isStringRecord(parsed)) { + return null; + } + const id = optionalString(parsed.id); + const type = optionalString(parsed.type); + const status = optionalString(parsed.status); + const threadId = optionalString(parsed.thread); + const author = optionalString(parsed.author); + return { + author, + id: id && CARD_ID_PATTERN.test(id) ? id : null, + status: + status && CARD_STATUSES.has(status as CanvasBoardCardStatus) + ? (status as CanvasBoardCardStatus) + : null, + threadId: + threadId && EVENT_ID_PATTERN.test(threadId.toLowerCase()) + ? threadId.toLowerCase() + : null, + type: + type && CARD_TYPES.has(type as CanvasBoardCardType) + ? (type as CanvasBoardCardType) + : null, + }; + } catch { + return null; + } +} + +function parseCanvasBoardSection( + section: CanvasBoardSourceSection, +): ParsedCanvasBoardSection { + const bodyLines = [...section.bodyLines]; + const metadataLineIndex = bodyLines.findIndex( + (line) => line.trim().length > 0, + ); + if (metadataLineIndex === -1) { + return { + body: "", + hasExplicitMetadata: false, + metadata: { + author: null, + id: null, + status: null, + threadId: null, + type: null, + }, + }; + } + + const metadata = parseCardMetadataLine(bodyLines[metadataLineIndex]); + if (!metadata) { + return { + body: bodyLines.join("\n").trim(), + hasExplicitMetadata: false, + metadata: { + author: null, + id: null, + status: null, + threadId: null, + type: null, + }, + }; + } + + bodyLines.splice(metadataLineIndex, 1); + return { + body: bodyLines.join("\n").trim(), + hasExplicitMetadata: true, + metadata, + }; +} + +function serializeCardMetadata(metadata: { + author?: string | null; + id: string; + status: CanvasBoardCardStatus; + threadId?: string | null; + type: CanvasBoardCardType; +}): string { + return ``; +} + +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"; +} + +export function classifyCanvasBoardCardType( + title: string, +): CanvasBoardCardType { + const normalizedTitle = title.toLowerCase(); + if (/\b(agent|bot|berd)\b/u.test(normalizedTitle)) return "agent"; + if (/\b(person|people|member|steward|contributor)\b/u.test(normalizedTitle)) { + return "person"; + } + if (/\b(decision|decide|approved|verdict)\b/u.test(normalizedTitle)) { + return "decision"; + } + if (/\b(conversation|discussion|thread|work room)\b/u.test(normalizedTitle)) { + return "conversation"; + } + if (/\b(project|initiative|program|campaign)\b/u.test(normalizedTitle)) { + return "project"; + } + if (/\b(finished|made|shipped|artifact|showcase)\b/u.test(normalizedTitle)) { + return "artifact"; + } + if ( + /\b(task|todo|to-do|help|join|next|action|now|today|week|current|active)\b/u.test( + normalizedTitle, + ) + ) { + return "task"; + } + return "note"; +} + +function inferredCardStatus(kind: CanvasBoardCardKind): CanvasBoardCardStatus { + if (kind === "artifact") return "done"; + if (kind === "now") return "doing"; + return "backlog"; +} + +function parseCanvasBoardSource(content: string): CanvasBoardSource { + const introductionLines: string[] = []; + const preambleLines: string[] = []; + const sections: CanvasBoardSourceSection[] = []; + let title: 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")) { + 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(); + preambleLines.push(line); + continue; + } + } + + if (!isFenceBoundary) { + const h2Match = line.match(H2_PATTERN); + if (h2Match) { + activeSection = { + bodyLines: [], + headingLine: line, + title: h2Match[1].trim(), + }; + sections.push(activeSection); + continue; + } + } + + if (activeSection) { + activeSection.bodyLines.push(line); + } else { + preambleLines.push(line); + introductionLines.push(line); + } + } + + 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) => { + const parsedSection = parseCanvasBoardSection(section); + const kind = classifyCanvasBoardCard(section.title); + return { + author: parsedSection.metadata.author, + body: parsedSection.body, + hasExplicitMetadata: parsedSection.hasExplicitMetadata, + id: parsedSection.metadata.id ?? cardId(section.title, index), + kind, + status: parsedSection.metadata.status ?? inferredCardStatus(kind), + threadId: parsedSection.metadata.threadId, + title: section.title, + type: + parsedSection.metadata.type ?? + classifyCanvasBoardCardType(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({ + author: null, + body: introduction, + hasExplicitMetadata: false, + id: "overview-1", + kind: "welcome", + status: "backlog", + threadId: null, + title: "Overview", + type: "note", + }); + } + + return { + cards, + 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); + const type = draft.type ?? classifyCanvasBoardCardType(draft.title); + const status = draft.status ?? "backlog"; + const id = draft.id ?? cardId(draft.title, source.sections.length); + source.sections.push({ + bodyLines: [ + serializeCardMetadata({ + author: draft.author, + id, + status, + threadId: draft.threadId, + type, + }), + "", + ...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: [ + serializeCardMetadata({ + author: draft.author, + id: draft.id ?? cardId(draft.title, 0), + status: draft.status ?? "backlog", + threadId: draft.threadId, + type: draft.type ?? classifyCanvasBoardCardType(draft.title), + }), + "", + ...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; + } + + const currentCard = cards[sectionIndex]; + section.bodyLines = [ + serializeCardMetadata({ + author: draft.author ?? currentCard.author, + id: draft.id ?? currentCard.id, + status: draft.status ?? currentCard.status, + threadId: + draft.threadId === undefined ? currentCard.threadId : draft.threadId, + type: draft.type ?? currentCard.type, + }), + "", + ...draft.body.trim().split("\n"), + ]; + section.headingLine = `## ${draft.title.trim()}`; + section.title = draft.title.trim(); + return serializeCanvasBoardSource(source); +} + +export function updateCanvasBoardCardMetadata( + content: string, + cardIdToUpdate: string, + patch: Partial<{ + author: string | null; + status: CanvasBoardCardStatus; + threadId: string | null; + type: CanvasBoardCardType; + }>, +): string | null { + const source = parseCanvasBoardSource(content); + const cards = canvasBoardCardsFromSource(source); + const sectionIndex = cards.findIndex((card) => card.id === cardIdToUpdate); + const section = source.sections[sectionIndex]; + const card = cards[sectionIndex]; + if (!section || !card) { + return null; + } + + section.bodyLines = [ + serializeCardMetadata({ + author: patch.author === undefined ? card.author : patch.author, + id: card.id, + status: patch.status ?? card.status, + threadId: patch.threadId === undefined ? card.threadId : patch.threadId, + type: patch.type ?? card.type, + }), + "", + ...card.body.split("\n"), + ]; + return serializeCanvasBoardSource(source); +} + +export function canvasBoardCardConversationMarker(cardId: string): string { + return `magic-board-card:${cardId}`; +} + +export function buildCanvasBoardCardConversationOpener( + card: CanvasBoardCard, + channelName: string, +): string { + const body = card.body.trim(); + return [ + `## ${card.title}`, + body, + `_Conversation attached to the ${channelName} board._`, + ] + .filter((part) => part.length > 0) + .join("\n\n"); +} + +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; + 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"), + }; +} + +export function channelViewModeStorageKey(channelId: string): string { + return `${CHANNEL_VIEW_MODE_STORAGE_PREFIX}:${channelId}`; +} + +export function readStoredChannelViewMode( + channelId: string, +): ChannelViewMode | null { + const value = getStorageItem(channelViewModeStorageKey(channelId)); + return value === "board" || value === "stream" ? value : null; +} + +export function writeStoredChannelViewMode( + channelId: string, + mode: ChannelViewMode, +): boolean { + return setStorageItem(channelViewModeStorageKey(channelId), mode); +} diff --git a/desktop/src/features/channels/ui/ChannelBoard.tsx b/desktop/src/features/channels/ui/ChannelBoard.tsx new file mode 100644 index 00000000000..121222ac7b9 --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelBoard.tsx @@ -0,0 +1,722 @@ +import { + closestCenter, + DndContext, + DragOverlay, + KeyboardSensor, + pointerWithin, + PointerSensor, + useDroppable, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import type { + CollisionDetection, + DragEndEvent, + DragStartEvent, +} from "@dnd-kit/core"; +import { + rectSortingStrategy, + sortableKeyboardCoordinates, + SortableContext, + useSortable, +} from "@dnd-kit/sortable"; +import { + Bot, + Boxes, + CheckCircle2, + CircleDot, + Columns3, + FileCheck2, + FolderKanban, + Gavel, + GripVertical, + LayoutGrid, + ListTodo, + MessageCircle, + MessageSquareText, + Pencil, + Plus, + Settings2, + Sparkles, + StickyNote, + UserRound, + Users, +} from "lucide-react"; +import * as React from "react"; + +import { + type CanvasBoardCard, + type CanvasBoardCardStatus, + type CanvasBoardCardType, + 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_TYPE_STYLES: Record = { + agent: "border-fuchsia-500/25 bg-fuchsia-500/8", + artifact: "border-emerald-500/25 bg-emerald-500/8", + conversation: "border-blue-500/25 bg-blue-500/8", + decision: "border-orange-500/25 bg-orange-500/8", + note: "border-violet-500/20 bg-violet-500/7", + person: "border-sky-500/25 bg-sky-500/8", + project: "border-cyan-500/25 bg-cyan-500/8", + task: "border-amber-500/30 bg-amber-500/10", +}; + +const CARD_TYPE_LABELS: Record = { + agent: "Agent", + artifact: "Artifact", + conversation: "Conversation", + decision: "Decision", + note: "Note", + person: "Person", + project: "Project", + task: "Task", +}; + +const CARD_TYPE_ICONS = { + agent: Bot, + artifact: FileCheck2, + conversation: MessageCircle, + decision: Gavel, + note: StickyNote, + person: UserRound, + project: FolderKanban, + task: ListTodo, +} satisfies Record; + +const STATUS_LABELS: Record = { + backlog: "Backlog", + doing: "Doing", + done: "Done", +}; + +const STATUS_ICONS = { + backlog: CircleDot, + doing: Sparkles, + done: CheckCircle2, +} satisfies Record; + +const KANBAN_STATUSES: CanvasBoardCardStatus[] = ["backlog", "doing", "done"]; + +const boardCollisionDetection: CollisionDetection = (args) => { + const pointerCollisions = pointerWithin(args); + return pointerCollisions.length > 0 ? pointerCollisions : closestCenter(args); +}; + +type CanvasBoardLayout = "cards" | "kanban"; + +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; + onChangeCardStatus: ( + card: CanvasBoardCard, + status: CanvasBoardCardStatus, + ) => void; + onEditCard: (card: CanvasBoardCard) => void; + onManageBoard: () => void; + onMoveCard: (activeCardId: string, overCardId: string) => void; + onOpenMembers: () => void; + onOpenCardConversation: (card: CanvasBoardCard) => void; + onOpenStream: () => void; + pendingConversationCardId?: string | null; + 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, + canEdit, + channelNames, + isConversationPending, + isSaving, + onEdit, + onOpenConversation, +}: { + card: CanvasBoardCard; + canEdit: boolean; + channelNames: string[]; + isConversationPending: boolean; + isSaving: boolean; + onEdit: () => void; + onOpenConversation: () => void; +}) { + const Icon = CARD_TYPE_ICONS[card.type]; + const StatusIcon = STATUS_ICONS[card.status]; + const dragDisabled = !canEdit || isSaving; + const { + attributes, + isDragging, + isOver, + listeners, + setActivatorNodeRef, + setNodeRef, + } = useSortable({ + id: card.id, + disabled: dragDisabled, + }); + + return ( +
+ + +
+
+ + {CARD_TYPE_LABELS[card.type]} +
+ {canEdit ? ( +
+ + +
+ ) : null} +
+ {card.title} +
+ {card.body ? ( + + + + ) : null} + + + + {STATUS_LABELS[card.status]} + {card.author ? ( + + · + + by + + + ) : null} + + {card.threadId || canEdit ? ( + + ) : null} + +
+
+ ); +} + +function BoardCardDragOverlay({ card }: { card: CanvasBoardCard }) { + const Icon = CARD_TYPE_ICONS[card.type]; + + return ( + + +
+ + {CARD_TYPE_LABELS[card.type]} +
+ {card.title} +
+
+ ); +} + +function KanbanColumn({ + cards, + canEdit, + channelNames, + isSaving, + onEditCard, + onOpenCardConversation, + pendingConversationCardId, + status, +}: { + cards: CanvasBoardCard[]; + canEdit: boolean; + channelNames: string[]; + isSaving: boolean; + onEditCard: (card: CanvasBoardCard) => void; + onOpenCardConversation: (card: CanvasBoardCard) => void; + pendingConversationCardId?: string | null; + status: CanvasBoardCardStatus; +}) { + const { isOver, setNodeRef } = useDroppable({ id: `status:${status}` }); + const StatusIcon = STATUS_ICONS[status]; + + return ( +
+
+
+ + {STATUS_LABELS[status]} +
+ + {cards.length} + +
+ card.id)}> +
+ {cards.map((card) => ( + onEditCard(card)} + onOpenConversation={() => onOpenCardConversation(card)} + /> + ))} + {cards.length === 0 ? ( +
+ {canEdit ? "Drop a card here" : "No cards"} +
+ ) : 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({ + actionErrorMessage, + agentCount, + author, + canEdit, + channelName, + content, + errorMessage, + isLoading, + isSaving, + memberCount, + onChangeCardStatus, + onCreateCard, + onEditCard, + onManageBoard, + onMoveCard, + onOpenCardConversation, + onOpenMembers, + onOpenStream, + pendingConversationCardId, + 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); + const [activeCardId, setActiveCardId] = React.useState(null); + const [layout, setLayout] = React.useState("cards"); + 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; + } + const activeCard = board.cards.find( + (card) => card.id === String(event.active.id), + ); + const overId = String(event.over.id); + const overCard = board.cards.find((card) => card.id === overId); + const targetStatus = overId.startsWith("status:") + ? (overId.slice("status:".length) as CanvasBoardCardStatus) + : overCard?.status; + + if ( + layout === "kanban" && + activeCard && + targetStatus && + targetStatus !== activeCard.status + ) { + onChangeCardStatus(activeCard, targetStatus); + return; + } + if (overCard) { + onMoveCard(activeCard?.id ?? String(event.active.id), overCard.id); + } + } + + 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} +
+

+ Stewards curate the shared layout; every member can join linked + card threads. +

+
+
+
+ + +
+ {canEdit ? ( + + ) : null} + + +
+
+
+ + {actionErrorMessage ? ( +

+ {actionErrorMessage} +

+ ) : 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. +

+ +
+ ) : ( + setActiveCardId(null)} + onDragEnd={handleDragEnd} + onDragStart={handleDragStart} + sensors={sensors} + > + {layout === "cards" ? ( + card.id)} + strategy={rectSortingStrategy} + > +
+ {board.cards.map((card) => ( + onEditCard(card)} + onOpenConversation={() => onOpenCardConversation(card)} + /> + ))} + +
+
+ ) : ( +
+ {KANBAN_STATUSES.map((status) => ( + card.status === status)} + channelNames={channelNames} + isSaving={isSaving} + key={status} + onEditCard={onEditCard} + onOpenCardConversation={onOpenCardConversation} + pendingConversationCardId={pendingConversationCardId} + status={status} + /> + ))} +
+ )} + + {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..926c1bdb343 --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelBoardCardEditorDialog.tsx @@ -0,0 +1,242 @@ +import * as React from "react"; + +import { + type CanvasBoardCard, + type CanvasBoardCardDraft, + type CanvasBoardCardStatus, + type CanvasBoardCardType, + 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; +}; + +const CARD_TYPE_OPTIONS: Array<{ + label: string; + value: CanvasBoardCardType; +}> = [ + { label: "Note", value: "note" }, + { label: "Task", value: "task" }, + { label: "Decision", value: "decision" }, + { label: "Conversation", value: "conversation" }, + { label: "Project", value: "project" }, + { label: "Artifact", value: "artifact" }, + { label: "Person", value: "person" }, + { label: "Agent", value: "agent" }, +]; + +const CARD_STATUS_OPTIONS: Array<{ + label: string; + value: CanvasBoardCardStatus; +}> = [ + { label: "Backlog", value: "backlog" }, + { label: "Doing", value: "doing" }, + { label: "Done", value: "done" }, +]; + +const SELECT_CLASS_NAME = + "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"; + +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 [type, setType] = React.useState("note"); + const [status, setStatus] = React.useState("backlog"); + const draft = React.useMemo( + () => ({ body, status, title, type }), + [body, status, title, type], + ); + const validationError = validateCanvasBoardCardDraft(draft); + + React.useEffect(() => { + if (!open) { + return; + } + setTitle(card?.title ?? ""); + setBody(card?.body ?? ""); + setType(card?.type ?? "note"); + setStatus(card?.status ?? "backlog"); + }, [card?.body, card?.status, card?.title, card?.type, open]); + + async function handleSave(event: React.FormEvent) { + event.preventDefault(); + if (validationError || isSaving) { + return; + } + + try { + await onSave({ + body: body.trim(), + status, + title: title.trim(), + type, + }); + } 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} + /> +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ +