From ee16b704ef02cd72e0afa2878ee422d2238db012 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 04:56:05 +0000 Subject: [PATCH 1/4] Add collapsible chat right rail and composer permission modes. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop 开发空间 can open a right sidebar with file, review, background-task, browser, and terminal panels. Composer + shares that picker; 使用权限 persists per conversation and is sent on each turn. Co-authored-by: XYAI Labs --- CHANGELOG.md | 1 + .../ChatBrowserPanel.module.less | 6 + .../BrowserWorkspace/ChatDockPanelShell.tsx | 61 +++- dashboard/src/locales/en.json | 42 +++ dashboard/src/locales/zh.json | 42 +++ .../src/pages/Chat/chatRightRail.partial.less | 294 ++++++++++++++++++ .../pages/Chat/components/ChatDockPanel.tsx | 104 ++++++- .../ChatDockPanels.keepAlive.test.tsx | 14 +- .../pages/Chat/components/ChatDockPanels.tsx | 20 +- .../Chat/components/ChatDockReviewContent.tsx | 93 ++++++ .../components/ChatDockStartState.test.tsx | 17 + .../Chat/components/ChatDockStartState.tsx | 109 +++++++ .../Chat/components/ChatDockTasksContent.tsx | 126 ++++++++ .../src/pages/Chat/components/ChatInput.tsx | 11 + .../components/ChatInputActionsRow.test.tsx | 67 ++++ .../Chat/components/ChatInputActionsRow.tsx | 56 ++++ .../pages/Chat/components/ChatPanelPicker.tsx | 91 ++++++ .../components/ChatPermissionModeMenu.tsx | 158 ++++++++++ dashboard/src/pages/Chat/hooks/chatStore.ts | 4 + dashboard/src/pages/Chat/hooks/sseHelpers.ts | 1 + dashboard/src/pages/Chat/hooks/useChat.ts | 2 + .../pages/Chat/hooks/useChatDockPanel.test.ts | 29 +- .../src/pages/Chat/hooks/useChatDockPanel.ts | 117 ++++++- dashboard/src/pages/Chat/hooks/useChatSend.ts | 8 + dashboard/src/pages/Chat/index.module.less | 25 ++ dashboard/src/pages/Chat/index.tsx | 62 +++- .../src/pages/Chat/utils/chatMessages.test.ts | 14 + .../src/pages/Chat/utils/chatMessages.ts | 13 + .../pages/Chat/utils/permissionMode.test.ts | 38 +++ .../src/pages/Chat/utils/permissionMode.ts | 56 ++++ .../src/pages/Chat/utils/workspacePanels.ts | 23 ++ src/octop/api/routers/chat/models.py | 5 + src/octop/api/routers/chat/turn.py | 3 + src/octop/infra/agents/manager.py | 15 + src/octop/infra/agents/permission_mode.py | 49 +++ .../infra/gateway/process/message_keys.py | 3 + src/octop/infra/gateway/process/processor.py | 22 ++ tests/unit/agents/test_permission_mode.py | 31 ++ tests/unit/api/test_chat_composer_context.py | 26 ++ 39 files changed, 1829 insertions(+), 29 deletions(-) create mode 100644 dashboard/src/pages/Chat/chatRightRail.partial.less create mode 100644 dashboard/src/pages/Chat/components/ChatDockReviewContent.tsx create mode 100644 dashboard/src/pages/Chat/components/ChatDockStartState.test.tsx create mode 100644 dashboard/src/pages/Chat/components/ChatDockStartState.tsx create mode 100644 dashboard/src/pages/Chat/components/ChatDockTasksContent.tsx create mode 100644 dashboard/src/pages/Chat/components/ChatPanelPicker.tsx create mode 100644 dashboard/src/pages/Chat/components/ChatPermissionModeMenu.tsx create mode 100644 dashboard/src/pages/Chat/utils/permissionMode.test.ts create mode 100644 dashboard/src/pages/Chat/utils/permissionMode.ts create mode 100644 dashboard/src/pages/Chat/utils/workspacePanels.ts create mode 100644 src/octop/infra/agents/permission_mode.py create mode 100644 tests/unit/agents/test_permission_mode.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c4e051d2..d6514d83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ ### 新增 +- 开发空间对话右侧栏:可折叠,空状态「开始一个面板」,从文件 / 审查 / 后台任务 / 浏览器 / 终端起步。输入框「+」打开同一组面板,「使用权限」提供默认权限 / 自动审批 / 完全访问并随对话发送。 - 模型页本机大模型:Ollama 已安装但未运行时显示「启动 Ollama」(Windows 走已知安装路径的 `Ollama.exe` / `ollama app` / `ollama serve`);缺少可自动处理的依赖时提供「一键下载安装」,否则说明需手动安装;「搜索本机模型」后台扫描 `.gguf` / `.ggml`(以及明确的本地 LLM safetensors 目录),结果进入本机列表并可注册到 Ollama 供对话切换。 - 组织页双向互生长:FreeOS 资产经 `/api/freeos/ingest`(共享写入令牌,无需边车登录墙)落到 openXYOS 员工/人才/插件/技能表;控制面导出再装配回 FreeOS。循环在边车健康时不再把「已推送到控制面」写成 false。 - 组织页可从本地语料或 ima / 云知识库指针一键生产尚未入部门的专家 / 智能助手;本地模型一键下载后会注册到 Ollama 供应商。 diff --git a/dashboard/src/components/BrowserWorkspace/ChatBrowserPanel.module.less b/dashboard/src/components/BrowserWorkspace/ChatBrowserPanel.module.less index 2a7c3fdd..4e1e7cbb 100644 --- a/dashboard/src/components/BrowserWorkspace/ChatBrowserPanel.module.less +++ b/dashboard/src/components/BrowserWorkspace/ChatBrowserPanel.module.less @@ -211,6 +211,12 @@ } } +.toolbarLeading { + display: flex; + align-items: center; + flex-shrink: 0; +} + .toolbarActions { display: flex; align-items: center; diff --git a/dashboard/src/components/BrowserWorkspace/ChatDockPanelShell.tsx b/dashboard/src/components/BrowserWorkspace/ChatDockPanelShell.tsx index faf02891..8d7831c8 100644 --- a/dashboard/src/components/BrowserWorkspace/ChatDockPanelShell.tsx +++ b/dashboard/src/components/BrowserWorkspace/ChatDockPanelShell.tsx @@ -10,13 +10,13 @@ import type { MenuProps } from "antd"; import { useTranslation } from "react-i18next"; import { Check, + Columns2, Maximize2, Minimize2, - MoreVertical, PanelBottom, PanelRight, + PanelRightClose, PictureInPicture2, - X, } from "lucide-react"; import { beginPointerDragSession } from "../../hooks/usePointerDragSession"; import type { PanelMode } from "./index"; @@ -37,8 +37,13 @@ interface ChatDockPanelShellProps { style?: React.CSSProperties; /** Left-side title (filename / “远程浏览器”). */ title?: React.ReactNode; + /** Leading control before the title (e.g. add-panel +). */ + leadingActions?: React.ReactNode; /** Content actions left of the layout/close group (mode, refresh, download…). */ toolbarActions?: React.ReactNode; + /** Widen / restore the right dock (desktop). */ + onExpand?: () => void; + expandActive?: boolean; children: React.ReactNode; } @@ -116,7 +121,10 @@ const ChatDockPanelShell: React.FC = ({ onClose, style, title, + leadingActions, toolbarActions, + onExpand, + expandActive = false, children, }) => { const { t } = useTranslation(); @@ -449,6 +457,9 @@ const ChatDockPanelShell: React.FC = ({ onPointerDown={handlePopupDragStart} style={toolbarStyle} > + {leadingActions ? ( +
{leadingActions}
+ ) : null}
{title}
{toolbarActions ? ( @@ -507,18 +518,44 @@ const ChatDockPanelShell: React.FC = ({ aria-label={t("browserWorkspace.panelLayout", "面板布局")} onPointerDown={(e) => e.stopPropagation()} > - + - + {onExpand && mode === "right" ? ( + + + + ) : null} + + +
{windowControlsSpacerPx > 0 ? ( import("../../Control/Terminal")); @@ -49,6 +57,9 @@ interface ChatDockPanelProps { onSelectTab: (id: DockTabId) => void; onCloseTab: (id: DockTabId) => void; onOpenFile: (path: string) => void; + onOpenPanel?: (kind: WorkspacePanelKind) => void; + onExpand?: () => void; + expandActive?: boolean; browserEnvironment?: DisplayEnvironment; threadId?: string | null; isStreamingTurn?: boolean; @@ -76,6 +87,9 @@ const ChatDockPanel: React.FC = ({ onSelectTab, onCloseTab, onOpenFile, + onOpenPanel, + onExpand, + expandActive = false, browserEnvironment = "desktop", threadId = null, isStreamingTurn = false, @@ -89,6 +103,12 @@ const ChatDockPanel: React.FC = ({ const [terminalMounted, setTerminalMounted] = useState( openTabs.some((tab) => tab.kind === "terminal"), ); + const [reviewMounted, setReviewMounted] = useState( + openTabs.some((tab) => tab.kind === "review"), + ); + const [tasksMounted, setTasksMounted] = useState( + openTabs.some((tab) => tab.kind === "tasks"), + ); const [mountedFilePaths, setMountedFilePaths] = useState(() => openTabs.filter((tab) => tab.kind === "file").map((tab) => tab.path), ); @@ -99,6 +119,7 @@ const ChatDockPanel: React.FC = ({ () => openTabs.filter((tab) => tab.kind === "toolUi").map((tab) => tab.callId), ); + const [addOpen, setAddOpen] = useState(false); const [fileActionsByPath, setFileActionsByPath] = useState< Record >({}); @@ -116,8 +137,12 @@ const ChatDockPanel: React.FC = ({ useEffect(() => { const hasBrowser = openTabs.some((tab) => tab.kind === "browser"); const hasTerminal = openTabs.some((tab) => tab.kind === "terminal"); + const hasReview = openTabs.some((tab) => tab.kind === "review"); + const hasTasks = openTabs.some((tab) => tab.kind === "tasks"); setBrowserMounted(hasBrowser); setTerminalMounted(hasTerminal); + setReviewMounted(hasReview); + setTasksMounted(hasTasks); const openFilePaths = new Set( openTabs.filter((tab) => tab.kind === "file").map((tab) => tab.path), ); @@ -253,6 +278,16 @@ const ChatDockPanel: React.FC = ({ {t("chat.dockFileList", "文件变更")} + ) : tab.kind === "review" ? ( + <> + + {t("chat.rightRail.dockReviewTitle", "审查")} + + ) : tab.kind === "tasks" ? ( + <> + + {t("chat.rightRail.dockTasksTitle", "后台任务")} + ) : tab.kind === "browser" ? ( <> @@ -342,15 +377,49 @@ const ChatDockPanel: React.FC = ({ return null; }, [activeTab, fileActionsByPath, knowledgeActionsById, t]); + const addButton = onOpenPanel ? ( + { + onOpenPanel(kind); + setAddOpen(false); + }} + /> + } + > + + + + + ) : null; + return ( 0 ? tabBar : undefined} toolbarActions={toolbarActions} + onExpand={onExpand} + expandActive={expandActive} > + {openTabs.length === 0 && onOpenPanel ? ( + + ) : null}
{openTabs.some((tab) => tab.kind === "files") && (
= ({ ); })} + {reviewMounted && ( + + )} + + {tasksMounted && ( + + )} + {browserMounted && (
{ expect(screen.getByTestId("chat-dock-panel")).toBe(first); }); - it("does not mount an empty dock when there are no tabs", () => { + it("does not mount an empty dock when there are no tabs and the rail is closed", () => { render( { ); expect(screen.queryByTestId("chat-dock-panel")).toBeNull(); }); + + it("mounts the start-state dock when the rail is open with no tabs", () => { + render( + , + ); + expect(screen.getByTestId("chat-dock-panel")).toBeTruthy(); + }); }); diff --git a/dashboard/src/pages/Chat/components/ChatDockPanels.tsx b/dashboard/src/pages/Chat/components/ChatDockPanels.tsx index e8c13c26..f57ba894 100644 --- a/dashboard/src/pages/Chat/components/ChatDockPanels.tsx +++ b/dashboard/src/pages/Chat/components/ChatDockPanels.tsx @@ -1,6 +1,7 @@ import type { PanelMode } from "../../../components/BrowserWorkspace"; import type { DisplayEnvironment } from "../../../api/types/browser"; import type { DockTab, DockTabId } from "../hooks/useChatDockPanel"; +import type { WorkspacePanelKind } from "../utils/workspacePanels"; import styles from "../index.module.less"; import ChatDockPanel from "./ChatDockPanel"; @@ -17,6 +18,10 @@ interface ChatDockPanelsProps { onSelectTab: (id: DockTabId) => void; onCloseTab: (id: DockTabId) => void; onOpenFile: (path: string) => void; + onOpenPanel?: (kind: WorkspacePanelKind) => void; + onExpand?: () => void; + expandActive?: boolean; + railWide?: boolean; browserEnvironment: DisplayEnvironment; threadId?: string | null; isStreamingTurn?: boolean; @@ -51,6 +56,10 @@ export default function ChatDockPanels({ onSelectTab, onCloseTab, onOpenFile, + onOpenPanel, + onExpand, + expandActive = false, + railWide = false, browserEnvironment, threadId = null, isStreamingTurn = false, @@ -58,13 +67,17 @@ export default function ChatDockPanels({ onClose, onResizeStart, }: ChatDockPanelsProps) { - const keepAlive = openTabs.length > 0; + const keepAlive = openTabs.length > 0 || dockOpen; const visible = dockOpen && keepAlive; if (!keepAlive) { return null; } + const rightWidth = railWide + ? Math.max(panelSizes.rightWidth, 720) + : panelSizes.rightWidth; + const effectiveMode: PanelMode = dockMode === "right" && isMobile ? "popup" : dockMode; @@ -77,7 +90,7 @@ export default function ChatDockPanels({ effectiveMode === "bottom" ? { height: panelSizes.bottomHeight } : effectiveMode === "right" - ? { width: panelSizes.rightWidth } + ? { width: rightWidth } : undefined } agentId={agentId} @@ -87,6 +100,9 @@ export default function ChatDockPanels({ onSelectTab={onSelectTab} onCloseTab={onCloseTab} onOpenFile={onOpenFile} + onOpenPanel={onOpenPanel} + onExpand={onExpand} + expandActive={expandActive} browserEnvironment={browserEnvironment} threadId={threadId} isStreamingTurn={isStreamingTurn} diff --git a/dashboard/src/pages/Chat/components/ChatDockReviewContent.tsx b/dashboard/src/pages/Chat/components/ChatDockReviewContent.tsx new file mode 100644 index 00000000..5e9adcf2 --- /dev/null +++ b/dashboard/src/pages/Chat/components/ChatDockReviewContent.tsx @@ -0,0 +1,93 @@ +import { useState } from "react"; +import { Button, Empty, Spin } from "antd"; +import { useTranslation } from "react-i18next"; +import { useTrajectorySession } from "../hooks/useTrajectorySession"; +import TrajectoryLedger from "./TrajectoryLedger"; +import styles from "../index.module.less"; + +interface ChatDockReviewContentProps { + agentId: string; + threadId?: string | null; + visible: boolean; +} + +export default function ChatDockReviewContent({ + agentId, + threadId, + visible, +}: ChatDockReviewContentProps) { + const { t } = useTranslation(); + const { events, loading, error, retry } = useTrajectorySession({ + agentId, + threadId: threadId ?? null, + visible, + }); + const [selectedEventId, setSelectedEventId] = useState(null); + + if (!threadId) { + return ( +
+ +
+ ); + } + + if (loading && events.length === 0) { + return ( +
+ +
+ ); + } + + if (error && events.length === 0) { + return ( +
+ + + +
+ ); + } + + if (events.length === 0) { + return ( +
+ +
+ {t("chat.rightRail.reviewEmpty", "还没有可审查的操作")} +
+

+ {t( + "chat.rightRail.reviewEmptyHint", + "和助手对话后,工具调用与改动会显示在这里。", + )} +

+
+ } + /> +
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/dashboard/src/pages/Chat/components/ChatDockStartState.test.tsx b/dashboard/src/pages/Chat/components/ChatDockStartState.test.tsx new file mode 100644 index 00000000..fb501f96 --- /dev/null +++ b/dashboard/src/pages/Chat/components/ChatDockStartState.test.tsx @@ -0,0 +1,17 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import ChatDockStartState from "./ChatDockStartState"; + +describe("ChatDockStartState", () => { + it("lists the five starter panels", () => { + const onOpen = vi.fn(); + render(); + expect(screen.getByText("开始一个面板")).toBeInTheDocument(); + fireEvent.click(screen.getByText("打开审查")); + expect(onOpen).toHaveBeenCalledWith("review"); + fireEvent.click(screen.getByText("打开后台任务")); + expect(onOpen).toHaveBeenCalledWith("tasks"); + fireEvent.click(screen.getByText("打开终端")); + expect(onOpen).toHaveBeenCalledWith("terminal"); + }); +}); diff --git a/dashboard/src/pages/Chat/components/ChatDockStartState.tsx b/dashboard/src/pages/Chat/components/ChatDockStartState.tsx new file mode 100644 index 00000000..3702c964 --- /dev/null +++ b/dashboard/src/pages/Chat/components/ChatDockStartState.tsx @@ -0,0 +1,109 @@ +import { ChevronRight } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { + FileText, + Globe, + ListChecks, + ScanSearch, + Terminal, +} from "lucide-react"; +import type { WorkspacePanelKind } from "../utils/workspacePanels"; +import { WORKSPACE_PANEL_KINDS } from "../utils/workspacePanels"; +import styles from "../index.module.less"; + +const ICONS: Record = { + files: FileText, + review: ScanSearch, + tasks: ListChecks, + browser: Globe, + terminal: Terminal, +}; + +interface ChatDockStartStateProps { + onOpen: (kind: WorkspacePanelKind) => void; +} + +export default function ChatDockStartState({ onOpen }: ChatDockStartStateProps) { + const { t } = useTranslation(); + + const title = (kind: WorkspacePanelKind) => { + switch (kind) { + case "files": + return t("chat.rightRail.openFiles", "打开文件"); + case "review": + return t("chat.rightRail.openReview", "打开审查"); + case "tasks": + return t("chat.rightRail.openTasks", "打开后台任务"); + case "browser": + return t("chat.rightRail.openBrowser", "打开浏览器"); + case "terminal": + return t("chat.rightRail.openTerminal", "打开终端"); + } + }; + + const hint = (kind: WorkspacePanelKind) => { + switch (kind) { + case "files": + return t("chat.rightRail.openFilesHint", "文件"); + case "review": + return t("chat.rightRail.openReviewHint", "审查"); + case "tasks": + return t("chat.rightRail.openTasksHint", "后台任务"); + case "browser": + return t("chat.rightRail.openBrowserHint", "浏览器"); + case "terminal": + return t("chat.rightRail.openTerminalHint", "终端"); + } + }; + + return ( +
+
+
+ {t("chat.rightRail.title", "右侧栏")} +
+

+ {t("chat.rightRail.startTitle", "开始一个面板")} +

+

+ {t( + "chat.rightRail.startDesc", + "在右侧打开文件、审查、资源监视器、内置浏览器或终端,与左侧对话并行工作。", + )} +

+
+
+ {WORKSPACE_PANEL_KINDS.map((kind) => { + const Icon = ICONS[kind]; + return ( + + ); + })} +
+

+ {t("chat.rightRail.footerHint", "可通过顶部 + 按钮选择更多面板类型")} +

+
+ ); +} diff --git a/dashboard/src/pages/Chat/components/ChatDockTasksContent.tsx b/dashboard/src/pages/Chat/components/ChatDockTasksContent.tsx new file mode 100644 index 00000000..4971c1db --- /dev/null +++ b/dashboard/src/pages/Chat/components/ChatDockTasksContent.tsx @@ -0,0 +1,126 @@ +import { useEffect, useState } from "react"; +import { Button, Empty, Spin } from "antd"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { octopCronApi } from "../../../api/modules/cronjob"; +import type { OctopCronRow } from "../../../api/types"; +import { useServerTimezone } from "../../../hooks/useServerTimezone"; +import { WORKSPACE_TASKS_PATH } from "../../../layouts/conversationHome"; +import { formatCronTimestamp } from "../../Control/CronJobs/cronDisplay"; +import { apiErrorMessage } from "../../../utils/apiError"; +import styles from "../index.module.less"; + +interface ChatDockTasksContentProps { + agentId: string; + visible: boolean; +} + +export default function ChatDockTasksContent({ + agentId, + visible, +}: ChatDockTasksContentProps) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const timeZone = useServerTimezone(); + const [jobs, setJobs] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!visible || !agentId) return; + let cancelled = false; + setLoading(true); + setError(null); + void octopCronApi + .list(agentId) + .then((rows) => { + if (!cancelled) setJobs(rows); + }) + .catch((err: unknown) => { + if (!cancelled) setError(apiErrorMessage(err, t)); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [agentId, t, visible]); + + const openTasksPage = () => navigate(WORKSPACE_TASKS_PATH); + + if (loading && jobs.length === 0) { + return ( +
+ +
+ ); + } + + if (error && jobs.length === 0) { + return ( +
+ + + +
+ ); + } + + if (jobs.length === 0) { + return ( +
+ +
+ {t("chat.rightRail.tasksEmpty", "当前没有后台任务")} +
+

+ {t( + "chat.rightRail.tasksEmptyHint", + "定时任务和正在运行的工作会显示在这里。", + )} +

+
+ } + > + + +
+ ); + } + + return ( +
+
    + {jobs.map((job) => ( +
  • +
    {job.name || job.id}
    +
    + {job.trigger} + + {job.enabled + ? t("chat.rightRail.taskOn", "已启用") + : t("chat.rightRail.taskOff", "已停用")} + + + {formatCronTimestamp(job.last_run_at, timeZone)} + {job.last_status ? ` · ${job.last_status}` : ""} + +
    +
  • + ))} +
+
+ +
+
+ ); +} diff --git a/dashboard/src/pages/Chat/components/ChatInput.tsx b/dashboard/src/pages/Chat/components/ChatInput.tsx index 7028397a..d121ced1 100644 --- a/dashboard/src/pages/Chat/components/ChatInput.tsx +++ b/dashboard/src/pages/Chat/components/ChatInput.tsx @@ -19,6 +19,8 @@ import type { KnowledgeBase } from "../../../api/modules/knowledgeBases"; import type { SkillSpec } from "../../Agent/Skills/useSkills"; import type { ChatAgentOption } from "./ExpertAgentAvatar"; import type { AgentSubagentSummary } from "../../../api/modules/subagents"; +import type { ChatPermissionMode } from "../utils/permissionMode"; +import type { WorkspacePanelKind } from "../utils/workspacePanels"; import MentionPickerMenu from "./MentionPickerMenu"; import ChatInputPreviewBar from "./ChatInputPreviewBar"; import ChatInputActionsRow from "./ChatInputActionsRow"; @@ -111,6 +113,9 @@ interface ChatInputProps { defaultModel?: string | null; contextUsedTokens?: number | null; contextMaxTokens?: number; + permissionMode?: ChatPermissionMode; + onPermissionModeChange?: (mode: ChatPermissionMode) => void; + onOpenWorkspacePanel?: (kind: WorkspacePanelKind) => void; } const ChatInput = forwardRef( @@ -159,6 +164,9 @@ const ChatInput = forwardRef( defaultModel, contextUsedTokens = null, contextMaxTokens = 128_000, + permissionMode = "default", + onPermissionModeChange, + onOpenWorkspacePanel, }, ref, ) { @@ -837,6 +845,9 @@ const ChatInput = forwardRef( slashMenuItems={slashMenuItems} onSlashShortcutSelect={handleSlashSelect} onFileSelect={handleFileSelect} + permissionMode={permissionMode} + onPermissionModeChange={onPermissionModeChange} + onOpenWorkspacePanel={onOpenWorkspacePanel} onNewChat={onNewChat} onPolish={() => void handlePolish()} onToggleVoice={() => toggleVoice()} diff --git a/dashboard/src/pages/Chat/components/ChatInputActionsRow.test.tsx b/dashboard/src/pages/Chat/components/ChatInputActionsRow.test.tsx index 7be9f85b..ee0d51b4 100644 --- a/dashboard/src/pages/Chat/components/ChatInputActionsRow.test.tsx +++ b/dashboard/src/pages/Chat/components/ChatInputActionsRow.test.tsx @@ -137,4 +137,71 @@ describe("ChatInputActionsRow compact pickers", () => { fireEvent.click(modelButton!); expect(await screen.findByText("模型管理")).toBeInTheDocument(); }); + + it("opens the workspace panel picker from the composer + button", async () => { + const onOpenWorkspacePanel = vi.fn(); + const { container } = render( + + + , + ); + + const plus = container.querySelector("svg.lucide-plus")?.closest("button"); + expect(plus).not.toBeNull(); + fireEvent.click(plus!); + const openFiles = await screen.findByText("打开文件"); + fireEvent.click(openFiles.closest("button") ?? openFiles); + expect(onOpenWorkspacePanel).toHaveBeenCalledWith("files"); + }); + + it("shows the current permission mode on the trigger", () => { + render( + + + , + ); + + expect(screen.getByText("完全访问")).toBeInTheDocument(); + }); }); diff --git a/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx b/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx index 65726b76..0bcfc95e 100644 --- a/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx +++ b/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx @@ -23,7 +23,12 @@ import { Check, ChevronLeft, ChevronRight, + Plus, } from "lucide-react"; +import ChatPanelPicker from "./ChatPanelPicker"; +import ChatPermissionModeMenu from "./ChatPermissionModeMenu"; +import type { ChatPermissionMode } from "../utils/permissionMode"; +import type { WorkspacePanelKind } from "../utils/workspacePanels"; import { Tooltip, Popover, Drawer } from "antd"; import type { ResolvedModel } from "../../../api/types"; import type { KnowledgeBase } from "../../../api/modules/knowledgeBases"; @@ -122,6 +127,9 @@ interface ChatInputActionsRowProps { slashMenuItems: SlashMenuItem[]; onSlashShortcutSelect: (command: string) => void; onFileSelect: () => void; + permissionMode?: ChatPermissionMode; + onPermissionModeChange?: (mode: ChatPermissionMode) => void; + onOpenWorkspacePanel?: (kind: WorkspacePanelKind) => void; onNewChat: () => void; onPolish: () => void; onToggleVoice: () => void; @@ -173,6 +181,9 @@ export default function ChatInputActionsRow({ slashMenuItems, onSlashShortcutSelect, onFileSelect, + permissionMode = "default", + onPermissionModeChange, + onOpenWorkspacePanel, onNewChat, onPolish, onToggleVoice, @@ -199,6 +210,7 @@ export default function ChatInputActionsRow({ const [mobileOverflowOpen, setMobileOverflowOpen] = useState(false); /** Narrow-desktop overflow popover (tools / skills / …). */ const [overflowPopoverOpen, setOverflowPopoverOpen] = useState(false); + const [panelPickerOpen, setPanelPickerOpen] = useState(false); /** Active sub-picker for compact layouts (drawer on mobile, panel in popover). */ const [compactPicker, setCompactPicker] = useState( null, @@ -771,6 +783,46 @@ export default function ChatInputActionsRow({ renderMobileOverflowMenu() ); + const plusButton = onOpenWorkspacePanel ? ( + { + onOpenWorkspacePanel(kind); + setPanelPickerOpen(false); + }} + /> + } + > + + + + + ) : null; + + const permissionControl = onPermissionModeChange ? ( + + ) : null; + const renderSecondaryActions = () => { if (useCompactControls) { const modelButton = ( @@ -803,6 +855,8 @@ export default function ChatInputActionsRow({ return ( <> + {plusButton} + {permissionControl} {showModelPicker && (isMobile ? ( modelButton @@ -888,6 +942,8 @@ export default function ChatInputActionsRow({ return ( <> + {plusButton} + {permissionControl} {showModelPicker && ( = { + files: FileText, + review: ScanSearch, + tasks: ListChecks, + browser: Globe, + terminal: Terminal, +}; + +interface ChatPanelPickerProps { + onSelect: (kind: WorkspacePanelKind) => void; + disabledKinds?: ReadonlySet; +} + +export default function ChatPanelPicker({ + onSelect, + disabledKinds, +}: ChatPanelPickerProps) { + const { t } = useTranslation(); + + const label = (kind: WorkspacePanelKind) => { + switch (kind) { + case "files": + return t("chat.rightRail.openFiles", "打开文件"); + case "review": + return t("chat.rightRail.openReview", "打开审查"); + case "tasks": + return t("chat.rightRail.openTasks", "打开后台任务"); + case "browser": + return t("chat.rightRail.openBrowser", "打开浏览器"); + case "terminal": + return t("chat.rightRail.openTerminal", "打开终端"); + } + }; + + const hint = (kind: WorkspacePanelKind) => { + switch (kind) { + case "files": + return t("chat.rightRail.openFilesHint", "文件"); + case "review": + return t("chat.rightRail.openReviewHint", "审查"); + case "tasks": + return t("chat.rightRail.openTasksHint", "后台任务"); + case "browser": + return t("chat.rightRail.openBrowserHint", "浏览器"); + case "terminal": + return t("chat.rightRail.openTerminalHint", "终端"); + } + }; + + return ( +
+ {WORKSPACE_PANEL_KINDS.map((kind) => { + const Icon = ICONS[kind]; + const disabled = disabledKinds?.has(kind); + return ( + + ); + })} +
+ ); +} diff --git a/dashboard/src/pages/Chat/components/ChatPermissionModeMenu.tsx b/dashboard/src/pages/Chat/components/ChatPermissionModeMenu.tsx new file mode 100644 index 00000000..bbecccfd --- /dev/null +++ b/dashboard/src/pages/Chat/components/ChatPermissionModeMenu.tsx @@ -0,0 +1,158 @@ +import { useMemo, useState } from "react"; +import { Popover, Tooltip } from "antd"; +import { AlertTriangle, Check, ChevronDown, Hand, Sparkles } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { showConfirmModal } from "../../../utils/confirmModal"; +import type { ChatPermissionMode } from "../utils/permissionMode"; +import styles from "../index.module.less"; + +interface ChatPermissionModeMenuProps { + mode: ChatPermissionMode; + onChange: (mode: ChatPermissionMode) => void; + disabled?: boolean; + isMobile?: boolean; +} + +const MODE_ORDER: ChatPermissionMode[] = ["default", "auto", "full"]; + +export default function ChatPermissionModeMenu({ + mode, + onChange, + disabled, + isMobile, +}: ChatPermissionModeMenuProps) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + + const labels = useMemo( + () => ({ + default: t("chat.permissionMode.default", "默认权限"), + auto: t("chat.permissionMode.auto", "自动审批"), + full: t("chat.permissionMode.full", "完全访问"), + }), + [t], + ); + const hints = useMemo( + () => ({ + default: t( + "chat.permissionMode.defaultHint", + "按当前安全策略询问或执行工具。", + ), + auto: t( + "chat.permissionMode.autoHint", + "在受保护的工作区沙箱内自动执行命令;需要越过沙箱边界的操作交给自动审查器判断,高风险操作可能被拒绝或要求确认。", + ), + full: t( + "chat.permissionMode.fullHint", + "允许更高权限的操作;高风险命令仍可能被安全策略拦截。", + ), + }), + [t], + ); + + const applyMode = (next: ChatPermissionMode) => { + if (next === mode) { + setOpen(false); + return; + } + if (next === "full") { + setOpen(false); + showConfirmModal( + { + title: t("chat.permissionMode.fullConfirmTitle", "切换到完全访问?"), + content: t( + "chat.permissionMode.fullConfirm", + "完全访问会放宽本对话的工具审批。请确认你信任这次任务。", + ), + okText: t("common.confirm"), + cancelText: t("common.cancel"), + okButtonProps: { danger: true }, + onOk: () => onChange("full"), + }, + { isMobile }, + ); + return; + } + onChange(next); + setOpen(false); + }; + + const menu = ( +
+ {MODE_ORDER.map((item) => { + const active = item === mode; + const row = ( + + ); + if (item === "auto") { + return ( + + {row} + + ); + } + return row; + })} +
+ ); + + return ( + { + if (disabled) return; + setOpen(next); + }} + overlayClassName={styles.permissionPopover} + content={menu} + > + + + ); +} diff --git a/dashboard/src/pages/Chat/hooks/chatStore.ts b/dashboard/src/pages/Chat/hooks/chatStore.ts index 48cea7db..98084a1c 100644 --- a/dashboard/src/pages/Chat/hooks/chatStore.ts +++ b/dashboard/src/pages/Chat/hooks/chatStore.ts @@ -1851,6 +1851,7 @@ async function sendTurnWebSocket( onStreamEnd?: () => void, reasoningMode?: "auto" | "enabled" | "disabled", reasoningEffort?: string | null, + permissionMode?: "default" | "auto" | "full", ): Promise { const state = getOrCreate(sessionId); const resolvedThreadId = (threadId || sessionId).trim(); @@ -1928,6 +1929,7 @@ async function sendTurnWebSocket( } if (reasoningMode) payload.reasoning_mode = reasoningMode; if (reasoningEffort) payload.reasoning_effort = reasoningEffort; + if (permissionMode) payload.permission_mode = permissionMode; ws.send(JSON.stringify(payload)); }; @@ -2052,6 +2054,7 @@ export async function sendTurn( targetAgentIds?: string[] | null, reasoningMode?: "auto" | "enabled" | "disabled", reasoningEffort?: string | null, + permissionMode?: "default" | "auto" | "full", ): Promise { const state = getOrCreate(sessionId); @@ -2123,6 +2126,7 @@ export async function sendTurn( onStreamEnd, reasoningMode, reasoningEffort, + permissionMode, ); if (!wsOk) { state.messages = [ diff --git a/dashboard/src/pages/Chat/hooks/sseHelpers.ts b/dashboard/src/pages/Chat/hooks/sseHelpers.ts index 9933981a..bb37889b 100644 --- a/dashboard/src/pages/Chat/hooks/sseHelpers.ts +++ b/dashboard/src/pages/Chat/hooks/sseHelpers.ts @@ -53,6 +53,7 @@ export interface UserComposerContext { model?: string; reasoningMode?: "auto" | "enabled" | "disabled"; reasoningEffort?: string | null; + permissionMode?: "default" | "auto" | "full"; } export interface ChatMessage { diff --git a/dashboard/src/pages/Chat/hooks/useChat.ts b/dashboard/src/pages/Chat/hooks/useChat.ts index 25968274..aa4fdb10 100644 --- a/dashboard/src/pages/Chat/hooks/useChat.ts +++ b/dashboard/src/pages/Chat/hooks/useChat.ts @@ -831,6 +831,7 @@ export function useChat( composerContext?: UserComposerContext, reasoningMode?: "auto" | "enabled" | "disabled", reasoningEffort?: string | null, + permissionMode?: "default" | "auto" | "full", ) => { const key = storeKey || stableSessionId; @@ -863,6 +864,7 @@ export function useChat( targetAgentIds, reasoningMode, reasoningEffort, + permissionMode, ); }, [stableSessionId], diff --git a/dashboard/src/pages/Chat/hooks/useChatDockPanel.test.ts b/dashboard/src/pages/Chat/hooks/useChatDockPanel.test.ts index bb079148..45f26f4c 100644 --- a/dashboard/src/pages/Chat/hooks/useChatDockPanel.test.ts +++ b/dashboard/src/pages/Chat/hooks/useChatDockPanel.test.ts @@ -146,7 +146,7 @@ describe("useChatDockPanel tabs", () => { expect(result.current.dockOpen).toBe(true); }); - it("closeTab falls back to files and closes dock when empty", () => { + it("closeTab keeps the rail open so the start state can show", () => { const { result } = renderHook(() => useChatDockPanel(false)); act(() => { result.current.openBrowserTab(); @@ -155,7 +155,32 @@ describe("useChatDockPanel tabs", () => { result.current.closeTab("browser"); }); expect(result.current.openTabs).toEqual([]); - expect(result.current.dockOpen).toBe(false); + expect(result.current.dockOpen).toBe(true); + }); + + it("openReviewTab and openTasksTab add dedicated dock tabs", () => { + const { result } = renderHook(() => useChatDockPanel(false)); + act(() => { + result.current.openReviewTab(); + }); + expect(result.current.openTabs.map((t) => t.id)).toEqual(["review"]); + act(() => { + result.current.openTasksTab(); + }); + expect(result.current.activeTabId).toBe("tasks"); + expect(result.current.openTabs.map((t) => t.kind)).toEqual([ + "review", + "tasks", + ]); + }); + + it("openRail shows the empty sidebar without adding tabs", () => { + const { result } = renderHook(() => useChatDockPanel(false)); + act(() => { + result.current.openRail(); + }); + expect(result.current.dockOpen).toBe(true); + expect(result.current.openTabs).toEqual([]); }); it("openToolUiTab dedupes by callId and focuses the tool tab", () => { diff --git a/dashboard/src/pages/Chat/hooks/useChatDockPanel.ts b/dashboard/src/pages/Chat/hooks/useChatDockPanel.ts index 5afce1b4..d28cb929 100644 --- a/dashboard/src/pages/Chat/hooks/useChatDockPanel.ts +++ b/dashboard/src/pages/Chat/hooks/useChatDockPanel.ts @@ -10,9 +10,12 @@ import type { KnowledgeCitation } from "../../../utils/parseKnowledgeCitations"; import { dockKnowledgeTabId } from "../utils/dockKnowledgeTabId"; import { dockToolUiTabId } from "../utils/dockToolUiTabId"; import { usePanelResize, type PanelSizes } from "./usePanelResize"; +import type { WorkspacePanelKind } from "../utils/workspacePanels"; const PANEL_MODE_KEY = "octop:chat-dock:mode"; const PANEL_SIZE_KEY = "octop:chat-dock:size"; +const RAIL_OPEN_KEY = "octop:chat-right-rail:open"; +const RAIL_WIDE_KEY = "octop:chat-right-rail:wide"; /** Legacy keys — read once for migration. */ const LEGACY_FILE_MODE_KEY = "octop:file-panel:mode"; const LEGACY_BROWSER_MODE_KEY = "octop:browser-panel:mode"; @@ -21,6 +24,8 @@ const LEGACY_BROWSER_SIZE_KEY = "octop:browser-panel:size"; export type DockTab = | { id: "files"; kind: "files" } + | { id: "review"; kind: "review" } + | { id: "tasks"; kind: "tasks" } | { id: "browser"; kind: "browser" } | { id: "terminal"; kind: "terminal" } | { id: string; kind: "file"; path: string } @@ -90,6 +95,42 @@ function persistPanelSizes(sizes: PanelSizes) { } } +function loadRailOpen(isMobile: boolean): boolean { + if (isMobile) return false; + try { + const saved = localStorage.getItem(RAIL_OPEN_KEY); + if (saved === "true") return true; + if (saved === "false") return false; + } catch { + /* ignore */ + } + return false; +} + +function persistRailOpen(open: boolean) { + try { + localStorage.setItem(RAIL_OPEN_KEY, open ? "true" : "false"); + } catch { + /* ignore */ + } +} + +function loadRailWide(): boolean { + try { + return localStorage.getItem(RAIL_WIDE_KEY) === "true"; + } catch { + return false; + } +} + +function persistRailWide(wide: boolean) { + try { + localStorage.setItem(RAIL_WIDE_KEY, wide ? "true" : "false"); + } catch { + /* ignore */ + } +} + function ensureFilesTab(tabs: DockTab[]): DockTab[] { if (tabs.some((t) => t.id === "files")) return tabs; return [{ id: "files", kind: "files" }, ...tabs]; @@ -112,7 +153,8 @@ function fallbackActiveId( * Shared chat dock with tabbed file list / file viewers / browser / terminal. */ export function useChatDockPanel(isMobile: boolean, agentId?: string | null) { - const [dockOpen, setDockOpen] = useState(false); + const [dockOpen, setDockOpen] = useState(() => loadRailOpen(isMobile)); + const [railWide, setRailWide] = useState(loadRailWide); const [dockMode, setDockMode] = useState(loadPanelMode); const [openTabs, setOpenTabs] = useState([]); const [activeTabId, setActiveTabId] = useState(null); @@ -135,13 +177,19 @@ export function useChatDockPanel(isMobile: boolean, agentId?: string | null) { const openDock = useCallback(() => { setDockOpen(true); + persistRailOpen(true); if (isMobile) { setDockMode("bottom"); } }, [isMobile]); + const openRail = useCallback(() => { + openDock(); + }, [openDock]); + const handleClose = useCallback(() => { setDockOpen(false); + persistRailOpen(false); // Closing the dock restores tool UIs to the message stream. setOpenTabs((prev) => { const next = prev.filter((t) => t.kind !== "toolUi"); @@ -197,6 +245,24 @@ export function useChatDockPanel(isMobile: boolean, agentId?: string | null) { openDock(); }, [openDock]); + const openReviewTab = useCallback(() => { + setOpenTabs((prev) => { + if (prev.some((t) => t.id === "review")) return prev; + return [...prev, { id: "review", kind: "review" }]; + }); + setActiveTabId("review"); + openDock(); + }, [openDock]); + + const openTasksTab = useCallback(() => { + setOpenTabs((prev) => { + if (prev.some((t) => t.id === "tasks")) return prev; + return [...prev, { id: "tasks", kind: "tasks" }]; + }); + setActiveTabId("tasks"); + openDock(); + }, [openDock]); + const openTerminalTab = useCallback(() => { setOpenTabs((prev) => { if (prev.some((t) => t.id === "terminal")) return prev; @@ -261,11 +327,13 @@ export function useChatDockPanel(isMobile: boolean, agentId?: string | null) { /** Toggle dock open/closed around a dedicated tab (browser / terminal). */ const toggleDockTab = useCallback( - (tab: Extract) => { + (tab: Extract) => { setDockOpen((prevOpen) => { if (prevOpen && activeTabId === tab.id) { + persistRailOpen(false); return false; } + persistRailOpen(true); setOpenTabs((prev) => { if (prev.some((t) => t.id === tab.id)) return prev; return [...prev, tab]; @@ -288,16 +356,41 @@ export function useChatDockPanel(isMobile: boolean, agentId?: string | null) { toggleDockTab({ id: "terminal", kind: "terminal" }); }, [toggleDockTab]); + const openWorkspacePanel = useCallback( + (kind: WorkspacePanelKind) => { + if (kind === "files") { + openFileList(); + return; + } + if (kind === "review") { + openReviewTab(); + return; + } + if (kind === "tasks") { + openTasksTab(); + return; + } + if (kind === "browser") { + openBrowserTab(); + return; + } + openTerminalTab(); + }, + [openBrowserTab, openFileList, openReviewTab, openTasksTab, openTerminalTab], + ); + const closeTab = useCallback((id: DockTabId) => { setOpenTabs((prev) => { const next = prev.filter((t) => t.id !== id); - setActiveTabId((current) => { - const nextActive = fallbackActiveId(next, id, current); - if (next.length === 0) { - setDockOpen(false); - } - return nextActive; - }); + setActiveTabId((current) => fallbackActiveId(next, id, current)); + return next; + }); + }, []); + + const toggleRailWide = useCallback(() => { + setRailWide((prev) => { + const next = !prev; + persistRailWide(next); return next; }); }, []); @@ -333,6 +426,7 @@ export function useChatDockPanel(isMobile: boolean, agentId?: string | null) { return { dockOpen, dockMode, + railWide, filePath, openTabs, activeTabId, @@ -342,10 +436,14 @@ export function useChatDockPanel(isMobile: boolean, agentId?: string | null) { handleResizeStart, handleClose, handleModeChange, + openRail, openFileAt, openFileList, openKnowledgeCitation, openBrowserTab, + openReviewTab, + openTasksTab, + openWorkspacePanel, toggleBrowserPanel, openTerminalTab, toggleTerminalPanel, @@ -353,5 +451,6 @@ export function useChatDockPanel(isMobile: boolean, agentId?: string | null) { focusToolUiTab, closeTab, setActiveTab, + toggleRailWide, }; } diff --git a/dashboard/src/pages/Chat/hooks/useChatSend.ts b/dashboard/src/pages/Chat/hooks/useChatSend.ts index 9273e6b1..2f7c8508 100644 --- a/dashboard/src/pages/Chat/hooks/useChatSend.ts +++ b/dashboard/src/pages/Chat/hooks/useChatSend.ts @@ -25,6 +25,7 @@ interface UseChatSendParams { selectedTargetAgents?: string[]; reasoningMode: "auto" | "enabled" | "disabled"; reasoningEffort: string | null; + permissionMode?: "default" | "auto" | "full"; defaultModel?: string | null; sendMessage: ( text: string, @@ -39,6 +40,7 @@ interface UseChatSendParams { composerContext?: UserComposerContext, reasoningMode?: "auto" | "enabled" | "disabled", reasoningEffort?: string | null, + permissionMode?: "default" | "auto" | "full", ) => void; createSession: () => { session: Session; resolvedId: Promise }; renameSession: (id: string, name: string) => void; @@ -76,6 +78,7 @@ export function useChatSend({ selectedTargetAgents = [], reasoningMode, reasoningEffort, + permissionMode = "default", defaultModel, sendMessage, createSession, @@ -129,6 +132,8 @@ export function useChatSend({ overrides?.composerContext?.reasoningMode ?? reasoningMode, reasoningEffort: overrides?.composerContext?.reasoningEffort ?? reasoningEffort, + permissionMode: + overrides?.composerContext?.permissionMode ?? permissionMode, }); const modelOverride = @@ -151,6 +156,7 @@ export function useChatSend({ composerContext, composerContext?.reasoningMode ?? reasoningMode, composerContext?.reasoningEffort ?? reasoningEffort, + composerContext?.permissionMode ?? permissionMode, ); }; @@ -210,6 +216,7 @@ export function useChatSend({ targetAgents, composerContext?.reasoningMode ?? reasoningMode, composerContext?.reasoningEffort ?? reasoningEffort, + composerContext?.permissionMode ?? permissionMode, ); navigate(`/chat/${agent}/${tid}`, { replace: true }); }); @@ -230,6 +237,7 @@ export function useChatSend({ selectedTargetAgents, reasoningMode, reasoningEffort, + permissionMode, defaultModel, t, ], diff --git a/dashboard/src/pages/Chat/index.module.less b/dashboard/src/pages/Chat/index.module.less index db20893c..5c1350bf 100644 --- a/dashboard/src/pages/Chat/index.module.less +++ b/dashboard/src/pages/Chat/index.module.less @@ -11,10 +11,12 @@ @import "./chatSlashMention.partial.less"; @import "./chatInputCore.partial.less"; @import "./chatWelcome.partial.less"; +@import "./chatRightRail.partial.less"; @import "./chatThemeOverrides.partial.less"; /* ---------- Page shell ---------- */ .chatPage { + position: relative; /* Shared column metrics: title bar + composer alignment. */ --chat-column-max: 960px; --chat-column-pad-x: 24px; @@ -32,6 +34,29 @@ } /* ---------- Main chat area ---------- */ +.rightRailExpandEdge { + position: absolute; + top: 12px; + right: 10px; + z-index: 4; + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + border-radius: 8px; + background: var(--fn-bg-elevated, #fff); + color: var(--fn-text-secondary); + cursor: pointer; + box-shadow: var(--fn-shadow-sm, 0 1px 4px rgba(0, 0, 0, 0.08)); + + &:hover { + color: var(--fn-color-brand, #0033ff); + background: var(--fn-fill-secondary, rgba(0, 51, 255, 0.06)); + } +} + .chatMain { flex: 1; display: flex; diff --git a/dashboard/src/pages/Chat/index.tsx b/dashboard/src/pages/Chat/index.tsx index 836d419f..4c58d872 100644 --- a/dashboard/src/pages/Chat/index.tsx +++ b/dashboard/src/pages/Chat/index.tsx @@ -10,6 +10,7 @@ import { Terminal, FolderOpen, Activity, + PanelRightOpen, } from "lucide-react"; import { Alert, Button, Tooltip } from "antd"; import { message as antMessage } from "@/utils/antdMessage"; @@ -80,6 +81,12 @@ import { import ChatSidebarPanel from "./components/ChatSidebarPanel"; import ChatTitleBar from "./components/ChatTitleBar"; import ChatComposerChrome from "./components/ChatComposerChrome"; +import { + loadPermissionMode, + savePermissionMode, + type ChatPermissionMode, +} from "./utils/permissionMode"; +import type { WorkspacePanelKind } from "./utils/workspacePanels"; import AskQuestionCard from "./components/AskQuestionCard"; import { extractAskQuestions, isAskHitl } from "../../api/types/hitl"; import { isAgentChatReady } from "../../utils/agentError"; @@ -345,6 +352,7 @@ function ChatPageInner() { const { dockOpen, dockMode, + railWide, openTabs, activeTabId, panelSizes: dockPanelSizes, @@ -352,17 +360,35 @@ function ChatPageInner() { handleResizeStart: dockHandleResizeStart, handleClose: handleDockClose, handleModeChange: handleDockModeChange, + openRail, openFileList, openFileAt, openKnowledgeCitation, openBrowserTab, + openWorkspacePanel, toggleBrowserPanel, toggleTerminalPanel, openToolUiTab, focusToolUiTab, closeTab: closeDockTab, setActiveTab: setDockActiveTab, + toggleRailWide, } = useChatDockPanel(isMobile, resolvedAgentId); + const [permissionMode, setPermissionMode] = useState(() => + loadPermissionMode(activeThreadId), + ); + + useEffect(() => { + setPermissionMode(loadPermissionMode(activeThreadId)); + }, [activeThreadId]); + + const handlePermissionModeChange = useCallback( + (mode: ChatPermissionMode) => { + setPermissionMode(mode); + savePermissionMode(mode, activeThreadId); + }, + [activeThreadId], + ); const chromeCheckInFlightRef = useRef(false); const handleToggleBrowserPanel = useCallback(async () => { @@ -398,6 +424,20 @@ function ChatPageInner() { toggleBrowserPanel(); }, [browserSessionId, isMobile, navigate, t, toggleBrowserPanel]); + const handleOpenWorkspacePanel = useCallback( + (kind: WorkspacePanelKind) => { + if (kind === "browser") { + void handleToggleBrowserPanel(); + return; + } + if (kind === "terminal" && !canTerminal) { + return; + } + openWorkspacePanel(kind); + }, + [canTerminal, handleToggleBrowserPanel, openWorkspacePanel], + ); + const closeToolUiPanel = useCallback( (callId: string) => { closeDockTab(dockTabIdForToolCall(callId)); @@ -546,6 +586,7 @@ function ChatPageInner() { selectedKnowledgeBaseIds, reasoningMode, reasoningEffort, + permissionMode, defaultModel: activeAgent?.default_model ?? null, sendMessage, createSession, @@ -1274,7 +1315,7 @@ function ChatPageInner() { type="button" className={styles.chatFloatBtn} disabled={!activeThreadId || !agentChatReady} - onClick={() => setTrajectoryDrawerOpen(true)} + onClick={() => handleOpenWorkspacePanel("review")} aria-label={t("chat.openTrajectory", "运行轨迹")} > @@ -1353,6 +1394,9 @@ function ChatPageInner() { ) : null}
+ {!isMobile && !dockOpen && ( + + )} + { }); }); +describe("composer permissionMode", () => { + it("stamps and normalizes permissionMode", () => { + expect( + buildComposerContext({ permissionMode: "auto" }), + ).toEqual({ permissionMode: "auto" }); + expect( + normalizeComposerContext({ permissionMode: "full" }), + ).toEqual({ permissionMode: "full" }); + expect(normalizeComposerContext({ permissionMode: "yolo" })).toBeUndefined(); + }); +}); + describe("resolveTurnModelRef", () => { it("sends only an explicit composer selection", () => { expect(resolveTurnModelRef("p/picked", null)).toBe("p/picked"); diff --git a/dashboard/src/pages/Chat/utils/chatMessages.ts b/dashboard/src/pages/Chat/utils/chatMessages.ts index 552362cb..8b3efe04 100644 --- a/dashboard/src/pages/Chat/utils/chatMessages.ts +++ b/dashboard/src/pages/Chat/utils/chatMessages.ts @@ -48,6 +48,14 @@ export function normalizeComposerContext( ctx.reasoningEffort = raw.reasoningEffort.trim(); has = true; } + if ( + raw.permissionMode === "default" || + raw.permissionMode === "auto" || + raw.permissionMode === "full" + ) { + ctx.permissionMode = raw.permissionMode; + has = true; + } return has ? ctx : undefined; } @@ -88,6 +96,7 @@ export function buildComposerContext(params: { selectedModel?: string | null; reasoningMode?: "auto" | "enabled" | "disabled"; reasoningEffort?: string | null; + permissionMode?: "default" | "auto" | "full"; }): UserComposerContext | undefined { const ctx: UserComposerContext = {}; let has = false; @@ -122,6 +131,10 @@ export function buildComposerContext(params: { ctx.reasoningEffort = params.reasoningEffort; has = true; } + if (params.permissionMode) { + ctx.permissionMode = params.permissionMode; + has = true; + } return has ? ctx : undefined; } diff --git a/dashboard/src/pages/Chat/utils/permissionMode.test.ts b/dashboard/src/pages/Chat/utils/permissionMode.test.ts new file mode 100644 index 00000000..e1fcd647 --- /dev/null +++ b/dashboard/src/pages/Chat/utils/permissionMode.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + isChatPermissionMode, + loadPermissionMode, + savePermissionMode, +} from "./permissionMode"; + +describe("permissionMode", () => { + afterEach(() => { + localStorage.clear(); + }); + + it("accepts only the three composer modes", () => { + expect(isChatPermissionMode("default")).toBe(true); + expect(isChatPermissionMode("auto")).toBe(true); + expect(isChatPermissionMode("full")).toBe(true); + expect(isChatPermissionMode("yolo")).toBe(false); + expect(isChatPermissionMode("")).toBe(false); + }); + + it("defaults to default when nothing is stored", () => { + expect(loadPermissionMode()).toBe("default"); + expect(loadPermissionMode("thr_1")).toBe("default"); + }); + + it("uses the workspace default when the thread has no override", () => { + savePermissionMode("auto"); + expect(loadPermissionMode("thr_1")).toBe("auto"); + expect(loadPermissionMode()).toBe("auto"); + }); + + it("persists a per-conversation override", () => { + savePermissionMode("auto"); + savePermissionMode("full", "thr_1"); + expect(loadPermissionMode("thr_1")).toBe("full"); + expect(loadPermissionMode("thr_2")).toBe("auto"); + }); +}); diff --git a/dashboard/src/pages/Chat/utils/permissionMode.ts b/dashboard/src/pages/Chat/utils/permissionMode.ts new file mode 100644 index 00000000..0abcd709 --- /dev/null +++ b/dashboard/src/pages/Chat/utils/permissionMode.ts @@ -0,0 +1,56 @@ +/** Composer / turn permission modes for desktop chat. */ + +export const CHAT_PERMISSION_MODES = ["default", "auto", "full"] as const; + +export type ChatPermissionMode = (typeof CHAT_PERMISSION_MODES)[number]; + +const WORKSPACE_KEY = "octop:chat-permission-mode"; + +export function isChatPermissionMode(value: unknown): value is ChatPermissionMode { + return ( + value === "default" || value === "auto" || value === "full" + ); +} + +function threadKey(threadId: string): string { + return `${WORKSPACE_KEY}:${threadId}`; +} + +function readStored(key: string): ChatPermissionMode | null { + try { + const raw = localStorage.getItem(key); + return isChatPermissionMode(raw) ? raw : null; + } catch { + return null; + } +} + +function writeStored(key: string, mode: ChatPermissionMode): void { + try { + localStorage.setItem(key, mode); + } catch { + /* ignore quota / private mode */ + } +} + +/** Thread selection wins; otherwise the last workspace-wide choice. */ +export function loadPermissionMode( + threadId?: string | null, +): ChatPermissionMode { + const tid = threadId?.trim(); + if (tid) { + const perThread = readStored(threadKey(tid)); + if (perThread) return perThread; + } + return readStored(WORKSPACE_KEY) ?? "default"; +} + +/** Persist for this conversation and as the workspace default. */ +export function savePermissionMode( + mode: ChatPermissionMode, + threadId?: string | null, +): void { + writeStored(WORKSPACE_KEY, mode); + const tid = threadId?.trim(); + if (tid) writeStored(threadKey(tid), mode); +} diff --git a/dashboard/src/pages/Chat/utils/workspacePanels.ts b/dashboard/src/pages/Chat/utils/workspacePanels.ts new file mode 100644 index 00000000..c16d2d52 --- /dev/null +++ b/dashboard/src/pages/Chat/utils/workspacePanels.ts @@ -0,0 +1,23 @@ +/** Right-rail / composer + panel types in 开发空间. */ + +export const WORKSPACE_PANEL_KINDS = [ + "files", + "review", + "tasks", + "browser", + "terminal", +] as const; + +export type WorkspacePanelKind = (typeof WORKSPACE_PANEL_KINDS)[number]; + +export function isWorkspacePanelKind( + value: unknown, +): value is WorkspacePanelKind { + return ( + value === "files" || + value === "review" || + value === "tasks" || + value === "browser" || + value === "terminal" + ); +} diff --git a/src/octop/api/routers/chat/models.py b/src/octop/api/routers/chat/models.py index 54f6a580..701f8433 100644 --- a/src/octop/api/routers/chat/models.py +++ b/src/octop/api/routers/chat/models.py @@ -46,6 +46,7 @@ class ChatTurnBody(BaseModel): ) reasoning_mode: Literal["auto", "enabled", "disabled"] | None = None reasoning_effort: str | None = None + permission_mode: Literal["default", "auto", "full"] | None = None target_agent_ids: list[str] | None = Field( default=None, description="Optional agent ids to involve via @mention (same user only).", @@ -87,6 +88,9 @@ def from_ws_payload(cls, payload: dict[str, Any]) -> ChatTurnBody: if isinstance(payload.get("reasoning_effort"), str) and str(payload["reasoning_effort"]).strip() else None, + permission_mode=payload.get("permission_mode") + if payload.get("permission_mode") in ("default", "auto", "full") + else None, target_agent_ids=( [str(x) for x in payload["target_agent_ids"]] if isinstance(payload.get("target_agent_ids"), list) @@ -107,6 +111,7 @@ class UserTurnWsFrame(BaseModel): default_model: str | None = None reasoning_mode: Literal["auto", "enabled", "disabled"] | None = None reasoning_effort: str | None = None + permission_mode: Literal["default", "auto", "full"] | None = None mcp_servers: list[str] | None = None knowledge_base_ids: list[str] | None = None skills: list[str] | None = None diff --git a/src/octop/api/routers/chat/turn.py b/src/octop/api/routers/chat/turn.py index f8b0d14c..2d837843 100644 --- a/src/octop/api/routers/chat/turn.py +++ b/src/octop/api/routers/chat/turn.py @@ -288,6 +288,7 @@ async def prepare_dashboard_turn( target_agent_ids=target_ids, model_ref=model_ref, default_model=default_model, + permission_mode=turn.permission_mode, ) inbound_content = content_parts_from_dashboard_turn(turn) inbound_attachments = inbound_attachments_from_parts(inbound_content) @@ -332,6 +333,8 @@ def build_dashboard_inbound( metadata["reasoning_mode"] = turn.reasoning_mode if turn.reasoning_effort: metadata["reasoning_effort"] = turn.reasoning_effort + if turn.permission_mode is not None: + metadata["permission_mode"] = turn.permission_mode if prepared.composer_context: metadata[COMPOSER_CTX_KEY] = prepared.composer_context if prepared.inbound_attachments: diff --git a/src/octop/infra/agents/manager.py b/src/octop/infra/agents/manager.py index 8488dea8..f06879d5 100644 --- a/src/octop/infra/agents/manager.py +++ b/src/octop/infra/agents/manager.py @@ -2638,6 +2638,21 @@ def _prepare_stream_request(self, agent_id: str, request: dict[str, Any]) -> dic configurable = dict(req.get("configurable") or {}) configurable["plugin_tool_configs"] = tool_configs req["configurable"] = configurable + from octop.infra.agents.permission_mode import ( # noqa: PLC0415 + CONFIG_KEY as PERMISSION_MODE_KEY, + normalize_permission_mode, + permission_mode_security_override, + ) + + configurable = dict(req.get("configurable") or {}) + mode = normalize_permission_mode(configurable.get(PERMISSION_MODE_KEY)) + if mode is not None: + configurable[PERMISSION_MODE_KEY] = mode + patch = permission_mode_security_override(mode) + if patch: + policy = SecurityPolicy.merge(self._security.harness_policy(), patch) + req["interrupt_on"] = policy.resolve_interrupt_on() + req["configurable"] = configurable return apply_agent_runtime_to_stream_request(req, agent_cfg) def _build_harness_config(self, row: AgentRow) -> HarnessAgentConfig: diff --git a/src/octop/infra/agents/permission_mode.py b/src/octop/infra/agents/permission_mode.py new file mode 100644 index 00000000..ab8862cc --- /dev/null +++ b/src/octop/infra/agents/permission_mode.py @@ -0,0 +1,49 @@ +"""Per-turn chat permission modes (composer 使用权限). + +``default`` keeps the agent security policy. +``auto`` skips HITL inside the workspace sandbox; tool-guard stays on so +high-risk / sandbox-boundary actions can still warn or ask. +``full`` disables HITL and tool-guard for that turn (higher privilege). +""" + +from __future__ import annotations + +from typing import Any, Literal + +ChatPermissionMode = Literal["default", "auto", "full"] + +PERMISSION_MODES: tuple[ChatPermissionMode, ...] = ("default", "auto", "full") +CONFIG_KEY = "octop_permission_mode" + + +def normalize_permission_mode(raw: Any) -> ChatPermissionMode | None: + if raw in PERMISSION_MODES: + return raw # type: ignore[return-value] + return None + + +def permission_mode_security_override(mode: ChatPermissionMode) -> dict[str, Any] | None: + """Partial SecurityPolicy patch applied for one dashboard turn. + + ``default`` returns ``None`` so the stored global/agent policy wins. + """ + if mode == "auto": + return { + "hitl": {"enabled": False}, + "tool_guard": {"enabled": True, "mode": "require_approval"}, + } + if mode == "full": + return { + "hitl": {"enabled": False}, + "tool_guard": {"enabled": False}, + } + return None + + +__all__ = [ + "CONFIG_KEY", + "PERMISSION_MODES", + "ChatPermissionMode", + "normalize_permission_mode", + "permission_mode_security_override", +] diff --git a/src/octop/infra/gateway/process/message_keys.py b/src/octop/infra/gateway/process/message_keys.py index de4f55b5..eb5f296d 100644 --- a/src/octop/infra/gateway/process/message_keys.py +++ b/src/octop/infra/gateway/process/message_keys.py @@ -32,6 +32,7 @@ def build_composer_context( target_agent_ids: list[str] | None, model_ref: str | None, default_model: str | None, + permission_mode: str | None = None, ) -> dict[str, Any] | None: """Snapshot of per-turn composer selections for history display chips. @@ -52,6 +53,8 @@ def build_composer_context( default = (default_model or "").strip() if model and model != default: ctx["model"] = model + if permission_mode in ("default", "auto", "full"): + ctx["permissionMode"] = permission_mode return ctx or None diff --git a/src/octop/infra/gateway/process/processor.py b/src/octop/infra/gateway/process/processor.py index a6a92415..d2c54967 100644 --- a/src/octop/infra/gateway/process/processor.py +++ b/src/octop/infra/gateway/process/processor.py @@ -1285,6 +1285,28 @@ async def _build_dashboard_request( message_kwargs=message_kwargs or None, reasoning_overrides=reasoning_overrides, ) + from octop.infra.agents.permission_mode import ( # noqa: PLC0415 + CONFIG_KEY as PERMISSION_MODE_KEY, + normalize_permission_mode, + ) + + permission_mode = normalize_permission_mode(meta.get("permission_mode")) + if permission_mode is None and isinstance(composer, dict): + permission_mode = normalize_permission_mode(composer.get("permissionMode")) + if permission_mode is not None: + configurable = dict(request.get("configurable") or {}) + configurable[PERMISSION_MODE_KEY] = permission_mode + request["configurable"] = configurable + if isinstance(message_kwargs.get(COMPOSER_CTX_KEY), dict): + stamped_ctx = dict(message_kwargs[COMPOSER_CTX_KEY]) + stamped_ctx["permissionMode"] = permission_mode + message_kwargs[COMPOSER_CTX_KEY] = stamped_ctx + msgs = request.get("messages") + if msgs: + first = msgs[0] + extra = getattr(first, "additional_kwargs", None) + if isinstance(extra, dict): + extra[COMPOSER_CTX_KEY] = stamped_ctx self._attach_turn_knowledge_config( request, user_id=user_id, diff --git a/tests/unit/agents/test_permission_mode.py b/tests/unit/agents/test_permission_mode.py new file mode 100644 index 00000000..ef778f60 --- /dev/null +++ b/tests/unit/agents/test_permission_mode.py @@ -0,0 +1,31 @@ +from octop.infra.agents.permission_mode import ( + normalize_permission_mode, + permission_mode_security_override, +) + + +def test_normalize_permission_mode() -> None: + assert normalize_permission_mode("default") == "default" + assert normalize_permission_mode("auto") == "auto" + assert normalize_permission_mode("full") == "full" + assert normalize_permission_mode("yolo") is None + assert normalize_permission_mode(None) is None + + +def test_security_override_default_is_passthrough() -> None: + assert permission_mode_security_override("default") is None + + +def test_security_override_auto_keeps_tool_guard() -> None: + patch = permission_mode_security_override("auto") + assert patch is not None + assert patch["hitl"]["enabled"] is False + assert patch["tool_guard"]["enabled"] is True + assert patch["tool_guard"]["mode"] == "require_approval" + + +def test_security_override_full_relaxes_guards() -> None: + patch = permission_mode_security_override("full") + assert patch is not None + assert patch["hitl"]["enabled"] is False + assert patch["tool_guard"]["enabled"] is False diff --git a/tests/unit/api/test_chat_composer_context.py b/tests/unit/api/test_chat_composer_context.py index eb6e2c76..10366974 100644 --- a/tests/unit/api/test_chat_composer_context.py +++ b/tests/unit/api/test_chat_composer_context.py @@ -64,3 +64,29 @@ def test_build_composer_context_includes_model_override() -> None: default_model="openai/gpt-4o", ) assert ctx == {"model": "openai/gpt-4o-mini"} + + +def test_build_composer_context_includes_permission_mode() -> None: + ctx = build_composer_context( + mcp_servers=None, + skills=None, + target_agent_ids=None, + model_ref=None, + default_model=None, + permission_mode="auto", + ) + assert ctx == {"permissionMode": "auto"} + + +def test_chat_turn_body_reads_permission_mode() -> None: + from octop.api.routers.chat.models import ChatTurnBody + + turn = ChatTurnBody.from_ws_payload( + { + "text": "hi", + "permission_mode": "full", + "thread_id": "thr_1", + } + ) + assert turn.permission_mode == "full" + assert ChatTurnBody.from_ws_payload({"text": "hi", "permission_mode": "yolo"}).permission_mode is None From 87d11c811f50064eebf08f6f8240fe8ce951f96e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 04:59:31 +0000 Subject: [PATCH 2/4] Keep per-thread permission modes from clobbering the workspace default. Also open the terminal panel even without the terminal capability so the right-rail starter is never a dead button. Co-authored-by: XYAI Labs --- dashboard/src/pages/Chat/index.tsx | 5 +---- .../src/pages/Chat/utils/permissionMode.ts | 17 ++++++++++------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/dashboard/src/pages/Chat/index.tsx b/dashboard/src/pages/Chat/index.tsx index 4c58d872..018ef8be 100644 --- a/dashboard/src/pages/Chat/index.tsx +++ b/dashboard/src/pages/Chat/index.tsx @@ -430,12 +430,9 @@ function ChatPageInner() { void handleToggleBrowserPanel(); return; } - if (kind === "terminal" && !canTerminal) { - return; - } openWorkspacePanel(kind); }, - [canTerminal, handleToggleBrowserPanel, openWorkspacePanel], + [handleToggleBrowserPanel, openWorkspacePanel], ); const closeToolUiPanel = useCallback( diff --git a/dashboard/src/pages/Chat/utils/permissionMode.ts b/dashboard/src/pages/Chat/utils/permissionMode.ts index 0abcd709..c2aea7a4 100644 --- a/dashboard/src/pages/Chat/utils/permissionMode.ts +++ b/dashboard/src/pages/Chat/utils/permissionMode.ts @@ -6,10 +6,10 @@ export type ChatPermissionMode = (typeof CHAT_PERMISSION_MODES)[number]; const WORKSPACE_KEY = "octop:chat-permission-mode"; -export function isChatPermissionMode(value: unknown): value is ChatPermissionMode { - return ( - value === "default" || value === "auto" || value === "full" - ); +export function isChatPermissionMode( + value: unknown, +): value is ChatPermissionMode { + return value === "default" || value === "auto" || value === "full"; } function threadKey(threadId: string): string { @@ -45,12 +45,15 @@ export function loadPermissionMode( return readStored(WORKSPACE_KEY) ?? "default"; } -/** Persist for this conversation and as the workspace default. */ +/** Persist for this conversation, or as the workspace default when no thread. */ export function savePermissionMode( mode: ChatPermissionMode, threadId?: string | null, ): void { - writeStored(WORKSPACE_KEY, mode); const tid = threadId?.trim(); - if (tid) writeStored(threadKey(tid), mode); + if (tid) { + writeStored(threadKey(tid), mode); + return; + } + writeStored(WORKSPACE_KEY, mode); } From b2f84efb56b3b9996d3f0cc020b2da08dcdf8699 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 05:00:54 +0000 Subject: [PATCH 3/4] Fix permission-mode typing and Ruff import layout. Keep CONFIG_KEY unaliased so lazy imports stay isort-clean, and normalize modes through a typed map so mypy --strict is happy. Co-authored-by: XYAI Labs --- src/octop/infra/agents/manager.py | 6 +++--- src/octop/infra/agents/permission_mode.py | 9 +++++++-- src/octop/infra/gateway/process/processor.py | 4 ++-- tests/unit/api/test_chat_composer_context.py | 5 ++++- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/octop/infra/agents/manager.py b/src/octop/infra/agents/manager.py index f06879d5..49692f81 100644 --- a/src/octop/infra/agents/manager.py +++ b/src/octop/infra/agents/manager.py @@ -2639,15 +2639,15 @@ def _prepare_stream_request(self, agent_id: str, request: dict[str, Any]) -> dic configurable["plugin_tool_configs"] = tool_configs req["configurable"] = configurable from octop.infra.agents.permission_mode import ( # noqa: PLC0415 - CONFIG_KEY as PERMISSION_MODE_KEY, + CONFIG_KEY, normalize_permission_mode, permission_mode_security_override, ) configurable = dict(req.get("configurable") or {}) - mode = normalize_permission_mode(configurable.get(PERMISSION_MODE_KEY)) + mode = normalize_permission_mode(configurable.get(CONFIG_KEY)) if mode is not None: - configurable[PERMISSION_MODE_KEY] = mode + configurable[CONFIG_KEY] = mode patch = permission_mode_security_override(mode) if patch: policy = SecurityPolicy.merge(self._security.harness_policy(), patch) diff --git a/src/octop/infra/agents/permission_mode.py b/src/octop/infra/agents/permission_mode.py index ab8862cc..6a1c8aa1 100644 --- a/src/octop/infra/agents/permission_mode.py +++ b/src/octop/infra/agents/permission_mode.py @@ -14,11 +14,16 @@ PERMISSION_MODES: tuple[ChatPermissionMode, ...] = ("default", "auto", "full") CONFIG_KEY = "octop_permission_mode" +_MODE_BY_VALUE: dict[str, ChatPermissionMode] = { + "default": "default", + "auto": "auto", + "full": "full", +} def normalize_permission_mode(raw: Any) -> ChatPermissionMode | None: - if raw in PERMISSION_MODES: - return raw # type: ignore[return-value] + if isinstance(raw, str): + return _MODE_BY_VALUE.get(raw) return None diff --git a/src/octop/infra/gateway/process/processor.py b/src/octop/infra/gateway/process/processor.py index d2c54967..1624045b 100644 --- a/src/octop/infra/gateway/process/processor.py +++ b/src/octop/infra/gateway/process/processor.py @@ -1286,7 +1286,7 @@ async def _build_dashboard_request( reasoning_overrides=reasoning_overrides, ) from octop.infra.agents.permission_mode import ( # noqa: PLC0415 - CONFIG_KEY as PERMISSION_MODE_KEY, + CONFIG_KEY, normalize_permission_mode, ) @@ -1295,7 +1295,7 @@ async def _build_dashboard_request( permission_mode = normalize_permission_mode(composer.get("permissionMode")) if permission_mode is not None: configurable = dict(request.get("configurable") or {}) - configurable[PERMISSION_MODE_KEY] = permission_mode + configurable[CONFIG_KEY] = permission_mode request["configurable"] = configurable if isinstance(message_kwargs.get(COMPOSER_CTX_KEY), dict): stamped_ctx = dict(message_kwargs[COMPOSER_CTX_KEY]) diff --git a/tests/unit/api/test_chat_composer_context.py b/tests/unit/api/test_chat_composer_context.py index 10366974..9bb65bef 100644 --- a/tests/unit/api/test_chat_composer_context.py +++ b/tests/unit/api/test_chat_composer_context.py @@ -89,4 +89,7 @@ def test_chat_turn_body_reads_permission_mode() -> None: } ) assert turn.permission_mode == "full" - assert ChatTurnBody.from_ws_payload({"text": "hi", "permission_mode": "yolo"}).permission_mode is None + assert ( + ChatTurnBody.from_ws_payload({"text": "hi", "permission_mode": "yolo"}).permission_mode + is None + ) From 1f6bf106a850dc13d1ea13b2b4f99d01592e69cc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 05:17:56 +0000 Subject: [PATCH 4/4] Put the chat history tick rail on the scroll-track edge. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuse the existing Cindy/DSH TurnTimelineRail: hover previews a turn, click seeks to that message, and the rail stays visible at mid widths when the 开发空间 right sidebar is open. Co-authored-by: XYAI Labs --- CHANGELOG.md | 1 + .../src/pages/Chat/chatMessages.partial.less | 10 ++--- .../components/TurnTimelineRail.module.less | 27 ++++++++++--- .../Chat/components/TurnTimelineRail.test.tsx | 40 +++++++++++++++++++ .../Chat/components/TurnTimelineRail.tsx | 19 ++++++--- dashboard/src/pages/Chat/index.module.less | 16 ++++---- .../src/pages/Chat/utils/turnTimeline.test.ts | 7 ++++ .../src/pages/Chat/utils/turnTimeline.ts | 3 +- 8 files changed, 98 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6514d83..a5d8f581 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ ### 新增 - 开发空间对话右侧栏:可折叠,空状态「开始一个面板」,从文件 / 审查 / 后台任务 / 浏览器 / 终端起步。输入框「+」打开同一组面板,「使用权限」提供默认权限 / 自动审批 / 完全访问并随对话发送。 +- 开发空间对话区历史横杠:沿滚动条一侧显示回合刻度,悬停看摘要,点击跳到对应消息;打开右侧栏后仍可见。 - 模型页本机大模型:Ollama 已安装但未运行时显示「启动 Ollama」(Windows 走已知安装路径的 `Ollama.exe` / `ollama app` / `ollama serve`);缺少可自动处理的依赖时提供「一键下载安装」,否则说明需手动安装;「搜索本机模型」后台扫描 `.gguf` / `.ggml`(以及明确的本地 LLM safetensors 目录),结果进入本机列表并可注册到 Ollama 供对话切换。 - 组织页双向互生长:FreeOS 资产经 `/api/freeos/ingest`(共享写入令牌,无需边车登录墙)落到 openXYOS 员工/人才/插件/技能表;控制面导出再装配回 FreeOS。循环在边车健康时不再把「已推送到控制面」写成 false。 - 组织页可从本地语料或 ima / 云知识库指针一键生产尚未入部门的专家 / 智能助手;本地模型一键下载后会注册到 Ollama 供应商。 diff --git a/dashboard/src/pages/Chat/chatMessages.partial.less b/dashboard/src/pages/Chat/chatMessages.partial.less index f68f012c..7b5c2b0c 100644 --- a/dashboard/src/pages/Chat/chatMessages.partial.less +++ b/dashboard/src/pages/Chat/chatMessages.partial.less @@ -8,18 +8,18 @@ flex-direction: column; } -/* Left gutter so the in-thread turn timeline ticks do not cover bubbles. +/* Inline-end gutter so scroll-track ticks do not cover bubbles. Prefer parent `.chatMainWithTurnRail` (title + composer + list). This local class remains as a fallback when MessageList is mounted alone. */ .messageListWrapperWithRail { --chat-turn-rail-gutter: 48px; .messageListInner { - padding-inline-start: var(--chat-turn-rail-gutter); - padding-inline-end: var(--chat-column-pad-x, 24px); + padding-inline-start: var(--chat-column-pad-x, 24px); + padding-inline-end: var(--chat-turn-rail-gutter); - @media (max-width: 863px) { - padding-inline-start: var(--chat-column-pad-x, 24px); + @media (max-width: 559px) { + padding-inline-end: var(--chat-column-pad-x, 24px); } } } diff --git a/dashboard/src/pages/Chat/components/TurnTimelineRail.module.less b/dashboard/src/pages/Chat/components/TurnTimelineRail.module.less index e694952c..7c00edf4 100644 --- a/dashboard/src/pages/Chat/components/TurnTimelineRail.module.less +++ b/dashboard/src/pages/Chat/components/TurnTimelineRail.module.less @@ -2,12 +2,12 @@ pointer-events: none; position: absolute; inset-block: 0; - inset-inline-start: 0; + inset-inline-end: 0; z-index: 10; width: 48px; opacity: 0; visibility: hidden; - transform: translateX(-8px); + transform: translateX(8px); transition: opacity 150ms ease-out, transform 150ms ease-out, @@ -16,6 +16,10 @@ @media (prefers-reduced-motion: reduce) { transition: none; } + + &[data-direction="rtl"] { + transform: translateX(-8px); + } } .railVisible { @@ -28,7 +32,7 @@ .railScroll { pointer-events: auto; position: absolute; - inset-inline-start: 8px; + inset-inline-end: 8px; top: 50%; max-height: calc(100% - 6rem); width: 40px; @@ -59,10 +63,10 @@ .tickButton { display: flex; align-items: center; - justify-content: flex-start; + justify-content: flex-end; width: 40px; height: 14px; - padding: 0 0 0 2px; + padding: 0 2px 0 0; border: 0; border-radius: 4px; background: transparent; @@ -74,12 +78,23 @@ } } +.rail[data-direction="rtl"] { + .tickButton { + justify-content: flex-start; + padding: 0 0 0 2px; + } + + .tickBar { + transform-origin: left center; + } +} + .tickBar { display: block; width: 12px; height: 2px; border-radius: 999px; - transform-origin: left center; + transform-origin: right center; background: var(--fn-text-quaternary); transition: opacity 150ms ease-out, diff --git a/dashboard/src/pages/Chat/components/TurnTimelineRail.test.tsx b/dashboard/src/pages/Chat/components/TurnTimelineRail.test.tsx index 344aeadf..b48f4753 100644 --- a/dashboard/src/pages/Chat/components/TurnTimelineRail.test.tsx +++ b/dashboard/src/pages/Chat/components/TurnTimelineRail.test.tsx @@ -179,6 +179,11 @@ describe("TurnTimelineRail", () => { expect(rail).toHaveAttribute("data-item-count", "2"); expect(rail).toHaveAttribute("data-visible", "true"); expect(rail).toHaveAttribute("data-direction", "ltr"); + expect(rail).toHaveAttribute("data-placement", "end"); + + fireEvent.mouseEnter(screen.getByTestId("turn-timeline-tick-0")); + expect(await screen.findByText("first question")).toBeInTheDocument(); + expect(screen.getByText("first answer")).toBeInTheDocument(); fireEvent.click(screen.getByTestId("turn-timeline-tick-0")); await waitFor(() => { @@ -257,4 +262,39 @@ describe("TurnTimelineRail", () => { container.querySelector('[data-testid="turn-timeline-rail"]'), ).toBeNull(); }); + + it("stays visible at mid widths used when the right sidebar is open", async () => { + const messages = [ + msg("user", "u1", "first"), + msg("assistant", "a1", "a"), + msg("user", "u2", "second"), + msg("assistant", "a2", "b"), + ]; + const messageGroups = groupConsecutiveAssistantMessages(messages); + const { scroller, bubbleRefsMap } = mountScrollerWithAnchors(["u1", "u2"]); + const wrapper = document.createElement("div"); + Object.defineProperty(wrapper, "clientWidth", { + configurable: true, + value: 600, + }); + document.body.appendChild(wrapper); + + render( + , + ); + + const rail = await screen.findByTestId("turn-timeline-rail"); + expect(rail).toHaveAttribute("data-visible", "true"); + }); }); diff --git a/dashboard/src/pages/Chat/components/TurnTimelineRail.tsx b/dashboard/src/pages/Chat/components/TurnTimelineRail.tsx index 5d993b18..6fe5cb5a 100644 --- a/dashboard/src/pages/Chat/components/TurnTimelineRail.tsx +++ b/dashboard/src/pages/Chat/components/TurnTimelineRail.tsx @@ -33,6 +33,10 @@ import { } from "../utils/turnTimeline"; import styles from "./TurnTimelineRail.module.less"; +/** + * In-thread history scrubber (Cindy / DSH / Codex-style): one tick per user + * turn along the scroll-track edge, hover preview, click to seek. + */ const FLASH_CLASS = "turn-timeline-flash"; const FLASH_MS = 700; const JUMP_RETRY_FRAMES = 30; @@ -99,9 +103,12 @@ export default function TurnTimelineRail({ messages, messageGroups, { - userFallback: t("chat.turnTimeline.userFallback"), - emptyAssistant: t("chat.turnTimeline.emptyAssistant"), - runningAssistant: t("chat.turnTimeline.runningAssistant"), + userFallback: t("chat.turnTimeline.userFallback", "(空消息)"), + emptyAssistant: t("chat.turnTimeline.emptyAssistant", "(暂无回复)"), + runningAssistant: t( + "chat.turnTimeline.runningAssistant", + "(生成中…)", + ), }, { isStreaming }, ), @@ -308,19 +315,20 @@ export default function TurnTimelineRail({ viewportHeight: railViewport.height || totalHeight, forceFull, }); - const previewPlacement = textDirection === "rtl" ? "leftTop" : "rightTop"; + const previewPlacement = textDirection === "rtl" ? "rightTop" : "leftTop"; return (