diff --git a/.lane/memory/apps/app/src/timeline.ts/01M4YQXGSB8HYNWEWT8HE54FVA-resolve-agent-prefix-only-am.md b/.lane/memory/apps/app/src/timeline.ts/01M4YQXGSB8HYNWEWT8HE54FVA-resolve-agent-prefix-only-am.md new file mode 100644 index 00000000..de5adee6 --- /dev/null +++ b/.lane/memory/apps/app/src/timeline.ts/01M4YQXGSB8HYNWEWT8HE54FVA-resolve-agent-prefix-only-am.md @@ -0,0 +1,12 @@ +--- +id: 01M4YQXGSB8HYNWEWT8HE54FVA +anchor: function sources +created: 2026-10-15T02:58:00Z +norm: '1' +sig: 0ad9c2d0c83208a4 +body_hash: caee764965f73057 +raw_hash: cacc75e92aa998c6 +lines: 13-19 +--- + +resolve agent@ only among channels on the viewed channel's host; prefixes are unique per host, not across hosts diff --git a/.lane/memory/apps/host/src/worker.ts/01M4YQXGS3RQSHX8N6GH99R7D4-the-directory-s-self-name-st.md b/.lane/memory/apps/host/src/worker.ts/01M4YQXGS3RQSHX8N6GH99R7D4-the-directory-s-self-name-st.md new file mode 100644 index 00000000..677a36f7 --- /dev/null +++ b/.lane/memory/apps/host/src/worker.ts/01M4YQXGS3RQSHX8N6GH99R7D4-the-directory-s-self-name-st.md @@ -0,0 +1,12 @@ +--- +id: 01M4YQXGS3RQSHX8N6GH99R7D4 +anchor: '@file' +created: 2026-10-15T02:58:00Z +norm: '1' +sig: 1a43097a4e1ad24c +body_hash: d306f7381f78d80e +raw_hash: ddf38f30d6808a36 +lines: 1-194 +--- + +the directory's self.name stays the channel's prefix, which the app resolves agent@ authors by diff --git a/apps/app/src/app.tsx b/apps/app/src/app.tsx index 73f41998..2560764d 100644 --- a/apps/app/src/app.tsx +++ b/apps/app/src/app.tsx @@ -654,11 +654,13 @@ export function App() { setDraft(undefined)} onSettings={() => setSettings(true)} + onOpen={select} /> ) : ( diff --git a/apps/app/src/channel.tsx b/apps/app/src/channel.tsx index 8a71ecea..15e99afe 100644 --- a/apps/app/src/channel.tsx +++ b/apps/app/src/channel.tsx @@ -28,11 +28,13 @@ export type ChannelDraft = { channel: string; text: string }; type Props = { channel: Listing; + channels: Listing[]; user: string; remote: boolean; draft?: ChannelDraft; onDraftLoaded: () => void; onSettings?: () => void; + onOpen: (channel: Listing) => void; }; const LINK = /https?:\/\/[^\s<>()[\]{}"'`]+/g; @@ -197,7 +199,9 @@ export function ChannelDetails({ channel }: { channel: Listing }) { } /** A channel's tabs: its chat, plus Diff and Terminal views of that chat's lane. */ -export function Channel({ channel, user, remote, draft, onDraftLoaded, onSettings }: Props) { +export function Channel( + { channel, channels, user, remote, draft, onDraftLoaded, onSettings, onOpen }: Props, +) { const [changed, setChanged] = useState(); const check = useRef({ busy: false, again: false }); @@ -242,11 +246,13 @@ export function Channel({ channel, user, remote, draft, onDraftLoaded, onSetting return ( ); diff --git a/apps/app/src/conversation.tsx b/apps/app/src/conversation.tsx index a9edffee..fe201364 100644 --- a/apps/app/src/conversation.tsx +++ b/apps/app/src/conversation.tsx @@ -7,24 +7,27 @@ import { Composer } from "./composer"; import { publish } from "./details"; import { host } from "./host"; import { MessageQueue } from "./message-queue"; -import { toEvents } from "./timeline"; +import { sources, toEvents } from "./timeline"; import { useTranscript } from "./transcript"; import { Usage } from "./usage"; type Props = { channel: Listing; + /** Every listed channel, to name and open the channels that message this one. */ + channels: Listing[]; chat: number; user: string; draft?: string; onDraftLoaded: () => void; onSettings?: () => void; + onOpen: (channel: Listing) => void; /** Called once the chat loads and whenever its agent may have changed files since. */ onWork?: () => void; }; /** One chat's timeline and composer over its transcript; the content of a Chat tab. */ export function Conversation( - { channel, chat: id, user, draft, onDraftLoaded, onSettings, onWork }: Props, + { channel, channels, chat: id, user, draft, onDraftLoaded, onSettings, onOpen, onWork }: Props, ) { const transcript = useTranscript( channel.id, @@ -35,6 +38,7 @@ export function Conversation( const chat = transcript.info?.chats.find((value) => value.id === id); const model = chat?.model || channel.model; const people = useSyncExternalStore(host.subscribe, () => host.people); + const senders = useMemo(() => sources(channels, channel.host), [channels, channel.host]); const events = useMemo( () => toEvents( @@ -46,6 +50,7 @@ export function Conversation( model: model?.modelId, }, people, + senders, transcript.captions, transcript.recaps, ), @@ -56,6 +61,7 @@ export function Conversation( transcript.draft, model, people, + senders, transcript.captions, transcript.recaps, ], @@ -111,6 +117,10 @@ export function Conversation( working={transcript.busy || undefined} currentUser={{ login: user }} avatars={avatars} + openSender={(sender) => { + const source = senders.get(sender); + return source && (() => onOpen(source)); + }} intro={{ name: channel.name, createdAt: Math.floor(channel.created / 1000) }} /> ) @@ -128,7 +138,9 @@ export function Conversation( )}
- {transcript.live && } + {transcript.live && ( + + )} }, -) { +import { authorName } from "./timeline"; + +/** Authors are named as the timeline names them. */ +export function MessageQueue({ messages, people, channels }: { + messages: QueuedMessage[]; + people: Record; + channels: Map; +}) { if (!messages.length) return null; return (
- {people[message.author] || message.author.split("@")[0]} + {authorName(message.author, people, channels)} {message.invoked ? "@ace" : "Chat"}
diff --git a/apps/app/src/timeline.ts b/apps/app/src/timeline.ts index 4430f31a..41727d9d 100644 --- a/apps/app/src/timeline.ts +++ b/apps/app/src/timeline.ts @@ -1,4 +1,4 @@ -import type { People } from "@ace/host/protocol"; +import type { Listing, People } from "@ace/host/protocol"; import type { Event } from "@ace/ui"; import type { Item, Tool } from "./transcript"; @@ -6,6 +6,33 @@ import type { Item, Tool } from "./transcript"; const AGENT = "ace:agent"; const MENTION = /(?<=^|\s)@([a-zA-Z0-9_-]+)/g; +/** + * Channels on the viewed channel's host keyed by the author their agent's messages carry. The prefix + * outlives renames, so messages sent before one still find their channel. + */ +export function sources(channels: Listing[], host: string): Map { + return new Map( + channels.flatMap((channel) => + channel.host === host && channel.prefix ? [[`agent@${channel.prefix}`, channel]] : [] + ), + ); +} + +/** + * Human authors are Tailscale logins; without a known GitHub login, the name before the domain + * reads as a handle. Agent authors name the chat or channel after the `@`, so they stay whole. + */ +export function authorName( + author: string, + people: People, + channels: Map, +): string { + const source = channels.get(author); + if (source) return `#${source.name}`; + if (author.startsWith("agent@")) return author; + return people[author] || author.split("@")[0]!; +} + function text(value: string): Event.Message.Content.Text { const parts = [...value.matchAll(MENTION)].map((match) => ({ type: "mention" as const, @@ -57,6 +84,7 @@ export function toEvents( busy: boolean, draft: { text: string; model?: string }, people: People, + channels: Map, captions: Record, recaps: Record, ): Event[] { @@ -71,12 +99,10 @@ export function toEvents( if (item.kind === "message") { return { ...base, - // Authors are Tailscale logins; without a known GitHub login, the name before the domain - // reads as a handle. sender: { kind: "user", value: item.author, - display: people[item.author] || item.author.split("@")[0]!, + display: authorName(item.author, people, channels), }, content: [ text(item.invoked && !/(^|\s)@ace\b/i.test(item.text) ? `@ace ${item.text}` : item.text), diff --git a/apps/host/src/gateway.ts b/apps/host/src/gateway.ts index 680df32b..a51b256b 100644 --- a/apps/host/src/gateway.ts +++ b/apps/host/src/gateway.ts @@ -71,6 +71,7 @@ function local(): Listing[] { const { id, name: channel, + prefix, summary, revision, owner, @@ -85,6 +86,7 @@ function local(): Listing[] { id, host: name, name: channel, + prefix, summary, revision, owner, diff --git a/apps/host/src/protocol.ts b/apps/host/src/protocol.ts index 951eee8a..5b9936e8 100644 --- a/apps/host/src/protocol.ts +++ b/apps/host/src/protocol.ts @@ -44,6 +44,11 @@ export type Listing = { /** The machine running the channel, by its tailnet name. */ host: string; name: string; + /** + * The channel's original name, which stays unique on its host across renames. Messages its agent + * sends other channels are authored `agent@`. Older hosts omit it. + */ + prefix?: string; summary?: string; /** Revision of the channel's projected name and summary. */ revision?: number; diff --git a/packages/ui/src/components/timeline/timeline.tsx b/packages/ui/src/components/timeline/timeline.tsx index de879175..1212b4ea 100644 --- a/packages/ui/src/components/timeline/timeline.tsx +++ b/packages/ui/src/components/timeline/timeline.tsx @@ -612,6 +612,7 @@ type GroupProps = { onRowLeave?: () => void; onReact?: (uid: string, emoji: string) => void; onLink?: (href: string) => void; + openSender?: TimelineProps["openSender"]; onFileOpen?: (file: string) => void; onDiffFileOpen?: (file: string) => void; onReviewChanges?: () => void; @@ -621,8 +622,18 @@ type GroupProps = { }; function frame( - { group, turn, width, currentUser, alignment, avatars, hover, openWorking, settlingWorking }: - GroupProps, + { + group, + turn, + width, + currentUser, + alignment, + avatars, + hover, + openWorking, + settlingWorking, + openSender, + }: GroupProps, ) { let content = width - PAD * 2 - AVATAR - AVATAR_GAP; let text = content - BUBBLE_PAD_X * 2; @@ -633,6 +644,9 @@ function frame( ? group.display || String(group.sender || "User") : group.display || (group.run ? "Ace" : "ace"); let name = label; + let open = group.role === "user" && group.sender != null + ? openSender?.(String(group.sender)) + : undefined; let hasByline = group.rows.length > 0; let own = !!currentUser && group.role === "user" && (currentUser.own @@ -667,6 +681,7 @@ function frame( workingSettling, label, name, + open, hasByline, own, mine, @@ -784,7 +799,7 @@ function GroupAvatar({ props, state }: GroupRender) { } function GroupByline({ state }: { state: GroupState }) { - let { hasByline, mine, name, agent, tone, title, date, edit, editTitle } = state; + let { hasByline, mine, name, open, agent, tone, title, date, edit, editTitle } = state; return ( <> {hasByline && ( @@ -800,9 +815,22 @@ function GroupByline({ state }: { state: GroupState }) { paddingInlineEnd: mine ? 4 : 0, }} > - - {name} - + {open + ? ( + + ) + : ( + + {name} + + )} {agent && ( void; + /** How to open a sender whose name links somewhere, such as the channel that sent it. */ + openSender?: (sender: string) => (() => void) | undefined; /** Called when the user clicks a resolved document reference. */ onDocumentOpen?: (uid: DocumentCandidate["uid"]) => void; /** Called when the user clicks the reserved Plan reference. */ @@ -1117,6 +1147,7 @@ function useTimeline( onReact, target, onLink, + openSender, onDocumentOpen, onPlanOpen, onFileOpen, @@ -1482,6 +1513,7 @@ function useTimeline( onRowLeave, onReact, onLink, + openSender, onFileOpen, onDiffFileOpen, onReviewChanges, @@ -1530,6 +1562,7 @@ function Timeline(input: TimelineProps) { onRowLeave, onReact, onLink, + openSender, onFileOpen, onDiffFileOpen, onReviewChanges, @@ -1631,6 +1664,7 @@ function Timeline(input: TimelineProps) { onRowLeave={onRowLeave} onReact={onReact} onLink={onLink} + openSender={openSender} onFileOpen={onFileOpen} onDiffFileOpen={onDiffFileOpen} onReviewChanges={onReviewChanges}