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 (
+
+
+
+ {summary && {summary}
}
+
+ {mentions.map((mention, i) => (
+ -
+
+
+ ))}
+ {runs.map((run, i) => {
+ let adds = run.files.reduce((sum, file) => sum + (file.adds || 0), 0);
+ let dels = run.files.reduce((sum, file) => sum + (file.dels || 0), 0);
+ return (
+ -
+
+
+ );
+ })}
+
+
+
+
+
+
+ );
+}
+
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,