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:
@@ -225,7 +225,10 @@ type TimeTrigger =
|
||||
### 7.3 手动触发
|
||||
|
||||
“立即运行”在当前 Task 内创建独立 Job 和 Run,不改变下一次计划时间,不创建新 Task。
|
||||
重复点击使用调用级幂等键去重。
|
||||
提交期间,以及该计划已有排队或运行中的 occurrence 时,“立即运行”不可重复点击。
|
||||
后台同样拒绝为已有活动 occurrence 的计划再次入队。
|
||||
任务状态随队列变化刷新:待执行显示 queued,认领执行后显示 running;结束或取消排队后
|
||||
重新读取当前状态。没有活动 occurrence 的周期计划显示 idle 或 paused。
|
||||
|
||||
### 7.4 与普通消息的顺序
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user