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 手动触发 ### 7.3 手动触发
“立即运行”在当前 Task 内创建独立 Job 和 Run,不改变下一次计划时间,不创建新 Task。 “立即运行”在当前 Task 内创建独立 Job 和 Run,不改变下一次计划时间,不创建新 Task。
重复点击使用调用级幂等键去重。 提交期间,以及该计划已有排队或运行中的 occurrence 时,“立即运行”不可重复点击。
后台同样拒绝为已有活动 occurrence 的计划再次入队。
任务状态随队列变化刷新:待执行显示 queued,认领执行后显示 running;结束或取消排队后
重新读取当前状态。没有活动 occurrence 的周期计划显示 idle 或 paused。
### 7.4 与普通消息的顺序 ### 7.4 与普通消息的顺序
+26 -8
View File
@@ -1965,7 +1965,7 @@ describe('AssistantDatabase', () => {
database.close() 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. // Exercise the query boundary without 502 disk-synchronized fixture writes.
const database = new AssistantDatabase(':memory:') const database = new AssistantDatabase(':memory:')
database.initialize('C:\\Workspace') database.initialize('C:\\Workspace')
@@ -1992,8 +1992,8 @@ describe('AssistantDatabase', () => {
} }
const messages = database.getConversation(conversation.id).messages const messages = database.getConversation(conversation.id).messages
expect(messages).toHaveLength(500) expect(messages).toHaveLength(502)
expect(messages[0]?.content).toBe('消息 2') expect(messages[0]?.content).toBe('消息 0')
expect(messages.at(-1)?.content).toBe('消息 501') expect(messages.at(-1)?.content).toBe('消息 501')
database.close() database.close()
}) })
@@ -3708,6 +3708,9 @@ describe('AssistantDatabase', () => {
recurrence: 'daily', recurrence: 'daily',
nextRunAt: '2026-08-20T09:00:00.000Z' 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( const [dueItem] = database.queueDueSchedules(
new Date('2026-08-20T09:01:00.000Z') new Date('2026-08-20T09:01:00.000Z')
@@ -3718,6 +3721,10 @@ describe('AssistantDatabase', () => {
scheduleId: schedule.id, scheduleId: schedule.id,
taskId: schedule.taskId taskId: schedule.taskId
}) })
expect(taskStatus()).toBe('queued')
expect(() => database.queueScheduleNow(schedule.id)).toThrow(
'定时任务已有一次运行正在进行'
)
expect(database.listConversationQueueItems()).toEqual([ expect(database.listConversationQueueItems()).toEqual([
expect.objectContaining({ id: dueItem!.id }) expect.objectContaining({ id: dueItem!.id })
]) ])
@@ -3737,18 +3744,28 @@ describe('AssistantDatabase', () => {
schedule: { id: schedule.id }, schedule: { id: schedule.id },
runId: dueItem!.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( database.completeScheduleRun(
dueItem!.id, dueItem!.id,
'completed', 'completed',
new Date('2026-08-20T09:02:00.000Z') 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) const manualItem = database.queueScheduleNow(schedule.id)
expect(taskStatus()).toBe('queued')
expect(manualItem).toMatchObject({ expect(manualItem).toMatchObject({
source: 'schedule', source: 'schedule',
scheduleId: schedule.id scheduleId: schedule.id
}) })
database.cancelConversationQueueItem(manualItem.id) database.cancelConversationQueueItem(manualItem.id)
expect(taskStatus()).toBe('paused')
expect(database.listConversationQueueItems()).toEqual([]) expect(database.listConversationQueueItems()).toEqual([])
database.close() database.close()
}) })
@@ -4781,7 +4798,7 @@ describe('AssistantDatabase', () => {
database.close() 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( const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-bounded-local-conversation-') join(tmpdir(), 'goodbuddy-bounded-local-conversation-')
) )
@@ -4832,8 +4849,9 @@ describe('AssistantDatabase', () => {
]) ])
const restored = database.getConversation(conversationId) const restored = database.getConversation(conversationId)
expect(restored.messages).toHaveLength(500) expect(restored.messages).toHaveLength(501)
expect(restored.messages[0]?.id).toBe(messageId(1)) 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) expect(restored.messages.at(-1)?.id).toBe(newestMessageId)
const raw = new DatabaseSync(databasePath) const raw = new DatabaseSync(databasePath)
expect( expect(
@@ -4846,8 +4864,8 @@ describe('AssistantDatabase', () => {
) )
.get(conversationId) .get(conversationId)
).toEqual({ ).toEqual({
count: 500, count: 501,
minimum: 1, minimum: 0,
maximum: 500 maximum: 500
}) })
raw.close() raw.close()
+26 -61
View File
@@ -178,6 +178,7 @@ type TaskRow = {
instructions: string instructions: string
origin: AssistantTask['origin'] origin: AssistantTask['origin']
status: AssistantTask['status'] status: AssistantTask['status']
active_run_status?: 'pending' | 'running' | null
work_mode: LegacyWorkMode work_mode: LegacyWorkMode
progress: number | null progress: number | null
created_at: string created_at: string
@@ -576,6 +577,14 @@ function readProject(
return toProject(row) 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 { function toTask(row: TaskRow): AssistantTask {
return { return {
id: row.id, id: row.id,
@@ -589,9 +598,15 @@ function toTask(row: TaskRow): AssistantTask {
instructions: row.instructions, instructions: row.instructions,
origin: row.origin, origin: row.origin,
status: status:
row.origin === 'schedule' && row.status === 'queued' row.active_run_status === 'pending'
? 'idle' ? 'queued'
: row.status, : 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), workMode: normalizeInteractiveWorkMode(row.work_mode),
progress: row.progress ?? undefined, progress: row.progress ?? undefined,
createdAt: row.created_at, createdAt: row.created_at,
@@ -1168,8 +1183,6 @@ function serializeConversationMessageMetadata(
}) })
} }
const maximumRecoveredMessageLength = 1_000_000
function appendRecoveredMessageBlock( function appendRecoveredMessageBlock(
blocks: ConversationMessageBlock[] | undefined, blocks: ConversationMessageBlock[] | undefined,
type: 'text' | 'reasoning', type: 'text' | 'reasoning',
@@ -1183,16 +1196,13 @@ function appendRecoveredMessageBlock(
if (previous?.type === type) { if (previous?.type === type) {
current[current.length - 1] = { current[current.length - 1] = {
...previous, ...previous,
content: `${previous.content}${delta}`.slice( content: `${previous.content}${delta}`
0,
maximumRecoveredMessageLength
)
} }
} else { } else {
current.push({ current.push({
id: randomUUID(), id: randomUUID(),
type, type,
content: delta.slice(0, maximumRecoveredMessageLength) content: delta
}) })
} }
return current return current
@@ -1296,11 +1306,7 @@ function reduceRecoveredAgentEvent(
): ConversationMessage { ): ConversationMessage {
let next: ConversationMessage = message let next: ConversationMessage = message
if (event.type === 'text') { if (event.type === 'text') {
const remaining = Math.max( const delta = event.delta
0,
maximumRecoveredMessageLength - message.content.length
)
const delta = event.delta.slice(0, remaining)
const blocks = appendRecoveredMessageBlock( const blocks = appendRecoveredMessageBlock(
message.blocks, message.blocks,
'text', 'text',
@@ -1310,17 +1316,11 @@ function reduceRecoveredAgentEvent(
...message, ...message,
content: `${message.content}${delta}`, content: `${message.content}${delta}`,
blocks, blocks,
status: status: undefined
event.delta.length > remaining
? '回答过长,已在本地截断显示'
: undefined
} }
} else if (event.type === 'reasoning') { } else if (event.type === 'reasoning') {
const reasoning = message.reasoning ?? '' const reasoning = message.reasoning ?? ''
const delta = event.delta.slice( const delta = event.delta
0,
Math.max(0, maximumRecoveredMessageLength - reasoning.length)
)
const blocks = appendRecoveredMessageBlock( const blocks = appendRecoveredMessageBlock(
message.blocks, message.blocks,
'reasoning', 'reasoning',
@@ -1493,7 +1493,7 @@ function reduceRecoveredAgentEvent(
: ('interrupted' as const) : ('interrupted' as const)
const fallback = const fallback =
event.type === 'error' && !represented && !message.content event.type === 'error' && !represented && !message.content
? event.message.slice(0, maximumRecoveredMessageLength) ? event.message
: '' : ''
next = { next = {
...message, ...message,
@@ -2604,7 +2604,6 @@ export class AssistantDatabase {
FROM messages FROM messages
WHERE conversation_id = ? WHERE conversation_id = ?
ORDER BY sequence DESC ORDER BY sequence DESC
LIMIT 500
) )
ORDER BY sequence ASC` ORDER BY sequence ASC`
) )
@@ -2643,7 +2642,6 @@ export class AssistantDatabase {
FROM messages FROM messages
WHERE conversation_id = ? WHERE conversation_id = ?
ORDER BY sequence DESC ORDER BY sequence DESC
LIMIT 500
) )
ORDER BY sequence ASC` ORDER BY sequence ASC`
) )
@@ -2782,7 +2780,6 @@ export class AssistantDatabase {
updatedAt updatedAt
) )
for (const [sequence, message] of conversation.messages for (const [sequence, message] of conversation.messages
.slice(-500)
.entries()) { .entries()) {
insertMessage.run( insertMessage.run(
message.id, message.id,
@@ -2851,16 +2848,6 @@ export class AssistantDatabase {
SET content = ?, state = ?, metadata_json = ? SET content = ?, state = ?, metadata_json = ?
WHERE id = ?` 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') database.exec('BEGIN IMMEDIATE')
try { try {
@@ -2910,7 +2897,6 @@ export class AssistantDatabase {
let sequence = ( let sequence = (
nextSequence.get(header.id) as { sequence: number } nextSequence.get(header.id) as { sequence: number }
).sequence ).sequence
let insertedMessage = false
for (const message of save.messages) { for (const message of save.messages) {
const existingMessage = findMessage.get(message.id) as const existingMessage = findMessage.get(message.id) as
| { | {
@@ -2948,10 +2934,6 @@ export class AssistantDatabase {
new Date(message.createdAt).toISOString() new Date(message.createdAt).toISOString()
) )
sequence += 1 sequence += 1
insertedMessage = true
}
if (insertedMessage) {
trimMessages.run(header.id)
} }
} }
database.exec('COMMIT') database.exec('COMMIT')
@@ -3316,23 +3298,6 @@ export class AssistantDatabase {
}), }),
new Date(now).toISOString() 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 database
.prepare( .prepare(
'UPDATE conversations SET updated_at = ? WHERE id = ?' '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 safeLimit = Math.max(1, Math.min(500, Math.trunc(limit)))
const rows = this.requireDatabase() const rows = this.requireDatabase()
.prepare( .prepare(
`SELECT * FROM tasks `${taskSelect}
WHERE visible = 1 WHERE visible = 1
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT ?` LIMIT ?`
@@ -7698,7 +7663,7 @@ export class AssistantDatabase {
private getTask(taskId: string): AssistantTask { private getTask(taskId: string): AssistantTask {
const row = this.requireDatabase() const row = this.requireDatabase()
.prepare('SELECT * FROM tasks WHERE id = ?') .prepare(`${taskSelect} WHERE tasks.id = ?`)
.get(taskId) as TaskRow | undefined .get(taskId) as TaskRow | undefined
if (!row) { if (!row) {
throw new Error('任务不存在') throw new Error('任务不存在')
+274 -41
View File
@@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { import type {
AgentEvent, AgentEvent,
BrowserLiveState, BrowserLiveState,
BrowserTabId,
ContextAttachment, ContextAttachment,
ConversationQueueDispatch, ConversationQueueDispatch,
DesktopApi, DesktopApi,
@@ -143,6 +144,9 @@ const removeMaximizedChangedListener = vi.fn();
const removeProjectRecoveryListener = vi.fn(); const removeProjectRecoveryListener = vi.fn();
const run = vi.fn<DesktopApi["agent"]["run"]>(); const run = vi.fn<DesktopApi["agent"]["run"]>();
const modelProfileId = "00000000-0000-4000-8000-000000000001"; 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 projectId = "00000000-0000-4000-8000-000000000101";
const project = { const project = {
id: projectId, id: projectId,
@@ -246,6 +250,18 @@ const api: DesktopApi = {
}), }),
}, },
browser: { 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 () => {}), navigate: vi.fn(async () => {}),
back: vi.fn(async () => {}), back: vi.fn(async () => {}),
reload: vi.fn(async () => {}), reload: vi.fn(async () => {}),
@@ -976,6 +992,7 @@ function createBrowserState(
): BrowserLiveState { ): BrowserLiveState {
return { return {
conversationId, conversationId,
tabId: browserTabId,
status: "ready", status: "ready",
sessionActive: true, sessionActive: true,
isLoading: false, isLoading: false,
@@ -1107,12 +1124,21 @@ describe("App", () => {
.mockReset() .mockReset()
.mockResolvedValue(); .mockResolvedValue();
vi.mocked(api.conversationQueue.ready).mockReset().mockResolvedValue(); vi.mocked(api.conversationQueue.ready).mockReset().mockResolvedValue();
const queueChangeListeners = new Set<
Parameters<DesktopApi["conversationQueue"]["onChanged"]>[0]
>();
vi.mocked(api.conversationQueue.onChanged) vi.mocked(api.conversationQueue.onChanged)
.mockReset() .mockReset()
.mockImplementation((listener) => { .mockImplementation((listener) => {
conversationQueueChangeListener = listener; queueChangeListeners.add(listener);
conversationQueueChangeListener = (conversationId) => {
queueChangeListeners.forEach((callback) => callback(conversationId));
};
return () => { return () => {
conversationQueueChangeListener = undefined; queueChangeListeners.delete(listener);
if (queueChangeListeners.size === 0) {
conversationQueueChangeListener = undefined;
}
}; };
}); });
vi.mocked(api.conversationQueue.onDispatch) vi.mocked(api.conversationQueue.onDispatch)
@@ -1667,6 +1693,112 @@ describe("App", () => {
expect(within(taskRegion).getByText("Execute")).toBeInTheDocument(); 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 () => { it("keeps completed Conversation Task metadata compact and accessible", async () => {
const conversationId = "00000000-0000-4000-8000-000000000824"; const conversationId = "00000000-0000-4000-8000-000000000824";
const scheduleId = "00000000-0000-4000-8000-000000000828"; const scheduleId = "00000000-0000-4000-8000-000000000828";
@@ -3696,11 +3828,12 @@ describe("App", () => {
await waitFor(() => expect(api.conversations.saveLocal).toHaveBeenCalled()); await waitFor(() => expect(api.conversations.saveLocal).toHaveBeenCalled());
vi.mocked(api.conversations.saveLocal).mockClear(); vi.mocked(api.conversations.saveLocal).mockClear();
const fullOutput = `streamed ${"x".repeat(1_000_001)} tail`;
act(() => { act(() => {
agentListener?.({ agentListener?.({
requestId: request.requestId, requestId: request.requestId,
type: "text", type: "text",
delta: "流式增量", delta: fullOutput,
}); });
}); });
@@ -3714,7 +3847,7 @@ describe("App", () => {
expect(batch?.[0]?.messages).toEqual([ expect(batch?.[0]?.messages).toEqual([
expect.objectContaining({ expect.objectContaining({
role: "assistant", role: "assistant",
content: "流式增量", content: fullOutput,
state: "streaming", 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 () => { it("keeps a tool failure in details and hides retry after continuing", async () => {
render(<App />); render(<App />);
@@ -9132,7 +9292,6 @@ describe("App", () => {
expect(within(dialog).getByLabelText("新对话默认 Runtime")).toHaveValue( expect(within(dialog).getByLabelText("新对话默认 Runtime")).toHaveValue(
agentRuntimeSelectionKey({ agentRuntimeSelectionKey({
provider: "model", provider: "model",
profileId: modelProfileId,
}), }),
); );
fireEvent.change(within(dialog).getByLabelText("说明"), { fireEvent.change(within(dialog).getByLabelText("说明"), {
@@ -9142,7 +9301,6 @@ describe("App", () => {
target: { target: {
value: agentRuntimeSelectionKey({ value: agentRuntimeSelectionKey({
provider: "continue", provider: "continue",
profileId: modelProfileId,
}), }),
}, },
}); });
@@ -9940,13 +10098,9 @@ describe("App", () => {
"aria-selected", "aria-selected",
"true", "true",
); );
const taskIndexHeading = screen.getByRole("heading", { expect(
name: "任务索引", screen.getByText("没有活动项目。请选择其他任务范围或先打开一个项目。"),
}); ).toBeInTheDocument();
const newTaskButton = screen.getByRole("button", {
name: "新建任务",
});
expect(taskIndexHeading.parentElement).toContainElement(newTaskButton);
expect( expect(
screen.queryByRole("region", { name: "当前会话的任务" }), screen.queryByRole("region", { name: "当前会话的任务" }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
@@ -9968,7 +10122,7 @@ describe("App", () => {
expect( expect(
screen.queryByText(/选择文件后在当前工作区内预览/), screen.queryByText(/选择文件后在当前工作区内预览/),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: "浏览器" })); fireEvent.click(screen.getByRole("tab", { name: /^浏览器/u }));
expect(screen.queryByText("实时浏览器")).not.toBeInTheDocument(); expect(screen.queryByText("实时浏览器")).not.toBeInTheDocument();
expect(screen.getByLabelText("浏览器页面")).toBeInTheDocument(); expect(screen.getByLabelText("浏览器页面")).toBeInTheDocument();
expect(screen.getByText(/Agent 打开网页后/)).toBeInTheDocument(); expect(screen.getByText(/Agent 打开网页后/)).toBeInTheDocument();
@@ -9983,7 +10137,12 @@ describe("App", () => {
expect(exitFullscreen).toHaveAttribute("aria-pressed", "true"); expect(exitFullscreen).toHaveAttribute("aria-pressed", "true");
fireEvent.click(exitFullscreen); fireEvent.click(exitFullscreen);
expect(sidebar).not.toHaveClass("assistant-sidebar--browser-fullscreen"); 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( expect(
screen.getByRole("button", { name: "导入 PDF、图片或网页" }), screen.getByRole("button", { name: "导入 PDF、图片或网页" }),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -10027,7 +10186,12 @@ describe("App", () => {
expect(await screen.findByText("这是一条普通聊天回复")).toBeInTheDocument(); expect(await screen.findByText("这是一条普通聊天回复")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText("切换助手工作栏")); 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("助手工作栏"); const sidebar = screen.getByLabelText("助手工作栏");
expect( expect(
within(sidebar).queryByText("这是一条普通聊天回复"), within(sidebar).queryByText("这是一条普通聊天回复"),
@@ -10043,7 +10207,12 @@ describe("App", () => {
render(<App />); render(<App />);
fireEvent.click(screen.getByLabelText("切换助手工作栏")); 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", { const toolbar = screen.getByRole("form", {
name: "浏览器工具栏", name: "浏览器工具栏",
@@ -10074,8 +10243,11 @@ describe("App", () => {
fireEvent.click(go); fireEvent.click(go);
await waitFor(() => await waitFor(() =>
expect(api.browser.navigate).toHaveBeenLastCalledWith( expect(api.browser.navigate).toHaveBeenLastCalledWith(
expect.any(String), expect.objectContaining({
"https://example.com/start", conversationId: expect.any(String),
tabId: browserTabId,
url: "https://example.com/start",
}),
), ),
); );
expect(address).toHaveValue("https://example.com/start"); expect(address).toHaveValue("https://example.com/start");
@@ -10086,8 +10258,11 @@ describe("App", () => {
fireEvent.keyDown(address, { key: "Enter" }); fireEvent.keyDown(address, { key: "Enter" });
await waitFor(() => await waitFor(() =>
expect(api.browser.navigate).toHaveBeenLastCalledWith( expect(api.browser.navigate).toHaveBeenLastCalledWith(
expect.any(String), expect.objectContaining({
"http://example.com/enter", conversationId: expect.any(String),
tabId: browserTabId,
url: "http://example.com/enter",
}),
), ),
); );
expect(address).toHaveValue("http://example.com/enter"); expect(address).toHaveValue("http://example.com/enter");
@@ -10098,8 +10273,11 @@ describe("App", () => {
fireEvent.keyDown(address, { key: "Enter" }); fireEvent.keyDown(address, { key: "Enter" });
await waitFor(() => await waitFor(() =>
expect(api.browser.navigate).toHaveBeenLastCalledWith( expect(api.browser.navigate).toHaveBeenLastCalledWith(
expect.any(String), expect.objectContaining({
"mailto:user@example.com", conversationId: expect.any(String),
tabId: browserTabId,
url: "mailto:user@example.com",
}),
), ),
); );
@@ -10109,20 +10287,61 @@ describe("App", () => {
fireEvent.keyDown(address, { key: "Enter" }); fireEvent.keyDown(address, { key: "Enter" });
await waitFor(() => await waitFor(() =>
expect(api.browser.navigate).toHaveBeenLastCalledWith( expect(api.browser.navigate).toHaveBeenLastCalledWith(
expect.any(String), expect.objectContaining({
"http://localhost:3000/path", conversationId: expect.any(String),
tabId: browserTabId,
url: "http://localhost:3000/path",
}),
), ),
); );
expect(api.browser.navigate).toHaveBeenCalledTimes(4); 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 () => { it("suppresses duplicate browser actions while keeping stop loading available", async () => {
const navigation = deferred<void>(); const navigation = deferred<void>();
vi.mocked(api.browser.navigate).mockReturnValueOnce(navigation.promise); vi.mocked(api.browser.navigate).mockReturnValueOnce(navigation.promise);
render(<App />); render(<App />);
fireEvent.click(screen.getByLabelText("切换助手工作栏")); fireEvent.click(screen.getByLabelText("切换助手工作栏"));
fireEvent.click(screen.getByRole("tab", { name: "浏览器" })); fireEvent.click(screen.getByRole("tab", { name: /^浏览器/u }));
const toolbar = screen.getByRole("form", { const toolbar = screen.getByRole("form", {
name: "浏览器工具栏", name: "浏览器工具栏",
@@ -10138,9 +10357,12 @@ describe("App", () => {
fireEvent.click(go); fireEvent.click(go);
fireEvent.keyDown(address, { key: "Enter" }); fireEvent.keyDown(address, { key: "Enter" });
expect(api.browser.navigate).toHaveBeenCalledOnce(); await waitFor(() => expect(api.browser.navigate).toHaveBeenCalledOnce());
const conversationId = const firstNavigationRequest =
vi.mocked(api.browser.navigate).mock.calls[0]?.[0] ?? ""; vi.mocked(api.browser.navigate).mock.calls[0]?.[0] as unknown as
| { conversationId: string }
| undefined;
const conversationId = firstNavigationRequest?.conversationId ?? "";
act(() => { act(() => {
browserListener?.( browserListener?.(
createBrowserState(conversationId, { createBrowserState(conversationId, {
@@ -10168,7 +10390,9 @@ describe("App", () => {
}); });
expect(stopLoading).toBeEnabled(); expect(stopLoading).toBeEnabled();
fireEvent.click(stopLoading); fireEvent.click(stopLoading);
expect(api.browser.stopLoading).toHaveBeenCalledOnce(); await waitFor(() =>
expect(api.browser.stopLoading).toHaveBeenCalledOnce(),
);
await act(async () => { await act(async () => {
navigation.resolve(); navigation.resolve();
@@ -10189,7 +10413,7 @@ describe("App", () => {
expect(back).toBeEnabled(); expect(back).toBeEnabled();
fireEvent.click(back); fireEvent.click(back);
fireEvent.click(back); fireEvent.click(back);
expect(api.browser.back).toHaveBeenCalledOnce(); await waitFor(() => expect(api.browser.back).toHaveBeenCalledOnce());
await act(async () => { await act(async () => {
backNavigation.resolve(); backNavigation.resolve();
@@ -10224,7 +10448,7 @@ describe("App", () => {
expect(screen.getByLabelText("助手工作栏")).toHaveClass( expect(screen.getByLabelText("助手工作栏")).toHaveClass(
"assistant-sidebar--open", "assistant-sidebar--open",
); );
expect(screen.getByRole("tab", { name: "浏览器" })).toHaveAttribute( expect(screen.getByRole("tab", { name: /^浏览器/u })).toHaveAttribute(
"aria-selected", "aria-selected",
"true", "true",
); );
@@ -10236,9 +10460,20 @@ describe("App", () => {
name: "浏览器地址", name: "浏览器地址",
}); });
const back = within(toolbar).getByRole("button", { 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(); 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.focus(address);
fireEvent.change(address, { fireEvent.change(address, {
target: { value: "https://typed.example/draft" }, target: { value: "https://typed.example/draft" },
@@ -10261,12 +10496,12 @@ describe("App", () => {
expect(back).toBeEnabled(); expect(back).toBeEnabled();
fireEvent.click(back); fireEvent.click(back);
await waitFor(() => await waitFor(() =>
expect(api.browser.back).toHaveBeenCalledWith(conversationId), expect(api.browser.back).toHaveBeenCalledWith({ conversationId, tabId: browserTabId }),
); );
fireEvent.click(within(toolbar).getByRole("button", { name: "刷新" })); fireEvent.click(within(toolbar).getByRole("button", { name: "刷新" }));
await waitFor(() => await waitFor(() =>
expect(api.browser.reload).toHaveBeenCalledWith(conversationId), expect(api.browser.reload).toHaveBeenCalledWith({ conversationId, tabId: browserTabId }),
); );
fireEvent.change(address, { fireEvent.change(address, {
target: { value: "https://example.com/manual" }, target: { value: "https://example.com/manual" },
@@ -10274,8 +10509,7 @@ describe("App", () => {
fireEvent.click(within(toolbar).getByRole("button", { name: "前往" })); fireEvent.click(within(toolbar).getByRole("button", { name: "前往" }));
await waitFor(() => await waitFor(() =>
expect(api.browser.navigate).toHaveBeenCalledWith( expect(api.browser.navigate).toHaveBeenCalledWith(
conversationId, { conversationId, tabId: browserTabId, url: "https://example.com/manual" },
"https://example.com/manual",
), ),
); );
expect(address).toHaveValue("https://example.com/redirected"); expect(address).toHaveValue("https://example.com/redirected");
@@ -10296,8 +10530,7 @@ describe("App", () => {
fireEvent.click(within(toolbar).getByRole("button", { name: "前往" })); fireEvent.click(within(toolbar).getByRole("button", { name: "前往" }));
await waitFor(() => await waitFor(() =>
expect(api.browser.navigate).toHaveBeenCalledWith( expect(api.browser.navigate).toHaveBeenCalledWith(
conversationId, { conversationId, tabId: browserTabId, url: "https://example.com/canonical" },
"https://example.com/canonical",
), ),
); );
expect(address).toHaveValue("https://example.com/canonical/"); expect(address).toHaveValue("https://example.com/canonical/");
@@ -10374,7 +10607,7 @@ describe("App", () => {
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
fireEvent.click(stopLoading); fireEvent.click(stopLoading);
await waitFor(() => await waitFor(() =>
expect(api.browser.stopLoading).toHaveBeenCalledWith(conversationId), expect(api.browser.stopLoading).toHaveBeenCalledWith({ conversationId, tabId: browserTabId }),
); );
act(() => { act(() => {
@@ -10950,7 +11183,7 @@ describe("App", () => {
{ timeout: 3000 }, { timeout: 3000 },
), ),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText("全局")).toBeInTheDocument(); expect(screen.getByLabelText("全局")).toBeInTheDocument();
expect( expect(
screen.getByRole("button", { name: "新建笔记" }), screen.getByRole("button", { name: "新建笔记" }),
).toBeInTheDocument(); ).toBeInTheDocument();
+111 -91
View File
@@ -76,6 +76,8 @@ import type {
} from "../../shared/contracts"; } from "../../shared/contracts";
import { import {
defaultContextCompressionSettings, defaultContextCompressionSettings,
isAgentRuntimeModelProtocol,
isDeepSeekHarnessModelProfile,
maximumPastedImageBytes, maximumPastedImageBytes,
} from "../../shared/contracts"; } from "../../shared/contracts";
import { import {
@@ -87,6 +89,7 @@ import {
import { import {
agentRuntimeSelectionKey, agentRuntimeSelectionKey,
agentRuntimeSelectionSchema, agentRuntimeSelectionSchema,
getRuntimeSelectionProfileId,
type AgentRuntimeSelection, type AgentRuntimeSelection,
} from "../../shared/runtime-selection-contracts"; } from "../../shared/runtime-selection-contracts";
import { import {
@@ -538,8 +541,6 @@ const maximumPrimarySidebarWidth = 420;
const minimumPrimaryWorkspaceWidth = 480; const minimumPrimaryWorkspaceWidth = 480;
const primarySidebarKeyboardResizeStep = 16; const primarySidebarKeyboardResizeStep = 16;
const maxMessageContentLength = 1_000_000;
function getPrimarySidebarWidthLimits(viewportWidth: number): { function getPrimarySidebarWidthLimits(viewportWidth: number): {
minimum: number; minimum: number;
maximum: number; maximum: number;
@@ -598,14 +599,14 @@ function appendMessageContentBlock(
if (previous?.type === type) { if (previous?.type === type) {
current[current.length - 1] = { current[current.length - 1] = {
...previous, ...previous,
content: `${previous.content}${delta}`.slice(0, maxMessageContentLength), content: `${previous.content}${delta}`,
}; };
return current; return current;
} }
current.push({ current.push({
id: crypto.randomUUID(), id: crypto.randomUUID(),
type, type,
content: delta.slice(0, maxMessageContentLength), content: delta,
}); });
return current; return current;
} }
@@ -1181,7 +1182,7 @@ function loadConversations(
conversation.contextMetrics === undefined conversation.contextMetrics === undefined
? undefined ? undefined
: parseConversationContextMetrics(conversation.contextMetrics), : parseConversationContextMetrics(conversation.contextMetrics),
messages: conversation.messages.slice(-500).map((message) => messages: conversation.messages.map((message) =>
message.state === "streaming" message.state === "streaming"
? { ? {
...message, ...message,
@@ -1250,7 +1251,6 @@ function isConversation(value: unknown): value is Conversation {
assistantIdSchema.safeParse(entry.queueItemId).success) && assistantIdSchema.safeParse(entry.queueItemId).success) &&
(entry.role === "user" || entry.role === "assistant") && (entry.role === "user" || entry.role === "assistant") &&
typeof entry.content === "string" && typeof entry.content === "string" &&
entry.content.length <= 1_000_000 &&
(entry.reasoning === undefined || (entry.reasoning === undefined ||
typeof entry.reasoning === "string") && typeof entry.reasoning === "string") &&
(entry.blocks === undefined || (entry.blocks === undefined ||
@@ -1316,7 +1316,7 @@ function toConversationSnapshots(
...(conversation.branch ? { branch: conversation.branch } : {}), ...(conversation.branch ? { branch: conversation.branch } : {}),
title: conversation.title, title: conversation.title,
updatedAt: conversation.updatedAt, updatedAt: conversation.updatedAt,
messages: conversation.messages.slice(-500).map(toConversationMessage), messages: conversation.messages.map(toConversationMessage),
})); }));
} }
@@ -1388,7 +1388,6 @@ function createLocalConversationSaveBatch(
header: toLocalConversationHeader(conversation), header: toLocalConversationHeader(conversation),
messages: conversation.messages messages: conversation.messages
.filter((message) => previousMessages.get(message.id) !== message) .filter((message) => previousMessages.get(message.id) !== message)
.slice(-500)
.map(toConversationMessage), .map(toConversationMessage),
}); });
acknowledgements.push(conversation); acknowledgements.push(conversation);
@@ -1472,7 +1471,7 @@ function mergePersistedConversations(
return localMessage; return localMessage;
}), }),
...local.messages.filter((message) => !serverMessageIds.has(message.id)), ...local.messages.filter((message) => !serverMessageIds.has(message.id)),
].slice(-500); ];
const next = localIsNewer const next = localIsNewer
? { ...local, messages } ? { ...local, messages }
: { ...conversation, messages }; : { ...conversation, messages };
@@ -1543,13 +1542,12 @@ function resolveContextMetricsRuntimeSelection(
selection: AgentRuntimeSelection, selection: AgentRuntimeSelection,
settings: RuntimeSettings, settings: RuntimeSettings,
): AgentRuntimeSelection { ): AgentRuntimeSelection {
if (selection.provider !== "auto") { const effective = selection.provider !== "auto" ? selection : getRuntimeSelectionForProvider(
return selection;
}
return getRuntimeSelectionForProvider(
settings.provider === "auto" ? "opencode" : settings.provider, settings.provider === "auto" ? "opencode" : settings.provider,
settings, settings,
); );
const profileId = getRuntimeSelectionProfileId(effective, settings);
return profileId && effective.provider !== "auto" ? { ...effective, profileId } : effective;
} }
function getRuntimeSelectionLabel( function getRuntimeSelectionLabel(
@@ -1566,10 +1564,11 @@ function getRuntimeSelectionLabel(
if (!selection || !settings) { if (!selection || !settings) {
return status?.label ?? "Runtime"; return status?.label ?? "Runtime";
} }
const profileId = getRuntimeSelectionProfileId(selection, settings);
const profile = const profile =
"profileId" in selection && selection.profileId profileId
? settings.modelProfiles.find( ? settings.modelProfiles.find(
(candidate) => candidate.id === selection.profileId, (candidate) => candidate.id === profileId,
) )
: undefined; : undefined;
const requestedProfileMissing = const requestedProfileMissing =
@@ -1619,11 +1618,11 @@ function getConfiguredAgentRuntimeSource(
useOwnConfiguration: (runtime: string) => string; useOwnConfiguration: (runtime: string) => string;
}, },
): { label: string; detail: string } { ): { label: string; detail: string } {
const selection = getRuntimeSelectionForProvider(provider, settings); const profileId = getRuntimeSelectionProfileId({ provider }, settings);
const profile = const profile =
"profileId" in selection profileId
? settings.modelProfiles.find( ? settings.modelProfiles.find(
(candidate) => candidate.id === selection.profileId, (candidate) => candidate.id === profileId,
) )
: undefined; : undefined;
const runtimeLabel = const runtimeLabel =
@@ -1632,7 +1631,7 @@ function getConfiguredAgentRuntimeSource(
: provider === "continue" : provider === "continue"
? "Continue" ? "Continue"
: "DeepSeek Harness"; : "DeepSeek Harness";
if ("profileId" in selection) { if (profileId) {
return { return {
label: `${runtimeLabel} · ${profile?.name ?? labels.modelUnavailable}`, label: `${runtimeLabel} · ${profile?.name ?? labels.modelUnavailable}`,
detail: profile?.modelName ?? labels.selectModel, detail: profile?.modelName ?? labels.selectModel,
@@ -2288,7 +2287,7 @@ function App(): React.JSX.Element {
const [assistantSidebarTab, setAssistantSidebarTab] = const [assistantSidebarTab, setAssistantSidebarTab] =
useState<AssistantSidebarTab>("tasks"); useState<AssistantSidebarTab>("tasks");
const [browserStates, setBrowserStates] = useState< const [browserStates, setBrowserStates] = useState<
Record<string, BrowserLiveState> Record<string, Record<string, BrowserLiveState>>
>({}); >({});
const [view, setViewState] = useState<WorkspaceView>("chat"); const [view, setViewState] = useState<WorkspaceView>("chat");
const settingsEntryFocusRef = useRef<HTMLElement | undefined>(undefined); const settingsEntryFocusRef = useRef<HTMLElement | undefined>(undefined);
@@ -3151,9 +3150,9 @@ function App(): React.JSX.Element {
() => () =>
activeConversation?.runtimeSelection ?? activeConversation?.runtimeSelection ??
(runtimeSettings (runtimeSettings
? getDefaultRuntimeSelection(runtimeSettings) ? getProjectDefaultRuntimeSelection(activeProject, runtimeSettings)
: undefined), : undefined),
[activeConversation?.runtimeSelection, runtimeSettings], [activeConversation?.runtimeSelection, activeProject, runtimeSettings],
); );
const activeRuntimeSelectionKey = activeRuntimeSelection const activeRuntimeSelectionKey = activeRuntimeSelection
? agentRuntimeSelectionKey(activeRuntimeSelection) ? agentRuntimeSelectionKey(activeRuntimeSelection)
@@ -3621,9 +3620,7 @@ function App(): React.JSX.Element {
} }
const conversation = createConversation( const conversation = createConversation(
projectId, projectId,
runtimeSettings undefined,
? getProjectDefaultRuntimeSelection(project, runtimeSettings)
: undefined,
tRef.current("conversation.greeting"), tRef.current("conversation.greeting"),
); );
const nextConversations = [conversation, ...navigation.conversations]; const nextConversations = [conversation, ...navigation.conversations];
@@ -3722,6 +3719,7 @@ function App(): React.JSX.Element {
? [ ? [
{ {
conversationId: conversation.id, conversationId: conversation.id,
projectId: conversation.projectId,
messageId: message.id, messageId: message.id,
approvalId: message.approval.id, approvalId: message.approval.id,
title: message.approval.title, title: message.approval.title,
@@ -4264,41 +4262,29 @@ function App(): React.JSX.Element {
if (event.type === "text") { if (event.type === "text") {
updateMessage(run.conversationId, run.messageId, (message) => { 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( const blocks = appendMessageContentBlock(
message.blocks, message.blocks,
"text", "text",
acceptedDelta, event.delta,
); );
return { return {
...message, ...message,
content: `${message.content}${acceptedDelta}`, content: `${message.content}${event.delta}`,
blocks, blocks,
status: status: undefined,
event.delta.length > remaining
? tRef.current("chat.status.responseTruncated")
: undefined,
}; };
}); });
} else if (event.type === "reasoning") { } else if (event.type === "reasoning") {
updateMessage(run.conversationId, run.messageId, (message) => { updateMessage(run.conversationId, run.messageId, (message) => {
const currentReasoning = message.reasoning ?? ""; const currentReasoning = message.reasoning ?? "";
const acceptedDelta = event.delta.slice(
0,
Math.max(0, maxMessageContentLength - currentReasoning.length),
);
const blocks = appendMessageContentBlock( const blocks = appendMessageContentBlock(
message.blocks, message.blocks,
"reasoning", "reasoning",
acceptedDelta, event.delta,
); );
return { return {
...message, ...message,
reasoning: `${currentReasoning}${acceptedDelta}`, reasoning: `${currentReasoning}${event.delta}`,
status: undefined, status: undefined,
blocks, blocks,
}; };
@@ -4736,7 +4722,7 @@ function App(): React.JSX.Element {
: ("interrupted" as const); : ("interrupted" as const);
const fallbackError = const fallbackError =
event.type === "error" && !representedToolError && !message.content event.type === "error" && !representedToolError && !message.content
? event.message.slice(0, maxMessageContentLength) ? event.message
: ""; : "";
return { return {
...message, ...message,
@@ -5108,6 +5094,8 @@ function App(): React.JSX.Element {
} }
}; };
const remove = window.goodbuddy.conversations.onChanged(queueRefresh); const remove = window.goodbuddy.conversations.onChanged(queueRefresh);
const removeQueueListener =
window.goodbuddy.conversationQueue.onChanged(queueRefresh);
window.addEventListener("focus", refreshWhenVisible); window.addEventListener("focus", refreshWhenVisible);
document.addEventListener("visibilitychange", refreshWhenVisible); document.addEventListener("visibilitychange", refreshWhenVisible);
return () => { return () => {
@@ -5116,6 +5104,7 @@ function App(): React.JSX.Element {
window.clearTimeout(refreshTimer); window.clearTimeout(refreshTimer);
} }
remove(); remove();
removeQueueListener();
window.removeEventListener("focus", refreshWhenVisible); window.removeEventListener("focus", refreshWhenVisible);
document.removeEventListener("visibilitychange", refreshWhenVisible); document.removeEventListener("visibilitychange", refreshWhenVisible);
}; };
@@ -6015,19 +6004,35 @@ function App(): React.JSX.Element {
return; return;
} }
return browserApi.onState((state) => { return browserApi.onState((state) => {
const tabId = state.tabId;
setBrowserStates((current) => { 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 { return {
...current, ...current,
[state.conversationId]: [state.conversationId]: {
state.status === "stopped" || ...conversationStates,
state.frameDataUrl || [tabId]: state.frameDataUrl || !previous?.frameDataUrl
!previous?.frameDataUrl ? state
? state : {
: { ...state,
...state, frameDataUrl: previous.frameDataUrl,
frameDataUrl: previous.frameDataUrl, },
}, },
}; };
}); });
if ( if (
@@ -6101,9 +6106,7 @@ function App(): React.JSX.Element {
} else { } else {
const created = createConversation( const created = createConversation(
selected.id, selected.id,
runtimeSettings undefined,
? getProjectDefaultRuntimeSelection(selected, runtimeSettings)
: undefined,
t("conversation.greeting"), t("conversation.greeting"),
); );
setConversations((current) => [created, ...current]); setConversations((current) => [created, ...current]);
@@ -6139,9 +6142,7 @@ function App(): React.JSX.Element {
setWorkMode(normalizeInteractiveWorkMode(project.defaultWorkMode)); setWorkMode(normalizeInteractiveWorkMode(project.defaultWorkMode));
const conversation = createConversation( const conversation = createConversation(
project.id, project.id,
runtimeSettings undefined,
? getProjectDefaultRuntimeSelection(project, runtimeSettings)
: undefined,
t("conversation.greeting"), t("conversation.greeting"),
); );
setConversations((current) => [conversation, ...current]); setConversations((current) => [conversation, ...current]);
@@ -6482,9 +6483,7 @@ function App(): React.JSX.Element {
} }
const replacement = createConversation( const replacement = createConversation(
activeProjectId || undefined, activeProjectId || undefined,
runtimeSettings undefined,
? getProjectDefaultRuntimeSelection(activeProject, runtimeSettings)
: undefined,
t("conversation.greeting"), t("conversation.greeting"),
); );
setConversations((current) => [replacement, ...current]); setConversations((current) => [replacement, ...current]);
@@ -6863,11 +6862,9 @@ function App(): React.JSX.Element {
8, 8,
); );
const historySnapshot = conversationSnapshot.messages; const historySnapshot = conversationSnapshot.messages;
const retainedHistorySnapshot = historySnapshot const retainedHistorySnapshot = historySnapshot.filter(
.filter(
(message) => message.state === "complete" && message.content.trim(), (message) => message.state === "complete" && message.content.trim(),
) );
.slice(-500);
const projectIdSnapshot = queuedInput const projectIdSnapshot = queuedInput
? queuedInput.projectId ? queuedInput.projectId
: activeProjectId || undefined; : activeProjectId || undefined;
@@ -7165,11 +7162,9 @@ function App(): React.JSX.Element {
) { ) {
return; return;
} }
const history = activeConversation.messages const history = activeConversation.messages.filter(
.filter(
(message) => message.state === "complete" && message.content.trim(), (message) => message.state === "complete" && message.content.trim(),
) );
.slice(-500);
if (history.length < 2) { if (history.length < 2) {
notify({ notify({
tone: "info", tone: "info",
@@ -7799,6 +7794,7 @@ function App(): React.JSX.Element {
const runAssistantSchedule = async (scheduleId: string): Promise<void> => { const runAssistantSchedule = async (scheduleId: string): Promise<void> => {
await window.goodbuddy.schedules.runNow(scheduleId); await window.goodbuddy.schedules.runNow(scheduleId);
setAssistantTasks(await window.goodbuddy.tasks.list());
notify({ notify({
tone: "success", tone: "success",
message: t("notices.scheduleStarted"), message: t("notices.scheduleStarted"),
@@ -7846,9 +7842,7 @@ function App(): React.JSX.Element {
await window.goodbuddy.app.clearLocalData(); await window.goodbuddy.app.clearLocalData();
const conversation = createConversation( const conversation = createConversation(
activeProjectId || undefined, activeProjectId || undefined,
runtimeSettings undefined,
? getProjectDefaultRuntimeSelection(activeProject, runtimeSettings)
: undefined,
t("conversation.greeting"), t("conversation.greeting"),
); );
conversationsRef.current = [conversation]; conversationsRef.current = [conversation];
@@ -8044,12 +8038,8 @@ function App(): React.JSX.Element {
const runBrowserCommand = async ( const runBrowserCommand = async (
command: ( command: (
browserApi: NonNullable<typeof window.goodbuddy.browser>, browserApi: NonNullable<typeof window.goodbuddy.browser>,
conversationId: string,
) => Promise<void>, ) => Promise<void>,
): Promise<void> => { ): Promise<void> => {
if (!activeId) {
return;
}
const browserApi = window.goodbuddy.browser; const browserApi = window.goodbuddy.browser;
if (!browserApi) { if (!browserApi) {
notify({ notify({
@@ -8058,7 +8048,7 @@ function App(): React.JSX.Element {
}); });
return; return;
} }
await command(browserApi, activeId); await command(browserApi);
}; };
return ( return (
@@ -9580,6 +9570,16 @@ function App(): React.JSX.Element {
<strong role="presentation"> <strong role="presentation">
{t("runtime.directModels")} {t("runtime.directModels")}
</strong> </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( {runtimeSettings?.modelProfiles.map(
(profile) => ( (profile) => (
<button <button
@@ -9765,6 +9765,26 @@ function App(): React.JSX.Element {
> >
<span>{t("runtime.manage")}</span> <span>{t("runtime.manage")}</span>
</button> </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>
)} )}
</div> </div>
@@ -10735,7 +10755,7 @@ function App(): React.JSX.Element {
activeConversationId={activeId} activeConversationId={activeId}
approvals={pendingSidebarApprovals} approvals={pendingSidebarApprovals}
artifacts={sidebarArtifacts} artifacts={sidebarArtifacts}
browserState={browserStates[activeId]} browserStates={browserStates}
conversationTitles={conversationTitles} conversationTitles={conversationTitles}
currentProject={activeProject} currentProject={activeProject}
onCreateCustomTask={() => openCustomTaskDialog("current")} onCreateCustomTask={() => openCustomTaskDialog("current")}
@@ -10743,24 +10763,24 @@ function App(): React.JSX.Element {
selectedTaskId={selectedAssistantTaskId} selectedTaskId={selectedAssistantTaskId}
tasks={productAssistantTasks} tasks={productAssistantTasks}
projectNames={projectNames} projectNames={projectNames}
onBackBrowser={() => onBackBrowser={(conversationId, tabId) =>
runBrowserCommand((browserApi, conversationId) => runBrowserCommand((browserApi) =>
browserApi.back(conversationId), browserApi.back({ conversationId, tabId }),
) )
} }
onNavigateBrowser={(url) => onNavigateBrowser={(conversationId, tabId, url) =>
runBrowserCommand((browserApi, conversationId) => runBrowserCommand((browserApi) =>
browserApi.navigate(conversationId, url), browserApi.navigate({ conversationId, tabId, url }),
) )
} }
onReloadBrowser={() => onReloadBrowser={(conversationId, tabId) =>
runBrowserCommand((browserApi, conversationId) => runBrowserCommand((browserApi) =>
browserApi.reload(conversationId), browserApi.reload({ conversationId, tabId }),
) )
} }
onStopLoadingBrowser={() => onStopLoadingBrowser={(conversationId, tabId) =>
runBrowserCommand((browserApi, conversationId) => runBrowserCommand((browserApi) =>
browserApi.stopLoading(conversationId), browserApi.stopLoading({ conversationId, tabId }),
) )
} }
onImportArtifacts={async () => { onImportArtifacts={async () => {
@@ -185,6 +185,7 @@ export function ConversationTaskStrip({
onRunSchedule={onRunSchedule} onRunSchedule={onRunSchedule}
onSetScheduleEnabled={onSetScheduleEnabled} onSetScheduleEnabled={onSetScheduleEnabled}
schedule={selectedSchedule} schedule={selectedSchedule}
taskStatus={selectedTask.status}
taskTitle={selectedTask.title} taskTitle={selectedTask.title}
/> />
</div> </div>
+412 -68
View File
@@ -38,7 +38,8 @@ import type {
import { MarkdownRenderer } from './MarkdownRenderer' import { MarkdownRenderer } from './MarkdownRenderer'
import type { import type {
ApprovalDecision, ApprovalDecision,
BrowserLiveState BrowserLiveState,
BrowserTabId
} from '../../shared/contracts' } from '../../shared/contracts'
import { WorkspaceFilesPanel } from './WorkspaceFilesPanel' import { WorkspaceFilesPanel } from './WorkspaceFilesPanel'
import { SegmentedControl } from './WorkspacePrimitives' import { SegmentedControl } from './WorkspacePrimitives'
@@ -51,8 +52,11 @@ import type {
} from '../../shared/terminal-contracts' } from '../../shared/terminal-contracts'
import { import {
WORKBAR_APP_DEFINITIONS, WORKBAR_APP_DEFINITIONS,
WORKBAR_LIMITS,
normalizeWorkbarLayoutPreferences,
workbarLayoutPreferencesSchema, workbarLayoutPreferencesSchema,
type WorkbarAppDefinition, type WorkbarAppDefinition,
type WorkbarTaskScope,
type WorkbarTabInstance, type WorkbarTabInstance,
type WorkbarTargetRef type WorkbarTargetRef
} from '../../shared/workbar-contracts' } from '../../shared/workbar-contracts'
@@ -85,6 +89,7 @@ export type SidebarArtifact = {
export type PendingSidebarApproval = { export type PendingSidebarApproval = {
conversationId: string conversationId: string
projectId?: string
messageId: string messageId: string
approvalId: string approvalId: string
title: string title: string
@@ -105,13 +110,13 @@ type RightAssistantSidebarProps = {
workspaceChanges?: WorkspaceChanges workspaceChanges?: WorkspaceChanges
workspaceProjectId?: string workspaceProjectId?: string
activeConversationId?: string activeConversationId?: string
browserState?: BrowserLiveState browserStates?: Readonly<Record<string, Readonly<Record<string, BrowserLiveState>>>>
currentProject?: AssistantProject currentProject?: AssistantProject
restoreFocusRef?: { current: HTMLElement | null } restoreFocusRef?: { current: HTMLElement | null }
onBackBrowser?: () => Promise<void> onBackBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise<void>
onNavigateBrowser?: (url: string) => Promise<void> onNavigateBrowser?: (conversationId: string, tabId: BrowserTabId, url: string) => Promise<void>
onReloadBrowser?: () => Promise<void> onReloadBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise<void>
onStopLoadingBrowser?: () => Promise<void> onStopLoadingBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise<void>
onCreateCustomTask: () => void onCreateCustomTask: () => void
onImportArtifacts: () => Promise<void> onImportArtifacts: () => Promise<void>
onLoadArtifact: (artifactId: string) => Promise<void> onLoadArtifact: (artifactId: string) => Promise<void>
@@ -162,7 +167,10 @@ function loadPersistedWorkbarLayout(): ReturnType<
if (!value || value.length > 100_000) { if (!value || value.length > 100_000) {
return undefined return undefined
} }
return workbarLayoutPreferencesSchema.parse(JSON.parse(value)) return normalizeWorkbarLayoutPreferences(
JSON.parse(value),
DEFAULT_WORKBAR_INSTANCES
)
} catch { } catch {
return undefined return undefined
} }
@@ -172,7 +180,8 @@ function persistWorkbarLayout(
instances: readonly WorkbarTabInstance[], instances: readonly WorkbarTabInstance[],
activeInstanceId: string | null, activeInstanceId: string | null,
expanded: boolean, expanded: boolean,
widthRatio: number widthRatio: number,
taskScope: WorkbarTaskScope
): void { ): void {
try { try {
localStorage.setItem( localStorage.setItem(
@@ -183,7 +192,8 @@ function persistWorkbarLayout(
activeInstanceId, activeInstanceId,
expanded, expanded,
dock: 'right', dock: 'right',
widthRatio widthRatio,
taskScope
}) })
) )
) )
@@ -472,10 +482,12 @@ function BrowserToolbar({
function BrowserViewport({ function BrowserViewport({
browserState, browserState,
conversationId, conversationId,
tabId,
visible visible
}: { }: {
browserState?: BrowserLiveState browserState?: BrowserLiveState
conversationId?: string conversationId?: string
tabId?: BrowserTabId
visible: boolean visible: boolean
}): React.JSX.Element { }): React.JSX.Element {
const { t } = useTranslation('workspace') const { t } = useTranslation('workspace')
@@ -484,9 +496,10 @@ function BrowserViewport({
useLayoutEffect(() => { useLayoutEffect(() => {
const host = hostRef.current const host = hostRef.current
const browserApi = window.goodbuddy?.browser const browserApi = window.goodbuddy?.browser
if (!host || !conversationId || !browserApi?.setViewport) { if (!host || !conversationId || !tabId || !visible || !browserApi?.setViewport) {
return return
} }
const leaseToken = crypto.randomUUID()
let animationFrame: number | undefined let animationFrame: number | undefined
let lastRequest = '' let lastRequest = ''
let resizeObserver: ResizeObserver | undefined let resizeObserver: ResizeObserver | undefined
@@ -512,13 +525,17 @@ function BrowserViewport({
height: Math.round(rect.height) height: Math.round(rect.height)
} }
: undefined : undefined
const request = JSON.stringify({ conversationId, bounds }) const request = JSON.stringify({ conversationId, tabId, bounds })
if (request === lastRequest) { if (request === lastRequest) {
return return
} }
lastRequest = request lastRequest = request
void browserApi void browserApi
.setViewport(isVisible ? conversationId : undefined, bounds) .setViewport(
isVisible
? { conversationId, tabId, leaseToken, bounds: bounds! }
: { leaseToken }
)
.catch(() => undefined) .catch(() => undefined)
}) })
} }
@@ -554,9 +571,9 @@ function BrowserViewport({
mutationObserver.disconnect() mutationObserver.disconnect()
window.removeEventListener('resize', syncViewport) window.removeEventListener('resize', syncViewport)
window.removeEventListener('scroll', syncViewport, true) window.removeEventListener('scroll', syncViewport, true)
void browserApi.setViewport().catch(() => undefined) void browserApi.setViewport({ leaseToken }).catch(() => undefined)
} }
}, [conversationId, visible]) }, [conversationId, tabId, visible])
return ( return (
<div <div
@@ -594,7 +611,7 @@ export function RightAssistantSidebar({
workspaceChanges, workspaceChanges,
workspaceProjectId, workspaceProjectId,
activeConversationId, activeConversationId,
browserState, browserStates = {},
currentProject, currentProject,
restoreFocusRef, restoreFocusRef,
onBackBrowser = async () => {}, onBackBrowser = async () => {},
@@ -670,30 +687,53 @@ export function RightAssistantSidebar({
) )
const [workbarInstances, setWorkbarInstances] = useState< const [workbarInstances, setWorkbarInstances] = useState<
WorkbarTabInstance[] WorkbarTabInstance[]
>(() => >(() => {
(initialLayout?.instances ?? defaultInstances).map((instance) => const instances = (initialLayout?.instances ?? defaultInstances).map(
instance.appId === 'terminal' (instance) => {
? instance if (instance.appId === 'terminal') {
: { return instance
...instance, }
title: const label =
localizedAppDefinitions.find( localizedAppDefinitions.find(
(definition) => definition.id === instance.appId (definition) => definition.id === instance.appId
)?.label ?? instance.title )?.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] = const [activeWorkbarInstanceId, setActiveWorkbarInstanceId] =
useState<string | null>( useState<string | null>(
() => () =>
initialLayout?.activeInstanceId ?? initialLayout?.activeInstanceId ??
defaultInstances.find((instance) => instance.appId === tab)?.id ?? workbarInstances.find((instance) => instance.appId === tab)?.id ??
defaultInstances[0]?.id ?? workbarInstances[0]?.id ??
null null
) )
const activeWorkbarApp = workbarInstances.find( const activeWorkbarInstance = workbarInstances.find(
(instance) => instance.id === activeWorkbarInstanceId (instance) => instance.id === activeWorkbarInstanceId
)?.appId )
const activeWorkbarApp = activeWorkbarInstance?.appId
const [splitLayoutWidth, setSplitLayoutWidth] = useState( const [splitLayoutWidth, setSplitLayoutWidth] = useState(
window.innerWidth window.innerWidth
) )
@@ -701,7 +741,17 @@ export function RightAssistantSidebar({
initialLayout?.widthRatio ?? defaultSidebarRatio initialLayout?.widthRatio ?? defaultSidebarRatio
) )
const [isResizing, setIsResizing] = useState(false) 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 sidebarRef = useRef<HTMLElement>(null)
const wasOpen = useRef(false) const wasOpen = useRef(false)
const sidebarWidth = clampSidebarWidth( const sidebarWidth = clampSidebarWidth(
@@ -748,6 +798,9 @@ export function RightAssistantSidebar({
const [taskFilter, setTaskFilter] = useState< const [taskFilter, setTaskFilter] = useState<
'attention' | 'active' | 'paused' | 'finished' 'attention' | 'active' | 'paused' | 'finished'
>('active') >('active')
const [taskScope, setTaskScope] = useState<WorkbarTaskScope>(
initialLayout?.taskScope ?? 'current-project'
)
const [actionError, setActionError] = useState('') const [actionError, setActionError] = useState('')
const [terminalSessionIds, setTerminalSessionIds] = useState< const [terminalSessionIds, setTerminalSessionIds] = useState<
Record<string, string> Record<string, string>
@@ -808,15 +861,36 @@ export function RightAssistantSidebar({
[] []
) )
const browserFullscreenActive = const browserFullscreenActive =
open && activeWorkbarApp === 'browser' && browserFullscreen open &&
activeWorkbarApp === 'browser' &&
browserFullscreenInstanceId === activeWorkbarInstanceId
const sidebarWidthLimits = getSidebarWidthLimits(splitLayoutWidth) const sidebarWidthLimits = getSidebarWidthLimits(splitLayoutWidth)
const canResize = const canResize =
open && open &&
!browserFullscreenActive && !browserFullscreenActive &&
sidebarWidthLimits.maximum > sidebarWidthLimits.minimum 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( const topLevelTasks = useMemo(
() => tasks.filter((task) => !task.parentTaskId), () =>
[tasks] tasks.filter(
(task) => !task.parentTaskId && taskMatchesScope(task.projectId)
),
[taskMatchesScope, tasks]
) )
const filteredTasks = useMemo( const filteredTasks = useMemo(
() => () =>
@@ -844,24 +918,46 @@ export function RightAssistantSidebar({
) )
useEffect(() => { 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) { if (lastExternalTabRef.current === tab) {
return return
} }
lastExternalTabRef.current = tab lastExternalTabRef.current = tab
const requested = workbarInstances.find( const defaultBrowserId = DEFAULT_WORKBAR_INSTANCES.find(
(instance) => instance.appId === tab (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) { 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) setActiveWorkbarInstanceId(requested.id)
} }
}, [tab, workbarInstances]) }, [activeConversationId, tab, workbarInstances])
const toggleBrowserFullscreen = useCallback((): void => { const toggleBrowserFullscreen = useCallback((): void => {
setBrowserFullscreen((current) => !current) setBrowserFullscreenInstanceId((current) =>
}, []) current === activeWorkbarInstanceId
? undefined
: activeWorkbarInstanceId ?? undefined
)
}, [activeWorkbarInstanceId])
useEffect(() => { useEffect(() => {
if (!browserFullscreenActive) { if (!browserFullscreenActive) {
@@ -881,12 +977,14 @@ export function RightAssistantSidebar({
workbarInstances, workbarInstances,
activeWorkbarInstanceId, activeWorkbarInstanceId,
open, open,
sidebarRatio sidebarRatio,
taskScope
) )
}, [ }, [
activeWorkbarInstanceId, activeWorkbarInstanceId,
open, open,
sidebarRatio, sidebarRatio,
taskScope,
workbarInstances workbarInstances
]) ])
@@ -905,8 +1003,12 @@ export function RightAssistantSidebar({
) )
const updateActiveWorkbarInstance = useCallback( const updateActiveWorkbarInstance = useCallback(
(instanceId: string): void => { (instanceId: string | null): void => {
setActiveWorkbarInstanceId(instanceId) setActiveWorkbarInstanceId(instanceId)
setBrowserFullscreenInstanceId(undefined)
if (!instanceId) {
return
}
const instance = workbarInstances.find( const instance = workbarInstances.find(
(candidate) => candidate.id === instanceId (candidate) => candidate.id === instanceId
) )
@@ -914,6 +1016,9 @@ export function RightAssistantSidebar({
instance && instance &&
instance.appId !== 'terminal' instance.appId !== 'terminal'
) { ) {
if (instance.appId === 'browser') {
browserMruRef.current[instance.id] = ++nextBrowserMruRef.current
}
onTabChange(instance.appId) onTabChange(instance.appId)
} }
}, },
@@ -928,21 +1033,51 @@ export function RightAssistantSidebar({
if (!definition) { if (!definition) {
return return
} }
if (
workbarInstances.length >= WORKBAR_LIMITS.maximumOpenInstances ||
(definition.instancePolicy === 'single' &&
workbarInstances.some(
(instance) => instance.appId === request.appId
))
) {
return
}
const sameTargetTerminals = workbarInstances.filter( const sameTargetTerminals = workbarInstances.filter(
(instance) => (instance) =>
instance.appId === 'terminal' && instance.appId === 'terminal' &&
JSON.stringify(instance.targetRef) === JSON.stringify(instance.targetRef) ===
JSON.stringify(request.targetRef) JSON.stringify(request.targetRef)
).length ).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 = { const instance: WorkbarTabInstance = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
appId: request.appId, appId: request.appId,
title: title:
request.appId === 'terminal' request.appId === 'terminal'
? `${definition.label} ${sameTargetTerminals + 1}` ? `${definition.label} ${sameTargetTerminals + 1}`
: request.appId === 'browser' && conversationTitle
? `${definition.label} · ${conversationTitle}${sameConversationBrowsers > 0 ? ` · ${sameConversationBrowsers + 1}` : ''}`
: definition.label, : definition.label,
...(request.targetRef ...(request.targetRef || conversationTarget
? { targetRef: request.targetRef } ? { targetRef: request.targetRef ?? conversationTarget }
: {}) : {})
} }
setWorkbarInstances((current) => { setWorkbarInstances((current) => {
@@ -958,7 +1093,72 @@ export function RightAssistantSidebar({
}) })
setActiveWorkbarInstanceId(instance.id) 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( const removeWorkbarInstance = useCallback(
@@ -976,12 +1176,59 @@ export function RightAssistantSidebar({
delete next[instanceId] delete next[instanceId]
return next return next
}) })
const nextBrowserTabIds = { ...browserTabIdsRef.current }
delete nextBrowserTabIds[instanceId]
browserTabIdsRef.current = nextBrowserTabIds
setBrowserTabIds(nextBrowserTabIds)
pendingBrowserTabsRef.current.delete(instanceId)
}, },
[] []
) )
const requestCloseWorkbarInstance = useCallback( const requestCloseWorkbarInstance = useCallback(
async (instance: WorkbarTabInstance): Promise<boolean> => { 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') { if (instance.appId !== 'terminal') {
removeWorkbarInstance(instance.id) removeWorkbarInstance(instance.id)
return true return true
@@ -1015,9 +1262,11 @@ export function RightAssistantSidebar({
}, },
[ [
removeWorkbarInstance, removeWorkbarInstance,
localizedAppDefinitions,
terminalAdapter, terminalAdapter,
terminalSessionIds, terminalSessionIds,
terminalSnapshots terminalSnapshots,
t
] ]
) )
@@ -1191,7 +1440,7 @@ export function RightAssistantSidebar({
return return
} }
event.preventDefault() event.preventDefault()
setBrowserFullscreen(false) setBrowserFullscreenInstanceId(undefined)
const width = clampSidebarWidth(nextWidth, layoutWidth) const width = clampSidebarWidth(nextWidth, layoutWidth)
setSidebarRatio( setSidebarRatio(
layoutWidth > 0 ? width / layoutWidth : defaultSidebarRatio 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 ( return (
<aside <aside
ref={sidebarRef} ref={sidebarRef}
@@ -1372,7 +1634,7 @@ export function RightAssistantSidebar({
return return
} }
event.preventDefault() event.preventDefault()
setBrowserFullscreen(false) setBrowserFullscreenInstanceId(undefined)
resizePointerId.current = event.pointerId resizePointerId.current = event.pointerId
event.currentTarget.setPointerCapture(event.pointerId) event.currentTarget.setPointerCapture(event.pointerId)
resizeFromClientX(event.clientX, true) resizeFromClientX(event.clientX, true)
@@ -1402,12 +1664,12 @@ export function RightAssistantSidebar({
onCreateInstance={createWorkbarInstance} onCreateInstance={createWorkbarInstance}
onResolveTerminalTarget={resolveTerminalTarget} onResolveTerminalTarget={resolveTerminalTarget}
renderTabAdornment={(instance) => renderTabAdornment={(instance) =>
instance.appId === 'tasks' && approvals.length > 0 ? ( instance.appId === 'tasks' && scopedApprovals.length > 0 ? (
<span <span
aria-label={`${t('sidebar.tasks.approvalsTitle')}: ${approvals.length}`} aria-label={`${t('sidebar.tasks.approvalsTitle')}: ${scopedApprovals.length}`}
className="assistant-sidebar__badge" className="assistant-sidebar__badge"
> >
{approvals.length} {scopedApprovals.length}
</span> </span>
) : null ) : null
} }
@@ -1420,7 +1682,9 @@ export function RightAssistantSidebar({
{actionError} {actionError}
</p> </p>
) : null} ) : null}
{instance.appId === 'terminal' && instance.targetRef ? ( {instance.appId === 'terminal' &&
instance.targetRef &&
instance.targetRef.type !== 'conversation' ? (
<Suspense <Suspense
fallback={ fallback={
<section <section
@@ -1465,16 +1729,43 @@ export function RightAssistantSidebar({
) : null} ) : null}
{instance.appId === 'tasks' && ( {instance.appId === 'tasks' && (
<section className="assistant-sidebar__section"> <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> <h3>
<ShieldAlert size={15} /> <ShieldAlert size={15} />
{t('sidebar.tasks.approvalsTitle')} {t('sidebar.tasks.approvalsTitle')}
</h3> </h3>
{approvals.length === 0 ? ( {scopedApprovals.length === 0 ? (
<p className="assistant-sidebar__empty"> <p className="assistant-sidebar__empty">
{t('sidebar.tasks.noApprovals')} {t('sidebar.tasks.noApprovals')}
</p> </p>
) : ( ) : (
approvals.map((approval) => ( scopedApprovals.map((approval) => (
<article <article
aria-label={`${t('sidebar.tasks.approvalsTitle')}: ${ aria-label={`${t('sidebar.tasks.approvalsTitle')}: ${
approval.toolName ?? approval.title approval.toolName ?? approval.title
@@ -1641,6 +1932,7 @@ export function RightAssistantSidebar({
onRunSchedule={onRunSchedule} onRunSchedule={onRunSchedule}
onSetScheduleEnabled={onSetScheduleEnabled} onSetScheduleEnabled={onSetScheduleEnabled}
schedule={schedule} schedule={schedule}
taskStatus={task.status}
taskTitle={task.title} taskTitle={task.title}
/> />
</div> </div>
@@ -1649,6 +1941,8 @@ export function RightAssistantSidebar({
) )
}) })
)} )}
</>
)}
</section> </section>
)} )}
@@ -1916,33 +2210,71 @@ export function RightAssistantSidebar({
{instance.appId === 'browser' && ( {instance.appId === 'browser' && (
<section <section
className="assistant-sidebar__browser" className="assistant-sidebar__browser"
key={activeConversationId} key={instance.id}
> >
<BrowserToolbar <BrowserToolbar
activeConversationId={activeConversationId} activeConversationId={
browserState={browserState} 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} fullscreen={browserFullscreenActive}
onBack={() => onBack={() =>
runAction( runAction(
onBackBrowser, async () => {
const binding = await ensureBrowserTab(instance)
if (binding) {
await onBackBrowser(binding.conversationId, binding.tabId)
}
},
t('sidebar.errors.backBrowser') t('sidebar.errors.backBrowser')
) )
} }
onNavigate={(url) => onNavigate={(url) =>
runAction( runAction(
() => onNavigateBrowser(url), async () => {
const binding = await ensureBrowserTab(instance)
if (binding) {
await onNavigateBrowser(
binding.conversationId,
binding.tabId,
url
)
}
},
t('sidebar.errors.navigateBrowser') t('sidebar.errors.navigateBrowser')
) )
} }
onReload={() => onReload={() =>
runAction( runAction(
onReloadBrowser, async () => {
const binding = await ensureBrowserTab(instance)
if (binding) {
await onReloadBrowser(binding.conversationId, binding.tabId)
}
},
t('sidebar.errors.reloadBrowser') t('sidebar.errors.reloadBrowser')
) )
} }
onStopLoading={() => onStopLoading={() =>
runAction( runAction(
onStopLoadingBrowser, async () => {
const binding = await ensureBrowserTab(instance)
if (binding) {
await onStopLoadingBrowser(
binding.conversationId,
binding.tabId
)
}
},
t('sidebar.errors.stopLoadingBrowser') t('sidebar.errors.stopLoadingBrowser')
) )
} }
@@ -1951,9 +2283,21 @@ export function RightAssistantSidebar({
} }
/> />
<BrowserViewport <BrowserViewport
browserState={browserState} browserState={
conversationId={activeConversationId} instance.targetRef?.type === 'conversation' &&
visible={open} 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> </section>
)} )}
+8 -1
View File
@@ -31,6 +31,7 @@ type TaskScheduleActionsProps = {
enabled: boolean enabled: boolean
) => Promise<void> ) => Promise<void>
schedule: AssistantSchedule schedule: AssistantSchedule
taskStatus: AssistantTask['status']
taskTitle: string taskTitle: string
} }
@@ -40,6 +41,7 @@ export function TaskScheduleActions({
onRunSchedule, onRunSchedule,
onSetScheduleEnabled, onSetScheduleEnabled,
schedule, schedule,
taskStatus,
taskTitle taskTitle
}: TaskScheduleActionsProps): React.JSX.Element { }: TaskScheduleActionsProps): React.JSX.Element {
const { t } = useTranslation('workspace') const { t } = useTranslation('workspace')
@@ -64,7 +66,12 @@ export function TaskScheduleActions({
return ( return (
<> <>
<button <button
disabled={busy} disabled={
busy ||
taskStatus === 'queued' ||
taskStatus === 'running' ||
taskStatus === 'waiting_approval'
}
onClick={() => onClick={() =>
void runAction( void runAction(
() => onRunSchedule(schedule.id), () => onRunSchedule(schedule.id),