feat: add stable scheduled tasks

Scheduled tasks previously created separate visible work for each run and lacked one conversation-backed product identity. Custom tasks now create or reuse one stable Task and Conversation, reuse that identity across triggers, and write text results back with Task provenance.

Tasks default to Execute while preserving the configured Runtime, tool authorization, and high-risk approval boundaries. Schema v22 backfills existing schedules to stable Task and Conversation links without deleting historical runs.

The conversation list, conversation Task strip, and Task Center now expose the same Task, with localized status metadata, overflow-aware titles, and shared schedule controls.

Release note: 现在可以创建关联当前或新会话的定制计划任务;重复执行会复用同一 Task 并将文本结果回写会话,左侧会话列表和 Task Center 可直接查看和管理。
This commit is contained in:
mesalogo
2026-08-19 15:37:01 +08:00
parent 43e1d162dc
commit 993c439228
38 changed files with 5339 additions and 773 deletions
+301 -6
View File
@@ -98,7 +98,7 @@ describe('AssistantDatabase', () => {
database.close()
})
it('migrates existing databases to schema version 20', async () => {
it('migrates existing databases to schema version 22', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-assistant-migration-')
)
@@ -127,7 +127,7 @@ describe('AssistantDatabase', () => {
user_version: number
}
).user_version
).toBe(21)
).toBe(22)
expect(
current
.prepare(
@@ -231,7 +231,7 @@ describe('AssistantDatabase', () => {
user_version: number
}
).user_version
).toBe(21)
).toBe(22)
expect(
current
.prepare(
@@ -269,6 +269,131 @@ describe('AssistantDatabase', () => {
current.close()
})
it('backfills one stable Task and Conversation for each v21 schedule', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-stable-schedule-migration-')
)
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
const initial = new AssistantDatabase(databasePath)
initial.initialize('C:\\Workspace')
const project = initial.listProjects()[0]!
const legacySchedule = initial.createSchedule({
projectId: project.id,
title: '旧版每日报告',
prompt: '生成每日报告',
workMode: 'ask',
recurrence: 'daily',
nextRunAt: '2026-08-20T00:00:00.000Z'
})
initial.close()
const legacyTaskId =
'00000000-0000-4000-8000-000000000221'
const legacyRunId =
'00000000-0000-4000-8000-000000000222'
const raw = new DatabaseSync(databasePath)
raw.exec('BEGIN IMMEDIATE')
raw
.prepare('DELETE FROM tasks WHERE id = ?')
.run(legacySchedule.taskId)
raw
.prepare('DELETE FROM conversations WHERE id = ?')
.run(legacySchedule.conversationId)
raw
.prepare(
`INSERT INTO tasks
(id, project_id, conversation_id, schedule_id, parent_task_id,
expert_id, routing_mode, title, instructions, origin, status,
priority, work_mode, progress, created_at, started_at,
completed_at, error, visible)
VALUES (?, ?, ?, NULL, NULL, NULL, NULL, ?, ?, 'schedule',
'completed', 0, 'ask', NULL, ?, ?, ?, NULL, 1)`
)
.run(
legacyTaskId,
project.id,
`schedule:${legacySchedule.id}`,
legacySchedule.title,
legacySchedule.prompt,
'2026-08-18T00:00:00.000Z',
'2026-08-18T00:00:00.000Z',
'2026-08-18T00:01:00.000Z'
)
raw
.prepare(
`INSERT INTO schedule_runs
(id, schedule_id, scheduled_for, task_id, status)
VALUES (?, ?, ?, ?, 'completed')`
)
.run(
legacyRunId,
legacySchedule.id,
'2026-08-18T00:00:00.000Z',
legacyTaskId
)
raw.exec(`
DROP INDEX IF EXISTS idx_tasks_schedule;
PRAGMA user_version = 21;
COMMIT;
`)
raw.close()
const migrated = new AssistantDatabase(databasePath)
migrated.initialize('C:\\Workspace')
const [schedule] = migrated.listSchedules(project.id)
expect(schedule).toMatchObject({
id: legacySchedule.id,
title: legacySchedule.title,
taskId: expect.any(String),
conversationId: expect.any(String)
})
expect(schedule!.taskId).not.toBe(legacyTaskId)
expect(
migrated.getConversation(schedule!.conversationId)
).toMatchObject({
projectId: project.id,
title: legacySchedule.title,
messages: []
})
expect(migrated.listTasks()).toEqual([
expect.objectContaining({
id: schedule!.taskId,
conversationId: schedule!.conversationId,
scheduleId: schedule!.id,
status: 'idle'
})
])
migrated.close()
const inspected = new DatabaseSync(databasePath)
expect(
inspected.prepare('PRAGMA user_version').get()
).toEqual({ user_version: 22 })
expect(
inspected
.prepare(
`SELECT parent_task_id, visible
FROM tasks
WHERE id = ?`
)
.get(legacyTaskId)
).toEqual({
parent_task_id: schedule!.taskId,
visible: 0
})
expect(
inspected
.prepare(
`SELECT name
FROM sqlite_master
WHERE type = 'index' AND name = 'idx_tasks_schedule'`
)
.get()
).toEqual({ name: 'idx_tasks_schedule' })
inspected.close()
})
it('backfills checklist todos when migrating existing magic notes', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-magic-todo-migration-')
@@ -997,6 +1122,26 @@ describe('AssistantDatabase', () => {
recurrence: 'daily',
nextRunAt: '2026-07-31T00:00:00.000Z'
})
expect(schedule).toMatchObject({
projectId: project.id,
workMode: 'ask',
taskId: expect.any(String),
conversationId: expect.any(String)
})
expect(
database.listTasks().find((task) => task.id === schedule.taskId)
).toMatchObject({
conversationId: schedule.conversationId,
scheduleId: schedule.id,
origin: 'schedule',
status: 'idle'
})
expect(database.getConversation(schedule.conversationId)).toMatchObject({
id: schedule.conversationId,
projectId: project.id,
title: '每日摘要',
messages: []
})
const [claim] = database.claimDueSchedules(
new Date('2026-07-31T00:01:00.000Z')
)
@@ -1011,7 +1156,6 @@ describe('AssistantDatabase', () => {
database.completeScheduleRun(
claim!.runId,
'completed',
undefined,
new Date('2026-07-31T00:01:00.000Z')
)
expect(database.listSchedules(project.id)[0]).toMatchObject({
@@ -1019,6 +1163,89 @@ describe('AssistantDatabase', () => {
nextRunAt: '2026-08-01T00:00:00.000Z',
lastRunAt: '2026-07-31T00:01:00.000Z'
})
expect(
database.listTasks().find((task) => task.id === schedule.taskId)
).toMatchObject({
status: 'idle',
completedAt: undefined
})
const manualClaim = database.claimScheduleNow(schedule.id)
expect(manualClaim.schedule).toMatchObject({
taskId: schedule.taskId,
conversationId: schedule.conversationId
})
expect(() => database.claimScheduleNow(schedule.id)).toThrow(
'已有一次运行正在进行'
)
database.completeScheduleRun(
manualClaim.runId,
'completed',
new Date('2026-07-31T00:02:00.000Z')
)
database.appendConversationMessage({
conversationId: schedule.conversationId,
role: 'assistant',
content: '今日任务状态正常',
status: '定时任务',
task: {
id: schedule.taskId,
title: schedule.title
}
})
expect(
database.getConversation(schedule.conversationId).messages
).toEqual([
expect.objectContaining({
content: '今日任务状态正常',
task: {
id: schedule.taskId,
title: schedule.title
}
})
])
const sharedConversationSchedule = database.createSchedule({
projectId: project.id,
conversationId: schedule.conversationId,
title: '每周复盘',
prompt: '复盘本周任务',
workMode: 'execute',
recurrence: 'weekly',
nextRunAt: '2027-01-01T00:00:00.000Z'
})
expect(sharedConversationSchedule.conversationId).toBe(
schedule.conversationId
)
expect(
database
.listTasks()
.filter(
(task) =>
task.conversationId === schedule.conversationId &&
task.origin === 'schedule'
)
).toHaveLength(2)
const countsBeforeFailedCreate = {
schedules: database.listSchedules().length,
tasks: database.listTasks().length,
conversations: database.listConversations().length
}
expect(() =>
database.createSchedule({
projectId: project.id,
conversationId:
'00000000-0000-4000-8000-000000000299',
title: '不应创建',
prompt: '无效对话',
workMode: 'execute',
recurrence: 'once',
nextRunAt: '2027-01-02T00:00:00.000Z'
})
).toThrow('所选对话不存在或不可用于任务')
expect({
schedules: database.listSchedules().length,
tasks: database.listTasks().length,
conversations: database.listConversations().length
}).toEqual(countsBeforeFailedCreate)
const overdue = database.createSchedule({
projectId: project.id,
title: '过期摘要',
@@ -1033,7 +1260,6 @@ describe('AssistantDatabase', () => {
database.completeScheduleRun(
overdueClaim!.runId,
'completed',
undefined,
new Date('2026-07-31T00:01:00.000Z')
)
expect(
@@ -1043,6 +1269,22 @@ describe('AssistantDatabase', () => {
).toMatchObject({
nextRunAt: '2026-08-01T00:00:00.000Z'
})
database.removeSchedule(sharedConversationSchedule.id)
expect(
database
.listSchedules(project.id)
.some((item) => item.id === sharedConversationSchedule.id)
).toBe(false)
expect(
database
.listTasks()
.find((task) => task.id === sharedConversationSchedule.taskId)
).toMatchObject({
conversationId: schedule.conversationId,
scheduleId: undefined,
status: 'completed',
workMode: 'execute'
})
database.close()
})
@@ -1083,7 +1325,6 @@ describe('AssistantDatabase', () => {
recovered.completeScheduleRun(
reclaimed!.runId,
'completed',
undefined,
new Date('2026-08-13T00:02:00.000Z')
)
expect(recovered.listSchedules()[0]).toMatchObject({
@@ -1091,9 +1332,45 @@ describe('AssistantDatabase', () => {
enabled: false,
lastRunAt: '2026-08-13T00:02:00.000Z'
})
expect(() =>
recovered.setScheduleEnabled(schedule.id, true)
).toThrow('已执行的一次性计划不能恢复自动运行')
recovered.close()
})
it('claims a bounded batch of independent due schedules', async () => {
const database = await createDatabase()
const scheduleIds = Array.from({ length: 3 }, (_, index) =>
database.createSchedule({
title: `批量任务 ${index + 1}`,
prompt: `执行批量任务 ${index + 1}`,
workMode: 'execute',
recurrence: 'once',
nextRunAt: '2026-08-13T00:00:00.000Z'
}).id
)
const firstBatch = database.claimDueSchedules(
new Date('2026-08-13T00:01:00.000Z'),
2
)
expect(firstBatch).toHaveLength(2)
expect(
new Set(firstBatch.map((claim) => claim.schedule.id)).size
).toBe(2)
const secondBatch = database.claimDueSchedules(
new Date('2026-08-13T00:01:00.000Z'),
2
)
expect(secondBatch).toHaveLength(1)
expect(scheduleIds).toContain(secondBatch[0]?.schedule.id)
for (const claim of [...firstBatch, ...secondBatch]) {
database.completeScheduleRun(claim.runId, 'completed')
}
database.close()
})
it('durably interrupts active tasks with completion times and audit events on startup', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-assistant-recovery-')
@@ -1847,9 +2124,27 @@ describe('AssistantDatabase', () => {
role: 'user',
content: '远程消息'
})
const linkedSchedule = database.createSchedule({
conversationId: localId,
title: '随会话删除的任务',
prompt: '整理会话',
workMode: 'execute',
recurrence: 'daily',
nextRunAt: '2027-01-01T00:00:00.000Z'
})
expect(database.deleteLocalConversation(localId)).toBe(true)
expect(database.deleteLocalConversation(localId)).toBe(false)
expect(
database
.listSchedules()
.some((schedule) => schedule.id === linkedSchedule.id)
).toBe(false)
expect(
database
.listTasks()
.some((task) => task.id === linkedSchedule.taskId)
).toBe(false)
expect(() =>
database.getConversation(localId)
).toThrow('对话不存在')
File diff suppressed because it is too large Load Diff
@@ -102,7 +102,7 @@ describe('AssistantDatabase heartbeat persistence', () => {
).count
check.close()
migrated.close()
expect(version).toBe(21)
expect(version).toBe(22)
expect(heartbeatTableCount).toBe(4)
})
+2 -1
View File
@@ -165,7 +165,8 @@ export class SubagentService {
instructions: input.parentRequest.prompt,
workMode: 'ask',
origin: 'subagent',
status: 'queued'
status: 'queued',
visible: false
})
this.emit(input, {
childTaskId,
+141
View File
@@ -2668,7 +2668,14 @@ describe('registerIpcHandlers agent terminal state', () => {
updatedAt: Date.now(),
messages: []
})),
appendConversationMessage: vi.fn(),
appendRemoteConversationMessage: vi.fn(),
completeScheduleRun: vi.fn(),
claimScheduleNow: vi.fn(),
listSchedules: vi.fn(() => []),
createSchedule: vi.fn(),
setScheduleEnabled: vi.fn(),
removeSchedule: vi.fn(),
upsertModelUsageCall: vi.fn(),
clearAssistantData: vi.fn(),
listExperts: vi.fn<() => Array<Record<string, unknown>>>(() => []),
@@ -2799,6 +2806,140 @@ describe('registerIpcHandlers agent terminal state', () => {
senderFrame: webContents.mainFrame
})
it('defaults custom scheduled Tasks to Execute', async () => {
const harness = createHarness({
runtimeId: 'continue',
capability: 'chat',
supportsToolExecution: true,
async *run() {
yield { type: 'done', requestId: 'unused' } as const
}
})
const conversationId =
'00000000-0000-4000-8000-000000000705'
const createInput = {
projectId: '00000000-0000-4000-8000-000000000401',
conversationId,
title: '每周汇总',
prompt: '汇总本周进展',
recurrence: 'weekly' as const,
nextRunAt: '2026-08-21T09:00:00.000Z'
}
await electronMocks.handlers.get(
ipcChannels.schedulesCreate
)?.(trustedEvent(harness.webContents), createInput)
expect(
harness.assistantDatabase.createSchedule
).toHaveBeenCalledWith({
...createInput,
workMode: 'execute'
})
await harness.dispose()
})
it('reuses a scheduled Task and writes text results to its Conversation', async () => {
const taskId = '00000000-0000-4000-8000-000000000701'
const conversationId =
'00000000-0000-4000-8000-000000000702'
const scheduleId =
'00000000-0000-4000-8000-000000000703'
const runId = '00000000-0000-4000-8000-000000000704'
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: true,
async *run(
request: { requestId: string },
_signal: AbortSignal,
authorize: (
input: {
scopeKey: string
title: string
description: string
}
) => Promise<string>
) {
expect(request.requestId).toBe(runId)
await authorize({
scopeKey: 'workspace.write',
title: '写入工作区',
description: '更新状态文件'
})
yield {
requestId: request.requestId,
type: 'text',
delta: '每日状态正常'
} as const
yield {
requestId: request.requestId,
type: 'done'
} as const
}
}
const harness = createHarness(runtime)
const schedule = {
id: scheduleId,
projectId: '00000000-0000-4000-8000-000000000401',
taskId,
conversationId,
title: '每日状态',
prompt: '汇总状态',
workMode: 'execute' as const,
recurrence: 'daily' as const,
nextRunAt: '2026-08-20T00:00:00.000Z',
enabled: true,
createdAt: '2026-08-19T00:00:00.000Z',
updatedAt: '2026-08-19T00:00:00.000Z'
}
harness.assistantDatabase.claimScheduleNow.mockReturnValue({
schedule,
runId
})
harness.approvalBroker.request.mockResolvedValue('once')
await electronMocks.handlers.get(
ipcChannels.schedulesRunNow
)?.(trustedEvent(harness.webContents), scheduleId)
await vi.waitFor(() =>
expect(
harness.assistantDatabase.completeScheduleRun
).toHaveBeenCalledWith(runId, 'completed')
)
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(taskId, 'running')
expect(harness.approvalBroker.request).toHaveBeenCalledWith(
expect.objectContaining({
requestId: taskId,
conversationId
}),
expect.any(AbortSignal),
expect.any(Function)
)
expect(
harness.assistantDatabase.appendConversationMessage
).toHaveBeenCalledWith({
conversationId,
role: 'assistant',
content: '每日状态正常',
status: '定时任务',
task: {
id: taskId,
title: '每日状态'
}
})
expect(
harness.assistantDatabase.createTextArtifact
).not.toHaveBeenCalled()
expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.conversationsChanged
)
await harness.dispose()
})
it('publishes Runtime usage as context metrics with one settings read', async () => {
const runtime = {
runtimeId: 'continue',
+232 -92
View File
@@ -156,8 +156,7 @@ import {
expertCreateSchema,
type AssistantSchedule,
type AssistantArtifact,
type ConversationAttachment,
type WorkMode
type ConversationAttachment
} from '../shared/assistant-contracts'
import {
CHANNEL_LIMITS,
@@ -1158,7 +1157,8 @@ export function registerIpcHandlers(
title: '智能心跳回顾',
instructions: '根据有界本地输入生成智能心跳报告',
workMode: 'ask',
origin: 'assistant'
origin: 'assistant',
visible: false
})
let output = ''
let completed = false
@@ -1242,28 +1242,52 @@ export function registerIpcHandlers(
)
}
}
const executeSchedule = async (
schedule: Omit<AssistantSchedule, 'workMode'> & {
workMode: WorkMode
},
origin: 'schedule' | 'delegation' | 'channel' = 'schedule',
externalSignal?: AbortSignal,
remoteContext?: {
channel: keyof typeof projectChannelLabels
channelLabel: string
senderDisplay: string
projectId: string
projectName: string
rootPath: string
conversationId: string
runtimeSelection: AgentRuntimeSelection
followConfiguredAgentRuntime?: boolean
runtime?: AgentRuntime
taskId?: string
contextIds?: string[]
resultFileRequested?: boolean
const publishConversationChange = (): void => {
if (!window.isDestroyed()) {
window.webContents.send(ipcChannels.conversationsChanged)
}
}
type ExecutionTemplate = Omit<
AssistantSchedule,
'taskId' | 'conversationId'
>
type ChannelExecutionContext = {
channel: keyof typeof projectChannelLabels
channelLabel: string
senderDisplay: string
projectId: string
projectName: string
rootPath: string
conversationId: string
runtimeSelection: AgentRuntimeSelection
followConfiguredAgentRuntime?: boolean
runtime?: AgentRuntime
taskId: string
contextIds?: string[]
resultFileRequested?: boolean
}
type TaskWorkExecution =
| {
origin: 'schedule'
schedule: AssistantSchedule
scheduleRunId: string
externalSignal?: AbortSignal
}
| {
origin: 'delegation'
schedule: ExecutionTemplate
externalSignal?: AbortSignal
}
| {
origin: 'channel'
schedule: ExecutionTemplate
externalSignal: AbortSignal
remoteContext: ChannelExecutionContext
}
const executeTaskWork = async (
input: TaskWorkExecution
): Promise<{
status: 'completed' | 'failed'
output?: string
@@ -1271,13 +1295,24 @@ export function registerIpcHandlers(
attachments?: ChannelMediaAttachment[]
artifactIds?: string[]
}> => {
const { origin, schedule } = input
const externalSignal = input.externalSignal
const remoteContext =
input.origin === 'channel' ? input.remoteContext : undefined
if (shuttingDown || executionPaused) {
return { status: 'failed', error: '应用正在退出' }
}
if (externalSignal?.aborted) {
return { status: 'failed', error: '请求已取消' }
}
const requestId = remoteContext?.taskId ?? randomUUID()
const taskId =
input.origin === 'schedule'
? input.schedule.taskId
: input.origin === 'channel'
? input.remoteContext.taskId
: randomUUID()
const requestId =
input.origin === 'schedule' ? input.scheduleRunId : taskId
const controller = new AbortController()
const abortFromExternal = (): void => {
controller.abort(externalSignal?.reason)
@@ -1287,22 +1322,27 @@ export function registerIpcHandlers(
})
activeRequests.set(requestId, controller)
const runtimeConversationId =
remoteContext?.conversationId ?? `${origin}:${schedule.id}`
if (remoteContext?.taskId) {
assistantDatabase.updateTaskStatus(requestId, 'running')
remoteContext?.conversationId ??
(input.origin === 'schedule'
? input.schedule.conversationId
: undefined) ??
`${origin}:${schedule.id}`
if (input.origin !== 'delegation') {
assistantDatabase.updateTaskStatus(taskId, 'running')
} else {
assistantDatabase.createTask({
id: requestId,
id: taskId,
projectId: schedule.projectId,
conversationId: runtimeConversationId,
title: schedule.title,
instructions: schedule.prompt,
workMode: schedule.workMode,
origin: origin === 'channel' ? 'delegation' : origin
origin: 'delegation',
visible: false
})
}
if (origin === 'schedule') {
assistantDatabase.bindScheduleRunTask(schedule.id, requestId)
publishConversationChange()
}
let output = ''
let completed = false
@@ -1313,7 +1353,7 @@ export function registerIpcHandlers(
onError: (error) => controller.abort(error),
onEvent: (event) => {
assistantDatabase.appendTaskEvent(
requestId,
taskId,
event.type,
event
)
@@ -1324,7 +1364,9 @@ export function registerIpcHandlers(
remoteContext?.runtime ??
(await resolveRequestRuntime({
projectId: schedule.projectId,
runtimeSelection: remoteContext?.runtimeSelection,
runtimeSelection:
remoteContext?.runtimeSelection ??
schedule.runtimeSelection,
workspaceOverride: remoteContext?.rootPath,
followConfiguredAgentRuntime:
remoteContext?.followConfiguredAgentRuntime
@@ -1389,9 +1431,12 @@ export function registerIpcHandlers(
return channelToolPolicy === 'policy' ? 'deny' : 'once'
}
assistantDatabase.updateTaskStatus(
requestId,
taskId,
'waiting_approval'
)
if (origin === 'schedule') {
publishConversationChange()
}
const settings = await settingsStore.getPolicySettings()
try {
return await approvalBroker.request(
@@ -1401,7 +1446,8 @@ export function registerIpcHandlers(
settings.toolApproval === 'policy'
? 'policy'
: undefined,
requestId,
requestId:
origin === 'schedule' ? taskId : requestId,
conversationId: runtimeConversationId
},
controller.signal,
@@ -1417,7 +1463,10 @@ export function registerIpcHandlers(
)
} finally {
if (!controller.signal.aborted) {
assistantDatabase.updateTaskStatus(requestId, 'running')
assistantDatabase.updateTaskStatus(taskId, 'running')
if (origin === 'schedule') {
publishConversationChange()
}
}
}
}
@@ -1445,14 +1494,21 @@ export function registerIpcHandlers(
agentRuntimeSelected ? undefined : authorize
)) {
if (agentEvent.type === 'model-usage') {
persistModelUsage(agentEvent)
persistModelUsage({
...agentEvent,
requestId: taskId,
callId:
origin === 'schedule'
? `${requestId}:${agentEvent.callId}`
: agentEvent.callId
})
continue
}
const taskEvent =
agentEvent.type === 'generated-image'
? persistGeneratedImage(agentEvent, {
projectId: schedule.projectId,
taskId: requestId,
taskId,
title: schedule.title
})
: agentEvent
@@ -1553,15 +1609,35 @@ export function registerIpcHandlers(
})
}
}
if (origin !== 'channel' && output.trim()) {
if (
input.origin === 'schedule' &&
(output.trim() || artifactIds.length > 0)
) {
assistantDatabase.appendConversationMessage({
conversationId: input.schedule.conversationId,
role: 'assistant',
content:
output.trim() ||
'任务已完成,独立成果已保存到成果工作栏。',
status: '定时任务',
...(artifactIds.length > 0 ? { artifactIds } : {}),
task: {
id: taskId,
title: schedule.title
}
})
publishConversationChange()
} else if (origin === 'delegation' && output.trim()) {
assistantDatabase.createTextArtifact({
projectId: schedule.projectId,
taskId: requestId,
taskId,
title: schedule.title,
content: output
})
}
assistantDatabase.updateTaskStatus(requestId, 'completed')
if (origin !== 'schedule') {
assistantDatabase.updateTaskStatus(taskId, 'completed')
}
showDesktopNotificationWhenUnfocused(window, {
title:
origin === 'channel'
@@ -1571,7 +1647,7 @@ export function registerIpcHandlers(
origin === 'channel'
? '结果已回复,并保存到远程通道会话。'
: origin === 'schedule'
? '结果已保存到 GoodBuddy 成果工作栏。'
? '结果已写入关联对话。'
: '结果已保存到成果工作栏和委派记录。'
})
return {
@@ -1586,10 +1662,24 @@ export function registerIpcHandlers(
eventBuffer.flush()
const message = safeRuntimeError(error, '定时任务执行失败')
assistantDatabase.updateTaskStatus(
requestId,
taskId,
controller.signal.aborted ? 'cancelled' : 'failed',
message
)
if (input.origin === 'schedule') {
assistantDatabase.appendConversationMessage({
conversationId: input.schedule.conversationId,
role: 'assistant',
content: message,
state: 'error',
status: '定时任务失败',
task: {
id: taskId,
title: schedule.title
}
})
publishConversationChange()
}
showDesktopNotificationWhenUnfocused(window, {
title:
origin === 'channel'
@@ -1720,34 +1810,71 @@ export function registerIpcHandlers(
yield { requestId: request.requestId, type: 'done' }
}
let scheduleTickRunning = false
const runDueSchedules = async (): Promise<void> => {
if (scheduleTickRunning || shuttingDown || executionPaused) {
const maximumConcurrentScheduleRuns = 4
let scheduleClaimRunning = false
let activeScheduleRuns = 0
const launchDueSchedules = (): void => {
if (
scheduleClaimRunning ||
shuttingDown ||
executionPaused ||
activeScheduleRuns >= maximumConcurrentScheduleRuns
) {
return
}
scheduleTickRunning = true
scheduleClaimRunning = true
try {
for (const claim of assistantDatabase.claimDueSchedules()) {
const result = await trackExecution(
executeSchedule(claim.schedule)
)
assistantDatabase.completeScheduleRun(
claim.runId,
result.status,
assistantDatabase.getScheduleRunTaskId(claim.runId)
)
}
if (!shuttingDown && !executionPaused) {
await trackExecution(heartbeatService.processDue())
const claims = assistantDatabase.claimDueSchedules(
new Date(),
maximumConcurrentScheduleRuns - activeScheduleRuns
)
activeScheduleRuns += claims.length
for (const claim of claims) {
const execution = (async () => {
const result = await executeTaskWork({
origin: 'schedule',
schedule: claim.schedule,
scheduleRunId: claim.runId
})
assistantDatabase.completeScheduleRun(
claim.runId,
result.status
)
publishConversationChange()
})()
void trackExecution(execution)
.catch(() => undefined)
.finally(() => {
activeScheduleRuns -= 1
launchDueSchedules()
})
}
} finally {
scheduleTickRunning = false
scheduleClaimRunning = false
}
}
const scheduleInterval = setInterval(() => {
void trackExecution(runDueSchedules()).catch(() => undefined)
}, 30_000)
void trackExecution(runDueSchedules()).catch(() => undefined)
let heartbeatTickRunning = false
const runDueHeartbeats = async (): Promise<void> => {
if (
heartbeatTickRunning ||
shuttingDown ||
executionPaused
) {
return
}
heartbeatTickRunning = true
try {
await heartbeatService.processDue()
} finally {
heartbeatTickRunning = false
}
}
const runDueWork = (): void => {
launchDueSchedules()
void trackExecution(runDueHeartbeats()).catch(() => undefined)
}
const scheduleInterval = setInterval(runDueWork, 30_000)
runDueWork()
const delegationEndpoint =
process.env.GOODBUDDY_DELEGATION_ENDPOINT?.trim()
const delegationToken =
@@ -1768,18 +1895,23 @@ export function registerIpcHandlers(
assistantDatabase.markDelegationDelivered(taskId)
},
onTask: (task) =>
trackExecution(executeSchedule({
id: task.id,
projectId: task.projectId,
title: task.title,
prompt: task.prompt,
workMode: task.workMode,
recurrence: 'once',
nextRunAt: new Date().toISOString(),
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}, 'delegation'))
trackExecution(
executeTaskWork({
origin: 'delegation',
schedule: {
id: task.id,
projectId: task.projectId,
title: task.title,
prompt: task.prompt,
workMode: task.workMode,
recurrence: 'once',
nextRunAt: new Date().toISOString(),
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
})
)
})
: undefined
remoteDelegation?.start()
@@ -1927,7 +2059,8 @@ export function registerIpcHandlers(
title: `${channelLabel}远程请求`,
instructions: executionPrompt,
workMode: parsed.workMode,
origin: 'delegation'
origin: 'delegation',
visible: false
})
publishRemoteActivity({
requestId: remoteTaskId,
@@ -2021,8 +2154,9 @@ export function registerIpcHandlers(
const now = new Date().toISOString()
const result = await trackExecution(
executeSchedule(
{
executeTaskWork({
origin: 'channel',
schedule: {
id: randomUUID(),
projectId: project.id,
title: `${channelLabel}远程请求`,
@@ -2034,9 +2168,8 @@ export function registerIpcHandlers(
createdAt: now,
updatedAt: now
},
'channel',
signal,
{
externalSignal: signal,
remoteContext: {
channel,
channelLabel,
senderDisplay,
@@ -2053,7 +2186,7 @@ export function registerIpcHandlers(
message.text
)
}
)
})
)
const responseText =
result.output?.trim() ||
@@ -2408,7 +2541,8 @@ export function registerIpcHandlers(
conversationId: request.conversationId,
title: parsedRequest.prompt.slice(0, 120),
instructions: parsedRequest.prompt,
workMode: request.workMode ?? 'ask'
workMode: request.workMode ?? 'ask',
visible: false
})
} catch (error) {
knowledgeGateway?.revoke(knowledgeCapabilityToken)
@@ -4255,12 +4389,14 @@ export function registerIpcHandlers(
value.scheduleId,
value.enabled
)
publishConversationChange()
}
)
registerHandler(ipcChannels.schedulesRemove, (event, input: unknown) => {
assertTrustedSender(event, window)
assistantDatabase.removeSchedule(assistantIdSchema.parse(input))
publishConversationChange()
})
registerHandler(ipcChannels.schedulesRunNow, (event, input: unknown) => {
@@ -4271,15 +4407,19 @@ export function registerIpcHandlers(
const claim = assistantDatabase.claimScheduleNow(
assistantIdSchema.parse(input)
)
void trackExecution(executeSchedule(claim.schedule))
.then((result) => {
assistantDatabase.completeScheduleRun(
claim.runId,
result.status,
assistantDatabase.getScheduleRunTaskId(claim.runId)
)
const execution = (async () => {
const result = await executeTaskWork({
origin: 'schedule',
schedule: claim.schedule,
scheduleRunId: claim.runId
})
.catch(() => undefined)
assistantDatabase.completeScheduleRun(
claim.runId,
result.status
)
publishConversationChange()
})()
void trackExecution(execution).catch(() => undefined)
})
registerHandler(ipcChannels.heartbeatsList, (event, input: unknown) => {
+138 -3
View File
@@ -15,7 +15,12 @@ import type {
DesktopApi
} from '../../shared/contracts'
import type { ApplicationSettings } from '../../shared/application-settings-contracts'
import type { AssistantProject } from '../../shared/assistant-contracts'
import type {
AssistantProject,
AssistantSchedule,
AssistantTask,
ConversationSnapshot
} from '../../shared/assistant-contracts'
import { agentRuntimeSelectionKey } from '../../shared/runtime-selection-contracts'
const speechRecognitionMocks = vi.hoisted(() => ({
@@ -397,6 +402,8 @@ const api: DesktopApi = {
create: vi.fn(async (input) => ({
...input,
id: crypto.randomUUID(),
taskId: crypto.randomUUID(),
conversationId: input.conversationId ?? crypto.randomUUID(),
enabled: true,
createdAt: '2026-07-31T00:00:00.000Z',
updatedAt: '2026-07-31T00:00:00.000Z'
@@ -778,6 +785,8 @@ describe('App', () => {
vi.mocked(api.conversations.onChanged)
.mockReset()
.mockReturnValue(() => undefined)
vi.mocked(api.tasks.list).mockReset().mockResolvedValue([])
vi.mocked(api.schedules.list).mockReset().mockResolvedValue([])
api.channels = undefined
newConversationListener = undefined
beforeQuitListener = undefined
@@ -899,6 +908,130 @@ describe('App', () => {
).not.toBeInTheDocument()
})
it('discovers product Tasks through their Conversation without exposing Runs', async () => {
const conversationId =
'00000000-0000-4000-8000-000000000821'
const taskId = '00000000-0000-4000-8000-000000000822'
const scheduleId =
'00000000-0000-4000-8000-000000000823'
const conversation: ConversationSnapshot = {
id: conversationId,
projectId,
title: '产品发布讨论',
updatedAt: Date.now(),
messages: []
}
const task: AssistantTask = {
id: taskId,
projectId,
conversationId,
scheduleId,
title: '每周发布总结',
instructions: '总结本周发布进度',
origin: 'schedule',
status: 'idle',
createdAt: '2026-08-19T00:00:00.000Z'
}
const schedule: AssistantSchedule = {
id: scheduleId,
projectId,
taskId,
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([conversation])
vi.mocked(api.tasks.list).mockResolvedValue([task])
vi.mocked(api.schedules.list).mockResolvedValue([schedule])
render(<App />)
const toggle = await screen.findByLabelText(
'展开或折叠“产品发布讨论”中的 1 个任务'
)
const conversationButton =
toggle.parentElement?.querySelector('.conversation-item')
expect(toggle.nextElementSibling).toBe(conversationButton)
expect(
conversationButton?.querySelector('.conversation-item__title')
).toHaveAttribute('title', '产品发布讨论')
expect(screen.queryByText('任务 1')).not.toBeInTheDocument()
fireEvent.click(toggle)
const taskTitle = await screen.findByText('每周发布总结', {
selector: '.conversation-task-child__title'
})
const taskChild = taskTitle.closest('button')
expect(taskChild).not.toBeNull()
expect(
taskChild?.querySelector('.conversation-task-child__icon')
).toHaveClass('conversation-task-child__icon--idle')
expect(
taskChild?.querySelector('.conversation-task-child__meta')
).toHaveTextContent('Execute · 每周 · 空闲')
expect(screen.queryByText('Run')).not.toBeInTheDocument()
fireEvent.click(taskChild!)
const taskRegion = await screen.findByRole('region', {
name: '当前会话的任务'
})
expect(within(taskRegion).getByText('Execute')).toBeInTheDocument()
})
it('routes scheduled Task approvals to the associated Conversation', async () => {
const conversationId =
'00000000-0000-4000-8000-000000000831'
const taskId = '00000000-0000-4000-8000-000000000832'
vi.mocked(api.conversations.list).mockResolvedValue([
{
id: conversationId,
projectId,
title: '发布审批会话',
updatedAt: Date.now(),
messages: []
}
])
vi.mocked(api.tasks.list).mockResolvedValue([
{
id: taskId,
projectId,
conversationId,
scheduleId: '00000000-0000-4000-8000-000000000833',
title: '发布任务',
instructions: '更新发布文件',
origin: 'schedule',
status: 'running',
createdAt: '2026-08-19T00:00:00.000Z'
}
])
render(<App />)
await screen.findAllByText('发布审批会话')
await waitFor(() => expect(api.tasks.list).toHaveBeenCalled())
act(() => {
agentListener?.({
requestId: taskId,
type: 'approval',
approvalId: '00000000-0000-4000-8000-000000000834',
title: '请求写入工作区',
description: '更新发布文件',
toolName: 'write_file',
argumentSummary: 'release.md',
allowPermanent: false
})
})
expect(await screen.findAllByText('请求写入工作区'))
.not.toHaveLength(0)
expect(screen.getByText('仅此次')).toBeInTheDocument()
expect(screen.getByLabelText('任务结果:发布任务'))
.toBeInTheDocument()
})
it('schedules lazy workspace routes for idle preloading', () => {
render(<App />)
@@ -928,7 +1061,7 @@ describe('App', () => {
expect(api.memory.list).toHaveBeenCalledWith(projectId)
expect(api.memory.list).toHaveBeenCalledWith()
expect(api.schedules.list).toHaveBeenCalledOnce()
expect(api.schedules.list).toHaveBeenCalledWith(projectId)
expect(api.schedules.list).toHaveBeenCalledWith()
expect(api.heartbeats.list).toHaveBeenCalledOnce()
})
})
@@ -6737,7 +6870,9 @@ describe('App', () => {
expect(
screen.getByRole('tab', { name: '任务中心' })
).toHaveAttribute('aria-selected', 'true')
expect(screen.getByText('自动化')).toBeInTheDocument()
expect(
screen.getByRole('heading', { name: '任务索引' })
).toBeInTheDocument()
expect(screen.queryByText('最近任务')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '上下文' }))
expect(
+638 -68
View File
@@ -4,6 +4,7 @@ import {
Check,
CheckCircle2,
ChevronDown,
ChevronRight,
CircleAlert,
CircleHelp,
Copy,
@@ -13,6 +14,7 @@ import {
HeartPulse,
Info,
Library,
ListTodo,
LoaderCircle,
Maximize2,
MessageSquarePlus,
@@ -158,6 +160,13 @@ import {
type PendingSidebarApproval,
type SidebarArtifact
} from './RightAssistantSidebar'
import {
CustomTaskDialog,
type CustomTaskDestination
} from './CustomTaskDialog'
import { ConversationTaskStrip } from './ConversationTaskStrip'
import { OverflowMarquee } from './OverflowMarquee'
import { findTaskSchedule } from './TaskScheduleActions'
import type { SettingsCategoryId } from './settings-categories'
import goodbuddyDarkIcon from './assets/goodbuddy-dark.png'
import goodbuddyLightIcon from './assets/goodbuddy-light.png'
@@ -665,6 +674,7 @@ function ChatHistoryPane({
onVisibleMessageCountChange,
quickActions,
scrollSnapshot,
taskStrip,
visibleMessageCount
}: {
active: boolean
@@ -703,6 +713,7 @@ function ChatHistoryPane({
) => void
quickActions: ChatQuickAction[]
scrollSnapshot?: ChatScrollSnapshot
taskStrip?: ReactNode
visibleMessageCount: number
}): React.JSX.Element {
const { t } = useTranslation('app')
@@ -911,6 +922,7 @@ function ChatHistoryPane({
hidden={!active}
inert={!active}
>
{taskStrip}
<section
className="chat"
id={active ? 'chat-message-list' : undefined}
@@ -1132,6 +1144,13 @@ function isConversation(value: unknown): value is Conversation {
entry.artifactIds.every(
(artifactId) => typeof artifactId === 'string'
))) &&
(entry.task === undefined ||
(typeof entry.task === 'object' &&
entry.task !== null &&
typeof (entry.task as Record<string, unknown>).id ===
'string' &&
typeof (entry.task as Record<string, unknown>).title ===
'string')) &&
(entry.attachments === undefined ||
(Array.isArray(entry.attachments) &&
entry.attachments.length <= 8 &&
@@ -1180,6 +1199,7 @@ function toConversationMessage(message: Message): ConversationMessage {
sourceReferences: message.sourceReferences,
knowledgeRetrieval: message.knowledgeRetrieval,
artifactIds: message.artifactIds,
task: message.task,
attachments: message.attachments
}
}
@@ -1256,6 +1276,55 @@ function mergeArtifacts(
)
}
function mergePersistedConversations(
current: readonly Conversation[],
incoming: readonly ConversationSnapshot[],
persistedLocal: Map<string, Conversation>
): Conversation[] {
const incomingById = new Map(
incoming.map((conversation) => [conversation.id, conversation])
)
const currentById = new Map(
current.map((conversation) => [conversation.id, conversation])
)
const merged = incoming.map((conversation): Conversation => {
if (conversation.remote) {
return conversation
}
const local = currentById.get(conversation.id)
if (!local || local.remote) {
persistedLocal.set(conversation.id, conversation)
return conversation
}
const localMessageById = new Map(
local.messages.map((message) => [message.id, message])
)
const serverMessageIds = new Set(
conversation.messages.map((message) => message.id)
)
const messages = [
...conversation.messages.map(
(message) => localMessageById.get(message.id) ?? message
),
...local.messages.filter(
(message) => !serverMessageIds.has(message.id)
)
].slice(-500)
const next =
local.updatedAt > conversation.updatedAt
? { ...local, messages }
: { ...conversation, messages }
persistedLocal.set(conversation.id, conversation)
return next
})
for (const conversation of current) {
if (!incomingById.has(conversation.id)) {
merged.push(conversation)
}
}
return merged.sort((left, right) => right.updatedAt - left.updatedAt)
}
function getProjectDefaultRuntimeSelection(
project: AssistantProject | undefined,
settings: RuntimeSettings
@@ -1722,6 +1791,7 @@ function ComposerMenuSelect<T extends string>({
function App(): React.JSX.Element {
const { i18n, t } = useTranslation('app')
const { t: tWorkspace } = useTranslation('workspace')
const tRef = useRef(t)
useEffect(() => {
tRef.current = t
@@ -1758,6 +1828,7 @@ function App(): React.JSX.Element {
const [projects, setProjects] = useState<AssistantProject[]>([])
const projectsRef = useRef(projects)
const [assistantTasks, setAssistantTasks] = useState<AssistantTask[]>([])
const assistantTasksRef = useRef(assistantTasks)
const [tokenUsage, setTokenUsage] =
useState<TokenUsageSummary>(emptyTokenUsage)
const [workspaceChanges, setWorkspaceChanges] =
@@ -1778,6 +1849,13 @@ function App(): React.JSX.Element {
const [assistantSchedules, setAssistantSchedules] = useState<
AssistantSchedule[]
>([])
const [selectedAssistantTaskId, setSelectedAssistantTaskId] =
useState<string>()
const [expandedTaskConversationIds, setExpandedTaskConversationIds] =
useState<Set<string>>(() => new Set())
const [customTaskDialog, setCustomTaskDialog] = useState<{
defaultDestination: CustomTaskDestination
}>()
const [assistantHeartbeats, setAssistantHeartbeats] = useState<
AssistantHeartbeatConfig[]
>([])
@@ -2344,6 +2422,10 @@ function App(): React.JSX.Element {
projectsRef.current = projects
}, [projects])
useEffect(() => {
assistantTasksRef.current = assistantTasks
}, [assistantTasks])
useEffect(() => {
resizeComposerTextarea(inputRef.current)
}, [input])
@@ -2977,6 +3059,48 @@ function App(): React.JSX.Element {
deferredSearchQuery,
searchConversationSnapshot
])
const productAssistantTasks = useMemo(
() =>
assistantTasks.filter(
(task) => !task.parentTaskId && task.origin === 'schedule'
),
[assistantTasks]
)
const tasksByConversation = useMemo(() => {
const grouped = new Map<string, AssistantTask[]>()
for (const task of productAssistantTasks) {
if (!task.conversationId) {
continue
}
const existing = grouped.get(task.conversationId) ?? []
existing.push(task)
grouped.set(task.conversationId, existing)
}
for (const tasks of grouped.values()) {
tasks.sort((left, right) =>
right.createdAt.localeCompare(left.createdAt)
)
}
return grouped
}, [productAssistantTasks])
const conversationTitles = useMemo(
() =>
new Map(
conversations.map((conversation) => [
conversation.id,
getConversationDisplayTitle(
conversation,
t('conversation.defaultTitle')
)
])
),
[conversations, t]
)
const projectNames = useMemo(
() =>
new Map(projects.map((project) => [project.id, project.name])),
[projects]
)
const pendingSidebarApprovals = useMemo<PendingSidebarApproval[]>(
() =>
conversations.flatMap((conversation) =>
@@ -3310,6 +3434,94 @@ function App(): React.JSX.Element {
(event: AgentEvent): void => {
const run = activeRuns.current.get(event.requestId)
if (!run) {
if (event.type !== 'approval') {
return
}
const attachScheduledApproval = (
task: AssistantTask | undefined
): void => {
if (
!task?.conversationId ||
task.origin !== 'schedule'
) {
return
}
setAssistantTasks((current) =>
current.map((candidate) =>
candidate.id === task.id
? { ...candidate, status: 'waiting_approval' }
: candidate
)
)
if (
activeConversationIdRef.current !== task.conversationId
) {
setUnreadConversationIds((current) => {
const next = new Set(current)
next.add(task.conversationId!)
return next
})
}
setConversations((current) =>
current.map((conversation) => {
if (conversation.id !== task.conversationId) {
return conversation
}
const existing = conversation.messages.find(
(message) =>
message.approval?.id === event.approvalId
)
if (existing) {
return conversation
}
return {
...conversation,
updatedAt: Date.now(),
messages: [
...conversation.messages,
{
id: crypto.randomUUID(),
role: 'assistant',
content: event.title,
createdAt: Date.now(),
state: 'complete',
task: {
id: task.id,
title: task.title
},
approval: {
id: event.approvalId,
title: event.title,
description: event.description,
toolName: event.toolName,
argumentSummary: event.argumentSummary,
allowPermanent: event.allowPermanent
}
}
]
}
})
)
}
const task = assistantTasksRef.current.find(
(candidate) => candidate.id === event.requestId
)
if (task) {
attachScheduledApproval(task)
} else {
void window.goodbuddy.tasks
.list()
.then((tasks) => {
setAssistantTasks(tasks)
assistantTasksRef.current = tasks
attachScheduledApproval(
tasks.find(
(candidate) => candidate.id === event.requestId
)
)
})
.catch(() => undefined)
}
return
}
@@ -3998,9 +4210,69 @@ function App(): React.JSX.Element {
}
let active = true
let refreshSequence = 0
const remove = window.goodbuddy.conversations.onChanged(() => {
const sequence = ++refreshSequence
void window.goodbuddy.conversations
let refreshTimer: number | undefined
let refreshInFlight = false
let refreshQueued = false
const queueRefresh = (): void => {
refreshSequence += 1
refreshQueued = true
if (refreshInFlight || refreshTimer !== undefined) {
return
}
refreshTimer = window.setTimeout(() => {
refreshTimer = undefined
refresh()
}, 50)
}
const refresh = (): void => {
if (refreshInFlight) {
refreshQueued = true
return
}
refreshInFlight = true
refreshQueued = false
const sequence = refreshSequence
const tasksRefresh = window.goodbuddy.tasks
.list()
.then((tasks) => {
if (!active || sequence !== refreshSequence) {
return
}
setAssistantTasks(tasks)
setActivityRecords((current) =>
reconcileActivityRecords(
current,
tasks,
new Set(activeRuns.current.keys())
)
)
})
.catch(() => {
if (active && sequence === refreshSequence) {
notify({
tone: 'error',
message: tRef.current('notices.taskHistoryReadFailed'),
dedupeKey: 'task-lifecycle-refresh'
})
}
})
const schedulesRefresh = window.goodbuddy.schedules
.list()
.then((schedules) => {
if (active && sequence === refreshSequence) {
setAssistantSchedules(schedules)
}
})
.catch(() => {
if (active && sequence === refreshSequence) {
notify({
tone: 'error',
message: tRef.current('notices.schedulesReadFailed'),
dedupeKey: 'schedule-lifecycle-refresh'
})
}
})
const conversationsRefresh = window.goodbuddy.conversations
.list()
.then((persisted) => {
if (!active || sequence !== refreshSequence) {
@@ -4045,17 +4317,16 @@ function App(): React.JSX.Element {
dedupeKey: 'remote-channel-message'
})
}
const local = conversationsRef.current.filter(
(conversation) => !conversation.remote
)
setConversations(
[...remote, ...local].sort(
(left, right) => right.updatedAt - left.updatedAt
setConversations((current) =>
mergePersistedConversations(
current,
persisted,
persistedLocalConversationsRef.current
)
)
})
.catch(() => {
if (active) {
if (active && sequence === refreshSequence) {
notify({
tone: 'error',
message: tRef.current(
@@ -4065,9 +4336,23 @@ function App(): React.JSX.Element {
})
}
})
})
void Promise.allSettled([
tasksRefresh,
schedulesRefresh,
conversationsRefresh
]).finally(() => {
refreshInFlight = false
if (active && refreshQueued) {
queueRefresh()
}
})
}
const remove = window.goodbuddy.conversations.onChanged(queueRefresh)
return () => {
active = false
if (refreshTimer !== undefined) {
window.clearTimeout(refreshTimer)
}
remove()
}
}, [conversationStoreReady])
@@ -4327,7 +4612,7 @@ function App(): React.JSX.Element {
return
}
void window.goodbuddy.schedules
.list(activeProjectId)
.list()
.then(setAssistantSchedules)
.catch(() =>
notify({
@@ -5726,11 +6011,14 @@ function App(): React.JSX.Element {
updateMessage(conversationId, messageId, (message) => ({
...message,
approval: undefined,
status: approved
? tRef.current('chat.approval.executing', {
decision: decisionLabel
})
: tRef.current('chat.approval.denied')
status:
approved && message.task
? undefined
: approved
? tRef.current('chat.approval.executing', {
decision: decisionLabel
})
: tRef.current('chat.approval.denied')
}))
} catch {
updateMessage(conversationId, messageId, (message) => ({
@@ -6109,6 +6397,163 @@ function App(): React.JSX.Element {
setView('chat')
}
const openAssistantTask = (task: AssistantTask): void => {
if (!task.conversationId) {
notify({
tone: 'info',
message: t('notices.conversationDeleted')
})
return
}
const conversation = conversations.find(
(candidate) => candidate.id === task.conversationId
)
if (!conversation) {
notify({
tone: 'info',
message: t('notices.conversationDeleted')
})
return
}
if (task.projectId) {
const project = projects.find(
(candidate) => candidate.id === task.projectId
)
setActiveProjectId(task.projectId)
if (project) {
setWorkMode(
normalizeInteractiveWorkMode(project.defaultWorkMode)
)
}
}
setSelectedAssistantTaskId(task.id)
setExpandedTaskConversationIds((current) => {
const next = new Set(current)
next.add(conversation.id)
return next
})
setActiveId(conversation.id)
setView('chat')
}
const openCustomTaskDialog = (
defaultDestination: CustomTaskDestination
): void => {
if (!activeProject || activeProject.kind !== 'user') {
notify({
tone: 'info',
message: t('customTask.errors.projectUnavailable')
})
return
}
setCustomTaskDialog({ defaultDestination })
}
const createCustomTask = async (
input: Parameters<typeof window.goodbuddy.schedules.create>[0]
): Promise<AssistantSchedule> => {
if (input.conversationId) {
persistLocalConversationChanges()
await conversationPersistenceQueueRef.current
}
const schedule = await window.goodbuddy.schedules.create(input)
setAssistantSchedules((current) => [
schedule,
...current.filter((item) => item.id !== schedule.id)
])
setSelectedAssistantTaskId(schedule.taskId)
setExpandedTaskConversationIds((current) => {
const next = new Set(current)
next.add(schedule.conversationId)
return next
})
const [conversationResult, taskResult, scheduleResult] =
await Promise.allSettled([
window.goodbuddy.conversations.list(),
window.goodbuddy.tasks.list(),
window.goodbuddy.schedules.list()
])
if (conversationResult.status === 'fulfilled') {
setConversations((current) =>
mergePersistedConversations(
current,
conversationResult.value,
persistedLocalConversationsRef.current
)
)
} else {
notify({
tone: 'error',
message: t('notices.remoteConversationRefreshFailed'),
dedupeKey: 'custom-task-conversation-refresh'
})
}
if (taskResult.status === 'fulfilled') {
setAssistantTasks(taskResult.value)
}
if (scheduleResult.status === 'fulfilled') {
setAssistantSchedules(scheduleResult.value)
}
if (
taskResult.status === 'rejected' ||
scheduleResult.status === 'rejected'
) {
notify({
tone: 'error',
message: t('notices.taskHistoryReadFailed'),
dedupeKey: 'custom-task-discovery-refresh'
})
}
if (schedule.projectId) {
const project = projects.find(
(candidate) => candidate.id === schedule.projectId
)
setActiveProjectId(schedule.projectId)
if (project) {
setWorkMode(
normalizeInteractiveWorkMode(project.defaultWorkMode)
)
}
}
setActiveId(schedule.conversationId)
setView('chat')
return schedule
}
const runAssistantSchedule = async (
scheduleId: string
): Promise<void> => {
await window.goodbuddy.schedules.runNow(scheduleId)
notify({
tone: 'success',
message: t('notices.scheduleStarted')
})
}
const setAssistantScheduleEnabled = async (
scheduleId: string,
enabled: boolean
): Promise<void> => {
await window.goodbuddy.schedules.setEnabled(scheduleId, enabled)
setAssistantSchedules((current) =>
current.map((schedule) =>
schedule.id === scheduleId
? { ...schedule, enabled }
: schedule
)
)
}
const removeAssistantSchedule = async (
scheduleId: string
): Promise<void> => {
await window.goodbuddy.schedules.remove(scheduleId)
setAssistantSchedules((current) =>
current.filter((schedule) => schedule.id !== scheduleId)
)
}
const clearLocalData = async (): Promise<void> => {
conversationPersistencePausedRef.current = true
try {
@@ -6432,7 +6877,16 @@ function App(): React.JSX.Element {
<div className="conversation-list">
<p className="section-label">{t('sidebar.recent')}</p>
{filteredConversations.map((conversation) => (
{filteredConversations.map((conversation) => {
const conversationTasks =
tasksByConversation.get(conversation.id) ?? []
const tasksExpanded =
expandedTaskConversationIds.has(conversation.id)
const conversationTitle = getConversationDisplayTitle(
conversation,
t('conversation.defaultTitle')
)
return (
<div className="conversation-entry" key={conversation.id}>
<div
className={
@@ -6441,6 +6895,34 @@ function App(): React.JSX.Element {
: 'conversation-row'
}
>
{conversationTasks.length > 0 && (
<button
aria-expanded={tasksExpanded}
aria-label={t('conversation.tasks.toggle', {
title: conversationTitle,
count: conversationTasks.length
})}
className="conversation-task-toggle"
onClick={() =>
setExpandedTaskConversationIds((current) => {
const next = new Set(current)
if (next.has(conversation.id)) {
next.delete(conversation.id)
} else {
next.add(conversation.id)
}
return next
})
}
type="button"
>
{tasksExpanded ? (
<ChevronDown aria-hidden="true" size={13} />
) : (
<ChevronRight aria-hidden="true" size={13} />
)}
</button>
)}
<button
className={
conversation.id === activeId
@@ -6450,6 +6932,7 @@ function App(): React.JSX.Element {
type="button"
onClick={() => {
setConversationActionsId('')
setSelectedAssistantTaskId(undefined)
setActiveId(conversation.id)
setUnreadConversationIds((current) => {
if (!current.has(conversation.id)) {
@@ -6462,7 +6945,7 @@ function App(): React.JSX.Element {
setView('chat')
}}
>
<span>
<span className="conversation-item__primary">
{conversation.remote && (
<b className="conversation-source-badge">
{
@@ -6472,10 +6955,10 @@ function App(): React.JSX.Element {
}
</b>
)}
{getConversationDisplayTitle(
conversation,
t('conversation.defaultTitle')
)}
<OverflowMarquee
className="conversation-item__title"
text={conversationTitle}
/>
{unreadConversationIds.has(conversation.id) && (
<i
aria-label={t('conversation.unread')}
@@ -6515,10 +6998,7 @@ function App(): React.JSX.Element {
conversationActionsId === conversation.id
}
aria-label={t('conversation.actions.more', {
title: getConversationDisplayTitle(
conversation,
t('conversation.defaultTitle')
)
title: conversationTitle
})}
className="conversation-more"
onClick={() => {
@@ -6548,10 +7028,7 @@ function App(): React.JSX.Element {
{conversationActionsId === conversation.id && (
<div
aria-label={t('conversation.actions.region', {
title: getConversationDisplayTitle(
conversation,
t('conversation.defaultTitle')
)
title: conversationTitle
})}
className="conversation-actions"
id={`conversation-actions-${conversation.id}`}
@@ -6594,16 +7071,10 @@ function App(): React.JSX.Element {
{!conversation.remote && (
<DestructiveConfirmActions
cancelAriaLabel={t('conversation.delete.cancelAria', {
title: getConversationDisplayTitle(
conversation,
t('conversation.defaultTitle')
)
title: conversationTitle
})}
confirmAriaLabel={t('conversation.delete.confirmAria', {
title: getConversationDisplayTitle(
conversation,
t('conversation.defaultTitle')
)
title: conversationTitle
})}
confirmLabel={t('conversation.delete.confirm')}
confirming={
@@ -6622,10 +7093,7 @@ function App(): React.JSX.Element {
setConfirmingConversationId(conversation.id)
}
triggerAriaLabel={t('conversation.delete.triggerAria', {
title: getConversationDisplayTitle(
conversation,
t('conversation.defaultTitle')
)
title: conversationTitle
})}
triggerLabel={t('conversation.delete.trigger')}
/>
@@ -6647,10 +7115,7 @@ function App(): React.JSX.Element {
>
<input
aria-label={t('conversation.renameAria', {
title: getConversationDisplayTitle(
conversation,
t('conversation.defaultTitle')
)
title: conversationTitle
})}
autoFocus
defaultValue={conversation.title}
@@ -6683,8 +7148,73 @@ function App(): React.JSX.Element {
</button>
</form>
)}
{tasksExpanded && conversationTasks.length > 0 && (
<ul
aria-label={t('conversation.tasks.list', {
title: conversationTitle
})}
className="conversation-task-children"
>
{conversationTasks.slice(0, 3).map((task) => {
const schedule = findTaskSchedule(
task,
assistantSchedules
)
return (
<li key={task.id}>
<button
className={
selectedAssistantTaskId === task.id
? 'conversation-task-child conversation-task-child--active'
: 'conversation-task-child'
}
onClick={() => openAssistantTask(task)}
type="button"
>
<ListTodo
aria-hidden="true"
className={`conversation-task-child__icon conversation-task-child__icon--${task.status}`}
size={13}
/>
<span className="conversation-task-child__title">
{task.title}
</span>
<small className="conversation-task-child__meta">
{schedule
? `${tWorkspace(`task.mode.${schedule.workMode}`)} · ${tWorkspace(
`sidebar.tasks.schedule.recurrence.${schedule.recurrence}`
)} · `
: ''}
{tWorkspace(`task.status.${task.status}`)}
</small>
</button>
</li>
)
})}
{conversationTasks.length > 3 && (
<li>
<button
className="conversation-task-view-all"
onClick={() => {
setSelectedAssistantTaskId(
conversationTasks[0]?.id
)
setActiveId(conversation.id)
setView('chat')
}}
type="button"
>
{t('conversation.tasks.viewAll', {
count: conversationTasks.length
})}
</button>
</li>
)}
</ul>
)}
</div>
))}
)
})}
{filteredConversations.length === 0 && (
<p className="conversation-empty">
{activeProject?.kind === 'channel' &&
@@ -6883,6 +7413,35 @@ function App(): React.JSX.Element {
scrollSnapshot={
chatScrollSnapshots[conversation.id]
}
taskStrip={
!conversation.remote ? (
<ConversationTaskStrip
locale={locale}
onCreate={() =>
openCustomTaskDialog('current')
}
onRemoveSchedule={removeAssistantSchedule}
onRunSchedule={runAssistantSchedule}
onSelectTask={setSelectedAssistantTaskId}
onSetScheduleEnabled={
setAssistantScheduleEnabled
}
schedules={assistantSchedules}
selectedTaskId={
(tasksByConversation.get(conversation.id) ?? [])
.some(
(task) =>
task.id === selectedAssistantTaskId
)
? selectedAssistantTaskId
: undefined
}
tasks={
tasksByConversation.get(conversation.id) ?? []
}
/>
) : undefined
}
visibleMessageCount={
visibleMessageCounts[conversation.id] ??
messageRenderBatchSize
@@ -8348,14 +8907,41 @@ function App(): React.JSX.Element {
</section>
</div>
)}
{customTaskDialog && activeProject?.kind === 'user' && (
<CustomTaskDialog
currentConversationAvailable={Boolean(
activeConversation &&
!activeConversation.remote &&
activeConversation.projectId === activeProject.id
)}
currentConversationId={activeConversation?.id}
defaultDestination={customTaskDialog.defaultDestination}
onClose={() => setCustomTaskDialog(undefined)}
onCreate={createCustomTask}
projectId={activeProject.id}
projectName={activeProject.name}
runtimeLabel={activeRuntimeLabel}
supportsToolExecution={Boolean(
runtime?.supportsToolExecution
)}
workspaceLabel={
activeProject.rootPath || t('customTask.scope.noWorkspace')
}
/>
)}
<RightAssistantSidebar
approvals={pendingSidebarApprovals}
artifacts={sidebarArtifacts}
attachments={attachments}
browserState={browserStates[activeId]}
conversationTitles={conversationTitles}
enabledLibraries={enabledSidebarLibraries}
memories={assistantMemories}
onCreateCustomTask={() => openCustomTaskDialog('new')}
schedules={assistantSchedules}
selectedTaskId={selectedAssistantTaskId}
tasks={productAssistantTasks}
projectNames={projectNames}
onClose={() => setAssistantSidebarOpen(false)}
onInteractBrowser={async () => {
if (!activeId) {
@@ -8392,13 +8978,6 @@ function App(): React.JSX.Element {
})
}
}}
onCreateSchedule={async (input) => {
const schedule = await window.goodbuddy.schedules.create({
...input,
projectId: activeProjectId || undefined
})
setAssistantSchedules((current) => [schedule, ...current])
}}
onImportArtifacts={async () => {
const imported = await window.goodbuddy.artifacts.importFiles(
activeProjectId || undefined
@@ -8423,12 +9002,8 @@ function App(): React.JSX.Element {
)
}}
onRemoveAttachment={removeAttachment}
onRemoveSchedule={async (scheduleId) => {
await window.goodbuddy.schedules.remove(scheduleId)
setAssistantSchedules((current) =>
current.filter((schedule) => schedule.id !== scheduleId)
)
}}
onOpenTask={openAssistantTask}
onRemoveSchedule={removeAssistantSchedule}
onRespondApproval={(approval, decision) => {
void respondToApproval(
approval.conversationId,
@@ -8437,13 +9012,8 @@ function App(): React.JSX.Element {
decision
)
}}
onRunSchedule={async (scheduleId) => {
await window.goodbuddy.schedules.runNow(scheduleId)
notify({
tone: 'success',
message: t('notices.scheduleStarted')
})
}}
onRunSchedule={runAssistantSchedule}
onSetScheduleEnabled={setAssistantScheduleEnabled}
onListWorkspaceDirectory={listWorkspaceDirectory}
onLoadWorkspaceFile={loadWorkspaceFile}
onOpenWorkspaceEntry={openWorkspaceEntry}
+35
View File
@@ -160,6 +160,41 @@ describe('ChatTimeline', () => {
).toBeInTheDocument()
})
it('labels scheduled result messages with Task provenance', () => {
const messages: Message[] = [
{
id: 'scheduled-result',
role: 'assistant',
content: '今日状态正常',
task: {
id: '00000000-0000-4000-8000-000000000831',
title: '每日状态'
},
createdAt: 1_775_000_000_000,
state: 'complete'
}
]
render(
<ChatTimeline
artifactById={new Map()}
conversationId="conversation-1"
hiddenMessageCount={0}
isUnusedConversation={false}
locale="zh-CN"
messageStartIndex={0}
messages={messages}
{...callbacks}
retryContent=""
totalMessageCount={messages.length}
/>
)
expect(
screen.getByLabelText('任务结果:每日状态')
).toHaveTextContent('每日状态')
})
it('shows every parallel expert output in its own expandable card', () => {
const messages: Message[] = [
{
+13
View File
@@ -3,6 +3,7 @@ import {
Download,
FileText,
Library,
ListTodo,
ShieldCheck,
TerminalSquare,
UserRound
@@ -64,6 +65,7 @@ export type Message = {
sourceReferences?: KnowledgeSearchReference[]
knowledgeRetrieval?: KnowledgeRetrievalStatus
artifactIds?: string[]
task?: ConversationMessage['task']
attachments?: ConversationAttachment[]
}
@@ -374,6 +376,17 @@ function ChatMessageRowView({
<strong>
{message.role === 'assistant' ? 'GoodBuddy' : t('chat.user')}
</strong>
{message.task && (
<span
aria-label={t('chat.taskResult', {
title: message.task.title
})}
className="message__task"
>
<ListTodo aria-hidden="true" size={12} />
{message.task.title}
</span>
)}
<span>{formatTime(message.createdAt, locale)}</span>
</div>
{message.attachments && message.attachments.length > 0 && (
@@ -0,0 +1,90 @@
import {
cleanup,
fireEvent,
render,
screen,
waitFor
} from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ConversationTaskStrip } from './ConversationTaskStrip'
afterEach(cleanup)
describe('ConversationTaskStrip', () => {
it('shows Task details and controls the stable schedule', async () => {
const taskId = '00000000-0000-4000-8000-000000000811'
const scheduleId =
'00000000-0000-4000-8000-000000000812'
const conversationId =
'00000000-0000-4000-8000-000000000813'
const onRunSchedule = vi.fn(async () => undefined)
const onSetScheduleEnabled = vi.fn(async () => undefined)
const onRemoveSchedule = vi.fn(async () => undefined)
render(
<ConversationTaskStrip
locale="zh-CN"
onCreate={vi.fn()}
onRemoveSchedule={onRemoveSchedule}
onRunSchedule={onRunSchedule}
onSelectTask={vi.fn()}
onSetScheduleEnabled={onSetScheduleEnabled}
schedules={[
{
id: scheduleId,
taskId,
conversationId,
title: '每日状态',
prompt: '汇总状态',
workMode: 'execute',
recurrence: 'daily',
nextRunAt: '2026-08-20T09:00:00.000Z',
enabled: true,
createdAt: '2026-08-19T00:00:00.000Z',
updatedAt: '2026-08-19T00:00:00.000Z'
}
]}
selectedTaskId={taskId}
tasks={[
{
id: taskId,
conversationId,
scheduleId,
title: '每日状态',
instructions: '汇总状态',
origin: 'schedule',
status: 'idle',
createdAt: '2026-08-19T00:00:00.000Z'
}
]}
/>
)
expect(screen.getByText('每日状态', { selector: 'strong' }))
.toBeInTheDocument()
expect(screen.getByText('Execute')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '立即运行' }))
await waitFor(() =>
expect(onRunSchedule).toHaveBeenCalledWith(scheduleId)
)
fireEvent.click(screen.getByRole('button', { name: '暂停' }))
await waitFor(() =>
expect(onSetScheduleEnabled).toHaveBeenCalledWith(
scheduleId,
false
)
)
fireEvent.click(
screen.getByRole('button', { name: '删除计划' })
)
expect(onRemoveSchedule).not.toHaveBeenCalled()
fireEvent.click(
screen.getByRole('button', {
name: '确认删除“每日状态”的计划'
})
)
await waitFor(() =>
expect(onRemoveSchedule).toHaveBeenCalledWith(scheduleId)
)
})
})
+218
View File
@@ -0,0 +1,218 @@
import {
ChevronDown,
ChevronUp,
CircleAlert,
ListTodo,
Plus
} from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type {
AssistantSchedule,
AssistantTask
} from '../../shared/assistant-contracts'
import {
findTaskSchedule,
TaskScheduleActions
} from './TaskScheduleActions'
type ConversationTaskStripProps = {
locale: string
onCreate: () => void
onRemoveSchedule: (scheduleId: string) => Promise<void>
onRunSchedule: (scheduleId: string) => Promise<void>
onSelectTask: (taskId: string) => void
onSetScheduleEnabled: (
scheduleId: string,
enabled: boolean
) => Promise<void>
schedules: AssistantSchedule[]
selectedTaskId?: string
tasks: AssistantTask[]
}
export function ConversationTaskStrip({
locale,
onCreate,
onRemoveSchedule,
onRunSchedule,
onSelectTask,
onSetScheduleEnabled,
schedules,
selectedTaskId,
tasks
}: ConversationTaskStripProps): React.JSX.Element {
const { t } = useTranslation('workspace')
const [manuallyExpanded, setManuallyExpanded] = useState(false)
const [collapsedTaskId, setCollapsedTaskId] = useState('')
const [actionError, setActionError] = useState('')
const selectedTask = useMemo(
() =>
tasks.find((task) => task.id === selectedTaskId) ?? tasks[0],
[selectedTaskId, tasks]
)
const selectedSchedule = selectedTask
? findTaskSchedule(selectedTask, schedules)
: undefined
const expanded =
manuallyExpanded ||
(Boolean(selectedTaskId) && collapsedTaskId !== selectedTaskId)
return (
<section
aria-label={t('taskStrip.ariaLabel')}
className="conversation-task-strip"
>
<header className="conversation-task-strip__header">
<button
aria-expanded={expanded}
className="conversation-task-strip__toggle"
onClick={() => {
if (expanded) {
setManuallyExpanded(false)
setCollapsedTaskId(selectedTaskId ?? '')
} else {
setManuallyExpanded(true)
setCollapsedTaskId('')
}
}}
type="button"
>
<ListTodo aria-hidden="true" size={15} />
<strong>{t('taskStrip.title')}</strong>
<span>
{t('taskStrip.count', {
count: tasks.length
})}
</span>
{expanded ? (
<ChevronUp aria-hidden="true" size={14} />
) : (
<ChevronDown aria-hidden="true" size={14} />
)}
</button>
<button
className="secondary-button conversation-task-strip__create"
onClick={onCreate}
type="button"
>
<Plus aria-hidden="true" size={13} />
{t('taskStrip.create')}
</button>
</header>
{expanded && (
<div className="conversation-task-strip__content">
{tasks.length === 0 ? (
<p className="conversation-task-strip__empty">
{t('taskStrip.empty')}
</p>
) : (
<>
<div
aria-label={t('taskStrip.taskList')}
className="conversation-task-strip__list"
>
{tasks.map((task) => (
<button
aria-pressed={selectedTask?.id === task.id}
className={
selectedTask?.id === task.id
? 'conversation-task-strip__task conversation-task-strip__task--active'
: 'conversation-task-strip__task'
}
key={task.id}
onClick={() => onSelectTask(task.id)}
type="button"
>
<span
aria-hidden="true"
className={`task-status-dot task-status-dot--${task.status}`}
/>
<span>{task.title}</span>
</button>
))}
</div>
{selectedTask && (
<article className="conversation-task-details">
<header>
<span>
<strong>{selectedTask.title}</strong>
<small>
{t(`task.status.${selectedTask.status}`)}
</small>
</span>
{selectedTask.status === 'failed' && (
<CircleAlert aria-hidden="true" size={15} />
)}
</header>
<dl>
<div>
<dt>{t('task.fields.mode')}</dt>
<dd>
{selectedSchedule
? t(`task.mode.${selectedSchedule.workMode}`)
: selectedTask.workMode
? t(`task.mode.${selectedTask.workMode}`)
: t('task.mode.unavailable')}
</dd>
</div>
<div>
<dt>{t('task.fields.schedule')}</dt>
<dd>
{selectedSchedule
? t(
`sidebar.tasks.schedule.recurrence.${selectedSchedule.recurrence}`
)
: t('task.schedule.none')}
</dd>
</div>
<div>
<dt>{t('task.fields.nextRun')}</dt>
<dd>
{selectedSchedule
? new Date(
selectedSchedule.nextRunAt
).toLocaleString(locale)
: t('task.notAvailable')}
</dd>
</div>
<div>
<dt>{t('task.fields.outcome')}</dt>
<dd>
{selectedTask.error ??
(selectedTask.completedAt
? t('task.completedAt', {
time: new Date(
selectedTask.completedAt
).toLocaleString(locale)
})
: t('task.noOutcome'))}
</dd>
</div>
</dl>
{selectedSchedule && (
<div className="conversation-task-details__actions">
<TaskScheduleActions
onError={setActionError}
onRemoveSchedule={onRemoveSchedule}
onRunSchedule={onRunSchedule}
onSetScheduleEnabled={onSetScheduleEnabled}
schedule={selectedSchedule}
taskTitle={selectedTask.title}
/>
</div>
)}
</article>
)}
</>
)}
{actionError && (
<p className="conversation-task-strip__error" role="alert">
{actionError}
</p>
)}
</div>
)}
</section>
)
}
+127
View File
@@ -0,0 +1,127 @@
import {
cleanup,
fireEvent,
render,
screen,
waitFor
} from '@testing-library/react'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { CustomTaskDialog } from './CustomTaskDialog'
const stylesheet = readFileSync(
join(process.cwd(), 'src', 'renderer', 'src', 'styles.css'),
'utf8'
)
afterEach(cleanup)
function futureLocalDateTime(): string {
const date = new Date(Date.now() + 24 * 60 * 60 * 1_000)
const local = new Date(
date.getTime() - date.getTimezoneOffset() * 60_000
)
return local.toISOString().slice(0, 16)
}
describe('CustomTaskDialog', () => {
it('keeps dialog spacing and form controls inside their layout bounds', () => {
expect(stylesheet).toMatch(/--space-5:\s*20px;/u)
expect(stylesheet).toMatch(
/\.custom-task-dialog,\s*\.custom-task-dialog \*,\s*\.custom-task-dialog \*::before,\s*\.custom-task-dialog \*::after\s*\{[^}]*box-sizing:\s*border-box;/u
)
expect(stylesheet).toMatch(
/\.custom-task-dialog__content\s*\{[^}]*padding:\s*var\(--space-5\);[^}]*overflow-y:\s*auto;[^}]*scrollbar-gutter:\s*stable;/u
)
expect(stylesheet).toMatch(
/@media \(max-height:\s*720px\)\s*\{[\s\S]*?\.custom-task-dialog__actions\s*\{[^}]*padding:\s*var\(--space-3\) var\(--space-5\);/u
)
})
it('defaults to Execute and keeps the current Conversation association', async () => {
const conversationId =
'00000000-0000-4000-8000-000000000801'
const onCreate = vi.fn(async (input) => ({
...input,
id: '00000000-0000-4000-8000-000000000802',
taskId: '00000000-0000-4000-8000-000000000803',
conversationId,
enabled: true,
createdAt: '2026-08-19T00:00:00.000Z',
updatedAt: '2026-08-19T00:00:00.000Z'
}))
render(
<CustomTaskDialog
currentConversationAvailable
currentConversationId={conversationId}
defaultDestination="current"
onClose={vi.fn()}
onCreate={onCreate}
projectId="00000000-0000-4000-8000-000000000804"
projectName="GoodBuddy Desktop"
runtimeLabel="OpenCode"
supportsToolExecution
workspaceLabel="C:\\Workspace"
/>
)
expect(
screen.getByRole('dialog', { name: '新建定制任务' })
).toHaveAttribute('aria-modal', 'true')
expect(screen.getByRole('button', { name: 'Execute' })).toHaveAttribute(
'aria-pressed',
'true'
)
expect(screen.getByRole('button', { name: '当前会话' })).toHaveAttribute(
'aria-pressed',
'true'
)
expect(screen.getByLabelText('任务名称')).toHaveFocus()
fireEvent.change(screen.getByLabelText('任务名称'), {
target: { value: '每周项目总结' }
})
fireEvent.change(screen.getByLabelText('任务要求'), {
target: { value: '总结完成和失败的工作' }
})
fireEvent.change(screen.getByLabelText('首次运行'), {
target: { value: futureLocalDateTime() }
})
fireEvent.click(screen.getByRole('button', { name: '创建任务' }))
await waitFor(() => expect(onCreate).toHaveBeenCalledOnce())
expect(onCreate).toHaveBeenCalledWith(
expect.objectContaining({
conversationId,
title: '每周项目总结',
prompt: '总结完成和失败的工作',
workMode: 'execute',
recurrence: 'once'
})
)
})
it('uses Ask when the selected Runtime cannot execute tools', () => {
render(
<CustomTaskDialog
currentConversationAvailable={false}
defaultDestination="new"
onClose={vi.fn()}
onCreate={vi.fn()}
projectName="GoodBuddy Desktop"
runtimeLabel="Direct model"
supportsToolExecution={false}
workspaceLabel="C:\\Workspace"
/>
)
expect(screen.getByRole('button', { name: 'Execute' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Ask' })).toHaveAttribute(
'aria-pressed',
'true'
)
expect(screen.getByRole('button', { name: '当前会话' })).toBeDisabled()
})
})
+379
View File
@@ -0,0 +1,379 @@
import { CalendarClock, FolderKanban, ShieldCheck, X } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import type {
AssistantSchedule,
ScheduleCreateInput
} from '../../shared/assistant-contracts'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import { SegmentedControl } from './WorkspacePrimitives'
export type CustomTaskDestination = 'current' | 'new'
type CustomTaskDialogProps = {
currentConversationAvailable: boolean
currentConversationId?: string
defaultDestination: CustomTaskDestination
projectId?: string
projectName: string
runtimeLabel: string
workspaceLabel: string
supportsToolExecution: boolean
onClose: () => void
onCreate: (input: ScheduleCreateInput) => Promise<AssistantSchedule>
}
function toLocalDateTimeValue(date: Date): string {
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000)
return local.toISOString().slice(0, 16)
}
export function CustomTaskDialog({
currentConversationAvailable,
currentConversationId,
defaultDestination,
projectId,
projectName,
runtimeLabel,
workspaceLabel,
supportsToolExecution,
onClose,
onCreate
}: CustomTaskDialogProps): React.JSX.Element {
const { t } = useTranslation('app')
const dialogRef = useRef<HTMLElement>(null)
const titleRef = useRef<HTMLInputElement>(null)
const [title, setTitle] = useState('')
const [prompt, setPrompt] = useState('')
const [destination, setDestination] = useState<CustomTaskDestination>(
defaultDestination === 'current' && currentConversationAvailable
? 'current'
: 'new'
)
const [workMode, setWorkMode] =
useState<ScheduleCreateInput['workMode']>(
supportsToolExecution ? 'execute' : 'ask'
)
const [recurrence, setRecurrence] =
useState<ScheduleCreateInput['recurrence']>('once')
const [nextRunAt, setNextRunAt] = useState(() =>
toLocalDateTimeValue(new Date(Date.now() + 60 * 60 * 1_000))
)
const [errors, setErrors] = useState<
Partial<Record<'title' | 'prompt' | 'destination' | 'nextRunAt' | 'form', string>>
>({})
const [submitting, setSubmitting] = useState(false)
useEffect(
() => activateModalFocus(() => titleRef.current),
[]
)
const destinationOptions = useMemo(
() => [
{
value: 'current' as const,
label: t('customTask.destination.current'),
disabled: !currentConversationAvailable
},
{
value: 'new' as const,
label: t('customTask.destination.new')
}
],
[currentConversationAvailable, t]
)
const submit = async (): Promise<void> => {
const nextErrors: typeof errors = {}
if (!title.trim()) {
nextErrors.title = t('customTask.errors.title')
}
if (!prompt.trim()) {
nextErrors.prompt = t('customTask.errors.instructions')
}
if (
destination === 'current' &&
(!currentConversationAvailable || !currentConversationId)
) {
nextErrors.destination = t('customTask.errors.destination')
}
const runAt = new Date(nextRunAt)
if (!nextRunAt || Number.isNaN(runAt.getTime())) {
nextErrors.nextRunAt = t('customTask.errors.time')
} else if (runAt.getTime() <= Date.now()) {
nextErrors.nextRunAt = t('customTask.errors.futureTime')
}
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) {
return
}
setSubmitting(true)
try {
await onCreate({
...(projectId ? { projectId } : {}),
...(destination === 'current' && currentConversationId
? { conversationId: currentConversationId }
: {}),
title: title.trim(),
prompt: prompt.trim(),
workMode,
recurrence,
nextRunAt: runAt.toISOString()
})
onClose()
} catch (reason) {
setErrors((current) => ({
...current,
form:
reason instanceof Error
? reason.message
: t('customTask.errors.create')
}))
} finally {
setSubmitting(false)
}
}
return createPortal(
<div
className="custom-task-dialog"
onMouseDown={(event) => {
if (event.target === event.currentTarget && !submitting) {
onClose()
}
}}
>
<section
aria-describedby="custom-task-description"
aria-labelledby="custom-task-title"
aria-modal="true"
className="custom-task-dialog__surface"
onKeyDown={(event) => {
if (event.key === 'Escape' && !submitting) {
event.preventDefault()
onClose()
} else {
trapTabFocus(event, dialogRef.current)
}
}}
ref={dialogRef}
role="dialog"
tabIndex={-1}
>
<header className="custom-task-dialog__header">
<div>
<span className="custom-task-dialog__eyebrow">
<CalendarClock aria-hidden="true" size={14} />
{t('customTask.eyebrow')}
</span>
<h2 id="custom-task-title">{t('customTask.title')}</h2>
<p id="custom-task-description">
{t('customTask.description')}
</p>
</div>
<button
aria-label={t('customTask.close')}
className="icon-button"
disabled={submitting}
onClick={onClose}
type="button"
>
<X aria-hidden="true" size={17} />
</button>
</header>
<div className="custom-task-dialog__content">
<label className="custom-task-dialog__field">
<span>{t('customTask.fields.name')}</span>
<input
aria-describedby={errors.title ? 'custom-task-title-error' : undefined}
aria-invalid={Boolean(errors.title)}
maxLength={120}
onChange={(event) => {
setTitle(event.target.value)
setErrors((current) => ({ ...current, title: undefined }))
}}
ref={titleRef}
value={title}
/>
{errors.title && (
<small id="custom-task-title-error" role="alert">
{errors.title}
</small>
)}
</label>
<label className="custom-task-dialog__field">
<span>{t('customTask.fields.instructions')}</span>
<textarea
aria-describedby={errors.prompt ? 'custom-task-prompt-error' : undefined}
aria-invalid={Boolean(errors.prompt)}
maxLength={100_000}
onChange={(event) => {
setPrompt(event.target.value)
setErrors((current) => ({ ...current, prompt: undefined }))
}}
rows={5}
value={prompt}
/>
{errors.prompt && (
<small id="custom-task-prompt-error" role="alert">
{errors.prompt}
</small>
)}
</label>
<div className="custom-task-dialog__choice">
<span>{t('customTask.fields.destination')}</span>
<SegmentedControl
ariaLabel={t('customTask.fields.destination')}
onChange={(value) => {
setDestination(value)
setErrors((current) => ({
...current,
destination: undefined
}))
}}
options={destinationOptions}
value={destination}
/>
<small>
{destination === 'current'
? t('customTask.destination.currentHelp')
: t('customTask.destination.newHelp')}
</small>
{!currentConversationAvailable && (
<small>{t('customTask.destination.currentUnavailable')}</small>
)}
{errors.destination && (
<small role="alert">{errors.destination}</small>
)}
</div>
<div className="custom-task-dialog__two-columns">
<div className="custom-task-dialog__choice">
<span>{t('customTask.fields.mode')}</span>
<SegmentedControl
ariaLabel={t('customTask.fields.mode')}
onChange={setWorkMode}
options={[
{
value: 'execute',
label: t('customTask.mode.execute'),
disabled: !supportsToolExecution
},
{ value: 'ask', label: t('customTask.mode.ask') }
]}
value={workMode}
/>
{!supportsToolExecution && (
<small>{t('customTask.mode.executeUnavailable')}</small>
)}
</div>
<label className="custom-task-dialog__field">
<span>{t('customTask.fields.recurrence')}</span>
<select
onChange={(event) =>
setRecurrence(
event.target.value as ScheduleCreateInput['recurrence']
)
}
value={recurrence}
>
<option value="once">{t('customTask.recurrence.once')}</option>
<option value="daily">{t('customTask.recurrence.daily')}</option>
<option value="weekly">{t('customTask.recurrence.weekly')}</option>
</select>
</label>
</div>
<label className="custom-task-dialog__field">
<span>{t('customTask.fields.time')}</span>
<input
aria-describedby={errors.nextRunAt ? 'custom-task-time-error' : undefined}
aria-invalid={Boolean(errors.nextRunAt)}
onChange={(event) => {
setNextRunAt(event.target.value)
setErrors((current) => ({
...current,
nextRunAt: undefined
}))
}}
type="datetime-local"
value={nextRunAt}
/>
{errors.nextRunAt && (
<small id="custom-task-time-error" role="alert">
{errors.nextRunAt}
</small>
)}
</label>
<section
aria-label={t('customTask.scope.title')}
className="custom-task-dialog__scope"
>
<header>
<FolderKanban aria-hidden="true" size={16} />
<strong>{t('customTask.scope.title')}</strong>
</header>
<dl>
<div>
<dt>{t('customTask.scope.project')}</dt>
<dd>{projectName}</dd>
</div>
<div>
<dt>{t('customTask.scope.runtime')}</dt>
<dd>{runtimeLabel}</dd>
</div>
<div>
<dt>{t('customTask.scope.workspace')}</dt>
<dd>{workspaceLabel}</dd>
</div>
<div>
<dt>{t('customTask.scope.tools')}</dt>
<dd>
<ShieldCheck aria-hidden="true" size={13} />
{workMode === 'execute'
? t('customTask.scope.executeApproval')
: t('customTask.scope.askReadOnly')}
</dd>
</div>
</dl>
</section>
{errors.form && (
<p className="custom-task-dialog__form-error" role="alert">
{errors.form}
</p>
)}
</div>
<footer className="custom-task-dialog__actions">
<button
className="secondary-button"
disabled={submitting}
onClick={onClose}
type="button"
>
{t('customTask.cancel')}
</button>
<button
className="primary-button"
disabled={submitting}
onClick={() => void submit()}
type="button"
>
{submitting
? t('customTask.creating')
: t('customTask.create')}
</button>
</footer>
</section>
</div>,
document.body
)
}
+1
View File
@@ -155,6 +155,7 @@ export function HeartbeatCenter({
}
const taskStatusLabels: Record<AssistantTask['status'], string> = {
queued: t('statuses.task.queued'),
idle: t('statuses.task.idle'),
running: t('statuses.task.running'),
waiting_approval: t('statuses.task.waitingApproval'),
paused: t('statuses.task.paused'),
+93
View File
@@ -0,0 +1,93 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { OverflowMarquee } from './OverflowMarquee'
const stylesheet = readFileSync(
join(process.cwd(), 'src', 'renderer', 'src', 'styles.css'),
'utf8'
).replaceAll('\r\n', '\n')
function setMeasuredWidth(
element: HTMLElement,
property: 'clientWidth' | 'scrollWidth',
value: number
): void {
Object.defineProperty(element, property, {
configurable: true,
value
})
}
describe('OverflowMarquee', () => {
afterEach(() => cleanup())
it('enables sliding only when the text exceeds its container', () => {
const text = '这是一个明显超过会话列表宽度的完整会话名称'
render(<OverflowMarquee text={text} />)
const container = screen.getByTitle(text)
const track = container.querySelector<HTMLElement>(
'.overflow-marquee__track'
)
expect(track).not.toBeNull()
setMeasuredWidth(container, 'clientWidth', 120)
setMeasuredWidth(track!, 'scrollWidth', 280)
fireEvent.mouseEnter(container)
expect(container).toHaveAttribute('data-overflowing', 'true')
expect(
container.style.getPropertyValue('--overflow-marquee-distance')
).toBe('160px')
expect(
container.style.getPropertyValue('--overflow-marquee-duration')
).toBe('2500ms')
setMeasuredWidth(track!, 'scrollWidth', 100)
fireEvent.mouseEnter(container)
expect(container).not.toHaveAttribute('data-overflowing')
expect(
container.style.getPropertyValue('--overflow-marquee-distance')
).toBe('')
})
it('keeps one readable text copy while its visual track moves', () => {
render(<OverflowMarquee text="完整会话名称" />)
const container = screen.getByTitle('完整会话名称')
expect(container).toHaveTextContent('完整会话名称')
expect(
container.querySelector('.overflow-marquee__track')
).toHaveTextContent('完整会话名称')
expect(container.querySelector('.sr-only')).not.toBeInTheDocument()
})
it('slides on row hover and removes displacement for reduced motion', () => {
const rowHoverIndex = stylesheet.indexOf('.conversation-row:hover')
const hoverTransformIndex = stylesheet.indexOf(
'calc(-1 * var(--overflow-marquee-distance))',
rowHoverIndex
)
expect(rowHoverIndex).toBeGreaterThan(-1)
expect(hoverTransformIndex).toBeGreaterThan(rowHoverIndex)
const reducedMotionIndex = stylesheet.indexOf(
'@media (prefers-reduced-motion: reduce)'
)
const reducedMotionHoverIndex = stylesheet.indexOf(
'.conversation-row:hover',
reducedMotionIndex
)
const reducedMotionResetIndex = stylesheet.indexOf(
'transform: none;',
reducedMotionHoverIndex
)
expect(reducedMotionHoverIndex).toBeGreaterThan(reducedMotionIndex)
expect(reducedMotionResetIndex).toBeGreaterThan(
reducedMotionHoverIndex
)
})
})
+95
View File
@@ -0,0 +1,95 @@
import { useCallback, useLayoutEffect, useRef } from 'react'
interface OverflowMarqueeProps {
className?: string
text: string
}
const marqueePixelsPerSecond = 64
const minimumMarqueeDurationMs = 1_200
const maximumMarqueeDurationMs = 8_000
export function OverflowMarquee({
className,
text
}: OverflowMarqueeProps): React.JSX.Element {
const containerRef = useRef<HTMLSpanElement>(null)
const trackRef = useRef<HTMLSpanElement>(null)
const measureOverflow = useCallback((): void => {
const container = containerRef.current
const track = trackRef.current
if (!container || !track) {
return
}
const overflowDistance = Math.ceil(
track.scrollWidth - container.clientWidth
)
if (overflowDistance <= 1) {
delete container.dataset.overflowing
container.style.removeProperty('--overflow-marquee-distance')
container.style.removeProperty('--overflow-marquee-duration')
return
}
const duration = Math.min(
maximumMarqueeDurationMs,
Math.max(
minimumMarqueeDurationMs,
Math.round(
(overflowDistance / marqueePixelsPerSecond) * 1_000
)
)
)
container.dataset.overflowing = 'true'
container.style.setProperty(
'--overflow-marquee-distance',
`${overflowDistance}px`
)
container.style.setProperty(
'--overflow-marquee-duration',
`${duration}ms`
)
}, [])
useLayoutEffect(() => {
measureOverflow()
const container = containerRef.current
const track = trackRef.current
if (
!container ||
!track ||
typeof ResizeObserver !== 'function'
) {
window.addEventListener('resize', measureOverflow)
return () =>
window.removeEventListener('resize', measureOverflow)
}
const observer = new ResizeObserver(measureOverflow)
observer.observe(container)
observer.observe(track)
return () => observer.disconnect()
}, [measureOverflow, text])
return (
<span
className={
className
? `overflow-marquee ${className}`
: 'overflow-marquee'
}
onMouseEnter={measureOverflow}
ref={containerRef}
title={text}
>
<span
className="overflow-marquee__track"
ref={trackRef}
>
{text}
</span>
</span>
)
}
@@ -32,8 +32,11 @@ function renderSidebar({
enabledLibraries={[]}
memories={[]}
schedules={[]}
tasks={[]}
conversationTitles={new Map()}
projectNames={new Map()}
onClose={vi.fn()}
onCreateSchedule={vi.fn(async () => undefined)}
onCreateCustomTask={vi.fn()}
onImportArtifacts={vi.fn(async () => undefined)}
onListWorkspaceDirectory={vi.fn(async (path: string) => ({
path,
@@ -49,6 +52,8 @@ function renderSidebar({
onRemoveSchedule={vi.fn(async () => undefined)}
onRespondApproval={vi.fn()}
onRunSchedule={vi.fn(async () => undefined)}
onSetScheduleEnabled={vi.fn(async () => undefined)}
onOpenTask={vi.fn()}
onStopBrowser={vi.fn(async () => undefined)}
onTabChange={vi.fn()}
open
@@ -161,14 +166,17 @@ describe('RightAssistantSidebar resizing', () => {
).not.toBeInTheDocument()
})
it('keeps automation in the task center without recent tasks', () => {
it('keeps the product Task index in the task center', () => {
renderSidebar({ tab: 'tasks' })
expect(screen.getByText('等待审批')).toBeInTheDocument()
expect(screen.getByText('自动化')).toBeInTheDocument()
expect(
screen.getByLabelText('定时任务标题')
screen.getByRole('heading', { name: '任务索引' })
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: '新建定制任务' })
).toBeInTheDocument()
expect(screen.queryByLabelText('定时任务标题')).not.toBeInTheDocument()
expect(screen.queryByText('最近任务')).not.toBeInTheDocument()
})
+195 -135
View File
@@ -2,12 +2,14 @@ import {
CheckCircle2,
ChevronLeft,
ChevronRight,
CircleAlert,
ExternalLink,
FileText,
FolderTree,
Hourglass,
ListTodo,
Monitor,
PanelRightClose,
Plus,
RefreshCw,
ShieldAlert,
Upload,
@@ -18,7 +20,7 @@ import { useTranslation } from 'react-i18next'
import type {
AssistantMemory,
AssistantSchedule,
ScheduleCreateInput,
AssistantTask,
WorkspaceChanges,
WorkspaceDirectoryListing,
WorkspaceFilePreview
@@ -31,6 +33,11 @@ import type {
KnowledgeLibrary
} from '../../shared/contracts'
import { WorkspaceFilesPanel } from './WorkspaceFilesPanel'
import { SegmentedControl } from './WorkspacePrimitives'
import {
findTaskSchedule,
TaskScheduleActions
} from './TaskScheduleActions'
export type AssistantSidebarTab =
| 'tasks'
@@ -65,13 +72,17 @@ type RightAssistantSidebarProps = {
enabledLibraries: KnowledgeLibrary[]
memories: AssistantMemory[]
schedules: AssistantSchedule[]
tasks: AssistantTask[]
conversationTitles: ReadonlyMap<string, string>
projectNames: ReadonlyMap<string, string>
selectedTaskId?: string
workspaceChanges?: WorkspaceChanges
workspaceProjectId?: string
browserState?: BrowserLiveState
onClose: () => void
onInteractBrowser: () => Promise<void>
onStopBrowser: () => Promise<void>
onCreateSchedule: (input: ScheduleCreateInput) => Promise<void>
onCreateCustomTask: () => void
onImportArtifacts: () => Promise<void>
onLoadArtifact: (artifactId: string) => Promise<void>
onRemoveAttachment: (attachmentId: string) => void
@@ -90,6 +101,11 @@ type RightAssistantSidebarProps = {
decision: ApprovalDecision
) => void
onRunSchedule: (scheduleId: string) => Promise<void>
onSetScheduleEnabled: (
scheduleId: string,
enabled: boolean
) => Promise<void>
onOpenTask: (task: AssistantTask) => void
onTabChange: (tab: AssistantSidebarTab) => void
}
@@ -133,13 +149,17 @@ export function RightAssistantSidebar({
enabledLibraries,
memories,
schedules,
tasks,
conversationTitles,
projectNames,
selectedTaskId,
workspaceChanges,
workspaceProjectId,
browserState,
onClose,
onInteractBrowser,
onStopBrowser,
onCreateSchedule,
onCreateCustomTask,
onImportArtifacts,
onLoadArtifact,
onRemoveAttachment,
@@ -150,6 +170,8 @@ export function RightAssistantSidebar({
onRemoveSchedule,
onRespondApproval,
onRunSchedule,
onSetScheduleEnabled,
onOpenTask,
onTabChange
}: RightAssistantSidebarProps): React.JSX.Element {
const { i18n, t } = useTranslation('workspace')
@@ -203,12 +225,9 @@ export function RightAssistantSidebar({
>()
const workspacePreviewRequest = useRef(0)
const [workspaceRefreshVersion, setWorkspaceRefreshVersion] = useState(0)
const [scheduleTitle, setScheduleTitle] = useState('')
const [schedulePrompt, setSchedulePrompt] = useState('')
const [scheduleTime, setScheduleTime] = useState('')
const [scheduleRecurrence, setScheduleRecurrence] = useState<
ScheduleCreateInput['recurrence']
>('once')
const [taskFilter, setTaskFilter] = useState<
'attention' | 'active' | 'paused' | 'finished'
>('active')
const [actionError, setActionError] = useState('')
const activeMemories = memories.filter(
(memory) => memory.status === 'confirmed'
@@ -222,6 +241,34 @@ export function RightAssistantSidebar({
const sidebarWidthLimits = getSidebarWidthLimits(viewportWidth)
const canResize =
open && viewportWidth >= compactSidebarBreakpoint
const topLevelTasks = useMemo(
() => tasks.filter((task) => !task.parentTaskId),
[tasks]
)
const filteredTasks = useMemo(
() =>
topLevelTasks.filter((task) => {
if (taskFilter === 'attention') {
return (
task.status === 'waiting_approval' ||
task.status === 'failed' ||
task.status === 'interrupted'
)
}
if (taskFilter === 'active') {
return (
task.status === 'idle' ||
task.status === 'queued' ||
task.status === 'running'
)
}
if (taskFilter === 'paused') {
return task.status === 'paused'
}
return task.status === 'completed' || task.status === 'cancelled'
}),
[taskFilter, topLevelTasks]
)
useEffect(() => {
const handleViewportResize = (): void => {
@@ -502,9 +549,19 @@ export function RightAssistantSidebar({
) : null}
{tab === 'tasks' && (
<section className="assistant-sidebar__section">
<p className="assistant-sidebar__section-description">
{t('sidebar.tasks.description')}
</p>
<div className="task-center__heading">
<p className="assistant-sidebar__section-description">
{t('sidebar.tasks.description')}
</p>
<button
className="primary-button"
onClick={onCreateCustomTask}
type="button"
>
<Plus aria-hidden="true" size={13} />
{t('sidebar.tasks.createCustom')}
</button>
</div>
<h3>
<ShieldAlert size={15} />
{t('sidebar.tasks.approvalsTitle')}
@@ -547,132 +604,135 @@ export function RightAssistantSidebar({
)}
<h3>
<Hourglass size={15} />
{t('sidebar.tasks.automationTitle')}
<ListTodo size={15} />
{t('sidebar.tasks.taskIndexTitle')}
</h3>
<div className="assistant-sidebar__schedule-form">
<input
aria-label={t(
'sidebar.tasks.schedule.titleAriaLabel'
)}
maxLength={120}
onChange={(event) => setScheduleTitle(event.target.value)}
placeholder={t(
'sidebar.tasks.schedule.titlePlaceholder'
)}
value={scheduleTitle}
<div className="task-center__filters">
<SegmentedControl
ariaLabel={t('sidebar.tasks.filters.ariaLabel')}
onChange={setTaskFilter}
options={[
{
value: 'attention',
label: t('sidebar.tasks.filters.attention')
},
{
value: 'active',
label: t('sidebar.tasks.filters.active')
},
{
value: 'paused',
label: t('sidebar.tasks.filters.paused')
},
{
value: 'finished',
label: t('sidebar.tasks.filters.finished')
}
]}
value={taskFilter}
/>
<textarea
aria-label={t(
'sidebar.tasks.schedule.promptAriaLabel'
)}
maxLength={100_000}
onChange={(event) => setSchedulePrompt(event.target.value)}
placeholder={t(
'sidebar.tasks.schedule.promptPlaceholder'
)}
rows={3}
value={schedulePrompt}
/>
<input
aria-label={t(
'sidebar.tasks.schedule.timeAriaLabel'
)}
onChange={(event) => setScheduleTime(event.target.value)}
type="datetime-local"
value={scheduleTime}
/>
<select
aria-label={t(
'sidebar.tasks.schedule.recurrenceAriaLabel'
)}
onChange={(event) =>
setScheduleRecurrence(
event.target.value as ScheduleCreateInput['recurrence']
)
}
value={scheduleRecurrence}
>
<option value="once">
{t('sidebar.tasks.schedule.recurrence.once')}
</option>
<option value="daily">
{t('sidebar.tasks.schedule.recurrence.daily')}
</option>
<option value="weekly">
{t('sidebar.tasks.schedule.recurrence.weekly')}
</option>
</select>
<button
className="primary-button"
disabled={
!scheduleTitle.trim() ||
!schedulePrompt.trim() ||
!scheduleTime
}
onClick={() => {
runAction(
() =>
onCreateSchedule({
title: scheduleTitle.trim(),
prompt: schedulePrompt.trim(),
workMode: 'ask',
recurrence: scheduleRecurrence,
nextRunAt: new Date(scheduleTime).toISOString()
}),
t('sidebar.errors.addSchedule'),
() => {
setScheduleTitle('')
setSchedulePrompt('')
setScheduleTime('')
}
)
}}
type="button"
>
{t('sidebar.tasks.schedule.add')}
</button>
</div>
{schedules.map((schedule) => (
<article
className="assistant-sidebar__schedule"
key={schedule.id}
>
<span>
<strong>{schedule.title}</strong>
<small>
{new Date(schedule.nextRunAt).toLocaleString(locale)} ·{' '}
{t(
`sidebar.tasks.schedule.recurrence.${schedule.recurrence}`
{filteredTasks.length === 0 ? (
<p className="assistant-sidebar__empty">
{topLevelTasks.length === 0
? t('sidebar.tasks.empty')
: t('sidebar.tasks.noFilterResults')}
</p>
) : (
filteredTasks.map((task) => {
const schedule = findTaskSchedule(task, schedules)
const conversationTitle = task.conversationId
? conversationTitles.get(task.conversationId)
: undefined
const projectName = task.projectId
? projectNames.get(task.projectId)
: undefined
return (
<article
className={
selectedTaskId === task.id
? 'task-center__item task-center__item--selected'
: 'task-center__item'
}
key={task.id}
>
<button
className="task-center__item-main"
onClick={() => onOpenTask(task)}
type="button"
>
<span
aria-hidden="true"
className={`task-status-dot task-status-dot--${task.status}`}
/>
<span>
<strong>{task.title}</strong>
<small>
{conversationTitle ??
t('sidebar.tasks.conversationUnavailable')}
</small>
</span>
{task.status === 'failed' ? (
<CircleAlert aria-hidden="true" size={14} />
) : (
<ChevronRight aria-hidden="true" size={14} />
)}
</button>
<div className="task-center__metadata">
<span>
{projectName
? t('sidebar.tasks.projectScope', {
project: projectName
})
: t('sidebar.tasks.globalScope')}
</span>
<span>
{schedule
? t(`task.mode.${schedule.workMode}`)
: task.workMode
? t(`task.mode.${task.workMode}`)
: t('task.mode.unavailable')}
</span>
<span>{t(`task.status.${task.status}`)}</span>
</div>
<p>
{task.error ??
(task.completedAt
? t('task.completedAt', {
time: new Date(
task.completedAt
).toLocaleString(locale)
})
: task.startedAt
? t('sidebar.tasks.startedAt', {
time: new Date(
task.startedAt
).toLocaleString(locale)
})
: schedule
? t('sidebar.tasks.nextRunAt', {
time: new Date(
schedule.nextRunAt
).toLocaleString(locale)
})
: t('sidebar.tasks.notStarted'))}
</p>
{schedule && (
<div className="task-center__actions">
<TaskScheduleActions
onError={setActionError}
onRemoveSchedule={onRemoveSchedule}
onRunSchedule={onRunSchedule}
onSetScheduleEnabled={onSetScheduleEnabled}
schedule={schedule}
taskTitle={task.title}
/>
</div>
)}
</small>
</span>
<div>
<button
onClick={() =>
runAction(
() => onRunSchedule(schedule.id),
t('sidebar.errors.runSchedule')
)
}
type="button"
>
{t('sidebar.tasks.schedule.runNow')}
</button>
<button
onClick={() =>
runAction(
() => onRemoveSchedule(schedule.id),
t('sidebar.errors.deleteSchedule')
)
}
type="button"
>
{t('sidebar.tasks.schedule.delete')}
</button>
</div>
</article>
))}
</article>
)
})
)}
</section>
)}
+127
View File
@@ -0,0 +1,127 @@
import {
CalendarClock,
Pause,
Play,
Trash2
} from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import type {
AssistantSchedule,
AssistantTask
} from '../../shared/assistant-contracts'
import { DestructiveConfirmActions } from './WorkspacePrimitives'
export function findTaskSchedule(
task: AssistantTask,
schedules: readonly AssistantSchedule[]
): AssistantSchedule | undefined {
return schedules.find(
(schedule) =>
schedule.id === task.scheduleId || schedule.taskId === task.id
)
}
type TaskScheduleActionsProps = {
onError: (message: string) => void
onRemoveSchedule: (scheduleId: string) => Promise<void>
onRunSchedule: (scheduleId: string) => Promise<void>
onSetScheduleEnabled: (
scheduleId: string,
enabled: boolean
) => Promise<void>
schedule: AssistantSchedule
taskTitle: string
}
export function TaskScheduleActions({
onError,
onRemoveSchedule,
onRunSchedule,
onSetScheduleEnabled,
schedule,
taskTitle
}: TaskScheduleActionsProps): React.JSX.Element {
const { t } = useTranslation('workspace')
const [busy, setBusy] = useState(false)
const [confirmingDelete, setConfirmingDelete] = useState(false)
const runAction = async (
action: () => Promise<void>,
fallback: string
): Promise<void> => {
setBusy(true)
onError('')
try {
await action()
} catch (reason) {
onError(reason instanceof Error ? reason.message : fallback)
} finally {
setBusy(false)
}
}
return (
<>
<button
disabled={busy}
onClick={() =>
void runAction(
() => onRunSchedule(schedule.id),
t('sidebar.errors.runSchedule')
)
}
type="button"
>
<Play aria-hidden="true" size={12} />
{t('sidebar.tasks.schedule.runNow')}
</button>
{(schedule.recurrence !== 'once' || !schedule.lastRunAt) && (
<button
disabled={busy}
onClick={() =>
void runAction(
() =>
onSetScheduleEnabled(schedule.id, !schedule.enabled),
t('sidebar.errors.updateSchedule')
)
}
type="button"
>
{schedule.enabled ? (
<Pause aria-hidden="true" size={12} />
) : (
<CalendarClock aria-hidden="true" size={12} />
)}
{schedule.enabled
? t('task.actions.pause')
: t('task.actions.resume')}
</button>
)}
<DestructiveConfirmActions
cancelAriaLabel={t('sidebar.tasks.schedule.cancelDelete')}
confirmAriaLabel={t(
'sidebar.tasks.schedule.confirmDelete',
{ title: taskTitle }
)}
confirmLabel={t(
'sidebar.tasks.schedule.confirmDeleteAction'
)}
confirming={confirmingDelete}
disabled={busy}
icon={<Trash2 aria-hidden="true" size={12} />}
message={t('sidebar.tasks.schedule.deleteMessage')}
onCancel={() => setConfirmingDelete(false)}
onConfirm={() => {
setConfirmingDelete(false)
void runAction(
() => onRemoveSchedule(schedule.id),
t('sidebar.errors.deleteSchedule')
)
}}
onRequestConfirm={() => setConfirmingDelete(true)}
triggerLabel={t('sidebar.tasks.schedule.delete')}
/>
</>
)
}
+17 -1
View File
@@ -106,7 +106,7 @@ describe('WorkspacePrimitives', () => {
/\.section-label\s*\{[^}]*font-size:\s*var\(--font-caption\);[^}]*font-weight:\s*600;/u
)
expect(stylesheet).toMatch(
/\.conversation-item span\s*\{[^}]*font-size:\s*var\(--font-body\);/u
/\.conversation-item__primary\s*\{[^}]*font-size:\s*var\(--font-body\);/u
)
expect(stylesheet).toMatch(
/\.conversation-item small\s*\{[^}]*font-size:\s*var\(--font-caption\);/u
@@ -134,6 +134,22 @@ describe('WorkspacePrimitives', () => {
)
})
it('keeps the conversation action close to the sidebar edge', () => {
expect(stylesheet).toMatch(
/\.conversation-row \.conversation-item\s*\{[^}]*padding-right:\s*36px;/u
)
expect(stylesheet).toMatch(
/\.conversation-more\s*\{[^}]*right:\s*5px;/u
)
expect(stylesheet).toMatch(
/\.conversation-row:has\(\.conversation-activity-indicator\)\s+\.conversation-item\s*\{[^}]*padding-right:\s*52px;/u
)
expect(stylesheet).toMatch(
/\.conversation-activity-indicator\s*\{[^}]*right:\s*38px;/u
)
expect(stylesheet).not.toContain('.conversation-delete')
})
it('separates Runtime-specific controls from the main composer toolbar', () => {
expect(stylesheet).toMatch(
/\.composer__toolbar--with-runtime-controls\s*\{[^}]*border-radius:\s*0;/u
@@ -93,6 +93,11 @@ export const app = {
copy: 'Copy full conversation',
export: 'Export Markdown'
},
tasks: {
toggle: 'Expand or collapse {{count}} tasks in “{{title}}”',
list: 'Tasks in “{{title}}”',
viewAll: 'View all {{count}} tasks'
},
delete: {
cancelAria: 'Cancel deleting conversation {{title}}',
confirmAria: 'Confirm permanently deleting conversation {{title}}',
@@ -103,6 +108,65 @@ export const app = {
trigger: 'Delete conversation'
}
},
customTask: {
eyebrow: 'CUSTOM TASK',
title: 'New custom task',
description:
'Create a task that runs on schedule and keeps recording results in a conversation.',
close: 'Close new custom task',
fields: {
name: 'Task name',
instructions: 'Task instructions',
destination: 'Conversation',
mode: 'Work mode',
recurrence: 'Frequency',
time: 'First run'
},
destination: {
current: 'Current conversation',
new: 'New conversation',
currentHelp:
'Associate the task with this conversation without renaming it or changing normal chat.',
newHelp:
'Create a conversation for this task, using the task name as its initial title.',
currentUnavailable:
'The current conversation cannot host this task. Choose a new conversation.'
},
mode: {
execute: 'Execute',
ask: 'Ask',
executeUnavailable:
'The current Runtime cannot use tools, so read-only Ask is selected.'
},
recurrence: {
once: 'Once',
daily: 'Daily',
weekly: 'Weekly'
},
scope: {
title: 'Execution scope',
project: 'Project',
runtime: 'Runtime',
workspace: 'Workspace',
tools: 'Tools and approval',
executeApproval:
'Use authorized tools; high-risk actions still require approval',
askReadOnly: 'Read-only run with no changes allowed',
noWorkspace: 'No workspace configured'
},
errors: {
title: 'Enter a task name.',
instructions: 'Enter task instructions.',
destination: 'Choose an available conversation.',
time: 'Choose a valid first run time.',
futureTime: 'The first run must be in the future.',
create: 'Could not create the task. Try again.',
projectUnavailable: 'Select an available regular project first.'
},
cancel: 'Cancel',
create: 'Create task',
creating: 'Creating…'
},
runtime: {
unavailable: 'Runtime unavailable',
detecting: 'Detecting runtime',
@@ -135,6 +199,7 @@ export const app = {
},
chat: {
user: 'You',
taskResult: 'Task result: {{title}}',
welcome: {
eyebrow: 'GOODBUDDY WORKSPACE',
title: 'What would you like to accomplish today?',
@@ -152,6 +152,7 @@ export const heartbeat = {
},
task: {
queued: 'Queued',
idle: 'Idle',
running: 'Running',
waitingApproval: 'Waiting for approval',
paused: 'Pending',
@@ -93,26 +93,40 @@ export const workspace = {
tasks: {
description:
'Review pending approvals and create or manage automations.',
createCustom: 'New custom task',
approvalsTitle: 'Awaiting approval',
noApprovals: 'There are no operations awaiting approval.',
deny: 'Deny',
allowOnce: 'Allow once',
automationTitle: 'Automation',
taskIndexTitle: 'Task index',
empty: 'Explicitly created tasks will appear here.',
noFilterResults: 'No tasks match this filter.',
conversationUnavailable: 'Conversation unavailable',
projectScope: 'Project: {{project}}',
globalScope: 'Global',
startedAt: 'Started {{time}}',
nextRunAt: 'Next run: {{time}}',
notStarted: 'Not run yet',
filters: {
ariaLabel: 'Filter tasks',
attention: 'Attention',
active: 'Active',
paused: 'Paused',
finished: 'Finished'
},
schedule: {
titleAriaLabel: 'Scheduled task title',
titlePlaceholder: 'Task title',
promptAriaLabel: 'Scheduled task instructions',
promptPlaceholder: 'Read-only task to complete on schedule',
timeAriaLabel: 'Scheduled task time',
recurrenceAriaLabel: 'Scheduled task recurrence',
recurrence: {
once: 'Once',
daily: 'Daily',
weekly: 'Weekly'
},
add: 'Add scheduled task',
runNow: 'Run now',
delete: 'Delete'
delete: 'Delete schedule',
cancelDelete: 'Cancel deleting the schedule',
confirmDelete: 'Confirm deleting the schedule for “{{title}}”',
confirmDeleteAction: 'Stop future runs',
deleteMessage:
'This stops future automatic runs but keeps the task, conversation, and existing results.'
}
},
context: {
@@ -179,9 +193,9 @@ export const workspace = {
},
errors: {
workspacePreview: 'Could not preview the workspace file',
addSchedule: 'Could not add the scheduled task',
runSchedule: 'Could not run the scheduled task',
deleteSchedule: 'Could not delete the scheduled task',
updateSchedule: 'Could not update the scheduled task',
refreshWorkspace: 'Could not refresh workspace files',
importResult: 'Could not import results',
loadResult: 'Could not load the result',
@@ -189,6 +203,48 @@ export const workspace = {
stopBrowser: 'Could not stop the browser'
}
},
task: {
status: {
queued: 'Idle',
idle: 'Idle',
running: 'Running',
waiting_approval: 'Awaiting approval',
paused: 'Paused',
completed: 'Completed',
failed: 'Failed',
cancelled: 'Cancelled',
interrupted: 'Interrupted'
},
mode: {
ask: 'Ask',
execute: 'Execute',
unavailable: 'Mode unavailable'
},
fields: {
mode: 'Mode',
schedule: 'Schedule',
nextRun: 'Next run',
outcome: 'Latest result'
},
schedule: {
none: 'No schedule'
},
actions: {
pause: 'Pause',
resume: 'Resume'
},
notAvailable: 'Unavailable',
completedAt: 'Completed {{time}}',
noOutcome: 'No run result yet'
},
taskStrip: {
ariaLabel: 'Tasks in this conversation',
title: 'Conversation tasks',
count: '{{count}}',
create: 'New task',
empty: 'This conversation has no tasks yet.',
taskList: 'Task list'
},
files: {
statuses: {
added: 'Added',
@@ -90,6 +90,11 @@ export const app = {
copy: '复制完整会话',
export: '导出 Markdown'
},
tasks: {
toggle: '展开或折叠“{{title}}”中的 {{count}} 个任务',
list: '“{{title}}”中的任务',
viewAll: '查看全部 {{count}} 个任务'
},
delete: {
cancelAria: '取消删除对话 {{title}}',
confirmAria: '确认永久删除对话 {{title}}',
@@ -100,6 +105,59 @@ export const app = {
trigger: '删除对话'
}
},
customTask: {
eyebrow: '定制任务',
title: '新建定制任务',
description: '创建一个按计划自动运行,并持续记录在会话中的任务。',
close: '关闭新建定制任务',
fields: {
name: '任务名称',
instructions: '任务要求',
destination: '关联会话',
mode: '执行模式',
recurrence: '运行频率',
time: '首次运行'
},
destination: {
current: '当前会话',
new: '新建会话',
currentHelp: '把任务关联到当前会话,不更改会话名称或普通聊天能力。',
newHelp: '为任务创建一条新会话,默认使用任务名称作为标题。',
currentUnavailable: '当前会话不能关联此任务,请选择新建会话。'
},
mode: {
execute: 'Execute',
ask: 'Ask',
executeUnavailable: '当前 Runtime 不支持工具执行,已使用只读 Ask。'
},
recurrence: {
once: '单次',
daily: '每日',
weekly: '每周'
},
scope: {
title: '执行范围',
project: '项目',
runtime: 'Runtime',
workspace: '工作目录',
tools: '工具与审批',
executeApproval: '使用已授权工具;高风险操作仍需审批',
askReadOnly: '只读运行,不允许执行变更',
noWorkspace: '未设置工作目录'
},
errors: {
title: '请输入任务名称。',
instructions: '请输入任务要求。',
destination: '请选择可用的关联会话。',
time: '请选择有效的首次运行时间。',
futureTime: '首次运行时间必须晚于当前时间。',
create: '创建任务失败,请重试。',
projectUnavailable: '请先选择一个可用的普通项目。'
},
cancel: '取消',
create: '创建任务',
creating: '创建中…'
},
runtime: {
unavailable: 'Runtime 不可用',
detecting: '正在检测运行时',
@@ -132,6 +190,7 @@ export const app = {
},
chat: {
user: '用户',
taskResult: '任务结果:{{title}}',
welcome: {
eyebrow: 'GOODBUDDY 工作台',
title: '今天想一起完成什么?',
@@ -148,6 +148,7 @@ export const heartbeat = {
},
task: {
queued: '等待中',
idle: '空闲',
running: '运行中',
waitingApproval: '等待审批',
paused: '待处理',
@@ -88,26 +88,39 @@ export const workspace = {
},
tasks: {
description: '处理当前待审批操作,并创建和管理自动化任务。',
createCustom: '新建定制任务',
approvalsTitle: '等待审批',
noApprovals: '当前没有等待审批的操作。',
deny: '拒绝',
allowOnce: '仅此次允许',
automationTitle: '自动化',
taskIndexTitle: '任务索引',
empty: '明确创建的任务会显示在这里。',
noFilterResults: '当前筛选条件下没有任务。',
conversationUnavailable: '关联会话不可用',
projectScope: '项目:{{project}}',
globalScope: '全局',
startedAt: '{{time}} 开始',
nextRunAt: '下次运行:{{time}}',
notStarted: '尚未运行',
filters: {
ariaLabel: '筛选任务',
attention: '待关注',
active: '进行中',
paused: '已暂停',
finished: '已完成'
},
schedule: {
titleAriaLabel: '定时任务标题',
titlePlaceholder: '任务标题',
promptAriaLabel: '定时任务内容',
promptPlaceholder: '要定时完成的只读任务',
timeAriaLabel: '定时任务时间',
recurrenceAriaLabel: '定时任务重复规则',
recurrence: {
once: '仅一次',
daily: '每天',
weekly: '每周'
},
add: '添加定时任务',
runNow: '立即运行',
delete: '删除'
delete: '删除计划',
cancelDelete: '取消删除计划',
confirmDelete: '确认删除“{{title}}”的计划',
confirmDeleteAction: '停止后续运行',
deleteMessage: '这会停止后续自动运行,但保留任务、会话和既有结果。'
}
},
context: {
@@ -168,9 +181,9 @@ export const workspace = {
},
errors: {
workspacePreview: '工作区文件预览失败',
addSchedule: '添加定时任务失败',
runSchedule: '运行定时任务失败',
deleteSchedule: '删除定时任务失败',
updateSchedule: '更新定时任务失败',
refreshWorkspace: '刷新工作区文件失败',
importResult: '导入成果失败',
loadResult: '加载成果失败',
@@ -178,6 +191,48 @@ export const workspace = {
stopBrowser: '停止浏览器失败'
}
},
task: {
status: {
queued: '空闲',
idle: '空闲',
running: '运行中',
waiting_approval: '等待审批',
paused: '已暂停',
completed: '已完成',
failed: '失败',
cancelled: '已取消',
interrupted: '已中断'
},
mode: {
ask: 'Ask',
execute: 'Execute',
unavailable: '模式不可用'
},
fields: {
mode: '模式',
schedule: '计划',
nextRun: '下次运行',
outcome: '最近结果'
},
schedule: {
none: '无计划'
},
actions: {
pause: '暂停',
resume: '恢复'
},
notAvailable: '不可用',
completedAt: '{{time}} 完成',
noOutcome: '尚无运行结果'
},
taskStrip: {
ariaLabel: '当前会话的任务',
title: '会话任务',
count: '{{count}} 个',
create: '新建任务',
empty: '当前会话还没有任务。',
taskList: '任务列表'
},
files: {
statuses: {
added: '新增',
+818 -21
View File
@@ -53,6 +53,7 @@
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--page-gutter: 32px;
--content-reading: 820px;
@@ -80,6 +81,749 @@
text-rendering: optimizeLegibility;
}
/* Product Tasks */
.chat-history-pane {
display: flex;
flex-direction: column;
}
.chat-history-pane > .chat {
height: auto;
flex: 1 1 auto;
}
.conversation-row:has(.conversation-task-toggle) .conversation-item {
padding-left: 42px;
}
.conversation-task-toggle {
position: absolute;
z-index: 1;
left: 7px;
display: grid;
width: 25px;
height: 25px;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text-muted);
cursor: pointer;
place-items: center;
}
.conversation-task-toggle:hover,
.conversation-task-toggle[aria-expanded='true'] {
background: var(--accent-subtle);
color: var(--accent);
}
.conversation-task-children {
display: grid;
margin: 2px 5px var(--space-2) 18px;
padding: 0;
gap: 2px;
list-style: none;
}
.conversation-task-children > li {
display: flex;
min-width: 0;
}
.conversation-task-children > li > button {
flex: 1;
}
.conversation-task-child,
.conversation-task-view-all {
display: flex;
min-width: 0;
min-height: 30px;
align-items: center;
padding: 5px var(--space-2);
border: 0;
border-radius: 7px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
font: inherit;
gap: var(--space-2);
text-align: left;
}
.conversation-task-child:hover,
.conversation-task-child--active {
background: var(--accent-subtle);
color: var(--accent);
}
.conversation-task-child__icon {
flex: 0 0 auto;
color: var(--text-muted);
}
.conversation-task-child__icon--running {
animation: pulse 1.1s ease-in-out infinite;
color: var(--accent);
}
.conversation-task-child__icon--waiting_approval {
color: var(--warning);
}
.conversation-task-child__icon--failed,
.conversation-task-child__icon--interrupted {
color: var(--danger);
}
.conversation-task-child__icon--completed {
color: var(--success);
}
.conversation-task-child__icon--paused,
.conversation-task-child__icon--cancelled {
color: var(--text-disabled);
}
.conversation-task-child__title {
min-width: 0;
flex: 1;
overflow: hidden;
font-size: var(--font-caption);
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-task-child__meta {
flex: 0 0 auto;
max-width: 55%;
overflow: hidden;
color: var(--text-muted);
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-task-view-all {
justify-content: center;
color: var(--accent);
font-size: var(--font-caption);
}
.conversation-task-strip {
flex: 0 0 auto;
padding:
var(--space-2)
max(var(--page-gutter), calc((100% - var(--content-reading)) / 2));
border-bottom: 1px solid var(--border-subtle);
background: var(--surface-raised);
}
.conversation-task-strip__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.conversation-task-strip__toggle {
display: flex;
min-width: 0;
align-items: center;
padding: 5px var(--space-2);
border: 0;
border-radius: var(--radius-control);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
font: inherit;
gap: var(--space-2);
}
.conversation-task-strip__toggle:hover {
background: var(--surface-subtle);
color: var(--text-primary);
}
.conversation-task-strip__toggle strong {
color: var(--text-primary);
font-size: var(--font-caption);
}
.conversation-task-strip__toggle span {
color: var(--text-muted);
font-size: var(--font-caption);
}
.conversation-task-strip__create {
min-height: 30px;
padding: 0 var(--space-2);
font-size: var(--font-caption);
}
.conversation-task-strip__content {
display: grid;
padding: var(--space-2) 0 var(--space-1);
gap: var(--space-2);
}
.conversation-task-strip__list {
display: flex;
min-width: 0;
overflow-x: auto;
gap: var(--space-1);
scrollbar-width: thin;
}
.conversation-task-strip__task {
display: inline-flex;
min-width: max-content;
align-items: center;
padding: 5px var(--space-2);
border: 1px solid var(--border-subtle);
border-radius: 999px;
background: var(--surface-subtle);
color: var(--text-secondary);
cursor: pointer;
font: inherit;
font-size: var(--font-caption);
gap: var(--space-2);
}
.conversation-task-strip__task:hover,
.conversation-task-strip__task--active {
border-color: var(--accent-selected);
background: var(--accent-subtle);
color: var(--accent);
}
.conversation-task-details {
display: grid;
padding: var(--space-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-control);
background: var(--surface-subtle);
gap: var(--space-3);
}
.conversation-task-details > header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-2);
}
.conversation-task-details > header > span {
display: grid;
min-width: 0;
gap: 2px;
}
.conversation-task-details > header strong {
overflow: hidden;
color: var(--text-primary);
font-size: var(--font-body);
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-task-details > header small {
color: var(--text-muted);
font-size: var(--font-caption);
}
.conversation-task-details > header > svg {
flex: 0 0 auto;
color: var(--danger);
}
.conversation-task-details dl {
display: grid;
margin: 0;
gap: var(--space-2);
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.conversation-task-details dl > div {
min-width: 0;
}
.conversation-task-details dt {
color: var(--text-muted);
font-size: var(--font-caption);
}
.conversation-task-details dd {
margin: 2px 0 0;
overflow: hidden;
color: var(--text-secondary);
font-size: var(--font-caption);
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-task-details__actions,
.task-center__actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
.conversation-task-details__actions button,
.task-center__actions button {
display: inline-flex;
min-height: 28px;
align-items: center;
padding: 0 var(--space-2);
border: 1px solid var(--border-control);
border-radius: 6px;
background: var(--surface-raised);
color: var(--text-secondary);
cursor: pointer;
font: inherit;
font-size: var(--font-caption);
gap: var(--space-1);
}
.conversation-task-details__actions button:hover,
.task-center__actions button:hover {
border-color: var(--accent-selected);
color: var(--accent);
}
.conversation-task-details__actions button:disabled,
.task-center__actions button:disabled {
cursor: wait;
opacity: 0.6;
}
.conversation-task-strip__empty,
.conversation-task-strip__error {
margin: 0;
color: var(--text-muted);
font-size: var(--font-caption);
}
.conversation-task-strip__error {
color: var(--danger);
}
.task-center__heading {
display: grid;
align-items: start;
gap: var(--space-2);
}
.task-center__heading .primary-button {
justify-self: start;
}
.task-center__filters {
min-width: 0;
overflow-x: auto;
}
.task-center__item {
display: grid;
padding: var(--space-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-control);
background: var(--surface-subtle);
gap: var(--space-2);
}
.task-center__item--selected {
border-color: var(--accent-selected);
box-shadow: inset 3px 0 0 var(--accent);
}
.task-center__item-main {
display: grid;
min-width: 0;
align-items: center;
padding: 0;
border: 0;
background: transparent;
color: var(--text-primary);
cursor: pointer;
font: inherit;
gap: var(--space-2);
grid-template-columns: auto minmax(0, 1fr) auto;
text-align: left;
}
.task-center__item-main > span:nth-child(2) {
display: grid;
min-width: 0;
gap: 2px;
}
.task-center__item-main strong,
.task-center__item-main small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-center__item-main strong {
font-size: var(--font-body);
}
.task-center__item-main small,
.task-center__item > p {
color: var(--text-muted);
font-size: var(--font-caption);
}
.task-center__item > p {
margin: 0;
}
.task-center__item-main > svg {
color: var(--text-muted);
}
.task-center__metadata {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
.task-center__metadata span {
padding: 2px 6px;
border-radius: 999px;
background: var(--surface-raised);
color: var(--text-muted);
font-size: 9px;
}
.task-status-dot {
width: 7px;
height: 7px;
flex: 0 0 auto;
border-radius: 50%;
background: var(--text-muted);
}
.task-status-dot--running {
animation: pulse 1.1s ease-in-out infinite;
background: var(--accent);
}
.task-status-dot--waiting_approval {
background: var(--warning);
}
.task-status-dot--failed,
.task-status-dot--interrupted {
background: var(--danger);
}
.task-status-dot--completed {
background: var(--success);
}
.task-status-dot--paused,
.task-status-dot--cancelled {
background: var(--text-disabled);
}
.message__meta .message__task {
display: inline-flex;
min-width: 0;
max-width: min(280px, 50vw);
align-items: center;
padding: 2px 6px;
border-radius: 999px;
background: var(--accent-subtle);
color: var(--accent);
font-size: 10px;
font-weight: 600;
gap: 4px;
}
.message__task svg {
flex: 0 0 auto;
}
.custom-task-dialog {
position: fixed;
z-index: 130;
inset: 0;
display: grid;
padding: var(--space-6);
background: color-mix(in srgb, var(--surface-canvas) 62%, transparent);
backdrop-filter: blur(6px);
place-items: center;
}
.custom-task-dialog,
.custom-task-dialog *,
.custom-task-dialog *::before,
.custom-task-dialog *::after {
box-sizing: border-box;
}
.custom-task-dialog__surface {
display: grid;
width: min(680px, 100%);
max-height: min(820px, calc(100vh - 64px));
overflow: hidden;
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-raised);
box-shadow: var(--shadow-dialog);
grid-template-rows: auto minmax(0, 1fr) auto;
}
.custom-task-dialog__header,
.custom-task-dialog__actions {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: var(--space-5);
gap: var(--space-4);
}
.custom-task-dialog__header {
border-bottom: 1px solid var(--border-subtle);
}
.custom-task-dialog__header > div {
min-width: 0;
}
.custom-task-dialog__header h2,
.custom-task-dialog__header p {
margin: 0;
}
.custom-task-dialog__header h2 {
color: var(--text-primary);
font-size: var(--font-page-title);
}
.custom-task-dialog__header p {
margin-top: var(--space-1);
color: var(--text-muted);
font-size: var(--font-body);
line-height: 1.5;
}
.custom-task-dialog__eyebrow {
display: inline-flex;
align-items: center;
margin-bottom: var(--space-2);
color: var(--accent);
font-size: var(--font-caption);
font-weight: 700;
gap: var(--space-1);
}
.custom-task-dialog__content {
display: grid;
min-height: 0;
padding: var(--space-5);
overflow-y: auto;
gap: var(--space-4);
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.custom-task-dialog__field,
.custom-task-dialog__choice {
display: grid;
min-width: 0;
gap: var(--space-2);
}
.custom-task-dialog__field > span,
.custom-task-dialog__choice > span {
color: var(--text-primary);
font-size: var(--font-body);
font-weight: 650;
}
.custom-task-dialog__field input,
.custom-task-dialog__field textarea,
.custom-task-dialog__field select {
width: 100%;
min-height: 38px;
padding: 8px 10px;
border: 1px solid var(--border-control);
border-radius: var(--radius-control);
outline: none;
background: var(--surface-subtle);
color: var(--text-primary);
font: inherit;
}
.custom-task-dialog__field textarea {
min-height: 104px;
resize: vertical;
}
.custom-task-dialog__field
:is(input, textarea, select):focus-visible {
border-color: var(--accent);
box-shadow: var(--focus-ring);
}
.custom-task-dialog__field
:is(input, textarea)[aria-invalid='true'] {
border-color: var(--danger);
}
.custom-task-dialog__field small,
.custom-task-dialog__choice small {
color: var(--text-muted);
font-size: var(--font-caption);
}
.custom-task-dialog__field small[role='alert'],
.custom-task-dialog__choice small[role='alert'] {
color: var(--danger);
}
.custom-task-dialog__two-columns {
display: grid;
min-width: 0;
align-items: start;
gap: var(--space-4);
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.custom-task-dialog__scope {
display: grid;
padding: var(--space-4);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-control);
background: var(--surface-subtle);
gap: var(--space-3);
}
.custom-task-dialog__scope > header {
display: flex;
align-items: center;
color: var(--text-primary);
gap: var(--space-2);
}
.custom-task-dialog__scope > header svg {
color: var(--accent);
}
.custom-task-dialog__scope dl {
display: grid;
margin: 0;
gap: var(--space-3);
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.custom-task-dialog__scope dl > div {
min-width: 0;
}
.custom-task-dialog__scope dt {
color: var(--text-muted);
font-size: var(--font-caption);
}
.custom-task-dialog__scope dd {
display: flex;
min-width: 0;
align-items: center;
margin: 3px 0 0;
overflow-wrap: anywhere;
color: var(--text-secondary);
font-size: var(--font-caption);
gap: var(--space-1);
}
.custom-task-dialog__form-error {
padding: var(--space-3);
border: 1px solid color-mix(in srgb, var(--danger) 35%, transparent);
border-radius: var(--radius-control);
margin: 0;
background: var(--danger-subtle);
color: var(--danger);
font-size: var(--font-caption);
}
.custom-task-dialog__actions {
align-items: center;
justify-content: flex-end;
padding-top: var(--space-4);
padding-bottom: var(--space-4);
border-top: 1px solid var(--border-subtle);
}
@media (max-height: 720px) {
.custom-task-dialog {
padding: var(--space-3);
}
.custom-task-dialog__surface {
max-height: calc(100vh - 24px);
}
.custom-task-dialog__header,
.custom-task-dialog__content {
padding: var(--space-3) var(--space-5);
}
.custom-task-dialog__content {
gap: var(--space-3);
}
.custom-task-dialog__field textarea {
min-height: 88px;
}
.custom-task-dialog__actions {
padding: var(--space-3) var(--space-5);
}
}
@media (max-width: 720px) {
.custom-task-dialog {
padding: var(--space-3);
}
.custom-task-dialog__surface {
max-height: calc(100vh - 24px);
}
.custom-task-dialog__header,
.custom-task-dialog__content,
.custom-task-dialog__actions {
padding: var(--space-4);
}
.custom-task-dialog__two-columns,
.custom-task-dialog__scope dl,
.conversation-task-details dl {
grid-template-columns: minmax(0, 1fr);
}
.conversation-task-strip {
padding-right: var(--space-3);
padding-left: var(--space-3);
}
.conversation-task-strip__header {
align-items: stretch;
flex-direction: column;
}
.conversation-task-strip__create {
width: 100%;
justify-content: center;
}
}
.knowledge-scope__retrieval-mode {
display: grid;
gap: var(--space-2);
@@ -1923,17 +2667,68 @@ textarea:focus-visible {
font-weight: 650;
}
.conversation-item span {
.conversation-item__primary {
display: flex;
min-width: 0;
flex: 1;
overflow: hidden;
align-items: center;
font-size: var(--font-body);
}
.conversation-item__title {
min-width: 0;
flex: 1;
}
.conversation-item__primary
> :where(
.conversation-source-badge,
.conversation-unread
) {
flex: 0 0 auto;
}
.conversation-item small {
flex: 0 0 auto;
color: var(--text-muted);
font-size: var(--font-caption);
}
.overflow-marquee {
--overflow-marquee-distance: 0px;
--overflow-marquee-duration: var(--motion-normal);
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-item small {
color: var(--text-muted);
font-size: var(--font-caption);
.overflow-marquee__track {
display: inline-block;
min-width: max-content;
transform: translateX(0);
transition: transform var(--motion-normal) ease-out;
}
.conversation-row:hover
.overflow-marquee[data-overflowing='true'],
.conversation-item:focus-visible
.overflow-marquee[data-overflowing='true'] {
text-overflow: clip;
}
.conversation-row:hover
.overflow-marquee[data-overflowing='true']
.overflow-marquee__track,
.conversation-item:focus-visible
.overflow-marquee[data-overflowing='true']
.overflow-marquee__track {
transform: translateX(
calc(-1 * var(--overflow-marquee-distance))
);
transition-delay: 300ms;
transition-duration: var(--overflow-marquee-duration);
transition-timing-function: linear;
}
.conversation-source-badge {
@@ -7799,12 +8594,17 @@ details.settings-section > :not(summary) + :not(summary) {
}
.conversation-row .conversation-item {
padding-right: 58px;
padding-right: 36px;
}
.conversation-row:has(.conversation-activity-indicator)
.conversation-item {
padding-right: 52px;
}
.conversation-activity-indicator {
position: absolute;
right: 10px;
right: 38px;
width: 8px;
height: 8px;
border-radius: 50%;
@@ -7815,8 +8615,7 @@ details.settings-section > :not(summary) + :not(summary) {
pointer-events: none;
}
.conversation-more,
.conversation-delete {
.conversation-more {
position: absolute;
display: grid;
width: 25px;
@@ -7830,18 +8629,12 @@ details.settings-section > :not(summary) + :not(summary) {
}
.conversation-more {
right: 31px;
}
.conversation-delete {
right: 5px;
}
.conversation-row:hover .conversation-more,
.conversation-row:hover .conversation-delete,
.conversation-row--active .conversation-more,
.conversation-more:focus-visible,
.conversation-delete:focus-visible {
.conversation-more:focus-visible {
opacity: 1;
}
@@ -7856,11 +8649,6 @@ details.settings-section > :not(summary) + :not(summary) {
color: var(--accent);
}
.conversation-delete:hover {
background: var(--danger-subtle);
color: var(--danger);
}
.conversation-actions {
display: grid;
padding: var(--space-1);
@@ -11542,6 +12330,15 @@ details.settings-section > :not(summary) + :not(summary) {
}
@media (prefers-reduced-motion: reduce) {
.conversation-row:hover
.overflow-marquee[data-overflowing='true']
.overflow-marquee__track,
.conversation-item:focus-visible
.overflow-marquee[data-overflowing='true']
.overflow-marquee__track {
transform: none;
}
*,
*::before,
*::after {
+15 -1
View File
@@ -250,6 +250,13 @@ export const conversationMessageSchema = z
.strict()
.optional(),
artifactIds: z.array(assistantIdSchema).max(8).optional(),
task: z
.object({
id: assistantIdSchema,
title: z.string().trim().min(1).max(120)
})
.strict()
.optional(),
attachments: z
.array(conversationAttachmentSchema)
.max(8)
@@ -433,6 +440,7 @@ export type AssistantTaskStatus =
| 'running'
| 'waiting_approval'
| 'paused'
| 'idle'
| 'completed'
| 'failed'
| 'cancelled'
@@ -442,6 +450,7 @@ export type AssistantTask = {
id: string
projectId?: string
conversationId?: string
scheduleId?: string
parentTaskId?: string
expertId?: string
routingMode?: 'manual' | 'smart'
@@ -449,6 +458,7 @@ export type AssistantTask = {
instructions: string
origin: 'user' | 'assistant' | 'schedule' | 'delegation' | 'subagent'
status: AssistantTaskStatus
workMode?: WorkMode
progress?: number
createdAt: string
startedAt?: string
@@ -535,9 +545,10 @@ export type AssistantMemory = MemoryCreateInput & {
export const scheduleCreateSchema = z
.object({
projectId: z.string().uuid().optional(),
conversationId: z.string().uuid().optional(),
title: z.string().trim().min(1).max(120),
prompt: z.string().trim().min(1).max(100_000),
workMode: z.literal('ask'),
workMode: workModeSchema.default('execute'),
recurrence: z.enum(['once', 'daily', 'weekly']),
nextRunAt: z.string().datetime({ offset: true })
})
@@ -547,6 +558,9 @@ export type ScheduleCreateInput = z.infer<typeof scheduleCreateSchema>
export type AssistantSchedule = ScheduleCreateInput & {
id: string
taskId: string
conversationId: string
runtimeSelection?: ProjectCreateInput['runtimeSelection']
enabled: boolean
lastRunAt?: string
createdAt: string