Skip to content

Commit b15a08f

Browse files
committed
feat(app): populate the channel details sidebar
1 parent e6e5c36 commit b15a08f

13 files changed

Lines changed: 612 additions & 27 deletions

File tree

‎apps/app/src/channel.tsx‎

Lines changed: 156 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
1-
import { useCallback, useRef, useState } from "react";
1+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
22

3-
import type { Changes } from "@ace/channel/protocol";
4-
import type { Listing } from "@ace/host/protocol";
5-
import { SessionDetailsView, Sidebar, useLayoutRight } from "@ace/ui";
3+
import type { Changes, GithubPull } from "@ace/channel/protocol";
4+
import type { ChannelInfo, Listing } from "@ace/host/protocol";
5+
import {
6+
type DetailsFact,
7+
type DetailsLink,
8+
type DetailsSubagent,
9+
SessionDetailsView,
10+
Sidebar,
11+
useLayoutRight,
12+
} from "@ace/ui";
613

714
import { Conversation } from "./conversation";
15+
import { forget, publish, useDetails } from "./details";
816
import { host } from "./host";
917
import { CHAT, Layout } from "./layout/layout";
1018
import { Diff } from "./panels/diff";
1119
import { Terminal } from "./panels/terminal";
20+
import type { Item } from "./transcript";
1221

1322
// Channels have one chat today; its tabs follow the chat the Chat tab shows.
1423
const chat = 1;
@@ -24,8 +33,129 @@ type Props = {
2433
onSettings?: () => void;
2534
};
2635

