From c46a86e698b8219990ff64f557ccc2868558bfe0 Mon Sep 17 00:00:00 2001 From: reoring Date: Thu, 12 Nov 2026 17:01:00 +0900 Subject: [PATCH] web: let the AI activity log scroll and show every received line (APT-808) --- web/src/features/build/artifact-pane.tsx | 4 ++-- web/src/features/improve/conversation.tsx | 7 +++--- web/src/features/improve/side-pane.tsx | 6 ++--- web/src/model/build/progress.ts | 8 ++----- web/src/model/build/stage.test.ts | 4 ++-- web/src/ui/activity-log.tsx | 29 +++++++++++++++++++++++ web/src/ui/index.ts | 1 + 7 files changed, 42 insertions(+), 17 deletions(-) create mode 100644 web/src/ui/activity-log.tsx diff --git a/web/src/features/build/artifact-pane.tsx b/web/src/features/build/artifact-pane.tsx index a107c50..9044c4e 100644 --- a/web/src/features/build/artifact-pane.tsx +++ b/web/src/features/build/artifact-pane.tsx @@ -5,7 +5,7 @@ import { buildCopy } from '../../model/build/copy' import { buildFailureReference } from '../../model/build/failure' import { importedRowCount } from '../../model/build/questions' import type { BuildFailure, BuildProposal, BuildSpec, BuildStage, ProgressCard as Progress } from '../../model/build/types' -import { Card, CardBody, CopyButton, Disclosure, LinkButton, Notice } from '../../ui' +import { ActivityLog, Card, CardBody, CopyButton, Disclosure, LinkButton, Notice } from '../../ui' import { ImplementPanel, type ImplementationState } from './implement' import type { PreviewDatabaseReset } from './implement/use-implementation' import { implementationCopy } from './implement/copy' @@ -64,7 +64,7 @@ export function ProgressCard({ progress, failure }: { progress: Progress; failur

{progress.title}

{progress.elapsedLabel ? {progress.elapsedLabel} : null}
{progress.hint ?

{progress.hint}

: null} {reference ?

{reference}

: null} - {progress.activity ?
{progress.activity.join('\n')}
: null} + {progress.activity ? : null} {progress.steps ?
    {progress.steps.map((step, index) =>
  1. {progress.staged ? ({ todo: '○', doing: '●', done: '✔', failed: '!' })[step.state] : text.artifact.states[step.state]}
    {step.label}{step.elapsedLabel ? {step.elapsedLabel} : null}
    {step.expectation ? {step.expectation} : null}{step.detail && step.detail !== progress.hint ? {step.detail} : null}
    diff --git a/web/src/features/improve/conversation.tsx b/web/src/features/improve/conversation.tsx index f5bf87f..bbf2e9e 100644 --- a/web/src/features/improve/conversation.tsx +++ b/web/src/features/improve/conversation.tsx @@ -8,7 +8,7 @@ import { activityLines, formatElapsed } from '../../model/build/progress' import type { BuildFailure } from '../../model/build/types' import { isProductLanguage } from '../../model/build/wording' import { improveTimeline, runVersion, sortedTurns, type ImproveState, type ImproveTask } from '../../model/improve' -import { Button, CopyButton, LinkButton, Notice } from '../../ui' +import { ActivityLog, Button, CopyButton, LinkButton, Notice } from '../../ui' import { Composer } from '../build/composer' import { AiTurn, AssistantMessage, UserMessage } from '../build/conversation-pane' import { text as buildText } from '../build/copy' @@ -37,7 +37,7 @@ export interface ImproveConversationProps { /** Product-language lines of the AI's live output; file paths and platform words stay hidden. */ function readableActivity(tail: string | undefined): string[] { - return activityLines(tail).filter(isProductLanguage).slice(-3) + return activityLines(tail).filter(isProductLanguage) } export const ImproveConversation = forwardRef(function ImproveConversation({ draft, state, appUrl, locked, waitingForReview, outcome, starting, sendFailure, startFailure, onSend, onRetrySend, onStart }, input) { @@ -132,8 +132,7 @@ export const ImproveConversation = forwardRef{lines.join('\n')} + return } function FailureNotice({ failure, onRetry }: { failure: BuildFailure; onRetry?(): void }) { diff --git a/web/src/features/improve/side-pane.tsx b/web/src/features/improve/side-pane.tsx index d7778f5..29c448f 100644 --- a/web/src/features/improve/side-pane.tsx +++ b/web/src/features/improve/side-pane.tsx @@ -4,7 +4,7 @@ import { buildCopy } from '../../model/build/copy' import { activityLines, formatElapsed } from '../../model/build/progress' import { isProductLanguage } from '../../model/build/wording' import type { ImproveState, ImproveVersion } from '../../model/improve' -import { Card, CardBody, LinkButton } from '../../ui' +import { ActivityLog, Card, CardBody, LinkButton } from '../../ui' import { text as buildText } from '../build/copy' import type { ImplementationState } from '../build/implement' import { text } from './copy' @@ -66,7 +66,7 @@ function RunProgress({ state, implementation, appUrl, proposalPending }: { state const copy = buildCopy.progress.task const tasks = implementation.tasks.filter((task) => run.taskIds.includes(task.id)) // The tail belongs to the task in progress; between tasks it would describe the finished one. - const activity = running && tasks.some((task) => task.status === 'running') ? activityLines(implementation.activityTail).filter(isProductLanguage).slice(-3) : [] + const activity = running && tasks.some((task) => task.status === 'running') ? activityLines(implementation.activityTail).filter(isProductLanguage) : [] const step = implementation.step === 'working' ? copy.working(Boolean(implementation.activityTail?.trim())) : implementation.step ? copy[implementation.step] : undefined const previewReady = Boolean(implementation.previewUrl) && implementation.previewStatus === 'ready' const title = state.phase === 'done' ? proposalPending ? proposalText.sent : text.updated(state.version) : running ? text.run.running : implementation.failure?.title ?? text.run.running @@ -87,7 +87,7 @@ function RunProgress({ state, implementation, appUrl, proposalPending }: { state
  2. })}
- {activity.length ?
{activity.join('\n')}
: null} + {(running || proposalPending) && implementation.previewUrl ? previewReady ? } className="mt-3">{text.run.preview} :

{text.run.previewPreparing}

: null} diff --git a/web/src/model/build/progress.ts b/web/src/model/build/progress.ts index 0db0944..6957180 100644 --- a/web/src/model/build/progress.ts +++ b/web/src/model/build/progress.ts @@ -144,13 +144,9 @@ export function buildProgress(preparation: ProgressCard | undefined, input: Impl } } -/** Lines of the live activity a progress card shows. */ -export const ACTIVITY_LINES = 6 - -/** The last `ACTIVITY_LINES` non-empty lines of an activity tail. */ +/** The non-empty lines of an activity tail, oldest first. */ export function activityLines(text: string | undefined): string[] { - const lines = (text ?? '').split('\n').map((line) => line.trimEnd()).filter(Boolean) - return lines.slice(-ACTIVITY_LINES) + return (text ?? '').split('\n').map((line) => line.trimEnd()).filter(Boolean) } /** Lease expiry signals interruption, not failure: server-side recovery still owns the run. */ diff --git a/web/src/model/build/stage.test.ts b/web/src/model/build/stage.test.ts index d820667..f9301ef 100644 --- a/web/src/model/build/stage.test.ts +++ b/web/src/model/build/stage.test.ts @@ -176,9 +176,9 @@ describe('long-operation progress', () => { expect(progressCard({ kind: 'implement', phase: 'starting-workspace', now }).kind).toBe('workspace') expect(progressCard({ kind: 'implement', phase: 'pushing', now }).title).toBe('新しい版を保存しています') }) - test('a running operation shows its last activity lines; a finished one shows none', () => { + test('a running operation shows every received activity line; a finished one shows none', () => { const activityTail = ['', 'a', 'b ', '', 'c', 'd', 'e', 'f', 'g', ''].join('\n') - expect(progressCard({ kind: 'plan', operation: { ...generation, status: 'running', activityTail }, now }).activity).toEqual(['b', 'c', 'd', 'e', 'f', 'g']) + expect(progressCard({ kind: 'plan', operation: { ...generation, status: 'running', activityTail }, now }).activity).toEqual(['a', 'b', 'c', 'd', 'e', 'f', 'g']) expect(progressCard({ kind: 'plan', operation: { ...generation, status: 'succeeded', activityTail }, now }).activity).toBeUndefined() expect(progressCard({ kind: 'implement', phase: 'running', activityTail: 'x\ny', now }).activity).toEqual(['x', 'y']) }) diff --git a/web/src/ui/activity-log.tsx b/web/src/ui/activity-log.tsx new file mode 100644 index 0000000..33932cc --- /dev/null +++ b/web/src/ui/activity-log.tsx @@ -0,0 +1,29 @@ +import { useLayoutEffect, useRef } from 'react' + +/** Distance from the bottom, in pixels, still treated as "reading the newest line". */ +const FOLLOW_SLACK = 8 + +/** + * The AI's live activity: every line received, in a box that scrolls. + * New lines keep the newest in view until the reader scrolls up to read earlier ones. + */ +export function ActivityLog({ lines, label, className }: { lines: string[]; label: string; className?: string }) { + const ref = useRef(null) + const following = useRef(true) + const text = lines.join('\n') + useLayoutEffect(() => { + const box = ref.current + if (box && following.current) box.scrollTop = box.scrollHeight + }, [text]) + if (!lines.length) return null + return
 {
+      const box = event.currentTarget
+      following.current = box.scrollHeight - box.scrollTop - box.clientHeight <= FOLLOW_SLACK
+    }}
+    className={`overflow-auto whitespace-pre-wrap break-words rounded bg-stone-50 font-mono text-xs leading-relaxed text-stone-600 ${className ?? ''}`}
+  >{text}
+} diff --git a/web/src/ui/index.ts b/web/src/ui/index.ts index 77361c5..edeaff5 100644 --- a/web/src/ui/index.ts +++ b/web/src/ui/index.ts @@ -1,4 +1,5 @@ // Builder Console design-system kit. Pages import everything from here. +export { ActivityLog } from './activity-log' export { Button, buttonClasses, IconButton, LinkButton, OpenLink, type ButtonProps, type ButtonSize, type ButtonTone, type LinkButtonProps } from './button' export { cn } from './cn' export { DataTable } from './data-table'