Skip to content

Commit 46c53c3

Browse files
committed
fix(chat): improve realtime progress history
1 parent 473ffa4 commit 46c53c3

20 files changed

Lines changed: 717 additions & 143 deletions

src/client/RelayApp.svelte

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -921,6 +921,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later
921921
<MessageList
922922
messages={chatView ? [...chatView.messages] : []}
923923
activities={chatView ? [...chatView.activities] : []}
924+
activeTurnId={chatView?.activeTurnId ?? null}
924925
interactions={chatView ? [...chatView.interactions] : []}
925926
answers={userInputAnswers}
926927
onanswer={setUserInputAnswer}

src/client/features/chat/ActivityList.svelte

Lines changed: 55 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,53 +7,83 @@ SPDX-License-Identifier: AGPL-3.0-or-later
77
<script lang="ts">
88
import type { HistoryActivity } from './activity-summary.js';
99
import { presentActivity } from './activity-presentation.js';
10-
let { activities }: { activities: HistoryActivity[] } = $props();
10+
let {
11+
activities,
12+
variant = 'summary',
13+
}: { activities: HistoryActivity[]; variant?: 'live' | 'summary' } = $props();
1114
1215
let visibleActivities = $derived(
1316
activities.flatMap((activity) => {
1417
const presentation = presentActivity(activity);
15-
return presentation ? [{ id: activity.id, ...presentation }] : [];
18+
return presentation && presentation.kind.toLowerCase().replaceAll(' ', '') !== 'filechange'
19+
? [{ id: activity.id, ...presentation }]
20+
: [];
1621
}),
1722
);
18-
let hasInProgressActivity = $derived(
19-
visibleActivities.some(
20-
(activity) => activity.status !== 'completed' && activity.status !== 'failed',
21-
),
22-
);
2323
</script>
2424

