diff --git a/.lane/memory/apps/app/src/app.tsx/01M4YS4GDCM47CYY24B7DG6FV9-pins-are-per-person-and-neve.md b/.lane/memory/apps/app/src/app.tsx/01M4YS4GDCM47CYY24B7DG6FV9-pins-are-per-person-and-neve.md index b5992d56..8b6874e8 100644 --- a/.lane/memory/apps/app/src/app.tsx/01M4YS4GDCM47CYY24B7DG6FV9-pins-are-per-person-and-neve.md +++ b/.lane/memory/apps/app/src/app.tsx/01M4YS4GDCM47CYY24B7DG6FV9-pins-are-per-person-and-neve.md @@ -4,9 +4,9 @@ anchor: function pinnedGroup created: 2026-10-15T03:19:17Z norm: '1' sig: 58e3b298c3735df9 -body_hash: b8c3efd6fb70866d -raw_hash: d12e8469fd135be8 -vouched: 2026-10-15T03:35:06Z +body_hash: 8d39b5406045357d +raw_hash: 8adf6bb06abf3680 +vouched: 2026-10-22T14:47:34Z lines: 149-161 --- diff --git a/.lane/memory/apps/app/src/app.tsx/01M4YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md b/.lane/memory/apps/app/src/app.tsx/01M4YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md index 44b6ad4e..76bdb45a 100644 --- a/.lane/memory/apps/app/src/app.tsx/01M4YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md +++ b/.lane/memory/apps/app/src/app.tsx/01M4YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md @@ -4,9 +4,9 @@ anchor: function App created: 2026-10-15T03:38:04Z norm: '1' sig: 063d0f00fea02511 -body_hash: a9cafa59e02070dc -raw_hash: df133116ed1fd81f -vouched: 2026-10-22T14:56:07Z +body_hash: e4eb291a858d929b +raw_hash: 6281f263fe223b15 +vouched: 2026-10-22T15:09:40Z lines: 235-824 --- diff --git a/.lane/memory/apps/app/src/app.tsx/01M5AYXP2WM5WYKYA2RC58AAYC-each-capability-mirrors-a-ho.md b/.lane/memory/apps/app/src/app.tsx/01M5AYXP2WM5WYKYA2RC58AAYC-each-capability-mirrors-a-ho.md index 68a48442..b337f86f 100644 --- a/.lane/memory/apps/app/src/app.tsx/01M5AYXP2WM5WYKYA2RC58AAYC-each-capability-mirrors-a-ho.md +++ b/.lane/memory/apps/app/src/app.tsx/01M5AYXP2WM5WYKYA2RC58AAYC-each-capability-mirrors-a-ho.md @@ -4,9 +4,9 @@ anchor: function row created: 2026-10-19T20:51:09Z norm: '1' sig: 3ee93dbf7c44f79a -body_hash: 1c77e647620585f1 -raw_hash: 25dda144b7871c5f -vouched: 2026-10-21T22:00:14Z +body_hash: 8365145a4d0f53de +raw_hash: c6795856e4c91aa5 +vouched: 2026-10-22T14:47:34Z lines: 120-170 --- diff --git a/.lane/memory/apps/app/src/attention.ts/01M4XQJSWS1YHDS9TRD27C41WG-told-persists-per-host-and-u.md b/.lane/memory/apps/app/src/attention.ts/01M4XQJSWS1YHDS9TRD27C41WG-told-persists-per-host-and-u.md index 94f9d393..b6514539 100644 --- a/.lane/memory/apps/app/src/attention.ts/01M4XQJSWS1YHDS9TRD27C41WG-told-persists-per-host-and-u.md +++ b/.lane/memory/apps/app/src/attention.ts/01M4XQJSWS1YHDS9TRD27C41WG-told-persists-per-host-and-u.md @@ -4,9 +4,9 @@ anchor: '@file' created: 2026-10-14T17:32:54Z norm: '1' sig: 9d222e7bbdcbbb9b -body_hash: 3aa62bf2730ffbd6 -raw_hash: 25ea69f781b64004 -vouched: 2026-10-15T17:31:19Z +body_hash: d991eadc415d5ec7 +raw_hash: 45c8c49ae096f57c +vouched: 2026-10-22T14:47:34Z lines: 1-102 --- diff --git a/.lane/memory/apps/app/src/dashboard.tsx/01M5AYX7CX6GW2PYNTV3BS5F91-with-no-usable-model-on-this.md b/.lane/memory/apps/app/src/dashboard.tsx/01M5AYX7CX6GW2PYNTV3BS5F91-with-no-usable-model-on-this.md index 50e9bee6..95018c9b 100644 --- a/.lane/memory/apps/app/src/dashboard.tsx/01M5AYX7CX6GW2PYNTV3BS5F91-with-no-usable-model-on-this.md +++ b/.lane/memory/apps/app/src/dashboard.tsx/01M5AYX7CX6GW2PYNTV3BS5F91-with-no-usable-model-on-this.md @@ -4,9 +4,9 @@ anchor: function Dashboard created: 2026-10-19T20:51:03Z norm: '1' sig: 4bfb45497157c0e0 -body_hash: 3517529f5c766be4 -raw_hash: 40843a80b4f5c8da -vouched: 2026-10-22T14:56:08Z +body_hash: d1696132fea7c765 +raw_hash: 907b4bdd8de26f76 +vouched: 2026-10-22T15:09:45Z lines: 256-606 --- diff --git a/.lane/memory/apps/host/src/catalog.ts/01M5J19G3V2MV9HP7BDEH6QX5F-mention-counts-only-grow-app.md b/.lane/memory/apps/host/src/catalog.ts/01M5J19G3V2MV9HP7BDEH6QX5F-mention-counts-only-grow-app.md new file mode 100644 index 00000000..35a51f83 --- /dev/null +++ b/.lane/memory/apps/host/src/catalog.ts/01M5J19G3V2MV9HP7BDEH6QX5F-mention-counts-only-grow-app.md @@ -0,0 +1,12 @@ +--- +id: 01M5J19G3V2MV9HP7BDEH6QX5F +anchor: function said +created: 2026-10-22T14:47:23Z +norm: '1' +sig: e80bd30ab53e049a +body_hash: ae83801c0d0d76e7 +raw_hash: c4ce2d878b06ba59 +lines: 285-290 +--- + +mention counts only grow; apps subtract the count they last saw, so resetting one hides or invents mentions diff --git a/.lane/memory/apps/host/src/gateway.ts/01M5AZ6NV8GAWASJKAVZPAMNM7-a-move-s-fence-names-local-c.md b/.lane/memory/apps/host/src/gateway.ts/01M5AZ6NV8GAWASJKAVZPAMNM7-a-move-s-fence-names-local-c.md index 89f9f69c..f3b4185b 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M5AZ6NV8GAWASJKAVZPAMNM7-a-move-s-fence-names-local-c.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M5AZ6NV8GAWASJKAVZPAMNM7-a-move-s-fence-names-local-c.md @@ -4,8 +4,9 @@ anchor: function local created: 2026-10-19T20:56:13Z norm: '1' sig: 2bb79c92d28ae467 -body_hash: fa355c5e44da2b1a -raw_hash: c5e60940ca9e03a5 +body_hash: 403aedf70502b8fa +raw_hash: 9a6b1610ac602b9d +vouched: 2026-10-22T14:47:34Z lines: 92-145 --- 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 4a22cb1b..e9d41f77 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: fa085b53cc7365ec -raw_hash: 40117df15a7c5ea7 -vouched: 2026-10-22T15:02:43Z +body_hash: 38cce3f1f25177dd +raw_hash: e92b21447ef6a159 +vouched: 2026-10-22T15:09:48Z 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 377e6a87..55d94b56 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: fa085b53cc7365ec -raw_hash: 40117df15a7c5ea7 -vouched: 2026-10-22T15:02:43Z +body_hash: 38cce3f1f25177dd +raw_hash: e92b21447ef6a159 +vouched: 2026-10-22T15:09:55Z lines: 1-1309 --- diff --git a/.lane/memory/apps/host/src/worker.ts/01M4YQXGS3RQSHX8N6GH99R7D4-the-directory-s-self-name-st.md b/.lane/memory/apps/host/src/worker.ts/01M4YQXGS3RQSHX8N6GH99R7D4-the-directory-s-self-name-st.md index eb56fef5..f5c9e29a 100644 --- a/.lane/memory/apps/host/src/worker.ts/01M4YQXGS3RQSHX8N6GH99R7D4-the-directory-s-self-name-st.md +++ b/.lane/memory/apps/host/src/worker.ts/01M4YQXGS3RQSHX8N6GH99R7D4-the-directory-s-self-name-st.md @@ -4,9 +4,9 @@ anchor: '@file' created: 2026-10-15T02:58:00Z norm: '1' sig: 1a43097a4e1ad24c -body_hash: b6bff4ee7aa001b5 -raw_hash: da64faa9eaa88a3e -vouched: 2026-10-20T17:05:11Z +body_hash: 3d68c7dfc3bf4c24 +raw_hash: eac2665348c286fe +vouched: 2026-10-22T14:47:34Z lines: 1-194 --- diff --git a/.lane/memory/apps/host/src/workspace.ts/01M4YZN8YAPCHNSDB68W52VXK5-staleness-counts-unanswered.md b/.lane/memory/apps/host/src/workspace.ts/01M4YZN8YAPCHNSDB68W52VXK5-staleness-counts-unanswered.md index 9f9c92c1..13aa03e5 100644 --- a/.lane/memory/apps/host/src/workspace.ts/01M4YZN8YAPCHNSDB68W52VXK5-staleness-counts-unanswered.md +++ b/.lane/memory/apps/host/src/workspace.ts/01M4YZN8YAPCHNSDB68W52VXK5-staleness-counts-unanswered.md @@ -4,8 +4,9 @@ anchor: function link created: 2026-10-15T05:13:18Z norm: '1' sig: 70a4266f20c7e0b6 -body_hash: ab38af677adfb446 -raw_hash: d6d6ef85bf34d26e +body_hash: 9c7b5f0af7b00b30 +raw_hash: b03cf6340d03ce58 +vouched: 2026-10-22T14:47:35Z lines: 24-115 --- 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 080b98f6..d187d2ef 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: 64e6dacda54276dc -raw_hash: 6d689f07f518e3a9 -vouched: 2026-10-22T15:03:17Z +body_hash: 197cae8f51ae874a +raw_hash: 18b03b0abb19ae39 +vouched: 2026-10-22T15:09:56Z 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 23738cab..5db475b9 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: 64e6dacda54276dc -raw_hash: 6d689f07f518e3a9 -vouched: 2026-10-22T15:03:17Z +body_hash: 197cae8f51ae874a +raw_hash: 18b03b0abb19ae39 +vouched: 2026-10-22T15:09:57Z 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 9ab111f8..87de043f 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: 64e6dacda54276dc -raw_hash: 6d689f07f518e3a9 -vouched: 2026-10-22T15:03:17Z +body_hash: 197cae8f51ae874a +raw_hash: 18b03b0abb19ae39 +vouched: 2026-10-22T15:09:58Z 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 57c3acf1..9aa80032 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: 64e6dacda54276dc -raw_hash: 6d689f07f518e3a9 -vouched: 2026-10-22T15:03:17Z +body_hash: 197cae8f51ae874a +raw_hash: 18b03b0abb19ae39 +vouched: 2026-10-22T15:09:58Z 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 359620a1..b6609259 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: 64e6dacda54276dc -raw_hash: 6d689f07f518e3a9 -vouched: 2026-10-22T15:03:17Z +body_hash: 197cae8f51ae874a +raw_hash: 18b03b0abb19ae39 +vouched: 2026-10-22T15:09:59Z lines: 127-873 --- diff --git a/apps/app/src/app.tsx b/apps/app/src/app.tsx index 3c924886..5d553d39 100644 --- a/apps/app/src/app.tsx +++ b/apps/app/src/app.tsx @@ -35,7 +35,7 @@ import { } from "@ace/host/protocol"; import { chosen, deployed, remember } from "./address"; -import { glow, type Outcome, useAttention } from "./attention"; +import { type Attention, glow, useAttention } from "./attention"; import { Channel, ChannelDetails, type ChannelDraft } from "./channel"; import { useCompute } from "./compute"; import { Dashboard } from "./dashboard"; @@ -135,8 +135,9 @@ function row( user: string, host: string, online: SidebarRow["online"], - outcome?: Outcome, + attention?: Attention, ): SidebarRow { + const outcome = attention?.mark; const place = placement(channel); // A host's channels are all its owner's, so this is also the host's owner-only check. const own = channel.owner === user && channel.host === host; @@ -175,9 +176,9 @@ function row( agent: channel.state === "running" && channel.busy && outcome !== "question" ? "thinking" : "idle", - unreadCount: outcome === "done" ? 1 : 0, + unreadCount: outcome === "done" || outcome === "message" ? 1 : 0, needsAttention: outcome === "failed" || outcome === "question", - mentionCount: 0, + mentionCount: attention?.mentions || 0, lastActivityAt: Math.floor((channel.active || channel.created) / 1000), online, }; @@ -199,7 +200,7 @@ function pinnedGroup( pins: string[], hello: Hello, collapsed: Partial>, - attention: Map, + attention: Map, online: (channel: Listing) => SidebarRow["online"], ): SessionSidebarGroup { const rows = pins.flatMap((id) => { @@ -228,7 +229,7 @@ function projectGroups( people: People, now: number, collapsed: Partial>, - attention: Map, + attention: Map, pins: string[], online: (channel: Listing) => SidebarRow["online"], ): SessionSidebarGroup[] { @@ -447,9 +448,12 @@ export function App() { removable: removable(value), })), ]; + const github = people[hello.user]; + const names = useMemo(() => github ? [github.toLowerCase()] : [], [github]); const { attention, pulse } = useAttention( channels, hello.user, + names, page === "channels" ? channel?.id : undefined, ); const online = (channel: Listing) => @@ -955,6 +959,7 @@ export function App() { user={hello.user} people={people} presence={presence[current.id]} + attention={attention} local={local} connected={connected} onSetup={!local && current.repo ? () => startSetup(current) : undefined} diff --git a/apps/app/src/attention.ts b/apps/app/src/attention.ts index 7057adcc..ebe84f7d 100644 --- a/apps/app/src/attention.ts +++ b/apps/app/src/attention.ts @@ -9,13 +9,25 @@ import { isFocused, subscribe } from "./focus"; import { host } from "./host"; /** - * Run ends this person has seen or been told about, by channel. Ends before `since` predate this - * browser profile's first visit, so history never lights up. + * 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. Anything before `since` predates this browser + * profile's first visit, so history never lights up. Profiles from before mentions have no `heard`. */ -type State = { since: number; seen: Record; told: Record }; +type State = { + since: number; + seen: Record; + told: Record; + heard?: Record; +}; export type Outcome = Exclude; +/** A run this person invoked, or `message` for anything anyone else wrote since they last looked. */ +export type Mark = Outcome | "message"; + +/** What a channel holds for this person since they last saw it. */ +export type Attention = { mark?: Mark; mentions: number }; + const NOTICES: Record = { done: "Agent finished", failed: "Agent run failed", @@ -25,20 +37,47 @@ const NOTICES: Record = { /** The pulse holds its glow this long, then keeps these props until it has faded out. */ export const glow = { hold: 1800, props: { glowOpacity: 0.6, glowFadeIn: 0.8, glowFadeOut: 1.3 } }; +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. */ function pending(channel: Listing, user: string, state: State): Outcome | undefined { const { ran } = channel; if (!ran || ran.author !== user || ran.outcome === "stopped") return; - if (ran.at <= Math.max(state.since, state.seen[channel.id] || 0)) return; + if (ran.at <= seenAt(channel, state)) return; return ran.outcome; } +function mark(channel: Listing, user: string, state: State): Mark | undefined { + const outcome = pending(channel, user, state); + if (outcome) return outcome; + const { said } = channel; + if (said && said.author !== user && said.at > seenAt(channel, state)) return "message"; +} + +/** Every mention of any of this person's names the channel's host has counted. */ +function mentioned(channel: Listing, names: string[]): number { + return names.reduce((total, name) => total + (channel.mentions?.[name] || 0), 0); +} + +/** A channel first met after `since` started after this profile did, so all its mentions are new. */ +function baseline(channel: Listing, names: string[], state: State): number { + return state.heard?.[channel.id] + ?? (channel.created > state.since ? 0 : mentioned(channel, names)); +} + /** * Runs this person invoked that ended, or that wait on a question, while they were not watching the * channel. Each pulses the window's glow or sends one desktop notification, then marks its row - * until they open it. + * until they open it. Others' messages and @mentions of `names` (lowercased GitHub logins) mark the + * row too, without a notification. */ -export function useAttention(channels: Listing[], user: string, open: string | undefined) { +export function useAttention( + channels: Listing[], + user: string, + names: string[], + open: string | undefined, +) { const [state, setState] = useLocalStorage( `ace:attention:${host.url}:${user}`, null, @@ -47,20 +86,26 @@ export function useAttention(channels: Listing[], user: string, open: string | u const [pulse, setPulse] = useState<"on" | "fading">(); useEffect(() => { - if (user && !state) setState({ since: Date.now(), seen: {}, told: {} }); + if (user && !state) setState({ since: Date.now(), seen: {}, told: {}, heard: {} }); }, [user, state, setState]); useEffect(() => { if (!user || !state) return; const seen: Record = {}; const told: Record = {}; + const heard: Record = {}; for (const channel of channels) { - const { id, ran } = channel; - if (!ran) continue; - if (watching && id === open) { - if ((state.seen[id] || 0) < ran.at) seen[id] = ran.at; + const { id, ran, said } = channel; + const looking = watching && id === open; + // Mentions are counts, not times, so each channel keeps the count it was last seen at. + const count = looking ? mentioned(channel, names) : baseline(channel, names, state); + if (state.heard?.[id] !== count) heard[id] = count; + if (looking) { + const last = Math.max(ran?.at || 0, said?.at || 0); + if ((state.seen[id] || 0) < last) seen[id] = last; continue; } + if (!ran) continue; const outcome = pending(channel, user, state); if (!outcome || ran.at <= (state.told[id] || 0)) continue; told[id] = ran.at; @@ -69,11 +114,18 @@ export function useAttention(channels: Listing[], user: string, open: string | u desktop?.notify(channel.name, NOTICES[outcome]).catch(() => {}); } } - if (!Object.keys(seen).length && !Object.keys(told).length) return; + if (!Object.keys(seen).length && !Object.keys(told).length && !Object.keys(heard).length) { + return; + } setState((value) => - value && { ...value, seen: { ...value.seen, ...seen }, told: { ...value.told, ...told } } + value && { + ...value, + seen: { ...value.seen, ...seen }, + told: { ...value.told, ...told }, + heard: { ...value.heard, ...heard }, + } ); - }, [channels, user, state, setState, watching, open]); + }, [channels, user, names, state, setState, watching, open]); useEffect(() => { if (!pulse) return; @@ -85,14 +137,15 @@ export function useAttention(channels: Listing[], user: string, open: string | u }, [pulse]); const attention = useMemo(() => { - const found = new Map(); + const found = new Map(); if (!state) return found; for (const channel of channels) { - const outcome = pending(channel, user, state); - if (outcome) found.set(channel.id, outcome); + const value = mark(channel, user, state); + const mentions = Math.max(0, mentioned(channel, names) - baseline(channel, names, state)); + if (value || mentions) found.set(channel.id, { mark: value, mentions }); } return found; - }, [channels, user, state]); + }, [channels, user, names, state]); return { attention, pulse }; } diff --git a/apps/app/src/dashboard.tsx b/apps/app/src/dashboard.tsx index 6f3509a1..e72a05ca 100644 --- a/apps/app/src/dashboard.tsx +++ b/apps/app/src/dashboard.tsx @@ -18,6 +18,7 @@ import { IconCloud, IconExternal, IconGlobe, + IconHand, IconHash, IconLaptop, IconPlus, @@ -26,6 +27,7 @@ import { import type { GithubList, ModelRef } from "@ace/channel/protocol"; import type { Listing, People, Presence } from "@ace/host/protocol"; +import type { Attention } from "./attention"; import { Notice } from "./composer"; import { desktop, here } from "./desktop"; import { prompt, StateIcon } from "./github"; @@ -248,6 +250,30 @@ function TeammateRow({ teammate, onChannel }: { ); } +/** The sidebar row's marks: a raised hand for a run that needs you, mentions, then unread. */ +function Marks({ attention }: { attention?: Attention }) { + if (!attention) return null; + const { mark, mentions } = attention; + return ( + + {(mark === "failed" || mark === "question") && ( + + )} + {mentions > 0 && ( + + {mentions} + + )} + {(mark === "done" || mark === "message") && ( + + )} + + ); +} + export function Dashboard( { project, @@ -256,6 +282,7 @@ export function Dashboard( user, people, presence, + attention, local, connected, onSetup, @@ -275,6 +302,7 @@ export function Dashboard( user: string; people: People; presence: Presence[] | undefined; + attention: Map; local: boolean; connected: boolean; /** Offered for a GitHub project with no checkout on this host. */ @@ -547,6 +575,7 @@ export function Dashboard( )} + {channel.state === "offline" && ( , + attention: Map, ): CommandPaletteItem[] { const names = new Map(projects.map((project) => [project.id, label(project, host)])); return channels.map((channel) => { const project = channelProject(channel); - const outcome = attention.get(channel.id); + const { mark, mentions } = attention.get(channel.id) || { mentions: 0 }; return { kind: "session", id: `channel:${channel.id}`, @@ -63,11 +63,13 @@ function channelItems( // Other projects' channels wait for a search so the current project's stay in reach. hiddenUntilSearch: project !== current, // A run waiting on a question is still busy, but it waits on them, as in the sidebar. - status: outcome === "question" || outcome === "failed" + status: mark === "question" || mark === "failed" ? "attention" + : mentions + ? "mention" : channel.state === "running" && channel.busy ? "busy" - : outcome === "done" + : mark ? "unread" : undefined, }; @@ -91,7 +93,7 @@ export function Palette({ channels, projects, host, project, attention, onChanne host: string; /** The current project's id. */ project?: string; - attention: Map; + attention: Map; onChannel: (channel: Listing) => void; /** Starts a channel in the current project, when one can start there. */ onCreate?: { name: string; run: () => void }; diff --git a/apps/app/src/timeline.ts b/apps/app/src/timeline.ts index 7211da3c..74dff326 100644 --- a/apps/app/src/timeline.ts +++ b/apps/app/src/timeline.ts @@ -1,11 +1,10 @@ -import type { QueuedMessage, Reactions } from "@ace/channel/protocol"; +import { MENTION, type QueuedMessage, type Reactions } from "@ace/channel/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 diff --git a/apps/host/src/catalog.ts b/apps/host/src/catalog.ts index 1013d172..e1023dce 100644 --- a/apps/host/src/catalog.ts +++ b/apps/host/src/catalog.ts @@ -12,7 +12,7 @@ import { import { userInfo } from "node:os"; import { join } from "node:path"; -import { type Metadata, type ModelRef, type RunEnd, validateName } from "@ace/channel"; +import { type Metadata, type ModelRef, type RunEnd, type Said, validateName } from "@ace/channel"; import { config } from "./config"; import { identity } from "./tailnet"; @@ -45,6 +45,10 @@ export type Listing = { busy?: boolean; /** The latest invoked run's end, a hint for its author's attention; pi keeps the run itself. */ ran?: RunEnd; + /** The latest message, a hint for teammates' attention like `ran`. */ + said?: { at: number; author: string }; + /** Messages that @mentioned each name since this record began counting; never reset. */ + mentions?: Record; archived?: boolean; /** Base URL of the service hosting the channel; this host serves its workspace unless `compute`. */ hosted?: string; @@ -278,6 +282,13 @@ export function ran(id: string, ran: RunEnd): void { write({ ...read(id), ran }); } +export function said(id: string, { at, author, mentions }: Said): void { + const record = read(id); + const counts = { ...record.mentions }; + for (const name of mentions || []) counts[name] = (counts[name] || 0) + 1; + write({ ...record, said: { at, author }, ...(mentions ? { mentions: counts } : {}) }); +} + function isListed(record: Listing): boolean { return !!tailnet && record.tailnet === tailnet; } diff --git a/apps/host/src/gateway.ts b/apps/host/src/gateway.ts index b993835f..312b17e9 100644 --- a/apps/host/src/gateway.ts +++ b/apps/host/src/gateway.ts @@ -121,6 +121,8 @@ async function local(): Promise { created, active, ran, + said, + mentions, hosted, } = record; const listing: Listing = { @@ -139,6 +141,8 @@ async function local(): Promise { }; if (active) listing.active = active; if (ran) listing.ran = ran; + if (said) listing.said = said; + if (mentions) listing.mentions = mentions; const { root, repo } = checkouts[index]!; listing.root = root; if (repo) listing.repo = repo; diff --git a/apps/host/src/protocol.ts b/apps/host/src/protocol.ts index c3a3d82e..524cb9b4 100644 --- a/apps/host/src/protocol.ts +++ b/apps/host/src/protocol.ts @@ -106,6 +106,10 @@ export type Listing = { busy?: boolean; /** The latest invoked run's end; absent on older hosts and before a channel's first one. */ ran?: RunEnd; + /** The latest message a person or another channel's agent wrote; absent on older hosts. */ + said?: { at: number; author: string }; + /** How many messages have @mentioned each lowercased name; absent on older hosts. */ + mentions?: Record; state: ChannelState; /** The hosting service's base URL, for a hosted channel; `host` is then its workspace. */ hosted?: string; diff --git a/apps/host/src/worker.ts b/apps/host/src/worker.ts index 90904d70..1c8727ef 100644 --- a/apps/host/src/worker.ts +++ b/apps/host/src/worker.ts @@ -61,6 +61,7 @@ const channel = await Channel.open({ onActivity: (at) => catalog.activity(id, at), onBusy: (busy) => catalog.busy(id, busy), onRun: (ran) => catalog.ran(id, ran), + onSaid: (said) => catalog.said(id, said), owner: record.owner, project: record.project, model: record.model, diff --git a/apps/host/src/workspace.ts b/apps/host/src/workspace.ts index dbb18379..36b8234c 100644 --- a/apps/host/src/workspace.ts +++ b/apps/host/src/workspace.ts @@ -84,6 +84,7 @@ function link(record: catalog.Listing): () => Promise { if ("active" in call) return catalog.activity(record.id, call.active); if ("busy" in call) return catalog.busy(record.id, call.busy); if ("ran" in call) return catalog.ran(record.id, call.ran); + if ("said" in call) return catalog.said(record.id, call.said); log("debug", "workspace.call", { channel: record.id, ...("cancel" in call diff --git a/packages/channel/src/channel.ts b/packages/channel/src/channel.ts index 09802540..1e90280a 100644 --- a/packages/channel/src/channel.ts +++ b/packages/channel/src/channel.ts @@ -79,8 +79,10 @@ import type { ModelRef, Request, RunEnd, + Said, Usage, } from "./protocol"; +import { MENTION } from "./protocol"; import { queue, withdrawn } from "./queue"; import * as room from "./room"; import { exportSeed, importSeed, type Seed } from "./seed"; @@ -141,6 +143,8 @@ export type Options = { onBusy?(busy: boolean): void; /** Publish each run a person invoked as it ends, for their attention. */ onRun?(run: RunEnd): void; + /** Publish each message people and other channels' agents write, for teammates' attention. */ + onSaid?(said: Said): void; log: Log; }; @@ -269,6 +273,7 @@ export class Channel { if (change.type === "entry" && change.value.model?.[0]?.role === "assistant") { channel.#caption(change.value.conversationId); } + if (change.type === "entry") channel.#said(change.value); if (change.type !== "document") continue; // Commit order, not request completion order, decides the state clients see. if (change.record.kind === SettingsDoc.definition.kind) { @@ -798,6 +803,23 @@ export class Channel { return { kind: "metadata", chat, ...this.#metadata, ...this.#settings }; } + #said(entry: EntryRecord): void { + const human = room.human(entry); + if (!human) return; + const names = [...human.text.matchAll(MENTION)].map((match) => match[1]!.toLowerCase()); + const mentions = [...new Set(names)]; + try { + this.#options.onSaid?.({ + chat: entry.conversationId, + at: human.at, + author: human.author, + ...(mentions.length ? { mentions } : {}), + }); + } catch (error) { + this.#log("warn", "said.publish", failure(error)); + } + } + #activity(): void { const now = Date.now(); if (now - this.#active < ACTIVITY_INTERVAL) return; diff --git a/packages/channel/src/protocol.ts b/packages/channel/src/protocol.ts index 00a40c4c..dd062ada 100644 --- a/packages/channel/src/protocol.ts +++ b/packages/channel/src/protocol.ts @@ -36,6 +36,15 @@ export type RunEnd = { author: string; }; +/** + * A message a person or another channel's agent wrote, published for teammates' attention. + * `mentions` are the lowercased names it @mentions, which people write as GitHub logins. + */ +export type Said = { chat: ChatId; at: number; author: string; mentions?: string[] }; + +/** An @mention in message text; the channel counts what the app links, so both use this one. */ +export const MENTION = /(?<=^|\s)@([a-zA-Z0-9_-]+)/g; + export type ChannelInfo = Metadata & { id: string; /** Directory of the project checkout lanes are created from. */ diff --git a/packages/channel/src/workspace.ts b/packages/channel/src/workspace.ts index e18a35dc..ab490b60 100644 --- a/packages/channel/src/workspace.ts +++ b/packages/channel/src/workspace.ts @@ -17,7 +17,7 @@ import { import { type Browser, type BrowserRequest, type BrowserResult, isBrowserAction } from "./browser"; import { type Desktop, type DesktopRequest, type DesktopResult, isDesktopAction } from "./desktop"; import type { ImageFile, Images } from "./images"; -import type { Metadata, RunEnd } from "./protocol"; +import type { Metadata, RunEnd, Said } from "./protocol"; declare function btoa(data: string): string; declare function atob(data: string): string; @@ -36,7 +36,8 @@ export type WorkspaceMessage = | { metadata: Metadata } | { active: number } | { busy: boolean } - | { ran: RunEnd }; + | { ran: RunEnd } + | { said: Said }; /** Workspace to channel. */ export type Reply = diff --git a/services/channel/src/object.ts b/services/channel/src/object.ts index d692194d..beff2e7f 100644 --- a/services/channel/src/object.ts +++ b/services/channel/src/object.ts @@ -154,6 +154,7 @@ export class HostedChannel extends DurableObject { onMetadata: (metadata) => this.#project({ metadata }), onActivity: (active) => this.#project({ active }), onRun: (ran) => this.#project({ ran }), + onSaid: (said) => this.#project({ said }), onBusy: (busy) => { this.#reside(busy); this.#project({ busy });