diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 623bfbc3a..9eedcf252 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -31,13 +31,14 @@ const rules = [ // Exceptions should stay rare and temporary. Prefer splitting files instead. const overrides = new Map([ ["src/app/AppShell.tsx", 750], + ["src/features/channels/hooks.ts", 525], // canvas query + mutation hooks ["src/features/channels/ui/ChannelManagementSheet.tsx", 800], ["src/features/messages/ui/MessageComposer.tsx", 650], // media upload handlers (paste, drop, dialog) ["src/features/settings/ui/SettingsView.tsx", 600], ["src/features/sidebar/ui/AppSidebar.tsx", 850], // channels + forums creation forms ["src/features/tokens/ui/TokenSettingsCard.tsx", 800], ["src/shared/api/relayClientSession.ts", 725], // durable websocket session manager with reconnect/replay/recovery state - ["src/shared/api/tauri.ts", 975], + ["src/shared/api/tauri.ts", 1025], // canvas API functions ]); async function walkFiles(directory) { diff --git a/desktop/src-tauri/src/commands/canvas.rs b/desktop/src-tauri/src/commands/canvas.rs new file mode 100644 index 000000000..a27df6eb9 --- /dev/null +++ b/desktop/src-tauri/src/commands/canvas.rs @@ -0,0 +1,30 @@ +use reqwest::Method; +use tauri::State; + +use crate::{ + app_state::AppState, + models::SetCanvasBody, + relay::{build_authed_request, send_json_request}, +}; + +#[tauri::command] +pub async fn get_canvas( + channel_id: String, + state: State<'_, AppState>, +) -> Result { + let path = format!("/api/channels/{channel_id}/canvas"); + let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?; + send_json_request(request).await +} + +#[tauri::command] +pub async fn set_canvas( + channel_id: String, + content: String, + state: State<'_, AppState>, +) -> Result { + let path = format!("/api/channels/{channel_id}/canvas"); + let request = build_authed_request(&state.http_client, Method::PUT, &path, &state)? + .json(&SetCanvasBody { content: &content }); + send_json_request(request).await +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 29087a295..853f73b40 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -2,6 +2,7 @@ mod agent_discovery; mod agent_models; mod agent_settings; mod agents; +mod canvas; mod channels; mod dms; mod identity; @@ -15,6 +16,7 @@ pub use agent_discovery::*; pub use agent_models::*; pub use agent_settings::*; pub use agents::*; +pub use canvas::*; pub use channels::*; pub use dms::*; pub use identity::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 9355d784e..4e895e75f 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -141,6 +141,8 @@ pub fn run() { remove_channel_member, join_channel, leave_channel, + get_canvas, + set_canvas, get_feed, search_messages, send_channel_message, diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index 7ffd3441a..be93767be 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -392,6 +392,11 @@ pub struct GetForumThreadQuery { pub cursor: Option, } +#[derive(Serialize)] +pub struct SetCanvasBody<'a> { + pub content: &'a str, +} + fn deserialize_null_string_as_empty<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index 777dd9c3f..544c4becf 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -11,6 +11,7 @@ import { archiveChannel, createChannel, deleteChannel, + getCanvas, getChannelDetails, getChannelMembers, getChannels, @@ -18,6 +19,7 @@ import { leaveChannel, openDm, removeChannelMember, + setCanvas, setChannelPurpose, setChannelTopic, unarchiveChannel, @@ -474,3 +476,38 @@ export function useSelectedChannel( setSelectedChannelId, }; } + +// ── Canvas ──────────────────────────────────────────────────────────────────── + +export function useCanvasQuery(channelId: string | null, enabled = true) { + return useQuery({ + queryKey: ["channel-canvas", channelId], + queryFn: () => { + if (!channelId) { + return Promise.reject(new Error("No channel selected")); + } + return getCanvas(channelId); + }, + enabled: enabled && channelId !== null, + }); +} + +export function useSetCanvasMutation(channelId: string | null) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (content: string) => { + if (!channelId) { + return Promise.reject(new Error("No channel selected")); + } + return setCanvas({ channelId, content }); + }, + onSuccess: () => { + if (channelId) { + void queryClient.invalidateQueries({ + queryKey: ["channel-canvas", channelId], + }); + } + }, + }); +} diff --git a/desktop/src/features/channels/ui/ChannelCanvas.tsx b/desktop/src/features/channels/ui/ChannelCanvas.tsx new file mode 100644 index 000000000..d7ea8616b --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelCanvas.tsx @@ -0,0 +1,133 @@ +import { Pencil, Save, X } from "lucide-react"; +import * as React from "react"; + +import { + useCanvasQuery, + useSetCanvasMutation, +} from "@/features/channels/hooks"; +import { Button } from "@/shared/ui/button"; +import { Markdown } from "@/shared/ui/markdown"; +import { Textarea } from "@/shared/ui/textarea"; + +type ChannelCanvasProps = { + channelId: string | null; + canEdit: boolean; + isArchived: boolean; +}; + +export function ChannelCanvas({ + channelId, + canEdit, + isArchived, +}: ChannelCanvasProps) { + const canvasQuery = useCanvasQuery(channelId, channelId !== null); + const setCanvasMutation = useSetCanvasMutation(channelId); + const [isEditing, setIsEditing] = React.useState(false); + const [draft, setDraft] = React.useState(""); + + const canvasContent = canvasQuery.data?.content ?? null; + + function handleStartEditing() { + setDraft(canvasContent ?? ""); + setIsEditing(true); + } + + function handleCancelEditing() { + setIsEditing(false); + setDraft(""); + } + + async function handleSave() { + await setCanvasMutation.mutateAsync(draft); + setIsEditing(false); + } + + if (canvasQuery.isLoading) { + return

Loading canvas…

; + } + + if (canvasQuery.error instanceof Error) { + return ( +

+ {canvasQuery.error.message} +

+ ); + } + + if (isEditing) { + return ( +
+