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
1 change: 1 addition & 0 deletions apps/app/src/conversation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
132 changes: 131 additions & 1 deletion packages/ui/src/components/timeline/timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ import {
compile,
type Group,
isCommentChunk,
type Missed,
missed,
type Row,
type TimelineItem,
type TimelineWorking,
Expand Down Expand Up @@ -86,6 +88,7 @@ import {
WORKING_SUMMARY_PAD_END,
} from "./layout";
import {
IconBot,
IconChevronDownMicro,
IconCommit,
IconEnter,
Expand All @@ -94,7 +97,9 @@ import {
IconPullRequest,
IconRecord,
IconTerminal,
IconX,
} from "../../icons";
import { Button } from "../../ui/button";

import "./timeline.css";

Expand Down Expand Up @@ -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 (
<div
className="pointer-events-none absolute inset-x-0 top-0 z-10 pt-2"
style={{ paddingInline: PAD }}
>
<section
aria-label="While you were away"
className="utils:max-width pointer-events-auto flex max-h-[min(24rem,60vh)] flex-col gap-2 rounded-xl border border-border/60 bg-background/95 p-3 text-sm shadow-lg backdrop-blur"
>
<header className="flex min-w-0 items-start gap-2">
<div className="min-w-0 flex-1">
<h2 className="font-medium">While you were away</h2>
{parts.length > 0 && (
<p className="text-xs text-muted-foreground">{parts.join(" · ")}</p>
)}
</div>
<Button variant="ghost" size="icon-sm" aria-label="Dismiss" onClick={onDismiss}>
<IconX />
</Button>
</header>
{summary && <p className="line-clamp-2 text-xs text-muted-foreground">{summary}</p>}
<ul className="-mx-1.5 flex min-h-0 flex-col overflow-y-auto">
{mentions.map((mention, i) => (
<li key={`m${i}`}>
<button
type="button"
onClick={() => onShow(mention.index)}
className="flex w-full min-w-0 items-baseline gap-2 rounded-md px-1.5 py-1 text-left hover:bg-muted"
>
<span className="shrink-0 rounded-full bg-accent px-1.5 text-[0.6875rem] font-medium leading-4 text-accent-foreground">
@
</span>
<span className="min-w-0 truncate">
<span className="font-medium">{mention.who}</span> mentioned you
{mention.text && <span className="text-muted-foreground">: {mention.text}</span>}
</span>
</button>
</li>
))}
{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 (
<li key={`r${i}`}>
<button
type="button"
onClick={() => onShow(run.index)}
className="flex w-full min-w-0 items-start gap-2 rounded-md px-1.5 py-1 text-left hover:bg-muted"
>
<IconBot className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" aria-hidden />
<span className="flex min-w-0 flex-col">
<span className="line-clamp-2">{codeSpans(run.text)}</span>
{(run.files.length > 0 || run.pulls.length > 0) && (
<span className="truncate text-xs text-muted-foreground">
{[
run.files.length
&& `${count(run.files.length, "file")} changed${
adds || dels ? ` +${adds} −${dels}` : ""
}`,
...run.pulls.map((pull) => `#${pull.number} ${pull.title}`),
].filter(Boolean).join(" · ")}
</span>
)}
</span>
</button>
</li>
);
})}
</ul>
<div className="flex">
<Button variant="secondary" size="sm" onClick={() => onShow(missed.index)}>
Jump to first unread
</Button>
</div>
</section>
</div>
);
}

function codeSpans(text: string) {
return text.split(/`([^`]+)`/).map((part, i) =>
i % 2 ? <code key={i} className="font-mono text-[0.92em]">{part}</code> : part
Expand Down Expand Up @@ -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<string>;
/** Called when the user clicks a rendered message link. */
Expand Down Expand Up @@ -1214,6 +1325,7 @@ function useTimeline(
onReact,
target,
unread,
summary,
onLink,
openSender,
onDocumentOpen,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1595,6 +1716,10 @@ function useTimeline(
hover: toolbar ? hover : null,
onToolbarAction,
onOpenChange,
away,
summary,
show,
dismiss: () => setCaughtUp(true),
};
}

Expand Down Expand Up @@ -1644,6 +1769,10 @@ function Timeline(input: TimelineProps) {
hover,
onToolbarAction,
onOpenChange,
away,
summary,
show,
dismiss,
} = useTimeline(input);
return (
<ExecActionProvider value={onExecAction ?? null}>
Expand Down Expand Up @@ -1765,6 +1894,7 @@ function Timeline(input: TimelineProps) {
);
}}
</ScrollView>
{away && <CatchUp missed={away} summary={summary} onShow={show} onDismiss={dismiss} />}
</ToolProvider>
</ExecActionProvider>
);
Expand Down
56 changes: 55 additions & 1 deletion packages/ui/src/lib/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1190,14 +1190,68 @@ 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<string>): Missed | undefined {
let start = items.findIndex(item => item.kind === "new");
if (start < 0) return;
let people = new Set<string>();
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,
Chunk,
EditedFile,
Group,
ImageNode,
Missed,
Row,
TimelineItem,
TimelineWorking,
Expand Down