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
2 changes: 1 addition & 1 deletion cmd/cyberagent-desktop/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -883,7 +883,7 @@ func runDesktop(config desktopOptions) error {

appOptions := &options.App{
Title: app.Name, Width: defaultDesktopWidth, Height: defaultDesktopHeight,
MinWidth: 320, MinHeight: 320,
MinWidth: 480, MinHeight: 600,
Frameless: true,
WindowStartState: options.Normal,
BackgroundColour: options.NewRGBA(0, 0, 0, 0),
Expand Down
Binary file added docs/images/appearance/narrow-dark.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/appearance/narrow-mist.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/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.
47 changes: 47 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,35 @@ Screenshots were captured from a production build in Edge on Windows at 1440 ×
2026-10-19. Project content, replies, and usage are offline demonstration data. The README
combines the Light and Dark captures of the same conversation.

### 窄窗口 / Narrow windows

桌面窗口最小尺寸为 **480 × 600 逻辑像素**;系统显示缩放会影响截图中的实际像素数。
The desktop window has a minimum size of **480 × 600 logical pixels**; display scaling changes the physical pixel count.

两侧面板按窗口宽度依次收起:左侧栏展开时,文件或终端面板在 ≤960px 收起;
左侧栏在 ≤720px 收起。左侧栏已隐藏时,右侧面板的阈值为 ≤720px。
重新拉宽后恢复原文件或终端,手动关闭的面板保持关闭。左侧使用 300ms、右侧使用 500ms
的推拉过渡;系统开启「减少动态效果」后立即切换。
The file or terminal pane collapses at ≤960px with the sidebar open, or ≤720px with it hidden.
The sidebar collapses at ≤720px. Widening restores the selected file or terminal; a manually closed pane stays closed.
Transitions take 300ms on the left and 500ms on the right, and are disabled when reduced motion is enabled.

窗口变窄时,侧栏收为可打开的浮层,并保留宽屏时的显示偏好。标题与常用工具合为一行,
「审阅改动」「查看上下文」可从右上角对话菜单打开。输入框保留常用图标和模型选择,
窄输入框的编辑模式切换移入「+」菜单;窗口缩放、切换编辑模式都保留草稿。
底部统计在窄窗口保持一行,点击轮次或用量可查看生成速度、缓存命中等完整说明。
The sidebar becomes an overlay in narrow windows and remembers its desktop visibility.
The title and frequent tools share one row. Review and context move into the conversation menu; editor mode moves into the composer’s
**+** menu. Drafts remain intact when resizing or switching editors.
Statistics stay on one line; click activity or usage to see generation speed, cache usage and metric details.

| 深色 / Dark | 雾透 / Mist |
| --- | --- |
| ![480px 深色对话](images/appearance/narrow-dark.png) | ![480px 雾透对话](images/appearance/narrow-mist.png) |

以上为 480 × 844 的 Windows / Edge 生产构建截图,使用本地离线演示数据。
These 480 × 844 captures use the production build in Edge on Windows with local demo data.

## Portable Build Diagnostics

```powershell
Expand All @@ -68,6 +97,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
2 changes: 1 addition & 1 deletion web/e2e/workbench.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ async function narrowConversation(page: Page) {
await page.setViewportSize({ width: 390, height: 844 });
const toggle = page.getByRole("button", { name: /^(?:显示|隐藏)侧栏$/u });
await expect(toggle).toBeVisible();
if (await toggle.getAttribute("aria-pressed") === "true") await toggle.click();
// Resizing closes the sidebar automatically; clicking during that change can reopen it.
await expect(toggle).toHaveAttribute("aria-pressed", "false");
await expect(page.getByRole("navigation", { name: "对话导航", exact: true })).toBeHidden();
}
Expand Down
11 changes: 5 additions & 6 deletions web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,9 @@
}

:root {
--prayu-font-ui: "JetBrains Mono Variable", "JetBrains Mono", "HarmonyOS Sans SC",
"Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", "Noto Sans SC", monospace;
--prayu-font-display: "JetBrains Mono Variable", "JetBrains Mono", "HarmonyOS Sans SC",
"Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", "Noto Sans SC", monospace;
--prayu-font-ui: "Segoe UI", "HarmonyOS Sans SC", "Microsoft YaHei UI",
"PingFang SC", "Noto Sans CJK SC", "Noto Sans SC", system-ui, sans-serif;
--prayu-font-display: var(--prayu-font-ui);
--prayu-font-mono: "JetBrains Mono Variable", "JetBrains Mono", "HarmonyOS Sans SC",
"Cascadia Code", Consolas, monospace;
--prayu-prose-size: 15px;
Expand Down Expand Up @@ -2643,8 +2642,8 @@ body { background: transparent; }
--prayu-selected: var(--prayu-primary-bg);
--prayu-selected-ink: var(--prayu-primary-fg);
--prayu-surface-solid: #eff1f5;
--prayu-text: #4c4f69;
--prayu-muted: #5c5f77;
--prayu-text: #30363d;
--prayu-muted: #697078;
--prayu-border: var(--dsh-glass-rim-soft);
--prayu-border-strong: var(--dsh-glass-rim);
--prayu-shadow: rgb(0 0 0 / 12%);
Expand Down
15 changes: 6 additions & 9 deletions web/src/styles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,20 +32,17 @@ describe("workbench Composer layout", () => {
});

describe("product typography roles", () => {
it("uses the approved JetBrains and HarmonyOS stacks for interface and display text", () => {
it("uses proportional interface fonts and shares them with display text", () => {
const uiStack = styles.match(/--prayu-font-ui:\s*([^;]+);/u)?.[1] ?? "";
const displayStack = styles.match(/--prayu-font-display:\s*([^;]+);/u)?.[1] ?? "";

expect(uiStack).toContain("JetBrains Mono Variable");
expect(uiStack).toMatch(/^"Segoe UI",/u);
expect(uiStack).toContain("HarmonyOS Sans SC");
expect(uiStack).toContain("Microsoft YaHei UI");
expect(uiStack).toContain("monospace");
expect(uiStack).not.toContain("Cascadia Code");
expect(displayStack).toContain("JetBrains Mono Variable");
expect(displayStack).toContain("HarmonyOS Sans SC");
expect(displayStack).toContain("Microsoft YaHei UI");
expect(displayStack).toContain("PingFang SC");
expect(displayStack).toContain("monospace");
expect(uiStack).toContain("PingFang SC");
expect(uiStack).toMatch(/system-ui,\s*sans-serif$/u);
expect(uiStack).not.toMatch(/JetBrains Mono|Cascadia Code|monospace/u);
expect(displayStack).toBe("var(--prayu-font-ui)");
});

it("loads the four byte-identical vendor HarmonyOS Sans TTF weights", () => {
Expand Down
44 changes: 43 additions & 1 deletion web/src/v2/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ describe("V2Workbench inspector navigation", () => {
});
it("closes the narrow navigation drawer after new-task and settings navigation while retaining the draft", async () => {
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
matches: query === "(max-width: 760px)", media: query,
matches: query === "(max-width: 720px)", media: query,
addEventListener: vi.fn(), removeEventListener: vi.fn(),
addListener: vi.fn(), removeListener: vi.fn(), dispatchEvent: vi.fn(), onchange: null,
})));
Expand Down Expand Up @@ -206,6 +206,48 @@ describe("V2Workbench inspector navigation", () => {
expectDrawerClosed();
});

it("automatically collapses the sidebar when squeezed to narrow width and restores it when widened", async () => {
let narrowListener: ((event: MediaQueryListEvent) => void) | undefined;
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
matches: false, media: query,
addEventListener: vi.fn((type: string, listener: (event: MediaQueryListEvent) => void) => {
if (query === "(max-width: 720px)" && type === "change") narrowListener = listener;
}),
removeEventListener: vi.fn(),
addListener: vi.fn(), removeListener: vi.fn(), dispatchEvent: vi.fn(), onchange: null,
})));
window.history.replaceState({}, "", `#/threads/${createdThread.id}`);
const client = { hasThreadControl: true, getPage: vi.fn(async (path: string) => ({
items: path === "/workspaces" ? [workspace] : [createdThread],
page: { limit: 100 }, requestID: path,
})) } as unknown as APIClient;
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={queryClient}><V2Workbench client={client} /></QueryClientProvider>);
await screen.findByTestId("v2-conversation");
expect(screen.getByRole("navigation", { name: "对话导航" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "隐藏侧栏" })).toHaveAttribute("aria-pressed", "true");

act(() => { narrowListener?.({ matches: true } as MediaQueryListEvent); });
expect(screen.queryByRole("navigation", { name: "对话导航" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "显示侧栏" })).toHaveAttribute("aria-pressed", "false");

act(() => { narrowListener?.({ matches: false } as MediaQueryListEvent); });
expect(screen.getByRole("navigation", { name: "对话导航" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "隐藏侧栏" })).toHaveAttribute("aria-pressed", "true");

screen.getByRole("button", { name: "新对话" }).focus();
act(() => { narrowListener?.({ matches: true } as MediaQueryListEvent); });
expect(screen.getByRole("button", { name: "显示侧栏" })).toHaveFocus();
const sidebar = document.querySelector(".v2-sidebar-slot");
expect(sidebar).toHaveAttribute("inert");
expect(sidebar).toHaveAttribute("aria-hidden", "true");
act(() => { narrowListener?.({ matches: false } as MediaQueryListEvent); });
fireEvent.click(screen.getByRole("button", { name: "隐藏侧栏" }));
act(() => { narrowListener?.({ matches: true } as MediaQueryListEvent); });
act(() => { narrowListener?.({ matches: false } as MediaQueryListEvent); });
expect(screen.getByRole("button", { name: "显示侧栏" })).toHaveAttribute("aria-pressed", "false");
});

