feat: add persistent conversation input queue

Messages and Scheduled Task occurrences previously could not share one ordered path while a Conversation was active. They now enter a durable FIFO queue with frozen execution settings and bounded attachments, recover safely after restart, and never write concurrently to the same timeline.

The compact queue above Composer supports removal and explicit interrupt-and-promote actions. SQLite schema v23 preserves pending schedule work, while Conversation Task children reuse the shared status-dot semantics for running, completed, failed, approval, paused, and cancelled states.

Release note: 回复生成期间仍可继续发送普通消息;消息与 Scheduled Task 会按 Conversation 顺序排队,并支持删除待发送项或立即中断后优先执行。
This commit is contained in:
mesalogo
2026-08-19 21:46:43 +08:00
parent 993c439228
commit 34033053be
28 changed files with 3441 additions and 362 deletions
+414 -30
View File
@@ -12,6 +12,7 @@ import type {
AgentEvent,
BrowserLiveState,
ContextAttachment,
ConversationQueueDispatch,
DesktopApi
} from '../../shared/contracts'
import type { ApplicationSettings } from '../../shared/application-settings-contracts'
@@ -74,6 +75,14 @@ let fileSelectionProgressListener:
let newConversationListener: (() => void) | undefined
let maximizedChangedListener: ((maximized: boolean) => void) | undefined
let beforeQuitListener: (() => Promise<void>) | undefined
let conversationQueueDispatchListener:
| ((dispatch: ConversationQueueDispatch) => void)
| undefined
let conversationQueueChangeListener:
| Parameters<
DesktopApi['conversationQueue']['onChanged']
>[0]
| undefined
const removeMaximizedChangedListener = vi.fn()
const run = vi.fn<DesktopApi['agent']['run']>()
const modelProfileId = '00000000-0000-4000-8000-000000000001'
@@ -336,6 +345,38 @@ const api: DesktopApi = {
deleteLocal: vi.fn(async () => true),
onChanged: vi.fn(() => () => undefined)
},
conversationQueue: {
list: vi.fn(async () => []),
enqueueUser: vi.fn(async (input) => {
const item = {
id: crypto.randomUUID(),
conversationId: input.conversationId,
source: 'user' as const,
label: input.prompt,
createdAt: '2026-07-31T00:00:00.000Z'
}
queueMicrotask(() =>
conversationQueueDispatchListener?.({ item, input })
)
return item
}),
remove: vi.fn(async () => {}),
interruptAndRun: vi.fn(async () => {}),
releaseUser: vi.fn(async () => {}),
ready: vi.fn(async () => {}),
onChanged: vi.fn((listener) => {
conversationQueueChangeListener = listener
return () => {
conversationQueueChangeListener = undefined
}
}),
onDispatch: vi.fn((listener) => {
conversationQueueDispatchListener = listener
return () => {
conversationQueueDispatchListener = undefined
}
})
},
workspace: {
getChanges: vi.fn(async () => ({
rootPath: 'C:\\Workspace',
@@ -755,6 +796,18 @@ function selectComposerOption(
fireEvent.click(option)
}
function selectProjectOption(projectName: string): void {
fireEvent.click(screen.getByRole('button', { name: '当前项目' }))
const menu = screen.getByRole('menu', { name: '当前项目' })
const option = within(menu)
.getByText(projectName, { selector: 'b' })
.closest<HTMLButtonElement>('[role="menuitemradio"]')
if (!option) {
throw new Error(`Missing project option: ${projectName}`)
}
fireEvent.click(option)
}
function deferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
@@ -785,6 +838,54 @@ describe('App', () => {
vi.mocked(api.conversations.onChanged)
.mockReset()
.mockReturnValue(() => undefined)
conversationQueueChangeListener = undefined
conversationQueueDispatchListener = undefined
vi.mocked(api.conversationQueue.list)
.mockReset()
.mockResolvedValue([])
vi.mocked(api.conversationQueue.enqueueUser)
.mockReset()
.mockImplementation(async (input) => {
const item = {
id: crypto.randomUUID(),
conversationId: input.conversationId,
source: 'user' as const,
label: input.prompt,
createdAt: '2026-07-31T00:00:00.000Z'
}
queueMicrotask(() =>
conversationQueueDispatchListener?.({ item, input })
)
return item
})
vi.mocked(api.conversationQueue.remove)
.mockReset()
.mockResolvedValue()
vi.mocked(api.conversationQueue.interruptAndRun)
.mockReset()
.mockResolvedValue()
vi.mocked(api.conversationQueue.releaseUser)
.mockReset()
.mockResolvedValue()
vi.mocked(api.conversationQueue.ready)
.mockReset()
.mockResolvedValue()
vi.mocked(api.conversationQueue.onChanged)
.mockReset()
.mockImplementation((listener) => {
conversationQueueChangeListener = listener
return () => {
conversationQueueChangeListener = undefined
}
})
vi.mocked(api.conversationQueue.onDispatch)
.mockReset()
.mockImplementation((listener) => {
conversationQueueDispatchListener = listener
return () => {
conversationQueueDispatchListener = undefined
}
})
vi.mocked(api.tasks.list).mockReset().mockResolvedValue([])
vi.mocked(api.schedules.list).mockReset().mockResolvedValue([])
api.channels = undefined
@@ -908,6 +1009,143 @@ describe('App', () => {
).not.toBeInTheDocument()
})
it('renders the unified user and Scheduled Task queue above the Composer', async () => {
const conversationId =
'00000000-0000-4000-8000-000000000951'
vi.mocked(api.conversations.list).mockResolvedValue([
{
id: conversationId,
projectId,
title: '排队对话',
updatedAt: Date.now(),
messages: []
}
])
vi.mocked(api.conversationQueue.list).mockResolvedValue([
{
id: '00000000-0000-4000-8000-000000000952',
conversationId,
source: 'schedule',
label: '每日汇总',
createdAt: '2026-08-20T09:00:00.000Z'
},
{
id: '00000000-0000-4000-8000-000000000953',
conversationId,
source: 'user',
label: '补充说明',
createdAt: '2026-08-20T09:01:00.000Z'
}
])
render(<App />)
const queue = await screen.findByRole('region', {
name: '对话待发送队列'
})
expect(queue.parentElement).toHaveClass('composer-wrap')
expect(queue.closest('.composer')).toBeNull()
expect(queue.nextElementSibling).toHaveClass('composer')
expect(within(queue).queryByText('待发送(2)')).not.toBeInTheDocument()
expect(within(queue).getByText('每日汇总')).toBeInTheDocument()
expect(within(queue).getByText('补充说明')).toBeInTheDocument()
fireEvent.click(
within(queue).getByRole('button', {
name: '立即运行“每日汇总”'
})
)
await waitFor(() =>
expect(
api.conversationQueue.interruptAndRun
).toHaveBeenCalledWith(
'00000000-0000-4000-8000-000000000952'
)
)
fireEvent.click(
within(queue).getByRole('button', {
name: '从待发送队列删除“补充说明”'
})
)
await waitFor(() =>
expect(api.conversationQueue.remove).toHaveBeenCalledWith(
'00000000-0000-4000-8000-000000000953'
)
)
})
it('preserves scoped queue updates that arrive during another refresh', async () => {
const firstConversationId =
'00000000-0000-4000-8000-000000000954'
const secondConversationId =
'00000000-0000-4000-8000-000000000955'
vi.mocked(api.conversations.list).mockResolvedValue([
{
id: firstConversationId,
projectId,
title: '第一条排队对话',
updatedAt: Date.now(),
messages: []
},
{
id: secondConversationId,
projectId,
title: '第二条排队对话',
updatedAt: Date.now() - 1,
messages: []
}
])
const firstRefresh = deferred<
Awaited<
ReturnType<DesktopApi['conversationQueue']['list']>
>
>()
vi.mocked(api.conversationQueue.list).mockImplementation(
async (conversationId) => {
if (conversationId === firstConversationId) {
return firstRefresh.promise
}
return []
}
)
render(<App />)
await waitFor(() =>
expect(conversationQueueChangeListener).toBeDefined()
)
act(() => {
conversationQueueChangeListener?.(firstConversationId)
})
await waitFor(() =>
expect(api.conversationQueue.list).toHaveBeenCalledWith(
firstConversationId
)
)
await act(async () => {
conversationQueueChangeListener?.(secondConversationId)
await new Promise((resolve) => window.setTimeout(resolve, 0))
})
act(() => {
firstRefresh.resolve([
{
id: '00000000-0000-4000-8000-000000000956',
conversationId: firstConversationId,
source: 'user',
label: '不能被后续刷新丢弃',
createdAt: '2026-08-20T09:03:00.000Z'
}
])
})
expect(
await screen.findByText('不能被后续刷新丢弃')
).toBeInTheDocument()
await waitFor(() =>
expect(api.conversationQueue.list).toHaveBeenCalledWith(
secondConversationId
)
)
})
it('discovers product Tasks through their Conversation without exposing Runs', async () => {
const conversationId =
'00000000-0000-4000-8000-000000000821'
@@ -969,8 +1207,10 @@ describe('App', () => {
const taskChild = taskTitle.closest('button')
expect(taskChild).not.toBeNull()
expect(
taskChild?.querySelector('.conversation-task-child__icon')
).toHaveClass('conversation-task-child__icon--idle')
taskChild?.querySelector('.task-status-dot')
).toHaveClass('task-status-dot--idle')
expect(taskChild?.querySelector('.lucide-clock-fading'))
.not.toBeInTheDocument()
expect(
taskChild?.querySelector('.conversation-task-child__meta')
).toHaveTextContent('Execute · 每周 · 空闲')
@@ -982,6 +1222,81 @@ describe('App', () => {
expect(within(taskRegion).getByText('Execute')).toBeInTheDocument()
})
it('uses shared status dots for Conversation Task children', async () => {
const conversationId =
'00000000-0000-4000-8000-000000000824'
const conversation: ConversationSnapshot = {
id: conversationId,
projectId,
title: '任务状态会话',
updatedAt: Date.now(),
messages: []
}
const tasks: AssistantTask[] = [
{
id: '00000000-0000-4000-8000-000000000825',
projectId,
conversationId,
title: '已完成任务',
instructions: '检查完成状态',
origin: 'schedule',
status: 'completed',
createdAt: '2026-08-19T03:00:00.000Z'
},
{
id: '00000000-0000-4000-8000-000000000826',
projectId,
conversationId,
title: '运行中任务',
instructions: '检查运行状态',
origin: 'schedule',
status: 'running',
createdAt: '2026-08-19T02:00:00.000Z'
},
{
id: '00000000-0000-4000-8000-000000000827',
projectId,
conversationId,
title: '失败任务',
instructions: '检查失败状态',
origin: 'schedule',
status: 'failed',
createdAt: '2026-08-19T01:00:00.000Z'
}
]
vi.mocked(api.conversations.list).mockResolvedValue([
conversation
])
vi.mocked(api.tasks.list).mockResolvedValue(tasks)
render(<App />)
fireEvent.click(
await screen.findByLabelText(
'展开或折叠“任务状态会话”中的 3 个任务'
)
)
for (const [title, status, label] of [
['已完成任务', 'completed', '已完成'],
['运行中任务', 'running', '运行中'],
['失败任务', 'failed', '失败']
] as const) {
const taskTitle = await screen.findByText(title, {
selector: '.conversation-task-child__title'
})
const taskChild = taskTitle.closest('button')
expect(
taskChild?.querySelector('.task-status-dot')
).toHaveClass(`task-status-dot--${status}`)
expect(
taskChild?.querySelector('.conversation-task-child__meta')
).toHaveTextContent(label)
}
expect(
document.querySelector('.conversation-task-child__icon')
).not.toBeInTheDocument()
})
it('routes scheduled Task approvals to the associated Conversation', async () => {
const conversationId =
'00000000-0000-4000-8000-000000000831'
@@ -3436,6 +3751,86 @@ describe('App', () => {
expect(runtimeButton).toBeEnabled()
})
it('queues another ordinary message while the Conversation is running', async () => {
render(<App />)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '第一条长消息' }
})
fireEvent.click(await screen.findByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const secondItem = {
id: '00000000-0000-4000-8000-000000000941',
conversationId: run.mock.calls[0]![0].conversationId,
source: 'user' as const,
label: '第二条排队消息',
createdAt: '2026-08-20T09:01:00.000Z'
}
vi.mocked(api.conversationQueue.enqueueUser)
.mockResolvedValueOnce(secondItem)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: secondItem.label }
})
fireEvent.click(
await screen.findByLabelText('加入待发送队列')
)
await waitFor(() =>
expect(api.conversationQueue.enqueueUser).toHaveBeenCalledTimes(2)
)
expect(run).toHaveBeenCalledTimes(1)
expect(
screen.queryByText(/当前对话已有任务正在运行/u)
).not.toBeInTheDocument()
const firstRequest = run.mock.calls[0]![0]
act(() => {
agentListener?.({
requestId: firstRequest.requestId,
type: 'done'
})
})
const secondInput = vi.mocked(
api.conversationQueue.enqueueUser
).mock.calls[1]![0]
act(() => {
conversationQueueDispatchListener?.({
item: secondItem,
input: secondInput
})
})
await waitFor(() => expect(run).toHaveBeenCalledTimes(2))
expect(run.mock.calls[1]![0]).toMatchObject({
queueItemId: secondItem.id,
prompt: secondItem.label
})
})
it('restores a queued message when Agent preflight rejects it', async () => {
run.mockRejectedValueOnce(new Error('Runtime 暂不可用'))
render(<App />)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '需要稍后重试的消息' }
})
fireEvent.click(await screen.findByLabelText('发送'))
await waitFor(() =>
expect(api.conversationQueue.releaseUser).toHaveBeenCalledOnce()
)
expect(
await screen.findByText('Runtime 暂不可用')
).toBeInTheDocument()
expect(
screen.queryByText('需要稍后重试的消息')
).not.toBeInTheDocument()
expect(
screen.queryByLabelText('停止生成')
).not.toBeInTheDocument()
})
it('keeps sent documents and images in conversation history', async () => {
const documentAttachment = {
id: '00000000-0000-4000-8000-000000000301',
@@ -4007,9 +4402,7 @@ describe('App', () => {
await waitFor(() =>
expect(api.workspace.getChanges).toHaveBeenCalledWith(projectId)
)
fireEvent.change(screen.getByLabelText('当前项目'), {
target: { value: secondProject.id }
})
selectProjectOption(secondProject.name)
await waitFor(() =>
expect(api.workspace.getChanges).toHaveBeenCalledWith(
secondProject.id
@@ -4296,18 +4689,16 @@ describe('App', () => {
render(<App />)
expect(await screen.findByLabelText('当前项目')).toHaveValue(
secondProject.id
)
expect(
await screen.findByRole('button', { name: '当前项目' })
).toHaveTextContent(secondProject.name)
expect(
screen.getByRole('button', {
name: '工作模式:Execute · 受控执行'
})
).toBeEnabled()
fireEvent.change(screen.getByLabelText('当前项目'), {
target: { value: project.id }
})
selectProjectOption(project.name)
await waitFor(() =>
expect(
localStorage.getItem('goodbuddy.active-project.v1')
@@ -4333,10 +4724,8 @@ describe('App', () => {
])
render(<App />)
await screen.findByRole('option', { name: '微信 ClawBot' })
fireEvent.change(await screen.findByLabelText('当前项目'), {
target: { value: channelProject.id }
})
await screen.findByRole('button', { name: '当前项目' })
selectProjectOption(channelProject.name)
expect(
screen.queryByRole('button', { name: /新建对话/u })
@@ -4482,9 +4871,8 @@ describe('App', () => {
render(<App />)
const weixinProject = channelProjects[0]!
fireEvent.change(await screen.findByLabelText('当前项目'), {
target: { value: weixinProject.id }
})
await screen.findByRole('button', { name: '当前项目' })
selectProjectOption(weixinProject.name)
fireEvent.click(
await screen.findByRole('button', { name: '打开设置' })
)
@@ -4646,10 +5034,8 @@ describe('App', () => {
])
render(<App />)
await screen.findByRole('option', { name: '微信 ClawBot' })
fireEvent.change(await screen.findByLabelText('当前项目'), {
target: { value: channelProject.id }
})
await screen.findByRole('button', { name: '当前项目' })
selectProjectOption(channelProject.name)
expect(
screen.getAllByRole('button', {
@@ -4672,9 +5058,9 @@ describe('App', () => {
render(<App />)
expect(await screen.findByLabelText('当前项目')).toHaveValue(
project.id
)
expect(
await screen.findByRole('button', { name: '当前项目' })
).toHaveTextContent(project.name)
await waitFor(() =>
expect(
localStorage.getItem('goodbuddy.active-project.v1')
@@ -6360,8 +6746,8 @@ describe('App', () => {
project.name
)
)
expect(screen.getByLabelText('当前项目')).toHaveValue(
secondProject.id
expect(screen.getByRole('button', { name: '当前项目' })).toHaveTextContent(
secondProject.name
)
})
@@ -7230,9 +7616,7 @@ describe('App', () => {
await screen.findByRole('tab', { name: '心跳计划' })
)
expect(await screen.findAllByText('旧项目心跳')).not.toHaveLength(0)
fireEvent.change(screen.getByLabelText('当前项目'), {
target: { value: secondProject.id }
})
selectProjectOption(secondProject.name)
fireEvent.click(
screen.getByRole('button', { name: '智能心跳' })
)
+451 -87
View File
@@ -14,7 +14,6 @@ import {
HeartPulse,
Info,
Library,
ListTodo,
LoaderCircle,
Maximize2,
MessageSquarePlus,
@@ -64,6 +63,8 @@ import type {
BrowserLiveState,
ContextAttachment,
ContextFileSelectionProgress,
ConversationQueueDispatch,
ConversationQueueUserInput,
KnowledgeSearchReference,
KnowledgeSnapshot,
RuntimeCustomizationSettings,
@@ -104,6 +105,7 @@ import type {
AssistantTask,
TokenUsageSummary,
ConversationMessage,
ConversationQueueItem,
ConversationSnapshot,
ConversationAttachment,
ConversationContextCompressionMarker,
@@ -165,6 +167,7 @@ import {
type CustomTaskDestination
} from './CustomTaskDialog'
import { ConversationTaskStrip } from './ConversationTaskStrip'
import { ConversationInputQueue } from './ConversationInputQueue'
import { OverflowMarquee } from './OverflowMarquee'
import { findTaskSchedule } from './TaskScheduleActions'
import type { SettingsCategoryId } from './settings-categories'
@@ -242,6 +245,29 @@ const recentCachedConversations = 5
const maximumCachedWorkspaceViews = 4
const recentCachedWorkspaceViews = 3
function sameConversationQueueItems(
current: ConversationQueueItem[],
next: ConversationQueueItem[]
): boolean {
return (
current.length === next.length &&
current.every((item, index) => {
const candidate = next[index]
return (
candidate !== undefined &&
item.id === candidate.id &&
item.conversationId === candidate.conversationId &&
item.source === candidate.source &&
item.label === candidate.label &&
item.createdAt === candidate.createdAt &&
item.scheduleRunId === candidate.scheduleRunId &&
item.scheduleId === candidate.scheduleId &&
item.taskId === candidate.taskId
)
})
)
}
type AppNotification = {
id: string
message: string
@@ -1849,6 +1875,13 @@ function App(): React.JSX.Element {
const [assistantSchedules, setAssistantSchedules] = useState<
AssistantSchedule[]
>([])
const [conversationQueueItems, setConversationQueueItems] = useState<
ConversationQueueItem[]
>([])
const dispatchedConversationQueueItems = useRef(new Set<string>())
const conversationQueueDispatchRef = useRef<
(dispatch: ConversationQueueDispatch) => void
>(() => undefined)
const [selectedAssistantTaskId, setSelectedAssistantTaskId] =
useState<string>()
const [expandedTaskConversationIds, setExpandedTaskConversationIds] =
@@ -2307,6 +2340,32 @@ function App(): React.JSX.Element {
requestAnimationFrame(() => sidebarToggleRef.current?.focus())
}, [])
useEffect(() => {
if (!conversationStoreReady) {
return
}
const conversationIds = [
...new Set(
conversationsRef.current
.filter((conversation) => !conversation.remote)
.map((conversation) => conversation.id)
)
]
void Promise.all(
conversationIds.map((conversationId) =>
window.goodbuddy.conversationQueue.ready(conversationId)
)
).catch(() => {
notify({
tone: 'error',
message: tRef.current(
'notices.conversationQueueResumeFailed'
),
dedupeKey: 'conversation-queue-resume'
})
})
}, [conversationStoreReady])
useEffect(() => {
const sweep = (): void => {
const now = Date.now()
@@ -4095,6 +4154,19 @@ function App(): React.JSX.Element {
})
activeRuns.current.delete(event.requestId)
setConversationActivity(run.conversationId, false)
requestAnimationFrame(() => {
void window.goodbuddy.conversationQueue
.ready(run.conversationId)
.catch(() => {
notify({
tone: 'error',
message: tRef.current(
'notices.conversationQueueResumeFailed'
),
dedupeKey: 'conversation-queue-resume'
})
})
})
flushConversationPersistenceAfterRenderRef.current = true
}
},
@@ -4357,6 +4429,123 @@ function App(): React.JSX.Element {
}
}, [conversationStoreReady])
useEffect(() => {
let active = true
let refreshInFlight = false
let refreshQueued = false
let refreshTimer: number | undefined
let refreshAll = false
const pendingConversationIds = new Set<string>()
const scheduleRefresh = (): void => {
if (refreshTimer !== undefined || refreshInFlight) {
refreshQueued = true
return
}
refreshTimer = window.setTimeout(() => {
refreshTimer = undefined
refreshQueued = false
const conversationIds = refreshAll
? undefined
: [...pendingConversationIds]
refreshAll = false
pendingConversationIds.clear()
refresh(conversationIds)
}, 0)
}
const refresh = (conversationIds?: string[]): void => {
refreshInFlight = true
const affectedConversationIds = new Set(
conversationIds ?? []
)
const reads = conversationIds
? conversationIds.map(async (conversationId) => ({
conversationId,
items:
await window.goodbuddy.conversationQueue.list(
conversationId
)
}))
: [
window.goodbuddy.conversationQueue
.list()
.then((items) => ({
conversationId: undefined,
items
}))
]
void Promise.all(reads)
.then((results) => {
if (!active) {
return
}
setConversationQueueItems((current) => {
const allItems = results.find(
(result) => result.conversationId === undefined
)?.items
const next = allItems
? allItems
: [
...current.filter(
(item) =>
!affectedConversationIds.has(
item.conversationId
)
),
...results.flatMap((result) => result.items)
].sort(
(left, right) =>
left.createdAt.localeCompare(right.createdAt) ||
left.id.localeCompare(right.id)
)
return sameConversationQueueItems(current, next)
? current
: next
})
})
.catch(() => {
if (active) {
notify({
tone: 'error',
message: tRef.current(
'notices.conversationQueueReadFailed'
),
dedupeKey: 'conversation-queue-read'
})
}
})
.finally(() => {
refreshInFlight = false
if (active && refreshQueued) {
refreshQueued = false
scheduleRefresh()
}
})
}
const queueRefresh = (conversationId?: string): void => {
if (conversationId) {
pendingConversationIds.add(conversationId)
} else {
refreshAll = true
}
scheduleRefresh()
}
refresh()
const remove =
window.goodbuddy.conversationQueue.onChanged(queueRefresh)
const removeDispatch =
window.goodbuddy.conversationQueue.onDispatch((dispatch) =>
conversationQueueDispatchRef.current(dispatch)
)
return () => {
active = false
if (refreshTimer !== undefined) {
window.clearTimeout(refreshTimer)
}
remove()
removeDispatch()
}
}, [])
useEffect(() => {
activityRecordsRef.current = activityRecords
const timeout = window.setTimeout(() => {
@@ -5591,38 +5780,65 @@ function App(): React.JSX.Element {
})
}, [])
const submit = async (): Promise<void> => {
const submit = async (
queuedDispatch?: ConversationQueueDispatch
): Promise<void> => {
const queuedInput = queuedDispatch?.input
const releaseQueuedItem = async (): Promise<void> => {
if (!queuedDispatch) {
return
}
try {
await window.goodbuddy.conversationQueue.releaseUser(
queuedDispatch.item.id
)
} catch {
notify({
tone: 'error',
message: t('notices.conversationQueueReleaseFailed')
})
}
}
const conversationSnapshot = queuedInput
? conversationsRef.current.find(
(conversation) =>
conversation.id === queuedInput.conversationId
)
: activeConversation
const command =
!queuedInput &&
activeRuntimeSelection?.provider === 'opencode'
? runtimeNativeSnapshot?.commands.find(
(candidate) =>
candidate.id === selectedRuntimeCommand
)
: undefined
const commandArguments = input.trim()
const prompt = command
const commandArguments = queuedInput ? '' : input.trim()
const prompt = queuedInput?.prompt ?? (command
? `/${command.name}${
commandArguments ? ` ${commandArguments}` : ''
}`
: commandArguments
if (!prompt || !activeConversation) {
: commandArguments)
if (!prompt || !conversationSnapshot) {
await releaseQueuedItem()
return
}
if (selectingContextFilesRef.current) {
if (!queuedInput && selectingContextFilesRef.current) {
notify({
tone: 'info',
message: t('composer.attachmentProgress.waitBeforeSending')
})
return
}
if (activeConversation.remote) {
if (conversationSnapshot.remote) {
notify({
tone: 'info',
message: t('notices.remoteConversationReadOnly')
})
await releaseQueuedItem()
return
}
if (!runtime) {
if (!queuedInput && !runtime) {
notify({
tone: 'info',
message: t('runtime.loadingRetry')
@@ -5630,8 +5846,9 @@ function App(): React.JSX.Element {
return
}
if (
runtimeSwitching ||
runtimeStatusKey !== activeRuntimeSelectionKey
!queuedInput &&
(runtimeSwitching ||
runtimeStatusKey !== activeRuntimeSelectionKey)
) {
notify({
tone: 'info',
@@ -5639,73 +5856,145 @@ function App(): React.JSX.Element {
})
return
}
if (!runtime.available) {
return
}
if (
preparingConversations.current.has(activeConversation.id) ||
[...activeRuns.current.values()].some(
(run) => run.conversationId === activeConversation.id
)
) {
notify({
tone: 'info',
message: t('notices.conversationAlreadyRunning')
})
if (!queuedInput && !runtime?.available) {
return
}
const requestId = crypto.randomUUID()
const conversationId = activeConversation.id
const attachmentSnapshot = attachments.slice(0, 8)
const historySnapshot = activeConversation.messages
const conversationId = conversationSnapshot.id
const attachmentSnapshot = (
queuedInput?.attachments ?? attachments
).slice(0, 8)
const historySnapshot = conversationSnapshot.messages
const retainedHistorySnapshot = historySnapshot
.filter(
(message) =>
message.state === 'complete' && message.content.trim()
)
.slice(-500)
const projectIdSnapshot = activeProjectId || undefined
const projectIdSnapshot = queuedInput
? queuedInput.projectId
: activeProjectId || undefined
const knowledgeRetrievalModeSnapshot =
activeConversation.knowledgeRetrievalMode ?? 'auto'
const runtimeSelectionSnapshot = activeRuntimeSelection
queuedInput?.knowledgeRetrievalMode ??
conversationSnapshot.knowledgeRetrievalMode ??
'auto'
const runtimeSelectionSnapshot =
queuedInput?.runtimeSelection ?? activeRuntimeSelection
if (!runtimeSelectionSnapshot) {
notify({ tone: 'info', message: t('runtime.notSelected') })
await releaseQueuedItem()
return
}
const runtimeControlSnapshot: RuntimeControl | undefined =
runtimeSelectionSnapshot.provider === 'opencode' &&
(selectedRuntimeAgent || command)
? {
provider: 'opencode',
...(selectedRuntimeAgent
? { agent: selectedRuntimeAgent }
: {}),
...(command
? {
command: {
name: command.name,
arguments: commandArguments
}
}
: {})
}
: runtimeSelectionSnapshot.provider === 'continue' &&
selectedContinuePreset
queuedInput
? queuedInput.runtimeControl
: runtimeSelectionSnapshot.provider === 'opencode' &&
(selectedRuntimeAgent || command)
? {
provider: 'continue',
presetId: selectedContinuePreset
provider: 'opencode',
...(selectedRuntimeAgent
? { agent: selectedRuntimeAgent }
: {}),
...(command
? {
command: {
name: command.name,
arguments: commandArguments
}
}
: {})
}
: undefined
const selectedExpertSnapshot =
runtime.capability === 'image-generation' ? '' : selectedExpertId
const workModeSnapshot = effectiveWorkMode
: runtimeSelectionSnapshot.provider === 'continue' &&
selectedContinuePreset
? {
provider: 'continue',
presetId: selectedContinuePreset
}
: undefined
const selectedExpertSnapshot = queuedInput
? queuedInput.teamMode
? 'team'
: queuedInput.expertId ?? ''
: runtime?.capability === 'image-generation'
? ''
: selectedExpertId
const workModeSnapshot =
normalizeInteractiveWorkMode(
queuedInput?.workMode ?? effectiveWorkMode
)
const knowledgeLibraryIdsSnapshot =
queuedInput?.knowledgeLibraryIds ?? enabledKnowledgeLibraryIds
const smartRoutingSnapshot =
queuedInput?.smartRouting ??
(!queuedInput &&
runtime?.capability !== 'image-generation' &&
runtimeSettings?.subagentSmartRoutingEnabled === true &&
!selectedExpertSnapshot &&
supportsSubagentSmartRouting(workModeSnapshot)
? true
: undefined)
if (!queuedInput) {
const queueInput: ConversationQueueUserInput = {
conversationId,
...(projectIdSnapshot ? { projectId: projectIdSnapshot } : {}),
runtimeSelection: runtimeSelectionSnapshot,
...(runtimeControlSnapshot
? { runtimeControl: runtimeControlSnapshot }
: {}),
...(selectedExpertSnapshot &&
selectedExpertSnapshot !== 'team'
? { expertId: selectedExpertSnapshot }
: {}),
...(selectedExpertSnapshot === 'team'
? { teamMode: true }
: {}),
...(smartRoutingSnapshot
? { smartRouting: true }
: {}),
workMode: workModeSnapshot,
includeMemoryContext: !command,
prompt,
attachments: attachmentSnapshot,
knowledgeLibraryIds: knowledgeLibraryIdsSnapshot,
knowledgeRetrievalMode: knowledgeRetrievalModeSnapshot
}
try {
await window.goodbuddy.conversationQueue.enqueueUser(queueInput)
setComposerMenuOpen(undefined)
setRuntimeMenuOpen(false)
setInput('')
updateAttachments([])
if (command) {
setSelectedRuntimeCommand('')
}
} catch (reason) {
notify({
tone: 'error',
message:
reason instanceof Error
? reason.message
: t('notices.sendFailed')
})
}
return
}
if (
dispatchedConversationQueueItems.current.has(
queuedDispatch.item.id
)
) {
return
}
dispatchedConversationQueueItems.current.add(
queuedDispatch.item.id
)
setComposerMenuOpen(undefined)
setRuntimeMenuOpen(false)
preparingConversations.current.add(conversationId)
setConversationActivity(conversationId, true)
setInput('')
updateAttachments([])
const userMessage: Message = {
id: crypto.randomUUID(),
role: 'user',
@@ -5733,13 +6022,10 @@ function App(): React.JSX.Element {
: conversation
)
)
const memoryContext =
runtime.capability === 'image-generation'
? ''
: buildMemoryContext(assistantMemories)
const executionPrompt = command
? prompt
: memoryContext
const memoryContext = queuedInput.includeMemoryContext
? buildMemoryContext(assistantMemories)
: ''
const executionPrompt = memoryContext
? `${prompt}\n\n${memoryContext}`
: prompt
const assistantMessage: Message = {
@@ -5810,6 +6096,7 @@ function App(): React.JSX.Element {
requestId,
conversationId,
projectId: projectIdSnapshot,
queueItemId: queuedDispatch.item.id,
runtimeSelection: runtimeSelectionSnapshot,
runtimeControl: runtimeControlSnapshot,
expertId:
@@ -5817,22 +6104,16 @@ function App(): React.JSX.Element {
? selectedExpertSnapshot
: undefined,
teamMode: selectedExpertSnapshot === 'team',
smartRouting:
runtime.capability !== 'image-generation' &&
runtimeSettings?.subagentSmartRoutingEnabled === true &&
!selectedExpertSnapshot &&
supportsSubagentSmartRouting(workModeSnapshot)
? true
: undefined,
smartRouting: smartRoutingSnapshot,
workMode: workModeSnapshot,
prompt: executionPrompt,
knowledgeLibraryIds: enabledKnowledgeLibraryIds,
knowledgeLibraryIds: knowledgeLibraryIdsSnapshot,
knowledgeRetrievalMode: knowledgeRetrievalModeSnapshot,
contextIds: attachmentSnapshot.map(
(attachment) => attachment.id
),
contextCompressionState:
activeConversation.contextCompressionState,
conversationSnapshot.contextCompressionState,
history: retainedHistorySnapshot.map((message) => ({
role: message.role,
content: message.content
@@ -5846,24 +6127,58 @@ function App(): React.JSX.Element {
for (const attachment of attachmentSnapshot) {
void window.goodbuddy.context.remove(attachment.id)
}
if (command) {
setSelectedRuntimeCommand('')
}
} catch (error) {
preparingConversations.current.delete(conversationId)
activeRuns.current.delete(requestId)
setConversationActivity(conversationId, false)
for (const attachment of attachmentSnapshot) {
void window.goodbuddy.context.remove(attachment.id)
}
handleAgentEvent({
requestId,
type: 'error',
status: 'failed',
setConversations((current) =>
current.map((conversation) =>
conversation.id === conversationId
? {
...conversation,
title:
conversationSnapshot.title === '新对话' &&
conversation.title === prompt.slice(0, 24)
? conversationSnapshot.title
: conversation.title,
updatedAt: Date.now(),
messages: conversation.messages.filter(
(message) =>
message.id !== userMessage.id &&
message.id !== assistantMessage.id
)
}
: conversation
)
)
setAssistantTasks((current) =>
current.filter((task) => task.id !== requestId)
)
setActivityRecords((current) =>
current.filter((record) => record.requestId !== requestId)
)
notify({
tone: 'error',
message:
error instanceof Error ? error.message : t('notices.sendFailed')
})
await releaseQueuedItem()
} finally {
dispatchedConversationQueueItems.current.delete(
queuedDispatch.item.id
)
}
}
useLayoutEffect(() => {
conversationQueueDispatchRef.current = (dispatch) => {
void submit(dispatch)
}
})
const compactRuntimeContext = async (): Promise<void> => {
if (
!activeConversation ||
@@ -6624,6 +6939,40 @@ function App(): React.JSX.Element {
activeConversation?.messages.some(
(message) => message.state === 'streaming'
) ?? false
const activeConversationQueueItems = useMemo(
() =>
conversationQueueItems.filter(
(item) => item.conversationId === activeId
),
[activeId, conversationQueueItems]
)
const conversationExecutionRunning =
isRunning ||
assistantTasks.some(
(task) =>
task.conversationId === activeId &&
(task.status === 'running' ||
task.status === 'waiting_approval')
)
const handleConversationQueueError = useCallback(
(message: string): void => {
notify({
tone: 'error',
message
})
},
[notify]
)
const interruptConversationQueueItem = useCallback(
(itemId: string) =>
window.goodbuddy.conversationQueue.interruptAndRun(itemId),
[]
)
const removeConversationQueueItem = useCallback(
(itemId: string) =>
window.goodbuddy.conversationQueue.remove(itemId),
[]
)
const runtimeAgentControlAvailable =
activeRuntimeSelection?.provider === 'opencode' &&
runtimeAgentOptions.length > 1
@@ -7171,10 +7520,9 @@ function App(): React.JSX.Element {
onClick={() => openAssistantTask(task)}
type="button"
>
<ListTodo
<span
aria-hidden="true"
className={`conversation-task-child__icon conversation-task-child__icon--${task.status}`}
size={13}
className={`task-status-dot task-status-dot--${task.status}`}
/>
<span className="conversation-task-child__title">
{task.title}
@@ -7498,6 +7846,13 @@ function App(): React.JSX.Element {
</div>
) : (
<>
<ConversationInputQueue
items={activeConversationQueueItems}
onError={handleConversationQueueError}
onInterruptAndRun={interruptConversationQueueItem}
onRemove={removeConversationQueueItem}
running={conversationExecutionRunning}
/>
<div className="composer">
{(attachments.length > 0 || selectingContextFiles) && (
<div
@@ -8087,7 +8442,8 @@ function App(): React.JSX.Element {
</div>
</div>
</div>
{isRunning ? (
<div className="composer__submit-actions">
{isRunning && (
<button
className="send-button send-button--stop"
type="button"
@@ -8101,11 +8457,15 @@ function App(): React.JSX.Element {
size={15}
/>
</button>
) : (
)}
<button
className="send-button"
type="button"
aria-label={t('composer.send')}
aria-label={
conversationExecutionRunning
? t('composer.queueMessage')
: t('composer.send')
}
disabled={
(!input.trim() &&
!(
@@ -8122,11 +8482,15 @@ function App(): React.JSX.Element {
runtimeStatusKey !== activeRuntimeSelectionKey
}
onClick={() => void submit()}
title={t('composer.sendTitle')}
title={
conversationExecutionRunning
? t('composer.queueMessageTitle')
: t('composer.sendTitle')
}
>
<Send aria-hidden="true" size={17} />
</button>
)}
</div>
</div>
{runtimeControlsProvider && (
<div
+2 -2
View File
@@ -3,7 +3,7 @@ import {
Download,
FileText,
Library,
ListTodo,
ClockFading,
ShieldCheck,
TerminalSquare,
UserRound
@@ -383,7 +383,7 @@ function ChatMessageRowView({
})}
className="message__task"
>
<ListTodo aria-hidden="true" size={12} />
<ClockFading aria-hidden="true" size={12} />
{message.task.title}
</span>
)}
@@ -0,0 +1,107 @@
import {
cleanup,
fireEvent,
render,
screen,
within,
waitFor
} from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ConversationInputQueue } from './ConversationInputQueue'
afterEach(cleanup)
describe('ConversationInputQueue', () => {
const items = [
{
id: '00000000-0000-4000-8000-000000000901',
conversationId: '00000000-0000-4000-8000-000000000900',
source: 'schedule' as const,
label: '每日汇总',
createdAt: '2026-08-20T09:00:00.000Z'
},
{
id: '00000000-0000-4000-8000-000000000902',
conversationId: '00000000-0000-4000-8000-000000000900',
source: 'user' as const,
label: '再补充一段说明',
createdAt: '2026-08-20T09:01:00.000Z'
}
]
it('shows both queue sources and exposes interrupt and remove actions', async () => {
const onInterruptAndRun = vi.fn(async () => undefined)
const onRemove = vi.fn(async () => undefined)
const { container } = render(
<ConversationInputQueue
items={items}
onError={vi.fn()}
onInterruptAndRun={onInterruptAndRun}
onRemove={onRemove}
running
/>
)
const queue = screen.getByRole('region', {
name: '对话待发送队列'
})
expect(queue).toBeInTheDocument()
expect(queue.querySelector('header')).not.toBeInTheDocument()
expect(
within(queue).getAllByRole('listitem')
).toHaveLength(2)
expect(screen.queryByText('待发送(2)')).not.toBeInTheDocument()
expect(
screen.queryByText('当前回复结束后按顺序执行')
).not.toBeInTheDocument()
expect(screen.getByText('每日汇总')).toBeInTheDocument()
expect(screen.getByText('再补充一段说明')).toBeInTheDocument()
expect(screen.queryByText('定时任务')).not.toBeInTheDocument()
expect(screen.queryByText('消息')).not.toBeInTheDocument()
expect(
container.querySelector('.lucide-clock-fading')
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', {
name: '中断当前回复并优先执行“每日汇总”'
})
)
await waitFor(() =>
expect(onInterruptAndRun).toHaveBeenCalledWith(items[0]!.id)
)
fireEvent.click(
screen.getByRole('button', {
name: '从待发送队列删除“再补充一段说明”'
})
)
await waitFor(() =>
expect(onRemove).toHaveBeenCalledWith(items[1]!.id)
)
})
it('uses run-now wording while idle and reports action errors', async () => {
const onError = vi.fn()
render(
<ConversationInputQueue
items={[items[0]!]}
onError={onError}
onInterruptAndRun={vi.fn(async () => {
throw new Error('队列项已变化')
})}
onRemove={vi.fn(async () => undefined)}
running={false}
/>
)
const action = screen.getByRole('button', {
name: '立即运行“每日汇总”'
})
expect(action).toHaveTextContent('立即运行')
fireEvent.click(action)
await waitFor(() =>
expect(onError).toHaveBeenCalledWith('队列项已变化')
)
})
})
+156
View File
@@ -0,0 +1,156 @@
import {
ClockFading,
CornerDownRight,
ListRestart,
LoaderCircle,
Trash2
} from 'lucide-react'
import { memo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { ConversationQueueItem } from '../../shared/assistant-contracts'
type ConversationInputQueueProps = {
items: ConversationQueueItem[]
running: boolean
onError: (message: string) => void
onInterruptAndRun: (itemId: string) => Promise<void>
onRemove: (itemId: string) => Promise<void>
}
export const ConversationInputQueue = memo(
function ConversationInputQueue({
items,
running,
onError,
onInterruptAndRun,
onRemove
}: ConversationInputQueueProps): React.JSX.Element | null {
const { t } = useTranslation('app')
const [pendingAction, setPendingAction] = useState<{
itemId: string
action: 'run' | 'remove'
}>()
if (items.length === 0) {
return null
}
const runAction = async (
itemId: string,
actionName: 'run' | 'remove',
action: () => Promise<void>
): Promise<void> => {
setPendingAction({ itemId, action: actionName })
try {
await action()
} catch (reason) {
onError(
reason instanceof Error
? reason.message
: t('composer.queue.actionFailed')
)
} finally {
setPendingAction(undefined)
}
}
return (
<section
aria-label={t('composer.queue.ariaLabel')}
className="conversation-input-queue"
>
<ol>
{items.map((item) => {
const busy = pendingAction?.itemId === item.id
const sourceLabel =
item.source === 'schedule'
? t('composer.queue.scheduledTask')
: t('composer.queue.message')
return (
<li key={item.id}>
<span
aria-label={sourceLabel}
className="conversation-input-queue__source"
role="img"
>
{item.source === 'schedule' ? (
<ClockFading aria-hidden="true" size={16} />
) : (
<CornerDownRight aria-hidden="true" size={16} />
)}
</span>
<span
className="conversation-input-queue__content"
title={item.label}
>
{item.label}
</span>
<span className="conversation-input-queue__actions">
<button
aria-label={t(
running
? 'composer.queue.interruptAria'
: 'composer.queue.runNowAria',
{ title: item.label }
)}
disabled={pendingAction !== undefined}
onClick={() =>
void runAction(item.id, 'run', () =>
onInterruptAndRun(item.id)
)
}
title={
running
? t('composer.queue.interrupt')
: t('composer.queue.runNow')
}
type="button"
>
{busy && pendingAction.action === 'run' ? (
<LoaderCircle
aria-hidden="true"
className="context-chip__spinner"
size={13}
/>
) : (
<ListRestart aria-hidden="true" size={13} />
)}
<span>
{running
? t('composer.queue.interrupt')
: t('composer.queue.runNow')}
</span>
</button>
<button
aria-label={t('composer.queue.removeAria', {
title: item.label
})}
className="conversation-input-queue__remove"
disabled={pendingAction !== undefined}
onClick={() =>
void runAction(item.id, 'remove', () =>
onRemove(item.id)
)
}
title={t('composer.queue.remove')}
type="button"
>
{busy && pendingAction.action === 'remove' ? (
<LoaderCircle
aria-hidden="true"
className="context-chip__spinner"
size={13}
/>
) : (
<Trash2 aria-hidden="true" size={13} />
)}
</button>
</span>
</li>
)
})}
</ol>
</section>
)
}
)
+2 -2
View File
@@ -2,7 +2,7 @@ import {
ChevronDown,
ChevronUp,
CircleAlert,
ListTodo,
ClockFading,
Plus
} from 'lucide-react'
import { useMemo, useState } from 'react'
@@ -78,7 +78,7 @@ export function ConversationTaskStrip({
}}
type="button"
>
<ListTodo aria-hidden="true" size={15} />
<ClockFading aria-hidden="true" size={15} />
<strong>{t('taskStrip.title')}</strong>
<span>
{t('taskStrip.count', {
+2 -2
View File
@@ -1,4 +1,4 @@
import { CalendarClock, FolderKanban, ShieldCheck, X } from 'lucide-react'
import { ClockFading, FolderKanban, ShieldCheck, X } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
@@ -166,7 +166,7 @@ export function CustomTaskDialog({
<header className="custom-task-dialog__header">
<div>
<span className="custom-task-dialog__eyebrow">
<CalendarClock aria-hidden="true" size={14} />
<ClockFading aria-hidden="true" size={14} />
{t('customTask.eyebrow')}
</span>
<h2 id="custom-task-title">{t('customTask.title')}</h2>
+2 -2
View File
@@ -6,7 +6,7 @@ import {
ExternalLink,
FileText,
FolderTree,
ListTodo,
ClockFading,
Monitor,
PanelRightClose,
Plus,
@@ -604,7 +604,7 @@ export function RightAssistantSidebar({
)}
<h3>
<ListTodo size={15} />
<ClockFading size={15} />
{t('sidebar.tasks.taskIndexTitle')}
</h3>
<div className="task-center__filters">
+2 -2
View File
@@ -1,5 +1,5 @@
import {
CalendarClock,
ClockFading,
Pause,
Play,
Trash2
@@ -91,7 +91,7 @@ export function TaskScheduleActions({
{schedule.enabled ? (
<Pause aria-hidden="true" size={12} />
) : (
<CalendarClock aria-hidden="true" size={12} />
<ClockFading aria-hidden="true" size={12} />
)}
{schedule.enabled
? t('task.actions.pause')
+21 -3
View File
@@ -412,6 +412,21 @@ export const app = {
stop: 'Stop generating',
send: 'Send',
sendTitle: 'Send message',
queueMessage: 'Queue message',
queueMessageTitle: 'Send after the current response finishes',
queue: {
ariaLabel: 'Conversation send queue',
scheduledTask: 'Scheduled task',
message: 'Message',
interrupt: 'Interrupt and run',
runNow: 'Run now',
interruptAria:
'Interrupt the current response and run “{{title}}” next',
runNowAria: 'Run “{{title}}” now',
remove: 'Remove',
removeAria: 'Remove “{{title}}” from the send queue',
actionFailed: 'Failed to update the send queue'
},
shortcut: 'Quick access: ',
context: {
confirmedTokenCount: 'Latest call {{used}}',
@@ -560,15 +575,18 @@ export const app = {
imageDownloadStarted: 'Image download started',
remoteConversationReadOnly:
'Continue a remote conversation from its messaging app',
conversationAlreadyRunning:
'A task is already running in this conversation. Wait for it to finish or stop it first.',
sendFailed: 'Failed to send',
stopFailed: 'Failed to stop generating. Try again.',
projectNotLoaded: 'The current project has not loaded yet.',
browserControlUnavailable:
'The built-in browser has not loaded. Restart GoodBuddy.',
browserStopFailed: 'Failed to stop the browser. Try again.',
scheduleStarted: 'Scheduled task started',
scheduleStarted: 'Scheduled task queued',
conversationQueueReadFailed: 'Failed to read the send queue',
conversationQueueReleaseFailed:
'The message failed and could not be restored to the send queue',
conversationQueueResumeFailed:
'Failed to resume this conversation’s send queue',
conversationPersistenceFailed:
'Failed to save conversations. Check local storage.',
remoteConversationRefreshFailed:
+20 -3
View File
@@ -397,6 +397,20 @@ export const app = {
stop: '停止生成',
send: '发送',
sendTitle: '发送消息',
queueMessage: '加入待发送队列',
queueMessageTitle: '当前回复结束后按顺序发送',
queue: {
ariaLabel: '对话待发送队列',
scheduledTask: '定时任务',
message: '消息',
interrupt: '立即中断并插入',
runNow: '立即运行',
interruptAria: '中断当前回复并优先执行“{{title}}”',
runNowAria: '立即运行“{{title}}”',
remove: '删除',
removeAria: '从待发送队列删除“{{title}}”',
actionFailed: '待发送队列操作失败'
},
shortcut: '快捷唤起:',
context: {
confirmedTokenCount: '本次调用 {{used}}',
@@ -525,14 +539,17 @@ export const app = {
imageUnavailable: '图片内容不可用',
imageDownloadStarted: '图片下载已开始',
remoteConversationReadOnly: '远程通道会话只能从对应消息应用继续发起',
conversationAlreadyRunning:
'当前对话已有任务正在运行,请等待完成或先停止',
sendFailed: '发送失败',
stopFailed: '停止生成失败,请重试',
projectNotLoaded: '当前项目尚未加载。',
browserControlUnavailable: '内置浏览器组件尚未加载,请重启 GoodBuddy',
browserStopFailed: '停止浏览器失败,请重试',
scheduleStarted: '定时任务已开始执行',
scheduleStarted: '定时任务已加入待执行队列',
conversationQueueReadFailed: '待发送队列读取失败',
conversationQueueReleaseFailed:
'消息执行失败,且无法恢复到待发送队列',
conversationQueueResumeFailed:
'无法继续执行当前对话的待发送队列',
conversationPersistenceFailed: '会话持久化失败,请检查本地存储',
remoteConversationRefreshFailed: '远程通道会话刷新失败',
projectReadFailed: '项目读取失败',
+117 -28
View File
@@ -158,34 +158,6 @@
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;
@@ -5067,6 +5039,107 @@ button > svg {
color: var(--danger);
}
.conversation-input-queue {
display: grid;
margin: 0 0 var(--space-2);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-card);
overflow: hidden;
background: var(--surface-raised);
}
.conversation-input-queue ol {
display: grid;
min-width: 0;
max-height: 164px;
padding: 0;
margin: 0;
overflow-y: auto;
list-style: none;
scrollbar-color: var(--border-control) transparent;
scrollbar-width: thin;
}
.conversation-input-queue li {
display: grid;
min-width: 0;
min-height: 40px;
align-items: center;
padding: var(--space-1) var(--space-2) var(--space-1)
var(--space-3);
gap: var(--space-2);
grid-template-columns: auto minmax(0, 1fr) auto;
}
.conversation-input-queue li + li {
border-top: 1px solid var(--border-subtle);
}
.conversation-input-queue__source {
display: grid;
width: 20px;
height: 20px;
place-items: center;
color: var(--text-muted);
}
.conversation-input-queue__content {
min-width: 0;
overflow: hidden;
color: var(--text-secondary);
font-size: var(--font-caption);
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-input-queue__actions,
.composer__submit-actions {
display: flex;
align-items: center;
gap: var(--space-1);
}
.conversation-input-queue__actions button {
display: inline-flex;
min-height: 28px;
align-items: center;
justify-content: center;
padding: 0 var(--space-2);
border: 0;
border-radius: var(--radius-control);
background: transparent;
color: var(--text-muted);
cursor: pointer;
font-size: var(--font-caption);
font-weight: 500;
gap: var(--space-1);
white-space: nowrap;
}
.conversation-input-queue__actions button:hover:not(:disabled) {
background: var(--accent-subtle);
color: var(--accent);
}
.conversation-input-queue__actions button:disabled {
color: var(--text-muted);
cursor: default;
opacity: 0.6;
}
.conversation-input-queue__actions
button.conversation-input-queue__remove {
width: 28px;
padding: 0;
}
.conversation-input-queue__actions
button.conversation-input-queue__remove:hover:not(:disabled) {
background: var(--danger-subtle);
color: var(--danger);
}
.image-viewer-backdrop {
position: fixed;
z-index: 80;
@@ -5763,6 +5836,22 @@ button > svg {
}
@container (max-width: 700px) {
.conversation-input-queue__actions button span {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
.conversation-input-queue__actions button {
width: 28px;
padding: 0;
}
.composer__controls {
flex-wrap: wrap;
}