diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 17d563cb37..416b443c2b 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -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 (
{showProfileTag && } + {sessionCwd && ( + + {displayPath(sessionCwd)} + / + + )} (footerSessionId ? : undefined), + () => + footerSessionId ? ( + <> + + + + ) : undefined, [footerSessionId] ) diff --git a/apps/desktop/src/app/chat/session-artifacts.test.tsx b/apps/desktop/src/app/chat/session-artifacts.test.tsx new file mode 100644 index 0000000000..ea6e1d9f3b --- /dev/null +++ b/apps/desktop/src/app/chat/session-artifacts.test.tsx @@ -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([]) + }) +}) diff --git a/apps/desktop/src/app/chat/session-artifacts.tsx b/apps/desktop/src/app/chat/session-artifacts.tsx new file mode 100644 index 0000000000..f6c12ed6ea --- /dev/null +++ b/apps/desktop/src/app/chat/session-artifacts.tsx @@ -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[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() + + 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 ( +
+ + {expanded && ( +
+ {artifacts.map(artifact => ( +
+ + +
+ ))} +
+ )} +
+ ) +} diff --git a/apps/desktop/src/components/assistant-ui/thread/changed-files-card.tsx b/apps/desktop/src/components/assistant-ui/thread/changed-files-card.tsx index 6d3f6b8346..ebf1a503f4 100644 --- a/apps/desktop/src/components/assistant-ui/thread/changed-files-card.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/changed-files-card.tsx @@ -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 (
- {copy.filesChanged(files.length)} - + {summary} + {changedCount > 0 && ( + + )}
{files.map(file => ( - +
+ + +
))}
diff --git a/apps/desktop/src/components/assistant-ui/thread/changed-files.test.ts b/apps/desktop/src/components/assistant-ui/thread/changed-files.test.ts new file mode 100644 index 0000000000..ac595c4727 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread/changed-files.test.ts @@ -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([]) + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/thread/changed-files.ts b/apps/desktop/src/components/assistant-ui/thread/changed-files.ts index 5742fb0c4f..8d4b72dd50 100644 --- a/apps/desktop/src/components/assistant-ui/thread/changed-files.ts +++ b/apps/desktop/src/components/assistant-ui/thread/changed-files.ts @@ -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() @@ -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 }) } } diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index f2416c67b1..d3af6e3fd3 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -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: 'جار تجهيز الصوت', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index a092fe0ffd..0273bb313a 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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...', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 4ac1ae515d..fc22f2c76b 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -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: '音声を準備中...', diff --git a/apps/desktop/src/i18n/ru.ts b/apps/desktop/src/i18n/ru.ts index 1264d71afa..41d3ac80e2 100644 --- a/apps/desktop/src/i18n/ru.ts +++ b/apps/desktop/src/i18n/ru.ts @@ -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: 'Подготовка аудио...', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index edd1f22f5b..17250cd76d 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -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 diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 3d6854fac6..14c2de425a 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -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: '正在準備音訊...', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index e0a6d3469c..6812b62498 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -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: '正在准备音频...',