Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
40 changes: 40 additions & 0 deletions desktop/src-tauri/src/commands/canvas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,25 @@ pub async fn get_canvas(
pub async fn set_canvas(
channel_id: String,
content: String,
enforce_revision: Option<bool>,
expected_event_id: Option<String>,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
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())?;
Comment on lines +65 to +66

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use a revision that orders same-second canvas writes

When multiple board mutations occur within one second, their signed events share the same second-resolution created_at; however, query_events orders ties by id ASC (crates/buzz-db/src/store/event.rs:685-693). After the client caches the returned ID of a later drag or status write, this limit-one query can still select the earlier lower-ID event, causing the next save to report a revision conflict despite no concurrent editor and causing reloads to select the earlier canvas. The revision must use an authoritative monotonically ordered token rather than assuming the last submitted event ID will become the query head.

Useful? React with 👍 / 👎.

}
let builder = events::build_set_canvas(uuid, &content)?;
let result = submit_event(builder, &state).await?;

Expand All @@ -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());
}
}
106 changes: 88 additions & 18 deletions desktop/src/app/routes/ChannelRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -115,6 +128,7 @@ export function ChannelRouteScreen({
autoSendDraftKey,
channelId,
searchHighlight,
hasStreamRouteIntent,
selectedPostId,
targetMessageId,
targetReplyId,
Expand Down Expand Up @@ -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 ||
Comment on lines +188 to +192

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve stream mode after resolving a message target

In a Dispatch channel or any channel whose saved preference is Board, a messageId only forces Stream until the timeline finds the row: ChannelScreen then calls clearMessageRouteTarget({ replace: true }), making this predicate false and immediately remounting the Board. The linked message therefore appears only briefly before being hidden, so retain Stream for the lifetime of the resolved navigation rather than deriving it solely from the transient URL parameter.

Useful? React with 👍 / 👎.

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[]
>(() => {
Expand Down Expand Up @@ -309,23 +361,41 @@ export function ChannelRouteScreen({
}

return (
<ChannelScreen
activeChannel={activeChannel}
autoSendDraftKey={autoSendDraftKey}
currentIdentity={identityQuery.data}
currentProfile={profileQuery.data}
onCloseForumPost={() => {
void closeForumPost(channelId);
}}
onSelectForumPost={(postId) => {
void goForumPost(channelId, postId);
<ChannelViewModeProvider
value={{
boardAvailable: channelView.boardAvailable && !hasRouteTarget,
mode: channelView.mode,
onModeChange: handleViewModeChange,
}}
selectedForumPostId={selectedPostId}
targetForumReplyId={targetReplyId}
targetMessageEvents={targetMessageEvents}
targetMessageId={targetMessageId}
targetSearchMessageId={activeSearchHighlight?.messageId}
targetSearchQuery={activeSearchHighlight?.query}
/>
>
{channelView.mode === "board" && activeChannel ? (
<ChannelBoardScreen
canvas={canvasQuery.data}
canvasError={canvasQuery.error}
canvasLoading={canvasQuery.isLoading}
channel={activeChannel}
currentPubkey={identityQuery.data?.pubkey}
/>
) : (
<ChannelScreen
activeChannel={activeChannel}
autoSendDraftKey={autoSendDraftKey}
currentIdentity={identityQuery.data}
currentProfile={profileQuery.data}
onCloseForumPost={() => {
void closeForumPost(channelId);
}}
onSelectForumPost={(postId) => {
void goForumPost(channelId, postId);
}}
selectedForumPostId={selectedPostId}
targetForumReplyId={targetReplyId}
targetMessageEvents={targetMessageEvents}
targetMessageId={targetMessageId}
targetSearchMessageId={activeSearchHighlight?.messageId}
targetSearchQuery={activeSearchHighlight?.query}
/>
)}
</ChannelViewModeProvider>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ function ForumPostRouteComponent() {
autoSendDraftKey={null}
channelId={channelId}
searchHighlight={searchHighlight}
hasStreamRouteIntent={false}
selectedPostId={postId}
targetMessageId={null}
targetReplyId={search.replyId ?? null}
Expand Down
3 changes: 3 additions & 0 deletions desktop/src/app/routes/channels.$channelId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
86 changes: 84 additions & 2 deletions desktop/src/features/channels/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<CanvasResponse>(
["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,
Comment on lines +1011 to +1015

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Backfill the desktop live canvas subscription

If the initial useCanvasQuery request completes before this WebSocket subscription becomes active, a canvas update published in that interval is absent from the history result, and limit: 0 prevents the subscription from replaying it. The desktop then displays stale board contents until another update or reconnect; start the live subscription with bounded backfill or establish readiness before refreshing history.

AGENTS.md reference: AGENTS.md:L183-L186

Useful? React with 👍 / 👎.

},
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) {
Expand Down
Loading