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: 01M5J3KZ44GVZ61NBP4E3170JN
anchor: class Channel
created: 2026-10-22T15:28:04Z
norm: '1'
sig: c905677fa4be33c1
body_hash: 11e85e18fedef3f8
raw_hash: 4a0a2a86ff71e00a
lines: 162-1046
---

typing is never stored: it reaches only listeners of that chat whose watch asked for typing, so replays and older clients never see it
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function Composer
created: 2026-10-15T02:50:44Z
norm: '1'
sig: 6e0e6c5c8afbd01a
body_hash: c1bc4d0ff6028d0a
raw_hash: f2b2dae833097ad7
vouched: 2026-10-22T15:13:29Z
body_hash: f1ce2fae59a8c894
raw_hash: 0815737299df6f4a
vouched: 2026-10-22T15:28:03Z
lines: 99-282
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function Composer
created: 2026-10-15T12:44:15Z
norm: '1'
sig: 6e0e6c5c8afbd01a
body_hash: c1bc4d0ff6028d0a
raw_hash: f2b2dae833097ad7
vouched: 2026-10-22T15:13:29Z
body_hash: f1ce2fae59a8c894
raw_hash: 0815737299df6f4a
vouched: 2026-10-22T15:28:03Z
lines: 101-290
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function Composer
created: 2026-10-15T14:16:28Z
norm: '1'
sig: 6e0e6c5c8afbd01a
body_hash: c1bc4d0ff6028d0a
raw_hash: f2b2dae833097ad7
vouched: 2026-10-22T15:13:25Z
body_hash: f1ce2fae59a8c894
raw_hash: 0815737299df6f4a
vouched: 2026-10-22T15:28:03Z
lines: 104-326
---

Expand Down
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: cd0b6429068face3
raw_hash: 9d8f485b8e977dab
vouched: 2026-10-16T22:08:16Z
body_hash: 6eead60056a515f0
raw_hash: e83ea96acc31564a
vouched: 2026-10-22T15:28:03Z
lines: 174-275
---

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ anchor: '@file'
created: 2026-10-20T17:10:44Z
norm: '1'
sig: e3b0c44298fc1c14
body_hash: fead7ca7cb28760f
raw_hash: 5b36bffc37f91339
body_hash: 34d1a59cc18e2a4e
raw_hash: 154e8bdfc835590a
vouched: 2026-10-22T15:28:03Z
lines: 1-187
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function websocket
created: 2026-10-15T20:16:37Z
norm: '1'
sig: 1b462e5d3bc33728
body_hash: 39ee5954e9b924c2
raw_hash: 0abdf1cfc47ee529
vouched: 2026-10-22T14:33:27Z
body_hash: 4e6ce6a0eaa1ee2f
raw_hash: fd92f6b6617f653e
vouched: 2026-10-22T15:28:03Z
lines: 673-737
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: '@file'
created: 2026-10-20T17:04:56Z
norm: '1'
sig: 1acdc0f6a0895ac5
body_hash: 2c286a27ab7e9d25
raw_hash: 0da8b2103ee9b539
vouched: 2026-10-22T15:20:51Z
body_hash: d40ee870a4451138
raw_hash: 07401839125f7e0e
vouched: 2026-10-22T15:28:03Z
lines: 1-1153
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: '@file'
created: 2026-10-22T14:00:47Z
norm: '1'
sig: 1acdc0f6a0895ac5
body_hash: 2c286a27ab7e9d25
raw_hash: 0da8b2103ee9b539
vouched: 2026-10-22T15:20:51Z
body_hash: d40ee870a4451138
raw_hash: 07401839125f7e0e
vouched: 2026-10-22T15:28:03Z
lines: 1-1309
---

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: 3278b21f03ff7630
raw_hash: 3e66d7913dbb2b75
vouched: 2026-10-22T15:24:38Z
body_hash: 97ca0ac50b13c167
raw_hash: 4b1f599e8b95d697
vouched: 2026-10-22T15:29:33Z
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: 3278b21f03ff7630
raw_hash: 3e66d7913dbb2b75
vouched: 2026-10-22T15:24:38Z
body_hash: 97ca0ac50b13c167
raw_hash: 4b1f599e8b95d697
vouched: 2026-10-22T15:29:33Z
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: 3278b21f03ff7630
raw_hash: 3e66d7913dbb2b75
vouched: 2026-10-22T15:24:38Z
body_hash: 97ca0ac50b13c167
raw_hash: 4b1f599e8b95d697
vouched: 2026-10-22T15:29:33Z
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: 3278b21f03ff7630
raw_hash: 3e66d7913dbb2b75
vouched: 2026-10-22T15:24:38Z
body_hash: 97ca0ac50b13c167
raw_hash: 4b1f599e8b95d697
vouched: 2026-10-22T15:29:33Z
lines: 121-817
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: class Channel
created: 2026-10-16T22:11:35Z
norm: '1'
sig: c905677fa4be33c1
body_hash: 3278b21f03ff7630
raw_hash: 3e66d7913dbb2b75
vouched: 2026-10-22T15:24:39Z
body_hash: 97ca0ac50b13c167
raw_hash: 4b1f599e8b95d697
vouched: 2026-10-22T15:29:33Z
lines: 127-873
---

