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 useAttention
created: 2026-10-22T15:40:01Z
norm: '1'
sig: 764bb872be391a4f
body_hash: e8f4fd56e6b15297
raw_hash: 3466b3d2a2a88367
vouched: 2026-10-22T20:42:40Z
body_hash: 0d70c33821757895
raw_hash: 6c2701af357899ff
vouched: 2026-10-23T12:23:27Z
lines: 100-195
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5MBEFW3PXMMV82S8RKT9VKY
anchor: function pending
created: 2026-10-23T12:23:22Z
norm: '1'
sig: af3b225fe1618ff5
body_hash: b9ff227c0ed2e9f3
raw_hash: 850b81451b16b439
lines: 62-69
---

a question is pending from the host's ran outcome alone (question until answered/done/failed/stopped), never from this profile's since or seen
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
id: 01M5MBEQ6RJJ5BY6QA3FWW6NBP
anchor: '@file'
created: 2026-10-23T12:23:29Z
norm: '1'
sig: 9d222e7bbdcbbb9b
body_hash: 57bde1c5f58e5bf4
raw_hash: 5c870af127b25658
lines: 1-218
supersedes: 01M5J49ZNB9XYK6GFYV3SBXKAP
---

`told` and `called` persist per host and user so reloads and reconnects never re-notify the same run end or mention; `since` keeps history from lighting up on a first visit, but a question still waiting is host state and marks its channel on any profile
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: a3c1f1281c5b3997
raw_hash: 10f2879f7a794315
vouched: 2026-10-22T22:28:50Z
body_hash: 6470793c4d7ec520
raw_hash: b16b1cb88d0267d5
vouched: 2026-10-23T12:23:27Z
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: a3c1f1281c5b3997
raw_hash: 10f2879f7a794315
vouched: 2026-10-22T22:28:50Z
body_hash: 6470793c4d7ec520
raw_hash: b16b1cb88d0267d5
vouched: 2026-10-23T12:23:27Z
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: a3c1f1281c5b3997
raw_hash: 10f2879f7a794315
vouched: 2026-10-22T22:28:50Z
body_hash: 6470793c4d7ec520
raw_hash: b16b1cb88d0267d5
vouched: 2026-10-23T12:23:27Z
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: a3c1f1281c5b3997
raw_hash: 10f2879f7a794315
vouched: 2026-10-22T22:28:50Z
body_hash: 6470793c4d7ec520
raw_hash: b16b1cb88d0267d5
vouched: 2026-10-23T12:23:27Z
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: a3c1f1281c5b3997
raw_hash: 10f2879f7a794315
vouched: 2026-10-22T22:28:50Z
body_hash: 6470793c4d7ec520
raw_hash: b16b1cb88d0267d5
vouched: 2026-10-23T12:23:27Z
lines: 127-873
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ anchor: class Questions
created: 2026-10-15T17:31:05Z
norm: '1'
sig: b320f3b4b2555f0f
body_hash: 8d55156e0bb23b9d
raw_hash: aa89381794b072eb
body_hash: db6d80cd41b3614e
raw_hash: 0a114ba9938f4f7d
vouched: 2026-10-23T12:23:27Z
lines: 86-161
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5MBEFYAQEH88T5212ES7WJX
anchor: class Questions
created: 2026-10-23T12:23:22Z
norm: '1'
sig: b320f3b4b2555f0f
body_hash: db6d80cd41b3614e
raw_hash: 0a114ba9938f4f7d
lines: 89-167
---

