diff --git a/Resources/Localization/en.lproj/Localizable.strings b/Resources/Localization/en.lproj/Localizable.strings index 218fc466..7155b34a 100644 --- a/Resources/Localization/en.lproj/Localizable.strings +++ b/Resources/Localization/en.lproj/Localizable.strings @@ -9,9 +9,8 @@ */ /* Sidebar navigation (WorkspaceSidebarShell) */ -"搜索任务" = "Search Tasks"; "新建会话" = "New Session"; -"工作台" = "Workspace"; +"工作台" = "Workbench"; "待处理" = "Inbox"; "已归档" = "Archived"; "环境" = "Environment"; @@ -19,15 +18,14 @@ /* Sidebar headings and lists (SessionSidebar) */ "置顶" = "Pinned"; -"任务组" = "Groups"; -"新建任务组" = "New Group"; -"编辑任务组" = "Edit Group"; -"创建任务组…" = "New Group…"; +"任务组" = "Task Groups"; +"新建任务组" = "New Task Group"; +"编辑任务组" = "Edit Task Group"; +"创建任务组…" = "New Task Group…"; "最近会话" = "Recent"; "全部会话" = "All Sessions"; "筛选最近会话" = "Filter Recent Sessions"; "筛选最近会话:%@" = "Filter recent sessions: %@"; -"新任务会出现在这里" = "New tasks show up here"; "没有符合筛选的会话" = "No sessions match the filter"; "没有找到会话" = "No sessions found"; "标题、Agent、环境或目录" = "Title, agent, environment, or directory"; @@ -49,8 +47,8 @@ /* Session groups sheet */ "会话分组" = "Session Groups"; -"先在侧栏新建任务组,再关联会话。" = "Create a group in the sidebar first, then assign sessions to it."; -"可选多个任务组;取消全部选择即移出分组。不会共享对话上下文。" = "Select multiple groups; deselecting all removes the session from every group. Conversation context is never shared."; +"先在侧栏新建任务组,再关联会话。" = "Create a task group in the sidebar first, then assign sessions to it."; +"可选多个任务组;取消全部选择即移出分组。不会共享对话上下文。" = "Select multiple task groups; deselecting all removes the session from every task group. Conversation context is never shared."; "保存" = "Save"; "取消" = "Cancel"; "切换分支" = "Switch Branch"; @@ -63,7 +61,6 @@ /* Sidebar environment popover */ "环境与 Agent" = "Environments & Agents"; -"添加 SSH 机器" = "Add SSH Machine"; "本机 Agent…" = "Local Agent…"; "重新连接 Kimi" = "Reconnect Kimi"; "原生对话" = "Native Conversations"; @@ -125,7 +122,7 @@ /* Local agent setup sheet */ "本机 Agent" = "Local Agent"; -"本机执行环境与远程 SSH 主机分开:本机工具在你选择的 Mac 目录执行,不通过 ssh localhost。" = "The local environment is separate from remote SSH hosts: local tools run in a Mac directory you choose, not through ssh localhost."; +"本机执行环境与远程 SSH 主机分开:本机工具在你选择的 Mac 目录执行,不通过 ssh localhost。" = "The local environment is separate from remote SSH machines: local tools run in a Mac directory you choose, not through ssh localhost."; "已找到 omp %@" = "Found omp %@"; "已读取版本;此检测没有验证 RPC、工具审批或运行中引导。" = "Version read; this check did not exercise RPC, tool approvals, or live steering."; "找到了可执行文件,但无法使用" = "Found the executable, but it is unusable"; @@ -147,7 +144,6 @@ "浅色" = "Light"; "深色" = "Dark"; "Agent 与执行环境" = "Agent & Environments"; -"添加 SSH 环境…" = "Add SSH Environment…"; "远程连接沿用本机 SSH 配置。连接、重连与移除机器都在侧边栏的环境入口。" = "Remote connections reuse this Mac's SSH configuration. Connect, reconnect and remove machines from the environment menu in the sidebar."; "界面" = "Appearance"; "侧边栏可拖动调整宽度,系统会记住位置。透明度与动态效果遵循 macOS 辅助功能设置。" = "Drag the sidebar edge to resize it; the position is remembered. Transparency and motion follow macOS accessibility settings."; @@ -167,7 +163,7 @@ "结束终端" = "End Terminal"; "删除会话" = "Delete Session"; "将从 Kimi 服务删除「%@」及其历史,无法撤销。只想收起时,请使用归档。" = "This deletes \"%@\" and its history from the Kimi service, and cannot be undone. To simply tuck it away, archive it instead."; -"将删除工作台中的「%@」及对话记录。Agent 自身保存的历史仍保留。" = "This removes \"%@\" and its conversation record from the workspace. History kept by the agent itself is preserved."; +"将删除工作台中的「%@」及对话记录。Agent 自身保存的历史仍保留。" = "This removes \"%@\" and its conversation record from the workbench. History kept by the agent itself is preserved."; "将关闭「%@」的远端进程,运行中的任务会中断。只想整理列表时,请使用本机归档。" = "This closes the remote process for \"%@\", interrupting any running task. To tidy the list instead, archive it locally."; "移除机器?" = "Remove Machine?"; "移除机器" = "Remove Machine"; @@ -180,7 +176,7 @@ "等待恢复会话" = "Waiting to Resume the Session"; "连接后读取原会话;已不可用的会话不会自动新建。" = "The original session loads once connected; sessions that no longer exist are not recreated."; "连接后接回原终端;已结束的会话不会自动新建。" = "The original terminal reattaches once connected; ended sessions are not recreated."; -"返回工作台" = "Back to Workspace"; +"返回工作台" = "Back to Workbench"; "正在连接远端 Kimi 服务" = "Connecting to the remote Kimi service"; "终端显示信息" = "Terminal Rendering Info"; "终端连接已结束;可重新接入仍在运行的会话。" = "The terminal connection has ended; you can reattach to sessions that are still running."; @@ -223,15 +219,13 @@ "搜索要关联的对话或终端" = "Search conversations or terminals to link"; "未连接或已结束的%@:%@" = "Offline or ended %@: %@"; "已选 %lld 个会话 · 保存在本机" = "%lld selected · Saved locally"; -"保存任务组" = "Save Group"; +"保存任务组" = "Save Task Group"; "切换侧栏" = "Toggle Sidebar"; "显示或隐藏侧栏" = "Show or Hide Sidebar"; -"新建任务" = "New Task"; -"新建任务 · ⌘N" = "New Task · ⌘N"; "当前会话" = "Current Session"; @@ -243,7 +237,7 @@ "查看远端文件" = "View Remote Files"; -"同步会话" = "Refresh Conversation"; +"同步会话" = "Refresh Session"; "重新连接" = "Reconnect"; @@ -258,7 +252,6 @@ "Previous match" = "Previous match"; "Back" = "Back"; "Forward" = "Forward"; -"Next task needing attention" = "Next task needing attention"; "Drafts could not be saved" = "Drafts could not be saved"; "Keep Perch open" = "Keep Perch open"; "Quit anyway" = "Quit anyway"; @@ -266,8 +259,7 @@ "Notify when Perch is in the background" = "Notify when Perch is in the background"; "Completed responses, requests for input, and failures. Click a notification to open the task." = "Completed responses, requests for input, and failures. Click a notification to open the task."; "Stop task" = "Stop task"; -"任务组…" = "Group…"; -"原生对话…" = "Native Conversation…"; +"任务组…" = "Task Group…"; "新建" = "New"; "远端终端…" = "Remote Terminal…"; @@ -281,7 +273,7 @@ "清除搜索" = "Clear Search"; "另有 %lld 个关联会话尚未同步,可在关联管理中查看。" = "%lld other linked sessions have not synced yet. Find them in Link Existing Sessions."; "归档已完成 %lld" = "Archive Completed %lld"; -"任务组操作" = "Group Actions"; +"任务组操作" = "Task Group Actions"; "正在归档…" = "Archiving…"; "状态未同步" = "State not synced"; "需要处理" = "Needs Attention"; @@ -388,7 +380,7 @@ "Paused" = "Paused"; "接着做" = "Pick up where you left off"; -"全部任务组" = "All groups"; +"全部任务组" = "All task groups"; "打开任务组" = "Open a task group"; "工作台操作" = "Workbench actions"; "归档已查看结果 · %lld" = "Archive reviewed results · %lld"; @@ -408,18 +400,16 @@ "断开或离开前正在运行的任务,重新连接后的状态:" = "Tasks running before the disconnect, as found after reconnecting:"; "连接后才能确认这些会话的当前状态。" = "Reconnect to check the current status of these sessions."; "远程 Agent 沿用你的 SSH 配置。本机环境目前仅支持安装检测。" = "Remote agents use your SSH configuration. Local environments currently support installation detection only."; -"连接远程机器…" = "Connect a remote machine…"; "检测本机 Agent…" = "Detect local agents…"; "连接后查看最新进展" = "Reconnect to see the latest activity"; "先连接一台运行 Agent 的机器" = "Connect a machine running an agent"; "选择 Agent 和工作目录后开始" = "Choose an agent and working directory to begin"; -"开始第一段会话" = "Start your first conversation"; "%lld 个关联 · %lld 个未归档 · %lld 个已归档" = "%lld linked · %lld unarchived · %lld archived"; "上次会话尚未连接" = "The last session is not connected"; -"从一段新会话开始,或把已有会话关联到这个目标。" = "Start a conversation or link existing sessions to this goal."; +"从一段新会话开始,或把已有会话关联到这个目标。" = "Start a session or link existing sessions to this goal."; "已归档 · %lld" = "Archived · %lld"; -"归档会话仍保留在任务组中,恢复后可继续。" = "Archived sessions remain linked to this group. Restore them to continue."; +"归档会话仍保留在任务组中,恢复后可继续。" = "Archived sessions remain linked to this task group. Restore them to continue."; "本组会话" = "Group sessions"; "添加目标:做到什么程度算完成?" = "Add a goal: what does success look like?"; "管理关联" = "Manage linked sessions"; @@ -430,7 +420,6 @@ "%lld 个未归档 · %lld 个已归档 · %lld 个尚未同步" = "%lld unarchived · %lld archived · %lld not synced"; "Retry" = "Retry"; -"Unable to load this conversation." = "Unable to load this conversation."; "Choose Kimi or remove the attachments to start this task." = "Choose Kimi or remove the attachments to start this task."; /* Composer commands */ @@ -452,11 +441,9 @@ "Goal resume requested." = "Goal resume requested."; "Plan mode enabled." = "Plan mode enabled."; "Plan mode disabled." = "Plan mode disabled."; -"This agent does not expose /goal to Perch. Use its terminal or a Kimi conversation." = "This agent does not expose /goal to Perch. Use its terminal or a Kimi conversation."; "↑↓ 选择 · Tab 补全 · Return 执行完整命令 · Esc 关闭" = "↑↓ Choose · Tab Complete · Return Run a complete command · Esc Close"; "Run command" = "Run command"; "Goal created. First message accepted." = "Goal created. First message accepted."; -"Goal created; the first message is unconfirmed. Check the conversation before sending again." = "Goal created; the first message is unconfirmed. Check the conversation before sending again."; "Choose a model before starting a goal." = "Choose a model before starting a goal."; "Continue this task, or type / for commands…" = "Continue this task, or type / for commands…"; @@ -511,8 +498,6 @@ "安装或更新 Perch 桥接组件后重新检查。空闲的旧服务会自动安全重启;有任务运行时会等待任务结束。" = "Install or update the Perch bridge and check again. An idle old service restarts automatically; a busy service waits for its tasks to finish."; "安装说明" = "Installation guide"; "已检测到远端凭据;模型目录在首次 ACP 会话握手时读取,鉴权在发送时验证。" = "Remote credentials are present. Models are read during the first ACP session handshake; authentication is verified when sending."; -"开始任务" = "Start task"; -"开始首个任务" = "Start first task"; "打开远端终端" = "Open remote terminal"; "服务只通过 SSH 访问远端 127.0.0.1。填写文件路径即可,令牌不保存在 Mac 配置中。" = "The service is reached through SSH at remote 127.0.0.1. Enter the file path; the token is not saved in Mac settings."; "服务连接" = "Service connection"; @@ -525,7 +510,7 @@ "远端未安装 Perch 桥接组件。请安装桥接组件后重新检查。" = "The Perch bridge is not installed on the remote machine. Install it, then check again."; "远端桥接组件是 v%lld,此 Perch 需要 v%lld。请安装 / 更新桥接组件后重新检查。" = "The remote bridge is v%lld, but this Perch needs v%lld. Install / update the bridge, then check again."; "远端桥接组件是 v%lld,比此 Perch 支持的 v%lld 更新。请升级 Perch;不要降级远端组件,其他客户端可能正在使用它。" = "The remote bridge is v%lld, newer than the v%lld this Perch supports. Update Perch; do not downgrade the remote bridge, because other clients may be using it."; -"远端桥接服务是 v%lld,此 Perch 需要 v%lld。请在主机设置中安装 / 更新桥接组件。" = "The remote bridge service is v%lld, but this Perch needs v%lld. Install / update the bridge in the host settings."; +"远端桥接服务是 v%lld,此 Perch 需要 v%lld。请在主机设置中安装 / 更新桥接组件。" = "The remote bridge service is v%lld, but this Perch needs v%lld. Install / update the bridge in the machine settings."; "无法读取远端桥接组件的版本。请重新安装桥接组件后检查。" = "Could not read the remote bridge's version. Reinstall the bridge, then check again."; "无法读取远端桥接服务的版本。请重新安装桥接组件后重试。" = "Could not read the remote bridge service's version. Reinstall the bridge and retry."; "检查 Agent" = "Check agent"; @@ -564,7 +549,6 @@ "继续后即可填写首个任务;机器、Agent、目录与模型会自动带入。" = "Continue to write your first task. The machine, agent, directory and model are carried over."; "缺少 Qoder SDK 或 Node.js。请安装桥接组件和 Node.js 后重试。" = "Qoder SDK or Node.js is missing. Install the bridge and Node.js, then retry."; "缺少 Claude Code SDK 或 Node.js。请安装桥接组件和 Node.js 后重试。" = "Claude Code SDK or Node.js is missing. Install the bridge and Node.js, then retry."; -"要连接另一台机器,请添加新环境。" = "Add another environment to connect a different machine."; "请在远端安装并启动 Herdr,然后重新检查。" = "Install and start Herdr on the remote machine, then check again."; "请输入远端已存在且当前用户可访问的目录。" = "Enter an existing remote directory accessible to the current user."; "请输入远端项目的绝对路径。" = "Enter the absolute path of the remote project."; @@ -761,8 +745,8 @@ "上报时间未提供" = "Report time unavailable"; "Load all turns" = "Load all turns"; "Loaded conversation turns" = "Loaded conversation turns"; -"任务组:%@" = "Groups: %@"; -"打开任务组 %@" = "Open group %@"; +"任务组:%@" = "Task groups: %@"; +"打开任务组 %@" = "Open task group %@"; "机器" = "Machine"; "全部机器" = "All machines"; "筛选工作台" = "Filter the workbench"; @@ -838,7 +822,7 @@ "尚未读取到模型目录,可继续使用会话模型。" = "The model catalog is not available yet. You can keep using the session model."; "尚未读取到当前模型的思考档位。" = "Thinking levels for the current model are not available yet."; "连接恢复后可修改设置。" = "Settings can be changed after reconnecting."; -"正在同步会话,请稍候。" = "Syncing the conversation. Please wait."; +"正在同步会话,请稍候。" = "Syncing the session. Please wait."; "消息发送中,请稍候。" = "Sending the message. Please wait."; "正在更新模型设置…" = "Updating model settings…"; "任务运行中,完成或停止后可修改。" = "Settings can be changed after the running task finishes or stops."; @@ -859,7 +843,6 @@ "已发现 %lld 个本机 Agent" = "Found %lld local agents"; -"已安装,尚未接入本机对话" = "Installed; local conversations not yet connected"; "未找到可执行文件" = "Executable not found"; @@ -881,7 +864,6 @@ "连接会复用或启动本机服务;关闭 Perch 不会停止正在执行的任务。" = "Connecting reuses or starts local services. Closing Perch leaves running tasks active."; -"连接并新建任务" = "Connect and create task"; "连接本机已安装的 Agent,在你选择的 Mac 项目目录执行。" = "Connect installed agents to work in a Mac project directory you choose."; @@ -895,9 +877,9 @@ "查看文件(只读)" = "View files (read-only)"; -"当前会话没有可用的执行环境。" = "This conversation has no execution environment."; +"当前会话没有可用的执行环境。" = "This session has no execution environment."; -"当前会话没有工作目录。" = "This conversation has no working directory."; +"当前会话没有工作目录。" = "This session has no working directory."; "文件路径,可用绝对路径或相对当前目录" = "File path, absolute or relative to the working directory"; @@ -908,7 +890,7 @@ "在当前执行环境中只读查看文件。" = "Browse files read-only in the current execution environment."; "仅通知需要我处理的任务" = "Only notify for tasks that need my attention"; -"完成通知会合并;确认、提问与失败立即通知。可在会话右键菜单中单独静音。" = "Completion notifications are grouped. Approvals, questions and failures notify immediately. Mute individual tasks from their context menu."; +"完成通知会合并;确认、提问与失败立即通知。可在会话右键菜单中单独静音。" = "Completion notifications are grouped. Approvals, questions and failures notify immediately. Mute individual sessions from their context menu."; "恢复任务通知" = "Unmute task notifications"; "静音任务通知" = "Mute task notifications"; "通知未开启,请在系统设置 → 通知中允许 Perch。" = "Notifications are disabled. Allow Perch in System Settings → Notifications."; @@ -937,7 +919,7 @@ "下一项" = "Next item"; "不关联" = "None"; "仅允许本次" = "Allow once"; -"保存到任务组成果" = "Save to group outcomes"; +"保存到任务组成果" = "Save to task group outcomes"; "其余变化可在下方对应分区查看。" = "See the sections below for the remaining changes."; "固定结论、PR 或报告,并保留来源会话。" = "Keep conclusions, pull requests, and reports with their source sessions."; "处理完成后进入下一项" = "Advance after resolving"; @@ -956,12 +938,12 @@ "撤销上次归组" = "Undo last grouping"; "新进展 · %lld" = "New activity · %lld"; "最近回复" = "Latest reply"; -"本批最多 40 个未归档会话、24 个进行中的任务组。" = "Each batch includes up to 40 unarchived sessions and 24 active groups."; +"本批最多 40 个未归档会话、24 个进行中的任务组。" = "Each batch includes up to 40 unarchived sessions and 24 active task groups."; "来源会话" = "Source session"; "来源会话尚未同步" = "Source session has not synchronized"; "来源:%@" = "Source: %@"; "查看 Recap" = "View Recap"; -"查看本批内容 · %lld 个会话 · %lld 个任务组" = "Preview this batch · %lld sessions · %lld groups"; +"查看本批内容 · %lld 个会话 · %lld 个任务组" = "Preview this batch · %lld sessions · %lld task groups"; "标为已查看" = "Mark reviewed"; "标题" = "Title"; "正在生成归组建议…" = "Generating grouping suggestions…"; @@ -972,7 +954,7 @@ "添加成果" = "Add outcome"; "添加成果与决策" = "Add outcome or decision"; "点击生成后,将以下标题、目录末三级、已有归属、组名、目标与示例标题发送到你配置的摘要服务;不发送对话正文或工具输出。" = "Generating sends the titles, last three directory components, memberships, group names, goals, and example titles below to your configured summary service. Conversation text and tool output are excluded."; -"现有信息不足以建议新的关联。可以补充任务组目标或会话标题后重试。" = "There is not enough evidence for new memberships. Add group goals or clearer session titles and try again."; +"现有信息不足以建议新的关联。可以补充任务组目标或会话标题后重试。" = "There is not enough evidence for new memberships. Add task group goals or clearer session titles and try again."; "生成建议" = "Generate suggestions"; "确认看过这些变化" = "Acknowledge these changes"; "移除成果" = "Remove outcome"; @@ -1018,10 +1000,10 @@ "常用任务组" = "Frequent task groups"; "恢复" = "Restore"; "编辑目标" = "Edit goal"; -"收起任务组" = "Show active groups"; +"收起任务组" = "Show active task groups"; "正在推进的任务组" = "Active task groups"; "搜索任务组" = "Search task groups"; -"把共同目标和相关会话放在一起,也可以直接开始会话。" = "Organize related sessions around a goal, or start a conversation directly."; +"把共同目标和相关会话放在一起,也可以直接开始会话。" = "Organize related sessions around a goal, or start a session directly."; "没有匹配的任务组" = "No matching task groups"; "暂无进行中的任务组" = "No active task groups"; "下一步:%@" = "Next: %@"; @@ -1039,10 +1021,10 @@ "管理归属" = "Manage groups"; "管理任务组归属…" = "Manage task groups…"; "任务组归属" = "Task group membership"; -"还没有任务组。在下方填写名称,保存后会创建并关联此会话。" = "No task groups yet. Enter a name below to create one and link this conversation when you save."; +"还没有任务组。在下方填写名称,保存后会创建并关联此会话。" = "No task groups yet. Enter a name below to create one and link this session when you save."; "新建并关联任务组" = "Create and link a task group"; "任务组名称(可选)" = "Task group name (optional)"; -"可选多个任务组;取消全部选择且不新建,保存后即移出所有任务组。不会共享对话上下文。" = "Select multiple groups if needed. Clear all selections and leave the name empty to remove all memberships when saving. Conversation context is not shared."; +"可选多个任务组;取消全部选择且不新建,保存后即移出所有任务组。不会共享对话上下文。" = "Select multiple task groups if needed. Clear all selections and leave the name empty to remove all memberships when saving. Conversation context is not shared."; "分支改动" = "Branch changes"; @@ -1140,7 +1122,6 @@ "全部处理完了" = "All Caught Up"; "没有等待确认、回答或处理的事项;查看结果请到工作台。" = "Nothing is waiting for confirmation, an answer, or a fix. Results are in the workbench."; "共 %lld 项 · 确认、回答或处理错误;查看结果请到工作台。" = "%lld pending · approvals, questions, and errors appear here. Results are in the workbench."; -"打开会话" = "Open Session"; "粘贴内容已丢失,请重新粘贴后再发送。" = "The pasted content is gone. Paste it again before sending."; "附件需要在线上传,连接恢复后再发送。" = "Attachments upload online only; send after the connection returns."; "命令需要连接后执行,请等待恢复在线。" = "Commands run online only; wait for the connection to return."; @@ -1148,8 +1129,8 @@ "找不到这条回复对应的提问,无法重新生成。" = "No user prompt found for this reply; cannot regenerate."; "这条回复对应的提问没有文字内容。" = "The prompt for this reply has no text to resend."; "无效正则表达式" = "Invalid regular expression"; -"导出当前会话(Markdown)…" = "Export Current Conversation (Markdown)…"; -"导出当前会话(JSON)…" = "Export Current Conversation (JSON)…"; +"导出当前会话(Markdown)…" = "Export Current Session (Markdown)…"; +"导出当前会话(JSON)…" = "Export Current Session (JSON)…"; "当前 Agent 不支持发送附件" = "This agent does not support attachments."; "当前 Agent 不支持附件,请切换 Agent 或移除附件后再开始。" = "This agent does not support attachments. Switch agents or remove them before starting."; "读取附件失败:%@" = "Failed to read attachment: %@"; @@ -1160,21 +1141,21 @@ "项目" = "Projects"; -"临时会话" = "Temporary chats"; +"临时会话" = "Temporary sessions"; -"搜索项目与会话" = "Search projects and chats"; +"搜索项目与会话" = "Search projects and sessions"; "设置与环境" = "Settings & environments"; "更多项目" = "More projects"; -"显示全部 %lld 个会话" = "Show all %lld chats"; +"显示全部 %lld 个会话" = "Show all %lld sessions"; "移动到项目" = "Move to project"; "按所属仓库归组" = "Group by repository"; -"在此项目新建会话" = "New chat in this project"; +"在此项目新建会话" = "New Session in This Project"; "刚刚" = "now"; @@ -1190,21 +1171,20 @@ "重新识别项目" = "Refresh projects"; -"会话视图" = "Chat view"; +"会话视图" = "Session view"; "按项目" = "By project"; "平铺会话" = "Flat list"; -"更多会话(共 %lld 个)" = "More chats (%lld total)"; +"更多会话(共 %lld 个)" = "More sessions (%lld total)"; -"置顶常用会话,随时继续" = "Pin frequent chats to pick up anytime"; +"置顶常用会话,随时继续" = "Pin frequent sessions to pick up anytime"; -"切换会话视图" = "Switch chat view"; -"搜索会话" = "Search chats"; +"搜索会话" = "Search sessions"; -"没有可置顶的会话" = "No chats available to pin"; +"没有可置顶的会话" = "No sessions available to pin"; "上移,更早发送" = "Move up to send earlier"; @@ -1255,7 +1235,7 @@ "计划已更新,修改意见已保留,请核对后再提交。" = "The plan changed. Your feedback is preserved; review it before submitting."; -"计划已变化或会话已切换,请重新查看。" = "The plan or chat changed. Review it again."; +"计划已变化或会话已切换,请重新查看。" = "The plan or session changed. Review it again."; "计划已暂缓" = "Plan deferred"; @@ -1464,7 +1444,7 @@ "服务端未确认删除" = "The server did not confirm the deletion."; "Kimi 权限模式无效" = "The permission mode is not valid for Kimi."; "附件上传响应缺少文件标识" = "The attachment upload response has no file ID."; -"发送未确认,草稿已保留。请检查会话后再发送。\n%@" = "Send unconfirmed; your draft was kept. Check the conversation before sending again.\n%@"; +"发送未确认,草稿已保留。请检查会话后再发送。\n%@" = "Send unconfirmed; your draft was kept. Check the session before sending again.\n%@"; "引导未确认;消息已接收。%@" = "Steering unconfirmed; the message was accepted. %@"; "Stop request not confirmed: %@" = "Stop request not confirmed: %@"; "原生对话托管服务未安装或不可用" = "The native conversation service is not installed or not available."; @@ -1475,10 +1455,10 @@ "操作已受理,同步失败:%@" = "Action accepted; sync failed: %@"; "运行时拒绝消息" = "The runtime rejected the message."; "服务端没有受理记录,可移回草稿后发送" = "The server has no record of this request. Move it back to the draft and send again."; -"请同步并核对会话,暂勿重复提交" = "Sync and check the conversation. Do not resend yet."; +"请同步并核对会话,暂勿重复提交" = "Sync and check the session. Do not resend yet."; "暂不支持在 Perch 中执行 /%@,请在终端使用" = "Perch does not support running /%@ yet. Use it in the terminal."; "当前版本不支持 /compact 参数;草稿已保留,请调整后发送" = "This version does not support /compact arguments. Your draft was kept; edit it and send again."; -"终止结果未知,请核对会话" = "Stop result unknown. Check the conversation."; +"终止结果未知,请核对会话" = "Stop result unknown. Check the session."; "0 matches" = "0 matches"; "All" = "All"; "User" = "User"; @@ -1552,7 +1532,7 @@ "%lld 天" = "%lld d"; "文件" = "Files"; "Git 变更" = "Git changes"; -"未选择主机" = "No host selected"; +"未选择主机" = "No machine selected"; "已取消读取。" = "Loading cancelled."; "远端连接失败或已断开。" = "The remote connection failed or was closed."; "关闭文件面板" = "Close file panel"; @@ -1608,9 +1588,8 @@ "SSH 转发建立超时" = "Timed out setting up SSH forwarding."; "请先连接远端" = "Connect to the remote machine first."; "此终端已不在远端列表中" = "This terminal is no longer listed on the remote machine."; -"本地工作台读取失败,原文件已保留:%@" = "Could not read the local workspace; the original file was kept: %@"; -"工作台保存失败:%@" = "Could not save the workspace: %@"; -"The task environment is unavailable" = "The task environment is unavailable"; +"本地工作台读取失败,原文件已保留:%@" = "Could not read the local workbench; the original file was kept: %@"; +"工作台保存失败:%@" = "Could not save the workbench: %@"; "Menlo 14 pt\n窗口 backingScaleFactor: %@\n视图逻辑尺寸: %@ pt\n视图 backing 尺寸: %@ px\nGhostty surface: %@ px\n终端网格: %@\n像素尺寸一致: %@" = "Menlo 14 pt\nWindow backingScaleFactor: %@\nView logical size: %@ pt\nView backing size: %@ px\nGhostty surface: %@ px\nTerminal grid: %@\nPixel sizes match: %@"; "是" = "Yes"; "否" = "No"; @@ -1621,7 +1600,7 @@ "不会归档:%@" = "Won't archive: %@"; "请输入远端路径。" = "Enter a remote path."; "路径包含无效字符。" = "The path contains invalid characters."; -"当前会话没有远端工作目录,请输入绝对路径。" = "This conversation has no remote working directory. Enter an absolute path."; +"当前会话没有远端工作目录,请输入绝对路径。" = "This session has no remote working directory. Enter an absolute path."; "远端返回了无法识别的结果。" = "The remote machine returned an unrecognized result."; "未跟踪" = "Untracked"; "暂存:%@" = "Staged: %@"; @@ -1647,3 +1626,18 @@ "回车排到下一轮发送" = "Return to send next turn"; "回车执行命令" = "Return to run the command"; "成果操作" = "Outcome actions"; + +/* Sidebar and workbench consistency: canonical session, machine and task group terms (2026-10-16) */ +"下一个待处理会话" = "Next Session Needing Attention"; +"无法读取此会话。" = "Unable to load this session."; +"此 Agent 尚未向 Perch 开放 /goal,请在其终端或 Kimi 会话中使用。" = "This agent does not expose /goal to Perch. Use its terminal or a Kimi session."; +"目标已创建,但首条消息尚未确认。请检查会话后再发送。" = "Goal created; the first message is unconfirmed. Check the session before sending again."; +"项目视图选项" = "Project View Options"; +"新会话会出现在这里" = "New sessions show up here"; +"新建会话 · ⌘N" = "New Session · ⌘N"; +"会话…" = "Session…"; +"开始首个会话" = "Start First Session"; +"开始会话" = "Start session"; +"连接并新建会话" = "Connect and Start Session"; +"已安装,尚未接入本机会话" = "Installed; not yet available for local sessions"; +"要连接另一台机器,请添加新机器。" = "To connect a different machine, add a new one."; diff --git a/Resources/Localization/zh-Hans.lproj/Localizable.strings b/Resources/Localization/zh-Hans.lproj/Localizable.strings index 9cefaa0d..f5ce05dd 100644 --- a/Resources/Localization/zh-Hans.lproj/Localizable.strings +++ b/Resources/Localization/zh-Hans.lproj/Localizable.strings @@ -15,7 +15,6 @@ "Previous match" = "上一个匹配"; "Back" = "返回"; "Forward" = "前进"; -"Next task needing attention" = "下一个待处理任务"; "Drafts could not be saved" = "草稿未能保存"; "Keep Perch open" = "留在 Perch"; "Quit anyway" = "仍然退出"; @@ -26,7 +25,6 @@ "Completed responses, requests for input, and failures. Click a notification to open the task." = "已完成的回复、待处理的输入请求和失败。点击通知可打开对应任务。"; "Stop task" = "停止任务"; -"搜索任务" = "搜索任务"; "新建会话" = "新建会话"; "工作台" = "工作台"; "待处理" = "待处理"; @@ -42,7 +40,6 @@ "全部会话" = "全部会话"; "筛选最近会话" = "筛选最近会话"; "筛选最近会话:%@" = "筛选最近会话:%@"; -"新任务会出现在这里" = "新任务会出现在这里"; "没有符合筛选的会话" = "没有符合筛选的会话"; "没有找到会话" = "没有找到会话"; "标题、Agent、环境或目录" = "标题、Agent、环境或目录"; @@ -70,7 +67,6 @@ "Kimi 归档与服务端同步;终端归档只整理本机列表,不结束远端进程。" = "Kimi 归档与服务端同步;终端归档只整理本机列表,不结束远端进程。"; "没有归档会话" = "没有归档会话"; "环境与 Agent" = "环境与 Agent"; -"添加 SSH 机器" = "添加 SSH 机器"; "本机 Agent…" = "本机 Agent…"; "重新连接 Kimi" = "重新连接 Kimi"; "原生对话" = "原生对话"; @@ -142,7 +138,6 @@ "浅色" = "浅色"; "深色" = "深色"; "Agent 与执行环境" = "Agent 与执行环境"; -"添加 SSH 环境…" = "添加 SSH 环境…"; "远程连接沿用本机 SSH 配置。连接、重连与移除机器都在侧边栏的环境入口。" = "远程连接沿用本机 SSH 配置。连接、重连与移除机器都在侧边栏的环境入口。"; "界面" = "界面"; "侧边栏可拖动调整宽度,系统会记住位置。透明度与动态效果遵循 macOS 辅助功能设置。" = "侧边栏可拖动调整宽度,系统会记住位置。透明度与动态效果遵循 macOS 辅助功能设置。"; @@ -213,8 +208,6 @@ "保存任务组" = "保存任务组"; "切换侧栏" = "切换侧栏"; "显示或隐藏侧栏" = "显示或隐藏侧栏"; -"新建任务" = "新建任务"; -"新建任务 · ⌘N" = "新建任务 · ⌘N"; "当前会话" = "当前会话"; "会话操作" = "会话操作"; "正在恢复会话…" = "正在恢复会话…"; @@ -225,7 +218,6 @@ "断开本地连接" = "断开本地连接"; "连接终端" = "连接终端"; "任务组…" = "任务组…"; -"原生对话…" = "原生对话…"; "新建" = "新建"; "远端终端…" = "远端终端…"; @@ -366,12 +358,10 @@ "断开或离开前正在运行的任务,重新连接后的状态:" = "断开或离开前正在运行的任务,重新连接后的状态:"; "连接后才能确认这些会话的当前状态。" = "连接后才能确认这些会话的当前状态。"; "远程 Agent 沿用你的 SSH 配置。本机环境目前仅支持安装检测。" = "远程 Agent 沿用你的 SSH 配置。本机环境目前仅支持安装检测。"; -"连接远程机器…" = "连接远程机器…"; "检测本机 Agent…" = "检测本机 Agent…"; "连接后查看最新进展" = "连接后查看最新进展"; "先连接一台运行 Agent 的机器" = "先连接一台运行 Agent 的机器"; "选择 Agent 和工作目录后开始" = "选择 Agent 和工作目录后开始"; -"开始第一段会话" = "开始第一段会话"; "%lld 个关联 · %lld 个未归档 · %lld 个已归档" = "%lld 个关联 · %lld 个未归档 · %lld 个已归档"; "上次会话尚未连接" = "上次会话尚未连接"; @@ -388,7 +378,6 @@ "%lld 个未归档 · %lld 个已归档 · %lld 个尚未同步" = "%lld 个未归档 · %lld 个已归档 · %lld 个尚未同步"; "Retry" = "重试"; -"Unable to load this conversation." = "无法读取此会话。"; "Choose Kimi or remove the attachments to start this task." = "请选择 Kimi 或移除附件,再开始任务。"; /* Composer commands */ @@ -410,11 +399,9 @@ "Goal resume requested." = "已请求继续目标。"; "Plan mode enabled." = "计划模式已开启。"; "Plan mode disabled." = "计划模式已关闭。"; -"This agent does not expose /goal to Perch. Use its terminal or a Kimi conversation." = "此 Agent 尚未向 Perch 开放 /goal,请在其终端或 Kimi 会话中使用。"; "↑↓ 选择 · Tab 补全 · Return 执行完整命令 · Esc 关闭" = "↑↓ 选择 · Tab 补全 · Return 执行完整命令 · Esc 关闭"; "Run command" = "执行命令"; "Goal created. First message accepted." = "目标已创建,首条消息已接收。"; -"Goal created; the first message is unconfirmed. Check the conversation before sending again." = "目标已创建,但首条消息尚未确认。请检查会话后再发送。"; "Choose a model before starting a goal." = "开始目标前请先选择模型。"; "Continue this task, or type / for commands…" = "继续此任务,或输入 / 使用命令…"; @@ -469,8 +456,6 @@ "安装或更新 Perch 桥接组件后重新检查。空闲的旧服务会自动安全重启;有任务运行时会等待任务结束。" = "安装或更新 Perch 桥接组件后重新检查。空闲的旧服务会自动安全重启;有任务运行时会等待任务结束。"; "安装说明" = "安装说明"; "已检测到远端凭据;模型目录在首次 ACP 会话握手时读取,鉴权在发送时验证。" = "已检测到远端凭据;模型目录在首次 ACP 会话握手时读取,鉴权在发送时验证。"; -"开始任务" = "开始任务"; -"开始首个任务" = "开始首个任务"; "打开远端终端" = "打开远端终端"; "服务只通过 SSH 访问远端 127.0.0.1。填写文件路径即可,令牌不保存在 Mac 配置中。" = "服务只通过 SSH 访问远端 127.0.0.1。填写文件路径即可,令牌不保存在 Mac 配置中。"; "服务连接" = "服务连接"; @@ -522,7 +507,6 @@ "继续后即可填写首个任务;机器、Agent、目录与模型会自动带入。" = "继续后即可填写首个任务;机器、Agent、目录与模型会自动带入。"; "缺少 Qoder SDK 或 Node.js。请安装桥接组件和 Node.js 后重试。" = "缺少 Qoder SDK 或 Node.js。请安装桥接组件和 Node.js 后重试。"; "缺少 Claude Code SDK 或 Node.js。请安装桥接组件和 Node.js 后重试。" = "缺少 Claude Code SDK 或 Node.js。请安装桥接组件和 Node.js 后重试。"; -"要连接另一台机器,请添加新环境。" = "要连接另一台机器,请添加新环境。"; "请在远端安装并启动 Herdr,然后重新检查。" = "请在远端安装并启动 Herdr,然后重新检查。"; "请输入远端已存在且当前用户可访问的目录。" = "请输入远端已存在且当前用户可访问的目录。"; "请输入远端项目的绝对路径。" = "请输入远端项目的绝对路径。"; @@ -817,7 +801,6 @@ "已发现 %lld 个本机 Agent" = "已发现 %lld 个本机 Agent"; -"已安装,尚未接入本机对话" = "已安装,尚未接入本机对话"; "未找到可执行文件" = "未找到可执行文件"; @@ -839,7 +822,6 @@ "连接会复用或启动本机服务;关闭 Perch 不会停止正在执行的任务。" = "连接会复用或启动本机服务;关闭 Perch 不会停止正在执行的任务。"; -"连接并新建任务" = "连接并新建任务"; "连接本机已安装的 Agent,在你选择的 Mac 项目目录执行。" = "连接本机已安装的 Agent,在你选择的 Mac 项目目录执行。"; @@ -1098,7 +1080,6 @@ "全部处理完了" = "全部处理完了"; "没有等待确认、回答或处理的事项;查看结果请到工作台。" = "没有等待确认、回答或处理的事项;查看结果请到工作台。"; "共 %lld 项 · 确认、回答或处理错误;查看结果请到工作台。" = "共 %lld 项 · 确认、回答或处理错误;查看结果请到工作台。"; -"打开会话" = "打开会话"; "粘贴内容已丢失,请重新粘贴后再发送。" = "粘贴内容已丢失,请重新粘贴后再发送。"; "附件需要在线上传,连接恢复后再发送。" = "附件需要在线上传,连接恢复后再发送。"; "命令需要连接后执行,请等待恢复在线。" = "命令需要连接后执行,请等待恢复在线。"; @@ -1158,7 +1139,6 @@ "置顶常用会话,随时继续" = "置顶常用会话,随时继续"; -"切换会话视图" = "切换会话视图"; "搜索会话" = "搜索会话"; @@ -1568,7 +1548,6 @@ "此终端已不在远端列表中" = "此终端已不在远端列表中"; "本地工作台读取失败,原文件已保留:%@" = "本地工作台读取失败,原文件已保留:%@"; "工作台保存失败:%@" = "工作台保存失败:%@"; -"The task environment is unavailable" = "任务环境不可用"; "Menlo 14 pt\n窗口 backingScaleFactor: %@\n视图逻辑尺寸: %@ pt\n视图 backing 尺寸: %@ px\nGhostty surface: %@ px\n终端网格: %@\n像素尺寸一致: %@" = "Menlo 14 pt\n窗口 backingScaleFactor: %@\n视图逻辑尺寸: %@ pt\n视图 backing 尺寸: %@ px\nGhostty surface: %@ px\n终端网格: %@\n像素尺寸一致: %@"; "是" = "是"; "否" = "否"; @@ -1605,3 +1584,18 @@ "回车排到下一轮发送" = "回车排到下一轮发送"; "回车执行命令" = "回车执行命令"; "成果操作" = "成果操作"; + +/* Sidebar and workbench consistency: canonical session, machine and task group terms (2026-10-16) */ +"下一个待处理会话" = "下一个待处理会话"; +"无法读取此会话。" = "无法读取此会话。"; +"此 Agent 尚未向 Perch 开放 /goal,请在其终端或 Kimi 会话中使用。" = "此 Agent 尚未向 Perch 开放 /goal,请在其终端或 Kimi 会话中使用。"; +"目标已创建,但首条消息尚未确认。请检查会话后再发送。" = "目标已创建,但首条消息尚未确认。请检查会话后再发送。"; +"项目视图选项" = "项目视图选项"; +"新会话会出现在这里" = "新会话会出现在这里"; +"新建会话 · ⌘N" = "新建会话 · ⌘N"; +"会话…" = "会话…"; +"开始首个会话" = "开始首个会话"; +"开始会话" = "开始会话"; +"连接并新建会话" = "连接并新建会话"; +"已安装,尚未接入本机会话" = "已安装,尚未接入本机会话"; +"要连接另一台机器,请添加新机器。" = "要连接另一台机器,请添加新机器。"; diff --git a/Sources/AgentWorkbench/CommandPalette.swift b/Sources/AgentWorkbench/CommandPalette.swift index e56b76b4..d183e39c 100644 --- a/Sources/AgentWorkbench/CommandPalette.swift +++ b/Sources/AgentWorkbench/CommandPalette.swift @@ -31,8 +31,9 @@ struct CommandPalette: View { Text("↑↓ 选择 · Tab 补全 · Return 执行完整命令 · Esc 关闭") .font(.system(size: 10)).foregroundStyle(.secondary).padding(10) }.frame(maxWidth: .infinity, alignment: .leading) - .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10)) - .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(Color.primary.opacity(0.08))) + .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: WorkbenchTheme.Radius.card)) + .overlay(RoundedRectangle(cornerRadius: WorkbenchTheme.Radius.card) + .strokeBorder(Color.primary.opacity(WorkbenchTheme.Stroke.hairline))) .accessibilityLabel("命令建议,\(completion.matches.count) 项") } } @@ -58,8 +59,10 @@ private struct CommandPaletteRow: View { Text("\(command.children.count) 个子命令").font(.system(size: 10)).foregroundStyle(.tertiary) } }.padding(.horizontal, 10).padding(.vertical, 7).contentShape(Rectangle()) - .background(selected || hovered ? Color.primary.opacity(0.06) : .clear, - in: RoundedRectangle(cornerRadius: 6)) + // The keyboard row outranks the pointer's, so hover never reads as a second selection. + .background(Color.primary.opacity(selected ? WorkbenchTheme.Fill.selected + : hovered ? WorkbenchTheme.Fill.hover : 0), + in: RoundedRectangle(cornerRadius: WorkbenchTheme.Radius.row)) }.buttonStyle(.plain).onHover { hovered = $0 } .accessibilityAddTraits(selected ? .isSelected : []) } diff --git a/Sources/AgentWorkbench/ConversationActivityBar.swift b/Sources/AgentWorkbench/ConversationActivityBar.swift index 2e527476..bb132e50 100644 --- a/Sources/AgentWorkbench/ConversationActivityBar.swift +++ b/Sources/AgentWorkbench/ConversationActivityBar.swift @@ -730,7 +730,8 @@ private struct KimiTaskRow: View { } } }.frame(maxWidth: .infinity, alignment: .leading).padding(10) - .background(Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 6)) + .background(Color.primary.opacity(WorkbenchTheme.Fill.surface), + in: RoundedRectangle(cornerRadius: WorkbenchTheme.Radius.card)) .padding(.leading, 10).padding(.top, 5) } diff --git a/Sources/AgentWorkbench/ConversationFindBar.swift b/Sources/AgentWorkbench/ConversationFindBar.swift index 14fe232e..4160170f 100644 --- a/Sources/AgentWorkbench/ConversationFindBar.swift +++ b/Sources/AgentWorkbench/ConversationFindBar.swift @@ -133,7 +133,8 @@ private struct FindOptionToggle: View { Button { on.toggle(); onChange() } label: { Text(label).font(.system(size: 11, weight: .medium, design: .monospaced)) .frame(width: 22, height: 20) - .background(on ? Color.primary.opacity(0.12) : Color.clear, in: RoundedRectangle(cornerRadius: 5)) + .background(on ? Color.primary.opacity(WorkbenchTheme.Fill.selected) : Color.clear, + in: RoundedRectangle(cornerRadius: WorkbenchTheme.Radius.chip)) .contentShape(Rectangle()) }.buttonStyle(.plain).foregroundStyle(on ? .primary : .secondary).help(help) .accessibilityLabel(help) diff --git a/Sources/AgentWorkbench/ConversationScrollControls.swift b/Sources/AgentWorkbench/ConversationScrollControls.swift index a7c17956..73484465 100644 --- a/Sources/AgentWorkbench/ConversationScrollControls.swift +++ b/Sources/AgentWorkbench/ConversationScrollControls.swift @@ -112,8 +112,8 @@ struct ReturnToLatestButton: View { .padding(.horizontal, hasNewReply ? 12 : 0) .frame(width: hasNewReply ? nil : 32, height: 32) .workbenchFloatingSurface() - .overlay(Capsule().fill(Color.primary.opacity(hovered && isVisible ? 0.05 : 0))) - .overlay(Capsule().strokeBorder(.primary.opacity(0.08))) + .overlay(Capsule().fill(Color.primary.opacity(hovered && isVisible ? WorkbenchTheme.Fill.hover : 0))) + .overlay(Capsule().strokeBorder(.primary.opacity(WorkbenchTheme.Stroke.hairline))) .shadow(color: .black.opacity(0.08), radius: 5, y: 2) .contentShape(Capsule()) }.buttonStyle(.plain).accessibilityIdentifier("return-to-latest") @@ -306,7 +306,7 @@ private struct ConversationBookmarksMenu: View { Image(systemName: "bookmark.fill").font(.system(size: 12)) .frame(width: 30, height: 30) .workbenchFloatingSurface() - .overlay(RoundedRectangle(cornerRadius: 15).strokeBorder(.primary.opacity(0.08))) + .overlay(RoundedRectangle(cornerRadius: 15).strokeBorder(.primary.opacity(WorkbenchTheme.Stroke.hairline))) .shadow(color: .black.opacity(0.08), radius: 5, y: 2) }.menuStyle(.borderlessButton).menuIndicator(.hidden).fixedSize() .help("已收藏的轮次").accessibilityLabel("已收藏的轮次") @@ -376,8 +376,9 @@ private struct ConversationTurnRail: View { } .transaction { $0.animation = nil } // Neighbor text changes without crossfading. .padding(12).frame(width: min(340, availableWidth), height: 136, alignment: .topLeading) - .background(Color(nsColor: .windowBackgroundColor), in: RoundedRectangle(cornerRadius: 10)) - .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(.primary.opacity(0.06))) + .background(Color(nsColor: .windowBackgroundColor), in: RoundedRectangle(cornerRadius: WorkbenchTheme.Radius.card)) + .overlay(RoundedRectangle(cornerRadius: WorkbenchTheme.Radius.card) + .strokeBorder(.primary.opacity(WorkbenchTheme.Stroke.hairline))) .shadow(color: .black.opacity(0.07), radius: 8, y: 3) .offset(x: railWidth, y: min(max(0, previewY - 28), max(0, height - 136))) .allowsHitTesting(false).accessibilityHidden(true) @@ -423,20 +424,20 @@ private struct ConversationTurnMarks: View { } if let hovered, hovered != selected { let y = layout.y(for: hovered) - context.fill(Path(roundedRect: CGRect(x: 4, y: y - 6, width: 24, height: 14), cornerRadius: 5), - with: .color(.primary.opacity(0.045))) + context.fill(Path(roundedRect: CGRect(x: 4, y: y - 6, width: 24, height: 14), cornerRadius: WorkbenchTheme.Radius.chip), + with: .color(.primary.opacity(WorkbenchTheme.Fill.hover))) tick(y: y, width: 14, opacity: 0.55) } tick(y: layout.y(for: current), width: 20, opacity: 0.85) if keyboardFocused { let y = layout.y(for: selected ?? current) - context.stroke(Path(roundedRect: CGRect(x: 3, y: y - 7, width: 26, height: 16), cornerRadius: 5), + context.stroke(Path(roundedRect: CGRect(x: 3, y: y - 7, width: 26, height: 16), cornerRadius: WorkbenchTheme.Radius.chip), with: .color(.primary.opacity(0.18)), lineWidth: 1) } if let selected { let y = layout.y(for: selected) - context.fill(Path(roundedRect: CGRect(x: 3, y: y - 7, width: 26, height: 16), cornerRadius: 5), - with: .color(.primary.opacity(0.09))) + context.fill(Path(roundedRect: CGRect(x: 3, y: y - 7, width: 26, height: 16), cornerRadius: WorkbenchTheme.Radius.chip), + with: .color(.primary.opacity(WorkbenchTheme.Fill.selected))) tick(y: y, width: 20, opacity: 1, height: 2.5) } } diff --git a/Sources/AgentWorkbench/HostIdentityIcon.swift b/Sources/AgentWorkbench/HostIdentityIcon.swift index 4e6b33c0..4c52975e 100644 --- a/Sources/AgentWorkbench/HostIdentityIcon.swift +++ b/Sources/AgentWorkbench/HostIdentityIcon.swift @@ -19,8 +19,9 @@ struct HostIdentityIcon: View { return Image(nsImage: image).renderingMode(.original) } + /// This Mac, or a remote machine. `globe` stays the language symbol in settings. private static func symbol(for hostID: UUID?) -> String { - hostID == ExecutionEnvironment.localHostID ? "desktopcomputer" : "globe" + hostID == ExecutionEnvironment.localHostID ? "desktopcomputer" : "server.rack" } private static func color(for hostID: UUID?) -> NSColor { diff --git a/Sources/AgentWorkbench/KimiConnection.swift b/Sources/AgentWorkbench/KimiConnection.swift index b5402cfb..8a0c7291 100644 --- a/Sources/AgentWorkbench/KimiConnection.swift +++ b/Sources/AgentWorkbench/KimiConnection.swift @@ -1039,7 +1039,7 @@ final class KimiConnection { } } catch { let message = L("发送未确认,草稿已保留。请检查会话后再发送。\n\(error.localizedDescription)") - if startingGoal { commandFeedback[id] = L("Goal created; the first message is unconfirmed. Check the conversation before sending again.") } + if startingGoal { commandFeedback[id] = L("目标已创建,但首条消息尚未确认。请检查会话后再发送。") } updatePrompt(promptID, for: id, status: "unknown", error: message) if selectedId == id { actionError = message } } diff --git a/Sources/AgentWorkbench/KimiWorkspaceView.swift b/Sources/AgentWorkbench/KimiWorkspaceView.swift index 26e1d3ca..4b204f92 100644 --- a/Sources/AgentWorkbench/KimiWorkspaceView.swift +++ b/Sources/AgentWorkbench/KimiWorkspaceView.swift @@ -59,7 +59,7 @@ struct KimiWorkspaceView: View { VStack(spacing: 14) { if connection.loading { ProgressView("正在读取会话…") } else { - Text(connection.actionError ?? L("Unable to load this conversation.")).foregroundStyle(.secondary).textSelection(.enabled) + Text(connection.actionError ?? L("无法读取此会话。")).foregroundStyle(.secondary).textSelection(.enabled) Button("Retry") { connection.select(id) }.disabled(!connection.online) } }.padding(28).frame(maxWidth: .infinity, maxHeight: .infinity) @@ -397,7 +397,7 @@ private struct KimiTimeline: View { Button("仅允许本次") { connection.resolve(approval, decision: "approved") }.buttonStyle(.borderedProminent) Button("拒绝") { connection.resolve(approval, decision: "rejected") } }.disabled(!connection.online || !connection.snapshotReady || connection.resolving.contains(approval.id)) - }.padding(16).background(.orange.opacity(0.05), in: RoundedRectangle(cornerRadius: 10)) + }.padding(16).background(.orange.opacity(0.05), in: RoundedRectangle(cornerRadius: WorkbenchTheme.Radius.card)) } ForEach(c.snapshot.pendingQuestions) { question in KimiQuestionView(question: question, connection: connection) }.id(c.snapshot.session.id) } @@ -527,14 +527,14 @@ struct KimiQuestionView: View { } .padding(10) .background(isCurrent ? Color.accentColor.opacity(0.06) : .clear, - in: RoundedRectangle(cornerRadius: 8)) + in: RoundedRectangle(cornerRadius: WorkbenchTheme.Radius.row)) } Button("提交回答") { guard sessionID == nil || sessionID == connection.selectedId else { return } connection.answer(question, answers: answers) }.buttonStyle(.borderedProminent) .disabled(!submittable || !connection.online || !connection.snapshotReady || connection.resolving.contains(question.id)) - }.padding(16).background(kimiAccent.opacity(0.05), in: RoundedRectangle(cornerRadius: 10)) + }.padding(16).background(kimiAccent.opacity(0.05), in: RoundedRectangle(cornerRadius: WorkbenchTheme.Radius.card)) } private func selected(_ option: String, in q: KimiQuestion.Item) -> Bool { diff --git a/Sources/AgentWorkbench/LocalAgentSetupSheet.swift b/Sources/AgentWorkbench/LocalAgentSetupSheet.swift index 5727795b..a3f03a9c 100644 --- a/Sources/AgentWorkbench/LocalAgentSetupSheet.swift +++ b/Sources/AgentWorkbench/LocalAgentSetupSheet.swift @@ -29,7 +29,7 @@ struct LocalAgentSetupSheet: View { Text(version).font(.callout) Text(path).font(.caption).foregroundStyle(.secondary).lineLimit(2).textSelection(.enabled) if !LocalAgentDiscovery.supported.contains(kind) { - Text("已安装,尚未接入本机对话").font(.caption).foregroundStyle(.secondary) + Text("已安装,尚未接入本机会话").font(.caption).foregroundStyle(.secondary) } case .unusable(_, let reason): Text(reason).font(.caption).foregroundStyle(.orange).lineLimit(3) @@ -51,7 +51,7 @@ struct LocalAgentSetupSheet: View { Button("重新检测") { model.discoverLocalAgents() }.disabled(model.probingLocal) Spacer() Button("关闭") { dismiss() }.keyboardShortcut(.cancelAction) - Button("连接并新建任务") { + Button("连接并新建会话") { do { try model.connectLocalAgents(LocalAgentDiscovery.supported.filter { selected.contains($0) }); dismiss() } catch { self.error = error.localizedDescription } }.disabled(model.probingLocal || !selected.contains { model.localAgents[$0]?.executablePath != nil }) diff --git a/Sources/AgentWorkbench/ModelPicker.swift b/Sources/AgentWorkbench/ModelPicker.swift index ff64d207..7213e8f7 100644 --- a/Sources/AgentWorkbench/ModelPicker.swift +++ b/Sources/AgentWorkbench/ModelPicker.swift @@ -154,8 +154,10 @@ struct ModelPickerRow: View { Image(systemName: "checkmark").font(.system(size: 11, weight: .semibold)) .opacity(selected ? 1 : 0).accessibilityHidden(true) }.padding(.horizontal, 10).padding(.vertical, 8).contentShape(Rectangle()) - .background(hovered || selected ? Color.primary.opacity(0.06) : .clear, - in: RoundedRectangle(cornerRadius: 6)) + // The chosen row outranks the pointer's, so hover never reads as a second choice. + .background(Color.primary.opacity(selected ? WorkbenchTheme.Fill.selected + : hovered ? WorkbenchTheme.Fill.hover : 0), + in: RoundedRectangle(cornerRadius: WorkbenchTheme.Radius.row)) }.buttonStyle(.plain).onHover { hovered = $0 } .accessibilityAddTraits(selected ? .isSelected : []) } diff --git a/Sources/AgentWorkbench/NativeAgentConnection.swift b/Sources/AgentWorkbench/NativeAgentConnection.swift index f37625c7..4c4e33ee 100644 --- a/Sources/AgentWorkbench/NativeAgentConnection.swift +++ b/Sources/AgentWorkbench/NativeAgentConnection.swift @@ -576,7 +576,7 @@ final class NativeAgentConnection { return } if SlashCommands.invocation(in: text, from: [AgentCommand(name: "goal")]) != nil { - actionError = L("This agent does not expose /goal to Perch. Use its terminal or a Kimi conversation.") + actionError = L("此 Agent 尚未向 Perch 开放 /goal,请在其终端或 Kimi 会话中使用。") return } var payload: [OutboundAttachment] = [] diff --git a/Sources/AgentWorkbench/NativeAgentView.swift b/Sources/AgentWorkbench/NativeAgentView.swift index 231b37c7..a952cb3f 100644 --- a/Sources/AgentWorkbench/NativeAgentView.swift +++ b/Sources/AgentWorkbench/NativeAgentView.swift @@ -496,6 +496,6 @@ struct NativeInteractionView: View { TextField("填写回答", text: $text).textFieldStyle(.roundedBorder) HStack { Button("提交回答") { submit(["value": .string(text)]) }; Button("取消") { submit(["cancelled": .bool(true)]) } } } - }.padding(16).frame(maxWidth: .infinity, alignment: .leading).background(.orange.opacity(0.06), in: RoundedRectangle(cornerRadius: 10)).disabled(!connection.online || submitting) + }.padding(16).frame(maxWidth: .infinity, alignment: .leading).background(.orange.opacity(0.06), in: RoundedRectangle(cornerRadius: WorkbenchTheme.Radius.card)).disabled(!connection.online || submitting) } } diff --git a/Sources/AgentWorkbench/NewTaskDirectoryPicker.swift b/Sources/AgentWorkbench/NewTaskDirectoryPicker.swift index 6ca618b7..bb0d6c9e 100644 --- a/Sources/AgentWorkbench/NewTaskDirectoryPicker.swift +++ b/Sources/AgentWorkbench/NewTaskDirectoryPicker.swift @@ -53,8 +53,7 @@ struct NewTaskDirectoryPicker: View { if path == cwd { Image(systemName: "checkmark").font(.system(size: 11)) } }.frame(maxWidth: .infinity, alignment: .leading) .padding(8).contentShape(Rectangle()) - }.buttonStyle(WorkbenchDisclosureButtonStyle()).help(path) - .background(path == highlightedPath ? Color.primary.opacity(0.06) : .clear, in: RoundedRectangle(cornerRadius: 6)) + }.buttonStyle(WorkbenchDisclosureButtonStyle(selected: path == highlightedPath)).help(path) .accessibilityAddTraits(path == highlightedPath ? .isSelected : []) .id(path) } diff --git a/Sources/AgentWorkbench/NewTaskView.swift b/Sources/AgentWorkbench/NewTaskView.swift index 7cfce70e..59b8ad2e 100644 --- a/Sources/AgentWorkbench/NewTaskView.swift +++ b/Sources/AgentWorkbench/NewTaskView.swift @@ -359,7 +359,7 @@ struct NewTaskView: View { icon: { HostIdentityIcon.menuImage(for: kimi.host.id) } } }.menuStyle(.borderlessButton).menuIndicator(.hidden) - .fixedSize(horizontal: false, vertical: true).accessibilityLabel("运行环境") + .fixedSize(horizontal: false, vertical: true).accessibilityLabel("机器") Rectangle().fill(.quaternary).frame(width: 1, height: 14) Button { showDirectory.toggle() } label: { DraftMenuLabel { @@ -397,7 +397,7 @@ struct NewTaskView: View { ProgressView().controlSize(.mini) Text("正在连接所选 Agent…") } else { - Label("尚未连接所选 Agent。", systemImage: "network") + Label("尚未连接所选 Agent。", systemImage: "wifi.slash") Spacer(minLength: 0) Button("连接") { if provider == .kimi { kimi.connect() } else { native.connect() } } } diff --git a/Sources/AgentWorkbench/PlanReviewView.swift b/Sources/AgentWorkbench/PlanReviewView.swift index f976321b..0f56d459 100644 --- a/Sources/AgentWorkbench/PlanReviewView.swift +++ b/Sources/AgentWorkbench/PlanReviewView.swift @@ -121,7 +121,8 @@ struct PlanReviewPanel: View { } } }.padding(16).frame(maxWidth: .infinity, alignment: .leading) - .background(.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 12)) + .background(.primary.opacity(WorkbenchTheme.Fill.surface), + in: RoundedRectangle(cornerRadius: WorkbenchTheme.Radius.card)) // Enter and Escape have no approval binding; dismissal sends nothing. .sheet(isPresented: $showPlan) { VStack(alignment: .leading, spacing: 12) { diff --git a/Sources/AgentWorkbench/ProjectMessageComposer.swift b/Sources/AgentWorkbench/ProjectMessageComposer.swift index 37608ada..fb176a5b 100644 --- a/Sources/AgentWorkbench/ProjectMessageComposer.swift +++ b/Sources/AgentWorkbench/ProjectMessageComposer.swift @@ -81,7 +81,8 @@ struct ProjectMessageComposer: View { Button { choose(path) } label: { Text(path).font(.system(size: 11, design: .monospaced)).lineLimit(1).truncationMode(.middle) .frame(maxWidth: .infinity, alignment: .leading).frame(height: 23).padding(.horizontal, 5) - .background(index == choice ? Color.primary.opacity(0.07) : .clear) + .background(index == choice ? Color.primary.opacity(WorkbenchTheme.Fill.selected) : .clear, + in: RoundedRectangle(cornerRadius: WorkbenchTheme.Radius.row)) .contentShape(Rectangle()) }.buttonStyle(.plain).help(path).id(index) } @@ -92,7 +93,8 @@ struct ProjectMessageComposer: View { } Text("↑↓ 选择 · Return / Tab 引用 · Esc 关闭,不会发送消息") .font(.system(size: 10)).foregroundStyle(.secondary) - }.padding(8).background(Color.primary.opacity(0.03), in: RoundedRectangle(cornerRadius: 8)) + }.padding(8).background(Color.primary.opacity(WorkbenchTheme.Fill.surface), + in: RoundedRectangle(cornerRadius: WorkbenchTheme.Radius.card)) } let references = ProjectFileReference.references(in: text) if !references.isEmpty { @@ -113,7 +115,8 @@ struct ProjectMessageComposer: View { Button { remove(reference) } label: { Image(systemName: "xmark.circle.fill") } .help("移除文件引用").accessibilityLabel("移除文件引用") }.font(.system(size: 11)).buttonStyle(.plain).padding(5) - .background(Color.primary.opacity(0.05), in: RoundedRectangle(cornerRadius: 5)) + .background(Color.primary.opacity(WorkbenchTheme.Fill.surface), + in: RoundedRectangle(cornerRadius: WorkbenchTheme.Radius.chip)) } } } @@ -129,7 +132,8 @@ struct ProjectMessageComposer: View { Button { remove(paste) } label: { Image(systemName: "xmark.circle.fill") } .help("移除粘贴内容").accessibilityLabel("移除粘贴内容") }.font(.system(size: 11)).buttonStyle(.plain).padding(5) - .background(Color.primary.opacity(0.05), in: RoundedRectangle(cornerRadius: 5)) + .background(Color.primary.opacity(WorkbenchTheme.Fill.surface), + in: RoundedRectangle(cornerRadius: WorkbenchTheme.Radius.chip)) } } } diff --git a/Sources/AgentWorkbench/RemoteFileView.swift b/Sources/AgentWorkbench/RemoteFileView.swift index 2c4005fc..376bfea2 100644 --- a/Sources/AgentWorkbench/RemoteFileView.swift +++ b/Sources/AgentWorkbench/RemoteFileView.swift @@ -915,7 +915,7 @@ struct RemoteFilePanel: View { } } .padding(.horizontal, 14).padding(.vertical, 6) - .background(entry.path == browser.gitPath ? Color.primary.opacity(0.05) : .clear) + .background(entry.path == browser.gitPath ? Color.primary.opacity(WorkbenchTheme.Fill.selected) : .clear) } }.padding(.vertical, 8) }.frame(minHeight: 80, idealHeight: max(80, min(220, Double(entries.count) * 42 + 16)), maxHeight: max(80, min(220, Double(entries.count) * 42 + 16))) diff --git a/Sources/AgentWorkbench/RemoteSetupView.swift b/Sources/AgentWorkbench/RemoteSetupView.swift index 84abff29..7349f5c4 100644 --- a/Sources/AgentWorkbench/RemoteSetupView.swift +++ b/Sources/AgentWorkbench/RemoteSetupView.swift @@ -28,7 +28,7 @@ struct AddHostSheet: View { Image(systemName: "chevron.right").font(.caption2).foregroundStyle(.tertiary) stepLabel("准备 Agent", step: .agent) Image(systemName: "chevron.right").font(.caption2).foregroundStyle(.tertiary) - stepLabel("开始任务", step: .project) + stepLabel("开始会话", step: .project) } }.padding(WorkbenchTheme.Dialog.padding) Divider() @@ -70,7 +70,7 @@ struct AddHostSheet: View { Text("SSH 地址").font(.callout.weight(.medium)) TextField("SSH 别名或 user@host", text: $setup.destination).textFieldStyle(.roundedBorder) .disabled(setup.original != nil) - if setup.original != nil { Text("要连接另一台机器,请添加新环境。").font(.caption).foregroundStyle(.secondary) } + if setup.original != nil { Text("要连接另一台机器,请添加新机器。").font(.caption).foregroundStyle(.secondary) } } VStack(alignment: .leading, spacing: 6) { Text("显示名称").font(.callout.weight(.medium)) @@ -292,7 +292,7 @@ struct AddHostSheet: View { Button("使用此环境") { finish(start: false) } .buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction).disabled(!setup.canFinish) } else { - Button("开始首个任务") { finish(start: true) } + Button("开始首个会话") { finish(start: true) } .buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction).disabled(!setup.canFinish) } } diff --git a/Sources/AgentWorkbench/SelectionActions.swift b/Sources/AgentWorkbench/SelectionActions.swift index 1e5e3770..1d06ff48 100644 --- a/Sources/AgentWorkbench/SelectionActions.swift +++ b/Sources/AgentWorkbench/SelectionActions.swift @@ -261,7 +261,8 @@ private struct SelectionActionButton: View { Image(systemName: systemImage).font(.system(size: 11)) Text(title).font(.system(size: 12)) }.padding(.horizontal, 9).padding(.vertical, 6).contentShape(Rectangle()) - .background(hovered ? Color.primary.opacity(0.07) : .clear, in: RoundedRectangle(cornerRadius: 7)) + .background(hovered ? Color.primary.opacity(WorkbenchTheme.Fill.hover) : .clear, + in: RoundedRectangle(cornerRadius: WorkbenchTheme.Radius.row)) }.buttonStyle(.plain).onHover { hovered = $0 } .accessibilityLabel(title) } diff --git a/Sources/AgentWorkbench/SessionRowChrome.swift b/Sources/AgentWorkbench/SessionRowChrome.swift index dde497ef..0f94791e 100644 --- a/Sources/AgentWorkbench/SessionRowChrome.swift +++ b/Sources/AgentWorkbench/SessionRowChrome.swift @@ -114,18 +114,15 @@ struct SessionRowChrome: View { }.buttonStyle(.plain).font(.system(size: 11)).foregroundStyle(.secondary) .frame(width: actionWidth, alignment: .trailing).clipped() .background { - if compact && showActions { RoundedRectangle(cornerRadius: 6).fill(.regularMaterial) } + if compact && showActions { + RoundedRectangle(cornerRadius: WorkbenchTheme.Radius.chip).fill(.regularMaterial) + } } .opacity(showActions ? 1 : 0).allowsHitTesting(showActions).accessibilityHidden(!showActions) - .animation(reduceMotion ? nil : .easeOut(duration: 0.1), value: showActions) + .animation(reduceMotion ? nil : .easeOut(duration: SidebarMetrics.highlightDuration), value: showActions) }.frame(width: trailingWidth, alignment: .trailing) }.padding(.trailing, 5).frame(height: rowHeight) - .background { - RoundedRectangle(cornerRadius: SidebarMetrics.cornerRadius) - .fill(Color.primary.opacity(SidebarMetrics.fill(selected: selected, hovered: hovered))) - .animation(reduceMotion ? nil : .easeOut(duration: 0.1), value: selected) - .animation(reduceMotion ? nil : .easeOut(duration: 0.1), value: hovered) - } + .background { SidebarHighlight(selected: selected, hovered: hovered) } .contentShape(Rectangle()).onHover { hovered = $0 } // Native tooltip carries what the row cannot show: the full title // plus host and directory. A custom card covered the reading area. diff --git a/Sources/AgentWorkbench/SessionSidebar.swift b/Sources/AgentWorkbench/SessionSidebar.swift index b60df322..1afdb02c 100644 --- a/Sources/AgentWorkbench/SessionSidebar.swift +++ b/Sources/AgentWorkbench/SessionSidebar.swift @@ -35,6 +35,8 @@ struct WorkbenchSidebar: View { let selectedProjectID = projection.projects.first { project in project.sessions.contains { $0.id == model.tabs.selectedID } }?.id + // The dashboard's first-run signal: no machine list set up yet, this Mac included. + let addMachine: (() -> Void)? = model.configuredEnvironment ? nil : { model.configureHost() } WorkspaceSidebarShell(page: page, attentionCount: projection.attentionCount, environmentSummary: L("\(model.connections.count) 个环境"), environmentNeedsAttention: environmentNeedsAttention, @@ -44,6 +46,7 @@ struct WorkbenchSidebar: View { onArchive: { model.showArchive() }, onAllSessions: { model.showAllSessions() }, onGroups: { model.clearScope(); model.showHome(); model.showAllTaskGroups = true }, + onAddMachine: addMachine, scrollRows: !usesTable, scrollTargetID: scrollTarget(in: projection)) { #if PERCH_ACCEPTANCE if experiment.sidebarVariant == "projects" { projectRows(projection, index: index) } @@ -217,7 +220,11 @@ struct WorkbenchSidebar: View { result.append(.favorites) if favoritesExpanded { result += projection.favorites.map { .session($0, false) } - if projection.favorites.isEmpty { result.append(.emptyFavorites) } + // The invitation to pin needs something to pin. On a first run the picker + // it opens would be empty, so it waits for the first session. + if projection.favorites.isEmpty && model.allSessions.contains(where: { !$0.archived }) { + result.append(.emptyFavorites) + } } result.append(.projects) let collapsed = collapsedProjects @@ -259,19 +266,9 @@ struct WorkbenchSidebar: View { }.buttonStyle(SidebarNavigationStyle()).accessibilityIdentifier("sidebar.pin.empty") case .projects: HStack(spacing: 6) { - Menu { - Picker("会话视图", selection: $groupedByProject) { - Label("按项目", systemImage: "folder").tag(true) - Label("平铺会话", systemImage: "list.bullet").tag(false) - }.pickerStyle(.inline) - Divider() - Button("重新识别项目") { model.sidebarRepositories.retry() } - } label: { - // The same section title as 置顶; the chevron marks a menu here. - SidebarSectionTitle(title: Text(groupedByProject ? L("项目") : L("最近会话")), expanded: true) - .frame(height: SidebarMetrics.sectionHeaderHeight) - }.menuStyle(.borderlessButton).menuIndicator(.hidden).fixedSize() - .help("切换会话视图").accessibilityIdentifier("sidebar.layout") + SidebarViewOptionsMenu(title: Text(groupedByProject ? L("项目") : L("最近会话")), + groupedByProject: $groupedByProject, + onRefresh: { model.sidebarRepositories.retry() }) Spacer() if groupedByProject && model.sidebarRepositories.isResolving { ProgressView().controlSize(.mini).help("正在识别项目…") @@ -284,7 +281,7 @@ struct WorkbenchSidebar: View { .accessibilityLabel("重新识别项目") } recentActions - }.padding(.leading, SidebarMetrics.rowInset).padding(.trailing, 2) + }.padding(.trailing, 2) .padding(.top, SidebarMetrics.sectionTopSpacing).padding(.bottom, SidebarMetrics.sectionBottomSpacing) case .project(let project): SidebarProjectHeader(project: project, expanded: !collapsedProjects.contains(project.id), @@ -313,7 +310,7 @@ struct WorkbenchSidebar: View { } } if projection.ungrouped.isEmpty && (filter != .all || projection.favorites.isEmpty) { - Text(L(key: filter == .all ? "新任务会出现在这里" : "没有符合筛选的会话")) + Text(L(key: filter == .all ? "新会话会出现在这里" : "没有符合筛选的会话")) .font(.system(size: SidebarMetrics.detailSize)).foregroundStyle(.secondary) .padding(.horizontal, SidebarMetrics.rowInset).padding(.vertical, 12) } @@ -335,7 +332,8 @@ struct WorkbenchSidebar: View { Button { showPinPicker = true } label: { Image(systemName: "plus").font(.system(size: 11, weight: .semibold)) .foregroundStyle(Color.primary).frame(width: 22, height: 22) - .background(Color.primary.opacity(0.07), in: RoundedRectangle(cornerRadius: 6)) + .background(Color.primary.opacity(WorkbenchTheme.Fill.selected), + in: RoundedRectangle(cornerRadius: SidebarMetrics.cornerRadius)) } .buttonStyle(SidebarNavigationStyle()).help("置顶会话") .accessibilityLabel("置顶会话").accessibilityIdentifier("sidebar.pin.add") @@ -360,7 +358,7 @@ struct WorkbenchSidebar: View { let group = summary.group Button { model.showHome(groupID: group.id) } label: { HStack(spacing: SidebarMetrics.labelSpacing) { - Image(systemName: "folder") + Image(systemName: "square.stack.3d.up") .font(.system(size: SidebarMetrics.iconSize, weight: .regular)).imageScale(.medium) .frame(width: SidebarMetrics.iconWidth) Text(group.name).lineLimit(1); Spacer(minLength: 4) @@ -385,7 +383,7 @@ struct WorkbenchSidebar: View { } @ViewBuilder private func recentFooter(_ projection: SidebarProjection) -> some View { if projection.recent.isEmpty { - Text(L(key: filter == .all ? "新任务会出现在这里" : "没有符合筛选的会话")) + Text(L(key: filter == .all ? "新会话会出现在这里" : "没有符合筛选的会话")) .font(.system(size: SidebarMetrics.detailSize)).foregroundStyle(.secondary) .padding(.leading, SidebarMetrics.titleOffset).padding(.vertical, 10) } @@ -431,6 +429,36 @@ struct WorkbenchSidebar: View { } } +/// The 项目 header's title opens the view options. The ellipsis marks a menu, so it +/// never reads as 置顶's fold chevron. Hover state lives here, not in the sidebar. +private struct SidebarViewOptionsMenu: View { + let title: Text + @Binding var groupedByProject: Bool + let onRefresh: () -> Void + @State private var hovered = false + + var body: some View { + Menu { + Picker("会话视图", selection: $groupedByProject) { + Label("按项目", systemImage: "folder").tag(true) + Label("平铺会话", systemImage: "list.bullet").tag(false) + }.pickerStyle(.inline) + Divider() + Button("重新识别项目", action: onRefresh) + } label: { + // The section title starts at the icon column, inside the hover fill. + SidebarSectionTitle(title: title, menu: true) + .padding(.horizontal, SidebarMetrics.rowInset) + .frame(height: SidebarMetrics.sectionHeaderHeight).contentShape(Rectangle()) + }.menuStyle(.borderlessButton).menuIndicator(.hidden).fixedSize() + // Drawn behind the menu control, so it does not depend on how AppKit renders the label. + .background { SidebarHighlight(hovered: hovered) } + .onHover { hovered = $0 } + .help("项目视图选项").accessibilityLabel("项目视图选项") + .accessibilityIdentifier("sidebar.layout") + } +} + private struct SidebarPinPicker: View { let model: WorkbenchModel @State private var query = "" @@ -487,7 +515,7 @@ struct SessionDirectoryView: View { let index = model.groupIndex VStack(alignment: .leading, spacing: 16) { HStack { - Text(L(key: isSearchSheet ? "搜索任务" : "全部会话")).font(.title2.weight(.semibold)) + Text(L(key: isSearchSheet ? "搜索项目与会话" : "全部会话")).font(.title2.weight(.semibold)) Spacer() if isSearchSheet { Button("完成") { dismiss() }.keyboardShortcut(.cancelAction) } } @@ -620,7 +648,7 @@ struct SessionActionsMenu: View { Divider() Button("按所属仓库归组") { model.moveSidebarSession(item, to: nil) } } - Button { model.groupingSession = item } label: { Label("管理任务组归属…", systemImage: "folder") } + Button { model.groupingSession = item } label: { Label("管理任务组归属…", systemImage: "square.stack.3d.up") } Button { model.toggleTaskNotifications(item.id) } label: { Label(model.mutedTasks.contains(item.id) ? L("恢复任务通知") : L("静音任务通知"), systemImage: model.mutedTasks.contains(item.id) ? "bell" : "bell.slash") @@ -674,7 +702,7 @@ private struct ConnectionControls: View { HStack { Text("环境与 Agent").foregroundStyle(.secondary); Spacer() Button { dismiss(); model.configureHost() } label: { Image(systemName: "plus").frame(width: 28, height: 28).contentShape(Rectangle()) } - .buttonStyle(.plain).help("添加 SSH 机器").accessibilityLabel("添加 SSH 机器") + .buttonStyle(.plain).help("添加机器…").accessibilityLabel("添加机器…") } Button { dismiss(); model.showLocalSetup = true } label: { Label { Text("本机 Agent…") } icon: { HostIdentityIcon(hostID: ExecutionEnvironment.localHostID) } @@ -719,7 +747,9 @@ private struct ConnectionControls: View { } }.padding(.vertical, 6) } - }.font(.system(size: 11)).padding(15).background(Color.primary.opacity(0.025), in: RoundedRectangle(cornerRadius: 10)).padding(10) + }.font(.system(size: 11)).padding(15) + .background(Color.primary.opacity(WorkbenchTheme.Fill.surface), in: RoundedRectangle(cornerRadius: WorkbenchTheme.Radius.card)) + .padding(10) } @State private var bridgeUpdating = false @State private var bridgeUpdateError: String? @@ -742,7 +772,11 @@ private struct ConnectionControls: View { update: (() -> Void)? = nil, reconnect: @escaping () -> Void) -> some View { VStack(alignment: .leading, spacing: 5) { HStack { - Circle().fill(online ? .green : .orange).frame(width: 5, height: 5) + // Offline uses the one offline symbol; a column keeps the names aligned. + Group { + if online { Circle().fill(.green).frame(width: 5, height: 5) } + else { Image(systemName: "wifi.slash").foregroundStyle(.orange) } + }.frame(width: SidebarMetrics.iconWidth) Text(name); Spacer() Text(LocalizedStringKey(online ? "已连接" : error == nil ? "未连接" : "需要处理")).foregroundStyle(.secondary) if updating { diff --git a/Sources/AgentWorkbench/SessionStatusIndicator.swift b/Sources/AgentWorkbench/SessionStatusIndicator.swift index 5f1ef1fb..fa96ac42 100644 --- a/Sources/AgentWorkbench/SessionStatusIndicator.swift +++ b/Sources/AgentWorkbench/SessionStatusIndicator.swift @@ -6,18 +6,51 @@ struct SidebarAgentIndicator: View { let item: WorkspaceSession var body: some View { - AgentMark(kind: item.reference.kind, size: 13) - .overlay(alignment: .bottomTrailing) { - Group { - if !item.online { - Image(systemName: "minus.circle.fill").foregroundStyle(.secondary) - } else if item.section == .attention { - Image(systemName: "exclamationmark.circle.fill").foregroundStyle(.orange) - } else if item.section == .review { - Circle().fill(.blue).frame(width: 5, height: 5) + let mark = AgentMark(kind: item.reference.kind, size: SidebarMetrics.iconSize) + if let badge = SidebarStatusBadge(item) { + // A gap ring cut out of the mark around the badge, so the badge reads on + // every brand color (several are orange or blue). The cut shows whatever + // is behind the row, the glass or the row highlight; nothing is painted. + mark.mask { + Rectangle().fill(Color.black) + .overlay(alignment: .bottomTrailing) { + badge.background { Capsule().fill(Color.black).padding(-SidebarMetrics.badgeGap) } + .offset(SidebarMetrics.badgeOffset) + .blendMode(.destinationOut) } - }.font(.system(size: 7, weight: .bold)).offset(x: 3, y: 2) + .compositingGroup() } + .overlay(alignment: .bottomTrailing) { badge.offset(SidebarMetrics.badgeOffset) } + } else { + mark + } + } +} + +/// The compact rows' status: offline (the same symbol as the full indicator), +/// waiting for input, or unread. Other states show the agent mark alone. +private struct SidebarStatusBadge: View { + private enum Kind { case offline, attention, review } + private let kind: Kind + + init?(_ item: WorkspaceSession) { + if !item.online { kind = .offline } + else if item.section == .attention { kind = .attention } + else if item.section == .review { kind = .review } + else { return nil } + } + + var body: some View { + Group { + switch kind { + case .offline: + Image(systemName: "wifi.slash").foregroundStyle(.secondary) + case .attention: + Image(systemName: "exclamationmark.circle.fill").foregroundStyle(.orange) + case .review: + Circle().fill(.blue).frame(width: SidebarMetrics.badgeDotSize, height: SidebarMetrics.badgeDotSize) + } + }.font(.system(size: SidebarMetrics.badgeSize, weight: .bold)) } } diff --git a/Sources/AgentWorkbench/SidebarMetrics.swift b/Sources/AgentWorkbench/SidebarMetrics.swift index 19bdd036..785cd99d 100644 --- a/Sources/AgentWorkbench/SidebarMetrics.swift +++ b/Sources/AgentWorkbench/SidebarMetrics.swift @@ -21,7 +21,9 @@ enum SidebarMetrics { static let labelSpacing: CGFloat = 8 static let titleSize: CGFloat = 13 static let detailSize: CGFloat = 11 - static let cornerRadius: CGFloat = 7 + /// Keyboard shortcut hints after a row title (⌘K, ⌘,): one size everywhere. + static let shortcutSize: CGFloat = 11 + static let cornerRadius: CGFloat = WorkbenchTheme.Radius.row /// Project disclosure chevron column and the gap after it. static let disclosureWidth: CGFloat = 12 static let disclosureSpacing: CGFloat = 4 @@ -39,18 +41,49 @@ enum SidebarMetrics { static let sectionBottomSpacing: CGFloat = 2 static let sectionHeight: CGFloat = sectionTopSpacing + sectionHeaderHeight + sectionBottomSpacing + /// Compact status badge past the agent mark's bottom-trailing corner: symbol + /// size, the unread dot, its offset, and the gap ring cut out of the mark so + /// the badge reads on any brand color. + static let badgeSize: CGFloat = 7 + static let badgeDotSize: CGFloat = 5 + static let badgeOffset = CGSize(width: 3, height: 2) + static let badgeGap: CGFloat = 1.25 + + /// Hover fades: row highlights and the actions they reveal. + static let highlightDuration: Double = 0.1 + /// Row highlight strength: pressed > selected > hovered. static func fill(selected: Bool, hovered: Bool, pressed: Bool = false) -> Double { - pressed ? 0.11 : selected ? 0.08 : hovered ? 0.045 : 0 + pressed ? WorkbenchTheme.Fill.pressed + : selected ? WorkbenchTheme.Fill.selected + : hovered ? WorkbenchTheme.Fill.hover : 0 } } -/// A section header title in the shared style, with an optional count and the -/// disclosure chevron after it. +/// The highlight behind every sidebar row and header control: one shape, the +/// three strengths and one fade. It tints the glass; it never replaces it. +struct SidebarHighlight: View { + var selected = false + var hovered = false + var pressed = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + let opacity = SidebarMetrics.fill(selected: selected, hovered: hovered, pressed: pressed) + RoundedRectangle(cornerRadius: SidebarMetrics.cornerRadius).fill(Color.primary.opacity(opacity)) + .animation(reduceMotion ? nil : .easeOut(duration: SidebarMetrics.highlightDuration), value: opacity) + } +} + +/// A section header title in the shared style, with an optional count and, after +/// it, the disclosure chevron of a folding section or the ellipsis of a header +/// that opens a menu. struct SidebarSectionTitle: View { let title: Text var count: Int? = nil var expanded: Bool? = nil + /// The header opens a menu instead of folding its section. + var menu = false var body: some View { HStack(spacing: 4) { title.font(.system(size: SidebarMetrics.sectionTitleSize, weight: .semibold)) @@ -65,6 +98,11 @@ struct SidebarSectionTitle: View { .font(.system(size: 8, weight: .semibold)).foregroundStyle(.tertiary) .accessibilityHidden(true) } + if menu { + Image(systemName: "ellipsis") + .font(.system(size: SidebarMetrics.sectionTitleSize, weight: .semibold)).foregroundStyle(.tertiary) + .accessibilityHidden(true) + } } } } diff --git a/Sources/AgentWorkbench/TaskGroupOverview.swift b/Sources/AgentWorkbench/TaskGroupOverview.swift index 1274e4d5..cc737b6e 100644 --- a/Sources/AgentWorkbench/TaskGroupOverview.swift +++ b/Sources/AgentWorkbench/TaskGroupOverview.swift @@ -105,8 +105,9 @@ private struct GroupRowStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .padding(.horizontal, 8) - .background(Color.primary.opacity(configuration.isPressed ? 0.06 : hovered ? 0.03 : 0), - in: RoundedRectangle(cornerRadius: 8)) + .background(Color.primary.opacity(configuration.isPressed ? WorkbenchTheme.Fill.pressed + : hovered ? WorkbenchTheme.Fill.hover : 0), + in: RoundedRectangle(cornerRadius: WorkbenchTheme.Radius.row)) .padding(.horizontal, -8) .onHover { hovered = $0 } } diff --git a/Sources/AgentWorkbench/WorkbenchApp.swift b/Sources/AgentWorkbench/WorkbenchApp.swift index 4e99c0d3..f079755a 100644 --- a/Sources/AgentWorkbench/WorkbenchApp.swift +++ b/Sources/AgentWorkbench/WorkbenchApp.swift @@ -35,13 +35,13 @@ struct WorkbenchApp: App { } } CommandGroup(replacing: .newItem) { - Button(L("新建任务")) { model.startNewTask() }.keyboardShortcut("n") + Button(L("新建会话")) { model.startNewTask() }.keyboardShortcut("n") Button(L("添加机器…")) { model.configureHost() }.keyboardShortcut("n", modifiers: [.command, .shift]) Button(L("新建远端终端…")) { model.showNewTerminal = true }.keyboardShortcut("t") .disabled(model.selectedConnection?.online != true) } CommandMenu(L("Conversation")) { - Button(L("搜索任务")) { model.showSessionSearch = true }.keyboardShortcut("k") + Button(L("搜索项目与会话")) { model.showSessionSearch = true }.keyboardShortcut("k") Button(L("Find in conversation")) { model.showConversationFind = true NotificationCenter.default.post(name: .init("PerchFocusFind"), object: nil) @@ -56,7 +56,7 @@ struct WorkbenchApp: App { Divider() Button(L("Back")) { model.navigate(-1) }.keyboardShortcut("[", modifiers: .command).disabled(!model.canNavigate(-1)) Button(L("Forward")) { model.navigate(1) }.keyboardShortcut("]", modifiers: .command).disabled(!model.canNavigate(1)) - Button(L("Next task needing attention")) { model.nextAttentionTask() }.keyboardShortcut("j", modifiers: [.command, .shift]) + Button(L("下一个待处理会话")) { model.nextAttentionTask() }.keyboardShortcut("j", modifiers: [.command, .shift]) } CommandGroup(after: .help) { Button(L("终端显示信息…")) { model.inspectRendering() }.disabled(model.selectedTerminal == nil) diff --git a/Sources/AgentWorkbench/WorkbenchDashboard.swift b/Sources/AgentWorkbench/WorkbenchDashboard.swift index 4fe71384..994e3100 100644 --- a/Sources/AgentWorkbench/WorkbenchDashboard.swift +++ b/Sources/AgentWorkbench/WorkbenchDashboard.swift @@ -154,13 +154,14 @@ struct WorkbenchDashboard: View { if changes.count > 5 { Text("其余变化可在下方对应分区查看。").font(.caption).foregroundStyle(.secondary) } }.font(.system(size: 12)) .padding(14).frame(maxWidth: .infinity, alignment: .leading) - .background(Color.primary.opacity(0.025), in: RoundedRectangle(cornerRadius: 12)) + .background(Color.primary.opacity(WorkbenchTheme.Fill.surface), + in: RoundedRectangle(cornerRadius: WorkbenchTheme.Radius.card)) } private var title: LocalizedStringKey { if attentionOnly { return "等你处理" } if let group { return LocalizedStringKey(group.name) } - if projection.emptyState == .noEnvironment || projection.emptyState == .noSessions { return "开始第一段会话" } + if projection.emptyState == .noEnvironment || projection.emptyState == .noSessions { return "开始首个会话" } return "工作台" } @@ -202,7 +203,7 @@ struct WorkbenchDashboard: View { Text(facet.name).font(.system(size: 11)).lineLimit(1).truncationMode(.middle) Image(systemName: "xmark.circle.fill").font(.system(size: 10)).foregroundStyle(.tertiary) }.padding(.horizontal, 9).frame(minHeight: 28) - .background(Color.primary.opacity(0.05), in: Capsule()).contentShape(Capsule()) + .background(Color.primary.opacity(WorkbenchTheme.Fill.surface), in: Capsule()).contentShape(Capsule()) }.buttonStyle(.plain) .help("取消筛选:\(facet.name)").accessibilityLabel("取消筛选:\(facet.name)") } @@ -278,7 +279,7 @@ struct WorkbenchDashboard: View { Text(LocalizedStringKey(state.title)).font(.system(size: 15, weight: .medium)) if state == .noEnvironment { HStack(spacing: 10) { - Button("连接远程机器…", action: onConnectRemote).buttonStyle(.borderedProminent) + Button("添加机器…", action: onConnectRemote).buttonStyle(.borderedProminent) .tint(WorkbenchTheme.accent).foregroundStyle(WorkbenchTheme.actionGlyph) Button("检测本机 Agent…", action: onStartLocal) }.padding(.top, 4) @@ -349,7 +350,7 @@ struct WorkbenchDashboard: View { .font(.system(size: 12)).foregroundStyle(.orange).textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) .padding(14).frame(maxWidth: .infinity, alignment: .leading) - .background(Color.orange.opacity(0.08), in: RoundedRectangle(cornerRadius: 10)) + .background(Color.orange.opacity(0.08), in: RoundedRectangle(cornerRadius: WorkbenchTheme.Radius.card)) } private func row(_ item: WorkspaceSession, in section: WorkQueueSection) -> some View { @@ -381,7 +382,12 @@ struct QueueRow: View { let onMarkReviewed: () -> Void let actions: Actions @State private var hovered = false + /// The row's own target and each control on it. Focus on any of them shows the + /// hover actions, so the keyboard reaches 已查看 and 管理归属 as the pointer does. + private enum Focus { case open, group, otherGroups, manage, reviewed } + @FocusState private var focus: Focus? + private var showActions: Bool { hovered || focus != nil } private var groupBadge: String? { SessionGroupIndex.label(groups.map(\.name)) } private var tooltip: String { var lines = [item.title] @@ -398,9 +404,10 @@ struct QueueRow: View { HStack(alignment: .top, spacing: 12) { SessionStatusIndicator(item: item).frame(width: 17, height: 16).padding(.top, 2).accessibilityHidden(true) VStack(alignment: .leading, spacing: 4) { - Text(item.title).font(.system(size: 13, weight: .medium)).lineLimit(2) + // The row's button reads the title and metadata, so the text is not read twice. + Text(item.title).font(.system(size: 13, weight: .medium)).lineLimit(2).accessibilityHidden(true) HStack(spacing: 6) { - Text(metadata).lineLimit(1).truncationMode(.middle).layoutPriority(-1) + Text(metadata).lineLimit(1).truncationMode(.middle).layoutPriority(-1).accessibilityHidden(true) membershipControls }.font(.system(size: 11)).foregroundStyle(.secondary) }.frame(maxWidth: .infinity, alignment: .leading) @@ -409,42 +416,84 @@ struct QueueRow: View { .foregroundStyle(waiting ? AnyShapeStyle(.orange) : AnyShapeStyle(.tertiary)) .fixedSize() .padding(.top, 2) - .opacity(hovered && showsHoverAction ? 0 : 1) + .opacity(showActions && showsHoverAction ? 0 : 1) + .accessibilityHidden(true) } if showsHoverAction { Button("已查看", action: onMarkReviewed) .font(.system(size: 11)) .padding(.horizontal, 8).padding(.vertical, 2) - .background(Color.primary.opacity(0.06), in: Capsule()) + .background(Color.primary.opacity(WorkbenchTheme.Fill.surface), in: Capsule()) .padding(.top, 1).fixedSize() - .opacity(hovered ? 1 : 0) + .focused($focus, equals: .reviewed) + .opacity(showActions ? 1 : 0) } }.padding(.horizontal, 10).padding(.vertical, 11) .opacity(item.online ? 1 : 0.55) .contentShape(Rectangle()) - .onTapGesture { if item.online { onOpen() } } - .background(hovered && item.online ? Color.primary.opacity(0.035) : .clear, in: RoundedRectangle(cornerRadius: 8)) + .onTapGesture { openIfOnline() } + .background { openTarget } .overlay(alignment: .bottom) { Divider().padding(.leading, 39).opacity(0.5) } .onHover { hovered = $0 } - .contextMenu { actions } + .contextMenu { + if showsHoverAction { + Button("标为已查看", action: onMarkReviewed) + Divider() + } + actions + } .help(tooltip) .accessibilityElement(children: .contain) - .accessibilityAction(named: Text("打开会话")) { if item.online { onOpen() } } } private var showsHoverAction: Bool { item.canMarkReviewed && item.online } + private func openIfOnline() { if item.online { onOpen() } } + + /// Keyboard focus reads as selection, never as a hover. + private var highlight: Double { + guard item.online else { return 0 } + if focus == .open { return WorkbenchTheme.Fill.selected } + return hovered ? WorkbenchTheme.Fill.hover : 0 + } + + private var accessibilityDetail: String { + [metadata, time ?? ""].filter { !$0.isEmpty }.joined(separator: " · ") + } + + /// The row's button, drawn behind the content so the membership controls stay + /// separate targets. A plain Button joins the key view loop only with Keyboard + /// Navigation on; this target is focusable either way, Return or Space opens it, + /// and VoiceOver reads it as a button. The pointer opens the row through the tap above. + private var openTarget: some View { + RoundedRectangle(cornerRadius: WorkbenchTheme.Radius.row) + .fill(Color.primary.opacity(highlight)) + .focusable(item.online) + .focused($focus, equals: .open) + .focusEffectDisabled() + .onKeyPress(.return) { openIfOnline(); return .handled } + .onKeyPress(.space) { openIfOnline(); return .handled } + .accessibilityElement() + .accessibilityLabel(item.title) + .accessibilityValue(accessibilityDetail) + .accessibilityAddTraits(.isButton) + .accessibilityAction { openIfOnline() } + .modifier(MarkReviewedAccessibilityAction(isAvailable: showsHoverAction, action: onMarkReviewed)) + .disabled(!item.online) + } + /// Group membership rides the metadata line instead of a full-width third row; /// every row still states its group, and ungrouped rows keep a visible action. @ViewBuilder private var membershipControls: some View { if let first = groups.first { Button { onOpenGroup(first.id) } label: { HStack(spacing: 3) { - Image(systemName: "folder").font(.system(size: 9)).accessibilityHidden(true) + Image(systemName: "square.stack.3d.up").font(.system(size: 9)).accessibilityHidden(true) Text(first.name).lineLimit(1).truncationMode(.middle).frame(maxWidth: 140) } }.buttonStyle(QuietLinkStyle()).fixedSize().help(first.name) .accessibilityLabel("打开任务组:\(first.name)") + .focused($focus, equals: .group) if groups.count > 1 { Menu { ForEach(groups.dropFirst()) { group in @@ -452,18 +501,35 @@ struct QueueRow: View { } } label: { Text("另 \(groups.count - 1) 组") } .menuStyle(.borderlessButton).fixedSize() + .focused($focus, equals: .otherGroups) } Button("管理归属", action: onManageGroups).buttonStyle(QuietLinkStyle()).fixedSize() - .opacity(hovered ? 1 : 0) + .focused($focus, equals: .manage) + .opacity(showActions ? 1 : 0) } else { Text("未归组").foregroundStyle(.tertiary) - Text("·").foregroundStyle(.quaternary) + Text("·").foregroundStyle(.quaternary).accessibilityHidden(true) Button("关联任务组", action: onManageGroups).buttonStyle(QuietLinkStyle()).fixedSize() + .focused($focus, equals: .manage) } } } +/// 已查看 is a hover action on screen; VoiceOver gets it as a named action of the row's button. +private struct MarkReviewedAccessibilityAction: ViewModifier { + let isAvailable: Bool + let action: () -> Void + + @ViewBuilder func body(content: Content) -> some View { + if isAvailable { + content.accessibilityAction(named: Text("标为已查看"), action) + } else { + content + } + } +} + /// The one actionable stat is a button; the capsule only deepens on hover. private struct SummaryStatStyle: ButtonStyle { @State private var hovered = false @@ -495,7 +561,7 @@ private extension DashboardEmptyState { var symbol: String { switch self { case .noEnvironment: return "server.rack" - case .noSessions: return "plus.message" + case .noSessions: return "square.and.pencil" case .nothingPending: return "checkmark.circle" case .noMatches: return "line.3.horizontal.decrease.circle" } @@ -508,8 +574,9 @@ private struct ChangeRowStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .padding(.horizontal, 6).padding(.vertical, 1) - .background(Color.primary.opacity(configuration.isPressed ? 0.07 : hovered ? 0.04 : 0), - in: RoundedRectangle(cornerRadius: 6)) + .background(Color.primary.opacity(configuration.isPressed ? WorkbenchTheme.Fill.pressed + : hovered ? WorkbenchTheme.Fill.hover : 0), + in: RoundedRectangle(cornerRadius: WorkbenchTheme.Radius.row)) .onHover { hovered = $0 } } } diff --git a/Sources/AgentWorkbench/WorkbenchGlass.swift b/Sources/AgentWorkbench/WorkbenchGlass.swift index 6ca57b13..6ffac134 100644 --- a/Sources/AgentWorkbench/WorkbenchGlass.swift +++ b/Sources/AgentWorkbench/WorkbenchGlass.swift @@ -129,19 +129,28 @@ struct WorkbenchDisclosureContent: View { } } struct WorkbenchDisclosureButtonStyle: ButtonStyle { + /// The keyboard highlight of a list built from these buttons. A press + /// outranks it, and it outranks hover, so one row never shows two fills. + var selected = false + func makeBody(configuration: Configuration) -> some View { - DisclosureButtonBody(configuration: configuration) + DisclosureButtonBody(configuration: configuration, selected: selected) } private struct DisclosureButtonBody: View { let configuration: ButtonStyle.Configuration + let selected: Bool @State private var hovered = false var body: some View { configuration.label - .background(Color.primary.opacity(configuration.isPressed ? 0.05 : hovered ? 0.025 : 0), - in: RoundedRectangle(cornerRadius: 6)) + .background(Color.primary.opacity(wash), in: RoundedRectangle(cornerRadius: 6)) .onHover { hovered = $0 } } + private var wash: Double { + configuration.isPressed ? WorkbenchTheme.Fill.pressed + : selected ? WorkbenchTheme.Fill.selected + : hovered ? WorkbenchTheme.Fill.hover : 0 + } } } diff --git a/Sources/AgentWorkbench/WorkbenchHeader.swift b/Sources/AgentWorkbench/WorkbenchHeader.swift index 5b4099aa..bbbb6f27 100644 --- a/Sources/AgentWorkbench/WorkbenchHeader.swift +++ b/Sources/AgentWorkbench/WorkbenchHeader.swift @@ -19,7 +19,7 @@ struct WorkbenchHeader: View { // The same symbols as the sidebar destinations. Image(systemName: model.showArchived ? "archivebox" : model.onlyAttention ? "tray" : model.showSessionDirectory ? "bubble.left.and.bubble.right" - : model.selectedGroup != nil ? "folder" : model.showAllTaskGroups ? "square.stack.3d.up" : "square.grid.2x2") + : model.selectedGroup != nil || model.showAllTaskGroups ? "square.stack.3d.up" : "square.grid.2x2") .font(.system(size: WorkbenchChrome.symbolSize, weight: .regular)).imageScale(.medium).foregroundStyle(.secondary) } }.frame(width: WorkbenchChrome.sidebarSymbolWidth) @@ -136,7 +136,7 @@ struct WorkbenchHeaderActions: View { .buttonStyle(.plain).help("编辑任务组").accessibilityLabel("编辑任务组") } Menu { - Button("原生对话…") { model.startNewTask() } + Button("会话…") { model.startNewTask() } Button("远端终端…") { model.showNewTerminal = true }.disabled(model.selectedConnection?.online != true) Button("任务组…") { model.editGroup() } } label: { WorkbenchToolbarSymbol(name: "plus").workbenchControlSurface() }.menuStyle(.borderlessButton).menuIndicator(.hidden).fixedSize() diff --git a/Sources/AgentWorkbench/WorkbenchModel.swift b/Sources/AgentWorkbench/WorkbenchModel.swift index a1bf3fa0..96ba8e32 100644 --- a/Sources/AgentWorkbench/WorkbenchModel.swift +++ b/Sources/AgentWorkbench/WorkbenchModel.swift @@ -968,7 +968,7 @@ final class WorkbenchModel { result.retryFailures(); executeBatchArchive(result) } private func archiveRequest(_ reference: SessionReference, archived: Bool = true) async throws { - guard let kimi = kimiEnvironments[reference.hostID], let native = nativeEnvironments[reference.hostID] else { throw WorkbenchError(L("The task environment is unavailable")) } + guard let kimi = kimiEnvironments[reference.hostID], let native = nativeEnvironments[reference.hostID] else { throw WorkbenchError(L("当前会话没有可用的执行环境。")) } if reference.kind == .kimi { try await kimi.setArchived(reference.terminalID, archived: archived, refresh: false) } else if [.omp, .qoder, .qoderintl, .dsh, .codex, .claude].contains(reference.kind) { try await native.setArchived(reference.terminalID, archived: archived) diff --git a/Sources/AgentWorkbench/WorkbenchSettings.swift b/Sources/AgentWorkbench/WorkbenchSettings.swift index f159e7b6..9e0c7247 100644 --- a/Sources/AgentWorkbench/WorkbenchSettings.swift +++ b/Sources/AgentWorkbench/WorkbenchSettings.swift @@ -43,7 +43,7 @@ struct WorkbenchSettings: View { } } Button { editingHost = nil; showSSH = true } label: { - Label("添加 SSH 环境…", systemImage: "plus") + Label("添加机器…", systemImage: "plus") .padding(.horizontal, 6).padding(.vertical, 4) .contentShape(Rectangle()) } @@ -191,7 +191,7 @@ private struct SettingsSectionHeader: View { .font(.system(size: 10, weight: .semibold)) .foregroundStyle(.white) .frame(width: 20, height: 20) - .background(tint.gradient, in: RoundedRectangle(cornerRadius: 5, style: .continuous)) + .background(tint.gradient, in: RoundedRectangle(cornerRadius: WorkbenchTheme.Radius.chip, style: .continuous)) } } } @@ -297,8 +297,8 @@ private struct SettingsRow: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .background(Color.primary.opacity(hovered ? 0.05 : 0), - in: RoundedRectangle(cornerRadius: 6, style: .continuous)) + .background(Color.primary.opacity(hovered ? WorkbenchTheme.Fill.hover : 0), + in: RoundedRectangle(cornerRadius: WorkbenchTheme.Radius.row, style: .continuous)) .onHover { hovered = $0 } .accessibilityElement(children: .combine) } diff --git a/Sources/AgentWorkbench/WorkbenchTheme.swift b/Sources/AgentWorkbench/WorkbenchTheme.swift index c6b9485f..ad25ca60 100644 --- a/Sources/AgentWorkbench/WorkbenchTheme.swift +++ b/Sources/AgentWorkbench/WorkbenchTheme.swift @@ -54,6 +54,28 @@ extension WorkbenchTheme { enum Dialog { static let padding: CGFloat = 24 } } +extension WorkbenchTheme { + /// Row and control highlights, at the sidebar's strengths: pressed > selected > hovered. + /// Keyboard selection is `selected`, so it never reads as a hover. + enum Fill { + static let hover: Double = 0.045 + static let selected: Double = 0.08 + static let pressed: Double = 0.11 + /// A grouped, static surface behind content (cards, chips). + static let surface: Double = 0.035 + } + enum Stroke { + /// Hairline borders of panels and cards. + static let hairline: Double = 0.08 + } + /// Corner radii of highlights and containers. Glass surfaces and reply blocks keep their own. + enum Radius { + static let row: CGFloat = 7 + static let chip: CGFloat = 5 + static let card: CGFloat = 10 + } +} + /// Appearance override from settings; `system` leaves the scheme to macOS. enum AppAppearance: String, CaseIterable, Identifiable { case system diff --git a/Sources/AgentWorkbench/WorkspaceSidebarShell.swift b/Sources/AgentWorkbench/WorkspaceSidebarShell.swift index 6a79ae26..8a0992c7 100644 --- a/Sources/AgentWorkbench/WorkspaceSidebarShell.swift +++ b/Sources/AgentWorkbench/WorkspaceSidebarShell.swift @@ -16,6 +16,8 @@ struct WorkspaceSidebarShell: View { let onArchive: () -> Void var onAllSessions: (() -> Void)? = nil var onGroups: (() -> Void)? = nil + /// Set while no machine is configured: the footer then offers the add-machine sheet. + var onAddMachine: (() -> Void)? = nil var scrollRows = true var scrollTargetID: String? = nil @ViewBuilder let rows: Rows @@ -30,7 +32,7 @@ struct WorkspaceSidebarShell: View { HStack(spacing: 4) { // A field-like target: tinted, never bordered, same height as rows. navigation("搜索项目与会话", symbol: "magnifyingglass", shortcut: "⌘K", action: onSearch) - .background(.primary.opacity(0.05), in: RoundedRectangle(cornerRadius: SidebarMetrics.cornerRadius)) + .background(.primary.opacity(WorkbenchTheme.Fill.surface), in: RoundedRectangle(cornerRadius: SidebarMetrics.cornerRadius)) .accessibilityIdentifier("sidebar.search") Button(action: onNew) { Image(systemName: "square.and.pencil") @@ -68,6 +70,11 @@ struct WorkspaceSidebarShell: View { .accessibilityIdentifier("sidebar.groups") } Divider().padding(.vertical, 6) + if let onAddMachine { + // First run: adding a machine is one visible row, not only ⚙ → 环境管理 → +. + navigation("添加机器…", symbol: "server.rack", shortcut: "⇧⌘N", action: onAddMachine) + .accessibilityIdentifier("sidebar.addMachine") + } Button { showEnvironments = false showSettings.toggle() @@ -80,7 +87,7 @@ struct WorkspaceSidebarShell: View { Circle().fill(.orange).frame(width: 6, height: 6) } } - Text("设置与环境"); Spacer(); Text("⌘,").foregroundStyle(.tertiary) + Text("设置与环境"); Spacer(); shortcutHint("⌘,") }.padding(.horizontal, SidebarMetrics.rowInset).frame(height: SidebarMetrics.rowHeight).contentShape(Rectangle()) }.buttonStyle(SidebarNavigationStyle()) .accessibilityIdentifier("sidebar.settings") @@ -90,7 +97,7 @@ struct WorkspaceSidebarShell: View { environments } else { VStack(spacing: 3) { - navigation("环境管理", symbol: "desktopcomputer") { showEnvironments = true } + navigation("环境管理", symbol: "server.rack") { showEnvironments = true } .help(environmentSummary) if environmentNeedsAttention { Text("环境需要处理").font(.caption).foregroundStyle(.orange) @@ -123,12 +130,15 @@ struct WorkspaceSidebarShell: View { Text("\(count)").font(.system(size: SidebarMetrics.detailSize, weight: .medium)).monospacedDigit() .foregroundStyle(.secondary) } - if let shortcut { - Text(shortcut).font(.system(size: SidebarMetrics.detailSize)).foregroundStyle(.tertiary).fixedSize() - } + if let shortcut { shortcutHint(shortcut) } }.padding(.horizontal, SidebarMetrics.rowInset).frame(height: SidebarMetrics.rowHeight).contentShape(Rectangle()) }.buttonStyle(SidebarNavigationStyle(selected: selected)).accessibilityAddTraits(selected ? .isSelected : []) } + + /// ⌘K, ⌘, and the like: verbatim, one size and tone in every row. + private func shortcutHint(_ shortcut: String) -> some View { + Text(shortcut).font(.system(size: SidebarMetrics.shortcutSize)).foregroundStyle(.tertiary).fixedSize() + } } /// The header and row list have separate lifetimes; collapse never navigates. @@ -139,10 +149,13 @@ struct SidebarProjectHeader: View { let onToggle: () -> Void let onNew: () -> Void @State private var hovered = false + @State private var pressed = false private enum Focus { case toggle, create } @FocusState private var focused: Focus? var body: some View { + // One highlight spans the whole row, the count / + column included, like the + // other rows. + keeps its own hit target and hover inside it. HStack(spacing: 0) { Button(action: onToggle) { // Chevron, then the icon in the column its sessions use below it. @@ -166,12 +179,12 @@ struct SidebarProjectHeader: View { }.padding(.leading, SidebarMetrics.rowInset) .frame(height: project.disambiguate ? SidebarMetrics.subtitleRowHeight : SidebarMetrics.rowHeight) .contentShape(Rectangle()) - }.buttonStyle(SidebarNavigationStyle()).focused($focused, equals: .toggle) + }.buttonStyle(SidebarPressStyle(pressed: $pressed)).focused($focused, equals: .toggle) .help(project.detail ?? project.title) .accessibilityLabel(project.title) .accessibilityValue(expanded ? Text("已展开") : Text("已收起")) .accessibilityIdentifier("sidebar.project.\(project.id)") - .contextMenu { Button("新建会话", action: onNew).disabled(!canCreate) } + .contextMenu { Button("在此项目新建会话", action: onNew).disabled(!canCreate) } ZStack { Text("\(project.sessions.count)").font(.system(size: SidebarMetrics.detailSize)).monospacedDigit() .foregroundStyle(.tertiary) @@ -185,7 +198,18 @@ struct SidebarProjectHeader: View { .help("在此项目新建会话").accessibilityLabel("在此项目新建会话") .opacity(hovered || focused != nil ? 1 : 0).allowsHitTesting(hovered || focused != nil) }.frame(width: 28) - }.padding(.top, 2).onHover { hovered = $0 } + }.background { SidebarHighlight(hovered: hovered, pressed: pressed) } + .contentShape(Rectangle()).onHover { hovered = $0 } + .padding(.top, 2) + } +} + +/// The main button of a row that draws its own full-width highlight: it reports +/// the press to the row instead of drawing a background of its own. +private struct SidebarPressStyle: ButtonStyle { + @Binding var pressed: Bool + func makeBody(configuration: Configuration) -> some View { + configuration.label.onChange(of: configuration.isPressed) { _, isPressed in pressed = isPressed } } } @@ -240,16 +264,9 @@ private struct SidebarNavigationBody: View { let configuration: ButtonStyleConfiguration let selected: Bool @State private var hovered = false - @Environment(\.accessibilityReduceMotion) private var reduceMotion - private var opacity: Double { - SidebarMetrics.fill(selected: selected, hovered: hovered, pressed: configuration.isPressed) - } var body: some View { configuration.label.contentShape(Rectangle()) - .background { - RoundedRectangle(cornerRadius: SidebarMetrics.cornerRadius).fill(Color.primary.opacity(opacity)) - .animation(reduceMotion ? nil : .easeOut(duration: 0.1), value: opacity) - } + .background { SidebarHighlight(selected: selected, hovered: hovered, pressed: configuration.isPressed) } .onHover { hovered = $0 } } } diff --git a/Sources/AgentWorkbench/WorkspaceSplitView.swift b/Sources/AgentWorkbench/WorkspaceSplitView.swift index ed9ca70f..b9a8f625 100644 --- a/Sources/AgentWorkbench/WorkspaceSplitView.swift +++ b/Sources/AgentWorkbench/WorkspaceSplitView.swift @@ -170,8 +170,8 @@ struct WorkspaceSplitView "$app/Contents/Info.plist" <<'PLIST'