From 844cf7d0f1f8d302be6c519567897b025ab4c575 Mon Sep 17 00:00:00 2001 From: QAyong Date: Tue, 29 Sep 2026 22:37:53 +0800 Subject: [PATCH] fix(buddy): stabilize desktop notification navigation Retain native notifications until explicit dismissal so a completion toast can still open its original conversation after the user switches tasks. Queue and acknowledge navigation targets across window and renderer readiness, and preserve message reveals until scrolling completes. Register a Windows shortcut for development toast delivery and document the confirmed cross-conversation workflow. --- .../main/DesktopNotificationService.ts | 103 +++++-- .../DesktopNotificationService.spec.ts | 205 +++++++++---- .../buddy/electron/main/__tests__/ipc.spec.ts | 2 + .../electron/main/app/DesktopIntegrations.ts | 14 +- .../electron/main/app/DesktopWindowHost.ts | 52 +++- .../app/__tests__/DesktopWindowHost.spec.ts | 86 ++++++ apps/buddy/electron/main/app/environment.ts | 27 +- apps/buddy/electron/main/ipc.ts | 19 +- apps/buddy/electron/preload/desktop.ts | 24 +- apps/buddy/electron/shared/desktopApi.ts | 10 +- .../src/app/bootstrap/DesktopAppProvider.vue | 22 +- .../__tests__/useDesktopNavigation.spec.ts | 54 +++- .../src/app/bootstrap/useDesktopLifecycle.ts | 1 + .../src/app/bootstrap/useDesktopNavigation.ts | 77 +++-- .../src/app/workbench/ActiveTaskProjection.ts | 3 + .../src/app/workbench/useDesktopWorkbench.ts | 2 +- apps/buddy/src/i18n/locales/en-US/app.ts | 3 + apps/buddy/src/i18n/locales/zh-CN/app.ts | 3 + apps/buddy/src/modules/tasks/taskContext.ts | 1 + .../workspace/DesktopChatWorkspace.vue | 18 +- .../__tests__/useChatViewport.spec.ts | 26 +- .../widgets/workspace/useChatViewport.ts | 61 +++- .../widgets/workspace/useChatWorkspace.ts | 6 + ...0260929-desktop-notification-navigation.md | 290 ++++++++++++++++++ 24 files changed, 962 insertions(+), 147 deletions(-) create mode 100644 apps/buddy/electron/main/app/__tests__/DesktopWindowHost.spec.ts create mode 100644 docs/bugs/bug-20260929-desktop-notification-navigation.md diff --git a/apps/buddy/electron/main/DesktopNotificationService.ts b/apps/buddy/electron/main/DesktopNotificationService.ts index abbdd934..6a740f9e 100644 --- a/apps/buddy/electron/main/DesktopNotificationService.ts +++ b/apps/buddy/electron/main/DesktopNotificationService.ts @@ -11,7 +11,10 @@ export interface DesktopNotificationInput { } export interface DesktopSystemNotification { + close: () => void onClick: (listener: () => void) => void + onClose: (listener: (reason?: 'userCanceled' | 'applicationHidden' | 'timedOut') => void) => void + onFailed: (listener: () => void) => void show: () => void } @@ -28,6 +31,7 @@ export interface DesktopNotificationServiceOptions { 'notificationsEnabled' | 'notifyWhenFocused' > isWindowFocused: () => boolean + onError: (error: unknown) => void openTarget: (target: DesktopNotificationTarget) => Promise | void request: DesktopRuntimeGateway['request'] } @@ -50,19 +54,23 @@ const BODY_LABELS = { export class DesktopNotificationService { readonly #options: DesktopNotificationServiceOptions readonly #shownEvents = new Set() + readonly #pendingEvents = new Set() + // Keep native click handlers alive independently of the selected conversation. + readonly #notifications = new Map() + #disposed = false constructor(options: DesktopNotificationServiceOptions) { this.#options = options } async handle(notification: { method: string, params: unknown }): Promise { - if (notification.method !== 'run.event') + if (this.#disposed || notification.method !== 'run.event') return const event = runsRequestSchemas.runStateEvent.safeParse(notification.params) if (!event.success) return const eventKey = `${event.data.runId}:${event.data.sequence}:${event.data.type}` - if (this.#shownEvents.has(eventKey)) + if (this.#shownEvents.has(eventKey) || this.#pendingEvents.has(eventKey)) return if (!shouldShowDesktopNotification({ eventType: event.data.type, @@ -72,24 +80,79 @@ export class DesktopNotificationService { return } - const run = runsResponseSchemas.run.parse( - await this.#options.request('runs.get', { runId: event.data.runId }), - ) - const conversation = conversationResponseSchemas.conversation.parse( - await this.#options.request('conversations.get', { conversationId: run.conversationId }), - ) - const labels = BODY_LABELS[this.#options.getLanguage()] - const systemNotification = this.#options.createNotification({ - body: labels[event.data.type as keyof Omit], - title: conversation.title?.trim() || labels.untitled, - }) - systemNotification.onClick(() => { - void this.#options.openTarget({ - conversationId: run.conversationId, - runId: run.id, + this.#pendingEvents.add(eventKey) + try { + const run = runsResponseSchemas.run.parse( + await this.#options.request('runs.get', { runId: event.data.runId }), + ) + if (this.#disposed) + return + const conversation = conversationResponseSchemas.conversation.parse( + await this.#options.request('conversations.get', { conversationId: run.conversationId }), + ) + if (this.#disposed) + return + const labels = BODY_LABELS[this.#options.getLanguage()] + const systemNotification = this.#options.createNotification({ + body: labels[event.data.type as keyof Omit], + title: conversation.title?.trim() || labels.untitled, }) - }) - systemNotification.show() - this.#shownEvents.add(eventKey) + this.#notifications.set(eventKey, systemNotification) + const release = () => { + if (this.#notifications.get(eventKey) !== systemNotification) + return false + this.#notifications.delete(eventKey) + return true + } + systemNotification.onClick(() => { + if (!release()) + return + void Promise.resolve().then(() => { + if (!this.#disposed) { + return this.#options.openTarget({ + conversationId: run.conversationId, + runId: run.id, + }) + } + }).catch(error => this.#options.onError(error)) + }) + systemNotification.onClose((reason) => { + // A timed-out/hidden Windows banner can still be clicked in Action Center. + if (reason === 'userCanceled') + release() + }) + systemNotification.onFailed(() => { + if (release()) + this.#shownEvents.delete(eventKey) + }) + this.#shownEvents.add(eventKey) + try { + systemNotification.show() + } + catch (error) { + release() + this.#shownEvents.delete(eventKey) + throw error + } + } + finally { + this.#pendingEvents.delete(eventKey) + } + } + + dispose(): void { + this.#disposed = true + const notifications = [...this.#notifications.values()] + this.#notifications.clear() + this.#shownEvents.clear() + this.#pendingEvents.clear() + for (const notification of notifications) { + try { + notification.close() + } + catch (error) { + this.#options.onError(error) + } + } } } diff --git a/apps/buddy/electron/main/__tests__/DesktopNotificationService.spec.ts b/apps/buddy/electron/main/__tests__/DesktopNotificationService.spec.ts index 17e160c3..b8c2dc23 100644 --- a/apps/buddy/electron/main/__tests__/DesktopNotificationService.spec.ts +++ b/apps/buddy/electron/main/__tests__/DesktopNotificationService.spec.ts @@ -1,77 +1,156 @@ +import type { DesktopSystemNotification } from '../DesktopNotificationService' import { describe, expect, it, vi } from 'vitest' import { DesktopNotificationService } from '../DesktopNotificationService' -describe('desktopNotificationService', () => { - it('shows one generic conversation notification and opens its target', async () => { - let click: (() => void) | null = null - const show = vi.fn() - const openTarget = vi.fn() - const service = new DesktopNotificationService({ - createNotification() { - return { - onClick(listener) { - click = listener - }, - show, - } - }, - getLanguage: () => 'zh-CN', - getSettings: () => ({ notificationsEnabled: true, notifyWhenFocused: false }), - isWindowFocused: () => false, - openTarget, - request: async (method) => { - if (method === 'runs.get') { - return { - approvalPolicy: 'policy', - branchId: 'branch-1', - completedAt: '2026-08-19T08:00:00.000Z', - conversationId: 'conversation-1', - errorCode: null, - executionProfile: 'workspace_write', - id: 'run-1', - modelId: 'gpt-5', - providerId: 'openai', - purpose: 'chat', - reasoningLevel: 'high', - startedAt: '2026-08-19T07:59:00.000Z', - status: 'completed', - triggeringMessageId: 'message-1', - } - } +type TestNotification = DesktopSystemNotification & { + click: () => void + dismiss: (reason?: 'userCanceled' | 'applicationHidden' | 'timedOut') => void + fail: () => void +} + +function fixture() { + const notifications: TestNotification[] = [] + const target = { conversationId: 'conversation-1', runId: 'run-1' } + const openTarget = vi.fn() + const onError = vi.fn() + const service = new DesktopNotificationService({ + createNotification() { + let click = () => {} + let dismiss: (reason?: 'userCanceled' | 'applicationHidden' | 'timedOut') => void = () => {} + let fail = () => {} + const notification = { + close: vi.fn(), + click: () => click(), + dismiss: (reason?: 'userCanceled' | 'applicationHidden' | 'timedOut') => dismiss(reason), + fail: () => fail(), + onClick(listener) { + click = listener + }, + onClose(listener) { + dismiss = listener + }, + onFailed(listener) { + fail = listener + }, + show: vi.fn(), + } satisfies TestNotification + notifications.push(notification) + return notification + }, + getLanguage: () => 'zh-CN', + getSettings: () => ({ notificationsEnabled: true, notifyWhenFocused: false }), + isWindowFocused: () => false, + onError, + openTarget, + request: async (method) => { + if (method === 'runs.get') { return { - activeBranchId: 'branch-1', approvalPolicy: 'policy', - createdAt: '2026-08-19T07:58:00.000Z', - deletedAt: null, + branchId: 'branch-1', + completedAt: '2026-08-19T08:00:00.000Z', + conversationId: target.conversationId, + errorCode: null, executionProfile: 'workspace_write', - id: 'conversation-1', - modelSelection: null, - spaceId: null, - title: '整理发布说明', - updatedAt: '2026-08-19T08:00:00.000Z', + id: target.runId, + modelId: 'gpt-5', + providerId: 'openai', + purpose: 'chat', + reasoningLevel: 'high', + startedAt: '2026-08-19T07:59:00.000Z', + status: 'completed', + triggeringMessageId: 'message-1', } - }, - }) - const notification = { - method: 'run.event', - params: { - createdAt: '2026-08-19T08:00:00.000Z', - payload: { errorCode: null }, - runId: 'run-1', - sequence: 8, - type: 'run.completed', - }, - } + } + return { + activeBranchId: 'branch-1', + approvalPolicy: 'policy', + createdAt: '2026-08-19T07:58:00.000Z', + deletedAt: null, + executionProfile: 'workspace_write', + id: target.conversationId, + modelSelection: null, + spaceId: null, + title: '整理发布说明', + updatedAt: '2026-08-19T08:00:00.000Z', + } + }, + }) + const event = { + method: 'run.event', + params: { + createdAt: '2026-08-19T08:00:00.000Z', + payload: { errorCode: null }, + runId: 'run-1', + sequence: 8, + type: 'run.completed', + }, + } - await service.handle(notification) - await service.handle(notification) + return { service, notifications, openTarget, onError, event, target } +} - expect(show).toHaveBeenCalledOnce() - expect(click).not.toBeNull() - click!() - expect(openTarget).toHaveBeenCalledWith({ +describe('desktopNotificationService', () => { + it('shows one notification for concurrent or replayed events and opens its target once', async () => { + const f = fixture() + await Promise.all([f.service.handle(f.event), f.service.handle(f.event)]) + await f.service.handle(f.event) + + expect(f.notifications).toHaveLength(1) + expect(f.notifications[0]!.show).toHaveBeenCalledOnce() + f.notifications[0]!.click() + f.notifications[0]!.click() + await vi.waitFor(() => expect(f.openTarget).toHaveBeenCalledOnce()) + expect(f.openTarget).toHaveBeenCalledWith({ conversationId: 'conversation-1', runId: 'run-1', }) }) + + it('keeps the original target clickable after banner timeout and another conversation notification', async () => { + const f = fixture() + await f.service.handle(f.event) + f.target.conversationId = 'conversation-2' + f.target.runId = 'run-2' + await f.service.handle({ ...f.event, params: { ...f.event.params, runId: 'run-2', sequence: 9 } }) + const older = f.notifications[0]! + older.dismiss('timedOut') + older.dismiss('applicationHidden') + older.click() + await vi.waitFor(() => expect(f.openTarget).toHaveBeenCalledWith({ conversationId: 'conversation-1', runId: 'run-1' })) + f.service.dispose() + expect(older.close).not.toHaveBeenCalled() + expect(f.notifications[1]!.close).toHaveBeenCalledOnce() + }) + + it('ignores dismissed clicks and closes retained notifications when the service stops', async () => { + const f = fixture() + await f.service.handle(f.event) + f.notifications[0]!.dismiss('userCanceled') + f.notifications[0]!.click() + await f.service.handle({ ...f.event, params: { ...f.event.params, sequence: 9 } }) + f.notifications[1]!.dismiss('timedOut') + f.service.dispose() + f.service.dispose() + f.notifications[1]!.click() + await f.service.handle({ ...f.event, params: { ...f.event.params, sequence: 10 } }) + await Promise.resolve() + expect(f.openTarget).not.toHaveBeenCalled() + expect(f.notifications).toHaveLength(2) + expect(f.notifications[0]!.close).not.toHaveBeenCalled() + expect(f.notifications[1]!.close).toHaveBeenCalledOnce() + }) + + it('releases failed deliveries for retry and reports rejected click navigation', async () => { + const f = fixture() + await f.service.handle(f.event) + f.notifications[0]!.fail() + await f.service.handle(f.event) + f.notifications[0]!.click() + const error = new Error('Window unavailable') + f.openTarget.mockRejectedValueOnce(error) + f.notifications[1]!.click() + await vi.waitFor(() => expect(f.onError).toHaveBeenCalledWith(error)) + expect(f.openTarget).toHaveBeenCalledOnce() + expect(f.notifications).toHaveLength(2) + }) }) diff --git a/apps/buddy/electron/main/__tests__/ipc.spec.ts b/apps/buddy/electron/main/__tests__/ipc.spec.ts index 3f1ad535..cfaad242 100644 --- a/apps/buddy/electron/main/__tests__/ipc.spec.ts +++ b/apps/buddy/electron/main/__tests__/ipc.spec.ts @@ -36,6 +36,8 @@ describe('registerDesktopIpc', () => { getSandboxStatus: async () => 'available', setupSandbox: async () => 'cancelled', getWindow: () => window, + getPendingOpenTarget: () => null, + completeOpenTarget: vi.fn(), onConfigUpdated: vi.fn(), openFeedbackIssue: vi.fn(), openReleasePage: vi.fn(), diff --git a/apps/buddy/electron/main/app/DesktopIntegrations.ts b/apps/buddy/electron/main/app/DesktopIntegrations.ts index f79c958a..0245ae56 100644 --- a/apps/buddy/electron/main/app/DesktopIntegrations.ts +++ b/apps/buddy/electron/main/app/DesktopIntegrations.ts @@ -121,7 +121,15 @@ export class DesktopIntegrations { const notifications = new DesktopNotificationService({ createNotification(input) { const notification = new Notification(input) - return { onClick: listener => notification.on('click', listener), show: () => notification.show() } + notification.on('show', () => diagnostics.record({ scope: 'desktop', level: 'info', event: 'notification.shown' })) + notification.on('failed', (_event, error) => diagnostics.record({ scope: 'desktop', level: 'warn', event: 'notification.delivery_failed', error: new Error(error) })) + return { + close: () => notification.close(), + onClick: listener => notification.once('click', listener), + onClose: listener => notification.on('close', details => listener(process.platform === 'win32' ? details.reason : 'userCanceled')), + onFailed: listener => notification.once('failed', listener), + show: () => notification.show(), + } }, getLanguage: () => runtime.language, getSettings: () => ({ @@ -129,6 +137,7 @@ export class DesktopIntegrations { notifyWhenFocused: runtime.config?.desktop.notifyWhenFocused ?? false, }), isWindowFocused: () => windows.window?.isFocused() ?? false, + onError: error => diagnostics.record({ scope: 'desktop', level: 'warn', event: 'notification.target.failed', error }), openTarget: target => windows.openTarget(target), request: service.request.bind(service), }) @@ -159,6 +168,7 @@ export class DesktopIntegrations { diagnostics.record({ scope: 'desktop', level: 'warn', event: 'notification.failed', error }) }) })) + this.#subscriptions.push(() => notifications.dispose()) const updates = registerDesktopUpdates(this.#environment, runtime, windows) this.#subscriptions.push(updates.dispose) registerDesktopIpc({ @@ -171,6 +181,8 @@ export class DesktopIntegrations { executeCommand: this.executeCommand, getWindow: () => windows.window, onConfigUpdated: config => this.applyConfig(config), + getPendingOpenTarget: () => windows.getPendingOpenTarget(), + completeOpenTarget: (requestId, result) => windows.completeOpenTarget(requestId, result), openFeedbackIssue: feedback => shell.openExternal(createFeedbackIssueUrl(feedback)), openReleasePage: url => shell.openExternal(url), }) diff --git a/apps/buddy/electron/main/app/DesktopWindowHost.ts b/apps/buddy/electron/main/app/DesktopWindowHost.ts index 7b6ff54e..62100e14 100644 --- a/apps/buddy/electron/main/app/DesktopWindowHost.ts +++ b/apps/buddy/electron/main/app/DesktopWindowHost.ts @@ -1,5 +1,5 @@ import type { BrowserWindow } from 'electron' -import type { LexoraConfig } from '../../shared/desktopApi' +import type { DesktopOpenTarget, DesktopOpenTargetRequest, DesktopOpenTargetResult, LexoraConfig } from '../../shared/desktopApi' import type { DesktopEnvironment } from './typing' import process from 'node:process' import { app, nativeTheme, screen } from 'electron' @@ -21,6 +21,9 @@ export class DesktopWindowHost { readonly #environment: DesktopEnvironment #manager: DesktopWindowManager | null = null #onRecoveryExhausted: (() => void) | null = null + #pendingTarget: DesktopOpenTargetRequest | null = null + #targetVersion = 0 + #rendererReady = false constructor(environment: DesktopEnvironment) { this.#environment = environment @@ -50,6 +53,7 @@ export class DesktopWindowHost { bindings.onRecoveryExhausted() }, createWindow: () => { + this.#rendererReady = false const handle = createDesktopWindow({ appName: environment.paths.appName, iconPath: environment.desktopIconPath, @@ -72,6 +76,10 @@ export class DesktopWindowHost { handle.window.webContents.on('did-finish-load', () => { environment.events.publish({ level: 'info', event: 'window.loaded' }) }) + handle.window.webContents.on('did-start-navigation', (_event, _url, inPlace, mainFrame) => { + if (mainFrame && !inPlace) + this.#rendererReady = false + }) handle.window.once('closed', () => { environment.events.publish({ level: 'info', event: 'window.closed' }) }) @@ -85,7 +93,7 @@ export class DesktopWindowHost { }) this.#manager = manager this.#onRecoveryExhausted = bindings.onRecoveryExhausted - if (!environment.isSmokeTest && environment.initialLaunchIntent === 'foreground') + if (this.#pendingTarget || (!environment.isSmokeTest && environment.initialLaunchIntent === 'foreground')) await manager.open() return manager.window ?? manager.load() } @@ -100,15 +108,48 @@ export class DesktopWindowHost { }) } - async openTarget(target: { conversationId: string, runId: string }): Promise { + getPendingOpenTarget(): DesktopOpenTargetRequest | null { + this.#rendererReady = true + return this.#pendingTarget ? { ...this.#pendingTarget } : null + } + + completeOpenTarget(requestId: number, result: DesktopOpenTargetResult): void { + if (this.#pendingTarget?.requestId !== requestId) + return + this.#environment.events.publish({ level: result === 'failed' ? 'warn' : 'info', event: `notification.target.${result}` }) + this.#pendingTarget = null + } + + async openTarget(target: DesktopOpenTarget): Promise { + const request = { ...target, requestId: ++this.#targetVersion } + this.#pendingTarget = request + this.#environment.events.publish({ level: 'info', event: 'notification.clicked' }) if (!this.#manager) return if (this.#manager.recoveryExhausted) { this.#onRecoveryExhausted?.() return } - await this.#manager.open() - this.window?.webContents.send(DESKTOP_IPC_CHANNELS.appOpenTarget, target) + try { + await this.#manager.open() + } + catch (error) { + this.#environment.diagnostics.record({ scope: 'desktop', level: 'error', event: 'notification.target.open_failed', error }) + try { + await this.#manager.open() + } + catch (fallbackError) { + this.#environment.diagnostics.record({ scope: 'desktop', level: 'error', event: 'notification.target.retry_failed', error: fallbackError }) + return + } + } + try { + if (this.#rendererReady && this.#pendingTarget === request) + this.window?.webContents.send(DESKTOP_IPC_CHANNELS.appOpenTarget, request) + } + catch (error) { + this.#environment.diagnostics.record({ scope: 'desktop', level: 'error', event: 'notification.target.delivery_failed', error }) + } } updateAppearance(): void { @@ -128,5 +169,6 @@ export class DesktopWindowHost { this.#manager?.dispose() this.#manager = null this.#onRecoveryExhausted = null + this.#rendererReady = false } } diff --git a/apps/buddy/electron/main/app/__tests__/DesktopWindowHost.spec.ts b/apps/buddy/electron/main/app/__tests__/DesktopWindowHost.spec.ts new file mode 100644 index 00000000..87e12341 --- /dev/null +++ b/apps/buddy/electron/main/app/__tests__/DesktopWindowHost.spec.ts @@ -0,0 +1,86 @@ +import type { BrowserWindow } from 'electron' +import type { DesktopEnvironment } from '../typing' +import { EventEmitter } from 'node:events' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DESKTOP_IPC_CHANNELS } from '../../../shared/desktopApi' +import { DesktopWindowHost } from '../DesktopWindowHost' + +const native = vi.hoisted(() => ({ create: vi.fn() })) +vi.mock('electron', () => ({ app: { isPackaged: false }, nativeTheme: { shouldUseDarkColors: false }, screen: { getAllDisplays: () => [] } })) +vi.mock('../../window', () => ({ createDesktopWindow: native.create, applyDesktopWindowAppearance: vi.fn() })) +vi.mock('../../desktopWindowState', () => ({ + DesktopWindowStateStore: class { + async read() { return null } + }, + resolveVisibleWindowPlacement: () => null, +})) + +beforeEach(() => native.create.mockReset()) + +async function fixture(beforeInitialize?: (host: DesktopWindowHost) => Promise) { + const window = Object.assign(new EventEmitter(), { + webContents: Object.assign(new EventEmitter(), { send: vi.fn() }), + isDestroyed: () => false, + isMinimized: () => true, + restore: vi.fn(), + show: vi.fn(), + focus: vi.fn(), + }) + native.create.mockReturnValue({ window: window as unknown as BrowserWindow, load: async () => {} }) + const record = vi.fn() + const host = new DesktopWindowHost({ + paths: { windowState: 'unused' }, + events: { publish: vi.fn() }, + diagnostics: { record }, + initialLaunchIntent: 'background', + } as unknown as DesktopEnvironment) + await beforeInitialize?.(host) + await host.initialize({ isQuitting: () => false, onHidden: vi.fn(), onRecoveryExhausted: vi.fn(), onWindowCreated: vi.fn() }) + return { host, window, record } +} + +describe('notification window targets', () => { + it('keeps an early click until the renderer subscribes and brings the window forward', async () => { + const { host, window } = await fixture(host => host.openTarget({ conversationId: 'early', runId: 'run' })) + expect(window.show).toHaveBeenCalledOnce() + expect(window.restore).toHaveBeenCalledOnce() + expect(window.focus).toHaveBeenCalledOnce() + expect(window.webContents.send).not.toHaveBeenCalled() + expect(host.getPendingOpenTarget()).toMatchObject({ conversationId: 'early', runId: 'run' }) + }) + + it('reports failures, ignores old acknowledgements and waits for a reloaded renderer', async () => { + const { host, window } = await fixture() + host.getPendingOpenTarget() + await host.openTarget({ conversationId: 'a', runId: 'run-a' }) + const a = host.getPendingOpenTarget()! + await host.openTarget({ conversationId: 'b', runId: 'run-b' }) + const b = host.getPendingOpenTarget()! + host.completeOpenTarget(a.requestId, 'opened') + expect(host.getPendingOpenTarget()).toEqual(b) + host.completeOpenTarget(b.requestId, 'failed') + expect(host.getPendingOpenTarget()).toBeNull() + expect(window.webContents.send).toHaveBeenLastCalledWith(DESKTOP_IPC_CHANNELS.appOpenTarget, b) + window.webContents.emit('did-start-navigation', {}, 'url', false, true) + window.webContents.send.mockClear() + await host.openTarget({ conversationId: 'c', runId: 'run-c' }) + expect(window.webContents.send).not.toHaveBeenCalled() + const c = host.getPendingOpenTarget()! + expect(c.conversationId).toBe('c') + host.completeOpenTarget(c.requestId, 'opened') + expect(host.getPendingOpenTarget()).toBeNull() + }) + + it('records a window activation failure and retries showing without dropping the target', async () => { + const { host, window, record } = await fixture() + host.getPendingOpenTarget() + window.show.mockImplementationOnce(() => { + throw new Error('window activation failed') + }) + await host.openTarget({ conversationId: 'a', runId: 'run-a' }) + await vi.waitFor(() => expect(window.show).toHaveBeenCalledTimes(2)) + expect(record).toHaveBeenCalledWith(expect.objectContaining({ event: 'notification.target.open_failed' })) + expect(host.getPendingOpenTarget()).toMatchObject({ conversationId: 'a' }) + expect(window.webContents.send).toHaveBeenCalledWith(DESKTOP_IPC_CHANNELS.appOpenTarget, expect.objectContaining({ conversationId: 'a' })) + }) +}) diff --git a/apps/buddy/electron/main/app/environment.ts b/apps/buddy/electron/main/app/environment.ts index 6e656379..bd4cccde 100644 --- a/apps/buddy/electron/main/app/environment.ts +++ b/apps/buddy/electron/main/app/environment.ts @@ -1,9 +1,9 @@ import type { DesktopEnvironment } from './typing' import { mkdirSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' -import { dirname } from 'node:path' +import { dirname, join } from 'node:path' import process from 'node:process' -import { app, crashReporter, Menu, protocol } from 'electron' +import { app, crashReporter, Menu, protocol, shell } from 'electron' import buddyPackage from '../../../package.json' import { currentPlatform } from '../../../platform/currentPlatform' import { resolveBuddyPrivateDirectories } from '../../../platform/native/nativeHost' @@ -133,5 +133,28 @@ export async function prepareDesktopReady(environment: DesktopEnvironment): Prom environment.diagnostics.record({ scope: 'desktop', level: 'warn', event: 'window_state.unavailable', ...readDiagnosticError(error) }) } app.setAppUserModelId(paths.desktopName) + if (process.platform === 'win32' && !app.isPackaged && paths.profile === 'development') { + try { + // The installer supplies this registration for packaged builds. Keep the + // development activator stable so its shortcut matches across restarts. + app.setToastActivatorCLSID('{74C2AE92-485D-4BF5-9E30-68A2C2C3B79E}') + const programs = join(app.getPath('appData'), 'Microsoft', 'Windows', 'Start Menu', 'Programs') + mkdirSync(programs, { recursive: true }) + const registered = shell.writeShortcutLink(join(programs, `${paths.appName}.lnk`), 'create', { + target: process.execPath, + args: `"${app.getAppPath()}"`, + cwd: app.getAppPath(), + appUserModelId: paths.desktopName, + toastActivatorClsid: app.toastActivatorCLSID, + description: paths.appName, + }) + if (!registered) + throw new Error('Could not register the development notification shortcut') + environment.diagnostics.record({ scope: 'desktop', level: 'info', event: 'notification.development_registered' }) + } + catch (error) { + environment.diagnostics.record({ scope: 'desktop', level: 'warn', event: 'notification.registration_failed', error }) + } + } Menu.setApplicationMenu(null) } diff --git a/apps/buddy/electron/main/ipc.ts b/apps/buddy/electron/main/ipc.ts index 34197dd0..87b81af2 100644 --- a/apps/buddy/electron/main/ipc.ts +++ b/apps/buddy/electron/main/ipc.ts @@ -1,6 +1,6 @@ import type { BrowserWindow, IpcMainInvokeEvent } from 'electron' import type { BuddyRuntimeProfile } from '../../shared/runtime/profile' -import type { LexoraConfig } from '../shared/desktopApi' +import type { DesktopOpenTargetRequest, DesktopOpenTargetResult, LexoraConfig } from '../shared/desktopApi' import type { LexoraConfigStore } from './config/LexoraConfigStore' import type { ExecuteDesktopCommand } from './desktopCommands' import process from 'node:process' @@ -29,6 +29,8 @@ export interface RegisterDesktopIpcOptions { runtimeProfile: BuddyRuntimeProfile configStore: LexoraConfigStore getWindow: () => BrowserWindow | null + getPendingOpenTarget: () => DesktopOpenTargetRequest | null + completeOpenTarget: (requestId: number, result: DesktopOpenTargetResult) => void onConfigUpdated: (config: LexoraConfig) => Promise | void openFeedbackIssue: (feedback: string) => Promise openReleasePage: (url: string) => Promise @@ -36,6 +38,21 @@ export interface RegisterDesktopIpcOptions { } export function registerDesktopIpc(options: RegisterDesktopIpcOptions): void { + ipcMain.handle(DESKTOP_IPC_CHANNELS.appGetPendingOpenTarget, (event) => { + assertTrustedSender(event, options.getWindow()) + return options.getPendingOpenTarget() + }) + ipcMain.handle(DESKTOP_IPC_CHANNELS.appCompleteOpenTarget, (event, input: unknown) => { + assertTrustedSender(event, options.getWindow()) + if (!input || typeof input !== 'object') + throw new Error('Invalid Desktop notification result') + const { requestId, result } = input as Record + if (typeof requestId !== 'number' || !Number.isSafeInteger(requestId) || requestId <= 0 + || (result !== 'opened' && result !== 'cancelled' && result !== 'failed')) { + throw new Error('Invalid Desktop notification result') + } + options.completeOpenTarget(requestId, result) + }) ipcMain.handle(DESKTOP_IPC_CHANNELS.appSetupSandbox, async (event) => { assertTrustedSender(event, options.getWindow()) return sandboxSetupResultSchema.parse(await options.setupSandbox()) diff --git a/apps/buddy/electron/preload/desktop.ts b/apps/buddy/electron/preload/desktop.ts index 794aff95..deb6c945 100644 --- a/apps/buddy/electron/preload/desktop.ts +++ b/apps/buddy/electron/preload/desktop.ts @@ -4,7 +4,7 @@ import type { ApplicationStartupState } from '../../shared/diagnostics/applicati import type { CpuProfileRequest } from '../../shared/diagnostics/performanceDiagnostic' import type { RendererDiagnosticReport } from '../../shared/diagnostics/rendererDiagnostic' import type { RendererLifecycleReport } from '../../shared/lifecycle/serviceLifecycle' -import type { DesktopAppInfo, DesktopOpenTarget, DesktopWindowState, LexoraConfigPatch, LexoraDesktopApi } from '../shared/desktopApi' +import type { DesktopAppInfo, DesktopOpenTarget, DesktopOpenTargetRequest, DesktopOpenTargetResult, DesktopWindowState, LexoraConfigPatch, LexoraDesktopApi } from '../shared/desktopApi' import type { DesktopCommandId } from '../shared/desktopCommands' import type { DesktopUpdateAction, DesktopUpdateState } from '../shared/desktopUpdates' import { ipcRenderer, webUtils } from 'electron' @@ -69,9 +69,25 @@ export function createDesktopApi(): Pick ipcRenderer.off(DESKTOP_IPC_CHANNELS.appPrepareQuit, handler) }, - onOpenTarget: (listener: (target: DesktopOpenTarget) => void) => ( - subscribe(DESKTOP_IPC_CHANNELS.appOpenTarget, listener) - ), + onOpenTarget: (listener: (target: DesktopOpenTarget) => Promise) => { + let disposed = false + let latestRequestId = 0 + async function dispatch(request: DesktopOpenTargetRequest | null) { + if (!request || disposed || request.requestId <= latestRequestId) + return + latestRequestId = request.requestId + const result = await listener({ conversationId: request.conversationId, runId: request.runId }) + if (!disposed) + await ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.appCompleteOpenTarget, { requestId: request.requestId, result }) + } + const reportError = (error: unknown) => console.error('Desktop notification navigation failed', error) + const stop = subscribe(DESKTOP_IPC_CHANNELS.appOpenTarget, request => void dispatch(request).catch(reportError)) + void ipcRenderer.invoke(DESKTOP_IPC_CHANNELS.appGetPendingOpenTarget).then(dispatch).catch(reportError) + return () => { + disposed = true + stop() + } + }, onHidden: (listener: () => void) => subscribe(DESKTOP_IPC_CHANNELS.appHidden, listener), openFeedbackIssue: (feedback: string) => ipcRenderer.invoke( DESKTOP_IPC_CHANNELS.appOpenFeedbackIssue, diff --git a/apps/buddy/electron/shared/desktopApi.ts b/apps/buddy/electron/shared/desktopApi.ts index f9e117f4..5e6e0a80 100644 --- a/apps/buddy/electron/shared/desktopApi.ts +++ b/apps/buddy/electron/shared/desktopApi.ts @@ -39,6 +39,8 @@ export const DESKTOP_IPC_CHANNELS = { appOpenFeedbackIssue: 'lexora:app:open-feedback-issue', appOpenReleasePage: 'lexora:app:open-release-page', appOpenTarget: 'lexora:app:open-target', + appGetPendingOpenTarget: 'lexora:app:get-pending-open-target', + appCompleteOpenTarget: 'lexora:app:complete-open-target', appHidden: 'lexora:app:hidden', appPrepareQuit: 'lexora:app:prepare-quit', appPrepareQuitAck: 'lexora:app:prepare-quit-ack', @@ -79,6 +81,12 @@ export interface DesktopOpenTarget { runId: string } +export interface DesktopOpenTargetRequest extends DesktopOpenTarget { + requestId: number +} + +export type DesktopOpenTargetResult = 'opened' | 'cancelled' | 'failed' + export interface DesktopWindowState { isMaximized: boolean } @@ -223,7 +231,7 @@ export interface LexoraDesktopApi { setupSandbox: () => Promise onBeforeQuit: (listener: () => Promise) => () => void onHidden: (listener: () => void) => () => void - onOpenTarget: (listener: (target: DesktopOpenTarget) => void) => () => void + onOpenTarget: (listener: (target: DesktopOpenTarget) => Promise) => () => void openFeedbackIssue: (feedback: string) => Promise openReleasePage: (url: string) => Promise } diff --git a/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue b/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue index 14624c75..74249bb3 100644 --- a/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue +++ b/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue @@ -182,9 +182,10 @@ provide(runtimeAvailabilityKey, { loading: lifecycle.loading, failed: lifecycle. const navigation = useDesktopNavigation({ router, ready, + isReady: () => lifecycle.dataReady.value, session: { - activeTaskId: computed(() => selectedTask.value?.session.activeTaskId.value ?? null), - spaceId: computed(() => selectedTask.value?.session.spaceId.value ?? null), + activeTaskId: computed(() => workbench.activeResource.value?.scheme === 'task' ? workbench.activeResource.value.id : null), + spaceId: computed(() => selectedTask.value?.session.spaceId.value ?? (workbench.activeResource.value?.data.spaceId as string | null | undefined) ?? null), navigationVersion: () => workbench.navigationVersion, openTask: (id, signal) => workbench.openTask(id, signal), startTask: spaceId => workbench.newTask(spaceId), @@ -199,10 +200,22 @@ const navigation = useDesktopNavigation({ return task.workspace.session.activeBranchId.value === run.branchId || await task.workspace.transcript.activateBranch(run.branchId) }, - onError: () => message.error(translateBuddy(stores.applicationSettings.language.value, 'desktop.command.failed')), + onError: (error) => { + const key = error instanceof Error && error.message === 'DESKTOP_NOTIFICATION_BRANCH_UNAVAILABLE' + ? 'desktop.notification.branchUnavailable' + : error instanceof Error && error.message === 'DESKTOP_NOTIFICATION_NOT_READY' + ? 'desktop.notification.notReady' + : 'desktop.command.failed' + message.error(translateBuddy(stores.applicationSettings.language.value, key)) + }, }) const { notificationTarget } = navigation -onScopeDispose(api.app.onOpenTarget(navigation.openTarget)) +let stopOpenTarget: (() => void) | undefined +watch(lifecycle.dataReady, (ready) => { + stopOpenTarget?.() + stopOpenTarget = ready ? api.app.onOpenTarget(navigation.openTarget) : undefined +}, { immediate: true }) +onScopeDispose(() => stopOpenTarget?.()) const browserGuestHost = useTemplateRef('browserGuestHost') const browserGuests = useBrowserGuestHost(browserGuestHost) onScopeDispose(workbench.controller.subscribe(() => void nextTick(() => browserGuests.layout?.()))) @@ -242,6 +255,7 @@ useProvideDesktopUi({ appSidebarCollapsed: shell.appSidebarCollapsed, }) useProvideTaskEnvironment({ + completeNotificationReveal: navigation.completeNotificationReveal, resources, browser: api.browser, browserGuests, diff --git a/apps/buddy/src/app/bootstrap/__tests__/useDesktopNavigation.spec.ts b/apps/buddy/src/app/bootstrap/__tests__/useDesktopNavigation.spec.ts index 6aa931fe..aca7c08f 100644 --- a/apps/buddy/src/app/bootstrap/__tests__/useDesktopNavigation.spec.ts +++ b/apps/buddy/src/app/bootstrap/__tests__/useDesktopNavigation.spec.ts @@ -10,7 +10,7 @@ import { useDesktopNavigation } from '../useDesktopNavigation' const scopes: ReturnType[] = [] afterEach(() => scopes.splice(0).forEach(scope => scope.stop())) -async function fixture(ready = Promise.resolve()) { +async function fixture(ready = Promise.resolve(), isReady?: () => boolean) { const router = createRouter({ history: createMemoryHistory(), routes: [ { name: DESKTOP_ROUTE_NAMES.tasks, path: '/tasks', component: {} }, { name: DESKTOP_ROUTE_NAMES.settingsApp, path: '/settings/app', component: {} }, @@ -42,6 +42,7 @@ async function fixture(ready = Promise.resolve()) { const navigation = scope.run(() => useDesktopNavigation({ router, ready, + isReady, getRun, activateRunBranch, notifications: { markSeen }, @@ -87,11 +88,32 @@ describe('desktop navigation intent', () => { const a = f.navigation.openTarget({ conversationId: 'a', runId: 'a' }) const b = f.navigation.openTarget({ conversationId: 'b', runId: 'b' }) ready.resolve() + await vi.waitFor(() => expect(f.navigation.notificationTarget.value?.conversationId).toBe('b')) + f.navigation.completeNotificationReveal('b', 'message-b') await Promise.all([a, b]) expect(f.activeTaskId.value).toBe('b') - expect(f.navigation.notificationTarget.value).toEqual({ conversationId: 'b', messageId: 'message-b' }) + expect(f.getRun).toHaveBeenCalledTimes(1) + expect(f.getRun).toHaveBeenCalledWith('b') + expect(f.activateRunBranch).toHaveBeenCalledTimes(1) + expect(f.activateRunBranch).toHaveBeenCalledWith(expect.objectContaining({ branchId: 'branch-b' })) + expect(f.navigation.notificationTarget.value).toBeNull() }) + it('waits for the target conversation to become active before looking up and activating its run', async () => { + const ready = deferred() + const f = await fixture(ready.promise) + const opening = f.navigation.openTarget({ conversationId: 'run', runId: 'run' }) + await nextTick() + expect(f.getRun).not.toHaveBeenCalled() + ready.resolve() + await vi.waitFor(() => expect(f.navigation.notificationTarget.value?.conversationId).toBe('run')) + f.navigation.completeNotificationReveal('run', 'message-run') + await opening + expect(f.activeTaskId.value).toBe('run') + expect(f.getRun).toHaveBeenCalledWith('run') + expect(f.activateRunBranch).toHaveBeenCalledWith(expect.objectContaining({ branchId: 'branch-run' })) + expect(f.navigation.notificationTarget.value).toBeNull() + }) it.each(['route', 'task', 'dispose'])('cancels a queued target after a newer %s operation', async (operation) => { const ready = deferred() const f = await fixture(ready.promise) @@ -113,14 +135,36 @@ describe('desktop navigation intent', () => { it('keeps the queued notification across automatic startup restoration', async () => { const ready = deferred() - const f = await fixture(ready.promise) + let initialized = false + const f = await fixture(ready.promise, () => initialized) const opening = f.navigation.openTarget({ conversationId: 'a', runId: 'a' }) - f.activeTaskId.value = 'restored' + f.select('restored') await nextTick() + initialized = true ready.resolve() + await vi.waitFor(() => expect(f.navigation.notificationTarget.value?.conversationId).toBe('a')) + f.navigation.completeNotificationReveal('a', 'message-a') await opening expect(f.activeTaskId.value).toBe('a') - expect(f.navigation.notificationTarget.value).toEqual({ conversationId: 'a', messageId: 'message-a' }) + expect(f.navigation.notificationTarget.value).toBeNull() + }) + + it('keeps a notification target after three seconds while its message is still loading', async () => { + vi.useFakeTimers() + try { + const f = await fixture() + const opening = f.navigation.openTarget({ conversationId: 'a', runId: 'a' }) + await vi.waitFor(() => expect(f.navigation.notificationTarget.value?.conversationId).toBe('a')) + await vi.advanceTimersByTimeAsync(5_000) + expect(f.navigation.notificationTarget.value).toEqual({ conversationId: 'a', messageId: 'message-a' }) + f.select('manual') + await nextTick() + await expect(opening).resolves.toBe('cancelled') + expect(f.navigation.notificationTarget.value).toBeNull() + } + finally { + vi.useRealTimers() + } }) it('discards an old run lookup after A to B to A selection', async () => { diff --git a/apps/buddy/src/app/bootstrap/useDesktopLifecycle.ts b/apps/buddy/src/app/bootstrap/useDesktopLifecycle.ts index 0270fff1..0afe2464 100644 --- a/apps/buddy/src/app/bootstrap/useDesktopLifecycle.ts +++ b/apps/buddy/src/app/bootstrap/useDesktopLifecycle.ts @@ -36,6 +36,7 @@ export function useDesktopLifecycle(options: DesktopLifecycleOptions) { const runtimeReady = computed(() => state.value.stages.some(stage => stage.stage === 'runtime.connection' && stage.status === 'completed')) const loading = computed(() => !dataReady.value && !failed.value && state.value.status !== 'failed') const hasFailed = computed(() => failed.value || state.value.status === 'failed') + watch(hasFailed, value => value && resolveReady(), { immediate: true, flush: 'sync' }) const preparation = Promise.all([appState.initialize(), shell.initialize()]) void preparation.catch(() => { diff --git a/apps/buddy/src/app/bootstrap/useDesktopNavigation.ts b/apps/buddy/src/app/bootstrap/useDesktopNavigation.ts index e83d29cd..4a56a9ab 100644 --- a/apps/buddy/src/app/bootstrap/useDesktopNavigation.ts +++ b/apps/buddy/src/app/bootstrap/useDesktopNavigation.ts @@ -1,16 +1,16 @@ -import type { DesktopOpenTarget } from '@buddy-electron/shared/desktopApi' +import type { DesktopOpenTarget, DesktopOpenTargetResult } from '@buddy-electron/shared/desktopApi' import type { LocalRun } from '@buddy-shared/runs/runApi' import type { RouteLocationRaw, Router } from 'vue-router' import type { NotificationCenterStore } from '@/modules/notifications' import type { DesktopNotification } from '@/modules/notifications/contracts' import type { TaskSession } from '@/modules/tasks/contracts' -import { useTimeoutFn } from '@vueuse/core' import { onScopeDispose, readonly, shallowRef, watch } from 'vue' import { desktopRouteLocations } from '@/shared/navigation/desktopRoutes' interface DesktopNavigationOptions { router: Router ready: Promise + isReady?: () => boolean session: Pick notifications: Pick openUpdate: () => Promise @@ -24,14 +24,15 @@ type RunTarget = Pick(null) - const highlightTimer = useTimeoutFn(() => notificationTarget.value = null, 3_000, { immediate: false }) let pending: { controller: AbortController, taskId: string | null, spaceId?: string, version: number } | null = null let disposed = false + let finishReveal: ((result: DesktopOpenTargetResult) => void) | null = null function cancel() { + finishReveal?.('cancelled') + finishReveal = null pending?.controller.abort() pending = null - highlightTimer.stop() notificationTarget.value = null } @@ -40,6 +41,8 @@ export function useDesktopNavigation(options: DesktopNavigationOptions) { cancel() }) watch([session.activeTaskId, session.spaceId], ([taskId, spaceId]) => { + if (options.isReady?.() === false) + return if (pending && session.navigationVersion() !== pending.version && (taskId !== pending.taskId || (pending.spaceId !== undefined && spaceId !== pending.spaceId))) cancel() }) @@ -62,41 +65,69 @@ export function useDesktopNavigation(options: DesktopNavigationOptions) { } } - async function openTask(conversationId: string, runId?: string) { - await openWorkspace({ taskId: conversationId }, async (signal) => { + function completeNotificationReveal(conversationId: string, messageId: string, result: 'opened' | 'cancelled' = 'opened') { + if (notificationTarget.value?.conversationId !== conversationId || notificationTarget.value.messageId !== messageId) + return + finishReveal?.(result) + finishReveal = null + notificationTarget.value = null + } + + async function openTaskTarget(conversationId: string, runId?: string, waitForReveal = false) { + return openWorkspace({ taskId: conversationId }, async (signal) => { await session.openTask(conversationId, signal) - if (signal.aborted || session.activeTaskId.value !== conversationId || !runId) - return + if (signal.aborted || session.activeTaskId.value !== conversationId) + return 'cancelled' + if (!runId) + return 'opened' const run = await options.getRun(runId) - if (signal.aborted || session.activeTaskId.value !== conversationId || run?.conversationId !== conversationId) - return - if (!await options.activateRunBranch(run) || signal.aborted || session.activeTaskId.value !== conversationId) - return + if (signal.aborted || session.activeTaskId.value !== conversationId) + return 'cancelled' + if (run?.conversationId !== conversationId) + throw new Error('DESKTOP_NOTIFICATION_TARGET_UNAVAILABLE') + const activated = await options.activateRunBranch(run) + if (signal.aborted || session.activeTaskId.value !== conversationId) + return 'cancelled' + if (!activated) + throw new Error('DESKTOP_NOTIFICATION_BRANCH_UNAVAILABLE') + const reveal = Promise.withResolvers() + finishReveal = reveal.resolve notificationTarget.value = { conversationId, messageId: run.triggeringMessageId } - highlightTimer.start() + return waitForReveal ? reveal.promise : 'opened' }) } + async function openTask(conversationId: string, runId?: string) { + await openTaskTarget(conversationId, runId) + } + async function openSpace(spaceId: string) { - await openWorkspace({ taskId: null, spaceId }, () => session.startTask(spaceId)) + await openWorkspace({ taskId: null, spaceId }, async () => { + await session.startTask(spaceId) + return 'opened' + }) } - async function openWorkspace(target: { taskId: string | null, spaceId?: string }, activate: (signal: AbortSignal) => Promise) { + async function openWorkspace(target: { taskId: string | null, spaceId?: string }, activate: (signal: AbortSignal) => Promise): Promise { cancel() if (disposed) - return + return 'cancelled' const controller = new AbortController() pending = { ...target, controller, version: session.navigationVersion() } try { await router.push(desktopRouteLocations.tasks()) await options.ready - if (!controller.signal.aborted) { - await activate(controller.signal) - } + if (controller.signal.aborted) + return 'cancelled' + if (options.isReady?.() === false) + throw new Error('DESKTOP_NOTIFICATION_NOT_READY') + return await activate(controller.signal) } catch (error) { - if (!controller.signal.aborted) - options.onError(error) + if (controller.signal.aborted) + return 'cancelled' + options.onError(error) + return 'failed' } } @@ -117,10 +148,10 @@ export function useDesktopNavigation(options: DesktopNavigationOptions) { } function openTarget(target: DesktopOpenTarget) { - return openTask(target.conversationId, target.runId) + return openTaskTarget(target.conversationId, target.runId, true) } - return { navigate, notificationTarget: readonly(notificationTarget), openNotification, openSpace, openTarget, openTask } + return { navigate, notificationTarget: readonly(notificationTarget), completeNotificationReveal, openNotification, openSpace, openTarget, openTask } } export type DesktopNavigation = ReturnType diff --git a/apps/buddy/src/app/workbench/ActiveTaskProjection.ts b/apps/buddy/src/app/workbench/ActiveTaskProjection.ts index f0d53253..c1b6f503 100644 --- a/apps/buddy/src/app/workbench/ActiveTaskProjection.ts +++ b/apps/buddy/src/app/workbench/ActiveTaskProjection.ts @@ -1,11 +1,13 @@ import type { TaskWorkspacePool } from './TaskWorkspacePool' import type { TaskCapability } from '@/modules/tasks/contracts' +import type { ResourceRef } from '@/workbench/common/workbench' import type { WorkbenchController } from '@/workbench/services/WorkbenchController' import { computed, shallowRef } from 'vue' export class ActiveTaskProjection { readonly #active = shallowRef(null) readonly current = computed(() => this.#active.value) + readonly resource = shallowRef(null) readonly #subscriptions: (() => void)[] constructor(readonly controller: WorkbenchController, readonly pool: TaskWorkspacePool) { @@ -23,6 +25,7 @@ export class ActiveTaskProjection { reconcile(): void { const pane = this.controller.pane(this.controller.layout.activePane) const view = pane?.view ? this.controller.layout.views[pane.view] : null + this.resource.value = view?.resource ?? null const task = view ? this.pool.peek(view.resource) : undefined this.#active.value = task?.workspace.restoration.state.value === 'ready' ? task : null } diff --git a/apps/buddy/src/app/workbench/useDesktopWorkbench.ts b/apps/buddy/src/app/workbench/useDesktopWorkbench.ts index 18fe6741..294d2c18 100644 --- a/apps/buddy/src/app/workbench/useDesktopWorkbench.ts +++ b/apps/buddy/src/app/workbench/useDesktopWorkbench.ts @@ -543,7 +543,7 @@ export function useDesktopWorkbench(options: { api: LexoraDesktopApi, events: Ap await inputs.flush().catch(options.onError) return saved } - return { api, renderers, fileToolbarTargets, fileView, closeContextFiles, readingPositions, discardTask, prepareTaskDeletion, activeTask, backupError, controller, copies, models, pool, persistence, initialize, flush, dispose, openTask, newTask, startTaskWithSkill, openFile, dropResource, language, get initialized() { + return { api, renderers, fileToolbarTargets, fileView, closeContextFiles, readingPositions, discardTask, prepareTaskDeletion, activeTask, activeResource: activity.resource, backupError, controller, copies, models, pool, persistence, initialize, flush, dispose, openTask, newTask, startTaskWithSkill, openFile, dropResource, language, get initialized() { return initialized.value }, get navigationVersion() { return navigationVersion diff --git a/apps/buddy/src/i18n/locales/en-US/app.ts b/apps/buddy/src/i18n/locales/en-US/app.ts index f15d957a..3bb7e77e 100644 --- a/apps/buddy/src/i18n/locales/en-US/app.ts +++ b/apps/buddy/src/i18n/locales/en-US/app.ts @@ -38,6 +38,9 @@ export default { 'desktop.command.run': 'Run command', 'desktop.command.inputFailed': 'Command unavailable or failed. Your input was kept.', 'desktop.command.failed': 'The action failed. Try again.', + 'desktop.notification.branchUnavailable': 'The conversation is open, but the current operation prevents switching to the notification branch. Finish the run or edit, then click the notification again.', + 'desktop.notification.notReady': 'The workspace is not ready. Restore loading and try again.', + 'desktop.notification.messageUnavailable': 'The notification message could not be located. Wait for loading to recover or click the notification again.', 'desktop.command.help.feedback': 'Feedback', 'desktop.command.help.openDocumentation': 'Documentation', 'desktop.command.help.openLogsDirectory': 'Open logs directory', diff --git a/apps/buddy/src/i18n/locales/zh-CN/app.ts b/apps/buddy/src/i18n/locales/zh-CN/app.ts index 7acec7f0..f774fc95 100644 --- a/apps/buddy/src/i18n/locales/zh-CN/app.ts +++ b/apps/buddy/src/i18n/locales/zh-CN/app.ts @@ -36,6 +36,9 @@ export default { 'desktop.command.run': '执行命令', 'desktop.command.inputFailed': '命令不可用或执行失败,输入已保留。', 'desktop.command.failed': '操作失败,请稍后重试', + 'desktop.notification.branchUnavailable': '对话已打开,但当前操作阻止切换到通知对应的分支。请结束运行或编辑后,再点击通知重试。', + 'desktop.notification.notReady': '工作台尚未准备好,请恢复加载后重试。', + 'desktop.notification.messageUnavailable': '暂时无法定位通知对应的消息,请等待加载恢复,或再次点击通知重试。', 'desktop.command.help.feedback': '意见反馈', 'desktop.command.help.openDocumentation': '使用文档', 'desktop.command.help.openLogsDirectory': '打开日志目录', diff --git a/apps/buddy/src/modules/tasks/taskContext.ts b/apps/buddy/src/modules/tasks/taskContext.ts index 524c0ea6..1b270d31 100644 --- a/apps/buddy/src/modules/tasks/taskContext.ts +++ b/apps/buddy/src/modules/tasks/taskContext.ts @@ -9,6 +9,7 @@ export interface TaskEnvironment { browserGuests: DesktopBrowserGuestSurfaceHost clipboard: LexoraDesktopApi['clipboard'] notificationTarget: Readonly> + completeNotificationReveal?: (conversationId: string, messageId: string, result?: 'opened' | 'cancelled') => void resources: TaskResourcePanel } diff --git a/apps/buddy/src/modules/tasks/widgets/workspace/DesktopChatWorkspace.vue b/apps/buddy/src/modules/tasks/widgets/workspace/DesktopChatWorkspace.vue index 90437693..5963e87e 100644 --- a/apps/buddy/src/modules/tasks/widgets/workspace/DesktopChatWorkspace.vue +++ b/apps/buddy/src/modules/tasks/widgets/workspace/DesktopChatWorkspace.vue @@ -3,8 +3,10 @@ import type { LocalConversationTreeNode } from '@buddy-shared/conversation/conve import type { BuddyChatMessageListHandle } from '../transcript/chatMessageViewport' import type { ChatWorkspaceEmits, ChatWorkspaceProps } from './typing' import { readBuddyUserMessageContent } from '@buddy-shared/conversation/buddyUserContent' +import { useMessage } from 'naive-ui' import { computed, defineAsyncComponent, nextTick, shallowRef, useTemplateRef, watch } from 'vue' import { useBuddyI18n } from '@/i18n/buddyI18n' +import { useTaskEnvironment } from '@/modules/tasks/taskContext' import DesktopRuntimePane from '@/platform/runtime/DesktopRuntimePane.vue' import WorkbenchSlot from '@/shared/ui/contributions/WorkbenchSlot.vue' import { useConversationNodeDetail } from '../../state/conversations/useConversationNodeDetail' @@ -62,7 +64,17 @@ watch(() => props.viewMode, (value) => { else nodeDetail.close() }, { immediate: true }) const messageList = useTemplateRef('messageList') -const { isEmpty, isLoading, language, transcriptBindings, viewport, welcomeVariant } = useChatWorkspace(props, messageList) +const message = useMessage() +const environment = useTaskEnvironment() +const { isEmpty, isLoading, language, transcriptBindings, viewport, welcomeVariant } = useChatWorkspace( + props, + messageList, + { + onRevealError: () => message.error(t('desktop.notification.messageUnavailable')), + onRevealed: messageId => environment.completeNotificationReveal?.(props.workspace.session.activeConversationId.value ?? '', messageId), + onRevealCancelled: messageId => environment.completeNotificationReveal?.(props.workspace.session.activeConversationId.value ?? '', messageId, 'cancelled'), + }, +) watch([pageRef, isLoading], ([element, loading], _, cleanup) => { if (!element || loading) return @@ -104,7 +116,9 @@ const quoteNavigation = useChatQuoteNavigation({ language: () => props.workspace.language.value, conversationId: () => props.workspace.session.activeConversationId.value, viewMode: () => props.viewMode, - revealMessage: id => viewport.revealMessage(id), + revealMessage: async (id) => { + await viewport.revealMessage(id) + }, loadQuote: async (messageId, quoteId) => { const conversationId = props.workspace.session.activeConversationId.value if (!conversationId) diff --git a/apps/buddy/src/modules/tasks/widgets/workspace/__tests__/useChatViewport.spec.ts b/apps/buddy/src/modules/tasks/widgets/workspace/__tests__/useChatViewport.spec.ts index 4243dfa1..b06e65fa 100644 --- a/apps/buddy/src/modules/tasks/widgets/workspace/__tests__/useChatViewport.spec.ts +++ b/apps/buddy/src/modules/tasks/widgets/workspace/__tests__/useChatViewport.spec.ts @@ -1,6 +1,6 @@ import type { BuddyChatMessageListHandle, ChatMessageScrollAnchor, ChatMessageScrollMetrics, ChatReadingPositions } from '../../transcript/chatMessageViewport' import { deferred } from '@buddy-tests/deferred' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { effectScope, nextTick, shallowRef } from 'vue' import { useChatViewport } from '../useChatViewport' @@ -43,6 +43,9 @@ function createViewport(loadOlderMessages: () => Promise, initial: { } = {}) { const original = createList() const options = { + onRevealError: vi.fn(), + onRevealed: vi.fn(), + onRevealCancelled: vi.fn(), readingPositions: initial.readingPositions, activeBranchId: shallowRef('branch-1'), activeConversationId: shallowRef('conversation-1'), @@ -221,6 +224,23 @@ describe('chat viewport operations', () => { expect(fixture.original.metrics.scrollTop).toBe(300) }) + it('keeps a failed notification reveal and retries when its timeline becomes available', async () => { + const fixture = createViewport(async () => false, { isLoading: true, revealMessageId: 'new-target' }) + fixture.options.hasOlderMessages.value = false + const scroll = vi.spyOn(fixture.original.handle, 'scrollToMessage').mockReturnValueOnce(null) + fixture.options.isLoading.value = false + await vi.waitFor(() => expect(fixture.options.onRevealError).toHaveBeenCalledOnce()) + expect(fixture.viewport.isPositioning.value).toBe(false) + fixture.options.timelineItems.value = [{ id: 'new-target', kind: 'message' }] + await vi.waitFor(() => expect(scroll).toHaveBeenCalledTimes(2)) + await nextTick() + fixture.options.timelineItems.value = [{ id: 'new-target', kind: 'message' }, { id: 'later', kind: 'message' }] + await nextTick() + expect(scroll).toHaveBeenCalledTimes(2) + expect(fixture.original.metrics.scrollTop).toBe(300) + expect(fixture.options.onRevealed).toHaveBeenCalledExactlyOnceWith('new-target') + }) + it.each(['before', 'after'] as const)('reveals a target received before the list mounts %s initial loading finishes', async (mountOrder) => { const fixture = createViewport(async () => false) fixture.options.hasOlderMessages.value = false @@ -303,6 +323,10 @@ describe('chat viewport operations', () => { expect(pages).toBe(0) expect(mounted.revealed).toEqual([]) expect(mounted.highlights).toEqual([]) + if (reason === 'clear-target' || reason === 'dispose') + expect(fixture.options.onRevealCancelled).not.toHaveBeenCalled() + else + expect(fixture.options.onRevealCancelled).toHaveBeenCalledExactlyOnceWith('old-target') fixture.options.revealMessageId.value = null }) diff --git a/apps/buddy/src/modules/tasks/widgets/workspace/useChatViewport.ts b/apps/buddy/src/modules/tasks/widgets/workspace/useChatViewport.ts index 0041216f..3ce8db7d 100644 --- a/apps/buddy/src/modules/tasks/widgets/workspace/useChatViewport.ts +++ b/apps/buddy/src/modules/tasks/widgets/workspace/useChatViewport.ts @@ -31,6 +31,9 @@ interface UseChatViewportOptions { list: ValueRef loadOlderMessages: () => Promise timelineItems: ValueRef> + onRevealError?: () => void + onRevealed?: (messageId: string) => void + onRevealCancelled?: (messageId: string) => void } interface RevealMessageOptions { @@ -56,6 +59,8 @@ export function useChatViewport(options: UseChatViewportOptions) { let pendingHistory: ScrollOperation | null = null let pendingPage: Promise | null = null let pendingRevealMessageId: string | null = null + let revealOperation: ScrollOperation | null = null + let reportedRevealMessageId: string | null = null let disposed = false watch( @@ -72,7 +77,7 @@ export function useChatViewport(options: UseChatViewportOptions) { pendingHistory = null pendingPage = null if (contextChanged) - pendingRevealMessageId = null + cancelPendingReveal() readingAnchor = null if (contextChanged) pendingPosition = readingPositions.get(scopeKey()) ?? null @@ -88,6 +93,7 @@ export function useChatViewport(options: UseChatViewportOptions) { watch(() => options.revealMessageId.value, (messageId) => { operationGeneration += 1 pendingRevealMessageId = messageId + reportedRevealMessageId = null if (!resumePendingReveal() && isPositioning.value) void scrollToTailAfterRender() }, { flush: 'sync', immediate: true }) @@ -95,7 +101,13 @@ export function useChatViewport(options: UseChatViewportOptions) { if (!resumePendingReveal() && (isPositioning.value || scrollState.value.ownership === 'following')) void scrollToTailAfterRender() }) + watch(() => options.isLoadingOlderMessages.value, (loading) => { + if (!loading) + resumePendingReveal() + }) watch(() => options.timelineItems.value, (items, previous) => { + if (pendingRevealMessageId && resumePendingReveal()) + return if (isPositioning.value || options.isLoading.value || scrollState.value.ownership !== 'detached') return const first = previous[0] @@ -145,15 +157,35 @@ export function useChatViewport(options: UseChatViewportOptions) { scrollState.value = detachChatScroll(scrollState.value) if (!options.list.value || options.isLoading.value) return true + if (options.isLoadingOlderMessages.value || (revealOperation && isCurrent(revealOperation))) + return true const revealing = revealMessage(messageId, () => pendingRevealMessageId === messageId) - const generation = operationGeneration - void revealing.then(() => { - if (generation === operationGeneration) + const operation = { generation: operationGeneration, list: options.list.value } + revealOperation = operation + void revealing.then((revealed) => { + if (revealOperation === operation) + revealOperation = null + if (!isCurrent(operation) || pendingRevealMessageId !== messageId) + return + if (revealed) { pendingRevealMessageId = null + options.onRevealed?.(messageId) + } + else if (reportedRevealMessageId !== messageId) { + reportedRevealMessageId = messageId + options.onRevealError?.() + } }) return true } + function cancelPendingReveal() { + const messageId = pendingRevealMessageId + pendingRevealMessageId = null + if (messageId) + options.onRevealCancelled?.(messageId) + } + function beginOperation(): ScrollOperation { return { generation: ++operationGeneration, list: options.list.value } } @@ -174,7 +206,7 @@ export function useChatViewport(options: UseChatViewportOptions) { scrollState.value = observation.state if (observation.movedByReader) { operationGeneration += 1 - pendingRevealMessageId = null + cancelPendingReveal() } readingAnchor = scrollState.value.ownership === 'detached' ? options.list.value?.captureScrollAnchor() ?? null @@ -213,7 +245,7 @@ export function useChatViewport(options: UseChatViewportOptions) { if (metrics && metrics.scrollHeight <= metrics.clientHeight) return operationGeneration += 1 - pendingRevealMessageId = null + cancelPendingReveal() readingAnchor = null pendingPosition = null scrollState.value = detachChatScroll(scrollState.value) @@ -223,7 +255,7 @@ export function useChatViewport(options: UseChatViewportOptions) { if (disposed) return operationGeneration += 1 - pendingRevealMessageId = null + cancelPendingReveal() readingAnchor = null pendingPosition = null pendingHistory = null @@ -243,7 +275,7 @@ export function useChatViewport(options: UseChatViewportOptions) { async function returnToLatest() { if (disposed) return - pendingRevealMessageId = null + cancelPendingReveal() readingAnchor = null pendingPosition = null const operation = beginOperation() @@ -331,7 +363,7 @@ export function useChatViewport(options: UseChatViewportOptions) { revealOptions: RevealMessageOptions = {}, ) { if (disposed) - return + return false const operation = beginOperation() scrollState.value = detachChatScroll(scrollState.value) const isActive = () => isCurrent(operation) && isRequested() @@ -346,24 +378,25 @@ export function useChatViewport(options: UseChatViewportOptions) { } catch {} if (!isActive()) - return + return false await nextTick() if (!isActive()) - return - if (revealOptions.highlight) - operation.list?.highlightMessage(messageId) + return false const metrics = operation.list?.scrollToMessage(messageId, loadedHistory ? 'auto' : revealOptions.behavior) if (metrics) { + if (revealOptions.highlight) + operation.list?.highlightMessage(messageId) scrollState.value = recordProgrammaticChatScroll(scrollState.value, metrics) readingAnchor = operation.list?.captureScrollAnchor() ?? null } pendingPosition = null positionedScopeKey = scopeKey() isPositioning.value = false + return Boolean(metrics) } function revealOutlineMessage(messageId: string) { - pendingRevealMessageId = null + cancelPendingReveal() return revealMessage(messageId, () => true, { behavior: 'smooth', highlight: true, diff --git a/apps/buddy/src/modules/tasks/widgets/workspace/useChatWorkspace.ts b/apps/buddy/src/modules/tasks/widgets/workspace/useChatWorkspace.ts index 8414d804..01ded06f 100644 --- a/apps/buddy/src/modules/tasks/widgets/workspace/useChatWorkspace.ts +++ b/apps/buddy/src/modules/tasks/widgets/workspace/useChatWorkspace.ts @@ -8,6 +8,11 @@ import { useChatViewport } from './useChatViewport' export function useChatWorkspace( props: Readonly, messageList: Readonly>, + revealCallbacks: { + onRevealError?: () => void + onRevealed?: (messageId: string) => void + onRevealCancelled?: (messageId: string) => void + } = {}, ) { const workspace = computed(() => props.workspace) const execution = computed(() => workspace.value.execution) @@ -18,6 +23,7 @@ export function useChatWorkspace( const isEmpty = computed(() => session.value.activeConversationId.value === null) const welcomeVariant = shallowRef(selectDesktopChatWelcomeVariant(workspace.value.welcomePreference.value)) const viewport = useChatViewport({ + ...revealCallbacks, readingPositions: props.readingPositions, activeBranchId: computed(() => session.value.activeBranchId.value), activeConversationId: computed(() => session.value.activeConversationId.value), diff --git a/docs/bugs/bug-20260929-desktop-notification-navigation.md b/docs/bugs/bug-20260929-desktop-notification-navigation.md new file mode 100644 index 00000000..6122f071 --- /dev/null +++ b/docs/bugs/bug-20260929-desktop-notification-navigation.md @@ -0,0 +1,290 @@ +# Bug:桌面通知点击后打开目标会话不稳定 + +**日期:** 2026-09-29
+**优先级:** 高
+**状态:** 通知生命周期与导航修复已完成;Windows 开发版跨会话通知真实点击已由用户确认通过
+**代码基准:** 修复前为 `369dbacf`;修复现随本次提交记录。此前核查基准为 `6dd38bf3`,与修复前基准的关键实现相同。 + +## 用户反馈与预期结果 + +用户反馈:桌面通知点击后表现不稳定,有时桌面窗口没有出现;应用窗口已经存在时,也可能没有切到对应会话。用户转述,作者曾实现此功能,后续分屏改造后出现失效现象。 + +预期:点击通知后显示并聚焦桌面窗口,打开目标会话;存在关联执行记录时,切到对应分支并定位关联消息。若目标不可用或操作被阻止,应说明原因,避免无反馈地结束。 + +本文区分三个结果:窗口是否显示、会话是否打开、分支与消息是否定位。消息定位失败不等于会话没有打开,也不能用来解释窗口为什么没有出现。 + +## 核查范围与证据等级 + +- **用户反馈:** 上述不稳定现象;具体安装版本、发生频率和日志尚未收集。 +- **代码确认:** 可以直接看到的条件、返回路径及状态处理。 +- **时序推导:** 多处代码组合后存在的失败路径,尚未完成界面复现。 +- **待实测:** Windows 是否发出点击回调、窗口是否实际获得前台焦点,以及退出后旧通知的行为。 + +首次核查只阅读代码和 Git 历史。随后用户授权修复,修改与验证记录见文末。此前对话提到的临时探针已删除,无法独立核验,不作为本文的实测证据。本文所列代码行号对应修复前基准。 + +## 功能链路 + +系统通知点击 → 主进程打开窗口 → 发送会话目标 → 页面等待初始化 → 工作台打开会话 → 查询关联执行记录 → 激活对应分支 → 消息视图加载历史并滚动定位。 + +其中“执行记录”是一次任务运行,代码称为 `run`;“分支”是同一会话中的不同对话路径;“IPC”是主进程向页面发送消息的机制。 + +## 修复前失败路径记录 + +### N01:打开窗口失败后,通知目标丢弃,点击异常未单独处理 + +**证据:** 代码确认;是否为用户遇到的窗口不出现原因,待实测。 + +`DesktopWindowHost.openTarget` 先等待 `manager.open()`,成功后才发送目标。窗口加载失败或加载期间退出会使等待失败;窗口管理器不存在时直接返回;自动恢复已用尽时只显示恢复界面,不保留这次目标。 + +通知点击使用 `void openTarget(...)`,没有捕获该异步操作的失败。普通 `show()` 有 `window.activate_failed` 日志,通知打开路径没有相同处理。`DesktopIntegrations` 中的 `notification.failed` 捕获的是通知生成过程,不能覆盖之后点击回调的异步失败。 + +**用户表现:** 主窗口没有出现,或只出现恢复界面;后续会话跳转不执行。 + +**依据:** + +- `apps/buddy/electron/main/DesktopNotificationService.ts:86` +- `apps/buddy/electron/main/app/DesktopWindowHost.ts:97`、`:107` +- `apps/buddy/electron/main/DesktopWindowManager.ts:51`、`:86` +- `apps/buddy/electron/main/app/DesktopIntegrations.ts:158` + +### N02:目标只发送一次,没有接收与完成确认 + +**证据:** 代码确认机制缺口;具体丢消息场景和频率待实测。 + +主进程只调用一次 `webContents.send`,页面侧只注册事件监听。没有目标暂存、接收确认、跳转结果回传或重放机制。如果消息发送时监听不可用,或接收后页面重载、崩溃,没有恢复这次点击的路径。不能据此认定正常运行时监听一定尚未注册。 + +**用户表现:** 窗口可能出现,但没有切到目标会话;主进程无法判断页面是否收到或完成。 + +**依据:** `apps/buddy/electron/main/app/DesktopWindowHost.ts:115`;`apps/buddy/electron/preload/subscribe.ts:3`;`apps/buddy/src/app/bootstrap/DesktopAppProvider.vue:206`。 + +### N03:导航取消条件不能区分启动恢复与加载过渡,可能取消通知自身 + +**证据:** 代码确认取消条件;以下完整触发过程属于时序推导。 + +通知开始时记录导航版本。之后只要版本变化且当前会话不同于目标,就调用 `cancel()`,中止请求并清除定位目标。该判断没有识别变化来源。 + +**路径 A:启动恢复。** 通知在初始化结束前进入等待;没有保存的工作台布局时,启动恢复调用 `openTask` 或 `newTask`,增加导航版本。如果恢复出的当前会话不同于通知目标,待处理通知会被取消。普通布局恢复不必然增加版本,因此不是所有冷启动都会触发。 + +**路径 B:分屏加载。** 当前是会话 A,目标 B 已在另一窗格但尚未加载完成。通知打开 B 时增加导航版本,并聚焦 B 的现有视图。`ActiveTaskProjection` 对未恢复完成的会话返回空值,取消判断于是看到“当前不是 B”,中止通知后续处理。B 后来可能显示出来,但分支激活和消息定位已经取消。 + +**用户表现:** 停留在恢复出的会话;或目标窗格出现,但没有继续定位通知关联的分支、消息。 + +**依据:** + +- `apps/buddy/src/app/bootstrap/useDesktopNavigation.ts:31`、`:42`、`:84` +- `apps/buddy/src/app/workbench/useDesktopWorkbench.ts:253`、`:409` +- `apps/buddy/src/workbench/services/WorkbenchController.ts:263` +- `apps/buddy/src/app/workbench/ActiveTaskProjection.ts:23` + +### N04:启动等待没有超时,失败后也没有通知专属恢复流程 + +**证据:** 代码确认;完整启动失败场景待验证。 + +通知等待 `options.ready`,没有超时。生命周期的 `ready` 在运行服务就绪后触发的初始化流程结束时才解决;若启动失败发生在该流程开始之前,等待可能长期不结束。另一方面,该流程即使失败也会在 `finally` 中解决 `ready`,通知随后仍会尝试打开会话;后续失败没有自动重试机制。 + +**用户表现:** 窗口已经出现,但跳转一直等待;或初始化失败后打开失败,需要再次点击。 + +**依据:** `apps/buddy/src/app/bootstrap/useDesktopNavigation.ts:92`;`apps/buddy/src/app/bootstrap/useDesktopLifecycle.ts:30`、`:112`、`:120`。 + +### N05:需要切换到另一分支时,现有操作限制阻止定位 + +**证据:** 代码确认;这是有条件的限制,不是审批通知必然失败。 + +通知入口先判断是否已经在目标分支;同一分支直接成功。需要切换到另一分支时,则要求运行服务就绪、没有活动任务、没有发送或分支修改、没有权限设置更新、没有正在编辑的消息,且目标分支存在于列表中。否则返回失败,通知不再设置消息定位目标。 + +分支列表刷新失败或状态不完整时,列表检查也可能阻止切换。正常打开会话会等待初始化与分支刷新,不能简单把“列表还没加载”视为必然路径。 + +**用户表现:** 目标会话已打开,但仍是原分支,或者没有定位关联消息。 + +**依据:** `apps/buddy/src/app/bootstrap/DesktopAppProvider.vue:196`;`apps/buddy/src/modules/tasks/state/conversations/useChatBranchMutations.ts:65`、`:76`;`apps/buddy/src/app/bootstrap/useDesktopNavigation.ts:73`。 + +### N06:固定三秒计时清除尚未完成的消息定位 + +**证据:** 代码确认。 + +打开会话、激活分支后,通知设置目标消息并启动三秒计时器。计时器清除整个定位目标,而不只是结束视觉高亮。消息视图收到空目标时会中止待定位操作。因此,历史消息加载超过三秒或消息列表仍不可用时,定位会丢失。 + +**用户表现:** 会话、分支正确,但没有滚动到目标消息。 + +**依据:** `apps/buddy/src/app/bootstrap/useDesktopNavigation.ts:27`、`:75`;`apps/buddy/src/modules/tasks/widgets/workspace/useChatViewport.ts:88`、`:141`。 + +### N07:滚动未找到目标,也会结束操作并丢弃重试机会 + +**证据:** 代码确认。 + +定位过程中加载旧消息失败或返回 `false`,会停止分页并尝试滚动。实际滚动函数找不到目标消息时返回空值,上层没有把该结果作为定位失败处理,仍结束操作;待定位目标随后被清除。一次临时加载错误、历史未加载到目标或目标不存在,都可能导致本次定位结束,不会在加载恢复后自动继续。 + +**用户表现:** 会话已经打开,停留在其他消息位置。 + +**依据:** `apps/buddy/src/modules/tasks/widgets/workspace/useChatViewport.ts:141`、`:328`;`apps/buddy/src/modules/tasks/widgets/transcript/BuddyChatTranscriptViewport.vue:113`;`apps/buddy/src/modules/tasks/state/runs/useChatRunSync.ts:175`。 + +## 其他会阻止跳转的现有规则与异常 + +下列情况需要记录原因,但不能全部按程序缺陷处理: + +| 条件 | 当前行为 | +|---|---| +| 当前是有未发送内容的新会话草稿,目标尚未打开 | 等待草稿处理选择;关闭弹窗、按 Esc 或取消会保留原页面 | +| 目标会话已删除、不可用或恢复失败 | 保留原页面或显示加载失败 | +| 同一窗格出现更新的打开请求 | 取消此前请求,优先后来的操作 | +| 用户离开任务页面或切换到其他会话 | 可能取消通知导航或消息定位 | +| 查询关联执行记录失败 | 会话可能已打开,但后续分支与消息定位停止;记录不存在时服务返回错误 | +| 用户主动滚动、改变阅读布局或返回最新消息 | 取消尚未完成的消息定位 | + +依据:`useDesktopWorkbench.ts:228`、`:238`;`TaskWorkspacePool.ts:53`;`WorkbenchController.ts:209`;`useDesktopNavigation.ts:38`、`:65`;`useChatViewport.ts:167`、`:209`、`:243`;`apps/buddy/service/src/runs/registerRunRpc.ts:48`。上述未带目录的文件分别位于本文前述工作台、导航及消息视图目录中。 + +## 尚不能认定的原因与历史纠正 + +- 隐藏到托盘与彻底退出进程不同。当前通知目标存在于运行进程的点击回调中,没有实现携带会话目标的重新启动路径;`DesktopApplication.ts:99` 的 `second-instance` 只显示窗口。但退出后旧通知是否重启应用、是否走此事件,需要 Windows 实测。 +- 窗口显示使用最小化恢复、`show()` 与 `focus()`。仅凭没有调用 `app.focus()` 或临时置顶,不能认定它就是窗口未弹出的原因。 +- `1467c3fe`(2026-09-18)引入工作台与分屏相关接入,但同时已有“目标分支已激活则直接成功”的判断。直接测试 `activateBranch()` 返回失败,不能证明实际通知入口在同一分支失败。 +- 三秒计时至少在 `f585dd68`(2026-09-08)已存在,不能归因于分屏重构。 +- 现有 `useDesktopNavigation.spec.ts:136` 的启动恢复用例只修改会话,不增加导航版本,未覆盖 N03 路径 A。导航测试使用模拟的分支激活方法,未验证完整工作台接入。 + +## 原始修复建议 + +1. **高:** 处理 N03,区分主动导航、启动恢复与加载过渡,避免通知取消自身。 +2. **高:** 处理 N06、N07,把定位完成与视觉高亮计时分开;定位失败时保留明确结果和可恢复目标。 +3. **高:** 处理 N01、N02,保存待打开目标,记录点击、接收、打开结果;窗口恢复或页面重建后仍能处理有效目标。 +4. **中:** 处理 N04、N05,明确展示等待或受阻原因,并提供恢复路径;不要为通知跳转直接放宽分支修改限制。 + +实现采用以下取舍:新的通知点击覆盖旧请求;用户主动导航或改变阅读位置取消待定位目标;会话、执行记录不可用或分支切换受阻时提示失败并结束本次请求。窗口或页面暂时不可用时保留最新目标,恢复后继续处理。 + +## Windows 实机验收范围 + +单元测试使用模拟窗口与页面状态;后续已运行真实 Windows Electron 窗口与页面,并用模拟数据验证三轮场景,结果见下文。系统通知浮层物理点击尚未完成。完整验收范围如下: + +1. 已运行且隐藏到托盘:点击另一会话通知,应显示窗口并打开目标。 +2. 初始化恢复期间点击通知:使用独立测试配置,确认恢复操作不会丢弃通知目标。 +3. 分屏中目标窗格尚未加载完成:点击后应等待目标可用,再完成分支与消息定位。 +4. 同一分支仍在运行:点击审批通知应能打开和定位;另一分支受阻时应说明原因。 +5. 历史加载超过三秒、单次加载失败或找不到消息:不应把未完成的定位当作成功。 +6. 页面恢复或重建期间点击:确认目标是否收到、保留,以及恢复后的处理结果。 + +验证应分别记录窗口、会话、分支、消息定位四个结果。系统点击回调是否到达也应单独记录;不要只用“点击没反应”作为最终结果。 + +## 修复记录(2026-09-29) + +| 问题 | 已实施处理 | +|---|---| +| N01:窗口打开失败 | 点击时立即保存最新目标;窗口尚未创建时也保留。打开失败记录诊断,并重试一次;恢复用尽时目标保留到窗口恢复。 | +| N02:消息没有完成确认 | 增加带请求编号的待处理目标与完成回传。页面就绪后主动领取目标;页面重建后可重新领取。旧请求的回传不能清除新请求。 | +| N03:恢复或分屏加载导致取消 | 系统通知等工作台初始化完成后再处理;导航判断使用当前窗格的会话资源,不依赖尚未加载完成的会话实例。用户主动切换会话仍会取消。 | +| N04:初始化失败后永久等待 | 生命周期出现明确失败时结束等待;通知在工作台未就绪时由主进程保留,加载恢复后再领取,不提前尝试打开。 | +| N05:分支受阻后无反馈 | 保留原有分支操作限制,以及已在目标分支时的成功判断。切换失败显示原因,结束本次请求,由用户在解除限制后重试。 | +| N06:三秒清除未完成定位 | 删除固定三秒清除;实际滚动成功后清除目标并回传完成。用户主动滚动、切换分支、返回最新或发送消息时取消待定位目标。 | +| N07:滚动失败被当作完成 | 滚动函数返回是否成功。失败时保留目标并提示一次;列表、加载状态或消息更新后继续尝试,成功后停止重试。 | + +主要修改位于 `DesktopWindowHost.ts`、主进程 IPC 与预加载桥接、`DesktopAppProvider.vue`、`useDesktopNavigation.ts`、`ActiveTaskProjection.ts`、`useChatViewport.ts` 及其消息定位回调连接处。新增提示同时覆盖中文与英文。 + +### 已执行验证 + +- 相关 5 个测试文件共 61 条用例通过:窗口目标暂存与重试、IPC、导航、生命周期、消息定位。后续补齐取消回传后,仅重跑受影响的导航与消息定位两组,53 条用例通过。 +- 已完成修改文件的 ESLint 检查。 +- `pnpm --filter @uselexora/lexora-buddy build:electron` 通过,包含 `vue-tsc` 类型检查及主进程、预加载、页面的 Electron 构建。 + +### 尚未验证与范围边界 + +- 真实 Windows 窗口显示与获得焦点已验证。系统通知浮层未暴露为 Computer Use 可操作窗口,因此模拟触发了实际 `Notification` 对象的 `click` 事件;不能据此确认鼠标点击系统浮层后的事件投递。 +- 未新增“彻底退出应用后点击旧通知,携带目标重新启动”的支持;本文已说明该场景需要系统实测,不能按托盘隐藏处理。 +- 没有绕过运行中的分支切换限制;这类受阻操作会明确提示。 +- 本次没有提交代码或生成 Windows 安装包。 + +## 模拟数据与真实桌面验证(2026-09-29) + +**环境:** 当前工作树的 Electron 构建,独立 `test` 配置与 SQLite 数据库;真实主进程、预加载桥接、工作台、分屏与消息列表。未使用用户现有会话或模型供应商,也未调用外部模型。 + +**数据:** 会话 A 有 3 条消息,会话 B 初始有 100 条,第三轮扩展到 300 条。模拟完成记录 `notify-run-b` 指向 `notify-test-b`、`notify-branch-b` 和第 5 条消息 `notify-message-b-5`,页面显示“通知定位目标 B-005”。 + +**操作方式:** 在构建副本加入仅用于观察的实例入口,直接注入模拟数据库记录;通过真实 `DesktopNotificationService.handle(run.event)` 生成系统通知。已观察到 `Notification` 的 `show` 回调。因系统浮层不在可操作窗口列表中,触发实际通知对象的 `click` 事件,随后走原有窗口打开、IPC、导航、分支与滚动链路。第二、三轮仅对测试实例的指定查询加入五秒延迟。 + +| 场景 | 观察结果 | 耗时 | +|---|---|---| +| 当前会话 A,点击关闭按钮隐藏到托盘,再模拟点击 B 通知 | 隐藏前 `isVisible=false`、`isFocused=false`;点击后两者均为 `true`,切到 B,并定位第 5 条消息;收到 `notification.target.opened`。 | 310 毫秒 | +| 左侧 B、右侧 A,页面重建时 B 会话查询延迟五秒,期间模拟点击通知 | 保留两个窗格;恢复后左侧 B 成为活动窗格,第 5 条消息位于可视区;收到完成回传。 | 5,277 毫秒 | +| 分屏中 B 有 300 条历史,目标位于未加载的旧消息;首个历史分页延迟五秒 | 点击前目标消息未渲染;第 3,508 毫秒只有点击事件,未误报完成;分页结束后继续获取更旧一页,并定位第 5 条消息,B 窗格获得焦点。 | 5,406 毫秒 | + +三轮最终目标消息的上边缘约为窗口内 `y=76`,均在消息可视区中。上述耗时是点击日志到页面完成回传之间的时间,不代表所有机器的性能。 + +**证据目录:** `C:\Users\Lenovo\AppData\Local\Temp\lexora-notification-1790686278941`,包含 `notification-test-results.json`、运行日志与三张截图:`hidden-notification-result.png`、`split-loading-result.png`、`slow-history-result.png`。测试实例与测试通知已关闭,临时构建入口已删除;测试缓存已归档到 `apps/buddy/.output/notification-cache-20260929`。失败启动时使用的独立测试目录保留,不影响项目源代码。 + +**结论边界:** 已验证“通知点击回调到达后,真实窗口显示、聚焦、会话切换和定位”的链路,包括超过三秒的加载。尚未验证 Windows 浮层鼠标点击、NSIS 安装版、进程完全退出后的旧通知、运行中另一分支受阻以及真实分页失败后的恢复。 + +## 开发版不显示通知的补充修复(2026-09-29) + +用户确认任务完成时开发窗口已最小化,因此不能用“前台通知”默认关闭解释本次现象。现有开发配置为 `notifications_enabled=true`、`notify_when_focused=false`;通知策略没有禁用开发模式。 + +**确认的代码缺口:** Windows 开发版只设置 `AppUserModelID`,没有创建携带该标识和 `ToastActivatorCLSID` 的开始菜单快捷方式。修复前未找到 `Lexora Buddy Dev.lnk`。Electron 的 [Windows 通知文档](https://www.electronjs.org/docs/latest/tutorial/notifications)要求配置这些信息;这是一项已确认的登记缺口,不能据此认定所有通知不显示都由它引起。 + +**修改:** `prepareDesktopReady` 仅在 Windows 未打包的 `development` 配置下创建或更新开发版快捷方式,并固定开发版通知激活器标识。登记失败记日志,不阻止启动。实际业务通知补充 `notification.shown` 与 `notification.delivery_failed` 回调日志;不修改用户通知开关或会话数据。 + +**实测:** 重启使用 `C:\Users\Lenovo\.lexora-dev` 的真实开发实例,核对快捷方式名称、目标、应用标识和激活器均正确。通过临时本地调试连接,在该进程中创建“Lexora 开发版通知测试”,收到真实 `show` 回调,无 `failed` 回调;用户明确回复“看到了”右下角通知横幅。测试未调用模型或写入聊天记录;临时通知及调试入口已清理,开发预览继续运行。 + +**验证范围:** 本轮构建(含类型检查)、两处代码的 ESLint 和 `git diff --check` 通过。本轮确认了真实 Windows 横幅显示;通知是直接创建的投递探针,未伪造任务完成事件,未测试鼠标点击后导航,因此不能替代业务完成事件与通知点击的后续验收。 + +## 跨会话点击失败的补充排查(2026-09-29) + +用户新反馈:留在生成会话时,最小化后点击通知可以展开;准确失败顺序为 A 开始生成 → 用户切换到 B → 最小化 → A 完成并弹出系统通知 → 点击通知既没有展开窗口,也没有跳回 A。通知是在切到 B 并最小化之后才创建的,不能把此反馈改写成“先显示 A 通知,再切到 B”。 + +### 开发实例日志 + +同一启动 `e111df22-d232-46d7-b06a-980254f61f12`,本地时间 UTC+8: + +| 时间 | 事件 | 含义 | +|---|---|---| +| 21:46:38.234 | `notification.shown` | 业务通知已显示 | +| 21:46:39.737 | `notification.clicked` | 通知点击回调到达主进程 | +| 21:46:39.797 | `notification.target.opened` | 页面完成目标定位 | +| 21:47:45.231 | `notification.shown` | 第二次业务通知已显示 | +| 后续 | 没有对应的 `notification.clicked`、目标完成或窗口打开失败记录 | 第二次未进入现有点击打开入口 | + +日志来源:`C:\Users\Lenovo\AppData\Local\Lexora Buddy Dev\state\logs\application.jsonl`。现有事件不带通知唯一标识,关联依赖时间顺序;未据此断言第二次的底层 Windows 激活事件完全没有发生。 + +### 已确认的缺口与根因候选 + +- `DesktopNotificationService.handle` 内的系统通知只是局部变量,类只保存已显示事件的字符串集合;`DesktopIntegrations` 的原生 `Notification` 也是局部变量。发送结束后没有持有通知对象的长期容器。 +- 点击只绑定在该对象的 `click` 事件上;仓库没有注册 `Notification.handleActivation`,也没有独立保存系统通知标识到会话、run 的映射。 +- Electron 44.4.5 的 [Windows 激活实现](https://github.com/electron/electron/blob/v44.4.5/shell/browser/notifications/win/windows_toast_activator.cc)会寻找对应通知对象,找不到就无法派发对象点击;[官方 API](https://www.electronjs.org/docs/latest/api/notification#notificationhandleactivationcallback-windows)专门提供对象被回收、应用重启和冷启动情况下的统一激活接收。 +- 因而“通知对象被回收或旧通知失去对象对应关系”是当前最可疑的失败原因,能够解释窗口和导航同时没有动作。此次尚无对象回收的直接记录,不能把“切换会话必然触发回收”写成实测结论。 +- `DesktopWindowHost.openTarget` 在记录 `notification.clicked` 之后才打开窗口;页面路由和分支失败不能解释这次缺少该入口日志。切换会话也不会移除全局主进程的 `service.onNotification` 订阅。 + +### 同类衍生场景 + +1. 横幅显示后等待较久、切换多次会话或发生内存回收,再点击通知:同样可能失去对象回调。尚未分别实测。 +2. 从 Windows 通知中心点击旧通知:不能假定横幅已经消失就不需要保留点击接收;对象生命周期必须覆盖旧通知。尚未单独实测。 +3. 进程完全退出或开发主进程重启后点击旧通知:原对象已不存在,现有 `second-instance` 仅显示窗口,不恢复会话目标。代码缺口确认,系统表现待实测。 +4. A 在生成,用户在前台看 B:通知策略只检查整个窗口的焦点,不检查当前可见会话,所以 `notifyWhenFocused=false` 时 A 的完成通知也被抑制;随后最小化不会补发。代码行为确认,与“已显示但点不动”分开记录。 + +### 验证不足的更正 + +此前模拟测试保留通知对象并主动触发它的 `click` 事件,因此只证明回调到达后的窗口、IPC、页面链路,无法检验真实对象生命周期或 Windows 点击入口。这轮未修改业务代码,未扩大自动测试;下一步应针对通知对象持有、Windows 统一激活接收与目标映射做最小修复,再验证真实跨会话点击。 + +## 对照 XTLaw 后的通知生命周期优化(2026-09-29) + +**对照结果:** `D:\code\XTLaw\apps\desktop\electron\main\ipc\notification-ipc.ts` 用主进程级 `Map` / `Set` 保存原生通知,避免随当前会话变化而丢失对象;点击后先恢复窗口,再发送固定会话目标。其 `close` 回调无条件释放对象,不能直接用于 Lexora 的 Windows 通知中心旧通知:横幅超时和隐藏也可能触发 `close`。 + +**本轮修改:** + +- 在现有 `DesktopNotificationService` 保存每条未清理通知的包装对象;该对象通过回调持有真实 Electron 通知,不依赖页面或选中会话。 +- Windows `timedOut`、`applicationHidden` 或缺失原因的 `close` 不释放通知;明确 `userCanceled` 才释放。其他平台的原生关闭映射为用户关闭,维持其清理行为。 +- 点击使用创建时绑定的会话和 run,且只处理一次;异步或同步打开失败由 `notification.target.failed` 记录。 +- 主进程退出时先阻止新投递、清空持有关系,再关闭所有仍保存的通知;窗口隐藏、最小化和切换会话不触发此清理。查询仍在进行时退出,也不会随后弹出新通知。 +- 并发到达的同一事件只创建一条通知;投递失败释放对象并允许事件重放重试。 + +**本轮验证:** `build:electron`(含类型检查及三端构建)、三个代码文件的 ESLint、`git diff --check` 均通过;通知服务与窗口目标两组共 7 条用例通过。其中回归路径验证了 A 通知出现后收到 B 通知,A 横幅超时/隐藏后点击仍使用 A 的原目标;还覆盖重复投递、重复点击、退出清理与投递失败。测试的系统通知是外部边界 mock,不代表 Windows 鼠标点击已经通过。 + +**复验步骤:** 使用新开发实例在 A 发起任务,生成中切换到 B 并最小化;A 完成后点击系统通知,应显示窗口、回到 A 并定位对应消息。另一次等待横幅消失,再从 Windows 通知中心点击,也应打开 A。分屏时已打开的 A 应在原窗格获得焦点。 + +### 用户实测结果(2026-09-29) + +用户按真实开发版使用流程复验:会话 A 生成内容时切换到会话 B 并最小化;A 完成并显示系统通知后,点击通知测试通过。该结果确认本次报告的跨会话场景可以恢复桌面窗口并跳回 A。此项是用户在 Windows 开发版中的实际操作结果,不是自动化模拟。 + +横幅消失后从通知中心点击、进程完全退出后点击旧通知、以及分屏中原窗格恢复等场景没有包含在这次用户确认中,仍按各自范围处理;模拟消息列表定位与慢加载结果见前文自动化验证记录。 + +**范围:** 本轮修复应用运行期间的通知生命周期,继续复用已实现的目标暂存、分屏导航和完成回传;不新增进程彻底退出后点击旧通知的冷启动恢复,不调整前台通知策略,不修改 XTLaw。 + +## 记录验收 + +- 已记录用户现象、失败条件、代码位置、证据等级与修复建议。 +- 已区分正常取消规则、代码缺口和待实测推断。 +- 已将修复内容、自动化验证结果、Windows 开发版跨会话真实点击结果和未覆盖范围补入文档;相关代码与本文档一并提交。