diff --git a/apps/desktop/electron/connection-registry.test.ts b/apps/desktop/electron/connection-registry.test.ts index 2ebe940aae..b9bc2a2711 100644 --- a/apps/desktop/electron/connection-registry.test.ts +++ b/apps/desktop/electron/connection-registry.test.ts @@ -116,6 +116,7 @@ test('merge preserves fields the editor does not carry (org, ssh extras)', () => org: 'nous', url: 'https://a.cloud' } + const renamed = mergeConnectionInput({ id: 'c', kind: 'cloud', label: 'Renamed', url: 'https://a.cloud' }, cloud) assert.equal(renamed.org, 'nous') diff --git a/apps/desktop/electron/image-context-menu.test.ts b/apps/desktop/electron/image-context-menu.test.ts index e9e8b9e98b..c36de87b07 100644 --- a/apps/desktop/electron/image-context-menu.test.ts +++ b/apps/desktop/electron/image-context-menu.test.ts @@ -44,10 +44,7 @@ test('keeps URL-dependent image actions when srcURL is available', () => { const { actions, calls } = createActions() const url = 'https://example.com/image.png' - const items = imageContextMenuItems( - { mediaType: 'image', hasImageContents: true, srcURL: url, x: 5, y: 8 }, - actions - ) + const items = imageContextMenuItems({ mediaType: 'image', hasImageContents: true, srcURL: url, x: 5, y: 8 }, actions) assert.deepEqual( items.map(item => item.label), diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 601971ba53..404bf05bb1 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -4677,6 +4677,7 @@ function downloadViaTokenToFile(url, token, ctx, options: any = {}) { const client = parsed.protocol === 'https:' ? https : http const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) + const req = client.request( parsed, { @@ -6838,6 +6839,7 @@ function downloadViaOauthSessionToFile(url, ctx, options: any = {}) { } const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) + const request = electronNet.request({ method: 'GET', url, @@ -6845,6 +6847,7 @@ function downloadViaOauthSessionToFile(url, ctx, options: any = {}) { useSessionCookies: true, redirect: 'follow' } as any) + let settled = false const timer = setTimeout(() => { @@ -6910,6 +6913,7 @@ async function finalizeGatewayDownload(res, statusCode, headers, ctx: any = {}) const disposition = headers['content-disposition'] || headers['Content-Disposition'] const filename = filenameFromContentDisposition(disposition) || ctx.suggested || ctx.fallbackName + const result = await dialog.showSaveDialog(mainWindow, { defaultPath: filename, title: 'Save File' @@ -6967,10 +6971,12 @@ async function saveGatewayFile(payload: any = {}) { const suggested = String(payload.suggestedName || '').trim() const fallbackName = path.basename(filePath) || suggested || 'download' const ctx = { suggested, fallbackName } + const requestPath = pathWithGlobalRemoteProfile(`/api/fs/download?path=${encodeURIComponent(filePath)}`, profile, { globalRemote: globalRemoteActive(), profileRemoteOverride: profileHasRemoteOverride(profile) }) + const url = `${connection.baseUrl}${requestPath}` try { @@ -7002,10 +7008,13 @@ async function saveGatewayFileViaDataUrl(connection, profile, filePath, ctx: any profileRemoteOverride: profileHasRemoteOverride(profile) } ) + const url = `${connection.baseUrl}${requestPath}` + const json = ( connection.authMode === 'oauth' ? await fetchJsonViaOauthSession(url) : await fetchJson(url, connection.token) ) as any + const dataUrl = json?.dataUrl if (!dataUrl) { @@ -7014,6 +7023,7 @@ async function saveGatewayFileViaDataUrl(connection, profile, filePath, ctx: any const buffer = parseDataUrlToBuffer(dataUrl) const filename = ctx.suggested || ctx.fallbackName + const result = await dialog.showSaveDialog(mainWindow, { defaultPath: filename, title: 'Save File' diff --git a/apps/desktop/src/app/artifacts/artifact-utils.ts b/apps/desktop/src/app/artifacts/artifact-utils.ts index 7da0f5b307..c0af68ccab 100644 --- a/apps/desktop/src/app/artifacts/artifact-utils.ts +++ b/apps/desktop/src/app/artifacts/artifact-utils.ts @@ -319,7 +319,8 @@ function explicitToolArtifactKey(keyPath: string, producerTool: boolean): boolea .split('.') .filter(segment => segment && !/^\d+$/.test(segment)) .some( - segment => STRONG_TOOL_ARTIFACT_KEY_RE.test(segment) || (producerTool && PRODUCER_TOOL_ARTIFACT_KEY_RE.test(segment)) + segment => + STRONG_TOOL_ARTIFACT_KEY_RE.test(segment) || (producerTool && PRODUCER_TOOL_ARTIFACT_KEY_RE.test(segment)) ) } diff --git a/apps/desktop/src/app/chat/composer/ime-composition-dom-repro.test.tsx b/apps/desktop/src/app/chat/composer/ime-composition-dom-repro.test.tsx index 36880a6cd3..917942cb43 100644 --- a/apps/desktop/src/app/chat/composer/ime-composition-dom-repro.test.tsx +++ b/apps/desktop/src/app/chat/composer/ime-composition-dom-repro.test.tsx @@ -154,7 +154,9 @@ describe('composer IME composition — send button visibility (#39614)', () => { composingRef.current = true }} onInput={event => { - if (composingRef.current) {return} + if (composingRef.current) { + return + } flushEditorToDraft(event.currentTarget) }} onKeyDown={handleKeyDown} diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts index ccea21f80a..bd32d4fd4a 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts @@ -962,7 +962,12 @@ describe('overlayLiveLanes', () => { sessionCount: 1, groups: [ lane({ id: '/www/app::branch::main', label: 'main', isMain: true, path: '/www/app', sessions: [session] }), - lane({ id: '/www/app/.worktrees/feature', label: 'feature', path: '/www/app/.worktrees/feature', sessions: [] }) + lane({ + id: '/www/app/.worktrees/feature', + label: 'feature', + path: '/www/app/.worktrees/feature', + sessions: [] + }) ] } ] diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts index 605ce38eef..9c30fca8c6 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts @@ -572,10 +572,7 @@ export function overlayRepoLanes( (placed.isMain && placedKey ? lanes.find( g => - g.isMain && - pathKey(g.path) === placedKey && - !g.id.includes('::branch::') && - !g.id.includes('::kanban') + g.isMain && pathKey(g.path) === placedKey && !g.id.includes('::branch::') && !g.id.includes('::kanban') ) : undefined) ?? (!placed.isMain && placedKey ? lanes.find(g => pathKey(g.path) === placedKey) : undefined) diff --git a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx index 5fc32e6c3b..80cddc5f06 100644 --- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx @@ -22,7 +22,14 @@ import { Codicon } from '@/components/ui/codicon' import { ColorSwatches } from '@/components/ui/color-swatches' import { ConfirmDialog } from '@/components/ui/confirm-dialog' import { CopyButton } from '@/components/ui/copy-button' -import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, preventCloseButtonAutoFocus } from '@/components/ui/dialog' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + preventCloseButtonAutoFocus +} from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { renameSession } from '@/hermes' import { useI18n } from '@/i18n' diff --git a/apps/desktop/src/app/chat/sidebar/session-row-details.test.ts b/apps/desktop/src/app/chat/sidebar/session-row-details.test.ts index aeed0025d0..cc61643eb2 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row-details.test.ts +++ b/apps/desktop/src/app/chat/sidebar/session-row-details.test.ts @@ -65,10 +65,7 @@ describe('session row details', () => { it('omits zero counts from metadata so the sidebar stays clean', () => { expect( - sessionRowDetails( - session({ git_branch: null, message_count: 0, model: null, tool_call_count: 0 }), - en - ) + sessionRowDetails(session({ git_branch: null, message_count: 0, model: null, tool_call_count: 0 }), en) ).toEqual({ metadata: '', preview: 'Explore Gmail-like density tiers for session rows.' }) }) @@ -99,10 +96,7 @@ describe('session row details', () => { } expect( - sessionRowDetails( - session({ git_branch: null, message_count: 3, model: null, tool_call_count: 5 }), - ja - ) + sessionRowDetails(session({ git_branch: null, message_count: 3, model: null, tool_call_count: 5 }), ja) ).toEqual({ metadata: '3 件のメッセージ · 5 件のツール呼び出し', preview: 'Explore Gmail-like density tiers for session rows.' diff --git a/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx index f25bfc9d53..60517df41f 100644 --- a/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx @@ -177,11 +177,7 @@ describe('refreshSessions identity + loading hygiene', () => { // both honor the tombstone, or a deleted platform thread resurrects. removed.ids = new Set(['tg-2']) listSidebarSessions.mockResolvedValue( - sidebar( - { sessions: [] }, - [], - [row('tg-1', { source: 'telegram' }), row('tg-2', { source: 'telegram' })] - ) + sidebar({ sessions: [] }, [], [row('tg-1', { source: 'telegram' }), row('tg-2', { source: 'telegram' })]) ) const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' })) @@ -194,7 +190,11 @@ describe('refreshSessions identity + loading hygiene', () => { // Per-platform pager: backend page still lists the doomed row. listAllProfileSessions.mockResolvedValue({ - sessions: [row('tg-1', { source: 'telegram' }), row('tg-2', { source: 'telegram' }), row('tg-3', { source: 'telegram' })], + sessions: [ + row('tg-1', { source: 'telegram' }), + row('tg-2', { source: 'telegram' }), + row('tg-3', { source: 'telegram' }) + ], total: 3 }) diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx index 1dc4735797..6e8bfcf9d7 100644 --- a/apps/desktop/src/app/settings/config-settings.tsx +++ b/apps/desktop/src/app/settings/config-settings.tsx @@ -28,7 +28,14 @@ import { useOnProfileSwitch } from '../hooks/use-on-profile-switch' import { PanelEmpty } from '../overlays/panel' import { ConfigField } from './config-field' -import { clearsEnabledToolsets, enumOptionsFor, getNested, isExternalMemoryProvider, sectionFieldEntries, setNested } from './helpers' +import { + clearsEnabledToolsets, + enumOptionsFor, + getNested, + isExternalMemoryProvider, + sectionFieldEntries, + setNested +} from './helpers' import { MemoryConnect } from './memory/connect' import { ProviderConfigPanel } from './memory/provider-config-panel' import { ModelSettings, ModelSettingsSkeleton } from './model-settings' diff --git a/apps/desktop/src/components/chat/activity-timer.ts b/apps/desktop/src/components/chat/activity-timer.ts index cb040e7673..56fbdee2be 100644 --- a/apps/desktop/src/components/chat/activity-timer.ts +++ b/apps/desktop/src/components/chat/activity-timer.ts @@ -68,11 +68,7 @@ export function useElapsedSeconds(active = true, timerKey?: string, since?: numb } }, [active, since, timerKey]) - useViewedInterval( - () => setElapsed(Math.max(0, Math.floor((Date.now() - start.current) / 1000))), - 1000, - active - ) + useViewedInterval(() => setElapsed(Math.max(0, Math.floor((Date.now() - start.current) / 1000))), 1000, active) return elapsed } diff --git a/apps/desktop/src/components/pet/floating-pet.tsx b/apps/desktop/src/components/pet/floating-pet.tsx index 883c015094..f60d7c7cd2 100644 --- a/apps/desktop/src/components/pet/floating-pet.tsx +++ b/apps/desktop/src/components/pet/floating-pet.tsx @@ -191,6 +191,7 @@ export function FloatingPet() { // re-sent over the WebSocket on every backstop refresh. const held = $petInfo.get() const knownRevision = held.enabled && held.spritesheetBase64 ? held.spritesheetRevision : undefined + const next = await requestGateway('pet.info', { knownRevision, profile: petProfile() @@ -253,10 +254,7 @@ export function FloatingPet() { // Always keep a timer. Event-capable backends use the slow backstop (same // contract as cron/sessions in use-background-sync); legacy keeps the // historical fast-while-inactive cadence. - const timer = window.setInterval( - pullIfVisible, - petInfoPollIntervalMs(changeEventsAvailable, active) - ) + const timer = window.setInterval(pullIfVisible, petInfoPollIntervalMs(changeEventsAvailable, active)) return () => { cancelled = true diff --git a/apps/desktop/src/components/pet/pet-info-poll.ts b/apps/desktop/src/components/pet/pet-info-poll.ts index c9b7cda306..27142aaf6f 100644 --- a/apps/desktop/src/components/pet/pet-info-poll.ts +++ b/apps/desktop/src/components/pet/pet-info-poll.ts @@ -13,10 +13,7 @@ export const PET_BACKSTOP_MS = 15_000 /** Cold-start retries after the first connect pull (fail-open recovery). */ export const PET_STARTUP_RETRY_MS = [1_000, 3_000, 8_000] as const -export function petInfoPollIntervalMs( - changeEventsAvailable: boolean, - active: boolean -): number { +export function petInfoPollIntervalMs(changeEventsAvailable: boolean, active: boolean): number { if (changeEventsAvailable) { return PET_BACKSTOP_MS } diff --git a/apps/desktop/src/components/pet/pet-sprite.test.tsx b/apps/desktop/src/components/pet/pet-sprite.test.tsx index 1adfc65140..2d0ac01760 100644 --- a/apps/desktop/src/components/pet/pet-sprite.test.tsx +++ b/apps/desktop/src/components/pet/pet-sprite.test.tsx @@ -251,12 +251,14 @@ describe('PetSprite RAF scheduling', () => { it('draws sprite frames with bicubic smoothing for illustration art', () => { const raf = installRaf() + const ctxMock = { clearRect: vi.fn(), drawImage: vi.fn(), imageSmoothingEnabled: false, imageSmoothingQuality: 'low' } as unknown as CanvasRenderingContext2D + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(ctxMock) render() diff --git a/apps/desktop/src/hooks/use-viewed-interval.ts b/apps/desktop/src/hooks/use-viewed-interval.ts index 7b99422672..5f5bc62c64 100644 --- a/apps/desktop/src/hooks/use-viewed-interval.ts +++ b/apps/desktop/src/hooks/use-viewed-interval.ts @@ -21,12 +21,14 @@ export function useViewedInterval(callback: () => void, intervalMs: number, enab } let intervalId: null | number = null + const stop = () => { if (intervalId !== null) { window.clearInterval(intervalId) intervalId = null } } + const sync = () => { const viewed = document.visibilityState === 'visible' && document.hasFocus() diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 8e7bccb851..a4b0940e7a 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -799,7 +799,8 @@ export const zh: Translations = { autosaveFailed: '自动保存失败', imported: '配置已导入', invalidJson: '配置 JSON 无效', - toolsetsWipeConfirm: '确定移除所有已启用的工具集吗?这将禁用记忆、终端、网络搜索、委派以及大多数其他工具,直到你重新启用它们。', + toolsetsWipeConfirm: + '确定移除所有已启用的工具集吗?这将禁用记忆、终端、网络搜索、委派以及大多数其他工具,直到你重新启用它们。', keepAwakeTitle: '保持电脑唤醒', keepAwakeDesc: '阻止本机休眠,让长时间或通宵运行继续进行。屏幕仍可变暗。', attachmentSizeTitle: '预览 / 图片加载大小上限', diff --git a/apps/desktop/src/lib/model-options.test.ts b/apps/desktop/src/lib/model-options.test.ts index a31e12a98e..4220d7494c 100644 --- a/apps/desktop/src/lib/model-options.test.ts +++ b/apps/desktop/src/lib/model-options.test.ts @@ -69,7 +69,9 @@ describe('requestModelOptions', () => { vi.mocked(getGlobalModelOptions).mockResolvedValueOnce(restPayload) - await expect(requestModelOptions({ gateway: gateway as never, sessionId: 'session-1' })).resolves.toEqual(restPayload) + await expect(requestModelOptions({ gateway: gateway as never, sessionId: 'session-1' })).resolves.toEqual( + restPayload + ) expect(getGlobalModelOptions).toHaveBeenCalledWith({ explicitOnly: true }) }) diff --git a/apps/desktop/src/store/reasoning-disclosure.ts b/apps/desktop/src/store/reasoning-disclosure.ts index 08821f8034..584c00a57a 100644 --- a/apps/desktop/src/store/reasoning-disclosure.ts +++ b/apps/desktop/src/store/reasoning-disclosure.ts @@ -5,9 +5,7 @@ import { persistBoolean, storedBoolean } from '@/lib/storage' const REASONING_COLLAPSED_BY_DEFAULT_STORAGE_KEY = 'hermes.desktop.reasoning.collapsedByDefault' /** Desktop-local presentation preference; shared backend config must not be changed by a single window. */ -export const $reasoningCollapsedByDefault = atom( - storedBoolean(REASONING_COLLAPSED_BY_DEFAULT_STORAGE_KEY, false) -) +export const $reasoningCollapsedByDefault = atom(storedBoolean(REASONING_COLLAPSED_BY_DEFAULT_STORAGE_KEY, false)) $reasoningCollapsedByDefault.subscribe(value => persistBoolean(REASONING_COLLAPSED_BY_DEFAULT_STORAGE_KEY, value)) diff --git a/ui-tui/packages/hermes-ink/src/ink/parse-keypress-drop-probe.test.ts b/ui-tui/packages/hermes-ink/src/ink/parse-keypress-drop-probe.test.ts index cfd1940369..3fd03507b1 100644 --- a/ui-tui/packages/hermes-ink/src/ink/parse-keypress-drop-probe.test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/parse-keypress-drop-probe.test.ts @@ -45,7 +45,7 @@ const cases: Array<[string, string, string]> = [ ['DEL-space-DEL + char', '\x7f \x7fô', 'ô'], ['trailing text after multi DEL', '\x7f\x7f\x7fươn', 'ươn'], ['char then DEL then char fused', 'o\x7fô', 'ô'], - ['multiple syllable fused', 'vuon\x7f\x7f\x7fương', 'vương'], + ['multiple syllable fused', 'vuon\x7f\x7f\x7fương', 'vương'] // CR/LF are intentionally NOT split (preserve paste/return semantics), so a // text token with an embedded CR is left whole; assert it is NOT split into // surviving letters here — that path is covered by the composer's return / diff --git a/ui-tui/src/__tests__/imeVietnameseTelex.test.tsx b/ui-tui/src/__tests__/imeVietnameseTelex.test.tsx index f4dc2e2438..fffd243b05 100644 --- a/ui-tui/src/__tests__/imeVietnameseTelex.test.tsx +++ b/ui-tui/src/__tests__/imeVietnameseTelex.test.tsx @@ -145,9 +145,27 @@ describe('Vietnamese Telex IME recomposition', () => { // Captured byte stream for Telex "vuonwg syx hanhj": each syllable injects a // U+202F marker, erases, and re-emits. Verified across read timings. const reads = [ - 'v', 'u', 'o', NNBSP, '\x7f\x7f', '\x7f\u01B0\u01A1', 'n', 'g', - ' ', 's', 'y', NNBSP, '\x7f', '\x7f\u1EF9', - ' ', 'h', 'a', 'n', 'h', NNBSP, '\x7f\x7f\x7f\x7f\u1EA1nh' + 'v', + 'u', + 'o', + NNBSP, + '\x7f\x7f', + '\x7f\u01B0\u01A1', + 'n', + 'g', + ' ', + 's', + 'y', + NNBSP, + '\x7f', + '\x7f\u1EF9', + ' ', + 'h', + 'a', + 'n', + 'h', + NNBSP, + '\x7f\x7f\x7f\x7f\u1EA1nh' ] for (const gapMs of [0, 17, 25]) { @@ -240,4 +258,4 @@ describe('Fast-echo suppression reset (60ms window)', () => { const reads2 = [...reads, '\x7f', '\x7f'] expect(await drive(reads2)).toBe('hel') }) -}) \ No newline at end of file +}) diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index 3314c35545..b81ae4d154 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -266,12 +266,14 @@ class TurnController { endReasoningPhase() { this.reasoningStreamingTimer = clear(this.reasoningStreamingTimer) + // Seal any open reasoning segment so its isLiveReasoning flag drops the // moment the reasoning phase ends — the panel must stop tracking the // turn's global reasoningActive, not stay "live" for the rest of the turn. if (this.reasoningSegmentIndex !== null) { this.syncReasoningSegment(false) } + patchTurnState({ reasoningActive: false, reasoningStreaming: false }) } diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 1bb0c88282..86b05e07e8 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -944,9 +944,11 @@ export function TextInput({ // guard. Use a short real-time window that spans a recompose burst; // normal typing re-enables fast-echo via the append path below. inkRepaintedRef.current = true + if (inkRepaintResetTimer.current) { clearTimeout(inkRepaintResetTimer.current) } + inkRepaintResetTimer.current = setTimeout(() => { inkRepaintResetTimer.current = null inkRepaintedRef.current = false @@ -1446,10 +1448,12 @@ export function TextInput({ // terminal baseline is synced again — clear any pending Ink-repaint // fast-echo suppression so normal backspace fast-echo resumes. inkRepaintedRef.current = false + if (inkRepaintResetTimer.current) { clearTimeout(inkRepaintResetTimer.current) inkRepaintResetTimer.current = null } + // ASCII-printable text advances the physical cursor by exactly // text.length cells (canFastAppendShape rejects non-ASCII, // wide chars, newlines). Notify Ink so the cached displayCursor