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 31a9e928..6561a7b0 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: b0a39d06b7ed2900 -raw_hash: 2b2e4ccf7e3ee6f2 -vouched: 2026-10-22T20:29:47Z +body_hash: 543e14e26111c21c +raw_hash: 36221fa4f08e0523 +vouched: 2026-10-22T20:42:40Z lines: 235-824 --- diff --git a/.lane/memory/apps/app/src/app.tsx/01M5JMVG9G87F31FGVV571Y6BJ-the-project-list-is-built-on.md b/.lane/memory/apps/app/src/app.tsx/01M5JMVG9G87F31FGVV571Y6BJ-the-project-list-is-built-on.md index 43d32bee..36e47d2f 100644 --- a/.lane/memory/apps/app/src/app.tsx/01M5JMVG9G87F31FGVV571Y6BJ-the-project-list-is-built-on.md +++ b/.lane/memory/apps/app/src/app.tsx/01M5JMVG9G87F31FGVV571Y6BJ-the-project-list-is-built-on.md @@ -4,9 +4,9 @@ anchor: function App created: 2026-10-22T20:29:16Z norm: '1' sig: 063d0f00fea02511 -body_hash: b0a39d06b7ed2900 -raw_hash: 2b2e4ccf7e3ee6f2 -vouched: 2026-10-22T20:29:47Z +body_hash: 543e14e26111c21c +raw_hash: 36221fa4f08e0523 +vouched: 2026-10-22T20:42:40Z lines: 330-1237 --- diff --git a/.lane/memory/apps/app/src/attention.ts/01M5J49W4DPK93Q9SVS23VW59A-mention-counts-are-ignored-u.md b/.lane/memory/apps/app/src/attention.ts/01M5J49W4DPK93Q9SVS23VW59A-mention-counts-are-ignored-u.md index 82021425..3c35117a 100644 --- a/.lane/memory/apps/app/src/attention.ts/01M5J49W4DPK93Q9SVS23VW59A-mention-counts-are-ignored-u.md +++ b/.lane/memory/apps/app/src/attention.ts/01M5J49W4DPK93Q9SVS23VW59A-mention-counts-are-ignored-u.md @@ -4,9 +4,9 @@ anchor: function useAttention created: 2026-10-22T15:40:01Z norm: '1' sig: 764bb872be391a4f -body_hash: eaaf5ba9249c1195 -raw_hash: 9ac21a4b149b612a -vouched: 2026-10-22T20:27:44Z +body_hash: e8f4fd56e6b15297 +raw_hash: 3466b3d2a2a88367 +vouched: 2026-10-22T20:42:40Z lines: 100-195 --- diff --git a/.lane/memory/apps/app/src/attention.ts/01M5J49ZNB9XYK6GFYV3SBXKAP-told-and-called-persist-per.md b/.lane/memory/apps/app/src/attention.ts/01M5J49ZNB9XYK6GFYV3SBXKAP-told-and-called-persist-per.md index fcbf6acd..1e09b519 100644 --- a/.lane/memory/apps/app/src/attention.ts/01M5J49ZNB9XYK6GFYV3SBXKAP-told-and-called-persist-per.md +++ b/.lane/memory/apps/app/src/attention.ts/01M5J49ZNB9XYK6GFYV3SBXKAP-told-and-called-persist-per.md @@ -4,9 +4,9 @@ anchor: '@file' created: 2026-10-22T15:40:05Z norm: '1' sig: 9d222e7bbdcbbb9b -body_hash: 07a9546c62fc409f -raw_hash: e6e88b5690c07577 -vouched: 2026-10-22T20:27:44Z +body_hash: fba094c5942bd35d +raw_hash: 1ba221762e725614 +vouched: 2026-10-22T20:42:40Z lines: 1-195 supersedes: 01M4XQJSWS1YHDS9TRD27C41WG --- diff --git a/.lane/memory/packages/ui/src/components/timeline/layout.ts/01M53FDJQ4C0BGDGXNAY5GK09T-a-file-block-s-cache-key-inc.md b/.lane/memory/packages/ui/src/components/timeline/layout.ts/01M53FDJQ4C0BGDGXNAY5GK09T-a-file-block-s-cache-key-inc.md index 1d67066a..e99a0f9c 100644 --- a/.lane/memory/packages/ui/src/components/timeline/layout.ts/01M53FDJQ4C0BGDGXNAY5GK09T-a-file-block-s-cache-key-inc.md +++ b/.lane/memory/packages/ui/src/components/timeline/layout.ts/01M53FDJQ4C0BGDGXNAY5GK09T-a-file-block-s-cache-key-inc.md @@ -4,8 +4,9 @@ anchor: '@file' created: 2026-10-16T23:05:41Z norm: '1' sig: ce515648b5bedef2 -body_hash: fb0394625d6bac4e -raw_hash: 1a90d920d15ad713 +body_hash: a984910f8f8a9cdc +raw_hash: c0f95564f75550a7 +vouched: 2026-10-22T20:42:40Z lines: 1-912 --- diff --git a/apps/app/src/app.tsx b/apps/app/src/app.tsx index 4a329190..967ca5fe 100644 --- a/apps/app/src/app.tsx +++ b/apps/app/src/app.tsx @@ -509,7 +509,7 @@ export function App() { ]; const github = people[hello.user]; const names = useMemo(() => github ? [github.toLowerCase()] : [], [github]); - const { attention, pulse } = useAttention( + const { attention, pulse, seen } = useAttention( channels, hello.user, names, @@ -1054,6 +1054,7 @@ export function App() { channels={channels} user={hello.user} remote={channel.host !== hello.host} + seen={seen(channel)} draft={draft} onDraftLoaded={() => setDraft(undefined)} onProviders={() => setSettings("providers")} diff --git a/apps/app/src/attention.ts b/apps/app/src/attention.ts index 78ac846c..8f9c87aa 100644 --- a/apps/app/src/attention.ts +++ b/apps/app/src/attention.ts @@ -204,5 +204,8 @@ export function useAttention( return found; }, [channels, user, names, state]); - return { attention, pulse }; + /** When this person last saw a channel; undefined before this profile's first visit. */ + const seen = (channel: Listing) => state ? seenAt(channel, state) : undefined; + + return { attention, pulse, seen }; } diff --git a/apps/app/src/channel.tsx b/apps/app/src/channel.tsx index 155dc6dd..d1b0bda5 100644 --- a/apps/app/src/channel.tsx +++ b/apps/app/src/channel.tsx @@ -45,6 +45,8 @@ type Props = { channels: Listing[]; user: string; remote: boolean; + /** When this person last saw the channel, before opening it now. */ + seen?: number; draft?: ChannelDraft; onDraftLoaded: () => void; onProviders?: () => void; @@ -301,9 +303,11 @@ export function ChannelDetails({ channel, user }: { channel: Listing; user: stri /** A channel's tabs: its chat, plus Diff and Terminal views of that chat's lane. */ export function Channel( - { channel, channels, user, remote, draft, onDraftLoaded, onProviders, onOpen }: Props, + { channel, channels, user, remote, seen, draft, onDraftLoaded, onProviders, onOpen }: Props, ) { const place = placement(channel); + // Taken as the channel opens: opening it marks everything seen, and later messages arrive in view. + const [unread] = useState(() => ({ since: seen, until: channel.said?.at })); const [changed, setChanged] = useState(); const check = useRef({ busy: false, again: false }); @@ -367,6 +371,7 @@ export function Channel( channels={channels} chat={chat} user={user} + unread={unread} draft={draft?.channel === channel.id ? draft.text : undefined} onDraftLoaded={onDraftLoaded} onProviders={!remote && !channel.hosted ? onProviders : undefined} diff --git a/apps/app/src/conversation.tsx b/apps/app/src/conversation.tsx index 9e011818..f98d1673 100644 --- a/apps/app/src/conversation.tsx +++ b/apps/app/src/conversation.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState, useSyncExternalStore } from "react"; +import { MENTION } from "@ace/channel/protocol"; import { Button, ChatStatus, Timeline, toast } from "@ace/ui"; import { channelProject, type Listing } from "@ace/host/protocol"; @@ -26,6 +27,8 @@ type Props = { channels: Listing[]; chat: number; user: string; + /** Others' messages after `since` and up to `until` arrived while this person was away. */ + unread?: { since?: number; until?: number }; draft?: string; onDraftLoaded: () => void; onProviders?: () => void; @@ -36,7 +39,18 @@ type Props = { /** One chat's timeline and composer over its transcript; the content of a Chat tab. */ export function Conversation( - { channel, channels, chat: id, user, draft, onDraftLoaded, onProviders, onOpen, onWork }: Props, + { + channel, + channels, + chat: id, + user, + unread, + draft, + onDraftLoaded, + onProviders, + onOpen, + onWork, + }: Props, ) { const offline = channel.state === "offline"; const placement = channel.hosted || channel.host; @@ -116,6 +130,28 @@ export function Conversation( () => new Map(transcript.items.map((item) => [item.key, item])), [transcript.items], ); + const { since, until = 0 } = unread || {}; + const first = since === undefined + ? undefined + : transcript.items.find((item) => + item.kind === "message" && item.author !== user && item.at > since && item.at <= until + )?.key; + // Mentions name people by GitHub login, as the channel's host counts them. + const self = people[user]?.toLowerCase(); + const mentioned = useMemo( + () => + new Set( + self + ? transcript.items.flatMap((item) => + item.kind === "message" && item.author !== user && !item.deleted + && [...item.text.matchAll(MENTION)].some((match) => match[1]!.toLowerCase() === self) + ? [item.key] + : [] + ) + : [], + ), + [transcript.items, user, self], + ); // Reactions and deletions target stored entries; the streamed draft and tool-only replies have none. const actions = (uid: string) => { const item = byKey.get(uid); @@ -219,6 +255,8 @@ export function Conversation( events={events} working={transcript.busy || undefined} currentUser={{ login: user, own: [user] }} + unread={first} + mentioned={mentioned} actions={actions} onReact={(uid, emoji) => { const entry = byKey.get(uid)!.entry!; diff --git a/packages/ui/src/components/timeline/layout.ts b/packages/ui/src/components/timeline/layout.ts index f8cbdbc1..9fc92600 100644 --- a/packages/ui/src/components/timeline/layout.ts +++ b/packages/ui/src/components/timeline/layout.ts @@ -590,7 +590,7 @@ async function parseBlockMap( }; for (let item of timeline) { - if (item.kind === "day") continue; + if (item.kind === "day" || item.kind === "new") continue; if (item.kind === "event") { jobs.push(convert(item.row.chunks, item.row.uid, eventTextCompact(item.row.chunks))); continue; @@ -666,7 +666,7 @@ function useBlockMap( }; for (let item of timeline) { - if (item.kind === "day") continue; + if (item.kind === "day" || item.kind === "new") continue; if (item.kind === "event") { map.set( item.row.uid, @@ -742,7 +742,7 @@ function itemHeight( open: Set = new Set(), openWorking: Set = new Set(), ): number { - if (item.kind === "day") return DAY_HEIGHT; + if (item.kind === "day" || item.kind === "new") return DAY_HEIGHT; if (item.kind === "event") { let b = blocks.get(item.row.uid); let content = width - PAD * 2 - AVATAR - AVATAR_GAP; diff --git a/packages/ui/src/components/timeline/timeline.tsx b/packages/ui/src/components/timeline/timeline.tsx index 1da4eaec..bd8a21cd 100644 --- a/packages/ui/src/components/timeline/timeline.tsx +++ b/packages/ui/src/components/timeline/timeline.tsx @@ -610,6 +610,7 @@ type GroupProps = { editing?: string; unsent?: ReadonlyMap; editable?: ReadonlySet; + mentioned?: ReadonlySet; openGroups: Set; openWorking: Set; settlingWorking: Set; @@ -960,8 +961,18 @@ function GroupTurn({ props, state, toolbar }: GroupRender & { toolbar: RowToolba } function GroupRows({ props, state, toolbar }: GroupRender & { toolbar: RowToolbar }) { - let { group, blocks, editing, unsent, target, onRowEnter, currentUser, onReact, onOpenChange } = - props; + let { + group, + blocks, + editing, + unsent, + mentioned, + target, + onRowEnter, + currentUser, + onReact, + onOpenChange, + } = props; let { content, mine } = state; let isUser = group.role === "user"; @@ -1032,9 +1043,15 @@ function GroupRows({ props, state, toolbar }: GroupRender & { toolbar: RowToolba background: isBubble ? target === uid && isUser ? "color-mix(in oklch, var(--accent) 75%, transparent)" + : mentioned?.has(uid) + ? "color-mix(in oklch, var(--accent) 25%, var(--muted))" : "var(--muted)" : "transparent", - boxShadow: tone ? `inset 3px 0 0 ${tone}` : undefined, + boxShadow: tone + ? `inset 3px 0 0 ${tone}` + : mentioned?.has(uid) + ? "inset 3px 0 0 var(--accent)" + : undefined, // Outlines never affect measured layout. outline: state === "failed" ? "1px solid var(--destructive)" : undefined, padding: isBubble ? `${BUBBLE_PAD_Y}px ${BUBBLE_PAD_X}px` : 0, @@ -1113,6 +1130,10 @@ type TimelineProps = onReact?: (uid: string, emoji: string) => void; /** Message UID to scroll into view. */ target?: string; + /** The first message the viewer hasn't seen, which a New divider sits above. */ + unread?: string; + /** Messages that mention the viewer, which are highlighted. */ + mentioned?: ReadonlySet; /** Called when the user clicks a rendered message link. */ onLink?: (href: string) => void; /** How to open a sender whose name links somewhere, such as the channel that sent it. */ @@ -1163,9 +1184,11 @@ function useTimeline( alignment = "left", editing, unsent, + mentioned, editable, onReact, target, + unread, onLink, openSender, onDocumentOpen, @@ -1202,7 +1225,12 @@ function useTimeline( let seenFinalWorking = useRef | null>(null); let point = useRef<{ x: number; y: number } | null>(null); let suppress = useRef(false); - let timeline = useMemo(() => compile(events, working, project), [events, working, project]); + let timeline = useMemo(() => compile(events, working, project, unread), [ + events, + working, + project, + unread, + ]); let finalWorkingKey = useMemo(() => finalWorkingIds(timeline).join("\0"), [timeline]); let visibleWorking = useMemo(() => { let next = new Set(openWorking); @@ -1524,6 +1552,7 @@ function useTimeline( target, editing, unsent, + mentioned, editable, openWorking, settlingWorking, @@ -1573,6 +1602,7 @@ function Timeline(input: TimelineProps) { target, editing, unsent, + mentioned, editable, openWorking, settlingWorking, @@ -1636,6 +1666,19 @@ function Timeline(input: TimelineProps) { ); } + if (item.kind === "new") { + return ( +
+
+ New +
+ ); + } if (item.kind === "event") { let content = lane - PAD * 2 - AVATAR - AVATAR_GAP; return ( @@ -1675,6 +1718,7 @@ function Timeline(input: TimelineProps) { editing={editing} unsent={unsent} editable={editable} + mentioned={mentioned} openGroups={openGroups} openWorking={openWorking} settlingWorking={settlingWorking} diff --git a/packages/ui/src/lib/timeline.ts b/packages/ui/src/lib/timeline.ts index 9d90dac1..c9349987 100644 --- a/packages/ui/src/lib/timeline.ts +++ b/packages/ui/src/lib/timeline.ts @@ -182,7 +182,9 @@ type AgentTurn = { type TimelineItem = | { kind: "group"; group: Group; turn?: AgentTurn } | { kind: "event"; row: Row } - | { kind: "day"; ts: number }; + | { kind: "day"; ts: number } + /** Where messages the viewer hasn't seen start. */ + | { kind: "new" }; // -- Constants -- @@ -1084,6 +1086,7 @@ function compile( events: Event[], working: TimelineWorking = false, project?: REPO, + unread?: string, ): TimelineItem[] { let items: TimelineItem[] = []; let current: { group: Group; ts: number } | null = null; @@ -1118,6 +1121,11 @@ function compile( lastDay = day; } + if (row.uid === unread) { + flush(false); + items.push({ kind: "new" }); + } + // System events always break the current group and stand alone. if (row.role === "system") { flush(false);