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:
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: 'جار تجهيز الصوت',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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: '音声を準備中...',
|
||||
|
||||
@@ -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: 'Подготовка аудио...',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '正在準備音訊...',
|
||||
|
||||
@@ -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: '正在准备音频...',
|
||||
|
||||
Reference in New Issue
Block a user