Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions web/src/features/build/artifact-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -64,7 +64,7 @@ export function ProgressCard({ progress, failure }: { progress: Progress; failur
<div className="flex flex-wrap items-center justify-between gap-2"><h3 className="font-semibold text-stone-900">{progress.title}</h3>{progress.elapsedLabel ? <span className="text-sm tabular-nums text-stone-500">{progress.elapsedLabel}</span> : null}</div>
{progress.hint ? <p className="mt-2 text-sm text-stone-600">{progress.hint}</p> : null}
{reference ? <p className="mt-3 break-all text-xs text-stone-600">{reference}</p> : null}
{progress.activity ? <pre aria-label={buildCopy.progress.observe.activity} className="mt-3 max-h-32 overflow-hidden whitespace-pre-wrap break-words rounded bg-stone-50 p-3 font-mono text-xs leading-relaxed text-stone-600">{progress.activity.join('\n')}</pre> : null}
{progress.activity ? <ActivityLog lines={progress.activity} label={buildCopy.progress.observe.activity} className="mt-3 max-h-48 p-3" /> : null}
{progress.steps ? <ol aria-label={progress.staged ? text.artifact.preparation : undefined} className="mt-4 space-y-3">{progress.steps.map((step, index) => <li key={`${step.label}-${index}`} aria-current={step.state === 'doing' ? 'step' : undefined} className="flex gap-3 text-sm">
<span aria-label={text.artifact.states[step.state]} className={step.state === 'failed' ? 'text-bad-ink' : step.state === 'todo' ? 'text-stone-400' : 'text-accent-strong'}>{progress.staged ? ({ todo: '○', doing: '●', done: '✔', failed: '!' })[step.state] : text.artifact.states[step.state]}</span>
<div className="min-w-0 flex-1"><div className="flex flex-wrap justify-between gap-2"><span className={step.state === 'doing' ? 'font-semibold' : undefined}>{step.label}</span>{step.elapsedLabel ? <span className="tabular-nums text-stone-500">{step.elapsedLabel}</span> : null}</div>{step.expectation ? <span className="block text-xs text-stone-500">{step.expectation}</span> : null}{step.detail && step.detail !== progress.hint ? <span className="block text-xs text-stone-500">{step.detail}</span> : null}</div>
Expand Down
7 changes: 3 additions & 4 deletions web/src/features/improve/conversation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<HTMLTextAreaElement, ImproveConversationProps>(function ImproveConversation({ draft, state, appUrl, locked, waitingForReview, outcome, starting, sendFailure, startFailure, onSend, onRetrySend, onStart }, input) {
Expand Down Expand Up @@ -132,8 +132,7 @@ export const ImproveConversation = forwardRef<HTMLTextAreaElement, ImproveConver
})

function Activity({ lines }: { lines: string[] }) {
if (!lines.length) return null
return <pre aria-label={buildCopy.progress.observe.activity} className="mt-2 max-h-24 overflow-hidden whitespace-pre-wrap break-words rounded bg-stone-50 p-2.5 font-mono text-xs leading-relaxed text-stone-600">{lines.join('\n')}</pre>
return <ActivityLog lines={lines} label={buildCopy.progress.observe.activity} className="mt-2 max-h-40 p-2.5" />
}

function FailureNotice({ failure, onRetry }: { failure: BuildFailure; onRetry?(): void }) {
Expand Down
6 changes: 3 additions & 3 deletions web/src/features/improve/side-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -87,7 +87,7 @@ function RunProgress({ state, implementation, appUrl, proposalPending }: { state
</li>
})}
</ol>
{activity.length ? <pre aria-label={buildCopy.progress.observe.activity} className="mt-3 max-h-24 overflow-hidden whitespace-pre-wrap break-words rounded bg-stone-50 p-2.5 font-mono text-xs leading-relaxed text-stone-600">{activity.join('\n')}</pre> : null}
<ActivityLog lines={activity} label={buildCopy.progress.observe.activity} className="mt-3 max-h-40 p-2.5" />
{(running || proposalPending) && implementation.previewUrl ? previewReady
? <LinkButton external href={implementation.previewUrl} size="sm" icon={<ExternalLink />} className="mt-3">{text.run.preview}</LinkButton>
: <p role="status" className="mt-3 text-xs text-stone-500">{text.run.previewPreparing}</p> : null}
Expand Down
8 changes: 2 additions & 6 deletions web/src/model/build/progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
4 changes: 2 additions & 2 deletions web/src/model/build/stage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
})
Expand Down
29 changes: 29 additions & 0 deletions web/src/ui/activity-log.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLPreElement>(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 <pre
ref={ref}
aria-label={label}
tabIndex={0}
onScroll={(event) => {
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}</pre>
}
1 change: 1 addition & 0 deletions web/src/ui/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down