Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/images/composer/new-conversation.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/composer/queued-messages.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 18 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 天和全部
Expand Down
7 changes: 4 additions & 3 deletions web/src/v2/app.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -221,8 +221,9 @@ function NewConversation({ client, workspaces, workspaceID, onWorkspaceChange, o
onManageModels={onManageModels} onPendingModelRouteChange={(route) =>
onOptionsChange({ ...options, modelRoute: route })}
pendingModelRoute={modelRoute}
newThreadControls={<><button aria-label={t("任务预算与项目配置", "Task budget and project configuration")} className="v2-composer-chip"
onClick={onOpenTaskConfiguration} type="button">{t("任务配置", "Task configuration")}{budget ? t(" · 自定义", " · Custom") : ""}</button>
newThreadControls={<><button aria-label={t("任务预算与项目配置", "Task budget and project configuration")} className="v2-composer-chip v2-composer-task-config"
title={t("任务预算与项目配置", "Task budget and project configuration")}
onClick={onOpenTaskConfiguration} type="button"><SlidersHorizontal aria-hidden="true" size={14} /><span className="v2-composer-optional-label">{t("任务配置", "Task configuration")}{budget ? t(" · 自定义", " · Custom") : ""}</span></button>
<V2PhasePicker phase={phase} onChange={onPhaseChange}
disabled={!client.hasThreadControl} planAvailable={client.hasPlanDelivery} />
<V2NetworkScopeControl disabled={!client.hasThreadControl}
Expand Down
5 changes: 3 additions & 2 deletions web/src/v2/components/approval-mode-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,14 +142,15 @@ export function V2ApprovalModeControl({ mode, fullActivation, fullUnavailableRea
{variant === "menu" ? <>
<button aria-controls={open ? id : undefined} aria-expanded={open} aria-haspopup="menu"
className={`v2-composer-chip${mode === "full" ? " is-risk" : ""}`}
title={`${selected.label}${mode === "full" ? ` · ${activationLabels[fullActivation]}` : ""}`}
onClick={() => { focusLastRef.current = false; setOpen((value) => !value); }}
onKeyDown={(event) => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault(); focusLastRef.current = event.key === "ArrowUp"; setOpen(true);
}
}} ref={triggerRef} type="button">
<SelectedIcon aria-hidden="true" size={14} />{selected.label}
{mode === "full" ? ` · ${activationLabels[fullActivation]}` : ""}
<SelectedIcon aria-hidden="true" size={14} /><span className="v2-composer-permission-label">{selected.label}
{mode === "full" ? ` · ${activationLabels[fullActivation]}` : ""}</span>
<ChevronDown aria-hidden="true" size={13} />
</button>
{open && <div aria-label={t("选择执行权限", "Choose execution permissions")} className="v2-permission-popover" id={id}
Expand Down
24 changes: 13 additions & 11 deletions web/src/v2/components/composer.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState, type FormEvent, type KeyboardEvent, type ReactNode } from "react";
import { ArrowUp, LoaderCircle, Paperclip, ClipboardPaste, FolderOpen, Database, Command } from "lucide-react";
import { ArrowUp, LoaderCircle, Paperclip, ClipboardPaste, FolderOpen, Database, Command, SquarePen } from "lucide-react";
import { APIRequestError, type APIClient } from "../../api/client";
import { useLocale } from "../../lib/locale";
import type { WorkspaceView } from "../../api/types";
Expand Down Expand Up @@ -321,6 +321,16 @@ export function V2Composer({ client, threadID, workspaceID, runID, workspaces, d
onChange={(next) => { setContent(next); setError(null); }} onSubmit={() => void submit()}
placeholder={inputPlaceholder} inputRef={inputRef} value={content} scope={scope} plainText={plainText} />
<div className="v2-composer-footer">
{!threadID && <label className="v2-workspace-picker">
<FolderOpen aria-hidden="true" size={14} />
<span className="sr-only">{t("工作区", "Workspace")}</span>
<select aria-label={t("选择工作区", "Select workspace")} disabled={submitting || workspaces.length === 0}
onChange={(event) => onWorkspaceChange(event.target.value)} value={workspaceID}>
{workspaces.map((workspace) => <option key={workspace.id} value={workspace.id}>
{workspace.name}
</option>)}
</select>
</label>}
<div className="v2-composer-tools">
<V2ComposerAddMenu actions={addActions} disabled={disabled} key={scope} triggerRef={addTriggerRef} />
<button className="v2-composer-mode-toggle" type="button" disabled={disabled}
Expand All @@ -334,21 +344,13 @@ export function V2Composer({ client, threadID, workspaceID, runID, workspaces, d
requestAnimationFrame(() => {
if (document.activeElement === toggle && !toggle.closest("[inert]")) inputRef.current?.focus();
});
}}>{plainText ? t("纯文本", "Plain text") : "Markdown"}</button>
}}><SquarePen aria-hidden="true" size={15} /><span className="v2-composer-optional-label">{plainText ? t("纯文本", "Plain text") : "Markdown"}</span></button>
{client.hasThreadControl &&
<input ref={attachmentPicker} type="file" multiple hidden aria-label={t("选择图片或文件", "Choose images or files")}
onChange={(event) => { const files = Array.from(event.target.files ?? []); event.target.value = ""; void addFiles(files); }} />}
{!threadID && newThreadControls}
{threadID && threadControls?.(hasUnsentDraft)}
{!threadID ? <label className="v2-workspace-picker">
<span className="sr-only">{t("工作区", "Workspace")}</span>
<select aria-label={t("选择工作区", "Select workspace")} disabled={submitting || workspaces.length === 0}
onChange={(event) => onWorkspaceChange(event.target.value)} value={workspaceID}>
{workspaces.map((workspace) => <option key={workspace.id} value={workspace.id}>
{workspace.name}
</option>)}
</select>
</label> : <V2PermissionControl client={client} threadID={threadID}
{threadID && <V2PermissionControl client={client} threadID={threadID}
onOpenModelSettings={onManageModels ? () => onManageModels(false) : undefined} />}
</div>
<div className="v2-composer-actions">
Expand Down
52 changes: 19 additions & 33 deletions web/src/v2/components/conversation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<APIClient["submitSessionMessage"]>>));
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"),
Expand All @@ -398,16 +397,14 @@ describe("V2Conversation", () => {
} as Partial<APIClient>);
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();
Expand All @@ -418,22 +415,10 @@ describe("V2Conversation", () => {
threadExecution: vi.fn(() => Promise.resolve(execution)), submitSessionMessage, submitThreadTurn,
} as Partial<APIClient>));
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)));
Expand Down Expand Up @@ -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<APIClient>));
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 () => {
Expand Down
Loading
Loading