From 658c960b02a44ebec107b79dd6f0d083cdcc7afc Mon Sep 17 00:00:00 2001 From: PianoNic <79938743+Pianonic@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:19:07 +0100 Subject: [PATCH] Show Helply's reply steps like Tessaly: text between steps in the reply, latest steps while streaming, folded when done --- src/Schuly.Frontend/public/i18n/de.json | 2 + src/Schuly.Frontend/public/i18n/en.json | 2 + src/Schuly.Frontend/public/i18n/fr.json | 2 + src/Schuly.Frontend/public/i18n/it.json | 2 + src/Schuly.Frontend/public/i18n/rm.json | 2 + .../app/pages/helply/helply-timeline.spec.ts | 25 ++- .../src/app/pages/helply/helply-timeline.ts | 29 ++- .../src/app/pages/helply/message.spec.ts | 16 ++ .../src/app/pages/helply/message.ts | 22 ++- .../src/app/pages/helply/reply-chain.ts | 176 +++++++++--------- src/Schuly.Frontend/src/styles.css | 16 ++ 11 files changed, 197 insertions(+), 97 deletions(-) diff --git a/src/Schuly.Frontend/public/i18n/de.json b/src/Schuly.Frontend/public/i18n/de.json index bdb50e04..e7104ca0 100644 --- a/src/Schuly.Frontend/public/i18n/de.json +++ b/src/Schuly.Frontend/public/i18n/de.json @@ -1113,6 +1113,8 @@ "run": { "pages": "{count, plural, one {# Seite} other {# Seiten}}", "times": "{count, plural, one {# Mal} other {# Mal}}", + "earlier": "{count, plural, one {+# früherer Schritt} other {+# frühere Schritte}}", + "fewer": "Weniger Schritte zeigen", "worked": "Erledigt", "steps": "{count, plural, one {# Schritt} other {# Schritte}}", "failed": "{count, plural, one {# Schritt hat nicht geklappt} other {# Schritte haben nicht geklappt}}" diff --git a/src/Schuly.Frontend/public/i18n/en.json b/src/Schuly.Frontend/public/i18n/en.json index 1b1bd842..85ee20a0 100644 --- a/src/Schuly.Frontend/public/i18n/en.json +++ b/src/Schuly.Frontend/public/i18n/en.json @@ -1113,6 +1113,8 @@ "run": { "pages": "{count, plural, one {# page} other {# pages}}", "times": "{count, plural, one {# time} other {# times}}", + "earlier": "{count, plural, one {+# earlier step} other {+# earlier steps}}", + "fewer": "Show fewer steps", "worked": "Worked it out", "steps": "{count, plural, one {# step} other {# steps}}", "failed": "{count, plural, one {# step didn't work} other {# steps didn't work}}" diff --git a/src/Schuly.Frontend/public/i18n/fr.json b/src/Schuly.Frontend/public/i18n/fr.json index c10db791..312f0b82 100644 --- a/src/Schuly.Frontend/public/i18n/fr.json +++ b/src/Schuly.Frontend/public/i18n/fr.json @@ -1113,6 +1113,8 @@ "run": { "pages": "{count, plural, one {# page} other {# pages}}", "times": "{count, plural, one {# fois} other {# fois}}", + "earlier": "{count, plural, one {+# étape précédente} other {+# étapes précédentes}}", + "fewer": "Afficher moins d'étapes", "worked": "C'est fait", "steps": "{count, plural, one {# étape} other {# étapes}}", "failed": "{count, plural, one {# étape n'a pas marché} other {# étapes n'ont pas marché}}" diff --git a/src/Schuly.Frontend/public/i18n/it.json b/src/Schuly.Frontend/public/i18n/it.json index 458c4ff7..a06c58da 100644 --- a/src/Schuly.Frontend/public/i18n/it.json +++ b/src/Schuly.Frontend/public/i18n/it.json @@ -1113,6 +1113,8 @@ "run": { "pages": "{count, plural, one {# pagina} other {# pagine}}", "times": "{count, plural, one {# volta} other {# volte}}", + "earlier": "{count, plural, one {+# passaggio precedente} other {+# passaggi precedenti}}", + "fewer": "Mostra meno passaggi", "worked": "Fatto", "steps": "{count, plural, one {# passaggio} other {# passaggi}}", "failed": "{count, plural, one {# passaggio non ha funzionato} other {# passaggi non hanno funzionato}}" diff --git a/src/Schuly.Frontend/public/i18n/rm.json b/src/Schuly.Frontend/public/i18n/rm.json index 50321c7a..aafa4b0d 100644 --- a/src/Schuly.Frontend/public/i18n/rm.json +++ b/src/Schuly.Frontend/public/i18n/rm.json @@ -1113,6 +1113,8 @@ "run": { "pages": "{count, plural, one {# pagina} other {# paginas}}", "times": "{count, plural, one {# giada} other {# giadas}}", + "earlier": "{count, plural, one {+# pass pli baud} other {+# pass pli baud}}", + "fewer": "Mussar main pass", "worked": "Fatg", "steps": "{count, plural, one {# pass} other {# pass}}", "failed": "{count, plural, one {# pass n'ha betg funcziunà} other {# pass n'han betg funcziunà}}" diff --git a/src/Schuly.Frontend/src/app/pages/helply/helply-timeline.spec.ts b/src/Schuly.Frontend/src/app/pages/helply/helply-timeline.spec.ts index 80257429..72fde932 100644 --- a/src/Schuly.Frontend/src/app/pages/helply/helply-timeline.spec.ts +++ b/src/Schuly.Frontend/src/app/pages/helply/helply-timeline.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { HelplySettings, ToolActivity } from '../../core/helply-types'; -import { buildTimeline, chainSummary, runDetail, stepIcon } from './helply-timeline'; +import { buildTimeline, chainBlocks, chainSummary, runDetail, runSummary, stepIcon } from './helply-timeline'; import { starterPrompts } from './helply-suggestions'; const tool = (id: string, at: number | null, change: Partial = {}): ToolActivity => ({ @@ -67,6 +67,29 @@ describe('buildTimeline', () => { }); }); +describe('chainBlocks and runSummary', () => { + it('groups consecutive steps into runs between the text', () => { + const text = 'First.\n\nSecond.\n\nAnswer.'; + const first = text.indexOf('First.') + 'First.'.length; + const second = text.indexOf('Second.') + 'Second.'.length; + const timeline = buildTimeline(text, [tool('a', first), tool('b', first, { label: 'Read an email' }), tool('c', second)]); + const blocks = chainBlocks(timeline.chain); + expect(blocks.map((b) => (b.kind === 'text' ? b.text : b.steps.map((s) => s.label)))).toEqual([ + 'First.', + ['Read your mail list', 'Read an email'], + 'Second.', + ['Read your mail list'], + ]); + }); + + it('sums folded runs in the one-line summary', () => { + const timeline = buildTimeline('', [tool('a', 0), tool('b', 0), tool('c', 0, { label: 'Read an email' })]); + const steps = chainBlocks(timeline.chain).flatMap((b) => (b.kind === 'steps' ? b.steps : [])); + expect(runSummary(steps)).toBe('Read your mail list · Read an email · 3 steps'); + expect(runSummary([])).toBe('Worked it out'); + }); +}); + describe('runDetail and stepIcon', () => { it('says how a run of steps went', () => { expect(runDetail([null])).toBeNull(); diff --git a/src/Schuly.Frontend/src/app/pages/helply/helply-timeline.ts b/src/Schuly.Frontend/src/app/pages/helply/helply-timeline.ts index c9527131..c236773a 100644 --- a/src/Schuly.Frontend/src/app/pages/helply/helply-timeline.ts +++ b/src/Schuly.Frontend/src/app/pages/helply/helply-timeline.ts @@ -105,10 +105,31 @@ export function buildTimeline(text: string, tools: readonly ToolActivity[]): Rep return { chain, answer: text.slice(position).trim(), steps: tools.length }; } -/** A finished chain in one line: "Read your mail list · 4 steps", or the two kinds of step it took. */ -export function chainSummary(timeline: ReplyTimeline): string { - const labels = [...new Set(timeline.chain.flatMap((e) => (e.kind === 'step' ? [e.label] : [])))]; +/** A stretch of the chain: text Helply wrote, or the run of steps it took between two stretches of text. */ +export type ChainBlock = { kind: 'text'; key: string; text: string } | { kind: 'steps'; key: string; steps: ChainStep[] }; + +/** The chain with its consecutive steps grouped, so each run folds and opens on its own. */ +export function chainBlocks(chain: readonly ChainEntry[]): ChainBlock[] { + const blocks: ChainBlock[] = []; + for (const entry of chain) { + const last = blocks[blocks.length - 1]; + if (entry.kind === 'text') blocks.push({ kind: 'text', key: entry.key, text: entry.text }); + else if (last?.kind === 'steps') last.steps.push(entry); + else blocks.push({ kind: 'steps', key: `steps-${entry.key}`, steps: [entry] }); + } + return blocks; +} + +/** A finished run in one line: "Read your mail list · 4 steps", or the two kinds of step it took. */ +export function runSummary(steps: readonly ChainStep[]): string { + const labels = [...new Set(steps.map((s) => s.label))]; if (!labels.length) return t('helply.run.worked'); const shown = labels.slice(0, 2).join(' · '); - return timeline.steps > 1 ? `${shown} · ${t('helply.run.steps', { count: timeline.steps })}` : shown; + const total = steps.reduce((sum, s) => sum + s.count, 0); + return total > 1 ? `${shown} · ${t('helply.run.steps', { count: total })}` : shown; +} + +/** A finished chain in one line. */ +export function chainSummary(timeline: ReplyTimeline): string { + return runSummary(timeline.chain.filter((e): e is ChainStep => e.kind === 'step')); } diff --git a/src/Schuly.Frontend/src/app/pages/helply/message.spec.ts b/src/Schuly.Frontend/src/app/pages/helply/message.spec.ts index c5eb8433..53854aaa 100644 --- a/src/Schuly.Frontend/src/app/pages/helply/message.spec.ts +++ b/src/Schuly.Frontend/src/app/pages/helply/message.spec.ts @@ -59,4 +59,20 @@ describe('Helply message markdown', () => { expect(link.getAttribute('rel')).toContain('noopener'); expect(el.querySelector('pk-markdown')!.textContent).toContain('click here (evil.example)'); }); + + it('shows the text between steps as part of the reply, with each run of steps between', async () => { + const fixture = TestBed.createComponent(HelplyMessageView); + const text = 'Checking mail.\n\nAnswer.'; + const at = 'Checking mail.'.length; + fixture.componentRef.setInput('message', { + ...message(text), + tools: [{ id: 't1', label: 'Read your mail list', status: 'done', at }], + }); + fixture.detectChanges(); + await fixture.whenStable(); + const el = fixture.nativeElement as HTMLElement; + expect(el.querySelectorAll('app-reply-chain')).toHaveLength(1); + expect(el.querySelectorAll('pk-markdown')).toHaveLength(2); + expect(el.textContent).toContain('Checking mail.'); + }); }); diff --git a/src/Schuly.Frontend/src/app/pages/helply/message.ts b/src/Schuly.Frontend/src/app/pages/helply/message.ts index 561a024e..d61632a7 100644 --- a/src/Schuly.Frontend/src/app/pages/helply/message.ts +++ b/src/Schuly.Frontend/src/app/pages/helply/message.ts @@ -9,7 +9,7 @@ import { HlmButton } from '@spartan-ng/helm/button'; import { ChatMessage } from '../../core/helply-state'; import { HelplyAttachment } from '../../core/helply-types'; import { AttachmentCards } from './attachment-cards'; -import { buildTimeline } from './helply-timeline'; +import { buildTimeline, chainBlocks } from './helply-timeline'; import { ReplyChain } from './reply-chain'; /** @@ -18,11 +18,11 @@ import { ReplyChain } from './reply-chain'; * highlighting, is styled here. */ const MARKDOWN = - 'text-sm leading-relaxed break-words ' + + 'break-words ' + '[&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0 ' + '[&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:my-0.5 ' + - '[&_h1]:mt-3 [&_h1]:mb-1 [&_h1]:text-base [&_h1]:font-semibold [&_h2]:mt-3 [&_h2]:mb-1 [&_h2]:text-base [&_h2]:font-semibold ' + - '[&_h3]:mt-2 [&_h3]:mb-1 [&_h3]:text-sm [&_h3]:font-semibold ' + + '[&_h1]:mt-4 [&_h1]:mb-1 [&_h1]:text-xl [&_h1]:font-semibold [&_h2]:mt-4 [&_h2]:mb-1 [&_h2]:text-lg [&_h2]:font-semibold ' + + '[&_h3]:mt-3 [&_h3]:mb-1 [&_h3]:text-base [&_h3]:font-semibold [&_:is(h1,h2,h3):first-child]:mt-0 ' + '[&_a]:text-primary [&_a]:underline [&_a+span]:text-muted-foreground [&_a]:underline-offset-2 [&_a]:rounded-sm [&_a:focus-visible]:ring-ring/50 [&_a:focus-visible]:ring-3 [&_a]:outline-none ' + '[&_:not(pre)>code]:bg-muted [&_:not(pre)>code]:rounded [&_:not(pre)>code]:px-1 [&_:not(pre)>code]:py-0.5 [&_code]:font-mono [&_:not(pre)>code]:text-xs ' + '[&_.pk-code-container>pre]:bg-muted [&_.pk-code-container>pre]:my-3 [&_.pk-code-container>pre]:overflow-x-auto [&_.pk-code-container>pre]:rounded-md [&_.pk-code-container>pre]:border [&_.pk-code-container>pre]:p-3 [&_.pk-code-container>pre]:text-[13px] ' + @@ -47,10 +47,15 @@ const MARKDOWN = } @else {
- -
- @if (timeline().chain.length) { - + +
+ + @for (block of blocks(); track block.key; let last = $last) { + @if (block.kind === 'steps') { + + } @else { + + } } @if (timeline().answer) { @@ -95,6 +100,7 @@ export class HelplyMessageView { protected readonly isUser = computed(() => this.message().role === 'user'); /** The steps Helply took, with the text it wrote between them, and its answer after the last one. */ protected readonly timeline = computed(() => buildTimeline(this.message().text, this.message().tools)); + protected readonly blocks = computed(() => chainBlocks(this.timeline().chain)); /** Copy takes the answer; a reply that is all steps copies its whole text. */ protected readonly copyText = computed(() => this.timeline().answer || this.message().text); /** Waiting for the model: nothing written yet after the last step and no step running. */ diff --git a/src/Schuly.Frontend/src/app/pages/helply/reply-chain.ts b/src/Schuly.Frontend/src/app/pages/helply/reply-chain.ts index 38bed629..d19ca5ad 100644 --- a/src/Schuly.Frontend/src/app/pages/helply/reply-chain.ts +++ b/src/Schuly.Frontend/src/app/pages/helply/reply-chain.ts @@ -13,7 +13,6 @@ import { lucideFileText, lucideGraduationCap, lucideMail, - lucideMessageSquareText, lucideMessagesSquare, lucideNotebookPen, lucideNotebookText, @@ -24,9 +23,8 @@ import { } from '@ng-icons/lucide'; import { PkChainOfThoughtImports } from '@prompt-kit/chain-of-thought'; import { PkCodeBlockImports } from '@prompt-kit/code-block'; -import { PkMarkdown } from '@prompt-kit/markdown'; import { ToolStatus } from '../../core/helply-types'; -import { ChainStep, ReplyTimeline, chainSummary } from './helply-timeline'; +import { ChainStep, runSummary } from './helply-timeline'; const STATUS_TEXT: Record = { running: 'helply.status.running', @@ -35,14 +33,17 @@ const STATUS_TEXT: Record = { denied: 'helply.status.denied', }; +/** How many of the latest steps show while a run is being taken; the earlier ones fold away behind a toggle. */ +const SHOWN_STEPS = 3; + /** - * What Helply did on the way to its answer, as one ordered chain of thought: the text it wrote between - * tool calls and each step, in the order they happened. It stays open while the reply streams; a - * finished chain of more than one entry folds into a one-line summary that opens it again. + * One run of steps Helply took between two stretches of its text. While the reply streams the latest few + * show (earlier ones fold behind a toggle, and steps rise in as they start); a finished run of more than + * one step folds into a one-line summary that opens it again. */ @Component({ selector: 'app-reply-chain', - imports: [TranslatePipe, NgIcon, NgTemplateOutlet, PkChainOfThoughtImports, PkCodeBlockImports, PkMarkdown], + imports: [TranslatePipe, NgIcon, NgTemplateOutlet, PkChainOfThoughtImports, PkCodeBlockImports], providers: [ provideIcons({ lucideBan, @@ -54,7 +55,6 @@ const STATUS_TEXT: Record = { lucideFileText, lucideGraduationCap, lucideMail, - lucideMessageSquareText, lucideMessagesSquare, lucideNotebookPen, lucideNotebookText, @@ -69,26 +69,27 @@ const STATUS_TEXT: Record = { @if (collapsible()) { } - +
= { [attr.inert]="shown() ? null : ''" >
- - @for (entry of timeline().chain; track entry.key) { - - @if (entry.kind === 'text') { -
- - - -
- } @else if (entry.code) { - - - - - - - {{ 'helply.command' | translate }} - - - - @if (entry.code.output !== null) { -
{{ entry.code.output || '(no output)' }}
- } - @if (entry.code.exitCode !== null) { -

- {{ 'helply.exitCode' | translate: { code: entry.code.exitCode } }} -

- } -
-
- } @else { - -
- - - -
- } -
+ + @if (active() && steps().length > shownSteps) { + + } + + @for (step of steps(); track step.key) { + @if ($index >= folded()) { + + @if (step.code) { + + + + + + + {{ 'helply.command' | translate }} + + + + @if (step.code.output !== null) { +
{{ step.code.output || '(no output)' }}
+ } + @if (step.code.exitCode !== null) { +

+ {{ 'helply.exitCode' | translate: { code: step.code.exitCode } }} +

+ } +
+
+ } @else { + +
+ + + +
+ } +
+ } }
@@ -165,7 +172,7 @@ const STATUS_TEXT: Record = { @if (step.status === 'running') { - {{ step.label }} + {{ step.label }} } @else { {{ step.label }} } @@ -189,23 +196,24 @@ export class ReplyChain { private static next = 0; protected readonly bodyId = `helply-chain-${ReplyChain.next++}`; - readonly timeline = input.required(); - /** The reply is still arriving: the chain stays open and cannot be folded. */ - readonly streaming = input(false); + readonly steps = input.required(); + /** Still being taken: the reply is streaming and nothing was written after this run yet. The run stays open. */ + readonly active = input(false); - /** Whether a finished chain was opened again. */ + protected readonly shownSteps = SHOWN_STEPS; + /** Whether the earlier steps of a run being taken are shown too. */ + protected readonly expanded = signal(false); + /** Whether a finished run was opened again. */ protected readonly open = signal(false); - /** A finished chain of more than one entry folds into a line; a single step is shown as it is. */ - protected readonly collapsible = computed(() => !this.streaming() && this.timeline().chain.length > 1); + /** A finished run of more than one step folds into a line; a single step is shown as it is. */ + protected readonly collapsible = computed(() => !this.active() && this.steps().length > 1); protected readonly shown = computed(() => !this.collapsible() || this.open()); - protected readonly summary = computed(() => chainSummary(this.timeline())); - protected readonly headerIcon = computed( - () => this.timeline().chain.find((e): e is ChainStep => e.kind === 'step')?.icon ?? 'lucideWrench', - ); - protected readonly failures = computed( - () => this.timeline().chain.filter((e) => e.kind === 'step' && e.status === 'failed').length, - ); + /** How many steps at the start of the run are folded away while it is taken; none once it is done. */ + protected readonly folded = computed(() => (this.expanded() || !this.active() ? 0 : Math.max(0, this.steps().length - SHOWN_STEPS))); + protected readonly summary = computed(() => runSummary(this.steps())); + protected readonly headerIcon = computed(() => this.steps()[0]?.icon ?? 'lucideWrench'); + protected readonly failures = computed(() => this.steps().filter((s) => s.status === 'failed').length); protected statusText(status: ToolStatus): string { return t(STATUS_TEXT[status]); diff --git a/src/Schuly.Frontend/src/styles.css b/src/Schuly.Frontend/src/styles.css index 64e33a90..41cd845a 100644 --- a/src/Schuly.Frontend/src/styles.css +++ b/src/Schuly.Frontend/src/styles.css @@ -105,6 +105,22 @@ @apply motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-2 motion-safe:duration-300 motion-safe:ease-out; } +/* Out of the top of a list: moves up and fades while its height folds, so what is below follows. */ +@utility fold-away { + @media (prefers-reduced-motion: no-preference) { + overflow: hidden; + interpolate-size: allow-keywords; + animation: fold-away 250ms ease-in forwards; + } +} +@keyframes fold-away { + to { + height: 0; + opacity: 0; + transform: translateY(-0.5rem); + } +} + @layer base { * { @apply border-border outline-ring/50;