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
Expand Up @@ -4,9 +4,9 @@ anchor: function useTranscript
created: 2026-10-15T02:55:20Z
norm: '1'
sig: 3961cde46070e655
body_hash: 6b86dda9a0611495
raw_hash: 510d0884aeb13f05
vouched: 2026-10-15T04:57:12Z
body_hash: cd0b6429068face3
raw_hash: 9d8f485b8e977dab
vouched: 2026-10-16T22:08:16Z
lines: 174-275
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ anchor: function useTranscript
created: 2026-10-15T04:57:01Z
norm: '1'
sig: 3961cde46070e655
body_hash: 6b86dda9a0611495
raw_hash: 510d0884aeb13f05
body_hash: cd0b6429068face3
raw_hash: 9d8f485b8e977dab
vouched: 2026-10-16T22:08:16Z
lines: 197-314
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: '@file'
created: 2026-10-15T02:58:00Z
norm: '1'
sig: 1a43097a4e1ad24c
body_hash: 0b3f3d86f114b123
raw_hash: 7ba394a2ba826e97
vouched: 2026-10-15T03:39:53Z
body_hash: 37c95d7886dedddc
raw_hash: 257b1619f353fd2f
vouched: 2026-10-16T22:08:16Z
lines: 1-194
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: class Channel
created: 2026-10-14T17:33:22Z
norm: '1'
sig: c905677fa4be33c1
body_hash: 26c17609650cbf0d
raw_hash: 296267bd2e8471ba
vouched: 2026-10-15T17:31:19Z
body_hash: 96ca003ab550dffe
raw_hash: 6e915715584ff7fa
vouched: 2026-10-16T22:08:16Z
lines: 114-792
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: class Channel
created: 2026-10-15T00:30:42Z
norm: '1'
sig: c905677fa4be33c1
body_hash: 26c17609650cbf0d
raw_hash: 296267bd2e8471ba
vouched: 2026-10-15T17:31:19Z
body_hash: 96ca003ab550dffe
raw_hash: 6e915715584ff7fa
vouched: 2026-10-16T22:08:16Z
lines: 114-792
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: class Channel
created: 2026-10-15T02:34:37Z
norm: '1'
sig: c905677fa4be33c1
body_hash: 26c17609650cbf0d
raw_hash: 296267bd2e8471ba
vouched: 2026-10-15T17:31:19Z
body_hash: 96ca003ab550dffe
raw_hash: 6e915715584ff7fa
vouched: 2026-10-16T22:08:16Z
lines: 114-794
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: class Channel
created: 2026-10-15T10:17:55Z
norm: '1'
sig: c905677fa4be33c1
body_hash: 26c17609650cbf0d
raw_hash: 296267bd2e8471ba
vouched: 2026-10-15T17:31:19Z
body_hash: 96ca003ab550dffe
raw_hash: 6e915715584ff7fa
vouched: 2026-10-16T22:08:16Z
lines: 121-817
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M53C4432AK0J31ABP2NSDBGY
anchor: function remove
created: 2026-10-16T22:08:05Z
norm: '1'
sig: e73f147af0f42c0d
body_hash: 704f93bfb2eeef37
raw_hash: b84fb0e3d822c289
lines: 83-112
---

only a message's author deletes it, and deletion replaces it in later model context through a pi context edit, never by rewriting or removing entries
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M53C443F8B2W36KZKW1R99PY
anchor: function react
created: 2026-10-16T22:08:05Z
norm: '1'
sig: 65d08af810f96cf1
body_hash: a6e46e61f28dc144
raw_hash: 9f46c79ced24f6a1
lines: 54-80
---

add and remove name the result rather than toggling, so repeated requests are no-ops; build the list before assigning it into the document, since later mutation is lost
54 changes: 50 additions & 4 deletions apps/app/src/conversation.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { useEffect, useMemo, useSyncExternalStore } from "react";
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";

import { Button, Timeline } from "@ace/ui";
import { Button, Timeline, toast } from "@ace/ui";
import type { Listing } from "@ace/host/protocol";

