Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
e6d996a
feat(desktop): rethink composer agent shortcut
tellaho Aug 22, 2026
fc74e9e
feat(desktop): show addressed agents in composer
tellaho Aug 22, 2026
2ad9c2b
fix(desktop): render addressed agents as inline mentions
tellaho Aug 22, 2026
7fc4f0c
feat(desktop): favor recently mentioned agents
tellaho Aug 22, 2026
69631cc
fix(desktop): preserve addressed mention during send
tellaho Aug 23, 2026
c182673
fix(desktop): dedupe addressed mention display
tellaho Aug 23, 2026
fb74ecc
fix(desktop): show chip when pinning agent
tellaho Aug 23, 2026
6562b0a
fix(desktop): select mentioned agents immediately
tellaho Aug 23, 2026
bd46867
fix(desktop): clear address with final agent mention
tellaho Aug 23, 2026
1e6f9b0
polish(desktop): smooth mention avatar exit
tellaho Aug 23, 2026
12b1a2b
polish(desktop): refine mention avatar size
tellaho Aug 24, 2026
694938a
fix(desktop): clear restored agent address with chip
tellaho Aug 24, 2026
78f0001
refactor(desktop): keep composer modules within size limits
tellaho Aug 24, 2026
652e62e
fix(desktop): preserve draft text when unpinning agents
tellaho Aug 24, 2026
c1b68e9
fix(desktop): honor reduced motion for agent addresses
tellaho Aug 24, 2026
a824c3f
fix(desktop): preserve addressed draft state
tellaho Aug 24, 2026
f3023f3
fix(composer): preserve explicit reply target mentions
tellaho Aug 24, 2026
915aacd
fix(composer): ignore DM recipient fan-out for recency
tellaho Aug 24, 2026
58f13d8
test(desktop): align agent mention e2e expectations
tellaho Aug 24, 2026
8854903
Show agent auto-mention confirmation on selection
tellaho Aug 25, 2026
d08dda4
fix(composer): lock queued attachment sends until settled
tellaho Aug 25, 2026
a358527
refactor(desktop): keep mention recency outside channel pane
tellaho Aug 25, 2026
5d0e2d4
test(desktop): prove queued send lock lifetime
tellaho Aug 25, 2026
883edc1
test(desktop): expose queued send lock lifetime
tellaho Aug 25, 2026
bccdaf4
fix(desktop): keep message row within size limit
tellaho Aug 25, 2026
5026735
test(composer): guard send lock through settlement
tellaho Aug 25, 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
20 changes: 10 additions & 10 deletions desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,6 @@ export const ChannelPane = React.memo(function ChannelPane({
}),
[activeChannel, currentPubkey, profiles],
);

