diff --git a/.lane/attic/packages/channel/src/channel.ts/01M5J3KZ44GVZ61NBP4E3170JN-typing-is-never-stored-it-re.md b/.lane/attic/packages/channel/src/channel.ts/01M5J3KZ44GVZ61NBP4E3170JN-typing-is-never-stored-it-re.md new file mode 100644 index 00000000..06bbd541 --- /dev/null +++ b/.lane/attic/packages/channel/src/channel.ts/01M5J3KZ44GVZ61NBP4E3170JN-typing-is-never-stored-it-re.md @@ -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 diff --git a/.lane/memory/apps/app/src/composer.tsx/01M4YQG7BVWEFZPD56A2BA0VE9-mode-ace-mode-channels-id-an.md b/.lane/memory/apps/app/src/composer.tsx/01M4YQG7BVWEFZPD56A2BA0VE9-mode-ace-mode-channels-id-an.md index 5c7f93f7..ad94eac4 100644 --- a/.lane/memory/apps/app/src/composer.tsx/01M4YQG7BVWEFZPD56A2BA0VE9-mode-ace-mode-channels-id-an.md +++ b/.lane/memory/apps/app/src/composer.tsx/01M4YQG7BVWEFZPD56A2BA0VE9-mode-ace-mode-channels-id-an.md @@ -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 --- diff --git a/.lane/memory/apps/app/src/composer.tsx/01M4ZSEZBJJ3AMEBTR7SH36FW4-models-are-read-only-while-t.md b/.lane/memory/apps/app/src/composer.tsx/01M4ZSEZBJJ3AMEBTR7SH36FW4-models-are-read-only-while-t.md index 9f362852..5869134e 100644 --- a/.lane/memory/apps/app/src/composer.tsx/01M4ZSEZBJJ3AMEBTR7SH36FW4-models-are-read-only-while-t.md +++ b/.lane/memory/apps/app/src/composer.tsx/01M4ZSEZBJJ3AMEBTR7SH36FW4-models-are-read-only-while-t.md @@ -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 --- diff --git a/.lane/memory/apps/app/src/composer.tsx/01M4ZYQVGB6C87WBEV12WPQ8PF-a-withdrawn-message-returns.md b/.lane/memory/apps/app/src/composer.tsx/01M4ZYQVGB6C87WBEV12WPQ8PF-a-withdrawn-message-returns.md index 5cc309a2..8b8e293e 100644 --- a/.lane/memory/apps/app/src/composer.tsx/01M4ZYQVGB6C87WBEV12WPQ8PF-a-withdrawn-message-returns.md +++ b/.lane/memory/apps/app/src/composer.tsx/01M4ZYQVGB6C87WBEV12WPQ8PF-a-withdrawn-message-returns.md @@ -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 --- diff --git a/.lane/memory/apps/app/src/transcript.ts/01M4YQRMMB6JT09W33MJMPKSMF-a-re-watch-keeps-showing-the.md b/.lane/memory/apps/app/src/transcript.ts/01M4YQRMMB6JT09W33MJMPKSMF-a-re-watch-keeps-showing-the.md index 493a1e9d..25d1fddb 100644 --- a/.lane/memory/apps/app/src/transcript.ts/01M4YQRMMB6JT09W33MJMPKSMF-a-re-watch-keeps-showing-the.md +++ b/.lane/memory/apps/app/src/transcript.ts/01M4YQRMMB6JT09W33MJMPKSMF-a-re-watch-keeps-showing-the.md @@ -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 --- diff --git a/.lane/memory/apps/app/src/transcript.ts/01M4YYQEXJX6JQ04TBRZ4DW1X8-a-watch-rejected-with-discon.md b/.lane/memory/apps/app/src/transcript.ts/01M4YYQEXJX6JQ04TBRZ4DW1X8-a-watch-rejected-with-discon.md index bbae6ee4..da801330 100644 --- a/.lane/memory/apps/app/src/transcript.ts/01M4YYQEXJX6JQ04TBRZ4DW1X8-a-watch-rejected-with-discon.md +++ b/.lane/memory/apps/app/src/transcript.ts/01M4YYQEXJX6JQ04TBRZ4DW1X8-a-watch-rejected-with-discon.md @@ -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 --- diff --git a/.lane/memory/apps/host/src/demo.ts/01M5D4PGYZHXCA8WY1C8ZF4NF7-synthetic-people-stay-in-the.md b/.lane/memory/apps/host/src/demo.ts/01M5D4PGYZHXCA8WY1C8ZF4NF7-synthetic-people-stay-in-the.md index fb1ec9a6..7ef1da68 100644 --- a/.lane/memory/apps/host/src/demo.ts/01M5D4PGYZHXCA8WY1C8ZF4NF7-synthetic-people-stay-in-the.md +++ b/.lane/memory/apps/host/src/demo.ts/01M5D4PGYZHXCA8WY1C8ZF4NF7-synthetic-people-stay-in-the.md @@ -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 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M50KBA3ME0Q9274A639MK6KJ-gateway-sockets-must-never-d.md b/.lane/memory/apps/host/src/gateway.ts/01M50KBA3ME0Q9274A639MK6KJ-gateway-sockets-must-never-d.md index 1cb9aef6..214af6cd 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M50KBA3ME0Q9274A639MK6KJ-gateway-sockets-must-never-d.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M50KBA3ME0Q9274A639MK6KJ-gateway-sockets-must-never-d.md @@ -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 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M5D4BXMERGZ1EZ3EYAFM3YC4-worker-requests-worker-never.md b/.lane/memory/apps/host/src/gateway.ts/01M5D4BXMERGZ1EZ3EYAFM3YC4-worker-requests-worker-never.md index 018b3888..84d3c060 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M5D4BXMERGZ1EZ3EYAFM3YC4-worker-requests-worker-never.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M5D4BXMERGZ1EZ3EYAFM3YC4-worker-requests-worker-never.md @@ -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 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M5HYM54QCSF4ESXJSWZK588G-every-project-op-that-change.md b/.lane/memory/apps/host/src/gateway.ts/01M5HYM54QCSF4ESXJSWZK588G-every-project-op-that-change.md index dd4f5c26..6061e6f1 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M5HYM54QCSF4ESXJSWZK588G-every-project-op-that-change.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M5HYM54QCSF4ESXJSWZK588G-every-project-op-that-change.md @@ -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 --- diff --git a/.lane/memory/packages/channel/src/channel.ts/01M4XQKND0900E94CG3BXBEJP7-a-run-s-end-is-read-from-the.md b/.lane/memory/packages/channel/src/channel.ts/01M4XQKND0900E94CG3BXBEJP7-a-run-s-end-is-read-from-the.md index 4e161dfc..bd7d01bf 100644 --- a/.lane/memory/packages/channel/src/channel.ts/01M4XQKND0900E94CG3BXBEJP7-a-run-s-end-is-read-from-the.md +++ b/.lane/memory/packages/channel/src/channel.ts/01M4XQKND0900E94CG3BXBEJP7-a-run-s-end-is-read-from-the.md @@ -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 --- diff --git a/.lane/memory/packages/channel/src/channel.ts/01M4YFFTK7TWGKENJAD1W10HJJ-the-desktop-default-changes.md b/.lane/memory/packages/channel/src/channel.ts/01M4YFFTK7TWGKENJAD1W10HJJ-the-desktop-default-changes.md index cd23abda..5da58be6 100644 --- a/.lane/memory/packages/channel/src/channel.ts/01M4YFFTK7TWGKENJAD1W10HJJ-the-desktop-default-changes.md +++ b/.lane/memory/packages/channel/src/channel.ts/01M4YFFTK7TWGKENJAD1W10HJJ-the-desktop-default-changes.md @@ -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 --- diff --git a/.lane/memory/packages/channel/src/channel.ts/01M4YPJQB6KKEW1YTRD08YCYJY-only-gateways-admit-clients.md b/.lane/memory/packages/channel/src/channel.ts/01M4YPJQB6KKEW1YTRD08YCYJY-only-gateways-admit-clients.md index 571b239b..e8b331be 100644 --- a/.lane/memory/packages/channel/src/channel.ts/01M4YPJQB6KKEW1YTRD08YCYJY-only-gateways-admit-clients.md +++ b/.lane/memory/packages/channel/src/channel.ts/01M4YPJQB6KKEW1YTRD08YCYJY-only-gateways-admit-clients.md @@ -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 --- diff --git a/.lane/memory/packages/channel/src/channel.ts/01M4ZH31DXZK0Y57C97DP84ZKX-handoffs-learns-queued-hando.md b/.lane/memory/packages/channel/src/channel.ts/01M4ZH31DXZK0Y57C97DP84ZKX-handoffs-learns-queued-hando.md index 22ba3275..9be8c60c 100644 --- a/.lane/memory/packages/channel/src/channel.ts/01M4ZH31DXZK0Y57C97DP84ZKX-handoffs-learns-queued-hando.md +++ b/.lane/memory/packages/channel/src/channel.ts/01M4ZH31DXZK0Y57C97DP84ZKX-handoffs-learns-queued-hando.md @@ -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 --- diff --git a/.lane/memory/packages/channel/src/channel.ts/01M53CAGW2RVRKCYF5BCJRTZX2-a-fork-s-starting-lane-is-re.md b/.lane/memory/packages/channel/src/channel.ts/01M53CAGW2RVRKCYF5BCJRTZX2-a-fork-s-starting-lane-is-re.md index 51941900..9df3eae3 100644 --- a/.lane/memory/packages/channel/src/channel.ts/01M53CAGW2RVRKCYF5BCJRTZX2-a-fork-s-starting-lane-is-re.md +++ b/.lane/memory/packages/channel/src/channel.ts/01M53CAGW2RVRKCYF5BCJRTZX2-a-fork-s-starting-lane-is-re.md @@ -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 --- diff --git a/apps/app/src/composer.tsx b/apps/app/src/composer.tsx index 560b7b9c..7751f8b7 100644 --- a/apps/app/src/composer.tsx +++ b/apps/app/src/composer.tsx @@ -104,6 +104,7 @@ export function Composer( ) { const composer = useRef(null); const sending = useRef(false); + const typed = useRef(0); const [pending, setPending] = useState(false); const [attached, setAttached] = useState([]); // Kept beside the channel's draft so a deliberate mode and an unsent model pick survive relaunch. @@ -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} diff --git a/apps/app/src/conversation.tsx b/apps/app/src/conversation.tsx index 313218d1..e8aa4dc7 100644 --- a/apps/app/src/conversation.tsx +++ b/apps/app/src/conversation.tsx @@ -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"; @@ -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.`; @@ -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( @@ -274,6 +276,9 @@ export function Conversation( /> )}
+ {typing.length > 0 && ( + + )} {transcript.live && (notice || transition || transcript.restarting) && (

{notice || transition || "Channel is restarting…"} diff --git a/apps/app/src/transcript.ts b/apps/app/src/transcript.ts index 07e34136..951644e2 100644 --- a/apps/app/src/transcript.ts +++ b/apps/app/src/transcript.ts @@ -63,6 +63,8 @@ export type Transcript = { /** Each tool call's recap of the work it was part of, by call ID. */ recaps: Record; reactions: Reactions; + /** When each person last said they were typing, in ms; their message ends it. */ + typing: Record; /** Text streamed for the reply being generated. */ draft: string; busy: boolean; @@ -80,6 +82,7 @@ const EMPTY: Transcript = { captions: {}, recaps: {}, reactions: {}, + typing: {}, draft: "", busy: false, live: false, @@ -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, @@ -271,6 +279,22 @@ function pick(event: Extract) { } /** 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[] { + 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; @@ -339,6 +363,7 @@ export function useTranscript( captions: true, progress: true, actions: true, + typing: true, ...(chat === undefined ? {} : { chat }), }, { event(event) { diff --git a/apps/host/src/demo.ts b/apps/host/src/demo.ts index b52bf2de..d7abaab2 100644 --- a/apps/host/src/demo.ts +++ b/apps/host/src/demo.ts @@ -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. */ @@ -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 }); diff --git a/apps/host/src/gateway.ts b/apps/host/src/gateway.ts index 908cf5aa..02c02ffb 100644 --- a/apps/host/src/gateway.ts +++ b/apps/host/src/gateway.ts @@ -959,7 +959,8 @@ function websocket(): Bun.WebSocketHandler { || 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 { diff --git a/docs/demo.md b/docs/demo.md index c9a588c7..d32b4faa 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -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, @@ -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. diff --git a/packages/channel/src/channel.ts b/packages/channel/src/channel.ts index 93bcf8b7..8126f447 100644 --- a/packages/channel/src/channel.ts +++ b/packages/channel/src/channel.ts @@ -155,6 +155,7 @@ type Listener = { send: Send; captions: boolean; actions: boolean; + typing: boolean; reacted?: boolean; }; @@ -410,6 +411,20 @@ export class Channel { return this.#rename(request); case "watch": return this.#watch(request, send); + case "typing": { + this.#author(request.author); + const { id } = await this.#conversation(request.chat); + const event: Event = { kind: "typing", chat: id, author: request.author }; + for (const listener of this.#listeners) { + if (listener.chat !== id || !listener.typing) continue; + try { + listener.send(event); + } catch { + this.#listeners.delete(listener); + } + } + return; + } case "changes": { const { cwd, lane } = await this.#place(request.chat); return changes(this.#options.env(cwd), cwd, this.#options.project, lane, context); @@ -951,6 +966,7 @@ export class Channel { send, captions: !!request.captions, actions, + typing: !!request.typing, }; this.#listeners.add(listener); // Read after the stream's snapshot, so every deletion it holds is here; later ones arrive live. diff --git a/packages/channel/src/protocol.ts b/packages/channel/src/protocol.ts index c810af43..ed6bbf11 100644 --- a/packages/channel/src/protocol.ts +++ b/packages/channel/src/protocol.ts @@ -254,6 +254,8 @@ export type Request = | { op: "share"; author: string; shared: boolean } | { op: "desktop"; author: string; enabled: boolean } | { op: "rename"; author: string; name: string } + /** Tell the chat's other watchers that the author is writing. Nothing is stored. */ + | { op: "typing"; chat?: ChatId; author: string } /** Resolve once the answer to a submission is placed: "done", or "unanswered" when stopped or killed. */ | { op: "wait"; submission: number } | { op: "changes"; chat?: ChatId } @@ -273,6 +275,8 @@ export type Request = progress?: boolean; /** Include deleted, edited, and reactions events; older clients do not understand them. */ actions?: boolean; + /** Include typing events; older clients do not understand them. */ + typing?: boolean; }; export type Event = @@ -368,7 +372,9 @@ export type Event = */ | ({ kind: "metadata"; chat: ChatId; shared?: boolean; desktop?: boolean } & Metadata) /** The replayed transcript has been sent; later events are live. */ - | { kind: "live"; chat: ChatId }; + | { kind: "live"; chat: ChatId } + /** `author` is writing in the chat; live only, and stale once a few seconds pass without another. */ + | { kind: "typing"; chat: ChatId; author: string }; export type Frame = | { id: number; ok: true; value: unknown }