import { Composer } from "./composer";
import { DeleteMessage } from "./delete-message";
import { desktop } from "./desktop";
import { publish } from "./details";
import { host } from "./host";
Expand Down Expand Up @@ -86,6 +87,7 @@ export function Conversation(
senders,
transcript.captions,
transcript.recaps,
transcript.reactions,
),
[
transcript.items,
Expand All @@ -97,8 +99,34 @@ export function Conversation(
senders,
transcript.captions,
transcript.recaps,
transcript.reactions,
],
);
const byKey = useMemo(
() => new Map(transcript.items.map((item) => [item.key, item])),
[transcript.items],
);
// Reactions and deletions target stored entries; the streamed draft and tool-only replies have none.
const actions = (uid: string) => {
const item = byKey.get(uid);
const stored = item?.entry !== undefined && !(item.kind === "message" && item.deleted);
// An archived channel refuses changes to its history.
const open = stored && channel.state !== "archived";
return { react: open, copy: stored && !!item.text, delete: open, permalink: false };
};
const [deleting, setDeleting] = useState<string>();
const act = (
request: { op: "react"; emoji: string; remove: boolean } | { op: "delete" },
uid: string,
) => {
const entry = byKey.get(uid)!.entry!;
host.channel(channel.id, { ...request, chat: id, author: user, entry }).catch((error: Error) =>
toast.error(
request.op === "react" ? "Could not react" : "Could not delete the message",
{ description: error.message },
)
);
};
// Keyed by Tailscale login for message authors and by GitHub login for mentions.
const avatars = useMemo(
() =>
Expand Down Expand Up @@ -146,9 +174,18 @@ export function Conversation(
<Timeline
className="h-full min-h-0 contain-paint scroll-fade [--fade:3rem]"
events={events}
toolbar={false}
working={transcript.busy || undefined}
currentUser={{ login: user }}
currentUser={{ login: user, own: [user] }}
actions={actions}
onReact={(uid, emoji) => {
const entry = byKey.get(uid)!.entry!;
const remove = !!transcript.reactions[entry]?.some((value) =>
value.from === user && value.emoji === emoji
);
act({ op: "react", emoji, remove }, uid);
}}
onCopy={(uid) => void navigator.clipboard.writeText(byKey.get(uid)!.text)}
onDelete={setDeleting}
avatars={avatars}
openSender={(sender) => {
const source = senders.get(sender);
Expand Down Expand Up @@ -178,6 +215,15 @@ export function Conversation(
</div>
)}
</div>
{deleting && (
<DeleteMessage
onDelete={() => {
act({ op: "delete" }, deleting);
setDeleting(undefined);
}}
onCancel={() => setDeleting(undefined)}
/>
)}
<div className="utils:max-width relative z-20 shrink-0 px-3 pb-3">
{transcript.live && (notice || transition || transcript.restarting) && (
<p className="pb-2 text-center text-xs text-muted-foreground" role="status">
Expand Down
39 changes: 39 additions & 0 deletions apps/app/src/delete-message.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@ace/ui";

type Props = {
onDelete: () => void;
onCancel: () => void;
};

/** Deleting can't be undone, and agents that already read the message keep what they read. */
export function DeleteMessage({ onDelete, onCancel }: Props) {
return (
<Dialog open onOpenChange={(next) => next || onCancel()}>
<DialogContent className="gap-3 sm:max-w-80" showCloseButton={false}>
<DialogHeader>
<DialogTitle>Delete message?</DialogTitle>
<DialogDescription>
Everyone in the channel sees that it was deleted. Agents won't read it again, but runs
that already read it keep what they learned.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button type="button" variant="destructive" onClick={onDelete}>
Delete message
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
15 changes: 13 additions & 2 deletions apps/app/src/timeline.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { QueuedMessage } from "@ace/channel/protocol";
import type { QueuedMessage, Reactions } from "@ace/channel/protocol";
import type { Listing, People } from "@ace/host/protocol";
import type { Event } from "@ace/ui";

Expand Down Expand Up @@ -126,14 +126,25 @@ export function toEvents(
channels: Map<string, Listing>,
captions: Record<string, string>,
recaps: Record<string, string>,
reactions: Reactions,
): Event[] {
const events = items.map((item) => {
const reacted = item.entry === undefined ? undefined : reactions[item.entry];
const base = {
id: `${topic}::${item.key}`,
uid: item.key,
type: "message",
topic,
created_at: Math.floor(item.at / 1000),
...(reacted
? {
reactions: reacted.map(({ from, emoji }) => ({
from,
display: authorName(from, people, channels),
emoji,
})),
}
: {}),
} as const;
if (item.kind === "message") {
return {
Expand All @@ -143,7 +154,7 @@ export function toEvents(
value: item.author,
display: authorName(item.author, people, channels),
},
content: [
content: item.deleted ? [text("Message deleted")] : [
text(item.invoked && !/(^|\s)@ace\b/i.test(item.text) ? `@ace ${item.text}` : item.text),
...(item.images || []).map((image) => ({
type: "image" as const,
Expand Down
33 changes: 30 additions & 3 deletions apps/app/src/transcript.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";

import type { Image, QueuedMessage } from "@ace/channel/protocol";
import type { Image, QueuedMessage, Reactions } from "@ace/channel/protocol";
import { DISCONNECTED } from "@ace/host/gateway-client";
import { CHANNEL_CLOSED, type ChannelInfo, type Event } from "@ace/host/protocol";

Expand All @@ -25,15 +25,19 @@ export type Item =
| {
kind: "message";
key: string;
entry: number;
at: number;
author: string;
text: string;
images?: Image[];
invoked: boolean;
deleted?: boolean;
}
| {
kind: "reply";
key: string;
/** Absent for a reply that only called tools, which takes no reactions. */
entry?: number;
at: number;
model: string;
text: string;
Expand All @@ -55,6 +59,7 @@ export type Transcript = {
captions: Record<string, string>;
/** Each tool call's recap of the work it was part of, by call ID. */
recaps: Record<string, string>;
reactions: Reactions;
/** Text streamed for the reply being generated. */
draft: string;
busy: boolean;
Expand All @@ -71,6 +76,7 @@ const EMPTY: Transcript = {
withdrawn: [],
captions: {},
recaps: {},
reactions: {},
draft: "",
busy: false,
live: false,
Expand All @@ -94,10 +100,22 @@ export function apply(state: Transcript, event: Event): Transcript {
...state,
items: [...state.items, { kind: "message", key: `e${event.entry}`, ...pick(event) }],
};
case "deleted":
return {
...state,
items: state.items.map((item) =>
item.kind === "message" && item.entry === event.entry
? { ...item, text: "", images: undefined, deleted: true }
: item
),
};
case "reactions":
return { ...state, reactions: event.reactions };
case "reply": {
const item: Item = {
kind: "reply",
key: `e${event.entry}`,
entry: event.entry,
at: event.at,
model: event.model,
text: event.text,
Expand Down Expand Up @@ -226,8 +244,16 @@ function withdraw(items: Item[], messages: QueuedMessage[]): Item[] {
}

function pick(event: Extract<Event, { kind: "message" }>) {
const { at, author, text, images, invoked } = event;
return { at, author, text, ...(images ? { images } : {}), invoked };
const { entry, at, author, text, images, invoked, deleted } = event;
return {
entry,
at,
author,
text,
...(images ? { images } : {}),
invoked,
...(deleted ? { deleted } : {}),
};
}

/** Automatic re-watches allowed before a watch stays open for `STABLE` ms. */
Expand Down Expand Up @@ -298,6 +324,7 @@ export function useTranscript(
queue: true,
captions: true,
progress: true,
actions: true,
...(chat === undefined ? {} : { chat }),
}, {
event(event) {
Expand Down
Loading