Expand Down
7 changes: 7 additions & 0 deletions apps/app/src/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export function Composer(
) {
const composer = useRef<ChatComposerHandle>(null);
const sending = useRef(false);
const typed = useRef(0);
const [pending, setPending] = useState(false);
const [attached, setAttached] = useState<Attachment[]>([]);
// Kept beside the channel's draft so a deliberate mode and an unsent model pick survive relaunch.
Expand Down Expand Up @@ -334,6 +335,12 @@ export function Composer(
busy={busy}
submitBusy={pending}
submitLabel={waiting}
onTypingChange={(value) => {
// Watchers drop a typist after 5 s without a signal, so one every 3 s keeps them shown.
if (!value || !ready || Date.now() - typed.current < 3000) return;
typed.current = Date.now();
host.channel(channel.id, { op: "typing", chat: id, author: user }).catch(() => {});
}}
canStop={busy && !blocked}
onStop={() => void host.channel(channel.id, { op: "stop", chat: id, author: user })}
canSend={ready && status === "open" && !pending}
Expand Down
9 changes: 7 additions & 2 deletions apps/app/src/conversation.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";

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

import { Checks } from "./checks";
Expand All @@ -15,7 +15,7 @@ import { OfflineHost, useRoster } from "./onboarding";
import { present } from "./presence";
import { pendingQuestion, Question } from "./question";
import { sources, toEvents } from "./timeline";
import { useTranscript } from "./transcript";
import { useTranscript, useTyping } from "./transcript";
import { Usage } from "./usage";

const stopped = (who: string) => `${who} isn't responding. The chat continues when it answers.`;
Expand Down Expand Up @@ -176,6 +176,8 @@ export function Conversation(
.filter((name) => name !== self && name !== "ace" && !name.includes("@"))
.map((name) => ({ name, avatar: avatars[name] }));
}, [people, user, presence, channel, transcript.items, roster, avatars]);
const typing = useTyping(transcript.typing).filter((author) => author !== user)
.map((author) => people[author] || author);
const results = useMemo(
() =>
transcript.items.reduce(
Expand Down Expand Up @@ -274,6 +276,9 @@ export function Conversation(
/>
)}
<div className="utils:max-width relative z-20 shrink-0 px-3 pb-3">
{typing.length > 0 && (
<ChatStatus className="absolute inset-x-5 bottom-full" typing={typing} disclaimer="" />
)}
{transcript.live && (notice || transition || transcript.restarting) && (
<p className="pb-2 text-center text-xs text-muted-foreground" role="status">
{notice || transition || "Channel is restarting…"}
Expand Down
27 changes: 26 additions & 1 deletion apps/app/src/transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ export type Transcript = {
/** Each tool call's recap of the work it was part of, by call ID. */
recaps: Record<string, string>;
reactions: Reactions;
/** When each person last said they were typing, in ms; their message ends it. */
typing: Record<string, number>;
/** Text streamed for the reply being generated. */
draft: string;
busy: boolean;
Expand All @@ -80,6 +82,7 @@ const EMPTY: Transcript = {
captions: {},
recaps: {},
reactions: {},
typing: {},
draft: "",
busy: false,
live: false,
Expand All @@ -98,11 +101,16 @@ export function apply(state: Transcript, event: Event): Transcript {
const items = withdraw(state.items, event.withdrawn);
return { ...state, queue: event.messages, returned, items };
}
case "message":
case "message": {
const { [event.author]: _, ...typing } = state.typing;
return {
...state,
typing,
items: [...state.items, { kind: "message", key: `e${event.entry}`, ...pick(event) }],
};
}
case "typing":
return { ...state, typing: { ...state.typing, [event.author]: Date.now() } };
case "deleted":
return {
...state,
Expand Down Expand Up @@ -271,6 +279,22 @@ function pick(event: Extract<Event, { kind: "message" }>) {
}

/** Automatic re-watches allowed before a watch stays open for `STABLE` ms. */
/** Typists send a signal every few seconds while writing; one this old has stopped. */
const TYPING = 5000;

/** Who said they were typing within the last few seconds, read again as each goes stale. */
export function useTyping(typing: Record<string, number>): string[] {
const [now, setNow] = useState(Date.now);
const fresh = Object.entries(typing).filter(([, at]) => at > now - TYPING);
const next = Math.min(...fresh.map(([, at]) => at + TYPING));
useEffect(() => {
if (next === Infinity) return;
const timer = setTimeout(() => setNow(Date.now()), next - Date.now());
return () => clearTimeout(timer);
}, [next]);
return fresh.map(([author]) => author);
}

const RECOVERIES = 3;
const STABLE = 60_000;

Expand Down Expand Up @@ -339,6 +363,7 @@ export function useTranscript(
captions: true,
progress: true,
actions: true,
typing: true,
...(chat === undefined ? {} : { chat }),
}, {
event(event) {
Expand Down
10 changes: 10 additions & 0 deletions apps/host/src/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ type Step =
| ({ close: true } & Target)
| { away: boolean }
| { leave: true }
/** Shows as typing in the chat for this many seconds. */
| ({ type: number } & Target)
| ({ say: string } & Target)
| ({ ask: string } & Target)
/** Reacts to the chat's newest message or reply. */
Expand Down Expand Up @@ -137,6 +139,14 @@ async function run(step: Step) {
}
const id = await present(step.who, step);
const chat = step.chat === undefined ? {} : { chat: step.chat };
if ("type" in step) {
// The app drops a typist 5 s after their last signal.
for (let left = step.type; left > 0; left -= 3) {
await request(id, { op: "typing", author, ...chat });
await Bun.sleep(Math.min(left, 3) * 1000);
}
return;
}
if ("say" in step) return void await request(id, { op: "say", author, text: step.say, ...chat });
// An ask returns once admitted; the run continues in the channel as anyone's would.
if ("ask" in step) return void await request(id, { op: "ask", author, text: step.ask, ...chat });
Expand Down
3 changes: 2 additions & 1 deletion apps/host/src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -959,7 +959,8 @@ function websocket(): Bun.WebSocketHandler<Client> {
|| request.op === "hello" || request.op === "models"
|| request.op === "release" || request.op === "terminal-input"
|| request.op === "terminal-resize"
|| (request.op === "channel" && ["watch", "info", "models"].includes(request.request.op));
|| (request.op === "channel"
&& ["watch", "info", "models", "typing"].includes(request.request.op));
const handling = handle(socket, request, send, id, trace);
pending.add(handling);
try {
Expand Down
6 changes: 4 additions & 2 deletions docs/demo.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Seed the channels first with `ace import` (see [channel data](data.md#seeds)).
},
"steps": [
{ "at": 0, "who": "alex", "open": true, "channel": "checkout" },
{ "at": 1, "who": "alex", "type": 2, "channel": "checkout" },
{ "at": 3, "who": "alex", "say": "pushed a fix, can someone check?", "channel": "checkout" },
{
"at": 6,
Expand All @@ -35,6 +36,7 @@ Seed the channels first with `ace import` (see [channel data](data.md#seeds)).

- `login` is the author their messages carry; `github` picks the avatar and name the app shows.
- `at` is seconds from the start. `loop` restarts the script that many seconds after its last step.
- Steps: `open`/`close` a channel (present there), `away` true or false, `leave`, `say`, `ask`, and
`react`, which reacts to the chat's newest message or reply. Acting opens the channel first.
- Steps: `open`/`close` a channel (present there), `away` true or false, `leave`, `type` (shows
them typing for that many seconds), `say`, `ask`, and `react`, which reacts to the chat's newest
message or reply. Acting opens the channel first.
`chat` targets a chat other than the root.
Loading