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
714import { Conversation } from "./conversation" ;
15+ import { forget , publish , useDetails } from "./details" ;
816import { host } from "./host" ;
917import { CHAT , Layout } from "./layout/layout" ;
1018import { Diff } from "./panels/diff" ;
1119import { 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.
1423const chat = 1 ;
@@ -24,8 +33,129 @@ type Props = {
2433 onSettings ?: ( ) => void ;
2534} ;
2635
36+ const LINK = / h t t p s ? : \/ \/ [ ^ \s < > ( ) [ \] { } " ' ` ] + / g;
37+ const GITHUB = / ^ h t t p s : \/ \/ g i t h u b \. c o m \/ ( [ ^ / ] + \/ [ ^ / ] + ) \/ ( i s s u e s | p u l l ) \/ ( \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+
27142export 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 (
0 commit comments