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 开发版跨会话真实点击结果和未覆盖范围补入文档;相关代码与本文档一并提交。