const handleWelcomeAddAgent = React.useCallback(() => {
onAddAgent?.({
beforeSend: () =>
Expand All @@ -387,13 +386,14 @@ export const ChannelPane = React.memo(function ChannelPane({
onWelcomeAddAgent: onAddAgent ? handleWelcomeAddAgent : undefined,
});
const channelIntro = isHuddleTranscript ? null : standardChannelIntro;
const { mainTimelineEntries, visibleMessages } = useChannelPaneMessages({
activeChannel,
isHuddleTranscript,
messages,
profiles,
threadSummaries,
});
const { mainTimelineEntries, recentMentions, visibleMessages } =
useChannelPaneMessages({
activeChannel,
isHuddleTranscript,
messages,
profiles,
threadSummaries,
});
useRenderScopedReactionHydration({
activeChannel,
mainTimelineEntries,
Expand Down Expand Up @@ -780,7 +780,7 @@ export const ChannelPane = React.memo(function ChannelPane({
: undefined
}
onSend={handleSendMessage}
profiles={profiles}
{...{ profiles, recentMentionPubkeys: recentMentions }}
showBackgroundUploadProgress={false}
placeholder={
timeoutState.active
Expand Down Expand Up @@ -889,7 +889,7 @@ export const ChannelPane = React.memo(function ChannelPane({
onScrollTargetSettled={resolveScrollTarget}
onToggleReaction={onToggleReaction}
onUnfollowThread={onUnfollowThread}
profiles={profiles}
{...{ profiles, recentMentionPubkeys: recentMentions }}
replyTargetMessage={threadReplyTargetMessage}
scrollTargetHighlights={!layoutScrollTargetId}
scrollTargetId={layoutScrollTargetId ?? threadScrollTargetId}
Expand Down
12 changes: 11 additions & 1 deletion desktop/src/features/channels/ui/useChannelPaneMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
} from "@/features/channels/ui/ChannelPane.helpers";
import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types";
import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel";
import { getRecentMentionPubkeys } from "@/features/messages/lib/recentMentionPubkeys";
import { isWelcomeExperienceChannel } from "@/features/onboarding/welcome";

type ChannelPaneMessagesOptions = Pick<
Expand Down Expand Up @@ -46,5 +47,14 @@ export function useChannelPaneMessages({
[isHuddleTranscript, profiles, threadSummaries, visibleMessages],
);

return { mainTimelineEntries, visibleMessages };
const recentMentionPubkeys = React.useMemo(
() => getRecentMentionPubkeys(messages, activeChannel?.channelType),
[activeChannel?.channelType, messages],
);

return {
mainTimelineEntries,
recentMentions: recentMentionPubkeys,
visibleMessages,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import test from "node:test";

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

const DARIA = "a".repeat(64);
const RIZZ = "b".repeat(64);

test("hides an address prefix already represented by an inline mention", () => {
assert.deepEqual(
getVisibleAgentAddressPubkeys("@Daria please review this", [DARIA], {
daria: DARIA,
}),
[],
);
});

test("keeps a tag-backed prefix when its inline mention was deleted", () => {
assert.deepEqual(
getVisibleAgentAddressPubkeys("please review this", [DARIA], {
daria: DARIA,
}),
[DARIA],
);
});

test("filters only addressed agents that are present inline", () => {
assert.deepEqual(
getVisibleAgentAddressPubkeys(
"@Daria please pair with someone",
[DARIA, RIZZ],
{
daria: DARIA,
rizz: RIZZ,
},
),
[RIZZ],
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { orderMentionPubkeysByText } from "@/features/messages/lib/orderMentionPubkeys";
import { normalizePubkey } from "@/shared/lib/pubkey";

/**
* Keep tag-backed address recipients visible without repeating ordinary inline
* mentions already present in the message body.
*/
export function getVisibleAgentAddressPubkeys(
body: string,
addressedPubkeys: readonly string[],
mentionPubkeysByName: Readonly<Record<string, string>> | undefined,
): string[] {
const inlineMentionPubkeys = new Set(
orderMentionPubkeysByText(body, mentionPubkeysByName, () => true),
);

return addressedPubkeys.filter(
(pubkey) => !inlineMentionPubkeys.has(normalizePubkey(pubkey)),
);
}
1 change: 1 addition & 0 deletions desktop/src/features/messages/lib/mentionCandidates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export type MentionCandidate = {
secondaryLabel?: string | null;
ownerPubkey?: string | null;
isAgent: boolean;
isActiveAgent?: boolean;
isManagedAgent?: boolean;
isGlobalSearchResult?: boolean;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ export function settleAutocompleteMentionInsert(
editor: { storage: object },
tr: Transaction,
text: string,
settleCaret = true,
): void {
const storage = mentionHighlightStorage(editor);
const mentionInsert = /(?:^|[\s(])([@#])([^\s]+) $/.exec(text);
Expand All @@ -222,7 +223,7 @@ export function settleAutocompleteMentionInsert(
}
}
}
tr.setMeta(mentionHighlightKey, true);
if (settleCaret) tr.setMeta(mentionHighlightKey, true);
}

export function syncMentionHighlightFromProps(
Expand Down
110 changes: 109 additions & 1 deletion desktop/src/features/messages/lib/mentionRanking.test.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";

import { rankMentionCandidates } from "./mentionRanking.ts";
import {
pickDefaultAgentCandidate,
rankMentionCandidates,
} from "./mentionRanking.ts";

const CHANNEL_BRAIN_PUBKEY = "1".repeat(64);
const OTHER_BRAIN_PUBKEY = "2".repeat(64);
Expand Down Expand Up @@ -139,3 +142,108 @@ test("rankMentionCandidates: owned teams rank with runnable personas", () => {
["team", "identity"],
);
});

test("pickDefaultAgentCandidate: active agents outrank stopped channel members", () => {
const stoppedMember = candidate({
displayName: "Ada",
isActiveAgent: false,
isAgent: true,
isMember: true,
pubkey: CHANNEL_BRAIN_PUBKEY,
});
const runningNonMember = candidate({
displayName: "Bea",
isActiveAgent: true,
isAgent: true,
pubkey: OTHER_BRAIN_PUBKEY,
});

assert.equal(
pickDefaultAgentCandidate([stoppedMember, runningNonMember]),
runningNonMember,
);
});

test("pickDefaultAgentCandidate: stable labels break ties instead of roster order", () => {
const vogue = candidate({
displayName: "Vogue",
isActiveAgent: true,
isAgent: true,
isMember: true,
pubkey: OTHER_BRAIN_PUBKEY,
});
const morgarita = candidate({
displayName: "Morgarita",
isActiveAgent: true,
isAgent: true,
isMember: true,
pubkey: CHANNEL_BRAIN_PUBKEY,
});

assert.equal(pickDefaultAgentCandidate([vogue, morgarita]), morgarita);
assert.equal(pickDefaultAgentCandidate([morgarita, vogue]), morgarita);
});

test("pickDefaultAgentCandidate: runnable personas break otherwise equal ties", () => {
const plain = candidate({
displayName: "Zulu",
isActiveAgent: true,
isAgent: true,
pubkey: OTHER_BRAIN_PUBKEY,
});
const runnable = candidate({
displayName: "Zulu 2",
isActiveAgent: true,
isAgent: true,
personaId: "active-persona",
pubkey: CHANNEL_BRAIN_PUBKEY,
});

assert.equal(
pickDefaultAgentCandidate([plain, runnable], new Set(["active-persona"])),
runnable,
);
});

test("pickDefaultAgentCandidate: recent eligible mentions outrank the fallback ranking", () => {
const stoppedRecentMember = candidate({
displayName: "Ada",
isActiveAgent: false,
isAgent: true,
isMember: true,
pubkey: CHANNEL_BRAIN_PUBKEY,
});
const runningNonMember = candidate({
displayName: "Bea",
isActiveAgent: true,
isAgent: true,
pubkey: OTHER_BRAIN_PUBKEY,
});

assert.equal(
pickDefaultAgentCandidate(
[runningNonMember, stoppedRecentMember],
new Set(),
[CHANNEL_BRAIN_PUBKEY],
),
stoppedRecentMember,
);
});

test("pickDefaultAgentCandidate: skips recent pubkeys that are not eligible candidates", () => {
const runningAgent = candidate({
isActiveAgent: true,
isAgent: true,
pubkey: OTHER_BRAIN_PUBKEY,
});

assert.equal(
pickDefaultAgentCandidate([runningAgent], new Set(), ["f".repeat(64)]),
runningAgent,
);
});

test("pickDefaultAgentCandidate: returns null without an addressable agent", () => {
assert.equal(pickDefaultAgentCandidate([]), null);
assert.equal(pickDefaultAgentCandidate([candidate()]), null);
});
53 changes: 53 additions & 0 deletions desktop/src/features/messages/lib/mentionRanking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
export type MentionCandidateForRanking = {
displayName: string | null;
isAgent: boolean;
isActiveAgent?: boolean;
isMember: boolean;
kind: "identity" | "persona" | "team";
personaId?: string | null;
Expand Down Expand Up @@ -51,6 +52,58 @@ function scoreMentionCandidateLabel(
return null;
}

export function pickDefaultAgentCandidate<T extends MentionCandidateForRanking>(
candidates: readonly T[],
activePersonaIds: ReadonlySet<string> = new Set(),
recentMentionPubkeys: readonly string[] = [],
): T | null {
const recentMentionRankByPubkey = new Map(
recentMentionPubkeys.map((pubkey, index) => [
normalizePubkey(pubkey),
index,
]),
);
return (
candidates
.filter((candidate) => candidate.isAgent && Boolean(candidate.pubkey))
.sort((left, right) => {
const leftRecentRank = left.pubkey
? recentMentionRankByPubkey.get(normalizePubkey(left.pubkey))
: undefined;
const rightRecentRank = right.pubkey
? recentMentionRankByPubkey.get(normalizePubkey(right.pubkey))
: undefined;
const recentDiff =
(leftRecentRank ?? recentMentionPubkeys.length) -
(rightRecentRank ?? recentMentionPubkeys.length);
if (recentDiff !== 0) return recentDiff;
const activeDiff =
Number(right.isActiveAgent === true) -
Number(left.isActiveAgent === true);
if (activeDiff !== 0) return activeDiff;
const memberDiff = Number(right.isMember) - Number(left.isMember);
if (memberDiff !== 0) return memberDiff;
const runnableDiff =
Number(
Boolean(right.personaId) &&
activePersonaIds.has(right.personaId ?? ""),
) -
Number(
Boolean(left.personaId) &&
activePersonaIds.has(left.personaId ?? ""),
);
if (runnableDiff !== 0) return runnableDiff;
const labelDiff = (left.displayName ?? "").localeCompare(
right.displayName ?? "",
undefined,
{ sensitivity: "base" },
);
if (labelDiff !== 0) return labelDiff;
return (left.pubkey ?? "").localeCompare(right.pubkey ?? "");
})[0] ?? null
);
}

export function rankMentionCandidates<T extends MentionCandidateForRanking>(
candidates: readonly T[],
query: string,
Expand Down
27 changes: 26 additions & 1 deletion desktop/src/features/messages/lib/mentionSuggestionMapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { formatOwnerLabel } from "@/features/profile/lib/identity";
import type { ChannelRole, ChannelType } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
import type { TeamMentionMember } from "./mentionCandidates";
import type { MentionCandidate, TeamMentionMember } from "./mentionCandidates";
import { mentionCandidateLabel } from "./mentionCandidates";
import { pickDefaultAgentCandidate } from "./mentionRanking";

export type MentionSuggestionCandidate = {
kind: "identity" | "persona" | "team";
Expand Down Expand Up @@ -74,3 +76,26 @@ export function mapMentionCandidateToSuggestion(opts: {
role: !candidate.isAgent && candidate.role === "admin" ? "admin" : null,
};
}

export function pickDefaultAgentSuggestion(opts: {
activePersonaIds: ReadonlySet<string>;
agentProvenanceReady: boolean;
candidates: readonly MentionCandidate[];
channelType?: ChannelType | null;
currentPubkey?: string | null;
ownerProfiles?: UserProfileLookup;
profiles?: UserProfileLookup;
recentMentionPubkeys?: readonly string[];
}): MentionSuggestion | null {
const candidate = pickDefaultAgentCandidate(
opts.candidates,
opts.activePersonaIds,
opts.recentMentionPubkeys,
);
if (!candidate) return null;
return mapMentionCandidateToSuggestion({
...opts,
candidate,
label: mentionCandidateLabel(candidate),
});
}
Loading
Loading