Skip to content
Merged
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
41 changes: 27 additions & 14 deletions desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import { useChannelIntro } from "@/features/channels/ui/useChannelIntro";
import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types";
import * as agentSessionSelection from "@/features/channels/ui/agentSessionSelection";
import { usePrepareDmSendChannel } from "@/features/channels/ui/usePrepareDmSendChannel";
import { useOwnSendArm } from "@/features/channels/ui/useOwnSendArm";
import { useChannelPaneMessages } from "@/features/channels/ui/useChannelPaneMessages";
import { useRoutedMessageEdit } from "@/features/channels/ui/useRoutedMessageEdit";
import { Button } from "@/shared/ui/button";
Expand Down Expand Up @@ -280,6 +281,11 @@ export const ChannelPane = React.memo(function ChannelPane({
}
return pubkeys;
}, [activityAgents, agentPubkeys, agentSessionAgents]);
const getTimelineChannelId = React.useCallback(
() => activeChannelIdRef.current,
[],
);
const armOwnSend = useOwnSendArm(messageTimelineRef, getTimelineChannelId);
const handleSendMessage = React.useCallback(
async (
content: string,
Expand All @@ -296,15 +302,24 @@ export const ChannelPane = React.memo(function ChannelPane({
isActiveWelcomeChannel &&
(containsWelcomePersonaMention(content) ||
mentionsKnownAgent(mentionPubkeys, knownAgentPubkeys));
messageTimelineRef.current?.scrollToBottomOnNextUpdate();
await onSendMessage(
content,
mentionPubkeys,
mediaTags,
channelId,
threadContext,
forceRest,
);
// A send prepared into another channel (a DM message that mentions an
// agent outside the DM) lands its row there, so this timeline never sees
// it, and the arm skips it. The pane navigates there once the send
// succeeds.
const disarmOwnSend = armOwnSend(channelId ?? activeChannelId);
try {
await onSendMessage(
content,
mentionPubkeys,
mediaTags,
channelId,
threadContext,
forceRest,
);
} catch (error) {
disarmOwnSend();
throw error;
}
if (
channelId &&
channelId !== activeChannelId &&
Expand All @@ -319,6 +334,7 @@ export const ChannelPane = React.memo(function ChannelPane({
},
[
activeChannelId,
armOwnSend,
completeWelcomeComposerBanner,
goChannel,
isActiveWelcomeChannel,
Expand Down Expand Up @@ -357,11 +373,8 @@ export const ChannelPane = React.memo(function ChannelPane({
[activeChannel, currentPubkey, profiles],
);
const handleWelcomeAddAgent = React.useCallback(() => {
onAddAgent?.({
beforeSend: () =>
messageTimelineRef.current?.scrollToBottomOnNextUpdate(),
});
}, [onAddAgent]);
onAddAgent?.({ beforeSend: armOwnSend });
}, [armOwnSend, onAddAgent]);
const standardChannelIntro = useChannelIntro({
activeChannel,
onAddAgent,
Expand Down
4 changes: 3 additions & 1 deletion desktop/src/features/channels/ui/ChannelPane.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,9 @@ export type ChannelPaneProps = {
onChannelManagementDeleted?: () => void;
onCloseIdleAuxiliaryPanel?: () => void;
onCloseProfilePanel: () => void;
onAddAgent?: (options?: { beforeSend?: () => void }) => void;
onAddAgent?: (options?: {
beforeSend?: (channelId: string) => () => void;
}) => void;
onAddFiles?: () => void;
onBrowseChannels?: () => void;
onCreateChannel?: () => void;
Expand Down
2 changes: 1 addition & 1 deletion desktop/src/features/channels/ui/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -559,7 +559,7 @@ export function ChannelScreen({
? handleSendVideoReviewComment
: undefined;
const handleOpenAddBot = React.useCallback(
(options?: { beforeSend?: () => void }) =>
(options?: { beforeSend?: (channelId: string) => () => void }) =>
welcomeAgentCreate.openAddAgent(() => setIsAddBotOpen(true), options),
[welcomeAgentCreate],
);
Expand Down
4 changes: 3 additions & 1 deletion desktop/src/features/channels/ui/useChannelIntro.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ export function useChannelIntro({
onWelcomeAddAgent,
}: {
activeChannel: Channel | null;
onAddAgent?: (options?: { beforeSend?: () => void }) => void;
onAddAgent?: (options?: {
beforeSend?: (channelId: string) => () => void;
}) => void;
onAddFiles?: () => void;
onBrowseChannels?: () => void;
onCreateChannel?: () => void;
Expand Down
85 changes: 85 additions & 0 deletions desktop/src/features/channels/ui/useOwnSendArm.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import assert from "node:assert/strict";
import test from "node:test";

import { installDOMShim } from "../observedUnreadTestHarness.mjs";

installDOMShim();

import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";

import { useOwnSendArm } from "./useOwnSendArm.ts";

// Mounts the hook over a timeline that records what it was asked to do. The
// pane's channel is read live, as ChannelPane reads activeChannelIdRef, so a
// test can switch it under an arm the composer already holds.
async function mountPane(channelId) {
const calls = [];
const timelineRef = {
current: {
cancelScrollToBottomOnNextUpdate: () => calls.push("disarm"),
scrollToBottomOnNextUpdate: (target) => calls.push(`arm ${target}`),
},
};
const pane = { arm: null, calls, channelId };
const getTimelineChannelId = () => pane.channelId;
function Harness() {
pane.arm = useOwnSendArm(timelineRef, getTimelineChannelId);
return null;
}
const root = createRoot(document.createElement("div"));
await act(async () => root.render(React.createElement(Harness)));
pane.unmount = () => act(async () => root.unmount());
return pane;
}

test("a send to the channel the timeline shows arms it", async () => {
const pane = await mountPane("general");

pane.arm("general");
pane.arm(null);

assert.deepEqual(pane.calls, ["arm general"]);
await pane.unmount();
});

// A send whose upload spans a channel switch settles with the channel it was
// written in. Its arm must not pin or hold the channel shown now, and must not
// take the generation from the send that is pending there.
test("a send that settles after the pane switched channels is not armed", async () => {
const pane = await mountPane("design");
pane.channelId = "general";
const disarmPending = pane.arm("general");

const disarmLate = pane.arm("design");
disarmLate();
assert.deepEqual(pane.calls, ["arm general"]);

disarmPending();
assert.deepEqual(pane.calls, ["arm general", "disarm"]);
await pane.unmount();
});

test("a failed send disarms the timeline", async () => {
const pane = await mountPane("general");

const disarm = pane.arm("general");
disarm();

assert.deepEqual(pane.calls, ["arm general", "disarm"]);
await pane.unmount();
});

test("a later send's arm outlives an earlier send's failure", async () => {
const pane = await mountPane("general");

const disarmEarlier = pane.arm("general");
const disarmLater = pane.arm("general");
disarmEarlier();
assert.deepEqual(pane.calls, ["arm general", "arm general"]);

disarmLater();
assert.deepEqual(pane.calls, ["arm general", "arm general", "disarm"]);
await pane.unmount();
});
41 changes: 41 additions & 0 deletions desktop/src/features/channels/ui/useOwnSendArm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import * as React from "react";

import type { MessageTimelineHandle } from "@/features/messages/ui/MessageTimeline";

type OwnSendTimeline = Pick<
MessageTimelineHandle,
"cancelScrollToBottomOnNextUpdate" | "scrollToBottomOnNextUpdate"
>;

const noDisarm = () => {};

/**
* Arms the timeline's own-send pin for a send to `channelId` and returns its
* disarm for a failed send.
*
* A send can settle after the pane moved to another channel (a slow upload
* spans the switch), so the arm is skipped unless `channelId` is the channel
* the timeline shows now, read live from `getTimelineChannelId`. A skipped arm
* leaves the generation alone, so it can't strand an earlier send's disarm.
* The generation keeps a late failure from disarming a later send's pin.
*/
export function useOwnSendArm(
timelineRef: React.RefObject<OwnSendTimeline | null>,
getTimelineChannelId: () => string | null,
) {
const generationRef = React.useRef(0);
return React.useCallback(
(channelId: string | null) => {
if (!channelId || channelId !== getTimelineChannelId()) {
return noDisarm;
}
const generation = ++generationRef.current;
timelineRef.current?.scrollToBottomOnNextUpdate(channelId);
return () => {
if (generationRef.current !== generation) return;
timelineRef.current?.cancelScrollToBottomOnNextUpdate();
};
},
[getTimelineChannelId, timelineRef],
);
}
15 changes: 12 additions & 3 deletions desktop/src/features/channels/useWelcomeAgentCreate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,17 @@ export function useWelcomeAgentCreate({
);
const [isOpen, setIsOpen] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const beforeSendRef = React.useRef<(() => void) | null>(null);
// Runs right before the chat-first send, with the channel it sends to, and
// returns how to undo it if the send fails.
const beforeSendRef = React.useRef<
((channelId: string) => () => void) | null
>(null);

const openAddAgent = React.useCallback(
(openRegularPicker: () => void, options?: { beforeSend?: () => void }) => {
(
openRegularPicker: () => void,
options?: { beforeSend?: (channelId: string) => () => void },
) => {
setError(null);
if (isWelcomeExperienceChannel(activeChannel)) {
beforeSendRef.current = options?.beforeSend ?? null;
Expand Down Expand Up @@ -59,15 +66,17 @@ export function useWelcomeAgentCreate({
return;
}
setError(null);
let undoBeforeSend: (() => void) | undefined;
try {
beforeSendRef.current?.();
undoBeforeSend = beforeSendRef.current?.(activeChannel.id);
await sendMessageMutation.mutateAsync({
channelId: activeChannel.id,
content: `@${welcomeGuideAgent.name}, help me create a new agent.`,
mentionPubkeys: [welcomeGuideAgent.pubkey],
});
setIsOpen(false);
} catch (cause) {
undoBeforeSend?.();
setError(
cause instanceof Error
? cause.message
Expand Down
Loading
Loading