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
Original file line number Diff line number Diff line change
@@ -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@<prefix> only among channels on the viewed channel's host; prefixes are unique per host, not across hosts
Original file line number Diff line number Diff line change
@@ -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@<prefix> authors by
2 changes: 2 additions & 0 deletions apps/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -654,11 +654,13 @@ export function App() {
<Channel
key={channel.id}
channel={channel}
channels={channels}
user={hello.user}
remote={channel.host !== hello.host}
draft={draft}
onDraftLoaded={() => setDraft(undefined)}
onSettings={() => setSettings(true)}
onOpen={select}
/>
)
: (
Expand Down
8 changes: 7 additions & 1 deletion apps/app/src/channel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<boolean>();
const check = useRef({ busy: false, again: false });

Expand Down Expand Up @@ -242,11 +246,13 @@ export function Channel({ channel, user, remote, draft, onDraftLoaded, onSetting
return (
<Conversation
channel={channel}
channels={channels}
chat={chat}
user={user}
draft={draft?.channel === channel.id ? draft.text : undefined}
onDraftLoaded={onDraftLoaded}
onSettings={!remote && !channel.hosted ? onSettings : undefined}
onOpen={onOpen}
onWork={inspect}
/>
);
Expand Down
18 changes: 15 additions & 3 deletions apps/app/src/conversation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -46,6 +50,7 @@ export function Conversation(
model: model?.modelId,
},
people,
senders,
transcript.captions,
transcript.recaps,
),
Expand All @@ -56,6 +61,7 @@ export function Conversation(
transcript.draft,
model,
people,
senders,
transcript.captions,
transcript.recaps,
],
Expand Down Expand Up @@ -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) }}
/>
)
Expand All @@ -128,7 +138,9 @@ export function Conversation(
)}
</div>
<div className="utils:max-width relative z-20 shrink-0 px-3 pb-3">
{transcript.live && <MessageQueue messages={transcript.queue} people={people} />}
{transcript.live && (
<MessageQueue messages={transcript.queue} people={people} channels={senders} />
)}
<Composer
channel={channel}
chat={id}
Expand Down
15 changes: 10 additions & 5 deletions apps/app/src/message-queue.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import type { QueuedMessage } from "@ace/channel/protocol";
import type { Listing } from "@ace/host/protocol";

/** Authors are Tailscale logins, named as the timeline names them. */
export function MessageQueue(
{ messages, people }: { messages: QueuedMessage[]; people: Record<string, string> },
) {
import { authorName } from "./timeline";

/** Authors are named as the timeline names them. */
export function MessageQueue({ messages, people, channels }: {
messages: QueuedMessage[];
people: Record<string, string>;
channels: Map<string, Listing>;
}) {
if (!messages.length) return null;
return (
<section
Expand All @@ -21,7 +26,7 @@ export function MessageQueue(
<li key={message.submission} className="px-3 py-2">
<div className="mb-1 flex min-w-0 items-center gap-2 text-muted-foreground">
<span className="truncate font-medium">
{people[message.author] || message.author.split("@")[0]}
{authorName(message.author, people, channels)}
</span>
<span className="shrink-0">{message.invoked ? "@ace" : "Chat"}</span>
</div>
Expand Down
34 changes: 30 additions & 4 deletions apps/app/src/timeline.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,38 @@
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";

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<string, Listing> {
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, Listing>,
): 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,
Expand Down Expand Up @@ -57,6 +84,7 @@ export function toEvents(
busy: boolean,
draft: { text: string; model?: string },
people: People,
channels: Map<string, Listing>,
captions: Record<string, string>,
recaps: Record<string, string>,
): Event[] {
Expand All @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions apps/host/src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ function local(): Listing[] {
const {
id,
name: channel,
prefix,
summary,
revision,
owner,
Expand All @@ -85,6 +86,7 @@ function local(): Listing[] {
id,
host: name,
name: channel,
prefix,
summary,
revision,
owner,
Expand Down
5 changes: 5 additions & 0 deletions apps/host/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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@<prefix>`. Older hosts omit it.
*/
prefix?: string;
summary?: string;
/** Revision of the channel's projected name and summary. */
revision?: number;
Expand Down
46 changes: 40 additions & 6 deletions packages/ui/src/components/timeline/timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -667,6 +681,7 @@ function frame(
workingSettling,
label,
name,
open,
hasByline,
own,
mine,
Expand Down Expand Up @@ -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 && (
Expand All @@ -800,9 +815,22 @@ function GroupByline({ state }: { state: GroupState }) {
paddingInlineEnd: mine ? 4 : 0,
}}
>
<span className="min-w-0 truncate font-medium text-foreground" title={name}>
{name}
</span>
{open
? (
<button
type="button"
className="min-w-0 truncate font-medium text-foreground hover:underline"
title={name}
onClick={open}
>
{name}
</button>
)
: (
<span className="min-w-0 truncate font-medium text-foreground" title={name}>
{name}
</span>
)}
{agent && (
<span
className="min-w-0 truncate text-[0.625rem] font-medium"
Expand Down Expand Up @@ -1067,6 +1095,8 @@ type TimelineProps =
target?: string;
/** Called when the user clicks a rendered message link. */
onLink?: (href: string) => 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. */
Expand Down Expand Up @@ -1117,6 +1147,7 @@ function useTimeline(
onReact,
target,
onLink,
openSender,
onDocumentOpen,
onPlanOpen,
onFileOpen,
Expand Down Expand Up @@ -1482,6 +1513,7 @@ function useTimeline(
onRowLeave,
onReact,
onLink,
openSender,
onFileOpen,
onDiffFileOpen,
onReviewChanges,
Expand Down Expand Up @@ -1530,6 +1562,7 @@ function Timeline(input: TimelineProps) {
onRowLeave,
onReact,
onLink,
openSender,
onFileOpen,
onDiffFileOpen,
onReviewChanges,
Expand Down Expand Up @@ -1631,6 +1664,7 @@ function Timeline(input: TimelineProps) {
onRowLeave={onRowLeave}
onReact={onReact}
onLink={onLink}
openSender={openSender}
onFileOpen={onFileOpen}
onDiffFileOpen={onDiffFileOpen}
onReviewChanges={onReviewChanges}
Expand Down