feat: add channel canvas frontend support (#130)

This commit is contained in:
Wes
2026-03-20 11:22:28 -07:00
committed by GitHub
parent a6fd68f9c0
commit e70d31eabf
10 changed files with 279 additions and 1 deletions
+2 -1
View File
@@ -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) {
+30
View File
@@ -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<serde_json::Value, String> {
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<serde_json::Value, String> {
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
}
+2
View File
@@ -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::*;
+2
View File
@@ -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,
+5
View File
@@ -392,6 +392,11 @@ pub struct GetForumThreadQuery {
pub cursor: Option<String>,
}
#[derive(Serialize)]
pub struct SetCanvasBody<'a> {
pub content: &'a str,
}
fn deserialize_null_string_as_empty<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: Deserializer<'de>,
+37
View File
@@ -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],
});
}
},
});
}
@@ -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 <p className="text-sm text-muted-foreground">Loading canvas</p>;
}
if (canvasQuery.error instanceof Error) {
return (
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{canvasQuery.error.message}
</p>
);
}
if (isEditing) {
return (
<div className="space-y-3">
<Textarea
aria-label="Canvas content"
className="min-h-48 font-mono text-sm"
data-testid="channel-canvas-editor"
disabled={setCanvasMutation.isPending}
onChange={(event) => setDraft(event.target.value)}
placeholder="Write your canvas content in Markdown…"
value={draft}
/>
<div className="flex gap-2">
<Button
data-testid="channel-canvas-save"
disabled={setCanvasMutation.isPending}
onClick={() => {
void handleSave().catch(() => {
// Error is already surfaced via setCanvasMutation.error
});
}}
size="sm"
type="button"
>
<Save className="h-4 w-4" />
{setCanvasMutation.isPending ? "Saving…" : "Save canvas"}
</Button>
<Button
data-testid="channel-canvas-cancel"
disabled={setCanvasMutation.isPending}
onClick={handleCancelEditing}
size="sm"
type="button"
variant="outline"
>
<X className="h-4 w-4" />
Cancel
</Button>
</div>
{setCanvasMutation.error instanceof Error ? (
<p className="text-sm text-destructive">
{setCanvasMutation.error.message}
</p>
) : null}
</div>
);
}
return (
<div className="space-y-3">
{canvasContent ? (
<div
className="rounded-2xl border border-border/70 bg-muted/20 px-4 py-3"
data-testid="channel-canvas-content"
>
<Markdown compact content={canvasContent} />
</div>
) : (
<p className="text-sm text-muted-foreground">
No canvas set for this channel.
</p>
)}
{canEdit && !isArchived ? (
<Button
data-testid="channel-canvas-edit"
onClick={handleStartEditing}
size="sm"
type="button"
variant="outline"
>
<Pencil className="h-4 w-4" />
{canvasContent ? "Edit canvas" : "Create canvas"}
</Button>
) : null}
</div>
);
}
@@ -53,6 +53,7 @@ import {
SheetTitle,
} from "@/shared/ui/sheet";
import { Textarea } from "@/shared/ui/textarea";
import { ChannelCanvas } from "./ChannelCanvas";
import { ChannelMemberInviteCard } from "./ChannelMemberInviteCard";
type ChannelManagementSheetProps = {
@@ -516,6 +517,19 @@ export function ChannelManagementSheet({
<Separator />
<Section
description="A shared Markdown document for the channel."
title="Canvas"
>
<ChannelCanvas
canEdit={canEditNarrative}
channelId={channelId}
isArchived={isArchived}
/>
</Section>
<Separator />
<Section
description="Owners and admins can invite members or remove them."
title="Members"
+38
View File
@@ -3,6 +3,7 @@ import { invoke as tauriInvoke } from "@tauri-apps/api/core";
import type {
AddChannelMembersInput,
AddChannelMembersResult,
CanvasResponse,
Channel,
ChannelDetail,
ChannelMember,
@@ -23,6 +24,8 @@ import type {
SearchMessagesInput,
SearchMessagesResponse,
SendChannelMessageResult,
SetCanvasInput,
SetCanvasResult,
SetPresenceResult,
SetChannelPurposeInput,
SetChannelTopicInput,
@@ -280,6 +283,17 @@ type RawManagedAgentPrereqs = {
mcp: RawCommandAvailability;
};
type RawCanvasResponse = {
content: string | null;
updated_at: number | null;
author: string | null;
};
type RawSetCanvasResult = {
ok: boolean;
event_id: string;
};
function toTauriError(error: unknown): Error {
if (error instanceof Error) {
return error;
@@ -588,6 +602,30 @@ export async function leaveChannel(channelId: string): Promise<void> {
await invokeTauri("leave_channel", { channelId });
}
export async function getCanvas(channelId: string): Promise<CanvasResponse> {
const response = await invokeTauri<RawCanvasResponse>("get_canvas", {
channelId,
});
return {
content: response.content,
updatedAt: response.updated_at,
author: response.author,
};
}
export async function setCanvas(
input: SetCanvasInput,
): Promise<SetCanvasResult> {
const response = await invokeTauri<RawSetCanvasResult>("set_canvas", {
channelId: input.channelId,
content: input.content,
});
return {
ok: response.ok,
eventId: response.event_id,
};
}
export async function getHomeFeed(
input: GetHomeFeedInput = {},
): Promise<HomeFeedResponse> {
+16
View File
@@ -65,6 +65,22 @@ export type SetChannelPurposeInput = {
purpose: string;
};
export type CanvasResponse = {
content: string | null;
updatedAt: number | null;
author: string | null;
};
export type SetCanvasInput = {
channelId: string;
content: string;
};
export type SetCanvasResult = {
ok: boolean;
eventId: string;
};
export type AddChannelMembersInput = {
channelId: string;
pubkeys: string[];