From c0364fa02eadf26abaec99c42f4b4c142b85ff75 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 29 Jul 2026 18:47:15 -0500 Subject: [PATCH 1/2] fix(desktop): treat landed memory writes as success, not warnings Trust explicit success over a stale isError envelope so real saves stop painting amber. Over-budget refusals keep a soft warning with "Memory write noted" instead of crowing "Saved", and entry_count labels as entries. --- .../assistant-ui/tool/fallback-model.test.ts | 35 +++++++++++++++++++ .../assistant-ui/tool/fallback-model/index.ts | 35 ++++++++++++++++--- apps/desktop/src/i18n/ar.ts | 1 + apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/ja.ts | 1 + apps/desktop/src/i18n/types.ts | 2 ++ apps/desktop/src/i18n/zh-hant.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + 8 files changed, 72 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback-model.test.ts b/apps/desktop/src/components/assistant-ui/tool/fallback-model.test.ts index 19a8cd61447..3ad0e174e1c 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback-model.test.ts +++ b/apps/desktop/src/components/assistant-ui/tool/fallback-model.test.ts @@ -415,3 +415,38 @@ describe('countDiffLineStats', () => { expect(countDiffLineStats(`--- a/x\n+++ b/x\n@@\n-old\n+new\n context\n+another`)).toEqual({ added: 2, removed: 1 }) }) }) + +describe('buildToolView memory status', () => { + const memory = (overrides: Partial[0]> = {}) => + buildToolView(part({ toolName: 'memory', ...overrides }), '') + + it('treats an explicit success payload as success even with isError', () => { + const view = memory({ + isError: true, + result: { + success: true, + entry_count: 13, + message: 'Applied 1 operation(s).', + duration_s: 0.003 + } + }) + + expect(view.status).toBe('success') + expect(view.title).toBe('Saved to memory') + expect(view.countLabel).toBe('13 entries') + expect(view.subtitle).toBe('Applied 1 operation(s).') + }) + + it('uses soft warning copy for over-budget refusals, not "Saved"', () => { + const view = memory({ + result: { + success: false, + error: 'Memory is full (2,200/2,200). Consolidate before adding more.' + } + }) + + expect(view.status).toBe('warning') + expect(view.title).toBe('Memory write noted') + expect(view.subtitle).toContain('Memory is full') + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts b/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts index 5a7a5066c62..52e6a44a8d1 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts +++ b/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts @@ -516,6 +516,16 @@ function toolResultCount( } } + // Memory success payloads put the live total on `entry_count` — keep the noun + // as entry/entries instead of falling through the generic `*_count` path. + if (part.toolName === 'memory') { + const entryTotal = countFromUnknown(resultRecord.entry_count) + + if (entryTotal !== null) { + return countMetric(entryTotal, 'entry') + } + } + const directCount = countFromRecord(resultRecord, fallbackNounByTool) if (directCount !== null) { @@ -699,14 +709,20 @@ function toolStatus(part: ToolPart, resultRecord: Record): Tool return 'running' } + // Explicit success wins over isError / nested-error heuristics. Memory writes + // return `{ success: true }` when the batch landed; a stale outer `isError` + // envelope must not paint a real save amber. + if (resultRecord.success === true || resultRecord.ok === true) { + return 'success' + } + if (!toolErrorText(part, resultRecord)) { return 'success' } // A rejected memory write is a budget negotiation, not a failure: the store - // refuses an over-limit batch and the agent immediately retries a smaller - // one. Painting the row destructive-red puts an alarm next to routine - // bookkeeping the user never has to act on. Amber says "noted" instead. + // refuses an over-limit batch and the agent retries smaller. Soft warning — + // never destructive-red beside routine bookkeeping. return part.toolName === 'memory' ? 'warning' : 'error' } @@ -1395,8 +1411,17 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView { const resultRecord = parseMaybeObject(part.result) const meta = toolMeta(part.toolName) const status = toolStatus(part, resultRecord) - const error = toolErrorText(part, resultRecord) - const baseTitle = part.result === undefined ? meta.pending : meta.done + // Skip residual error-heuristic text once status is success (stale isError + // envelope over a landed memory write would otherwise foul the subtitle). + const error = status === 'success' ? '' : toolErrorText(part, resultRecord) + // Over-budget memory refusals stay amber — don't claim "Saved". + const memoryMissed = part.toolName === 'memory' && part.result !== undefined && status !== 'success' + const baseTitle = + part.result === undefined + ? meta.pending + : memoryMissed + ? translateNow('assistant.tool.memoryWriteNoted') + : meta.done const titleParts = dynamicTitle( part, diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index c72898a9cea..48866637b0d 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -2349,6 +2349,7 @@ export const ar = defineLocale({ statusError: 'خطأ', statusRecovered: 'تم الاسترداد', statusDone: 'تم', + memoryWriteNoted: 'تم تسجيل كتابة الذاكرة', actions: { read: 'قراءة', reading: 'جار القراءة', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index d55c494e070..8ada8b48b31 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2758,6 +2758,7 @@ export const en: Translations = { statusError: 'Error', statusRecovered: 'Recovered', statusDone: 'Done', + memoryWriteNoted: 'Memory write noted', actions: { read: 'Read', reading: 'Reading', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index ad8f206abe2..84b9cf95201 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -2602,6 +2602,7 @@ export const ja = defineLocale({ statusError: 'エラー', statusRecovered: '回復しました', statusDone: '完了', + memoryWriteNoted: 'メモリへの書き込みを記録', actions: { read: '読み取り完了', reading: '読み取り中', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 8ccc8a034ca..3ec175fd002 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -2354,6 +2354,8 @@ export interface Translations { statusError: string statusRecovered: string statusDone: string + /** Over-budget / rejected memory write title — not "Saved to memory". */ + memoryWriteNoted: string actions: { read: string reading: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 5343d395724..e27b8284fd5 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -2521,6 +2521,7 @@ export const zhHant = defineLocale({ statusError: '錯誤', statusRecovered: '已復原', statusDone: '完成', + memoryWriteNoted: '已記下記憶寫入', actions: { read: '已讀取', reading: '正在讀取', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 86049412b4c..1797214d098 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2933,6 +2933,7 @@ export const zh: Translations = { statusError: '错误', statusRecovered: '已恢复', statusDone: '完成', + memoryWriteNoted: '已记下记忆写入', actions: { read: '已读取', reading: '正在读取', From 508e73a15afcdf8c31783735e2aa5b8fb3a8a93b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 29 Jul 2026 18:47:15 -0500 Subject: [PATCH 2/2] =?UTF-8?q?style(desktop):=20gold=E2=86=92purple=20chr?= =?UTF-8?q?ome=20on=20landed=20memory=20saves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paint successful memory tool rows with a gradient title, tinted brain glyph, and purple meta so a save reads prized rather than like a warning. --- .../components/assistant-ui/tool/fallback.tsx | 37 ++++++++++++++++--- apps/desktop/src/styles.css | 30 +++++++++++++++ 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx index 9447399d1a2..0ea7f4760fd 100644 --- a/apps/desktop/src/components/assistant-ui/tool/fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/fallback.tsx @@ -214,11 +214,14 @@ function ToolGlyph({ copy, filePath, icon, + legendary, status }: { copy: ToolStatusCopy filePath?: string icon?: string + /** Landed memory write — keep the brain glyph, tint it gold→purple. */ + legendary?: boolean status?: ToolStatus }) { const node = status ? ( @@ -226,10 +229,16 @@ function ToolGlyph({ ) : filePath ? ( ) : icon ? ( - + ) : null - return node ? {node} : null + return node ? ( + {node} + ) : null } // Which status (if any) should pre-empt the tool's icon in the leading @@ -275,11 +284,13 @@ function LinkifiedText({ className, text }: { className?: string; text: string } function ToolTitle({ isPending, + legendary, status, title, titleAction }: { isPending: boolean + legendary?: boolean status: ToolStatus title: string titleAction?: ToolTitleAction @@ -290,7 +301,8 @@ function ToolTitle({ SCAFFOLD_LABEL_CLASS, isPending && 'text-(--conversation-scaffold-meta)', status === 'error' && 'text-destructive', - status === 'warning' && 'text-amber-700 dark:text-amber-300' + status === 'warning' && 'text-amber-700 dark:text-amber-300', + legendary && !isPending && 'tool-memory-legendary-title text-transparent' )} > {isPending && titleAction ? ( @@ -466,6 +478,10 @@ function ToolEntry({ part }: ToolEntryProps) { const showDiffStats = !isPending && Boolean(diffStats && (diffStats.added > 0 || diffStats.removed > 0)) + // Landed memory write gets gold→purple chrome instead of the plain scaffold grey. + const memoryLegendary = !isPending && part.toolName === 'memory' && view.status === 'success' + const memoryMetaClass = memoryLegendary ? 'tool-memory-legendary-meta' : undefined + // The header trailing slot only carries the live duration timer while the // tool is running. The copy control used to live here too, but an // `opacity-0` (yet still clickable) button straddling the caret/duration made @@ -542,10 +558,19 @@ function ToolEntry({ part }: ToolEntryProps) { copy={copy} filePath={isFileEdit ? view.subtitle : undefined} icon={view.icon} + legendary={memoryLegendary} status={leadingStatus(isPending, view.status)} /> - - {!isPending && view.countLabel && {view.countLabel}} + + {!isPending && view.countLabel && ( + {view.countLabel} + )} {showDiffStats && diffStats && ( {diffStats.added > 0 && ( @@ -557,7 +582,7 @@ function ToolEntry({ part }: ToolEntryProps) { )} {!isFileEdit && !isPending && view.durationLabel && ( - {view.durationLabel} + {view.durationLabel} )} diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index aba9d14a64c..18b52070a50 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -188,6 +188,13 @@ --context-usage-subagents: color-mix(in srgb, var(--ui-blue) 70%, var(--ui-cyan)); --context-usage-memory: color-mix(in srgb, var(--ui-orange) 80%, var(--ui-yellow)); --context-usage-conversation: var(--ui-cyan); + /* Landed memory-write tool row — gold → purple, never amber warning. */ + --tool-memory-legendary-from: color-mix(in srgb, var(--ui-yellow) 72%, #f5d08a); + --tool-memory-legendary-mid: color-mix(in srgb, var(--ui-orange) 55%, var(--ui-purple)); + --tool-memory-legendary-to: color-mix(in srgb, var(--ui-purple) 82%, #c4b5fd); + --tool-memory-legendary-icon: color-mix(in srgb, var(--ui-yellow) 55%, var(--ui-purple)); + --tool-memory-legendary-meta: color-mix(in srgb, var(--ui-purple) 58%, var(--ui-text-tertiary)); + --tool-memory-legendary-glow: color-mix(in srgb, var(--ui-purple) 38%, transparent); /* Diff add/remove, derived from the semantic palette so every diff surface (tool cards, preview, review pane) tracks the theme's green/red. Only the foregrounds need a dark override — they mix toward the page instead of @@ -738,6 +745,29 @@ } } +/* Landed `memory` tool row — gold → purple on the brain glyph + title. + Pair with `text-transparent` so the scaffold label color can't win. */ +.tool-memory-legendary-title { + background-image: linear-gradient( + 105deg, + var(--tool-memory-legendary-from) 0%, + var(--tool-memory-legendary-mid) 48%, + var(--tool-memory-legendary-to) 100% + ); + -webkit-background-clip: text; + background-clip: text; + color: transparent !important; + font-weight: 600; +} + +.tool-memory-legendary-glyph { + filter: drop-shadow(0 0 0.28rem var(--tool-memory-legendary-glow)); +} + +.tool-memory-legendary-meta { + color: var(--tool-memory-legendary-meta); +} + .quest-glow { animation: quest-glow 1.8s ease-in-out infinite; }