feat(desktop): surface produced files, session artifacts fold, header cwd

Completed diff-less writes (write_file creates) were invisible in the
transcript. They now appear in the per-turn files card with a "new" tag
(preview on click, system-app open on hover), a collapsible per-session
artifacts fold sits above the usage footer, and the chat header shows
the session's workspace path ahead of the title.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-09-18 19:16:57 +08:00
parent 9845e0b262
commit 6946e37f96
13 changed files with 422 additions and 30 deletions
+24 -2
View File
@@ -23,6 +23,7 @@ import { type HermesGateway } from '@/hermes'
import { useI18n } from '@/i18n'
import type { ChatMessage } from '@/lib/chat-messages'
import { NEW_SESSION_TITLE, quickModelOptions, sessionTitle } from '@/lib/chat-runtime'
import { displayPath } from '@/lib/display-path'
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
import { currentModelCapabilities, modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options'
import { useStoreSelector } from '@/lib/use-session-slice'
@@ -70,6 +71,7 @@ import { ProfileTag } from './profile-tag'
import { isRouteSessionMismatch } from './route-session-state'
import { useRuntimeMessageRepository } from './runtime-repository'
import { ScrollToBottomButton } from './scroll-to-bottom-button'
import { SessionArtifacts } from './session-artifacts'
import { SessionUsageFooter } from './session-usage-footer'
import { useSessionView } from './session-view'
import { SessionActionsMenu } from './sidebar/session-actions-menu'
@@ -156,6 +158,10 @@ function ChatHeader({
? pinnedSessionIds.includes(selectedSessionId)
: false
// Breadcrumb-style locator ahead of the title: which workspace this session
// belongs to (WorkBuddy shows project/space/task; hermes' anchor is the cwd).
const sessionCwd = activeStoredSession?.cwd
// Secondary windows (new-session scratch, subagent watch, cmd-click pop-out)
// are compact side panels — they drop the session-actions header + border
// entirely. A brand-new draft has nothing to pin/delete/rename either.
@@ -166,13 +172,22 @@ function ChatHeader({
return (
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
<div
className={cn(titlebarHeaderTitleClass, showProfileTag && 'flex items-center')}
className={cn(titlebarHeaderTitleClass, (showProfileTag || sessionCwd) && 'flex min-w-0 items-center')}
style={{
maxWidth:
'calc(100vw - var(--titlebar-content-inset,0px) - var(--titlebar-tools-right) - var(--titlebar-tools-width) - 1.5rem)'
}}
>
{showProfileTag && <ProfileTag className="pointer-events-auto mr-1.5" profile={activeStoredSession?.profile} />}
{sessionCwd && (
<span
className="pointer-events-none mr-1.5 min-w-0 shrink truncate text-(--ui-text-tertiary)"
title={sessionCwd}
>
{displayPath(sessionCwd)}
<span className="mx-1 opacity-60">/</span>
</span>
)}
<SessionActionsMenu
align="start"
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}
@@ -700,8 +715,15 @@ const ChatViewContent = memo(function ChatViewContent({
// The footer needs a STORED id ($sessions rows key off it): activeSessionId
// is the runtime id, so fall back through the routed id for fresh resumes.
const footerSessionId = selectedSessionId || routedSessionId
const threadFooter = useMemo(
() => (footerSessionId ? <SessionUsageFooter sessionId={footerSessionId} /> : undefined),
() =>
footerSessionId ? (
<>
<SessionArtifacts />
<SessionUsageFooter sessionId={footerSessionId} />
</>
) : undefined,
[footerSessionId]
)
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import { deriveSessionArtifacts } from './session-artifacts'
const writePart = (path: string) => ({
type: 'tool-call',
toolName: 'write_file',
args: { path },
result: { path }
})
const editPart = (path: string) => ({
type: 'tool-call',
toolName: 'edit_file',
args: { path },
result: { inline_diff: `--- a/${path}\n+++ b/${path}\n+x`, path }
})
describe('deriveSessionArtifacts', () => {
it('collects completed file-edit paths across all messages in first-seen order', () => {
const artifacts = deriveSessionArtifacts([
{ role: 'user', parts: [{ type: 'text', text: 'hi' }] },
{ role: 'assistant', parts: [writePart('docs/a.md'), editPart('src/b.ts')] },
{ role: 'assistant', parts: [writePart('docs/c.md')] }
])
expect(artifacts).toEqual([
{ name: 'a.md', path: 'docs/a.md' },
{ name: 'b.ts', path: 'src/b.ts' },
{ name: 'c.md', path: 'docs/c.md' }
])
})
it('dedupes by path across turns', () => {
const artifacts = deriveSessionArtifacts([
{ role: 'assistant', parts: [writePart('a.md')] },
{ role: 'assistant', parts: [editPart('a.md')] }
])
expect(artifacts).toEqual([{ name: 'a.md', path: 'a.md' }])
})
it('skips running, failed, and non-file parts', () => {
const artifacts = deriveSessionArtifacts([
{
role: 'assistant',
parts: [
{ type: 'tool-call', toolName: 'write_file', args: { path: 'running.md' } },
{ type: 'tool-call', toolName: 'write_file', args: { path: 'failed.md' }, isError: true, result: {} },
{ type: 'tool-call', toolName: 'terminal', args: {}, result: { output: 'ok' } },
{ type: 'text', text: 'done' }
]
}
])
expect(artifacts).toEqual([])
})
it('reads parts from the content alias too', () => {
const artifacts = deriveSessionArtifacts([{ role: 'assistant', content: [writePart('via-content.md')] }])
expect(artifacts).toEqual([{ name: 'via-content.md', path: 'via-content.md' }])
})
it('handles empty and malformed messages', () => {
expect(deriveSessionArtifacts([])).toEqual([])
expect(deriveSessionArtifacts([null, {}, { parts: 'nope' }])).toEqual([])
})
})
@@ -0,0 +1,147 @@
import { useAuiState } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { useMemo, useState } from 'react'
import { useSessionView } from '@/app/chat/session-view'
import {
fileEditBasename,
fileEditPath,
isFileEditTool,
parseMaybeObject
} from '@/components/assistant-ui/tool/fallback-model'
import { FileTypeIcon } from '@/components/ui/file-type-icon'
import { useI18n } from '@/i18n'
import { displayPath } from '@/lib/display-path'
import { ChevronRight, ExternalLink } from '@/lib/icons'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { openFileWithSystemApp } from '@/lib/office-files'
import { toolResultRecord } from '@/lib/tool-result-metadata'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import { openPreview } from '@/store/preview'
export interface SessionArtifact {
/** Basename, for the row label. */
name: string
/** Path exactly as the tool reported it. */
path: string
}
interface ArtifactMessage {
content?: unknown
parts?: unknown
}
interface ArtifactToolPart {
toolResultMetadata?: Parameters<typeof toolResultRecord>[0]['toolResultMetadata']
args?: unknown
completedAt?: unknown
isError?: unknown
result?: unknown
toolName?: unknown
type?: unknown
}
/**
* Every file this session's completed file-edit tools touched, in first-seen
* order. Unlike the per-turn ChangedFilesCard this spans the WHOLE transcript
* and keeps diff-less creates — the session's products, not just its diffs.
*/
export function deriveSessionArtifacts(messages: readonly unknown[]): SessionArtifact[] {
const byPath = new Map<string, SessionArtifact>()
for (const rawMessage of messages) {
const message = (rawMessage ?? {}) as ArtifactMessage
const parts = Array.isArray(message.parts) ? message.parts : Array.isArray(message.content) ? message.content : []
for (const rawPart of parts) {
const part = (rawPart ?? {}) as ArtifactToolPart
if (part.type !== 'tool-call' || typeof part.toolName !== 'string' || !isFileEditTool(part.toolName)) {
continue
}
if ((part.result === undefined && part.completedAt === undefined) || part.isError) {
continue
}
const path = fileEditPath(parseMaybeObject(part.args), toolResultRecord(part))
if (path && !byPath.has(path)) {
byPath.set(path, { name: fileEditBasename(path), path })
}
}
}
return [...byPath.values()]
}
/**
* WorkBuddy-style "all products of this session" fold at the very end of the
* transcript, above the usage footer. A row opens the preview pane; the hover
* action opens the file with the system app.
*/
export function SessionArtifacts() {
const { t } = useI18n()
const messages = useAuiState(state => state.thread.messages)
const artifacts = useMemo(() => deriveSessionArtifacts(messages), [messages])
const [expanded, setExpanded] = useState(false)
const view = useSessionView()
const cwd = useStore(view.$cwd)
if (artifacts.length === 0) {
return null
}
async function openArtifactPreview(path: string) {
const preview = await normalizeOrLocalPreviewTarget(path, cwd || undefined)
if (preview) {
openPreview(preview, 'tool-result')
} else {
notifyError(new Error(`Could not open preview target: ${path}`), t.preview.unavailable)
}
}
return (
<div
className="mt-(--conversation-turn-gap) border-t border-border/65 pt-2 text-xs text-muted-foreground"
data-slot="session-artifacts"
>
<button
className="flex cursor-pointer items-center gap-1 transition-colors hover:text-foreground"
onClick={() => setExpanded(value => !value)}
type="button"
>
<ChevronRight className={cn('size-3 transition-transform', expanded && 'rotate-90')} />
{t.assistant.thread.sessionArtifacts(artifacts.length)}
</button>
{expanded && (
<div className="mt-1 flex flex-col">
{artifacts.map(artifact => (
<div className="row-hover group flex items-center rounded-md" key={artifact.path}>
<button
className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 px-1.5 py-1 text-left"
onClick={() => void openArtifactPreview(artifact.path)}
title={displayPath(artifact.path)}
type="button"
>
<FileTypeIcon className="shrink-0 text-(--ui-text-tertiary)" path={artifact.path} size="0.875rem" />
<span className="min-w-0 flex-1 truncate">{artifact.name}</span>
</button>
<button
aria-label={t.preview.openWithSystemApp}
className="mr-1 hidden shrink-0 cursor-pointer transition-colors hover:text-foreground group-hover:block"
onClick={() => void openFileWithSystemApp(artifact.path)}
title={t.preview.openWithSystemApp}
type="button"
>
<ExternalLink className="size-3.5" />
</button>
</div>
))}
</div>
)}
</div>
)
}
@@ -10,7 +10,12 @@ import { FadeScroll } from '@/components/ui/fade-scroll'
import { FileTypeIcon } from '@/components/ui/file-type-icon'
import { useI18n } from '@/i18n'
import { displayPath } from '@/lib/display-path'
import { ExternalLink } from '@/lib/icons'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { openFileWithSystemApp } from '@/lib/office-files'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import { openPreview } from '@/store/preview'
import { openReviewForPath, revealReview } from '@/store/review'
// ~5 rows. A turn that rewrites twenty files should still read as one card in
@@ -20,7 +25,9 @@ const MAX_ROWS_HEIGHT = '9.375rem'
/**
* Cursor-style "N files changed" summary closing out the newest assistant turn:
* one row per file it edited with that file's +/-, and a Review action opening
* the diff pane (⌘G). A row click opens that file's diff directly.
* the diff pane (⌘G). Completed writes whose result carries no diff (new-file
* creates) are the turn's PRODUCTS — their rows open the preview pane instead
* of the diff review. Every row also offers a hover "open with system app".
*
* Wears the shared `WIDGET_SHELL_CLASS` so it reads as the same panel as the
* transcript's other inline widgets rather than inventing its own chrome.
@@ -40,34 +47,74 @@ export const ChangedFilesCard: FC<{ parts: readonly unknown[] }> = ({ parts }) =
return null
}
const changedCount = files.filter(file => file.hasDiff).length
const producedCount = files.length - changedCount
const summary = [
changedCount > 0 ? copy.filesChanged(changedCount) : '',
producedCount > 0 ? copy.filesProduced(producedCount) : ''
]
.filter(Boolean)
.join(' · ')
async function openProducedPreview(path: string) {
const preview = await normalizeOrLocalPreviewTarget(path, viewCwd || undefined)
if (preview) {
openPreview(preview, 'tool-result')
} else {
notifyError(new Error(`Could not open preview target: ${path}`), t.preview.unavailable)
}
}
return (
<div
className={cn(WIDGET_SHELL_CLASS, 'mt-1.5 text-[length:var(--conversation-tool-font-size)]')}
data-slot="aui_changed-files"
>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-(--ui-text-primary)">{copy.filesChanged(files.length)}</span>
<button
className="shrink-0 cursor-pointer text-(--ui-text-tertiary) transition-colors hover:text-(--ui-text-primary)"
onClick={() => revealReview(scopeCwd, composerScope.target)}
type="button"
>
{copy.reviewChanges}
</button>
<span className="min-w-0 flex-1 truncate text-(--ui-text-primary)">{summary}</span>
{changedCount > 0 && (
<button
className="shrink-0 cursor-pointer text-(--ui-text-tertiary) transition-colors hover:text-(--ui-text-primary)"
onClick={() => revealReview(scopeCwd, composerScope.target)}
type="button"
>
{copy.reviewChanges}
</button>
)}
</div>
<FadeScroll className="-mx-1.5 mt-1.5 flex flex-col px-1.5" maxHeight={MAX_ROWS_HEIGHT}>
{files.map(file => (
<button
className="row-hover flex shrink-0 items-center gap-2 rounded-md px-1.5 py-1 text-left"
key={file.path}
onClick={() => void openReviewForPath(file.path, scopeCwd, composerScope.target)}
title={displayPath(file.path)}
type="button"
>
<FileTypeIcon className="shrink-0 text-(--ui-text-tertiary)" path={file.path} size="0.875rem" />
<span className="min-w-0 flex-1 truncate text-(--ui-text-secondary)">{file.name}</span>
<DiffCount added={file.added} removed={file.removed} />
</button>
<div className="row-hover group flex shrink-0 items-center rounded-md" key={file.path}>
<button
className="flex min-w-0 flex-1 items-center gap-2 px-1.5 py-1 text-left"
onClick={() =>
file.hasDiff
? void openReviewForPath(file.path, scopeCwd, composerScope.target)
: void openProducedPreview(file.path)
}
title={displayPath(file.path)}
type="button"
>
<FileTypeIcon className="shrink-0 text-(--ui-text-tertiary)" path={file.path} size="0.875rem" />
<span className="min-w-0 flex-1 truncate text-(--ui-text-secondary)">{file.name}</span>
{file.hasDiff ? (
<DiffCount added={file.added} removed={file.removed} />
) : (
<span className="shrink-0 text-(--ui-text-tertiary)">{copy.produced}</span>
)}
</button>
<button
aria-label={t.preview.openWithSystemApp}
className="mr-1 hidden shrink-0 cursor-pointer text-(--ui-text-tertiary) transition-colors hover:text-(--ui-text-primary) group-hover:block"
onClick={() => void openFileWithSystemApp(file.path)}
title={t.preview.openWithSystemApp}
type="button"
>
<ExternalLink className="size-3.5" />
</button>
</div>
))}
</FadeScroll>
</div>
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import { deriveChangedFiles } from './changed-files'
const DIFF = ['--- a/src/a.ts', '+++ b/src/a.ts', '+one', '+two', '-old'].join('\n')
const editPart = (path: string, diff: string) => ({
type: 'tool-call',
toolName: 'edit_file',
args: { path },
result: { inline_diff: diff, path }
})
describe('deriveChangedFiles', () => {
it('folds diff edits per path with summed stats', () => {
const files = deriveChangedFiles([editPart('src/a.ts', DIFF), editPart('src/a.ts', DIFF)])
expect(files).toEqual([{ added: 4, hasDiff: true, name: 'a.ts', path: 'src/a.ts', removed: 2 }])
})
it('surfaces completed diff-less writes as products', () => {
const files = deriveChangedFiles([
{ type: 'tool-call', toolName: 'write_file', args: { path: 'docs/new.md' }, result: { path: 'docs/new.md' } }
])
expect(files).toEqual([{ added: 0, hasDiff: false, name: 'new.md', path: 'docs/new.md', removed: 0 }])
})
it('skips running and failed calls', () => {
const files = deriveChangedFiles([
{ type: 'tool-call', toolName: 'write_file', args: { path: 'running.ts' } },
{
type: 'tool-call',
toolName: 'write_file',
args: { path: 'failed.ts' },
isError: true,
result: { error: 'denied' }
}
])
expect(files).toEqual([])
})
it('a later diff upgrades a product row in place', () => {
const files = deriveChangedFiles([
{ type: 'tool-call', toolName: 'write_file', args: { path: 'src/a.ts' }, result: { path: 'src/a.ts' } },
editPart('src/a.ts', DIFF)
])
expect(files).toEqual([{ added: 2, hasDiff: true, name: 'a.ts', path: 'src/a.ts', removed: 1 }])
})
it('ignores non-file tools', () => {
expect(
deriveChangedFiles([{ type: 'tool-call', toolName: 'terminal', args: {}, result: { output: 'ok' } }])
).toEqual([])
})
})
@@ -13,6 +13,10 @@ import { type ToolResultMetadata, toolResultRecord } from '@/lib/tool-result-met
export interface ChangedFile {
added: number
/** False for completed writes whose result carries no diff (write_file
* creates rehydrate diff-less) — the turn's products. The card sends these
* to preview/open instead of the diff review. */
hasDiff: boolean
/** Basename, for the row label. */
name: string
/** Path exactly as the tool reported it (absolute or repo-relative). */
@@ -23,6 +27,8 @@ export interface ChangedFile {
interface ChangedFilePart {
toolResultMetadata?: ToolResultMetadata
args?: unknown
completedAt?: unknown
isError?: unknown
result?: unknown
toolName?: unknown
type?: unknown
@@ -30,8 +36,10 @@ interface ChangedFilePart {
/**
* One row per file the turn edited, in first-touched order, with the +/- of
* every edit to that file summed. Only landed edits with a diff count: a call
* still running has no result, and a failed one changed nothing.
* every edit to that file summed. Running calls have no result and failed ones
* changed nothing — neither counts. Landed edits WITH a diff carry their
* stats; completed writes WITHOUT one (new-file creates) count as diff-less
* products under the same path.
*/
export function deriveChangedFiles(parts: readonly unknown[]): ChangedFile[] {
const byPath = new Map<string, ChangedFile>()
@@ -43,27 +51,36 @@ export function deriveChangedFiles(parts: readonly unknown[]): ChangedFile[] {
continue
}
const result = toolResultRecord(part)
const diff = inlineDiffFromResult(result)
if (!diff) {
if ((part.result === undefined && part.completedAt === undefined) || part.isError) {
continue
}
const result = toolResultRecord(part)
const path = fileEditPath(parseMaybeObject(part.args), result)
if (!path) {
continue
}
const stats = countDiffLineStats(diff)
const existing = byPath.get(path)
const diff = inlineDiffFromResult(result)
if (!diff) {
if (!existing) {
byPath.set(path, { added: 0, hasDiff: false, name: fileEditBasename(path), path, removed: 0 })
}
continue
}
const stats = countDiffLineStats(diff)
if (existing) {
existing.added += stats.added
existing.removed += stats.removed
existing.hasDiff = true
} else {
byPath.set(path, { added: stats.added, name: fileEditBasename(path), path, removed: stats.removed })
byPath.set(path, { added: stats.added, hasDiff: true, name: fileEditBasename(path), path, removed: stats.removed })
}
}
+4
View File
@@ -2083,6 +2083,7 @@ export const ar = defineLocale({
urlPlaceholder: 'https://example.com',
urlHintPre: 'سيقرأ Hermes الرابط ضمن السياق.',
attach: 'إرفاق',
aiDisclaimer: 'المحتوى مُنشأ بالذكاء الاصطناعي — تحقق من المعلومات المهمة',
queued: count => `${count} في الطابور`,
attachmentOnly: 'إرفاق فقط',
emptyTurn: 'اكتب رسالة أو أرفق ملفا.',
@@ -2851,6 +2852,9 @@ export const ar = defineLocale({
errorCopyDiagnostics: 'نسخ تفاصيل الخطأ',
errorSendDiagnostics: 'إرسال التشخيصات',
filesChanged: count => `${count} ملفات تم تغييرها`,
filesProduced: count => `${count} ملفات جديدة`,
produced: 'جديد',
sessionArtifacts: count => `نتائج هذه الجلسة (${count})`,
reviewChanges: 'مراجعة',
readAloudFailed: 'فشلت القراءة بصوت عال',
preparingAudio: 'جار تجهيز الصوت',
+4
View File
@@ -2943,6 +2943,7 @@ export const en: Translations = {
urlPlaceholder: 'https://example.com/post',
urlHintPre: 'Include the full URL, e.g. ',
attach: 'Attach',
aiDisclaimer: 'AI-generated content — verify important information',
queued: count => `${count} Queued`,
queuedPaused: count => `${count} Queued — paused`,
attachmentOnly: 'Attachment-only turn',
@@ -4131,6 +4132,9 @@ export const en: Translations = {
errorCopyDiagnostics: 'Copy error details',
errorSendDiagnostics: 'Send diagnostics',
filesChanged: count => (count === 1 ? '1 file changed' : `${count} files changed`),
filesProduced: count => (count === 1 ? '1 file produced' : `${count} files produced`),
produced: 'new',
sessionArtifacts: count => (count === 1 ? 'Session artifact (1)' : `Session artifacts (${count})`),
reviewChanges: 'Review',
readAloudFailed: 'Read aloud failed',
preparingAudio: 'Preparing audio...',
+4
View File
@@ -2435,6 +2435,7 @@ export const ja = defineLocale({
urlPlaceholder: 'https://example.com/post',
urlHintPre: '完全な URL を入力してください。例: ',
attach: '添付',
aiDisclaimer: '内容は AI が生成したものです。重要な情報は確認してください',
queued: count => `${count} 件キュー済み`,
queuedPaused: count => `${count} 件キュー済み — 一時停止中`,
attachmentOnly: '添付のみのターン',
@@ -3312,6 +3313,9 @@ export const ja = defineLocale({
errorCopyDiagnostics: 'エラー詳細をコピー',
errorSendDiagnostics: '診断情報を送信',
filesChanged: count => `${count} 件のファイルを変更`,
filesProduced: count => `${count} 件のファイルを作成`,
produced: '新規',
sessionArtifacts: count => `このセッションの成果物 (${count})`,
reviewChanges: 'レビュー',
readAloudFailed: '読み上げに失敗しました',
preparingAudio: '音声を準備中...',
+4
View File
@@ -2709,6 +2709,7 @@ export const ru = defineLocale({
urlPlaceholder: 'https://example.com/post',
urlHintPre: 'Укажите полный URL, например ',
attach: 'Прикрепить',
aiDisclaimer: 'Контент создан ИИ — проверяйте важную информацию',
queued: count => `${count} в очереди`,
queuedPaused: count => `${count} в очереди — пауза`,
attachmentOnly: 'Ход только с вложениями',
@@ -3594,6 +3595,9 @@ export const ru = defineLocale({
react: 'Реакция',
dismissError: 'Скрыть ошибку',
filesChanged: count => `${count} ${RU_PLURAL(count, 'файл изменён', 'файла изменено', 'файлов изменено')}`,
filesProduced: count => `${count} ${RU_PLURAL(count, 'файл создан', 'файла создано', 'файлов создано')}`,
produced: 'новый',
sessionArtifacts: count => `Результаты сеанса (${count})`,
reviewChanges: 'Проверить',
readAloudFailed: 'Не удалось зачитать вслух',
preparingAudio: 'Подготовка аудио...',
+8
View File
@@ -2532,6 +2532,8 @@ export interface Translations {
urlPlaceholder: string
urlHintPre: string
attach: string
/** One-line muted disclaimer under the composer ("AI-generated content…"). */
aiDisclaimer: string
queued: (count: number) => string
queuedPaused: (count: number) => string
attachmentOnly: string
@@ -3537,6 +3539,12 @@ export interface Translations {
errorCopyDiagnostics: string
errorSendDiagnostics: string
filesChanged: (count: number) => string
/** Turn products: completed file writes whose result carried no diff. */
filesProduced: (count: number) => string
/** Row tag marking a produced (newly written) file in the files card. */
produced: string
/** Collapsed fold at the transcript end listing every file this session produced. */
sessionArtifacts: (count: number) => string
reviewChanges: string
readAloudFailed: string
preparingAudio: string
+4
View File
@@ -2417,6 +2417,7 @@ export const zhHant = defineLocale({
urlPlaceholder: 'https://example.com/post',
urlHintPre: '請輸入完整 URL,例如 ',
attach: '附加',
aiDisclaimer: '內容由 AI 生成,請核實重要資訊',
queued: count => `${count} 個排隊中`,
queuedPaused: count => `${count} 個排隊中 — 已暫停`,
attachmentOnly: '僅附件回合',
@@ -3265,6 +3266,9 @@ export const zhHant = defineLocale({
errorCopyDiagnostics: '複製錯誤詳細資訊',
errorSendDiagnostics: '傳送診斷資訊',
filesChanged: count => `${count} 個檔案已變更`,
filesProduced: count => `${count} 個新檔案`,
produced: '新增',
sessionArtifacts: count => `本工作階段產物 (${count})`,
reviewChanges: '檢視',
readAloudFailed: '朗讀失敗',
preparingAudio: '正在準備音訊...',
+4
View File
@@ -3085,6 +3085,7 @@ export const zh = defineLocale({
urlPlaceholder: 'https://example.com/post',
urlHintPre: '请包含完整 URL,例如 ',
attach: '附加',
aiDisclaimer: '内容由 AI 生成,请核实重要信息',
queued: count => `${count} 条排队`,
queuedPaused: count => `${count} 条排队 — 已暂停`,
attachmentOnly: '仅附件回合',
@@ -4054,6 +4055,9 @@ export const zh = defineLocale({
errorCopyDiagnostics: '复制错误详情',
errorSendDiagnostics: '发送诊断信息',
filesChanged: count => `${count} 个文件已更改`,
filesProduced: count => `${count} 个新文件`,
produced: '新建',
sessionArtifacts: count => `本会话产物 (${count})`,
reviewChanges: '查看',
readAloudFailed: '朗读失败',
preparingAudio: '正在准备音频...',