36+
const LINK = /https?:\/\/[^\s<>()[\]{}"'`]+/g;
37+
const GITHUB = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/(issues|pull)\/(\d+)\/?$/;
38+
const compact = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 });
39+
const dollars = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
40+
const date = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" });
41+
// Open checks change on their own, so a visible pull request is read again this often.
42+
const PULL = 60_000;
43+
44+
/** Links people and agents wrote in the chat, newest mention first; tool output is left out. */
45+
function links(items: Item[], repo?: string): DetailsLink[] {
46+
const seen = new Map<string, DetailsLink>();
47+
for (const item of items) {
48+
for (const match of item.text.matchAll(LINK)) {
49+
const url = match[0].replace(/[.,;:!?*_]+$/, "");
50+
if (!URL.canParse(url)) continue;
51+
seen.delete(url);
52+
const github = GITHUB.exec(url);
53+
if (github) {
54+
const [, path, kind, number] = github;
55+
const label = `${path === repo ? "" : path}#${number}`;
56+
seen.set(url, { url, label, kind: kind === "pull" ? "pull" : "issue" });
57+
continue;
58+
}
59+
const { host, pathname, search } = new URL(url);
60+
seen.set(url, { url, label: `${host}${pathname.replace(/\/$/, "")}${search}`, kind: "web" });
61+
}
62+
}
63+
return [...seen.values()].toReversed();
64+
}
65+
66+
function subagents(items: Item[], busy: boolean): DetailsSubagent[] {
67+
return items.flatMap((item) =>
68+
item.kind === "reply"
69+
? item.tools.filter((tool) => tool.name === "subagent").map((tool) => {
70+
const args = tool.args as { task?: string; model?: string };
71+
const state: DetailsSubagent["state"] = tool.result
72+
? tool.result.error ? "failed" : "done"
73+
: busy && !item.stopped
74+
? "running"
75+
: "stopped";
76+
const task = args.task?.trim().split("\n")[0] || "Subagent";
77+
const model = args.model?.split("/").at(-1);
78+
return { id: tool.call, task, model, state };
79+
})
80+
: []
81+
);
82+
}
83+
84+
function facts(channel: Listing, info?: ChannelInfo): DetailsFact[] {
85+
const root = info?.chats.find((value) => value.id === chat);
86+
const model = root?.model || channel.model;
87+
const usage = info?.chats.reduce(
88+
(sum, value) => ({
89+
cost: sum.cost + (value.usage?.cost || 0),
90+
tokens: sum.tokens + (value.usage?.totalTokens || 0),
91+
}),
92+
{ cost: 0, tokens: 0 },
93+
);
94+
return [
95+
{
96+
label: "Project",
97+
value: channel.repo || channel.project.split("/").at(-1) || channel.project,
98+
title: channel.project,
99+
},
100+
{ label: "Host", value: channel.host },
101+
{ label: "Owner", value: channel.owner.split("@")[0]!, title: channel.owner },
102+
...(model
103+
? [{ label: "Model", value: model.modelId, title: `${model.provider}/${model.modelId}` }]
104+
: []),
105+
...(usage?.tokens
106+
? [
107+
{ label: "Tokens", value: compact.format(usage.tokens) },
108+
{ label: "Estimated cost", value: dollars.format(usage.cost) },
109+
]
110+
: []),
111+
{ label: "Created", value: date.format(channel.created) },
112+
];
113+
}
114+
115+
/** The newest pull request from the chat's branch, read again while it can be seen. */
116+
function usePull(
117+
repo: string | undefined,
118+
branch: string | undefined,
119+
head: string | undefined,
120+
open: boolean,
121+
) {
122+
const [pull, setPull] = useState<{ key: string; value: GithubPull | null }>();
123+
const key = repo && branch ? `${repo}\0${branch}` : undefined;
124+
useEffect(() => {
125+
if (!open || !repo || !branch) return;
126+
let active = true;
127+
const read = () =>
128+
host.request<GithubPull | null>({ op: "github-pull", repo, branch }).then(
129+
(value) => active && setPull({ key: `${repo}\0${branch}`, value }),
130+
() => {},
131+
);
132+
void read();
133+
const timer = setInterval(read, PULL);
134+
return () => {
135+
active = false;
136+
clearInterval(timer);
137+
};
138+
}, [open, repo, branch, head]);
139+
return pull && pull.key === key ? pull.value : undefined;
140+
}
141+
27142
export function ChannelDetails({ channel }: { channel: Listing }) {
28143
const right = useLayoutRight();
144+
const details = useDetails(channel.id);
145+
const { changes, info, items } = details;
146+
const busy = !!info?.chats.find((value) => value.id === chat)?.busy;
147+
const pull = usePull(channel.repo, changes?.branch, changes?.head, right.open);
148+
const totals = useMemo(() => {
149+
if (!changes) return;
150+
const sum = { adds: 0, dels: 0, files: changes.files.length };
151+
for (const file of changes.files) {
152+
sum.adds += file.adds;
153+
sum.dels += file.dels;
154+
}
155+
return sum;
156+
}, [changes]);
157+
const mentioned = useMemo(() => items && links(items, channel.repo), [items, channel.repo]);
158+
const agents = useMemo(() => items && subagents(items, busy), [items, busy]);
29159
return (
30160
<Sidebar
31161
side="right"
@@ -35,7 +165,17 @@ export function ChannelDetails({ channel }: { channel: Listing }) {
35165
className="-my-2 h-[calc(100%+1rem)]"
36166
innerClassName="h-full min-h-0"
37167
>
38-
<SessionDetailsView summary={channel.summary} onClose={() => right.setOpen(false)} />
168+
<SessionDetailsView
169+
summary={channel.summary}
170+
changes={totals}
171+
branch={changes?.branch}
172+
pull={pull}
173+
subagents={agents}
174+
links={mentioned?.filter((link) => link.url !== pull?.url)}
175+
facts={facts(channel, info)}
176+
onDiff={details.diff}
177+
onClose={() => right.setOpen(false)}
178+
/>
39179
</Sidebar>
40180
);
41181
}
@@ -54,7 +194,10 @@ export function Channel({ channel, user, remote, draft, onDraftLoaded, onSetting
54194
}
55195
run.busy = true;
56196
host.channel<Changes>(channel.id, { op: "changes", chat }).then(
57-
(value) => setChanged(value.files.length > 0),
197+
(changes) => {
198+
setChanged(changes.files.length > 0);
199+
publish(channel.id, { changes });
200+
},
58201
() => {},
59202
).finally(() => {
60203
run.busy = false;
@@ -64,12 +207,19 @@ export function Channel({ channel, user, remote, draft, onDraftLoaded, onSetting
64207
});
65208
}, [channel.id]);
66209

210+
const opener = useRef<() => void>(undefined);
211+
useEffect(() => {
212+
publish(channel.id, { diff: () => opener.current?.() });
213+
return () => forget(channel.id);
214+
}, [channel.id]);
215+
67216
return (
68217
<Layout
69218
id={channel.id}
70219
name={remote ? `${channel.name} · ${channel.host}` : channel.name}
71220
chat={chat}
72221
changed={changed}
222+
opener={opener}
73223
render={(data, uid, active, update) => {
74224
if (uid === CHAT) {
75225
return (

‎apps/app/src/conversation.tsx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Button, Timeline } from "@ace/ui";
44
import type { Listing } from "@ace/host/protocol";
55

66
import { Composer } from "./composer";
7+
import { publish } from "./details";
78
import { host } from "./host";
89
import { toEvents } from "./timeline";
910
import { useTranscript } from "./transcript";
@@ -57,6 +58,12 @@ export function Conversation(
5758
[transcript.items],
5859
);
5960

61+
// Replay delivers one event at a time; the sidebar reads the transcript once it is complete.
62+
const items = transcript.live ? transcript.items : undefined;
63+
useEffect(() => {
64+
publish(channel.id, { info: transcript.info, items });
65+
}, [channel.id, transcript.info, items]);
66+
6067
useEffect(() => {
6168
if (transcript.live) onWork?.();
6269
}, [transcript.live, transcript.busy, results, onWork]);

‎apps/app/src/details.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { useSyncExternalStore } from "react";
2+
3+
import type { Changes } from "@ace/channel/protocol";
4+
import type { ChannelInfo } from "@ace/host/protocol";
5+
6+
import type { Item } from "./transcript";
7+
8+
/** What an open channel's views know, shared with its details sidebar outside their tree. */
9+
export type Details = {
10+
info?: ChannelInfo;
11+
changes?: Changes;
12+
items?: Item[];
13+
/** Shows the chat's Diff tab, opening one when none is open. */
14+
diff?: () => void;
15+
};
16+
17+
const EMPTY: Details = {};
18+
const values = new Map<string, Details>();
19+
const listeners = new Set<() => void>();
20+
21+
function subscribe(listener: () => void) {
22+
listeners.add(listener);
23+
return () => void listeners.delete(listener);
24+
}
25+
26+
export function publish(channel: string, patch: Details) {
27+
values.set(channel, { ...values.get(channel), ...patch });
28+
for (const listener of listeners) listener();
29+
}
30+
31+
export function forget(channel: string) {
32+
values.delete(channel);
33+
for (const listener of listeners) listener();
34+
}
35+
36+
export function useDetails(channel: string): Details {
37+
return useSyncExternalStore(subscribe, () => values.get(channel) || EMPTY);
38+
}

‎apps/app/src/layout/layout.tsx‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ type Props = {
9393
render: Render;
9494
/** Notified when a tab is closed, so owners can release its resources. */
9595
onTabClose?: (uid: string, data: Content) => void;
96+
/** Receives the function that shows the chat's Diff tab, opening one beside the chat if needed. */
97+
opener?: { current?: () => void };
9698
};
9799

98100
const MIN = 350;
@@ -648,7 +650,7 @@ function Pane(props: PaneProps) {
648650
);
649651
}
650652

651-
function useLayout({ id, name, chat, changed, onTabClose }: Props) {
653+
function useLayout({ id, name, chat, changed, onTabClose, opener }: Props) {
652654
const [data] = useState(() => read(id));
653655
const [api] = useState(() => Split.create(data.state, { min: minimum }));
654656
const [meta, setMeta] = useState<Meta>(data.meta);
@@ -721,6 +723,23 @@ function useLayout({ id, name, chat, changed, onTabClose }: Props) {
721723
setMeta((meta) => ({ ...meta, [uid]: content("diff", chat) }));
722724
}, [api, chat, changed, diff, meta]);
723725

726+
const reveal = useEffectEvent(() => {
727+
const tabs = api.get().tabs;
728+
const uid = tabs.find((uid) => {
729+
const value = meta[uid];
730+
return value?.type === "diff" && value.chat === chat;
731+
});
732+
if (uid) return activate(uid);
733+
const pane = Split.host(api.get(), CHAT);
734+
if (pane) add(pane, "diff");
735+
});
736+
737+
useLayoutEffect(() => {
738+
if (!opener) return;
739+
opener.current = reveal;
740+
return () => void (opener.current = undefined);
741+
}, [opener]);
742+
724743
function nav(event: KeyboardEvent<HTMLElement>, pane: Split.Pane) {
725744
const tabs = pane.tabs;
726745
if (tabs.length < 2) return;

‎apps/host/src/gateway.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ const localOps = new Set<HostRequest["op"]>([
168168
"github-list",
169169
"github-detail",
170170
"github-files",
171+
"github-pull",
171172
"settings",
172173
"diagnostics",
173174
"key-set",
@@ -290,6 +291,8 @@ async function handle(
290291
return github.detail(request);
291292
case "github-files":
292293
return github.files(request.repo, request.number);
294+
case "github-pull":
295+
return github.pull(request.repo, request.branch);
293296
case "settings":
294297
return settings();
295298
case "diagnostics":

0 commit comments

Comments
 (0)