diff --git a/CHANGELOG.md b/CHANGELOG.md index c4e051d2..a5d8f581 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,8 @@ ### 新增 +- 开发空间对话右侧栏:可折叠,空状态「开始一个面板」,从文件 / 审查 / 后台任务 / 浏览器 / 终端起步。输入框「+」打开同一组面板,「使用权限」提供默认权限 / 自动审批 / 完全访问并随对话发送。 +- 开发空间对话区历史横杠:沿滚动条一侧显示回合刻度,悬停看摘要,点击跳到对应消息;打开右侧栏后仍可见。 - 模型页本机大模型: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/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 (