it("loads past 100 Threads and preserves an older URL selection through failed paging and refresh", async () => {
const user = userEvent.setup();
const first = Array.from({ length: 100 }, (_, index) => ({ ...createdThread,
Expand Down
48 changes: 38 additions & 10 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 Expand Up @@ -275,7 +276,32 @@ function V2WorkbenchContent({ client }: { client: APIClient }) {
const view = route.view ?? "conversation";
const settingsSection = route.section ?? "general";
const newConversation = route.kind !== "thread";
const [sidebarVisible, setSidebarVisible] = useState(() => !window.matchMedia?.("(max-width: 760px)").matches);
const [narrowLayout, setNarrowLayout] = useState(() => Boolean(window.matchMedia?.("(max-width: 720px)").matches));
const [desktopSidebarVisible, setDesktopSidebarVisible] = useState(true);
const [narrowSidebarVisible, setNarrowSidebarVisible] = useState(false);
const sidebarVisible = narrowLayout ? narrowSidebarVisible : desktopSidebarVisible;
const sidebarSlot = useRef<HTMLDivElement>(null);
const sidebarToggle = useRef<HTMLButtonElement>(null);
const setSidebarVisible = (visible: boolean) => {
if (!visible) sidebarToggle.current?.focus();
if (narrowLayout) setNarrowSidebarVisible(visible);
else setDesktopSidebarVisible(visible);
};
useEffect(() => {
const media = window.matchMedia?.("(max-width: 720px)");
if (!media) return;
const onChange = (event: MediaQueryListEvent) => {
if (sidebarSlot.current?.contains(document.activeElement)) sidebarToggle.current?.focus();
setNarrowLayout(event.matches);
setNarrowSidebarVisible(false);
};
if (typeof media.addEventListener === "function") {
media.addEventListener("change", onChange);
return () => media.removeEventListener("change", onChange);
}
media.addListener?.(onChange);
return () => media.removeListener?.(onChange);
}, []);
const [searchOpen, setSearchOpen] = useState(false);
const [threadSearch, setThreadSearch] = useState("");
const [appliedThreadSearch, setAppliedThreadSearch] = useState("");
Expand Down Expand Up @@ -397,7 +423,7 @@ function V2WorkbenchContent({ client }: { client: APIClient }) {
},
});
const closeNavigationSidebar = () => {
if (window.matchMedia?.("(max-width: 760px)").matches) setSidebarVisible(false);
if (window.matchMedia?.("(max-width: 720px)").matches) setSidebarVisible(false);
};
const openConversation = (threadID: string) => {
setBackgroundCreations((current) => current.filter((thread) => thread.id !== threadID));
Expand Down Expand Up @@ -475,11 +501,12 @@ function V2WorkbenchContent({ client }: { client: APIClient }) {

return <div className={`v2-shell${sidebarVisible ? " has-sidebar" : " no-sidebar"}`}>
<V2Titlebar canGoBack={navigation.canGoBack} onBack={surface === "settings" ? returnFromSettings : goBack}
onToggleSidebar={() => setSidebarVisible((visible) => !visible)} sidebarVisible={sidebarVisible} />
{sidebarVisible && <button aria-label={t("关闭侧栏", "Close sidebar")} className="v2-sidebar-backdrop"
onClick={() => setSidebarVisible(false)} type="button" />}
onToggleSidebar={() => setSidebarVisible(!sidebarVisible)} sidebarVisible={sidebarVisible} sidebarToggleRef={sidebarToggle} />
<button aria-label={t("关闭侧栏", "Close sidebar")} className="v2-sidebar-backdrop" aria-hidden={!sidebarVisible} inert={!sidebarVisible}
onClick={() => setSidebarVisible(false)} type="button" />
<div className="v2-shell-body">
{sidebarVisible && (surface === "settings" ? <V2SettingsSidebar onBack={returnFromSettings}
<div className="v2-sidebar-slot" ref={sidebarSlot} aria-hidden={!sidebarVisible} inert={!sidebarVisible}>
{surface === "settings" ? <V2SettingsSidebar onBack={returnFromSettings}
onSelect={setSettingsSection} section={settingsSection} /> : <V2Sidebar
onArchive={setArchiveCandidate} onNewConversation={startNew} onOpenModels={() => openModels(newConversation)}
onOpenSettings={openSettings}
Expand All @@ -501,7 +528,8 @@ function V2WorkbenchContent({ client }: { client: APIClient }) {
refreshing={threadsQuery.isFetching && !threadsQuery.isLoading && !threadsQuery.isFetchingNextPage}
onRetry={() => void (threadsQuery.isFetchNextPageError ? threadsQuery.fetchNextPage() : threadsQuery.refetch())}
onLoadMore={() => void threadsQuery.fetchNextPage()} onRefresh={() => void threadsQuery.refetch()}
selectedThreadID={newConversation ? "" : selectedThreadID} threads={threads} workspaces={workspaces} />)}
selectedThreadID={newConversation ? "" : selectedThreadID} threads={threads} workspaces={workspaces} />}
</div>
<div className="v2-product-surface">
{route.kind === "invalid" ? <div className="v2-notice" role="alert">{t("任务地址无法识别。", "The task address is not recognized.")}
<button onClick={startNew} type="button">{t("打开新对话", "Open new conversation")}</button></div>
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
Loading
Loading