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 ( <>