diff --git a/apps/app/src/conversation.tsx b/apps/app/src/conversation.tsx index f98d1673..b68c1e3b 100644 --- a/apps/app/src/conversation.tsx +++ b/apps/app/src/conversation.tsx @@ -256,6 +256,7 @@ export function Conversation( working={transcript.busy || undefined} currentUser={{ login: user, own: [user] }} unread={first} + summary={channel.summary} mentioned={mentioned} actions={actions} onReact={(uid, emoji) => { diff --git a/packages/ui/src/components/timeline/timeline.tsx b/packages/ui/src/components/timeline/timeline.tsx index 8b810679..76eccd56 100644 --- a/packages/ui/src/components/timeline/timeline.tsx +++ b/packages/ui/src/components/timeline/timeline.tsx @@ -47,6 +47,8 @@ import { compile, type Group, isCommentChunk, + type Missed, + missed, type Row, type TimelineItem, type TimelineWorking, @@ -86,6 +88,7 @@ import { WORKING_SUMMARY_PAD_END, } from "./layout"; import { + IconBot, IconChevronDownMicro, IconCommit, IconEnter, @@ -94,7 +97,9 @@ import { IconPullRequest, IconRecord, IconTerminal, + IconX, } from "../../icons"; +import { Button } from "../../ui/button"; import "./timeline.css"; @@ -491,6 +496,107 @@ function AgentWorkingContextView( } /** Recaps are plain text, but models still wrap commands and paths in backticks. */ +const count = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`; + +/** Names up to three people, then how many more. */ +function names(people: string[]): string { + if (people.length <= 3) return people.join(", "); + return `${people.slice(0, 2).join(", ")} and ${people.length - 2} others`; +} + +type CatchUpProps = { + missed: Missed; + summary?: string; + onShow: (index: number) => void; + onDismiss: () => void; +}; + +/** What happened since the viewer last read the chat, over the top of the timeline. */ +function CatchUp({ missed, summary, onShow, onDismiss }: CatchUpProps) { + let { messages, people, mentions, runs } = missed; + let parts = [ + messages && `${count(messages, "message")} from ${names(people)}`, + runs.length && count(runs.length, "agent run"), + ].filter(Boolean); + return ( +
+
+
+
+

While you were away

+ {parts.length > 0 && ( +

{parts.join(" · ")}

+ )} +
+ +
+ {summary &&

{summary}

} + +
+ +
+
+
+ ); +} + function codeSpans(text: string) { return text.split(/`([^`]+)`/).map((part, i) => i % 2 ? {part} : part @@ -1155,8 +1261,13 @@ 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. */ + /** + * The first message the viewer hasn't seen, which a New divider sits above. A card over the + * top of the timeline sums up what followed, so the timeline's parent must be positioned. + */ unread?: string; + /** The channel's summary, which leads that card. */ + summary?: string; /** Messages that mention the viewer, which are highlighted. */ mentioned?: ReadonlySet; /** Called when the user clicks a rendered message link. */ @@ -1214,6 +1325,7 @@ function useTimeline( onReact, target, unread, + summary, onLink, openSender, onDocumentOpen, @@ -1256,6 +1368,15 @@ function useTimeline( project, unread, ]); + let [caughtUp, setCaughtUp] = useState(false); + let away = useMemo( + () => caughtUp ? undefined : missed(timeline, mentioned), + [caughtUp, timeline, mentioned], + ); + let show = (index: number) => { + scroll.current?.scrollTo(index); + setCaughtUp(true); + }; let finalWorkingKey = useMemo(() => finalWorkingIds(timeline).join("\0"), [timeline]); let visibleWorking = useMemo(() => { let next = new Set(openWorking); @@ -1595,6 +1716,10 @@ function useTimeline( hover: toolbar ? hover : null, onToolbarAction, onOpenChange, + away, + summary, + show, + dismiss: () => setCaughtUp(true), }; } @@ -1644,6 +1769,10 @@ function Timeline(input: TimelineProps) { hover, onToolbarAction, onOpenChange, + away, + summary, + show, + dismiss, } = useTimeline(input); return ( @@ -1765,6 +1894,7 @@ function Timeline(input: TimelineProps) { ); }} + {away && } ); diff --git a/packages/ui/src/lib/timeline.ts b/packages/ui/src/lib/timeline.ts index c9349987..0b1c8833 100644 --- a/packages/ui/src/lib/timeline.ts +++ b/packages/ui/src/lib/timeline.ts @@ -1190,7 +1190,60 @@ function compile( return items; } -export { compile, merge, structure }; +/** What arrived after the New divider, for a reader catching up. */ +type Missed = { + /** Index of the New divider. */ + index: number; + messages: number; + /** Who posted, in the order they first did. */ + people: string[]; + mentions: { index: number; who: string; text: string }[]; + /** Finished agent turns, with their recap or else the start of their answer. */ + runs: { index: number; text: string; files: EditedFile[]; pulls: Artifact[] }[]; +}; + +function excerpt(row: Row): string { + let chunk = row.chunks.find(chunk => chunk.kind === "text"); + return chunk?.kind === "text" ? chunk.raw.replace(/\s+/g, " ").trim() : ""; +} + +function missed(items: TimelineItem[], mentioned?: ReadonlySet): Missed | undefined { + let start = items.findIndex(item => item.kind === "new"); + if (start < 0) return; + let people = new Set(); + let result: Missed = { index: start, messages: 0, people: [], mentions: [], runs: [] }; + for (let index = start + 1; index < items.length; index++) { + let item = items[index]!; + if (item.kind !== "group") continue; + let { group, turn } = item; + let who = group.display || String(group.sender || ""); + if (group.role === "user") { + result.messages += group.rows.length; + people.add(who); + for (let row of group.rows) { + if (mentioned?.has(row.uid)) result.mentions.push({ index, who, text: excerpt(row) }); + } + continue; + } + if (!turn?.final) continue; + let text = turn.recap || excerpt(turn.final); + let pulls = turn.artifacts.filter(artifact => artifact.kind === "pull-request"); + let last = result.runs.at(-1); + // A run longer than a group window spans several groups, and each carries the run's recap. + if (last && turn.recap && last.text === turn.recap) { + last.index = index; + let paths = new Set(last.files.map(file => file.path)); + last.files.push(...turn.files.filter(file => !paths.has(file.path))); + last.pulls.push(...pulls); + continue; + } + result.runs.push({ index, text, files: [...turn.files], pulls }); + } + result.people = [...people]; + return result; +} + +export { compile, merge, missed, structure }; export type { AgentTurn, Artifact, @@ -1198,6 +1251,7 @@ export type { EditedFile, Group, ImageNode, + Missed, Row, TimelineItem, TimelineWorking,