diff --git a/docs/images/composer/new-conversation.png b/docs/images/composer/new-conversation.png new file mode 100644 index 00000000..95c731e9 Binary files /dev/null and b/docs/images/composer/new-conversation.png differ diff --git a/docs/images/composer/queued-messages.png b/docs/images/composer/queued-messages.png new file mode 100644 index 00000000..0feda448 Binary files /dev/null and b/docs/images/composer/queued-messages.png differ diff --git a/docs/usage.md b/docs/usage.md index c182691c..869d85d7 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -68,6 +68,24 @@ false until that matrix is signed. ## Threads, Runs, and advanced diagnostics / Thread、Run 与高级诊断 +### 补充消息与排队 / Follow-up messages and queue + +任务运行中,发送的补充消息会排到输入框上方,当前执行结束后依次处理。文字消息可点击 +条目右侧的「引导」加入当前任务;「撤回」取消待处理消息,更多菜单提供编辑和全文、附件预览。 +图片和文件附件保留在下一轮消息中。输入框增高时,排队卡片随它向上移动;多条消息在卡片内滚动。 +底栏按输入框的可用宽度收起次要标签,模型名称过长时省略显示,完整名称仍可在模型菜单查看。 + +![紧凑宽度下的新对话输入框](images/composer/new-conversation.png) + +![输入框上方的待处理消息与引导操作](images/composer/queued-messages.png) + +以上为 Windows / Edge 生产构建截图,任务与回复使用本地离线演示数据。 + +Follow-up messages sent during execution appear above the composer and run in order afterward. +Use **Steer** on a text-only queued message to add it to the current task, **Withdraw** to cancel it, +or the more menu to edit and preview its full content and attachments. Images and files stay queued +for the next turn. The queue moves with the composer as it grows and scrolls internally when needed. + ### 用量统计 / Usage 在「设置 → 用量统计」查看 Universal-Code 自身的模型调用。今日、近 30 天和全部 diff --git a/web/src/v2/app.tsx b/web/src/v2/app.tsx index 85173d3c..10dd21ad 100644 --- a/web/src/v2/app.tsx +++ b/web/src/v2/app.tsx @@ -1,6 +1,6 @@ import { lazy, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { QueryClient, QueryClientProvider, useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { Folder } from "lucide-react"; +import { Folder, SlidersHorizontal } from "lucide-react"; import type { APIClient } from "../api/client"; import type { ProviderDefinitionView, TaskBudgetSettings, ThreadDetailView, ThreadView, WorkspaceView } from "../api/types"; import { normalizedTaskBudget } from "../api/task-configuration"; @@ -221,8 +221,9 @@ function NewConversation({ client, workspaces, workspaceID, onWorkspaceChange, o onManageModels={onManageModels} onPendingModelRouteChange={(route) => onOptionsChange({ ...options, modelRoute: route })} pendingModelRoute={modelRoute} - newThreadControls={<> + newThreadControls={<> {open &&
{ setContent(next); setError(null); }} onSubmit={() => void submit()} placeholder={inputPlaceholder} inputRef={inputRef} value={content} scope={scope} plainText={plainText} />
+ {!threadID && }
+ }}>
diff --git a/web/src/v2/components/conversation.test.tsx b/web/src/v2/components/conversation.test.tsx index a5e7201a..b38c831d 100644 --- a/web/src/v2/components/conversation.test.tsx +++ b/web/src/v2/components/conversation.test.tsx @@ -387,9 +387,8 @@ describe("V2Conversation", () => { expect(within(ui.container.querySelector(".v2-narrative")!).getByText("同样的排队要求")).toBeInTheDocument(); }); - it("sends an explicit current-task correction through the Session endpoint", async () => { - const submitSessionMessage = vi.fn(() => Promise.resolve({ steering: { id: "steer-current" } } as Awaited< - ReturnType>)); + it("queues ordinary sends during an active run without a delivery-mode selector", async () => { + const submitSessionMessage = vi.fn(); const submitThreadTurn = vi.fn(); const client = baseClient({ get: vi.fn(() => Promise.resolve({ ...detail("thread-a"), @@ -398,16 +397,14 @@ describe("V2Conversation", () => { } as Partial); renderConversation(client); const user = userEvent.setup(); - await screen.findByRole("combobox", { name: "发送方式" }); - await user.selectOptions(screen.getByRole("combobox", { name: "发送方式" }), "steer"); - await user.click(screen.getByRole("button", { name: "发送 thread-a" })); - await waitFor(() => expect(submitSessionMessage).toHaveBeenCalledWith("sess-thread-a", - { version: "session_message_submission.v1", content: "pending-thread-a", delivery_mode: "steer" }, - expect.any(String))); - expect(submitThreadTurn).not.toHaveBeenCalled(); + await user.click(await screen.findByRole("button", { name: "发送 thread-a" })); + expect(screen.queryByRole("combobox", { name: "发送方式" })).not.toBeInTheDocument(); + await waitFor(() => expect(submitThreadTurn).toHaveBeenCalledWith("thread-a", + expect.objectContaining({ content: "pending-thread-a" }), expect.any(String))); + expect(submitSessionMessage).not.toHaveBeenCalled(); }); - it("keeps an explicit correction draft when the task ends before sending", async () => { + it("sends the same draft normally when the task ends before sending", async () => { const execution: ThreadExecutionView = { version: "thread_execution.v1", thread_id: "thread-a", state: "running", execution_id: "execution-a", queued_messages: 0, capability_grant: false }; const submitSessionMessage = vi.fn(); @@ -418,22 +415,10 @@ describe("V2Conversation", () => { threadExecution: vi.fn(() => Promise.resolve(execution)), submitSessionMessage, submitThreadTurn, } as Partial)); const user = userEvent.setup(); - await screen.findByRole("combobox", { name: "发送方式" }); - await user.selectOptions(screen.getByRole("combobox", { name: "发送方式" }), "steer"); + await screen.findByRole("button", { name: "发送 thread-a" }); await act(async () => { view.queryClient.setQueryData(v2QueryKeys.execution("thread-a"), { ...execution, state: "idle", execution_id: undefined }); }); - expect(screen.getByRole("combobox", { name: "发送方式" })).toHaveValue("steer"); - // The invalid mode is explained before sending, and sending stays blocked. - expect(await screen.findByText(/当前任务已停止或不再运行/)).toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "发送 thread-a" })); - expect(submitSessionMessage).not.toHaveBeenCalled(); - expect(submitThreadTurn).not.toHaveBeenCalled(); - // Switching back to next-turn clears the hint; the selector hides again - // because no running task can accept a correction. - await user.click(screen.getByRole("button", { name: "切换为下一轮处理" })); - expect(screen.queryByText(/当前任务已停止或不再运行/)).not.toBeInTheDocument(); expect(screen.queryByRole("combobox", { name: "发送方式" })).not.toBeInTheDocument(); - // The retained draft now goes out as a regular next-turn message. await user.click(screen.getByRole("button", { name: "发送 thread-a" })); await waitFor(() => expect(submitThreadTurn).toHaveBeenCalledWith("thread-a", expect.objectContaining({ content: "pending-thread-a" }), expect.any(String))); @@ -840,29 +825,30 @@ describe("V2Conversation", () => { expect(onDraftChange).toHaveBeenCalledWith(""); }); - it("explains an unavailable correction as paused when the run is paused", async () => { + it("retains the paused state and sends a normal continuation after a run pauses", async () => { const execution: ThreadExecutionView = { version: "thread_execution.v1", thread_id: "thread-a", state: "running", execution_id: "execution-a", queued_messages: 0, capability_grant: false }; const pausedDetail: ThreadDetailView = { ...detail("thread-a"), active_run: { id: "run-thread-a", session_id: "sess-thread-a", status: "paused" } } as ThreadDetailView; - const view = renderConversation(baseClient({ hasThreadExecutionRead: true, + const submitThreadTurn = vi.fn(); + const submitSessionMessage = vi.fn(); + const view = renderConversation(baseClient({ hasThreadExecutionRead: true, submitThreadTurn, submitSessionMessage, get: vi.fn(() => Promise.resolve({ ...detail("thread-a"), active_run: { id: "run-thread-a", session_id: "sess-thread-a", status: "running" } })), threadExecution: vi.fn(() => Promise.resolve(execution)), } as Partial)); const user = userEvent.setup(); - await screen.findByRole("combobox", { name: "发送方式" }); - await user.selectOptions(screen.getByRole("combobox", { name: "发送方式" }), "steer"); + await screen.findByRole("button", { name: "发送 thread-a" }); await act(async () => { view.queryClient.setQueryData(v2QueryKeys.thread("thread-a"), pausedDetail); view.queryClient.setQueryData(v2QueryKeys.execution("thread-a"), { ...execution, state: "idle", execution_id: undefined }); }); - // The pause is named explicitly instead of implying the task ended. - expect(await screen.findByText(/当前任务已暂停,不能更新当前任务/)).toBeInTheDocument(); - expect(screen.queryByText(/当前任务已停止或不再运行/)).not.toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "切换为下一轮处理" })); - expect(screen.queryByText(/当前任务已暂停,不能更新当前任务/)).not.toBeInTheDocument(); + expect(screen.getByText("发送消息即可继续,也可以仅解除暂停。")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "发送 thread-a" })); + await waitFor(() => expect(submitThreadTurn).toHaveBeenCalledWith("thread-a", + expect.objectContaining({ content: "pending-thread-a" }), expect.any(String))); + expect(submitSessionMessage).not.toHaveBeenCalled(); }); it("shows confirmed Agent activity and stopping while the submission response is still pending", async () => { diff --git a/web/src/v2/components/conversation.tsx b/web/src/v2/components/conversation.tsx index 9cb6f990..b71465f9 100644 --- a/web/src/v2/components/conversation.tsx +++ b/web/src/v2/components/conversation.tsx @@ -383,7 +383,6 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana const composerContainerRef = useRef(null); const [composerFocusRequest, setComposerFocusRequest] = useState<{ threadID: string } | null>(null); const [confirmedSubmission, setConfirmedSubmission] = useState(null); - const [deliveryMode, setDeliveryMode] = useState<"next_turn" | "steer">("next_turn"); const reviewTrigger = useRef(null); const [fileDrawerState, setFileDrawerState] = useState<{ path: string; @@ -698,7 +697,6 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana setFileDrawerState({ path: ".", line: undefined, runID: undefined }); setComposerFocusRequest(null); olderScrollAnchorRef.current = null; - setDeliveryMode("next_turn"); }, [threadID, view]); useEffect(() => { if (!reviewEntry || reviewEntry.threadID !== threadID) return; @@ -784,10 +782,6 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana }; const canSteer = executionQuery.data?.state === "running" || (!executionQuery.data && detail.active_run?.status === "running"); - // A paused run is not gone: steer stays unavailable, but the explanation - // names the pause so the operator does not think the task ended. - const steerUnavailable = currentRun.status === "paused" ? t("当前任务已暂停", "The current task is paused") - : t("当前任务已停止或不再运行", "The current task has stopped or is no longer running"); const activityLabel = threadActivityLabel({ threadID, execution: executionQuery.data, readable: client.hasThreadExecutionRead === true, error: executionQuery.isError }); const fileReferenceUnavailableReason = currentRun.status === "waiting_approval" @@ -800,13 +794,7 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana : undefined; const send = async (content: string, files?: V2FileReference[], images?: WorkspaceImageAttachment[], draftVersion?: V2DraftVersion, attachments?: WorkspaceFileAttachment[], mcpReferences?: MCPDraftReference[]) => { - if (deliveryMode === "steer" && !canSteer) { - throw new Error(`${steerUnavailable}。纠正草稿已保留;请选择“下一轮处理”发送。`); - } - if (deliveryMode === "steer" && (files?.length || images?.length || attachments?.length || mcpReferences?.length)) { - throw new Error("更新当前任务目前只支持文字。附件和引用已保留,请选择“下一轮处理”发送完整消息。"); - } - const fingerprint = JSON.stringify([deliveryMode, content, files ?? [], imageIdentities(images), fileAttachmentIdentities(attachments), mcpReferenceIdentities(mcpReferences)]); + const fingerprint = JSON.stringify(["next_turn", content, files ?? [], imageIdentities(images), fileAttachmentIdentities(attachments), mcpReferenceIdentities(mcpReferences)]); let replaced = submissions.findLast(({ error }) => v2TurnOutcomeKnown(error))?.input.operationKey; // The user's new request waits for unknown earlier submissions to settle. // Confirmation uses the original payload and key, never the edited draft. @@ -826,7 +814,6 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana { text: draft ?? content, files: files ?? [], images: images ?? [], attachments, mcpReferences }); const operationKey = `v2-thread-turn-${globalThis.crypto.randomUUID()}`; const input: V2TurnInput = { threadID, workspaceID: detail.thread.workspace_id ?? "", content, - ...(deliveryMode === "steer" ? { deliveryMode: "steer" as const, sessionID: currentRun.session_id } : {}), ...(draft !== undefined ? { draft } : {}), operationKey, createdAt: new Date().toISOString(), ...(files?.length ? { files } : {}), ...(images?.length ? { images } : {}), @@ -975,7 +962,6 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana if (!onDraftChange) return; if (!draft?.includes(APPLICATION_PREVIEW_START_REQUEST)) appendDraftAndReveal(APPLICATION_PREVIEW_START_REQUEST); else appendDraftAndReveal(""); - setDeliveryMode("next_turn"); queryClient.setQueryData(applicationPreviewRequestKey(threadID), { request: APPLICATION_PREVIEW_START_REQUEST, phase: "draft", }); @@ -1111,18 +1097,6 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana {view === "inspector" && } - {view === "conversation" && setComposerFocusRequest({ threadID })} />} {client.hasControl && {open &&
diff --git a/web/src/v2/components/permission-control.tsx b/web/src/v2/components/permission-control.tsx index 223d0c2d..c5d64df6 100644 --- a/web/src/v2/components/permission-control.tsx +++ b/web/src/v2/components/permission-control.tsx @@ -116,13 +116,14 @@ function ThreadPermissionControl({ client, threadID, variant = "menu", onOpenMod } {variant === "menu" ?
{networkOpen &&
onChange(planning ? "deliver" : "plan")}> -
{open && createPortal(
{ if (event.target === event.currentTarget) setOpen(false); }}> diff --git a/web/src/v2/styles.css b/web/src/v2/styles.css index 1b385c50..0526e507 100644 --- a/web/src/v2/styles.css +++ b/web/src/v2/styles.css @@ -1220,22 +1220,30 @@ z-index: 2; } .v2-composer textarea::placeholder { color: color-mix(in srgb, var(--v2-muted) 78%, transparent); } -.v2-composer-footer { align-items: center; display: flex; flex-wrap: wrap; gap: 8px; min-height: 48px; justify-content: space-between; padding: 8px 12px 12px; position: relative; z-index: 5; } -.v2-composer-tools { align-items: center; display: flex; flex: 1 1 auto; flex-wrap: wrap; gap: 8px; min-width: 0; max-width: 100%; } -.v2-composer-actions { align-items: center; display: flex; flex: 0 1 auto; gap: 8px; margin-left: auto; min-width: 0; max-width: 100%; } -.v2-composer-actions > .v2-model-route-control { min-width: 0; } +.v2-composer-footer { align-items: center; display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 6px; min-height: 48px; padding: 8px 12px 12px; position: relative; z-index: 5; } +.v2-composer-footer:has(> .v2-workspace-picker) { grid-template-columns: minmax(0, 1fr) auto auto; } +.v2-composer-tools { align-items: center; display: flex; gap: 6px; min-width: 0; } +.v2-composer-tools > * { flex-shrink: 0; } +.v2-composer-actions { align-items: center; display: flex; gap: 6px; min-width: 0; max-width: 100%; justify-self: end; } +.v2-composer-actions > .v2-model-route-control { min-width: 0; max-width: 160px; } +.v2-composer-footer .v2-model-route-trigger > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.v2-composer-footer svg { flex-shrink: 0; } +.v2-composer-footer :is(.v2-thread-plan-controls, .v2-permission-host.is-menu) { flex-wrap: nowrap; gap: 6px; } +.v2-composer-footer .v2-composer-mode-toggle { display: inline-flex; align-items: center; gap: 6px; height: 34px; } .v2-composer-footer :is(.v2-phase-toggle, .v2-plan-trigger, .v2-composer-chip, .v2-workspace-picker select, .v2-model-route-trigger) { align-items: center; border-radius: 9px; box-sizing: border-box; display: inline-flex; font: 500 13px/20px var(--prayu-font-ui); - gap: 8px; + gap: 5px; height: 34px; min-height: 34px; padding: 0 8px; } -.v2-composer-footer .v2-thread-plan-controls { gap: 8px; } +.v2-composer-permission-label { max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.v2-workspace-picker { align-items: center; display: flex; gap: 4px; min-width: 0; color: var(--v2-muted); } +.v2-workspace-picker select { min-width: 0; width: 100%; text-overflow: ellipsis; } .v2-composer-surface > .v2-file-references { padding: 12px 16px 0; } .v2-composer-add { flex: 0 0 auto; position: relative; } .v2-composer-add-trigger { color: var(--v2-muted); } @@ -2280,10 +2288,8 @@ .v2-high-risk-actions button { flex: 1; } } -@container composer (max-width: 600px) { - .v2-composer-footer { flex-wrap: wrap; gap: 8px; position: relative; } - .v2-composer-tools { flex-basis: 100%; } - .v2-composer-actions { justify-content: flex-end; max-width: 100%; width: 100%; } +@container composer (max-width: 760px) { + .v2-composer-footer :is(.v2-composer-mode-toggle, .v2-composer-task-config) > .v2-composer-optional-label { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } .v2-composer-tools .v2-permission-control, .v2-composer-tools .v2-network-scope-control { position: static; } .v2-composer-tools .v2-permission-popover, @@ -2292,6 +2298,28 @@ .v2-composer-actions .v2-model-route-popover { bottom: calc(100% + 8px); right: 0; max-width: calc(100cqw - 24px); } } +@container composer (max-width: 700px) { + .v2-composer-footer .v2-composer-optional-label { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } + .v2-composer-footer .v2-plan-trigger.has-attention { box-shadow: inset 0 -2px var(--v2-muted); } +} + +@container composer (max-width: 520px) { + .v2-composer-footer:has(> .v2-workspace-picker) { grid-template-columns: auto minmax(0, 1fr); } + .v2-composer-footer > .v2-workspace-picker { grid-column: 1 / -1; grid-row: 1; } + .v2-composer-footer > .v2-workspace-picker select { max-width: 100%; width: auto; } + .v2-composer-actions > .v2-model-route-control { max-width: 110px; } + .v2-composer-footer .v2-composer-permission-label { max-width: 64px; } + .v2-composer-footer :is(.v2-thread-plan-controls, .v2-permission-host.is-menu), + .v2-composer-tools, .v2-composer-actions { gap: 3px; } + .v2-composer-footer :is(.v2-phase-toggle, .v2-plan-trigger, .v2-composer-chip, .v2-composer-mode-toggle) { padding-inline: 6px; } +} + +@container composer (max-width: 400px) { + .v2-composer-footer .v2-composer-permission-label { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } + .v2-composer-actions > .v2-model-route-control { max-width: 90px; } + .v2-composer-tools :is(.v2-permission-control, .v2-network-scope-control) > button > svg:last-child { display: none; } +} + @media (prefers-reduced-motion: reduce) { .v2-shell *, .v2-shell *::before,