From 428b4c0d083010305db72e602d89fbf302eccade Mon Sep 17 00:00:00 2001 From: mesalogo Date: Thu, 10 Sep 2026 01:24:51 +0800 Subject: [PATCH] fix: synchronize scheduled task status and run actions Reflect queued and active schedule runs in task status, refresh tasks on queue changes, and prevent duplicate Run Now actions until the current run finishes. Add lifecycle regression coverage and update scheduled task behavior documentation. This file-level commit also includes concurrent conversation history, browser workbar, and runtime selection changes in the affected files. Focused schedule regressions passed; full workspace validation has unrelated failures as recorded during development. Release note: Scheduled tasks now refresh their queued and running status and keep Run Now disabled while an occurrence is active. --- .../task-and-job/scheduled-task-prd.md | 5 +- src/main/assistant/assistant-database.test.ts | 34 +- src/main/assistant/assistant-database.ts | 87 +--- src/renderer/src/App.test.tsx | 315 ++++++++++-- src/renderer/src/App.tsx | 202 ++++---- src/renderer/src/ConversationTaskStrip.tsx | 1 + src/renderer/src/RightAssistantSidebar.tsx | 480 +++++++++++++++--- src/renderer/src/TaskScheduleActions.tsx | 9 +- 8 files changed, 862 insertions(+), 271 deletions(-) diff --git a/docs/features/task-and-job/scheduled-task-prd.md b/docs/features/task-and-job/scheduled-task-prd.md index ecc4411d..3151d688 100644 --- a/docs/features/task-and-job/scheduled-task-prd.md +++ b/docs/features/task-and-job/scheduled-task-prd.md @@ -225,7 +225,10 @@ type TimeTrigger = ### 7.3 手动触发 “立即运行”在当前 Task 内创建独立 Job 和 Run,不改变下一次计划时间,不创建新 Task。 -重复点击使用调用级幂等键去重。 +提交期间,以及该计划已有排队或运行中的 occurrence 时,“立即运行”不可重复点击。 +后台同样拒绝为已有活动 occurrence 的计划再次入队。 +任务状态随队列变化刷新:待执行显示 queued,认领执行后显示 running;结束或取消排队后 +重新读取当前状态。没有活动 occurrence 的周期计划显示 idle 或 paused。 ### 7.4 与普通消息的顺序 diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index b0203eed..638c7c7f 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -1965,7 +1965,7 @@ describe('AssistantDatabase', () => { database.close() }) - it('returns the latest 500 remote messages in chronological order', () => { + it('returns all remote messages in chronological order beyond 500', () => { // Exercise the query boundary without 502 disk-synchronized fixture writes. const database = new AssistantDatabase(':memory:') database.initialize('C:\\Workspace') @@ -1992,8 +1992,8 @@ describe('AssistantDatabase', () => { } const messages = database.getConversation(conversation.id).messages - expect(messages).toHaveLength(500) - expect(messages[0]?.content).toBe('消息 2') + expect(messages).toHaveLength(502) + expect(messages[0]?.content).toBe('消息 0') expect(messages.at(-1)?.content).toBe('消息 501') database.close() }) @@ -3708,6 +3708,9 @@ describe('AssistantDatabase', () => { recurrence: 'daily', nextRunAt: '2026-08-20T09:00:00.000Z' }) + const taskStatus = (): string | undefined => + database.listTasks().find((task) => task.id === schedule.taskId)?.status + expect(taskStatus()).toBe('idle') const [dueItem] = database.queueDueSchedules( new Date('2026-08-20T09:01:00.000Z') @@ -3718,6 +3721,10 @@ describe('AssistantDatabase', () => { scheduleId: schedule.id, taskId: schedule.taskId }) + expect(taskStatus()).toBe('queued') + expect(() => database.queueScheduleNow(schedule.id)).toThrow( + '定时任务已有一次运行正在进行' + ) expect(database.listConversationQueueItems()).toEqual([ expect.objectContaining({ id: dueItem!.id }) ]) @@ -3737,18 +3744,28 @@ describe('AssistantDatabase', () => { schedule: { id: schedule.id }, runId: dueItem!.id }) + expect(taskStatus()).toBe('running') + database.updateTaskStatus(schedule.taskId, 'waiting_approval') + expect(taskStatus()).toBe('waiting_approval') + database.updateTaskStatus(schedule.taskId, 'running') + expect(taskStatus()).toBe('running') database.completeScheduleRun( dueItem!.id, 'completed', new Date('2026-08-20T09:02:00.000Z') ) + expect(taskStatus()).toBe('idle') + database.setScheduleEnabled(schedule.id, false) + expect(taskStatus()).toBe('paused') const manualItem = database.queueScheduleNow(schedule.id) + expect(taskStatus()).toBe('queued') expect(manualItem).toMatchObject({ source: 'schedule', scheduleId: schedule.id }) database.cancelConversationQueueItem(manualItem.id) + expect(taskStatus()).toBe('paused') expect(database.listConversationQueueItems()).toEqual([]) database.close() }) @@ -4781,7 +4798,7 @@ describe('AssistantDatabase', () => { database.close() }) - it('keeps incremental local conversation storage bounded to 500 messages', async () => { + it('preserves history beyond 500 messages across incremental saves', async () => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-bounded-local-conversation-') ) @@ -4832,8 +4849,9 @@ describe('AssistantDatabase', () => { ]) const restored = database.getConversation(conversationId) - expect(restored.messages).toHaveLength(500) - expect(restored.messages[0]?.id).toBe(messageId(1)) + expect(restored.messages).toHaveLength(501) + expect(restored.messages[0]?.id).toBe(messageId(0)) + expect(database.listConversations().find((item) => item.id === conversationId)?.messages).toHaveLength(501) expect(restored.messages.at(-1)?.id).toBe(newestMessageId) const raw = new DatabaseSync(databasePath) expect( @@ -4846,8 +4864,8 @@ describe('AssistantDatabase', () => { ) .get(conversationId) ).toEqual({ - count: 500, - minimum: 1, + count: 501, + minimum: 0, maximum: 500 }) raw.close() diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index ef702a76..286b3636 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -178,6 +178,7 @@ type TaskRow = { instructions: string origin: AssistantTask['origin'] status: AssistantTask['status'] + active_run_status?: 'pending' | 'running' | null work_mode: LegacyWorkMode progress: number | null created_at: string @@ -576,6 +577,14 @@ function readProject( return toProject(row) } +const taskSelect = `SELECT tasks.*, + (SELECT status FROM schedule_runs + WHERE schedule_id = tasks.schedule_id + AND status IN ('pending', 'running') + ORDER BY CASE status WHEN 'running' THEN 0 ELSE 1 END + LIMIT 1) AS active_run_status + FROM tasks` + function toTask(row: TaskRow): AssistantTask { return { id: row.id, @@ -589,9 +598,15 @@ function toTask(row: TaskRow): AssistantTask { instructions: row.instructions, origin: row.origin, status: - row.origin === 'schedule' && row.status === 'queued' - ? 'idle' - : row.status, + row.active_run_status === 'pending' + ? 'queued' + : row.active_run_status === 'running' + ? row.status === 'waiting_approval' + ? 'waiting_approval' + : 'running' + : row.origin === 'schedule' && row.status === 'queued' + ? 'idle' + : row.status, workMode: normalizeInteractiveWorkMode(row.work_mode), progress: row.progress ?? undefined, createdAt: row.created_at, @@ -1168,8 +1183,6 @@ function serializeConversationMessageMetadata( }) } -const maximumRecoveredMessageLength = 1_000_000 - function appendRecoveredMessageBlock( blocks: ConversationMessageBlock[] | undefined, type: 'text' | 'reasoning', @@ -1183,16 +1196,13 @@ function appendRecoveredMessageBlock( if (previous?.type === type) { current[current.length - 1] = { ...previous, - content: `${previous.content}${delta}`.slice( - 0, - maximumRecoveredMessageLength - ) + content: `${previous.content}${delta}` } } else { current.push({ id: randomUUID(), type, - content: delta.slice(0, maximumRecoveredMessageLength) + content: delta }) } return current @@ -1296,11 +1306,7 @@ function reduceRecoveredAgentEvent( ): ConversationMessage { let next: ConversationMessage = message if (event.type === 'text') { - const remaining = Math.max( - 0, - maximumRecoveredMessageLength - message.content.length - ) - const delta = event.delta.slice(0, remaining) + const delta = event.delta const blocks = appendRecoveredMessageBlock( message.blocks, 'text', @@ -1310,17 +1316,11 @@ function reduceRecoveredAgentEvent( ...message, content: `${message.content}${delta}`, blocks, - status: - event.delta.length > remaining - ? '回答过长,已在本地截断显示' - : undefined + status: undefined } } else if (event.type === 'reasoning') { const reasoning = message.reasoning ?? '' - const delta = event.delta.slice( - 0, - Math.max(0, maximumRecoveredMessageLength - reasoning.length) - ) + const delta = event.delta const blocks = appendRecoveredMessageBlock( message.blocks, 'reasoning', @@ -1493,7 +1493,7 @@ function reduceRecoveredAgentEvent( : ('interrupted' as const) const fallback = event.type === 'error' && !represented && !message.content - ? event.message.slice(0, maximumRecoveredMessageLength) + ? event.message : '' next = { ...message, @@ -2604,7 +2604,6 @@ export class AssistantDatabase { FROM messages WHERE conversation_id = ? ORDER BY sequence DESC - LIMIT 500 ) ORDER BY sequence ASC` ) @@ -2643,7 +2642,6 @@ export class AssistantDatabase { FROM messages WHERE conversation_id = ? ORDER BY sequence DESC - LIMIT 500 ) ORDER BY sequence ASC` ) @@ -2782,7 +2780,6 @@ export class AssistantDatabase { updatedAt ) for (const [sequence, message] of conversation.messages - .slice(-500) .entries()) { insertMessage.run( message.id, @@ -2851,16 +2848,6 @@ export class AssistantDatabase { SET content = ?, state = ?, metadata_json = ? WHERE id = ?` ) - const trimMessages = database.prepare( - `DELETE FROM messages - WHERE id IN ( - SELECT id - FROM messages - WHERE conversation_id = ? - ORDER BY sequence DESC - LIMIT -1 OFFSET 500 - )` - ) database.exec('BEGIN IMMEDIATE') try { @@ -2910,7 +2897,6 @@ export class AssistantDatabase { let sequence = ( nextSequence.get(header.id) as { sequence: number } ).sequence - let insertedMessage = false for (const message of save.messages) { const existingMessage = findMessage.get(message.id) as | { @@ -2948,10 +2934,6 @@ export class AssistantDatabase { new Date(message.createdAt).toISOString() ) sequence += 1 - insertedMessage = true - } - if (insertedMessage) { - trimMessages.run(header.id) } } database.exec('COMMIT') @@ -3316,23 +3298,6 @@ export class AssistantDatabase { }), new Date(now).toISOString() ) - database - .prepare( - `DELETE FROM messages - WHERE id IN ( - SELECT id - FROM messages - WHERE conversation_id = ? - ORDER BY sequence DESC - LIMIT -1 OFFSET 500 - ) - AND NOT EXISTS ( - SELECT 1 FROM tasks - WHERE tasks.id = messages.request_id - AND tasks.remote_recoverable = 1 - )` - ) - .run(input.conversationId) database .prepare( 'UPDATE conversations SET updated_at = ? WHERE id = ?' @@ -4207,7 +4172,7 @@ export class AssistantDatabase { const safeLimit = Math.max(1, Math.min(500, Math.trunc(limit))) const rows = this.requireDatabase() .prepare( - `SELECT * FROM tasks + `${taskSelect} WHERE visible = 1 ORDER BY created_at DESC LIMIT ?` @@ -7698,7 +7663,7 @@ export class AssistantDatabase { private getTask(taskId: string): AssistantTask { const row = this.requireDatabase() - .prepare('SELECT * FROM tasks WHERE id = ?') + .prepare(`${taskSelect} WHERE tasks.id = ?`) .get(taskId) as TaskRow | undefined if (!row) { throw new Error('任务不存在') diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 5cd1c795..c6816cd2 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AgentEvent, BrowserLiveState, + BrowserTabId, ContextAttachment, ConversationQueueDispatch, DesktopApi, @@ -143,6 +144,9 @@ const removeMaximizedChangedListener = vi.fn(); const removeProjectRecoveryListener = vi.fn(); const run = vi.fn(); const modelProfileId = "00000000-0000-4000-8000-000000000001"; +const browserTabId = "00000000-0000-4000-8000-000000000701" as BrowserTabId; +const otherBrowserTabId = + "00000000-0000-4000-8000-000000000702" as BrowserTabId; const projectId = "00000000-0000-4000-8000-000000000101"; const project = { id: projectId, @@ -246,6 +250,18 @@ const api: DesktopApi = { }), }, browser: { + createTab: vi.fn(async ({ conversationId }) => ({ + conversationId, + tabId: browserTabId, + primary: true, + status: "ready" as const, + isLoading: false, + canGoBack: false, + createdAt: Date.now(), + updatedAt: Date.now(), + })), + listTabs: vi.fn(async () => []), + closeTab: vi.fn(async () => {}), navigate: vi.fn(async () => {}), back: vi.fn(async () => {}), reload: vi.fn(async () => {}), @@ -976,6 +992,7 @@ function createBrowserState( ): BrowserLiveState { return { conversationId, + tabId: browserTabId, status: "ready", sessionActive: true, isLoading: false, @@ -1107,12 +1124,21 @@ describe("App", () => { .mockReset() .mockResolvedValue(); vi.mocked(api.conversationQueue.ready).mockReset().mockResolvedValue(); + const queueChangeListeners = new Set< + Parameters[0] + >(); vi.mocked(api.conversationQueue.onChanged) .mockReset() .mockImplementation((listener) => { - conversationQueueChangeListener = listener; + queueChangeListeners.add(listener); + conversationQueueChangeListener = (conversationId) => { + queueChangeListeners.forEach((callback) => callback(conversationId)); + }; return () => { - conversationQueueChangeListener = undefined; + queueChangeListeners.delete(listener); + if (queueChangeListeners.size === 0) { + conversationQueueChangeListener = undefined; + } }; }); vi.mocked(api.conversationQueue.onDispatch) @@ -1667,6 +1693,112 @@ describe("App", () => { expect(within(taskRegion).getByText("Execute")).toBeInTheDocument(); }); + it("refreshes scheduled Task state on queue-only events and guards manual runs through completion", async () => { + const conversationId = "00000000-0000-4000-8000-000000000821"; + const task: AssistantTask = { + id: "00000000-0000-4000-8000-000000000822", + projectId, + conversationId, + scheduleId: "00000000-0000-4000-8000-000000000823", + title: "Scheduled lifecycle regression", + instructions: "Check scheduled task lifecycle", + origin: "schedule", + status: "idle", + createdAt: "2026-08-19T00:00:00.000Z", + }; + const schedule: AssistantSchedule = { + id: task.scheduleId!, + projectId, + taskId: task.id, + conversationId, + title: task.title, + prompt: task.instructions, + workMode: "execute", + recurrence: "weekly", + nextRunAt: "2026-08-21T09:00:00.000Z", + enabled: true, + createdAt: task.createdAt, + updatedAt: task.createdAt, + }; + vi.mocked(api.conversations.list).mockResolvedValue([ + { + id: conversationId, + projectId, + title: "Scheduled lifecycle conversation", + updatedAt: Date.now(), + messages: [], + }, + ]); + vi.mocked(api.tasks.list).mockResolvedValue([task]); + vi.mocked(api.schedules.list).mockResolvedValue([schedule]); + + render(); + + fireEvent.click( + await screen.findByLabelText( + "展开或折叠“Scheduled lifecycle conversation”中的 1 个任务", + ), + ); + fireEvent.click( + (await screen.findByText(task.title, { + selector: ".conversation-task-child__title", + })).closest("button")!, + ); + const taskRegion = await screen.findByRole("region", { + name: "当前会话的任务", + }); + const runNow = within(taskRegion).getByRole("button", { name: "立即运行" }); + expect(runNow).toBeEnabled(); + + const runRequest = deferred< + Awaited> + >(); + const taskRefresh = deferred(); + vi.mocked(api.schedules.runNow).mockReturnValueOnce(runRequest.promise); + vi.mocked(api.tasks.list).mockReturnValueOnce(taskRefresh.promise); + vi.mocked(api.tasks.list).mockClear(); + fireEvent.click(runNow); + expect(runNow).toBeDisabled(); + expect(api.schedules.runNow).toHaveBeenCalledWith(schedule.id); + expect(api.tasks.list).not.toHaveBeenCalled(); + + await act(async () => { + runRequest.resolve(undefined); + }); + expect(api.tasks.list).toHaveBeenCalledOnce(); + expect(within(taskRegion).getByText("空闲")).toBeInTheDocument(); + expect(runNow).toBeDisabled(); + + await act(async () => { + taskRefresh.resolve([{ ...task, status: "queued" }]); + }); + expect(taskRegion.querySelector(".task-status-dot")).toHaveClass( + "task-status-dot--queued", + ); + expect(runNow).toBeDisabled(); + + for (const [status, label] of [ + ["running", "运行中"], + ["waiting_approval", "等待审批"], + ["completed", "已完成"], + ] as const) { + vi.mocked(api.tasks.list).mockResolvedValue([{ ...task, status }]); + vi.mocked(api.schedules.list).mockClear(); + act(() => { + conversationQueueChangeListener?.(conversationId); + }); + expect(await within(taskRegion).findByText(label)).toBeInTheDocument(); + expect(api.schedules.list).toHaveBeenCalled(); + if (status === "completed") { + expect(runNow).toBeEnabled(); + } else { + expect(runNow).toBeDisabled(); + fireEvent.click(runNow); + } + } + expect(api.schedules.runNow).toHaveBeenCalledOnce(); + }); + it("keeps completed Conversation Task metadata compact and accessible", async () => { const conversationId = "00000000-0000-4000-8000-000000000824"; const scheduleId = "00000000-0000-4000-8000-000000000828"; @@ -3696,11 +3828,12 @@ describe("App", () => { await waitFor(() => expect(api.conversations.saveLocal).toHaveBeenCalled()); vi.mocked(api.conversations.saveLocal).mockClear(); + const fullOutput = `streamed ${"x".repeat(1_000_001)} tail`; act(() => { agentListener?.({ requestId: request.requestId, type: "text", - delta: "流式增量", + delta: fullOutput, }); }); @@ -3714,7 +3847,7 @@ describe("App", () => { expect(batch?.[0]?.messages).toEqual([ expect.objectContaining({ role: "assistant", - content: "流式增量", + content: fullOutput, state: "streaming", }), ]); @@ -4453,6 +4586,33 @@ describe("App", () => { }); }); + it("sends all persisted history beyond 500 messages", async () => { + const messages = Array.from({ length: 502 }, (_, index) => ({ + id: crypto.randomUUID(), + role: index % 2 ? "assistant" as const : "user" as const, + content: `history ${index}`, + createdAt: 1_775_000_000_000 + index, + state: "complete" as const, + })); + vi.mocked(api.conversations.list).mockResolvedValueOnce([{ + id: crypto.randomUUID(), projectId, title: "Full history", + updatedAt: 1_775_000_000_502, messages, + }]); + render(); + await screen.findByText("history 501"); + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "Continue" }, + }); + fireEvent.click(screen.getByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + expect(run.mock.calls[0]?.[0].history).toEqual( + messages.map(({ role, content }) => ({ role, content })), + ); + expect(run.mock.calls[0]?.[0].historyMessageIds).toEqual( + messages.map(({ id }) => id), + ); + }); + it("keeps a tool failure in details and hides retry after continuing", async () => { render(); @@ -9132,7 +9292,6 @@ describe("App", () => { expect(within(dialog).getByLabelText("新对话默认 Runtime")).toHaveValue( agentRuntimeSelectionKey({ provider: "model", - profileId: modelProfileId, }), ); fireEvent.change(within(dialog).getByLabelText("说明"), { @@ -9142,7 +9301,6 @@ describe("App", () => { target: { value: agentRuntimeSelectionKey({ provider: "continue", - profileId: modelProfileId, }), }, }); @@ -9940,13 +10098,9 @@ describe("App", () => { "aria-selected", "true", ); - const taskIndexHeading = screen.getByRole("heading", { - name: "任务索引", - }); - const newTaskButton = screen.getByRole("button", { - name: "新建任务", - }); - expect(taskIndexHeading.parentElement).toContainElement(newTaskButton); + expect( + screen.getByText("没有活动项目。请选择其他任务范围或先打开一个项目。"), + ).toBeInTheDocument(); expect( screen.queryByRole("region", { name: "当前会话的任务" }), ).not.toBeInTheDocument(); @@ -9968,7 +10122,7 @@ describe("App", () => { expect( screen.queryByText(/选择文件后在当前工作区内预览/), ).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole("tab", { name: "浏览器" })); + fireEvent.click(screen.getByRole("tab", { name: /^浏览器/u })); expect(screen.queryByText("实时浏览器")).not.toBeInTheDocument(); expect(screen.getByLabelText("浏览器页面")).toBeInTheDocument(); expect(screen.getByText(/Agent 打开网页后/)).toBeInTheDocument(); @@ -9983,7 +10137,12 @@ describe("App", () => { expect(exitFullscreen).toHaveAttribute("aria-pressed", "true"); fireEvent.click(exitFullscreen); expect(sidebar).not.toHaveClass("assistant-sidebar--browser-fullscreen"); - fireEvent.click(screen.getByRole("tab", { name: "成果" })); + fireEvent.click( + screen.getByRole("button", { name: "打开工作栏应用" }), + ); + fireEvent.click( + screen.getByText("成果", { selector: "strong" }).closest("button")!, + ); expect( screen.getByRole("button", { name: "导入 PDF、图片或网页" }), ).toBeInTheDocument(); @@ -10027,7 +10186,12 @@ describe("App", () => { expect(await screen.findByText("这是一条普通聊天回复")).toBeInTheDocument(); fireEvent.click(screen.getByLabelText("切换助手工作栏")); - fireEvent.click(screen.getByRole("tab", { name: "成果" })); + fireEvent.click( + screen.getByRole("button", { name: "打开工作栏应用" }), + ); + fireEvent.click( + screen.getByText("成果", { selector: "strong" }).closest("button")!, + ); const sidebar = screen.getByLabelText("助手工作栏"); expect( within(sidebar).queryByText("这是一条普通聊天回复"), @@ -10043,7 +10207,12 @@ describe("App", () => { render(); fireEvent.click(screen.getByLabelText("切换助手工作栏")); - fireEvent.click(screen.getByRole("tab", { name: "浏览器" })); + fireEvent.click(screen.getByRole("tab", { name: /^浏览器/u })); + await waitFor(() => expect(api.browser.createTab).toHaveBeenCalledOnce()); + expect(api.browser.createTab).toHaveBeenCalledWith({ + conversationId: expect.any(String), + workbarInstanceId: "10000000-0000-4000-8000-000000000003", + }); const toolbar = screen.getByRole("form", { name: "浏览器工具栏", @@ -10074,8 +10243,11 @@ describe("App", () => { fireEvent.click(go); await waitFor(() => expect(api.browser.navigate).toHaveBeenLastCalledWith( - expect.any(String), - "https://example.com/start", + expect.objectContaining({ + conversationId: expect.any(String), + tabId: browserTabId, + url: "https://example.com/start", + }), ), ); expect(address).toHaveValue("https://example.com/start"); @@ -10086,8 +10258,11 @@ describe("App", () => { fireEvent.keyDown(address, { key: "Enter" }); await waitFor(() => expect(api.browser.navigate).toHaveBeenLastCalledWith( - expect.any(String), - "http://example.com/enter", + expect.objectContaining({ + conversationId: expect.any(String), + tabId: browserTabId, + url: "http://example.com/enter", + }), ), ); expect(address).toHaveValue("http://example.com/enter"); @@ -10098,8 +10273,11 @@ describe("App", () => { fireEvent.keyDown(address, { key: "Enter" }); await waitFor(() => expect(api.browser.navigate).toHaveBeenLastCalledWith( - expect.any(String), - "mailto:user@example.com", + expect.objectContaining({ + conversationId: expect.any(String), + tabId: browserTabId, + url: "mailto:user@example.com", + }), ), ); @@ -10109,20 +10287,61 @@ describe("App", () => { fireEvent.keyDown(address, { key: "Enter" }); await waitFor(() => expect(api.browser.navigate).toHaveBeenLastCalledWith( - expect.any(String), - "http://localhost:3000/path", + expect.objectContaining({ + conversationId: expect.any(String), + tabId: browserTabId, + url: "http://localhost:3000/path", + }), ), ); expect(api.browser.navigate).toHaveBeenCalledTimes(4); }); + it("removes stopped browser state from the rendered tab", async () => { + render(); + + fireEvent.click(screen.getByLabelText("切换助手工作栏")); + fireEvent.click(screen.getByRole("tab", { name: /^浏览器/u })); + await waitFor(() => expect(api.browser.createTab).toHaveBeenCalledOnce()); + const createRequest = vi.mocked(api.browser.createTab!).mock.calls[0]?.[0]; + if (!createRequest) { + throw new Error("Missing browser create request"); + } + const address = screen.getByRole("textbox", { name: "浏览器地址" }); + + act(() => { + browserListener?.( + createBrowserState(createRequest.conversationId, { + url: "https://stopped.example/", + updatedAt: Date.now(), + }), + ); + }); + await waitFor(() => + expect(address).toHaveValue("https://stopped.example/"), + ); + + act(() => { + browserListener?.( + createBrowserState(createRequest.conversationId, { + status: "stopped", + sessionActive: false, + updatedAt: Date.now() + 1, + }), + ); + }); + + await waitFor(() => expect(address).toHaveValue("")); + expect(screen.getByText(/输入地址并前往可打开页面/u)).toBeInTheDocument(); + }); + it("suppresses duplicate browser actions while keeping stop loading available", async () => { const navigation = deferred(); vi.mocked(api.browser.navigate).mockReturnValueOnce(navigation.promise); render(); fireEvent.click(screen.getByLabelText("切换助手工作栏")); - fireEvent.click(screen.getByRole("tab", { name: "浏览器" })); + fireEvent.click(screen.getByRole("tab", { name: /^浏览器/u })); const toolbar = screen.getByRole("form", { name: "浏览器工具栏", @@ -10138,9 +10357,12 @@ describe("App", () => { fireEvent.click(go); fireEvent.keyDown(address, { key: "Enter" }); - expect(api.browser.navigate).toHaveBeenCalledOnce(); - const conversationId = - vi.mocked(api.browser.navigate).mock.calls[0]?.[0] ?? ""; + await waitFor(() => expect(api.browser.navigate).toHaveBeenCalledOnce()); + const firstNavigationRequest = + vi.mocked(api.browser.navigate).mock.calls[0]?.[0] as unknown as + | { conversationId: string } + | undefined; + const conversationId = firstNavigationRequest?.conversationId ?? ""; act(() => { browserListener?.( createBrowserState(conversationId, { @@ -10168,7 +10390,9 @@ describe("App", () => { }); expect(stopLoading).toBeEnabled(); fireEvent.click(stopLoading); - expect(api.browser.stopLoading).toHaveBeenCalledOnce(); + await waitFor(() => + expect(api.browser.stopLoading).toHaveBeenCalledOnce(), + ); await act(async () => { navigation.resolve(); @@ -10189,7 +10413,7 @@ describe("App", () => { expect(back).toBeEnabled(); fireEvent.click(back); fireEvent.click(back); - expect(api.browser.back).toHaveBeenCalledOnce(); + await waitFor(() => expect(api.browser.back).toHaveBeenCalledOnce()); await act(async () => { backNavigation.resolve(); @@ -10224,7 +10448,7 @@ describe("App", () => { expect(screen.getByLabelText("助手工作栏")).toHaveClass( "assistant-sidebar--open", ); - expect(screen.getByRole("tab", { name: "浏览器" })).toHaveAttribute( + expect(screen.getByRole("tab", { name: /^浏览器/u })).toHaveAttribute( "aria-selected", "true", ); @@ -10236,9 +10460,20 @@ describe("App", () => { name: "浏览器地址", }); const back = within(toolbar).getByRole("button", { name: "返回" }); - expect(address).toHaveValue("https://example.com/"); + await waitFor(() => expect(address).toHaveValue("https://example.com/")); expect(back).toBeDisabled(); + act(() => { + browserListener?.( + createBrowserState(conversationId ?? "", { + tabId: otherBrowserTabId, + url: "https://other-tab.example/", + updatedAt: Date.now() + 1, + }), + ); + }); + expect(address).toHaveValue("https://example.com/"); + fireEvent.focus(address); fireEvent.change(address, { target: { value: "https://typed.example/draft" }, @@ -10261,12 +10496,12 @@ describe("App", () => { expect(back).toBeEnabled(); fireEvent.click(back); await waitFor(() => - expect(api.browser.back).toHaveBeenCalledWith(conversationId), + expect(api.browser.back).toHaveBeenCalledWith({ conversationId, tabId: browserTabId }), ); fireEvent.click(within(toolbar).getByRole("button", { name: "刷新" })); await waitFor(() => - expect(api.browser.reload).toHaveBeenCalledWith(conversationId), + expect(api.browser.reload).toHaveBeenCalledWith({ conversationId, tabId: browserTabId }), ); fireEvent.change(address, { target: { value: "https://example.com/manual" }, @@ -10274,8 +10509,7 @@ describe("App", () => { fireEvent.click(within(toolbar).getByRole("button", { name: "前往" })); await waitFor(() => expect(api.browser.navigate).toHaveBeenCalledWith( - conversationId, - "https://example.com/manual", + { conversationId, tabId: browserTabId, url: "https://example.com/manual" }, ), ); expect(address).toHaveValue("https://example.com/redirected"); @@ -10296,8 +10530,7 @@ describe("App", () => { fireEvent.click(within(toolbar).getByRole("button", { name: "前往" })); await waitFor(() => expect(api.browser.navigate).toHaveBeenCalledWith( - conversationId, - "https://example.com/canonical", + { conversationId, tabId: browserTabId, url: "https://example.com/canonical" }, ), ); expect(address).toHaveValue("https://example.com/canonical/"); @@ -10374,7 +10607,7 @@ describe("App", () => { ).not.toBeInTheDocument(); fireEvent.click(stopLoading); await waitFor(() => - expect(api.browser.stopLoading).toHaveBeenCalledWith(conversationId), + expect(api.browser.stopLoading).toHaveBeenCalledWith({ conversationId, tabId: browserTabId }), ); act(() => { @@ -10950,7 +11183,7 @@ describe("App", () => { { timeout: 3000 }, ), ).toBeInTheDocument(); - expect(screen.getByText("全局")).toBeInTheDocument(); + expect(screen.getByLabelText("全局")).toBeInTheDocument(); expect( screen.getByRole("button", { name: "新建笔记" }), ).toBeInTheDocument(); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 3dee1d45..8a8f9e84 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -76,6 +76,8 @@ import type { } from "../../shared/contracts"; import { defaultContextCompressionSettings, + isAgentRuntimeModelProtocol, + isDeepSeekHarnessModelProfile, maximumPastedImageBytes, } from "../../shared/contracts"; import { @@ -87,6 +89,7 @@ import { import { agentRuntimeSelectionKey, agentRuntimeSelectionSchema, + getRuntimeSelectionProfileId, type AgentRuntimeSelection, } from "../../shared/runtime-selection-contracts"; import { @@ -538,8 +541,6 @@ const maximumPrimarySidebarWidth = 420; const minimumPrimaryWorkspaceWidth = 480; const primarySidebarKeyboardResizeStep = 16; -const maxMessageContentLength = 1_000_000; - function getPrimarySidebarWidthLimits(viewportWidth: number): { minimum: number; maximum: number; @@ -598,14 +599,14 @@ function appendMessageContentBlock( if (previous?.type === type) { current[current.length - 1] = { ...previous, - content: `${previous.content}${delta}`.slice(0, maxMessageContentLength), + content: `${previous.content}${delta}`, }; return current; } current.push({ id: crypto.randomUUID(), type, - content: delta.slice(0, maxMessageContentLength), + content: delta, }); return current; } @@ -1181,7 +1182,7 @@ function loadConversations( conversation.contextMetrics === undefined ? undefined : parseConversationContextMetrics(conversation.contextMetrics), - messages: conversation.messages.slice(-500).map((message) => + messages: conversation.messages.map((message) => message.state === "streaming" ? { ...message, @@ -1250,7 +1251,6 @@ function isConversation(value: unknown): value is Conversation { assistantIdSchema.safeParse(entry.queueItemId).success) && (entry.role === "user" || entry.role === "assistant") && typeof entry.content === "string" && - entry.content.length <= 1_000_000 && (entry.reasoning === undefined || typeof entry.reasoning === "string") && (entry.blocks === undefined || @@ -1316,7 +1316,7 @@ function toConversationSnapshots( ...(conversation.branch ? { branch: conversation.branch } : {}), title: conversation.title, updatedAt: conversation.updatedAt, - messages: conversation.messages.slice(-500).map(toConversationMessage), + messages: conversation.messages.map(toConversationMessage), })); } @@ -1388,7 +1388,6 @@ function createLocalConversationSaveBatch( header: toLocalConversationHeader(conversation), messages: conversation.messages .filter((message) => previousMessages.get(message.id) !== message) - .slice(-500) .map(toConversationMessage), }); acknowledgements.push(conversation); @@ -1472,7 +1471,7 @@ function mergePersistedConversations( return localMessage; }), ...local.messages.filter((message) => !serverMessageIds.has(message.id)), - ].slice(-500); + ]; const next = localIsNewer ? { ...local, messages } : { ...conversation, messages }; @@ -1543,13 +1542,12 @@ function resolveContextMetricsRuntimeSelection( selection: AgentRuntimeSelection, settings: RuntimeSettings, ): AgentRuntimeSelection { - if (selection.provider !== "auto") { - return selection; - } - return getRuntimeSelectionForProvider( + const effective = selection.provider !== "auto" ? selection : getRuntimeSelectionForProvider( settings.provider === "auto" ? "opencode" : settings.provider, settings, ); + const profileId = getRuntimeSelectionProfileId(effective, settings); + return profileId && effective.provider !== "auto" ? { ...effective, profileId } : effective; } function getRuntimeSelectionLabel( @@ -1566,10 +1564,11 @@ function getRuntimeSelectionLabel( if (!selection || !settings) { return status?.label ?? "Runtime"; } + const profileId = getRuntimeSelectionProfileId(selection, settings); const profile = - "profileId" in selection && selection.profileId + profileId ? settings.modelProfiles.find( - (candidate) => candidate.id === selection.profileId, + (candidate) => candidate.id === profileId, ) : undefined; const requestedProfileMissing = @@ -1619,11 +1618,11 @@ function getConfiguredAgentRuntimeSource( useOwnConfiguration: (runtime: string) => string; }, ): { label: string; detail: string } { - const selection = getRuntimeSelectionForProvider(provider, settings); + const profileId = getRuntimeSelectionProfileId({ provider }, settings); const profile = - "profileId" in selection + profileId ? settings.modelProfiles.find( - (candidate) => candidate.id === selection.profileId, + (candidate) => candidate.id === profileId, ) : undefined; const runtimeLabel = @@ -1632,7 +1631,7 @@ function getConfiguredAgentRuntimeSource( : provider === "continue" ? "Continue" : "DeepSeek Harness"; - if ("profileId" in selection) { + if (profileId) { return { label: `${runtimeLabel} · ${profile?.name ?? labels.modelUnavailable}`, detail: profile?.modelName ?? labels.selectModel, @@ -2288,7 +2287,7 @@ function App(): React.JSX.Element { const [assistantSidebarTab, setAssistantSidebarTab] = useState("tasks"); const [browserStates, setBrowserStates] = useState< - Record + Record> >({}); const [view, setViewState] = useState("chat"); const settingsEntryFocusRef = useRef(undefined); @@ -3151,9 +3150,9 @@ function App(): React.JSX.Element { () => activeConversation?.runtimeSelection ?? (runtimeSettings - ? getDefaultRuntimeSelection(runtimeSettings) + ? getProjectDefaultRuntimeSelection(activeProject, runtimeSettings) : undefined), - [activeConversation?.runtimeSelection, runtimeSettings], + [activeConversation?.runtimeSelection, activeProject, runtimeSettings], ); const activeRuntimeSelectionKey = activeRuntimeSelection ? agentRuntimeSelectionKey(activeRuntimeSelection) @@ -3621,9 +3620,7 @@ function App(): React.JSX.Element { } const conversation = createConversation( projectId, - runtimeSettings - ? getProjectDefaultRuntimeSelection(project, runtimeSettings) - : undefined, + undefined, tRef.current("conversation.greeting"), ); const nextConversations = [conversation, ...navigation.conversations]; @@ -3722,6 +3719,7 @@ function App(): React.JSX.Element { ? [ { conversationId: conversation.id, + projectId: conversation.projectId, messageId: message.id, approvalId: message.approval.id, title: message.approval.title, @@ -4264,41 +4262,29 @@ function App(): React.JSX.Element { if (event.type === "text") { updateMessage(run.conversationId, run.messageId, (message) => { - const remaining = Math.max( - 0, - maxMessageContentLength - message.content.length, - ); - const acceptedDelta = event.delta.slice(0, remaining); const blocks = appendMessageContentBlock( message.blocks, "text", - acceptedDelta, + event.delta, ); return { ...message, - content: `${message.content}${acceptedDelta}`, + content: `${message.content}${event.delta}`, blocks, - status: - event.delta.length > remaining - ? tRef.current("chat.status.responseTruncated") - : undefined, + status: undefined, }; }); } else if (event.type === "reasoning") { updateMessage(run.conversationId, run.messageId, (message) => { const currentReasoning = message.reasoning ?? ""; - const acceptedDelta = event.delta.slice( - 0, - Math.max(0, maxMessageContentLength - currentReasoning.length), - ); const blocks = appendMessageContentBlock( message.blocks, "reasoning", - acceptedDelta, + event.delta, ); return { ...message, - reasoning: `${currentReasoning}${acceptedDelta}`, + reasoning: `${currentReasoning}${event.delta}`, status: undefined, blocks, }; @@ -4736,7 +4722,7 @@ function App(): React.JSX.Element { : ("interrupted" as const); const fallbackError = event.type === "error" && !representedToolError && !message.content - ? event.message.slice(0, maxMessageContentLength) + ? event.message : ""; return { ...message, @@ -5108,6 +5094,8 @@ function App(): React.JSX.Element { } }; const remove = window.goodbuddy.conversations.onChanged(queueRefresh); + const removeQueueListener = + window.goodbuddy.conversationQueue.onChanged(queueRefresh); window.addEventListener("focus", refreshWhenVisible); document.addEventListener("visibilitychange", refreshWhenVisible); return () => { @@ -5116,6 +5104,7 @@ function App(): React.JSX.Element { window.clearTimeout(refreshTimer); } remove(); + removeQueueListener(); window.removeEventListener("focus", refreshWhenVisible); document.removeEventListener("visibilitychange", refreshWhenVisible); }; @@ -6015,19 +6004,35 @@ function App(): React.JSX.Element { return; } return browserApi.onState((state) => { + const tabId = state.tabId; setBrowserStates((current) => { - const previous = current[state.conversationId]; + const conversationStates = current[state.conversationId] ?? {}; + if (state.status === "stopped") { + if (!(tabId in conversationStates)) { + return current; + } + const nextConversationStates = { ...conversationStates }; + delete nextConversationStates[tabId]; + const next = { ...current }; + if (Object.keys(nextConversationStates).length === 0) { + delete next[state.conversationId]; + } else { + next[state.conversationId] = nextConversationStates; + } + return next; + } + const previous = conversationStates[tabId]; return { ...current, - [state.conversationId]: - state.status === "stopped" || - state.frameDataUrl || - !previous?.frameDataUrl - ? state - : { - ...state, - frameDataUrl: previous.frameDataUrl, - }, + [state.conversationId]: { + ...conversationStates, + [tabId]: state.frameDataUrl || !previous?.frameDataUrl + ? state + : { + ...state, + frameDataUrl: previous.frameDataUrl, + }, + }, }; }); if ( @@ -6101,9 +6106,7 @@ function App(): React.JSX.Element { } else { const created = createConversation( selected.id, - runtimeSettings - ? getProjectDefaultRuntimeSelection(selected, runtimeSettings) - : undefined, + undefined, t("conversation.greeting"), ); setConversations((current) => [created, ...current]); @@ -6139,9 +6142,7 @@ function App(): React.JSX.Element { setWorkMode(normalizeInteractiveWorkMode(project.defaultWorkMode)); const conversation = createConversation( project.id, - runtimeSettings - ? getProjectDefaultRuntimeSelection(project, runtimeSettings) - : undefined, + undefined, t("conversation.greeting"), ); setConversations((current) => [conversation, ...current]); @@ -6482,9 +6483,7 @@ function App(): React.JSX.Element { } const replacement = createConversation( activeProjectId || undefined, - runtimeSettings - ? getProjectDefaultRuntimeSelection(activeProject, runtimeSettings) - : undefined, + undefined, t("conversation.greeting"), ); setConversations((current) => [replacement, ...current]); @@ -6863,11 +6862,9 @@ function App(): React.JSX.Element { 8, ); const historySnapshot = conversationSnapshot.messages; - const retainedHistorySnapshot = historySnapshot - .filter( + const retainedHistorySnapshot = historySnapshot.filter( (message) => message.state === "complete" && message.content.trim(), - ) - .slice(-500); + ); const projectIdSnapshot = queuedInput ? queuedInput.projectId : activeProjectId || undefined; @@ -7165,11 +7162,9 @@ function App(): React.JSX.Element { ) { return; } - const history = activeConversation.messages - .filter( + const history = activeConversation.messages.filter( (message) => message.state === "complete" && message.content.trim(), - ) - .slice(-500); + ); if (history.length < 2) { notify({ tone: "info", @@ -7799,6 +7794,7 @@ function App(): React.JSX.Element { const runAssistantSchedule = async (scheduleId: string): Promise => { await window.goodbuddy.schedules.runNow(scheduleId); + setAssistantTasks(await window.goodbuddy.tasks.list()); notify({ tone: "success", message: t("notices.scheduleStarted"), @@ -7846,9 +7842,7 @@ function App(): React.JSX.Element { await window.goodbuddy.app.clearLocalData(); const conversation = createConversation( activeProjectId || undefined, - runtimeSettings - ? getProjectDefaultRuntimeSelection(activeProject, runtimeSettings) - : undefined, + undefined, t("conversation.greeting"), ); conversationsRef.current = [conversation]; @@ -8044,12 +8038,8 @@ function App(): React.JSX.Element { const runBrowserCommand = async ( command: ( browserApi: NonNullable, - conversationId: string, ) => Promise, ): Promise => { - if (!activeId) { - return; - } const browserApi = window.goodbuddy.browser; if (!browserApi) { notify({ @@ -8058,7 +8048,7 @@ function App(): React.JSX.Element { }); return; } - await command(browserApi, activeId); + await command(browserApi); }; return ( @@ -9580,6 +9570,16 @@ function App(): React.JSX.Element { {t("runtime.directModels")} + {runtimeSettings?.modelProfiles.map( (profile) => ( + {runtimeSettings && ( + <> + {t("runtime.fixedRuntimeModel")} + {(["opencode", "continue", "deepseek-harness"] as const).flatMap((provider) => + runtimeSettings.modelProfiles.filter((profile) => + (!activeProjectUsesManagedSsh || provider === "opencode") && + (provider === "deepseek-harness" ? isDeepSeekHarnessModelProfile(profile) : isAgentRuntimeModelProtocol(profile.protocol)) + ).map((profile) => { + const selection = { provider, profileId: profile.id }; + const selected = activeRuntimeSelectionKey === agentRuntimeSelectionKey(selection); + return ; + }) + )} + + )} )} @@ -10735,7 +10755,7 @@ function App(): React.JSX.Element { activeConversationId={activeId} approvals={pendingSidebarApprovals} artifacts={sidebarArtifacts} - browserState={browserStates[activeId]} + browserStates={browserStates} conversationTitles={conversationTitles} currentProject={activeProject} onCreateCustomTask={() => openCustomTaskDialog("current")} @@ -10743,24 +10763,24 @@ function App(): React.JSX.Element { selectedTaskId={selectedAssistantTaskId} tasks={productAssistantTasks} projectNames={projectNames} - onBackBrowser={() => - runBrowserCommand((browserApi, conversationId) => - browserApi.back(conversationId), + onBackBrowser={(conversationId, tabId) => + runBrowserCommand((browserApi) => + browserApi.back({ conversationId, tabId }), ) } - onNavigateBrowser={(url) => - runBrowserCommand((browserApi, conversationId) => - browserApi.navigate(conversationId, url), + onNavigateBrowser={(conversationId, tabId, url) => + runBrowserCommand((browserApi) => + browserApi.navigate({ conversationId, tabId, url }), ) } - onReloadBrowser={() => - runBrowserCommand((browserApi, conversationId) => - browserApi.reload(conversationId), + onReloadBrowser={(conversationId, tabId) => + runBrowserCommand((browserApi) => + browserApi.reload({ conversationId, tabId }), ) } - onStopLoadingBrowser={() => - runBrowserCommand((browserApi, conversationId) => - browserApi.stopLoading(conversationId), + onStopLoadingBrowser={(conversationId, tabId) => + runBrowserCommand((browserApi) => + browserApi.stopLoading({ conversationId, tabId }), ) } onImportArtifacts={async () => { diff --git a/src/renderer/src/ConversationTaskStrip.tsx b/src/renderer/src/ConversationTaskStrip.tsx index bb131b35..15325639 100644 --- a/src/renderer/src/ConversationTaskStrip.tsx +++ b/src/renderer/src/ConversationTaskStrip.tsx @@ -185,6 +185,7 @@ export function ConversationTaskStrip({ onRunSchedule={onRunSchedule} onSetScheduleEnabled={onSetScheduleEnabled} schedule={selectedSchedule} + taskStatus={selectedTask.status} taskTitle={selectedTask.title} /> diff --git a/src/renderer/src/RightAssistantSidebar.tsx b/src/renderer/src/RightAssistantSidebar.tsx index 13b67763..57b9a98a 100644 --- a/src/renderer/src/RightAssistantSidebar.tsx +++ b/src/renderer/src/RightAssistantSidebar.tsx @@ -38,7 +38,8 @@ import type { import { MarkdownRenderer } from './MarkdownRenderer' import type { ApprovalDecision, - BrowserLiveState + BrowserLiveState, + BrowserTabId } from '../../shared/contracts' import { WorkspaceFilesPanel } from './WorkspaceFilesPanel' import { SegmentedControl } from './WorkspacePrimitives' @@ -51,8 +52,11 @@ import type { } from '../../shared/terminal-contracts' import { WORKBAR_APP_DEFINITIONS, + WORKBAR_LIMITS, + normalizeWorkbarLayoutPreferences, workbarLayoutPreferencesSchema, type WorkbarAppDefinition, + type WorkbarTaskScope, type WorkbarTabInstance, type WorkbarTargetRef } from '../../shared/workbar-contracts' @@ -85,6 +89,7 @@ export type SidebarArtifact = { export type PendingSidebarApproval = { conversationId: string + projectId?: string messageId: string approvalId: string title: string @@ -105,13 +110,13 @@ type RightAssistantSidebarProps = { workspaceChanges?: WorkspaceChanges workspaceProjectId?: string activeConversationId?: string - browserState?: BrowserLiveState + browserStates?: Readonly>>> currentProject?: AssistantProject restoreFocusRef?: { current: HTMLElement | null } - onBackBrowser?: () => Promise - onNavigateBrowser?: (url: string) => Promise - onReloadBrowser?: () => Promise - onStopLoadingBrowser?: () => Promise + onBackBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise + onNavigateBrowser?: (conversationId: string, tabId: BrowserTabId, url: string) => Promise + onReloadBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise + onStopLoadingBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise onCreateCustomTask: () => void onImportArtifacts: () => Promise onLoadArtifact: (artifactId: string) => Promise @@ -162,7 +167,10 @@ function loadPersistedWorkbarLayout(): ReturnType< if (!value || value.length > 100_000) { return undefined } - return workbarLayoutPreferencesSchema.parse(JSON.parse(value)) + return normalizeWorkbarLayoutPreferences( + JSON.parse(value), + DEFAULT_WORKBAR_INSTANCES + ) } catch { return undefined } @@ -172,7 +180,8 @@ function persistWorkbarLayout( instances: readonly WorkbarTabInstance[], activeInstanceId: string | null, expanded: boolean, - widthRatio: number + widthRatio: number, + taskScope: WorkbarTaskScope ): void { try { localStorage.setItem( @@ -183,7 +192,8 @@ function persistWorkbarLayout( activeInstanceId, expanded, dock: 'right', - widthRatio + widthRatio, + taskScope }) ) ) @@ -472,10 +482,12 @@ function BrowserToolbar({ function BrowserViewport({ browserState, conversationId, + tabId, visible }: { browserState?: BrowserLiveState conversationId?: string + tabId?: BrowserTabId visible: boolean }): React.JSX.Element { const { t } = useTranslation('workspace') @@ -484,9 +496,10 @@ function BrowserViewport({ useLayoutEffect(() => { const host = hostRef.current const browserApi = window.goodbuddy?.browser - if (!host || !conversationId || !browserApi?.setViewport) { + if (!host || !conversationId || !tabId || !visible || !browserApi?.setViewport) { return } + const leaseToken = crypto.randomUUID() let animationFrame: number | undefined let lastRequest = '' let resizeObserver: ResizeObserver | undefined @@ -512,13 +525,17 @@ function BrowserViewport({ height: Math.round(rect.height) } : undefined - const request = JSON.stringify({ conversationId, bounds }) + const request = JSON.stringify({ conversationId, tabId, bounds }) if (request === lastRequest) { return } lastRequest = request void browserApi - .setViewport(isVisible ? conversationId : undefined, bounds) + .setViewport( + isVisible + ? { conversationId, tabId, leaseToken, bounds: bounds! } + : { leaseToken } + ) .catch(() => undefined) }) } @@ -554,9 +571,9 @@ function BrowserViewport({ mutationObserver.disconnect() window.removeEventListener('resize', syncViewport) window.removeEventListener('scroll', syncViewport, true) - void browserApi.setViewport().catch(() => undefined) + void browserApi.setViewport({ leaseToken }).catch(() => undefined) } - }, [conversationId, visible]) + }, [conversationId, tabId, visible]) return (
{}, @@ -670,30 +687,53 @@ export function RightAssistantSidebar({ ) const [workbarInstances, setWorkbarInstances] = useState< WorkbarTabInstance[] - >(() => - (initialLayout?.instances ?? defaultInstances).map((instance) => - instance.appId === 'terminal' - ? instance - : { - ...instance, - title: - localizedAppDefinitions.find( - (definition) => definition.id === instance.appId - )?.label ?? instance.title - } + >(() => { + const instances = (initialLayout?.instances ?? defaultInstances).map( + (instance) => { + if (instance.appId === 'terminal') { + return instance + } + const label = + localizedAppDefinitions.find( + (definition) => definition.id === instance.appId + )?.label ?? instance.title + if (instance.appId !== 'browser') { + return { ...instance, title: label } + } + const conversationId = + instance.targetRef?.type === 'conversation' + ? instance.targetRef.conversationId + : !initialLayout + ? activeConversationId + : undefined + return { + ...instance, + ...(conversationId + ? { + title: `${label} · ${conversationTitles.get(conversationId) ?? conversationId}`, + targetRef: { + type: 'conversation' as const, + conversationId + } + } + : { title: `${label} · ${t('sidebar.browser.unbound')}` }) + } + } ) - ) + return instances + }) const [activeWorkbarInstanceId, setActiveWorkbarInstanceId] = useState( () => initialLayout?.activeInstanceId ?? - defaultInstances.find((instance) => instance.appId === tab)?.id ?? - defaultInstances[0]?.id ?? + workbarInstances.find((instance) => instance.appId === tab)?.id ?? + workbarInstances[0]?.id ?? null ) - const activeWorkbarApp = workbarInstances.find( + const activeWorkbarInstance = workbarInstances.find( (instance) => instance.id === activeWorkbarInstanceId - )?.appId + ) + const activeWorkbarApp = activeWorkbarInstance?.appId const [splitLayoutWidth, setSplitLayoutWidth] = useState( window.innerWidth ) @@ -701,7 +741,17 @@ export function RightAssistantSidebar({ initialLayout?.widthRatio ?? defaultSidebarRatio ) const [isResizing, setIsResizing] = useState(false) - const [browserFullscreen, setBrowserFullscreen] = useState(false) + const [browserFullscreenInstanceId, setBrowserFullscreenInstanceId] = + useState() + const [browserTabIds, setBrowserTabIds] = useState< + Record + >({}) + const browserTabIdsRef = useRef>({}) + const pendingBrowserTabsRef = useRef< + Map> + >(new Map()) + const browserMruRef = useRef>({}) + const nextBrowserMruRef = useRef(0) const sidebarRef = useRef(null) const wasOpen = useRef(false) const sidebarWidth = clampSidebarWidth( @@ -748,6 +798,9 @@ export function RightAssistantSidebar({ const [taskFilter, setTaskFilter] = useState< 'attention' | 'active' | 'paused' | 'finished' >('active') + const [taskScope, setTaskScope] = useState( + initialLayout?.taskScope ?? 'current-project' + ) const [actionError, setActionError] = useState('') const [terminalSessionIds, setTerminalSessionIds] = useState< Record @@ -808,15 +861,36 @@ export function RightAssistantSidebar({ [] ) const browserFullscreenActive = - open && activeWorkbarApp === 'browser' && browserFullscreen + open && + activeWorkbarApp === 'browser' && + browserFullscreenInstanceId === activeWorkbarInstanceId const sidebarWidthLimits = getSidebarWidthLimits(splitLayoutWidth) const canResize = open && !browserFullscreenActive && sidebarWidthLimits.maximum > sidebarWidthLimits.minimum + const taskMatchesScope = useCallback( + (projectId?: string): boolean => { + if (taskScope === 'all-projects') { + return true + } + if (taskScope === 'global') { + return !projectId + } + return Boolean(currentProject && projectId === currentProject.id) + }, + [currentProject, taskScope] + ) + const scopedApprovals = useMemo( + () => approvals.filter((approval) => taskMatchesScope(approval.projectId)), + [approvals, taskMatchesScope] + ) const topLevelTasks = useMemo( - () => tasks.filter((task) => !task.parentTaskId), - [tasks] + () => + tasks.filter( + (task) => !task.parentTaskId && taskMatchesScope(task.projectId) + ), + [taskMatchesScope, tasks] ) const filteredTasks = useMemo( () => @@ -844,24 +918,46 @@ export function RightAssistantSidebar({ ) useEffect(() => { + // Context switches preserve the selected instance and its pinned binding. + // Only an explicit external tab change requests a different selection. if (lastExternalTabRef.current === tab) { return } lastExternalTabRef.current = tab - const requested = workbarInstances.find( - (instance) => instance.appId === tab - ) + const defaultBrowserId = DEFAULT_WORKBAR_INSTANCES.find( + (instance) => instance.appId === 'browser' + )?.id + const requested = + tab === 'browser' + ? workbarInstances + .filter( + (instance) => + instance.appId === 'browser' && + (!activeConversationId || + (instance.targetRef?.type === 'conversation' && + instance.targetRef.conversationId === activeConversationId) || + (instance.id === defaultBrowserId && + instance.targetRef?.type !== 'conversation') + ) + ) + .sort( + (left, right) => + (browserMruRef.current[right.id] ?? 0) - + (browserMruRef.current[left.id] ?? 0) + )[0] + : workbarInstances.find((instance) => instance.appId === tab) if (requested) { - // This effect intentionally mirrors the legacy external tab prop into - // the dynamic workbar only when that prop actually changes. - // eslint-disable-next-line react-hooks/set-state-in-effect setActiveWorkbarInstanceId(requested.id) } - }, [tab, workbarInstances]) + }, [activeConversationId, tab, workbarInstances]) const toggleBrowserFullscreen = useCallback((): void => { - setBrowserFullscreen((current) => !current) - }, []) + setBrowserFullscreenInstanceId((current) => + current === activeWorkbarInstanceId + ? undefined + : activeWorkbarInstanceId ?? undefined + ) + }, [activeWorkbarInstanceId]) useEffect(() => { if (!browserFullscreenActive) { @@ -881,12 +977,14 @@ export function RightAssistantSidebar({ workbarInstances, activeWorkbarInstanceId, open, - sidebarRatio + sidebarRatio, + taskScope ) }, [ activeWorkbarInstanceId, open, sidebarRatio, + taskScope, workbarInstances ]) @@ -905,8 +1003,12 @@ export function RightAssistantSidebar({ ) const updateActiveWorkbarInstance = useCallback( - (instanceId: string): void => { + (instanceId: string | null): void => { setActiveWorkbarInstanceId(instanceId) + setBrowserFullscreenInstanceId(undefined) + if (!instanceId) { + return + } const instance = workbarInstances.find( (candidate) => candidate.id === instanceId ) @@ -914,6 +1016,9 @@ export function RightAssistantSidebar({ instance && instance.appId !== 'terminal' ) { + if (instance.appId === 'browser') { + browserMruRef.current[instance.id] = ++nextBrowserMruRef.current + } onTabChange(instance.appId) } }, @@ -928,21 +1033,51 @@ export function RightAssistantSidebar({ if (!definition) { return } + if ( + workbarInstances.length >= WORKBAR_LIMITS.maximumOpenInstances || + (definition.instancePolicy === 'single' && + workbarInstances.some( + (instance) => instance.appId === request.appId + )) + ) { + return + } const sameTargetTerminals = workbarInstances.filter( (instance) => instance.appId === 'terminal' && JSON.stringify(instance.targetRef) === JSON.stringify(request.targetRef) ).length + const conversationTarget = + request.appId === 'browser' && activeConversationId + ? { + type: 'conversation' as const, + conversationId: activeConversationId + } + : undefined + const sameConversationBrowsers = conversationTarget + ? workbarInstances.filter( + (candidate) => + candidate.appId === 'browser' && + candidate.targetRef?.type === 'conversation' && + candidate.targetRef.conversationId === + conversationTarget.conversationId + ).length + : 0 + const conversationTitle = activeConversationId + ? conversationTitles.get(activeConversationId) ?? activeConversationId + : undefined const instance: WorkbarTabInstance = { id: crypto.randomUUID(), appId: request.appId, title: request.appId === 'terminal' ? `${definition.label} ${sameTargetTerminals + 1}` + : request.appId === 'browser' && conversationTitle + ? `${definition.label} · ${conversationTitle}${sameConversationBrowsers > 0 ? ` · ${sameConversationBrowsers + 1}` : ''}` : definition.label, - ...(request.targetRef - ? { targetRef: request.targetRef } + ...(request.targetRef || conversationTarget + ? { targetRef: request.targetRef ?? conversationTarget } : {}) } setWorkbarInstances((current) => { @@ -958,7 +1093,72 @@ export function RightAssistantSidebar({ }) setActiveWorkbarInstanceId(instance.id) }, - [localizedAppDefinitions, workbarInstances] + [activeConversationId, conversationTitles, localizedAppDefinitions, workbarInstances] + ) + + const ensureBrowserTab = useCallback( + ( + instance: WorkbarTabInstance + ): Promise< + { conversationId: string; tabId: BrowserTabId } | undefined + > => { + const existingTabId = browserTabIdsRef.current[instance.id] + const conversationId = + instance.targetRef?.type === 'conversation' + ? instance.targetRef.conversationId + : activeConversationId + if (existingTabId && conversationId) { + return Promise.resolve({ conversationId, tabId: existingTabId }) + } + const pending = pendingBrowserTabsRef.current.get(instance.id) + if (pending) { + return pending + } + if (!conversationId) { + return Promise.resolve(undefined) + } + + const request = (async () => { + const browserApi = window.goodbuddy.browser + if (!browserApi.createTab) { + throw new Error(t('sidebar.errors.browserControlUnavailable')) + } + if (instance.targetRef?.type !== 'conversation') { + const conversationTitle = + conversationTitles.get(conversationId) ?? conversationId + setWorkbarInstances((current) => + current.map((candidate) => + candidate.id === instance.id + ? { + ...candidate, + title: `${t('sidebar.tabs.browser.label')} · ${conversationTitle}`, + targetRef: { type: 'conversation', conversationId } + } + : candidate + ) + ) + } + + const tab = await browserApi.createTab({ + conversationId, + workbarInstanceId: instance.id + }) + const next = { + ...browserTabIdsRef.current, + [instance.id]: tab.tabId + } + browserTabIdsRef.current = next + setBrowserTabIds(next) + return { conversationId, tabId: tab.tabId } + })() + pendingBrowserTabsRef.current.set(instance.id, request) + void request.then( + () => pendingBrowserTabsRef.current.delete(instance.id), + () => pendingBrowserTabsRef.current.delete(instance.id) + ) + return request + }, + [activeConversationId, conversationTitles, t] ) const removeWorkbarInstance = useCallback( @@ -976,12 +1176,59 @@ export function RightAssistantSidebar({ delete next[instanceId] return next }) + const nextBrowserTabIds = { ...browserTabIdsRef.current } + delete nextBrowserTabIds[instanceId] + browserTabIdsRef.current = nextBrowserTabIds + setBrowserTabIds(nextBrowserTabIds) + pendingBrowserTabsRef.current.delete(instanceId) }, [] ) const requestCloseWorkbarInstance = useCallback( async (instance: WorkbarTabInstance): Promise => { + const definition = localizedAppDefinitions.find( + (candidate) => candidate.id === instance.appId + ) + if (definition?.closable !== true) { + return false + } + if (instance.appId === 'browser') { + let tabId = browserTabIdsRef.current[instance.id] + let conversationId = + instance.targetRef?.type === 'conversation' + ? instance.targetRef.conversationId + : undefined + const pending = pendingBrowserTabsRef.current.get(instance.id) + if (!tabId && pending) { + try { + const binding = await pending + tabId = binding?.tabId + conversationId = binding?.conversationId + } catch (reason) { + setActionError( + reason instanceof Error + ? reason.message + : t('sidebar.errors.closeBrowser') + ) + return false + } + } + if (tabId && conversationId) { + try { + await window.goodbuddy.browser.closeTab?.({ conversationId, tabId }) + } catch (reason) { + setActionError( + reason instanceof Error + ? reason.message + : t('sidebar.errors.closeBrowser') + ) + return false + } + } + removeWorkbarInstance(instance.id) + return true + } if (instance.appId !== 'terminal') { removeWorkbarInstance(instance.id) return true @@ -1015,9 +1262,11 @@ export function RightAssistantSidebar({ }, [ removeWorkbarInstance, + localizedAppDefinitions, terminalAdapter, terminalSessionIds, - terminalSnapshots + terminalSnapshots, + t ] ) @@ -1191,7 +1440,7 @@ export function RightAssistantSidebar({ return } event.preventDefault() - setBrowserFullscreen(false) + setBrowserFullscreenInstanceId(undefined) const width = clampSidebarWidth(nextWidth, layoutWidth) setSidebarRatio( layoutWidth > 0 ? width / layoutWidth : defaultSidebarRatio @@ -1319,6 +1568,19 @@ export function RightAssistantSidebar({ } } + useEffect(() => { + if (!open || activeWorkbarInstance?.appId !== 'browser') { + return + } + void ensureBrowserTab(activeWorkbarInstance).catch((reason: unknown) => { + setActionError( + reason instanceof Error + ? reason.message + : t('sidebar.errors.browserControlUnavailable') + ) + }) + }, [activeWorkbarInstance, ensureBrowserTab, open, t]) + return (
@@ -1649,6 +1941,8 @@ export function RightAssistantSidebar({ ) }) )} + + )} )} @@ -1916,33 +2210,71 @@ export function RightAssistantSidebar({ {instance.appId === 'browser' && (
runAction( - onBackBrowser, + async () => { + const binding = await ensureBrowserTab(instance) + if (binding) { + await onBackBrowser(binding.conversationId, binding.tabId) + } + }, t('sidebar.errors.backBrowser') ) } onNavigate={(url) => runAction( - () => onNavigateBrowser(url), + async () => { + const binding = await ensureBrowserTab(instance) + if (binding) { + await onNavigateBrowser( + binding.conversationId, + binding.tabId, + url + ) + } + }, t('sidebar.errors.navigateBrowser') ) } onReload={() => runAction( - onReloadBrowser, + async () => { + const binding = await ensureBrowserTab(instance) + if (binding) { + await onReloadBrowser(binding.conversationId, binding.tabId) + } + }, t('sidebar.errors.reloadBrowser') ) } onStopLoading={() => runAction( - onStopLoadingBrowser, + async () => { + const binding = await ensureBrowserTab(instance) + if (binding) { + await onStopLoadingBrowser( + binding.conversationId, + binding.tabId + ) + } + }, t('sidebar.errors.stopLoadingBrowser') ) } @@ -1951,9 +2283,21 @@ export function RightAssistantSidebar({ } />
)} diff --git a/src/renderer/src/TaskScheduleActions.tsx b/src/renderer/src/TaskScheduleActions.tsx index eaebec50..934ed3d3 100644 --- a/src/renderer/src/TaskScheduleActions.tsx +++ b/src/renderer/src/TaskScheduleActions.tsx @@ -31,6 +31,7 @@ type TaskScheduleActionsProps = { enabled: boolean ) => Promise schedule: AssistantSchedule + taskStatus: AssistantTask['status'] taskTitle: string } @@ -40,6 +41,7 @@ export function TaskScheduleActions({ onRunSchedule, onSetScheduleEnabled, schedule, + taskStatus, taskTitle }: TaskScheduleActionsProps): React.JSX.Element { const { t } = useTranslation('workspace') @@ -64,7 +66,12 @@ export function TaskScheduleActions({ return ( <>