onAsk hears answered when the tool's wait resolves (answer, skip or a queued message), never on abort, which the run's end already publishes
21 changes: 14 additions & 7 deletions apps/app/src/attention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import { host } from "./host";
/**
* Run ends and messages this person has seen or been told about, by channel, and how many mentions
* of them each channel had when they last saw it (`heard`) or were told (`called`). Anything before
* `since` predates this browser profile's first visit, so history never lights up. Profiles from
* before mentions have no `heard`, and from before mention notices no `called`.
* `since` predates this browser profile's first visit, so history never lights up; a question still
* waiting is host state, not history, so it marks its channel however old. Profiles from before
* mentions have no `heard`, and from before mention notices no `called`.
*/
type State = {
since: number;
Expand All @@ -22,7 +23,7 @@ type State = {
called?: Record<string, number>;
};

export type Outcome = Exclude<RunEnd["outcome"], "stopped">;
export type Outcome = Exclude<RunEnd["outcome"], "stopped" | "answered">;

/** A run this person invoked, or `message` for anything anyone else wrote since they last looked. */
export type Mark = Outcome | "message";
Expand Down Expand Up @@ -54,10 +55,15 @@ host.subscribe(() => {
const seenAt = (channel: Listing, state: State) =>
Math.max(state.since, state.seen[channel.id] || 0);

/** A stopped run was a participant's own doing, so it needs no attention. */
/**
* A stopped run was a participant's own doing, so it needs no attention. A question needs it until
* the host hears an answer, whenever or wherever this person last looked.
*/
function pending(channel: Listing, user: string, state: State): Outcome | undefined {
const { ran } = channel;
if (!ran || ran.author !== user || ran.outcome === "stopped") return;
if (!ran || ran.author !== user) return;
if (ran.outcome === "stopped" || ran.outcome === "answered") return;
if (ran.outcome === "question") return ran.outcome;
if (ran.at <= seenAt(channel, state)) return;
return ran.outcome;
}
Expand Down Expand Up @@ -102,7 +108,7 @@ function missed(found: { channel: Listing; count: number }[]): [string, string]
* Runs this person invoked that ended, or that wait on a question, while they were not watching the
* channel, teammates' runs that ended in their channels, and new @mentions of `names` (lowercased
* GitHub logins). Each pulses the window's glow or sends one desktop notification, then marks its
* row until they open it; mentions that arrived while Ace was closed or disconnected notify once
* row until they open it, or for a question until it is answered; mentions that arrived while Ace was closed or disconnected notify once
* together. Others' messages mark the row too, without a notification.
*/
export function useAttention(
Expand Down Expand Up @@ -151,7 +157,8 @@ export function useAttention(
if (!ran) continue;
const outcome = pending(channel, user, state);
if (!outcome && !isTeammateRun(channel, user, state)) continue;
if (ran.at <= (state.told[id] || 0)) continue;
// A question seen while it waited, or older than this profile, was already in front of them.
if (ran.at <= Math.max(state.told[id] || 0, seenAt(channel, state))) continue;
told[id] = ran.at;
if (watching) setPulse("on");
else if (outcome) desktop?.notify(channel.name, NOTICES[outcome]).catch(() => {});
Expand Down
7 changes: 4 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -667,9 +667,10 @@ Notifications are not built. Each app notices its own person's
finished and failed runs, and runs waiting on a question, from listings: it keeps the run ends it has shown or announced per host
and participant in browser storage, starting from its first visit. A run end that arrives while
another channel is open pulses the window's glow; one that arrives while the window is unfocused
sends a desktop notification. The channel's row keeps a dot (finished) or attention mark (failed
or waiting on a question)
until the person opens it in a focused window. Stopped runs need no attention.
sends a desktop notification. The channel's row keeps a dot (finished) or attention mark (failed)
until the person opens it in a focused window. A run waiting on a question keeps its attention mark
until the host publishes that it was answered, so every browser and phone shows it, even one first
opened after the question was asked. Stopped runs need no attention.

## The team

Expand Down
8 changes: 4 additions & 4 deletions packages/channel/src/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ export class Channel {
registry.install(CodingTools);
const handoffs = new Handoffs(options.directory);
registry.install(handoffs.extension);
const questions = new Questions((chat) => void channel.#asked(chat));
const questions = new Questions((chat, phase) => void channel.#asked(chat, phase));
registry.install(questions.extension);
const execute = options.desktop;
if (execute) {
Expand Down Expand Up @@ -596,15 +596,15 @@ export class Channel {
await this.#publishRun(chat, inputs.map((id) => settled.get(id)?.entry), outcome);
}

/** A run waiting on a question needs the same attention as one that ended. */
async #asked(chat: ConversationId): Promise<void> {
/** A run waiting on a question needs the same attention as one that ended, until it is answered. */
async #asked(chat: ConversationId, phase: "question" | "answered"): Promise<void> {
const inputs = this.#runs.get(chat);
if (!inputs) return;
const entries = await Promise.all(inputs.map(async (id) => {
const submission = await this.#harness.submission(id, context);
return (await submission?.status(context))?.entry;
}));
await this.#publishRun(chat, entries, "question");
await this.#publishRun(chat, entries, phase);
}

async #publishRun(
Expand Down
7 changes: 5 additions & 2 deletions packages/channel/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,11 @@ export type Metadata = { name: string; summary: string; revision: number };
export type RunEnd = {
chat: ChatId;
at: number;
/** `question` is published when a run starts waiting on a question, not at its end. */
outcome: "done" | "failed" | "stopped" | "question";
/**
* `question` is published when a run starts waiting on a question and `answered` when that wait
* ends with the run still going, so a pending question is read from the host, not from a client.
*/
outcome: "done" | "failed" | "stopped" | "question" | "answered";
author: string;
};

Expand Down
11 changes: 7 additions & 4 deletions packages/channel/src/questions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,10 @@ function queued(state: Readonly<InboxState> | null | undefined): string | undefi
export class Questions {
#queued = new Map<ConversationId, string>();
#waiting = new Map<ConversationId, Set<(author: string) => void>>();
#onAsk: (chat: ConversationId) => void;
#onAsk: (chat: ConversationId, phase: "question" | "answered") => void;

constructor(onAsk: (chat: ConversationId) => void) {
/** `onAsk` hears when a chat starts waiting on a question and when an answer or message ends it. */
constructor(onAsk: (chat: ConversationId, phase: "question" | "answered") => void) {
this.#onAsk = onAsk;
}

Expand Down Expand Up @@ -131,7 +132,7 @@ export class Questions {
doc.questions[call] = { items: args.questions };
return true;
}, context);
if (created) this.#onAsk(chat);
if (created) this.#onAsk(chat, "question");
const watch = (await api.watchDoc(QuestionsDoc, chat, context))!;
const settle = (value: typeof watch.value) => {
const answer = value?.questions[call]?.answer;
Expand All @@ -143,7 +144,9 @@ export class Questions {
if (context.abortSignal?.aborted) abort();
settle(watch.value);
watch.start(async (value) => settle(value));
return await promise;
const result = await promise;
this.#onAsk(chat, "answered");
return result;
} finally {
context.abortSignal?.removeEventListener("abort", abort);
await watch.stop();
Expand Down