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.
This commit is contained in:
mesalogo
2026-09-10 01:24:51 +08:00
parent ec100f8176
commit 428b4c0d08
8 changed files with 862 additions and 271 deletions
@@ -225,7 +225,10 @@ type TimeTrigger =
### 7.3 手动触发
“立即运行”在当前 Task 内创建独立 Job 和 Run,不改变下一次计划时间,不创建新 Task。
重复点击使用调用级幂等键去重。
提交期间,以及该计划已有排队或运行中的 occurrence 时,“立即运行”不可重复点击。
后台同样拒绝为已有活动 occurrence 的计划再次入队。
任务状态随队列变化刷新:待执行显示 queued,认领执行后显示 running;结束或取消排队后
重新读取当前状态。没有活动 occurrence 的周期计划显示 idle 或 paused。
### 7.4 与普通消息的顺序
+26 -8
View File
@@ -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()
+26 -61
View File
@@ -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('任务不存在')
+274 -41
View File
@@ -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<DesktopApi["agent"]["run"]>();
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<DesktopApi["conversationQueue"]["onChanged"]>[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(<App />);
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<ReturnType<DesktopApi["schedules"]["runNow"]>>
>();
const taskRefresh = deferred<AssistantTask[]>();
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(<App />);
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(<App />);
@@ -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(<App />);
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(<App />);
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<void>();
vi.mocked(api.browser.navigate).mockReturnValueOnce(navigation.promise);
render(<App />);
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();
+111 -91
View File
@@ -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<AssistantSidebarTab>("tasks");
const [browserStates, setBrowserStates] = useState<
Record<string, BrowserLiveState>
Record<string, Record<string, BrowserLiveState>>
>({});
const [view, setViewState] = useState<WorkspaceView>("chat");
const settingsEntryFocusRef = useRef<HTMLElement | undefined>(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<void> => {
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<typeof window.goodbuddy.browser>,
conversationId: string,
) => Promise<void>,
): Promise<void> => {
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 {
<strong role="presentation">
{t("runtime.directModels")}
</strong>
<button
aria-checked={activeRuntimeSelectionKey === "model:default"}
onClick={() => void switchRuntime({ provider: "model" })}
role="menuitemradio"
tabIndex={activeRuntimeSelectionKey === "model:default" ? 0 : -1}
type="button"
>
<span>{t("runtime.defaultDirect")}</span>
<small>{runtimeSettings?.modelProfiles.find((profile) => profile.id === runtimeSettings.defaultModelProfileId)?.name}</small>
</button>
{runtimeSettings?.modelProfiles.map(
(profile) => (
<button
@@ -9765,6 +9765,26 @@ function App(): React.JSX.Element {
>
<span>{t("runtime.manage")}</span>
</button>
{runtimeSettings && (
<>
<strong role="presentation">{t("runtime.fixedRuntimeModel")}</strong>
{(["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 <button key={`${provider}:${profile.id}`} type="button" role="menuitemradio"
aria-checked={selected} tabIndex={selected ? 0 : -1}
onClick={() => void switchRuntime(selection)}>
<span>{t("runtime.fixedModel")} · {provider === "opencode" ? "OpenCode" : provider === "continue" ? "Continue" : "DeepSeek Harness"} · {profile.name}</span>
<small>{profile.modelName}</small>
</button>;
})
)}
</>
)}
</div>
)}
</div>
@@ -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 () => {
@@ -185,6 +185,7 @@ export function ConversationTaskStrip({
onRunSchedule={onRunSchedule}
onSetScheduleEnabled={onSetScheduleEnabled}
schedule={selectedSchedule}
taskStatus={selectedTask.status}
taskTitle={selectedTask.title}
/>
</div>
+412 -68
View File
@@ -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<Record<string, Readonly<Record<string, BrowserLiveState>>>>
currentProject?: AssistantProject
restoreFocusRef?: { current: HTMLElement | null }
onBackBrowser?: () => Promise<void>
onNavigateBrowser?: (url: string) => Promise<void>
onReloadBrowser?: () => Promise<void>
onStopLoadingBrowser?: () => Promise<void>
onBackBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise<void>
onNavigateBrowser?: (conversationId: string, tabId: BrowserTabId, url: string) => Promise<void>
onReloadBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise<void>
onStopLoadingBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise<void>
onCreateCustomTask: () => void
onImportArtifacts: () => Promise<void>
onLoadArtifact: (artifactId: string) => Promise<void>
@@ -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 (
<div
@@ -594,7 +611,7 @@ export function RightAssistantSidebar({
workspaceChanges,
workspaceProjectId,
activeConversationId,
browserState,
browserStates = {},
currentProject,
restoreFocusRef,
onBackBrowser = async () => {},
@@ -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<string | null>(
() =>
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<string>()
const [browserTabIds, setBrowserTabIds] = useState<
Record<string, BrowserTabId>
>({})
const browserTabIdsRef = useRef<Record<string, BrowserTabId>>({})
const pendingBrowserTabsRef = useRef<
Map<string, Promise<{ conversationId: string; tabId: BrowserTabId } | undefined>>
>(new Map())
const browserMruRef = useRef<Record<string, number>>({})
const nextBrowserMruRef = useRef(0)
const sidebarRef = useRef<HTMLElement>(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<WorkbarTaskScope>(
initialLayout?.taskScope ?? 'current-project'
)
const [actionError, setActionError] = useState('')
const [terminalSessionIds, setTerminalSessionIds] = useState<
Record<string, string>
@@ -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<boolean> => {
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 (
<aside
ref={sidebarRef}
@@ -1372,7 +1634,7 @@ export function RightAssistantSidebar({
return
}
event.preventDefault()
setBrowserFullscreen(false)
setBrowserFullscreenInstanceId(undefined)
resizePointerId.current = event.pointerId
event.currentTarget.setPointerCapture(event.pointerId)
resizeFromClientX(event.clientX, true)
@@ -1402,12 +1664,12 @@ export function RightAssistantSidebar({
onCreateInstance={createWorkbarInstance}
onResolveTerminalTarget={resolveTerminalTarget}
renderTabAdornment={(instance) =>
instance.appId === 'tasks' && approvals.length > 0 ? (
instance.appId === 'tasks' && scopedApprovals.length > 0 ? (
<span
aria-label={`${t('sidebar.tasks.approvalsTitle')}: ${approvals.length}`}
aria-label={`${t('sidebar.tasks.approvalsTitle')}: ${scopedApprovals.length}`}
className="assistant-sidebar__badge"
>
{approvals.length}
{scopedApprovals.length}
</span>
) : null
}
@@ -1420,7 +1682,9 @@ export function RightAssistantSidebar({
{actionError}
</p>
) : null}
{instance.appId === 'terminal' && instance.targetRef ? (
{instance.appId === 'terminal' &&
instance.targetRef &&
instance.targetRef.type !== 'conversation' ? (
<Suspense
fallback={
<section
@@ -1465,16 +1729,43 @@ export function RightAssistantSidebar({
) : null}
{instance.appId === 'tasks' && (
<section className="assistant-sidebar__section">
<div className="task-center__filters">
<SegmentedControl
ariaLabel={t('sidebar.tasks.scope.ariaLabel')}
onChange={setTaskScope}
options={[
{
value: 'current-project',
label: t('sidebar.tasks.scope.currentProject')
},
{
value: 'global',
label: t('sidebar.tasks.scope.global')
},
{
value: 'all-projects',
label: t('sidebar.tasks.scope.allProjects')
}
]}
value={taskScope}
/>
</div>
{taskScope === 'current-project' && !currentProject ? (
<p className="assistant-sidebar__empty">
{t('sidebar.tasks.scope.noCurrentProject')}
</p>
) : (
<>
<h3>
<ShieldAlert size={15} />
{t('sidebar.tasks.approvalsTitle')}
</h3>
{approvals.length === 0 ? (
{scopedApprovals.length === 0 ? (
<p className="assistant-sidebar__empty">
{t('sidebar.tasks.noApprovals')}
</p>
) : (
approvals.map((approval) => (
scopedApprovals.map((approval) => (
<article
aria-label={`${t('sidebar.tasks.approvalsTitle')}: ${
approval.toolName ?? approval.title
@@ -1641,6 +1932,7 @@ export function RightAssistantSidebar({
onRunSchedule={onRunSchedule}
onSetScheduleEnabled={onSetScheduleEnabled}
schedule={schedule}
taskStatus={task.status}
taskTitle={task.title}
/>
</div>
@@ -1649,6 +1941,8 @@ export function RightAssistantSidebar({
)
})
)}
</>
)}
</section>
)}
@@ -1916,33 +2210,71 @@ export function RightAssistantSidebar({
{instance.appId === 'browser' && (
<section
className="assistant-sidebar__browser"
key={activeConversationId}
key={instance.id}
>
<BrowserToolbar
activeConversationId={activeConversationId}
browserState={browserState}
activeConversationId={
instance.targetRef?.type === 'conversation'
? instance.targetRef.conversationId
: activeConversationId
}
browserState={
instance.targetRef?.type === 'conversation' &&
browserTabIds[instance.id]
? browserStates[instance.targetRef.conversationId]?.[
browserTabIds[instance.id]!
]
: undefined
}
fullscreen={browserFullscreenActive}
onBack={() =>
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({
}
/>
<BrowserViewport
browserState={browserState}
conversationId={activeConversationId}
visible={open}
browserState={
instance.targetRef?.type === 'conversation' &&
browserTabIds[instance.id]
? browserStates[instance.targetRef.conversationId]?.[
browserTabIds[instance.id]!
]
: undefined
}
conversationId={
instance.targetRef?.type === 'conversation'
? instance.targetRef.conversationId
: undefined
}
tabId={browserTabIds[instance.id]}
visible={open && instance.id === activeWorkbarInstanceId}
/>
</section>
)}
+8 -1
View File
@@ -31,6 +31,7 @@ type TaskScheduleActionsProps = {
enabled: boolean
) => Promise<void>
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 (
<>
<button
disabled={busy}
disabled={
busy ||
taskStatus === 'queued' ||
taskStatus === 'running' ||
taskStatus === 'waiting_approval'
}
onClick={() =>
void runAction(
() => onRunSchedule(schedule.id),