Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
dc1ee8f
feat(desktop): add conversation-style DM composer
Jul 12, 2026
a00c147
fix(desktop): show DM recipient search skeletons
Jul 12, 2026
4aa187e
test(desktop): cover new DM compose states
Jul 12, 2026
5af5df6
fix(desktop): keep DM recipient picker open
Jul 12, 2026
0a72163
fix(desktop): prepare DM before agent mentions
Jul 12, 2026
70b1c9c
fix(desktop): lock recipients during mention send
Jul 12, 2026
7072d28
fix(desktop): guard delayed DM navigation
Jul 12, 2026
5508d9e
fix(desktop): reveal DM recipient pubkeys on hover
Jul 13, 2026
3b00ed3
fix(desktop): restore full DM recipient verification
Jul 13, 2026
dd2d42f
fix(desktop): remove duplicate recipient input prop
Jul 13, 2026
510eb2a
fix(desktop): bind first DM send to opened channel
Jul 13, 2026
63fa592
fix(desktop): reseed opened DM before routing
Jul 13, 2026
310659b
fix(desktop): preserve attached DM participants
Jul 13, 2026
542bd4e
fix(desktop): cancel stale first DM sends
Jul 13, 2026
503b5c0
fix(desktop): retain first DM agent membership
Jul 14, 2026
1ac937c
Merge origin/main into kennylopez-dm-new-message-page
klopez4212 Jul 14, 2026
01db095
fix(desktop): preserve refreshed DM recency
klopez4212 Jul 14, 2026
ce1983f
fix(desktop): route agent mentions to expanded DM
klopez4212 Jul 14, 2026
1d45a6a
fix(desktop): reset failed DM send target
klopez4212 Jul 14, 2026
ad616d1
fix(desktop): route existing DM agent mentions
klopez4212 Jul 14, 2026
00af758
fix(desktop): guard DM route after cache refresh
klopez4212 Jul 14, 2026
7f53dab
fix(desktop): block cross-DM thread agent mentions
klopez4212 Jul 14, 2026
9f8df7f
fix(desktop): order rapid PR status changes
klopez4212 Jul 14, 2026
c191714
fix(desktop): preserve DM mention routing
klopez4212 Jul 14, 2026
b5e922a
fix(desktop): defer DM thread agent mentions
klopez4212 Jul 14, 2026
ee71434
fix(desktop): discard stale prepared DMs
klopez4212 Jul 14, 2026
384a059
fix(desktop): route relay agents in DMs
klopez4212 Jul 14, 2026
8cc68da
fix(desktop): prevent stale DM reroutes
klopez4212 Jul 14, 2026
1a2b1c0
fix(desktop): cancel stale DM sends
klopez4212 Jul 14, 2026
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 @@ -45,6 +45,7 @@ export default defineConfig({
"**/edit-agent.spec.ts",
"**/doctor-cta-screenshots.spec.ts",
"**/pubkey-display-screenshots.spec.ts",
"**/dm-new-message-screenshots.spec.ts",
"**/file-attachment.spec.ts",
"**/image-attachment-gallery.spec.ts",
"**/composer-image-draw.spec.ts",
Expand Down
8 changes: 8 additions & 0 deletions desktop/src/app/AppShell.helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { SearchHit } from "@/shared/api/types";
export type AppView =
| "home"
| "channel"
| "messages"
| "agents"
| "workflows"
| "pulse"
Expand Down Expand Up @@ -117,6 +118,13 @@ export function deriveShellRoute(pathname: string): {
};
}

if (pathname === "/messages/new") {
return {
selectedChannelId: null,
selectedView: "messages",
};
}

if (pathname === "/agents") {
return {
selectedChannelId: null,
Expand Down
11 changes: 6 additions & 5 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,6 @@ export function AppShell() {
const [searchFocusRequest, setSearchFocusRequest] = React.useState(0);
const [browseDialogType, setBrowseDialogType] =
React.useState<BrowseDialogType>(null);
const [isNewDmOpen, setIsNewDmOpen] = React.useState(false);
const [isCreateChannelOpen, setIsCreateChannelOpen] = React.useState(false);
const [isHuddleDrawerOpen, setIsHuddleDrawerOpen] = React.useState(false);
const mainInsetRef = React.useRef<HTMLElement>(null);
Expand All @@ -119,6 +118,7 @@ export function AppShell() {
goAgents,
goChannel,
goHome,
goNewMessage,
goProjects,
goPulse,
goSettings,
Expand Down Expand Up @@ -547,7 +547,10 @@ export function AppShell() {
// Dispatch `buzz://message` deep links into the router.
useMessageDeepLinks();

const handleOpenNewDm = React.useCallback(() => setIsNewDmOpen(true), []);
const handleOpenNewDm = React.useCallback(
() => void goNewMessage(),
[goNewMessage],
);
const handleOpenCreateChannel = React.useCallback(
() => setIsCreateChannelOpen(true),
[],
Expand Down Expand Up @@ -754,16 +757,14 @@ export function AppShell() {
isCreatingChannel={createChannelMutation.isPending}
isCreatingForum={createForumMutation.isPending}
isLoading={channelsQuery.isLoading}
isOpeningDm={openDmMutation.isPending}
isNewDmOpen={isNewDmOpen}
isCreateChannelOpen={isCreateChannelOpen}
isPresencePending={presenceSession.isPending}
onAddWorkspace={(workspace) => {
const id = workspacesHook.addWorkspace(workspace);
handleSwitchWorkspace(id);
}}
onAddWorkspaceOpenChange={setIsAddWorkspaceOpen}
onNewDmOpenChange={setIsNewDmOpen}
onNewMessage={handleOpenNewDm}
onCreateChannelOpenChange={setIsCreateChannelOpen}
onOpenAddWorkspace={() => setIsAddWorkspaceOpen(true)}
onUpdateWorkspace={workspacesHook.updateWorkspace}
Expand Down
12 changes: 12 additions & 0 deletions desktop/src/app/navigation/useAppNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,17 @@ export function useAppNavigation() {
[commitNavigation],
);

const goNewMessage = React.useCallback(
(behavior?: NavigationBehavior) =>
commitNavigation(
{
to: "/messages/new",
},
behavior,
),
[commitNavigation],
);

const goForumPost = React.useCallback(
(
channelId: string,
Expand Down Expand Up @@ -284,6 +295,7 @@ export function useAppNavigation() {
goChannel,
goForumPost,
goHome,
goNewMessage,
goProject,
goProjects,
goPulse,
Expand Down
21 changes: 21 additions & 0 deletions desktop/src/app/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Route as agentsRouteImport } from "./routes/agents";
import { Route as indexRouteImport } from "./routes/index";
import { Route as workflowsDotworkflowIdRouteImport } from "./routes/workflows.$workflowId";
import { Route as projectsDotprojectIdRouteImport } from "./routes/projects.$projectId";
import { Route as messagesDotnewRouteImport } from "./routes/messages.new";
import { Route as channelsDotchannelIdRouteImport } from "./routes/channels.$channelId";
import { Route as channelsDotchannelIdDotpostsDotpostIdRouteImport } from "./routes/channels.$channelId.posts.$postId";

Expand Down Expand Up @@ -62,6 +63,11 @@ const projectsDotprojectIdRoute = projectsDotprojectIdRouteImport.update({
path: "/projects/$projectId",
getParentRoute: () => rootRouteImport,
} as any);
const messagesDotnewRoute = messagesDotnewRouteImport.update({
id: "/messages/new",
path: "/messages/new",
getParentRoute: () => rootRouteImport,
} as any);
const channelsDotchannelIdRoute = channelsDotchannelIdRouteImport.update({
id: "/channels/$channelId",
path: "/channels/$channelId",
Expand All @@ -83,6 +89,7 @@ export interface FileRoutesByFullPath {
"/settings": typeof settingsRoute;
"/workflows": typeof workflowsRoute;
"/channels/$channelId": typeof channelsDotchannelIdRoute;
"/messages/new": typeof messagesDotnewRoute;
"/projects/$projectId": typeof projectsDotprojectIdRoute;
"/workflows/$workflowId": typeof workflowsDotworkflowIdRoute;
"/channels/$channelId/posts/$postId": typeof channelsDotchannelIdDotpostsDotpostIdRoute;
Expand All @@ -96,6 +103,7 @@ export interface FileRoutesByTo {
"/settings": typeof settingsRoute;
"/workflows": typeof workflowsRoute;
"/channels/$channelId": typeof channelsDotchannelIdRoute;
"/messages/new": typeof messagesDotnewRoute;
"/projects/$projectId": typeof projectsDotprojectIdRoute;
"/workflows/$workflowId": typeof workflowsDotworkflowIdRoute;
"/channels/$channelId/posts/$postId": typeof channelsDotchannelIdDotpostsDotpostIdRoute;
Expand All @@ -110,6 +118,7 @@ export interface FileRoutesById {
"/settings": typeof settingsRoute;
"/workflows": typeof workflowsRoute;
"/channels/$channelId": typeof channelsDotchannelIdRoute;
"/messages/new": typeof messagesDotnewRoute;
"/projects/$projectId": typeof projectsDotprojectIdRoute;
"/workflows/$workflowId": typeof workflowsDotworkflowIdRoute;
"/channels/$channelId/posts/$postId": typeof channelsDotchannelIdDotpostsDotpostIdRoute;
Expand All @@ -125,6 +134,7 @@ export interface FileRouteTypes {
| "/settings"
| "/workflows"
| "/channels/$channelId"
| "/messages/new"
| "/projects/$projectId"
| "/workflows/$workflowId"
| "/channels/$channelId/posts/$postId";
Expand All @@ -138,6 +148,7 @@ export interface FileRouteTypes {
| "/settings"
| "/workflows"
| "/channels/$channelId"
| "/messages/new"
| "/projects/$projectId"
| "/workflows/$workflowId"
| "/channels/$channelId/posts/$postId";
Expand All @@ -151,6 +162,7 @@ export interface FileRouteTypes {
| "/settings"
| "/workflows"
| "/channels/$channelId"
| "/messages/new"
| "/projects/$projectId"
| "/workflows/$workflowId"
| "/channels/$channelId/posts/$postId";
Expand All @@ -165,6 +177,7 @@ export interface RootRouteChildren {
settingsRoute: typeof settingsRoute;
workflowsRoute: typeof workflowsRoute;
channelsDotchannelIdRoute: typeof channelsDotchannelIdRoute;
messagesDotnewRoute: typeof messagesDotnewRoute;
projectsDotprojectIdRoute: typeof projectsDotprojectIdRoute;
workflowsDotworkflowIdRoute: typeof workflowsDotworkflowIdRoute;
channelsDotchannelIdDotpostsDotpostIdRoute: typeof channelsDotchannelIdDotpostsDotpostIdRoute;
Expand Down Expand Up @@ -235,6 +248,13 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof projectsDotprojectIdRouteImport;
parentRoute: typeof rootRouteImport;
};
"/messages/new": {
id: "/messages/new";
path: "/messages/new";
fullPath: "/messages/new";
preLoaderRoute: typeof messagesDotnewRouteImport;
parentRoute: typeof rootRouteImport;
};
"/channels/$channelId": {
id: "/channels/$channelId";
path: "/channels/$channelId";
Expand All @@ -261,6 +281,7 @@ const rootRouteChildren: RootRouteChildren = {
settingsRoute: settingsRoute,
workflowsRoute: workflowsRoute,
channelsDotchannelIdRoute: channelsDotchannelIdRoute,
messagesDotnewRoute: messagesDotnewRoute,
projectsDotprojectIdRoute: projectsDotprojectIdRoute,
workflowsDotworkflowIdRoute: workflowsDotworkflowIdRoute,
channelsDotchannelIdDotpostsDotpostIdRoute:
Expand Down
1 change: 1 addition & 0 deletions desktop/src/app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const routes = rootRoute("root.tsx", [
route("/workflows/$workflowId", "workflows.$workflowId.tsx"),
route("/projects", "projects.tsx"),
route("/projects/$projectId", "projects.$projectId.tsx"),
route("/messages/new", "messages.new.tsx"),
route("/channels/$channelId", "channels.$channelId.tsx"),
route(
"/channels/$channelId/posts/$postId",
Expand Down
7 changes: 7 additions & 0 deletions desktop/src/app/routes/messages.new.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";

import { NewMessageScreen } from "@/features/messages/ui/NewMessageScreen";

export const Route = createFileRoute("/messages/new")({
component: NewMessageScreen,
});
79 changes: 74 additions & 5 deletions desktop/src/features/agents/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import {
createChannelManagedAgents,
ensureChannelAgentPresetInChannel,
} from "@/features/agents/channelAgents";
import { channelsQueryKey } from "@/features/channels/hooks";
import { resolveSnapshotAvatarPng } from "@/features/agents/ui/snapshotAvatarPng";
import {
channelsQueryKey,
upsertCachedChannelMember,
} from "@/features/channels/hooks";
import { evictUsersBatchEntries } from "@/features/profile/hooks";
import {
createManagedAgent,
Expand Down Expand Up @@ -58,6 +61,7 @@ import type {
AcpRuntime,
AgentPersona,
AgentTeam,
Channel,
CreateManagedAgentInput,
CreatePersonaInput,
CreateTeamInput,
Expand Down Expand Up @@ -95,14 +99,22 @@ export const managedAgentPrereqsQueryKey = ["managed-agent-prereqs"] as const;
export const backendProvidersQueryKey = ["backend-providers"] as const;
export const gitBashPrerequisiteQueryKey = ["git-bash-prerequisite"] as const;

type InvalidateAgentQueriesOptions = {
refetchChannels?: boolean;
};

async function invalidateAgentQueries(
queryClient: ReturnType<typeof useQueryClient>,
channelId: string | null,
options: InvalidateAgentQueriesOptions = {},
) {
await Promise.all([
queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }),
queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey }),
queryClient.invalidateQueries({ queryKey: channelsQueryKey }),
queryClient.invalidateQueries({
queryKey: channelsQueryKey,
refetchType: options.refetchChannels === false ? "none" : "active",
}),
...(channelId
? [
queryClient.invalidateQueries({
Expand All @@ -122,9 +134,27 @@ function refreshAgentQueriesInBackground(task: () => Promise<unknown>) {
function invalidateAgentQueriesInBackground(
queryClient: ReturnType<typeof useQueryClient>,
channelId: string | null,
options?: InvalidateAgentQueriesOptions,
) {
refreshAgentQueriesInBackground(() =>
invalidateAgentQueries(queryClient, channelId),
invalidateAgentQueries(queryClient, channelId, options),
);
}

function isCachedDmChannel(
queryClient: ReturnType<typeof useQueryClient>,
channelId: string | null,
) {
if (!channelId) {
return false;
}

return Boolean(
queryClient
.getQueryData<Channel[]>(channelsQueryKey)
?.some(
(channel) => channel.id === channelId && channel.channelType === "dm",
),
);
}

Expand Down Expand Up @@ -524,13 +554,33 @@ export function useAttachManagedAgentToChannelMutation(

return attachManagedAgentToChannel(effectiveChannelId, rest);
},
onSuccess: (result, variables) => {
const effectiveChannelId = variables.channelId ?? channelId;
if (!effectiveChannelId) {
return;
}

queryClient.setQueryData<Channel[]>(channelsQueryKey, (current) =>
upsertCachedChannelMember(current, effectiveChannelId, {
membershipAdded: result.membershipAdded,
Comment thread
klopez4212 marked this conversation as resolved.
name: result.agent.name,
pubkey: result.agent.pubkey,
}),
);
},
onSettled: (_data, _err, variables) => {
// Invalidate the effective channel (the one the server actually mutated)
// so its membership/agent state stays fresh. Invalidating the live
// hook-closure channelId when the user has already switched away would
// leave the compose-time channel stale.
const effectiveChannelId = variables?.channelId ?? channelId;
invalidateAgentQueriesInBackground(queryClient, effectiveChannelId);
// Stream membership is already applied to channelsQueryKey by onSuccess,
// so avoid replacing it with a lagged snapshot. DM membership is
// immutable: adding the agent creates a separate conversation, so refresh
// the list to discover that target instead of decorating the source DM.
invalidateAgentQueriesInBackground(queryClient, effectiveChannelId, {
refetchChannels: isCachedDmChannel(queryClient, effectiveChannelId),
});
},
});
}
Expand Down Expand Up @@ -578,9 +628,28 @@ export function useCreateChannelManagedAgentMutation(channelId: string | null) {
const failure = result.failures[0];
throw new Error(failure?.error ?? "Could not create agent.");
},
onSuccess: (result, variables) => {
const effectiveChannelId = variables.channelId ?? channelId;
if (!effectiveChannelId) {
return;
}

queryClient.setQueryData<Channel[]>(channelsQueryKey, (current) =>
upsertCachedChannelMember(current, effectiveChannelId, {
membershipAdded: result.membershipAdded,
name: result.agent.name,
pubkey: result.agent.pubkey,
}),
);
},
onSettled: (_data, _err, variables) => {
const effectiveChannelId = variables?.channelId ?? channelId;
invalidateAgentQueriesInBackground(queryClient, effectiveChannelId);
// Stream membership is already applied to channelsQueryKey by onSuccess,
// while immutable DM membership produces a separate conversation that
// must be discovered by refetching the channel list.
invalidateAgentQueriesInBackground(queryClient, effectiveChannelId, {
refetchChannels: isCachedDmChannel(queryClient, effectiveChannelId),
});
},
});
}
Expand Down
Loading
Loading