2525
{#if visibleActivities.length}
26-
<details id="chat-activity" open={hasInProgressActivity}>
27-
<summary>activity</summary>
28-
<ul class="activity-list">
29-
{#each visibleActivities as activity (activity.id)}
30-
<li
31-
class="activity-row"
32-
data-activity-kind={activity.kind.toLowerCase().replaceAll(' ', '-')}
33-
data-activity-status={activity.status}
34-
>
35-
<small class="activity-type"
36-
>{activity.kind}{activity.status ? ` · ${activity.status}` : ''}</small
26+
{#if variant === 'summary'}
27+
<details class="chat-activity">
28+
<summary>activity <span aria-hidden="true">· {visibleActivities.length}</span></summary>
29+
<ul class="activity-list">
30+
{#each visibleActivities as activity (activity.id)}
31+
<li
32+
class="activity-row"
33+
data-activity-kind={activity.kind.toLowerCase().replaceAll(' ', '-')}
34+
data-activity-status={activity.status}
35+
>
36+
<small class="activity-type"
37+
>{activity.kind}{activity.status ? ` · ${activity.status}` : ''}</small
38+
>
39+
<span class="activity-content">{activity.content}</span>
40+
</li>
41+
{/each}
42+
</ul>
43+
</details>
44+
{:else}
45+
<section class="live-activity" aria-label="Current activity">
46+
<strong>activity</strong>
47+
<ul class="activity-list">
48+
{#each visibleActivities as activity (activity.id)}
49+
<li
50+
class="activity-row"
51+
data-activity-kind={activity.kind.toLowerCase().replaceAll(' ', '-')}
52+
data-activity-status={activity.status}
3753
>
38-
<span class="activity-content">{activity.content}</span>
39-
</li>
40-
{/each}
41-
</ul>
42-
</details>
54+
<small class="activity-type"
55+
>{activity.kind}{activity.status ? ` · ${activity.status}` : ''}</small
56+
>
57+
<span class="activity-content">{activity.content}</span>
58+
</li>
59+
{/each}
60+
</ul>
61+
</section>
62+
{/if}
4363
{/if}
4464

4565
<style>
46-
#chat-activity {
66+
.chat-activity,
67+
.live-activity {
4768
--activity-gap: 0.4rem;
4869
--activity-type-color: var(--theme-text-muted);
4970
}
5071
51-
#chat-activity > summary {
72+
.chat-activity > summary {
5273
color: var(--activity-type-color);
5374
font-size: 0.875em;
5475
cursor: pointer;
5576
}
5677
78+
.live-activity {
79+
margin-block-start: 0.5rem;
80+
}
81+
82+
.live-activity > strong {
83+
color: var(--activity-type-color);
84+
font-size: 0.875em;
85+
}
86+
5787
.activity-list {
5888
display: grid;
5989
gap: var(--activity-gap);

src/client/features/chat/MessageList.svelte

Lines changed: 147 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later
1717
type Props = {
1818
messages: ChatMessage[];
1919
activities: HistoryActivity[];
20+
activeTurnId?: string | null;
2021
interactions?: ProjectedInteraction[];
2122
answers?: Record<string, string>;
2223
onanswer?(requestId: string, id: string, value: string): void;
@@ -28,6 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later
2829
let {
2930
messages,
3031
activities,
32+
activeTurnId = null,
3133
interactions = [],
3234
answers = {},
3335
onanswer = () => {},
@@ -38,10 +40,18 @@ SPDX-License-Identifier: AGPL-3.0-or-later
3840
}: Props = $props();
3941
let groups = $derived(groupMessages(messages));
4042
let expandedCommentary = $state<Record<string, boolean>>({});
41-
let latestAnswerId = $derived(
42-
groups.findLast((group) => group.kind === 'assistant' && group.answer)?.id,
43-
);
4443
let promptGroups = $derived(groups.filter((group) => group.kind === 'user'));
44+
let assistantGroups = $derived(groups.filter((group) => group.kind === 'assistant'));
45+
let latestAssistantId = $derived(assistantGroups.at(-1)?.id);
46+
let hasActiveAssistant = $derived(
47+
Boolean(
48+
activeTurnId &&
49+
assistantGroups.some(
50+
(group) =>
51+
group.turnId === activeTurnId || (!group.turnId && group.id === latestAssistantId),
52+
),
53+
),
54+
);
4555
let unassignedInteractions = $derived(
4656
interactions.filter(
4757
(interaction) =>
@@ -59,6 +69,36 @@ SPDX-License-Identifier: AGPL-3.0-or-later
5969
if (group.kind !== 'user') return [];
6070
return interactions.filter((interaction) => ownerGroupId(interaction) === group.id);
6171
}
72+
function turnActivities(group: (typeof groups)[number]): HistoryActivity[] {
73+
if (group.kind !== 'assistant') return [];
74+
return activities.filter((activity) =>
75+
activity.turnId ? activity.turnId === group.turnId : group.id === latestAssistantId,
76+
);
77+
}
78+
function currentActivities(): HistoryActivity[] {
79+
return activities.filter((activity) =>
80+
activity.turnId ? activity.turnId === activeTurnId : true,
81+
);
82+
}
83+
function regularActivities(items: HistoryActivity[]): HistoryActivity[] {
84+
return items.filter(
85+
(activity) => !activity.label.toLowerCase().replaceAll(' ', '').startsWith('filechange'),
86+
);
87+
}
88+
function fileChanges(items: HistoryActivity[]): Array<{ id: string; paths: string[] }> {
89+
return items.flatMap((activity) =>
90+
activity.label.toLowerCase().replaceAll(' ', '').startsWith('filechange')
91+
? [{ id: activity.id, paths: activity.detail.split('\n').filter(Boolean) }]
92+
: [],
93+
);
94+
}
95+
function isLive(group: (typeof groups)[number]): boolean {
96+
return Boolean(
97+
group.kind === 'assistant' &&
98+
activeTurnId &&
99+
(group.turnId === activeTurnId || (!group.turnId && group.id === latestAssistantId)),
100+
);
101+
}
62102
</script>
63103

64104
{#snippet inline(parts: CommentaryPart[])}
@@ -73,6 +113,22 @@ SPDX-License-Identifier: AGPL-3.0-or-later
73113
{/each}
74114
{/snippet}
75115

116+
{#snippet changedFiles(items: HistoryActivity[])}
117+
{@const changes = fileChanges(items)}
118+
{#if changes.length}
119+
<section class="file-changes" aria-label="Files changed">
120+
<strong>files changed</strong>
121+
<ul>
122+
{#each changes as change (change.id)}
123+
{#each change.paths as path (`${change.id}:${path}`)}
124+
<li><code>{path}</code></li>
125+
{/each}
126+
{/each}
127+
</ul>
128+
</section>
129+
{/if}
130+
{/snippet}
131+
76132
{#snippet content(text: string)}
77133
{#each renderCommentary(text) as block, blockIndex (blockIndex)}
78134
{#if block.kind === 'code'}
@@ -129,24 +185,11 @@ SPDX-License-Identifier: AGPL-3.0-or-later
129185
{ondecision}
130186
{onretry}
131187
/>
132-
{:else if group.answer}
188+
{:else if group.answer !== null}
189+
{@const ownedActivities = turnActivities(group)}
133190
<section class="answer-turn">
134191
<div class="entry-heading">
135-
<strong>answer</strong>
136-
{#if group.commentary}
137-
<button
138-
class="commentary-toggle"
139-
type="button"
140-
aria-expanded={Boolean(expandedCommentary[group.id])}
141-
aria-controls={`commentary-${group.id}`}
142-
onclick={() => (expandedCommentary[group.id] = !expandedCommentary[group.id])}
143-
>
144-
<span aria-hidden="true">{expandedCommentary[group.id] ? '' : '>'}</span>commentary
145-
</button>
146-
{/if}
147-
{#if group.id === latestAnswerId}
148-
<ActivityList {activities} />
149-
{/if}
192+
<strong>{isLive(group) ? 'working' : 'answer'}</strong>
150193
{#if group.occurredAt}
151194
<time datetime={new Date(group.occurredAt).toISOString()}>
152195
{formatMessageTime(group.occurredAt)}
@@ -156,22 +199,57 @@ SPDX-License-Identifier: AGPL-3.0-or-later
156199
</time>
157200
{/if}
158201
</div>
159-
{#if group.commentary && expandedCommentary[group.id]}
202+
{#if isLive(group)}
203+
{#if group.commentary}
204+
<div class="commentary-content live-commentary">
205+
{@render content(group.commentary)}
206+
</div>
207+
{/if}
208+
<ActivityList activities={regularActivities(ownedActivities)} variant="live" />
209+
{:else if group.commentary || regularActivities(ownedActivities).length}
210+
<div class="answer-history">
211+
{#if group.commentary}
212+
<button
213+
class="commentary-toggle"
214+
type="button"
215+
aria-expanded={Boolean(expandedCommentary[group.id])}
216+
aria-controls={`commentary-${group.id}`}
217+
onclick={() => (expandedCommentary[group.id] = !expandedCommentary[group.id])}
218+
>
219+
<span aria-hidden="true">{expandedCommentary[group.id] ? '' : ''}</span>
220+
commentary
221+
</button>
222+
{/if}
223+
<ActivityList activities={regularActivities(ownedActivities)} />
224+
</div>
225+
{/if}
226+
{#if !isLive(group) && group.commentary && expandedCommentary[group.id]}
160227
<div class="commentary-content" id={`commentary-${group.id}`}>
161228
{@render content(group.commentary)}
162229
</div>
163230
{/if}
231+
{@render changedFiles(ownedActivities)}
164232
<div class="entry-content">{@render content(group.answer)}</div>
165233
</section>
166-
{:else if group.commentary}
167-
<section class="commentary-turn">
234+
{:else if group.commentary !== null}
235+
{@const ownedActivities = turnActivities(group)}
236+
<section class={isLive(group) ? 'progress-turn' : 'commentary-turn'}>
168237
<div class="entry-heading">
238+
<strong>{isLive(group) ? 'working' : 'commentary'}</strong>
239+
</div>
240+
{#if isLive(group)}
241+
{#if group.commentary}
242+
<div class="entry-content">{@render content(group.commentary)}</div>
243+
{/if}
244+
<ActivityList activities={regularActivities(ownedActivities)} variant="live" />
245+
{:else}
169246
<details>
170247
<summary>commentary</summary>
171248
{@render content(group.commentary)}
172249
</details>
173-
<ActivityList {activities} />
174-
</div>
250+
<ActivityList activities={regularActivities(ownedActivities)} />
251+
{/if}
252+
{@render changedFiles(ownedActivities)}
175253
</section>
176254
{/if}
177255
</li>
@@ -189,6 +267,15 @@ SPDX-License-Identifier: AGPL-3.0-or-later
189267
/>
190268
</li>
191269
{/if}
270+
{#if activeTurnId && !hasActiveAssistant && currentActivities().length}
271+
<li class="progress-item">
272+
<section class="progress-turn">
273+
<div class="entry-heading"><strong>working</strong></div>
274+
<ActivityList activities={regularActivities(currentActivities())} variant="live" />
275+
{@render changedFiles(currentActivities())}
276+
</section>
277+
</li>
278+
{/if}
192279
</ol>
193280

194281
<style>
@@ -224,13 +311,22 @@ SPDX-License-Identifier: AGPL-3.0-or-later
224311
}
225312
226313
.commentary-turn,
314+
.progress-turn,
227315
.commentary-content {
228316
padding: 0.5rem 0.625rem;
229317
background: var(--theme-surface-subtle);
230318
border-inline-start: 0.25rem solid var(--theme-info);
231319
border-radius: 0.375rem;
232320
}
233321
322+
.live-commentary {
323+
background: transparent;
324+
}
325+
326+
.progress-turn {
327+
background: transparent;
328+
}
329+
234330
.entry-heading {
235331
display: flex;
236332
align-items: baseline;
@@ -253,6 +349,33 @@ SPDX-License-Identifier: AGPL-3.0-or-later
253349
font-size: 0.875em;
254350
}
255351
352+
.answer-history {
353+
display: flex;
354+
flex-wrap: wrap;
355+
align-items: baseline;
356+
gap: 0.25rem 0.75rem;
357+
margin-block: 0.2rem 0.35rem;
358+
}
359+
360+
.file-changes {
361+
margin-block: 0.5rem;
362+
padding: 0.5rem 0.625rem;
363+
background: var(--theme-surface-subtle);
364+
border-inline-start: 0.25rem solid var(--theme-success);
365+
border-radius: 0.375rem;
366+
}
367+
368+
.file-changes > strong {
369+
font-size: 0.875em;
370+
}
371+
372+
.file-changes ul {
373+
display: grid;
374+
gap: 0.15rem;
375+
margin: 0.25rem 0 0;
376+
padding-inline-start: 1.25rem;
377+
}
378+
256379
.commentary-content {
257380
margin-block: 0.25rem 0.5rem;
258381
}

0 commit comments

Comments
 (0)