diff --git a/desktop/frontend-next/src/actions.ts b/desktop/frontend-next/src/actions.ts index d5d6954a11..f604263407 100644 --- a/desktop/frontend-next/src/actions.ts +++ b/desktop/frontend-next/src/actions.ts @@ -190,6 +190,9 @@ export const ACTIONS: UIAction[] = [ { id: "image.open", kind: "view", target: "none", proof: "interaction" }, { id: "image.close", kind: "view", target: "none", proof: "interaction" }, + { id: "image.zoom", kind: "view", target: "none", proof: "interaction" }, + { id: "image.pan", kind: "view", target: "none", proof: "interaction" }, + { id: "image.download", kind: "view", target: "none", proof: "interaction" }, { id: "image.focus-trap", kind: "navigation", target: "none", proof: "interaction" }, { id: "tool.show-all", kind: "view", target: "none", proof: "interaction" }, { id: "tool.show-full", kind: "view", target: "none", proof: "interaction" }, @@ -307,6 +310,7 @@ export const ACTIONS: UIAction[] = [ { id: "side.collapse", kind: "view", target: "none", proof: "interaction" }, { id: "side.toggle", kind: "view", target: "none", proof: "interaction" }, { id: "side.quote", kind: "view", target: "none", proof: "interaction" }, + { id: "side.quote-answer", kind: "view", target: "none", proof: "interaction" }, { id: "side.unquote", kind: "view", target: "none", proof: "interaction" }, { id: "side.draft", kind: "view", target: "none", proof: "interaction" }, { id: "side.send", kind: "kernel-mutation", target: "none", proof: "interaction" }, diff --git a/desktop/frontend-next/src/design/composer.css b/desktop/frontend-next/src/design/composer.css index b998e7dbba..863422c0d0 100644 --- a/desktop/frontend-next/src/design/composer.css +++ b/desktop/frontend-next/src/design/composer.css @@ -41,6 +41,12 @@ .compose :is(.mode, .studio-branch, .model-picker):hover:not(:disabled) { background: var(--overlay); color: var(--text); border-color: transparent; } .compose :is(.mode, .studio-branch, .model-picker)[aria-expanded="true"] { background: var(--overlay); color: var(--text); } .compose :is(.mode, .studio-branch, .model-picker):focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } +.compose .compose-context { box-sizing: border-box; min-height: 34px; margin: 0; padding: 6px 0 0 12px; } +.compose .compose-context > .studio-runstate { min-height: 0; padding: 0 12px 0 0; } +.compose .mode.composition-trigger { height: 24px; min-height: 24px; border-radius: var(--r-sm); color: var(--text); font-weight: var(--w-med); } +.compose .mode.composition-trigger:is(:hover, [aria-expanded="true"]):not(:disabled) { border-color: var(--border); } +.compose .mode.composition-trigger.locked { color: var(--muted); } +.compose .mode.composition-trigger.unavailable { border-color: var(--border); color: var(--warn); } .compose :is(.studio-mode-picker, .studio-branch, .policy .mode) > :is(svg, .studio-icon, .ic):first-child { width: 14px; height: 14px; color: inherit; } .compose :is(.studio-mode-picker, .studio-branch, .policy .mode, .model-picker) > :is(svg, .studio-icon):last-child { width: 11px; height: 11px; color: var(--faint); } .compose .policy .mode .ic, .compose .policy .mode .ic svg { width: 14px; height: 14px; } @@ -90,6 +96,6 @@ @container conversation (max-width: 420px) { .compose .studio-effort-reading, .compose .studio-model-sep { display: none; } } -.compose .studio-model-group { flex: none; } +.compose .studio-model-group { flex: none; margin-left: auto; } .compose .model-picker .nm { flex: none; max-width: none; } .compose .studio-model-control { flex: none; min-width: 0; } diff --git a/desktop/frontend-next/src/i18n/en.ts b/desktop/frontend-next/src/i18n/en.ts index 8dfd02c913..293ca7a983 100644 --- a/desktop/frontend-next/src/i18n/en.ts +++ b/desktop/frontend-next/src/i18n/en.ts @@ -1215,7 +1215,7 @@ export const EN: Record = { "独立上下文 · 不进主轨迹": "Its own context · stays out of the main trajectory", "计划已移入右栏": "The plan is in the right rail", "收工检查": "Wrap-up check", - "查看其后续使用的简报": "See the digest it carries forward", + "查看其后续使用的简报": "See the archive note", "操作已完成,但会话刷新失败": "The operation completed, but the conversation could not refresh", "重试刷新会话": "Retry session refresh", "正在还原…": "Restoring…", @@ -1651,7 +1651,7 @@ export const EN: Record = { "立即压缩": "Compact now", "确认压缩": "Confirm compaction", "压缩当前上下文?": "Compact the current context?", - "模型将依据摘要继续工作;完整对话记录会保留。": "The model will continue from a summary. The full conversation will be preserved.", + "较早的内容会归档成文件,模型自己读回需要的部分;完整对话记录会保留。": "Earlier content is archived to a file the model reads back as needed. The full conversation will be preserved.", "查看本轮费用": "View turn cost", "查看钱包余额": "View wallet balance", "余额不足": "Insufficient balance", diff --git a/desktop/frontend-next/src/i18n/en_settings.ts b/desktop/frontend-next/src/i18n/en_settings.ts index 54f4e1ad93..d4cd3ce025 100644 --- a/desktop/frontend-next/src/i18n/en_settings.ts +++ b/desktop/frontend-next/src/i18n/en_settings.ts @@ -731,7 +731,7 @@ export const EN_SETTINGS: Record = { "拒绝提供": "Decline", "(补写过一次)": " (repaired once)", "(缺失部分由主机补全)": " (gaps kept by the host)", - "正在折叠为简报": "Folding into a digest", + "正在归档较早的内容": "Archiving earlier content", "本次未折叠任何内容": "This pass folded nothing", "扩展拒绝了这次折叠": "An extension refused this fold", "折叠结果没能保存": "The folded result could not be saved", @@ -788,7 +788,7 @@ export const EN_SETTINGS: Record = { "本段的 {n} 处改动均已写入简报": "All {n} changes made here are in the digest", "{kept}/{required} 处改动写进了简报": "{kept}/{required} changes are in the digest", "另有 {n} 处仅保留索引地址,需通过 recall 取回原文": "{n} more are index entries only; recall fetches the original", - "正在将 {n} 条消息折叠为简报": "Folding {n} messages into a digest", + "正在将 {n} 条消息归档": "Archiving {n} messages", "折叠了 {n} 条消息": "Folded {n} messages", // ── 首次连接 ───────────────────────────────────────────────────── @@ -1155,8 +1155,8 @@ export const EN_SETTINGS: Record = { "每一步的参数、输出与文件差异": "Each step's arguments, output and file diff", "长输出": "Long output", "超出一屏的命令输出与文件内容": "Command output and file contents longer than a screen", - "压缩简报": "Compaction brief", - "上下文压缩后,模型接下来依据的那份简报": "The brief the model works from after context is compacted", + "压缩归档": "Compaction archive", + "上下文压缩后,模型读回较早内容的位置和说明": "Where the model reads earlier content back from after compaction", "收起时预览思考开头": "Preview the beginning when collapsed", "在思考标题下方显示一行开头文字;思考进行中也显示,展开后隐藏。": "Show one line of opening text below the thinking header, including while thinking is in progress. Hidden when expanded.", "先检查配置,再决定改哪里。": "Check the configuration first, then decide what to change.", diff --git a/desktop/frontend-next/src/i18n/msg/app.ts b/desktop/frontend-next/src/i18n/msg/app.ts index 2c23e36d4b..6408392b9b 100644 --- a/desktop/frontend-next/src/i18n/msg/app.ts +++ b/desktop/frontend-next/src/i18n/msg/app.ts @@ -81,6 +81,7 @@ export const APP: Messages = { "app.pick_one_on_the_left": { zh: "从左栏选择,或在当前文件夹新建", en: "Pick one on the left, or start a new one in this folder" }, "app.point_at_an_executable": { zh: "指定一个可执行文件", en: "Point at an executable" }, "app.preparing_workspace": { zh: "准备工作目录", en: "Preparing workspace" }, + "app.project_folder_not_found": { zh: "找不到项目文件夹", en: "Project folder not found" }, "app.queued": { zh: "插话待送达", en: "Queued" }, "app.quick_actions": { zh: "快捷操作", en: "Quick actions" }, "app.recently_used": { zh: "最近用过", en: "Recently used" }, diff --git a/desktop/frontend-next/src/i18n/msg/chat.ts b/desktop/frontend-next/src/i18n/msg/chat.ts index 6556dcea37..7c17377163 100644 --- a/desktop/frontend-next/src/i18n/msg/chat.ts +++ b/desktop/frontend-next/src/i18n/msg/chat.ts @@ -91,7 +91,7 @@ export const CHAT: Messages = { "chat.commands": { zh: "命令", en: "commands" }, "chat.compact": { zh: "精简", en: "Compact" }, "chat.compact_context": { zh: "压缩上下文", en: "Compact context" }, - "chat.compaction_brief": { zh: "压缩简报", en: "Compaction brief" }, + "chat.compaction_brief": { zh: "压缩归档", en: "Compaction archive" }, "chat.compaction_did_not_complete": { zh: "压缩未完成", en: "Compaction did not complete" }, "chat.compacts_at_p_of_the": { zh: "模型窗口的 {p}% 自动整理。中转站未提供容量时使用 160k,可在上下文面板改为实际值。", en: "Compacts at {p}% of the model window. Gateways without capacity metadata use 160k; set the actual value in the context panel." }, "chat.compare_choose": { zh: "对比与选择", en: "Compare & choose" }, @@ -158,11 +158,20 @@ export const CHAT: Messages = { "chat.find_and_change_your_own": { zh: "查找并修改你自己的设置;改动前会先征得你同意。", en: "Find and change your own settings; a change is proposed to you first." }, "chat.find_files_by_glob_pattern": { zh: "按通配符模式查找文件,比如 *.go 或 **/*.test.ts。", en: "Find files by glob pattern, such as *.go or **/*.test.ts." }, "chat.find_files_n": { zh: "查找文件 ×{n}", en: "Find files ×{n}" }, + "chat.find_scope_all": { zh: "全部", en: "All" }, + "chat.find_scope_label": { zh: "查找范围", en: "Search scope" }, + "chat.find_scope_reply": { zh: "回复", en: "Replies" }, + "chat.find_scope_tool": { zh: "工具输出", en: "Tool output" }, + "chat.find_scope_user": { zh: "我的消息", en: "My messages" }, + "chat.find_searched_everything": { zh: "已经找遍全部历史", en: "Searched the whole history" }, + "chat.find_try_all_scope": { zh: "试试把范围改成「全部」", en: "try setting the scope to All" }, + "chat.find_try_without_case": { zh: "试试关掉「区分大小写」", en: "try turning off Match case" }, + "chat.find_try_without_regex": { zh: "试试关掉正则", en: "try turning off regular expressions" }, "chat.fit_width": { zh: "适应宽度", en: "Fit width" }, "chat.folded_n_messages": { zh: "折叠了 {n} 条消息", en: "Folded {n} messages" }, "chat.folder": { zh: "目录", en: "folder" }, - "chat.folding_into_a_digest": { zh: "正在折叠为简报", en: "Folding into a digest" }, - "chat.folding_n_messages_into_a": { zh: "正在将 {n} 条消息折叠为简报", en: "Folding {n} messages into a digest" }, + "chat.folding_into_a_digest": { zh: "正在归档较早的内容", en: "Archiving earlier content" }, + "chat.folding_n_messages_into_a": { zh: "正在将 {n} 条消息归档", en: "Archiving {n} messages" }, "chat.folding_preset": { zh: "折叠预设", en: "Folding preset" }, "chat.forget": { zh: "忘记", en: "Forget" }, "chat.forgetting": { zh: "忘掉中…", en: "Forgetting…" }, @@ -183,6 +192,8 @@ export const CHAT: Messages = { "chat.hide_data": { zh: "收起数据", en: "Hide data" }, "chat.history_could_not_be_loaded": { zh: "历史加载失败 · 重试", en: "History could not be loaded · Retry" }, "chat.hover_or_focus_the_chart": { zh: "悬停或聚焦图表以查看数值", en: "Hover or focus the chart to see values" }, + "chat.image_download": { zh: "下载图片", en: "Download image" }, + "chat.image_preview_hint": { zh: "滚轮缩放 · 拖动平移 · Esc 关闭", en: "Scroll to zoom · drag to pan · Esc to close" }, "chat.image_source": { zh: "图片来源", en: "Image source" }, "chat.image_unavailable": { zh: "图片不可用", en: "Image unavailable" }, "chat.image_unavailable_2": { zh: "图片暂不可用", en: "Image unavailable" }, @@ -368,7 +379,7 @@ export const CHAT: Messages = { "chat.searching_conversation_text": { zh: "正在搜索对话内容…", en: "Searching conversation text…" }, "chat.searching_earlier_messages": { zh: "正在查找较早消息…", en: "Searching earlier messages…" }, "chat.security_review_the_current_branchs": { zh: "在隔离的子代理里对当前分支的改动做安全审查,按严重程度标注;只读。", en: "Security-review the current branch's changes in an isolated sub-agent, tagged by severity; read-only." }, - "chat.see_the_digest_it_carries": { zh: "查看简报", en: "See the digest it carries forward" }, + "chat.see_the_digest_it_carries": { zh: "查看归档说明", en: "See the archive note" }, "chat.select": { zh: "选择", en: "Select" }, "chat.select_name": { zh: "选择 {name}", en: "Select {name}" }, "chat.selected": { zh: "已选择", en: "Selected" }, @@ -435,7 +446,7 @@ export const CHAT: Messages = { "chat.text_in_this_call_is": { zh: "这条调用里的文字,和本次任务从外部读到的内容(网页或工具结果)逐字相同。自动放行、全部放行和记住的答案都不能代你回答;请只批准这一次调用,或拒绝。", en: "Text in this call is copied word for word from content the task read from outside (a web page or a tool result). Auto, full access and remembered answers cannot answer for you; approve this one call, or decline." }, "chat.the_agent_takes_the_refusal": { zh: "agent 已收到拒绝,将改用其他方式或终止。", en: "The agent takes the refusal and either finds another way or stops." }, "chat.the_attempts_task": { zh: "候选要做的任务", en: "The attempts' task" }, - "chat.the_brief_the_model_works": { zh: "上下文压缩后,模型接下来依据的那份简报", en: "The brief the model works from after context is compacted" }, + "chat.the_brief_the_model_works": { zh: "上下文压缩后,模型读回较早内容的位置和说明", en: "Where the model reads earlier content back from after compaction" }, "chat.the_compaction_settings_could_not": { zh: "无法读取压缩配置。", en: "The compaction settings could not be read." }, "chat.the_current_reply_stays_in": { zh: "当前回复会留在运行历史里", en: "The current reply stays in the run history" }, "chat.the_history_index_is_still": { zh: "历史索引还在建立,搜索结果可能不完整", en: "The history index is still building; search results may be incomplete" }, @@ -518,10 +529,10 @@ export const CHAT: Messages = { "chat.page_number": { zh: "页码", en: "Page number" }, "chat.zoom_in": { zh: "放大", en: "Zoom in" }, "chat.zoom_out": { zh: "缩小", en: "Zoom out" }, - "chat.n_earlier_messages_folded": { zh: "较早的 {n} 条消息已折叠为简报", en: "{n} earlier messages folded into a digest" }, + "chat.n_earlier_messages_folded": { zh: "较早的 {n} 条消息已归档", en: "{n} earlier messages archived" }, "chat.show_the_folded_content": { zh: "查看被折叠的内容", en: "Show the folded content" }, "chat.hide_the_folded_content": { zh: "收起较早的内容", en: "Fold the earlier content again" }, - "chat.earlier_folded_into_digest": { zh: "较早的内容已折叠为简报", en: "Earlier content folded into a digest" }, + "chat.earlier_folded_into_digest": { zh: "较早的内容已归档", en: "Earlier content archived" }, "chat.compaction_auto": { zh: "自动", en: "Auto" }, "chat.compaction_manual": { zh: "手动", en: "Manual" }, }; diff --git a/desktop/frontend-next/src/i18n/msg/composer.ts b/desktop/frontend-next/src/i18n/msg/composer.ts index 02f4c38048..525e534e1c 100644 --- a/desktop/frontend-next/src/i18n/msg/composer.ts +++ b/desktop/frontend-next/src/i18n/msg/composer.ts @@ -201,6 +201,8 @@ export const COMPOSER: Messages = { "composer.reasoning_effort": { zh: "推理强度", en: "Reasoning effort" }, "composer.recommended": { zh: "推荐", en: "Recommended" }, "composer.reference_files": { zh: "引用文件", en: "Reference files" }, + "composer.refine_note": { zh: "用当前模型改写;采用前不会替换原文", en: "Rewritten with the current model; your text stays until you adopt it" }, + "composer.refine_original": { zh: "原文:{text}", en: "Original: {text}" }, "composer.refine_prompt": { zh: "优化提示词", en: "Refine prompt" }, "composer.refined_prompt": { zh: "优化后的提示词", en: "Refined prompt" }, "composer.refining": { zh: "正在优化…", en: "Refining…" }, @@ -246,7 +248,13 @@ export const COMPOSER: Messages = { "composer.session_messages_waiting_for_your": { zh: "等你决定的会话间消息", en: "Session messages waiting for your decision" }, "composer.show_amounts": { zh: "显示金额", en: "Show amounts" }, "composer.show_the_largest_items_in": { zh: "展开{name}中最大的几项", en: "Show the largest items in {name}" }, + "composer.side_badge_gone": { zh: "关掉就没了", en: "Gone when closed" }, + "composer.side_badge_not_main": { zh: "不进主会话", en: "Not in the main session" }, + "composer.side_badge_read_only": { zh: "只读", en: "Read-only" }, "composer.side_question": { zh: "辅助会话", en: "Side question" }, + "composer.side_quote_label": { zh: "引用", en: "Quote" }, + "composer.side_quote_to_main": { zh: "把这段回答引用到主输入框", en: "Quote this answer in the main input" }, + "composer.side_select_first": { zh: "先在对话或工作台里选中一段文字,就会出现在这里", en: "Select text in the conversation or workbench first and it appears here" }, "composer.size_of_a_referenced_conversation": { zh: "引用历史会话的长度", en: "Size of a referenced conversation" }, "composer.slower_the_model_does_more": { zh: "更慢:模型为同一请求做更多推理,消耗的 token 与费用明显更高。仅对当前会话生效。", en: "Slower: the model does more reasoning for the same request, using noticeably more tokens and money. Applies to this session only." }, "composer.source": { zh: "起点", en: "Source" }, @@ -270,7 +278,7 @@ export const COMPOSER: Messages = { "composer.the_local_service_did_not": { zh: "本机服务没有在时限内回答,尚未确认;模型若刚启动可能还在加载,稍后再验证一次", en: "The local service did not answer in time, so this is unconfirmed. A model that was just started may still be loading; verify again shortly." }, "composer.the_model_does_not_exist": { zh: "模型不存在或账号未开放", en: "The model does not exist or is not enabled for this account" }, "composer.the_model_is_repeating_the": { zh: "模型在重复输出同一段文字", en: "The model is repeating the same text" }, - "composer.the_model_will_continue_from": { zh: "模型将依据摘要继续工作;完整对话记录会保留。", en: "The model will continue from a summary. The full conversation will be preserved." }, + "composer.the_model_will_continue_from": { zh: "较早的内容会归档成文件,模型自己读回需要的部分;完整对话记录会保留。", en: "Earlier content is archived to a file the model reads back as needed. The full conversation will be preserved." }, "composer.the_reference_settings_could_not": { zh: "无法读取引用设置。", en: "The reference settings could not be read." }, "composer.the_request_did_not_succeed": { zh: "请求没有成功", en: "The request did not succeed" }, "composer.the_response_stalled": { zh: "回包停住了", en: "The response stalled" }, diff --git a/desktop/frontend-next/src/state/find.ts b/desktop/frontend-next/src/state/find.ts index 8c20439160..69e1e0b575 100644 --- a/desktop/frontend-next/src/state/find.ts +++ b/desktop/frontend-next/src/state/find.ts @@ -79,6 +79,18 @@ function haystack(it: Item): string { export interface FindMode { regex?: boolean; caseSensitive?: boolean; + scope?: FindScope; +} + +export type FindScope = "all" | "user" | "reply" | "tool"; + +export function inScope(it: Item, scope: FindScope = "all"): boolean { + switch (scope) { + case "user": return it.t === "user"; + case "reply": return it.t === "say"; + case "tool": return it.t === "tool" || it.t === "reads"; + default: return true; + } } /** One match: where it starts and how much it covers. */ @@ -161,6 +173,7 @@ export function search(items: Item[], query: string, mode: FindMode = {}): Hit[] const deadline = Date.now() + BUDGET_MS; for (let row = 0; row < items.length; row++) { if (Date.now() > deadline) break; + if (!inScope(items[row], mode.scope)) continue; const hay = finder.verbatim ? exactHaystack(items[row]) : haystack(items[row]); for (let m = finder.match(hay, 0); m; m = afterMatch(finder, hay, m)) { // An empty match (`^`, `a*`) covers nothing to paint; the painter skips diff --git a/desktop/frontend-next/src/styles/app.css b/desktop/frontend-next/src/styles/app.css index 5cb6ee1966..20b600f543 100644 --- a/desktop/frontend-next/src/styles/app.css +++ b/desktop/frontend-next/src/styles/app.css @@ -1711,43 +1711,6 @@ .reask-ft .dismiss:hover { color: var(--muted) } .reask-ft .hint { margin-left: auto; font: var(--w-reg) var(--fs-micro) var(--ui); color: var(--ghost) } - .findhost { position: relative; height: 0; z-index: 6 } - .tfind { - position: absolute; top: 10px; right: calc(var(--srail-w) + 12px); - display: flex; align-items: center; gap: 6px; padding: 6px 7px 6px 9px; - border: 1px solid var(--border); border-radius: var(--r-md); - background: var(--float); box-shadow: var(--shadow-float); - } - .tfind:focus-within { border-color: var(--focus) } - .tfind svg { width: 13px; height: 13px; flex: none; fill: none; stroke: var(--ghost); stroke-width: 1.5; stroke-linecap: round } - .tfind input::-webkit-search-cancel-button, - .tfind input::-webkit-search-decoration { -webkit-appearance: none; appearance: none } - .tfind input { - flex: none; width: 190px; min-width: 0; border: 0; background: none; outline: 0; color: var(--text); - font: var(--w-reg) var(--fs-ui) var(--ui); - } - .tfind input::placeholder { color: var(--ghost) } - .tfind .n { - flex: none; min-width: 52px; text-align: right; font: var(--w-reg) var(--fs-micro) var(--mono); - color: var(--faint); font-variant-numeric: tabular-nums; - } - .tfind .n[data-empty] { color: var(--muted) } - .tfind .st { - flex: none; display: grid; place-items: center; width: 22px; height: 22px; padding: 0; - border: 0; border-radius: var(--r-xs); background: none; cursor: pointer; color: var(--faint); - } - .tfind .st svg { stroke: currentColor } - .tfind .st:hover:not(:disabled) { background: var(--float-hi); color: var(--text) } - .tfind .st:disabled { opacity: .4; cursor: default } - .tfind .clr { - flex: none; border: 0; background: none; cursor: pointer; color: var(--ghost); - font-size: var(--fs-ui); line-height: 1; padding: 0 3px; - } - .tfind .clr:hover { color: var(--text) } - @container conversation (max-width: 460px) { - .tfind { right: 12px; left: 12px } - .tfind input { flex: 1; width: 0 } - } ::highlight(rx-find) { background-color: color-mix(in srgb, var(--warn) 26%, transparent); color: var(--text) } ::highlight(rx-find-now) { background-color: var(--accent); color: var(--accent-fg) } diff --git a/desktop/frontend-next/src/styles/composition-picker.css b/desktop/frontend-next/src/styles/composition-picker.css index beb139bf6b..54f3a79157 100644 --- a/desktop/frontend-next/src/styles/composition-picker.css +++ b/desktop/frontend-next/src/styles/composition-picker.css @@ -10,6 +10,11 @@ .composition-picker { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 4px; min-width: 0; max-width: 100%; } .composition-picker > .picker { min-width: 0; max-width: 100%; } .composition-trigger { max-width: 100%; } +:root .composition-menu .mi[data-on] { background: var(--overlay); } +:root .composer-menu.composition-menu .mi[data-on]::after { color: var(--net-ink); } +:root .composition-menu .mi[data-on]:has(.rt)::after { display: none; } +:root .composition-menu .studio-item-meta { padding: 0 5px; border: 1px solid var(--border); border-radius: var(--r-sm); color: var(--muted); font: var(--w-med) var(--fs-micro)/1.5 var(--ui); } +.composition-menu .mi:not([data-on]) .rt { color: var(--net-ink); font-family: var(--ui); font-weight: var(--w-med); } .composition-trigger .nm { max-width: 180px; } .composition-trigger > .studio-icon { width: 12px; height: 12px; flex: none; } .composition-picker [role="alert"] { overflow-wrap: anywhere; } diff --git a/desktop/frontend-next/src/styles/find.css b/desktop/frontend-next/src/styles/find.css new file mode 100644 index 0000000000..ce22132d30 --- /dev/null +++ b/desktop/frontend-next/src/styles/find.css @@ -0,0 +1,51 @@ +.findhost { position: relative; height: 0; z-index: 6 } +.tfindbox { + position: absolute; top: 10px; right: calc(var(--srail-w) + 12px); width: 440px; max-width: calc(100% - 24px); + display: flex; flex-direction: column; align-items: flex-end; gap: 6px; +} +.tfind { + box-sizing: border-box; width: 100%; display: flex; align-items: center; gap: 6px; padding: 6px 6px 6px 10px; + border: 1px solid var(--border); border-radius: var(--r-md); + background: var(--float); box-shadow: var(--shadow-float); +} +.tfind:focus-within { border-color: var(--focus) } +.tfind[data-missed] { border-color: var(--err) } +.tfind > svg:first-child { width: 14px; height: 14px; flex: none; fill: none; stroke: var(--faint); stroke-width: 1.5; stroke-linecap: round } +.tfind input::-webkit-search-cancel-button, +.tfind input::-webkit-search-decoration { -webkit-appearance: none; appearance: none } +.tfind input { + flex: 1; min-width: 0; border: 0; background: none; outline: 0; color: var(--text); + font: var(--w-med) var(--fs-ui) var(--ui); +} +.tfind input::placeholder { color: var(--ghost) } +.tfind .n { + flex: none; height: 20px; padding: 0 7px; border-radius: var(--r-pill); background: var(--overlay); + display: inline-grid; place-items: center; white-space: nowrap; + font: var(--w-med) var(--fs-micro) var(--ui); color: var(--muted); font-variant-numeric: tabular-nums; +} +.tfind .n:empty { display: none } +.tfind .n[data-empty] { background: none; padding: 0; font-size: var(--fs-meta); color: var(--err-ink) } +.tfind .st { + flex: none; display: grid; place-items: center; width: 28px; height: 28px; padding: 0; + border: 0; border-radius: var(--r-sm); background: none; cursor: pointer; color: var(--muted); +} +.tfind .st svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round } +.tfind .st:hover:not(:disabled) { background: var(--float-hi); color: var(--text) } +.tfind .st:disabled { opacity: .4; cursor: default } +.tfind .sep { flex: none; width: 1px; height: 18px; background: var(--hair) } +.tfind .mode-toggle { + flex: none; display: inline-grid; place-items: center; height: 24px; padding: 0 7px; border: 0; border-radius: var(--r-xs); + background: none; cursor: pointer; color: var(--muted); font: var(--w-semi, 600) var(--fs-meta) var(--mono); +} +.tfind .mode-toggle:hover { color: var(--text) } +.tfind .mode-toggle[aria-pressed="true"] { background: var(--overlay); color: var(--text) } +.tfind-scope { display: inline-flex; gap: 2px; padding: 2px; border-radius: var(--r-sm); background: var(--overlay) } +.tfind-scope button { + height: 24px; padding: 0 10px; border: 0; border-radius: var(--r-xs); background: none; cursor: pointer; + color: var(--muted); font: var(--w-med) var(--fs-meta) var(--ui); white-space: nowrap; +} +.tfind-scope button[aria-pressed="true"] { background: var(--raised); color: var(--text); font-weight: var(--w-semi, 600); box-shadow: 0 1px 2px rgb(0 0 0 / .08) } +.tfind-hint { font: var(--w-med) var(--fs-meta) var(--ui); color: var(--faint); text-align: right } +@container conversation (max-width: 460px) { + .tfindbox { right: 12px; left: 12px; width: auto } +} diff --git a/desktop/frontend-next/src/styles/legacy.css b/desktop/frontend-next/src/styles/legacy.css index 139030e03d..7dc7c48191 100644 --- a/desktop/frontend-next/src/styles/legacy.css +++ b/desktop/frontend-next/src/styles/legacy.css @@ -5,6 +5,8 @@ @import "./composition-picker.css" layer(legacy); @import "./dock.css" layer(legacy); @import "./studio.css" layer(legacy); +@import "./find.css" layer(legacy); +@import "./refine.css" layer(legacy); @import "./dialogcenter.css" layer(legacy); @import "./settings-type.css" layer(legacy); @import "./stophint.css" layer(legacy); diff --git a/desktop/frontend-next/src/styles/mono.test.ts b/desktop/frontend-next/src/styles/mono.test.ts index ffd6f666bf..34b64a8a5e 100644 --- a/desktop/frontend-next/src/styles/mono.test.ts +++ b/desktop/frontend-next/src/styles/mono.test.ts @@ -23,6 +23,7 @@ const MARKS: Record = { x: "× — the close mark, sized to hold its cell beside ✓", "ptab-x": "× — same mark on a pane tab", ah: "› — the affordance arrow", + "mode-toggle": "Aa and .* — the glyphs of the search flags they switch, which are the flags' own notation", prompt: "› — the composer's own prompt character", esc: "Esc — a keycap, which is lettering on a key rather than prose", "oobe-brand": "the lockup's STUDIO line, deliberately set in the terminal's face", diff --git a/desktop/frontend-next/src/styles/overview.css b/desktop/frontend-next/src/styles/overview.css index 20bb3960b4..da47289b10 100644 --- a/desktop/frontend-next/src/styles/overview.css +++ b/desktop/frontend-next/src/styles/overview.css @@ -26,3 +26,59 @@ .studio-overview-anchor:hover .studio-overview-popover { opacity: 1; visibility: visible; pointer-events: auto; transform: none; transition-delay: 0s; } .studio-meterrail:has(.studio-context-anchor[data-open]) .studio-overview-anchor:not([data-open]):hover .studio-overview-popover { opacity: 0; visibility: hidden; pointer-events: none; } } +.chrome-context-card.studio-overview-card { gap: 0; width: min(360px, calc(100vw - 32px)); padding: 0; overflow: hidden; background: var(--float); box-shadow: var(--shadow-float); } +.studio-overview-card > header { padding: 12px 14px 8px; border-bottom: 0; align-items: baseline; } +.studio-overview-card > header > span { display: flex; align-items: baseline; gap: 8px; width: 100%; } +.studio-overview-card > header b { font: var(--w-semi) var(--fs-lead)/1.3 var(--ui); } +.studio-overview-card > header small { margin-left: auto; color: var(--faint); font: var(--w-med) var(--fs-meta)/1.3 var(--ui); text-align: right; } +.studio-overview-card > p { margin: 0; padding: 0 14px 12px; color: var(--muted); font: var(--w-med) var(--fs-meta)/1.5 var(--ui); } +.studio-overview-card .studio-overview-grid { padding: 0 14px; gap: 6px; } +.studio-overview-card .studio-overview-grid > div { gap: 2px; padding: 8px 10px; border-radius: var(--r-sm); background: var(--page); } +.studio-overview-card .studio-overview-grid dt { color: var(--muted); font: var(--w-med) var(--fs-meta)/1.3 var(--ui); } +.studio-overview-card .studio-overview-grid dd { font: var(--w-semi) var(--fs-title)/1.2 var(--ui); font-variant-numeric: tabular-nums; } +.studio-overview-card .studio-overview-grid small { color: var(--faint); font: var(--w-med) var(--fs-micro)/1.4 var(--ui); } +.studio-overview-card > section { display: grid; gap: 0; padding: 10px 14px 4px; } +.studio-overview-card > section > div:first-child { display: flex; align-items: baseline; gap: 8px; color: var(--muted); font: var(--w-med) var(--fs-meta)/1.3 var(--ui); } +.studio-overview-card > section > div:first-child b { font-weight: var(--w-semi); } +.studio-overview-card .studio-overview-row { min-height: 26px; padding: 4px 0; font: var(--w-med) var(--fs-meta)/1.3 var(--ui); } +.studio-overview-card .studio-overview-row > span { color: var(--text); } +.studio-overview-card > section[aria-label] + section { border-top: 1px solid var(--hair); margin-top: 6px; padding-top: 10px; } +.chrome-context-card:not(.studio-overview-card) { gap: 0; width: min(360px, calc(100vw - 32px)); padding: 0; overflow: hidden; background: var(--float); box-shadow: var(--shadow-float); } +.chrome-context-card:not(.studio-overview-card) > header { padding: 12px 14px 8px; border-bottom: 0; align-items: baseline; } +.chrome-context-card:not(.studio-overview-card) > header b { font: var(--w-semi) var(--fs-lead)/1.3 var(--ui); } +.chrome-context-card:not(.studio-overview-card) > header small { display: none; } +.chrome-context-card:not(.studio-overview-card) > header strong { font: var(--w-semi) var(--fs-title)/1 var(--ui); } +.chrome-context-card:not(.studio-overview-card) > .chrome-context-bar { order: 1; height: 8px; gap: 0; margin: 0 14px; border-radius: var(--r-xs); background: var(--overlay); } +.chrome-context-card:not(.studio-overview-card) > .chrome-context-total { order: 2; margin: 4px 14px 0; color: var(--faint); font: var(--w-med) var(--fs-micro)/1.4 var(--ui); } +.chrome-context-card:not(.studio-overview-card) > .chrome-context-total b { color: inherit; font: inherit; } +.chrome-context-card:not(.studio-overview-card) > .chrome-context-parts { order: 3; grid-template-columns: 1fr; gap: 0; padding: 8px 14px 0; } +.chrome-context-card:not(.studio-overview-card) > .chrome-context-parts > div { grid-template-columns: 8px minmax(0, 1fr) auto; gap: 8px; height: 28px; color: var(--text); font: var(--w-med) var(--fs-meta)/1 var(--ui); } +.chrome-context-card:not(.studio-overview-card) > .chrome-context-parts > div > i { width: 8px; height: 8px; border-radius: 2px; } +.chrome-context-card:not(.studio-overview-card) > .chrome-context-parts b, +.chrome-context-card:not(.studio-overview-card) > .chrome-context-parts .chrome-context-more { color: var(--muted); font: var(--w-med) var(--fs-meta)/1 var(--ui); } +.chrome-context-card:not(.studio-overview-card) > * { order: 4; } +.chrome-context-card:not(.studio-overview-card) > header { order: 0; } +.chrome-context-card:not(.studio-overview-card) > .chrome-context-bar { order: 1; } +.chrome-context-card:not(.studio-overview-card) > .chrome-context-total { order: 2; } +.chrome-context-card:not(.studio-overview-card) > .chrome-context-parts { order: 3; } +.chrome-context-card:not(.studio-overview-card) > section { padding: 10px 14px 12px; margin-top: 8px; border-top: 1px solid var(--hair); } +.chrome-context-card [data-part="sys"] { background: var(--muted); } +.chrome-context-card [data-part="tool"] { background: var(--net); } +.chrome-context-card [data-part="user"] { background: color-mix(in srgb, var(--net) 60%, transparent); } +.chrome-context-card [data-part="reply"] { background: color-mix(in srgb, var(--net) 35%, transparent); } +.chrome-context-card [data-part="output"] { background: color-mix(in srgb, var(--net) 20%, transparent); } +.compact-now .wsconfirm { flex-direction: column; align-items: stretch; gap: 0; padding: 0; overflow: hidden; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--float); box-shadow: var(--shadow-float); } +.compact-now .wsconfirm-t { gap: 6px; padding: 14px 16px; } +.compact-now .wsconfirm-t .q { font: var(--w-semi) var(--fs-lead)/1.3 var(--ui); } +.compact-now .wsconfirm-t .h { color: var(--muted); font: var(--w-med) var(--fs-ui)/1.5 var(--ui); } +.compact-now .wsconfirm-t .u { margin-top: 4px; color: var(--text); font: var(--w-med) var(--fs-meta)/1.4 var(--ui); } +.compact-now .wsconfirm-t .u small { color: var(--faint); font: inherit; } +.compact-now .wsconfirm-a { justify-content: flex-end; gap: 6px; padding: 10px 16px; border-top: 1px solid var(--hair); } +.compact-now .wsconfirm-a button { height: 24px; padding: 0 10px; border: 1px solid transparent; border-radius: var(--r-pill); background: transparent; color: var(--muted); font: var(--w-med) var(--fs-meta) var(--ui); } +.compact-now .wsconfirm-a button:last-child { height: 32px; padding: 0 14px; border-color: var(--accent); border-radius: var(--r-sm); background: var(--accent); color: var(--accent-fg); font-size: var(--fs-ui); } +.errbar[data-cause="root-missing"] { align-items: center; gap: 10px; padding: 10px 14px; border: 0; border-radius: var(--r-md); background: color-mix(in srgb, var(--warn) 12%, var(--surface)); color: var(--text); font: var(--w-med) var(--fs-ui)/1.4 var(--ui); } +.errbar[data-cause="root-missing"] > .studio-icon { width: 16px; height: 16px; flex: none; color: var(--warn-ink); } +.errbar[data-cause="root-missing"] > span { display: grid; gap: 2px; } +.errbar[data-cause="root-missing"] > span > b { font-weight: var(--w-med); } +.errbar[data-cause="root-missing"] > span > small { color: var(--muted); font: var(--w-med) var(--fs-meta)/1.4 var(--ui); } +.errbar[data-cause="root-missing"] > button { height: 32px; padding: 0 14px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--raised); color: var(--text); font: var(--w-med) var(--fs-ui) var(--ui); text-decoration: none; white-space: nowrap; } diff --git a/desktop/frontend-next/src/styles/refine.css b/desktop/frontend-next/src/styles/refine.css new file mode 100644 index 0000000000..ea6bd8eed7 --- /dev/null +++ b/desktop/frontend-next/src/styles/refine.css @@ -0,0 +1,41 @@ +.refine-card { + display: grid; + gap: 8px; + margin: 0 0 8px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--r-md); + background: var(--hair); +} +.refine-card header { display: flex; align-items: center; gap: 6px; color: var(--muted); font: var(--w-med) var(--fs-micro)/1.4 var(--ui); } +.refine-card header .studio-icon { width: 13px; height: 13px; stroke-width: 1.6; } +.refine-card header b { color: var(--text); font-weight: var(--w-med); } +.refine-card header small { color: var(--faint); font: var(--w-reg) var(--fs-meta)/1.4 var(--ui); } +.refine-card header { font-size: var(--fs-ui); } +.refine-card header b { color: var(--text); font-weight: var(--w-semi); } +.refine-card header .studio-icon { color: var(--accent-ink); } +:root[data-theme] .refine-card[data-phase="ready"], :root[data-theme] .refine-card[data-phase="failed"] { border-color: var(--accent); background: var(--raised); } +.refine-card footer { align-items: center; } +.refine-original { + flex: 1; + min-width: 0; + overflow: hidden; + color: var(--faint); + font: var(--w-med) var(--fs-meta)/1.4 var(--ui); + text-overflow: ellipsis; + white-space: nowrap; +} +.refine-wait { display: flex; align-items: center; gap: 8px; } +.refine-wait .studio-icon { width: 13px; height: 13px; color: var(--net); animation: m-pulse var(--t-live) ease-in-out infinite; } +.refine-wait .btn { margin-left: auto; border-radius: 999px; } +.refine-text { + max-height: 240px; + overflow: auto; + color: var(--text); + font: var(--w-reg) var(--read)/1.6 var(--ui); + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.refine-wait { margin: 0; color: var(--faint); font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); } +.refine-error { margin: 0; color: var(--err-ink); font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); } +.refine-card footer { display: flex; justify-content: flex-end; gap: 6px; } diff --git a/desktop/frontend-next/src/styles/sidepanel.css b/desktop/frontend-next/src/styles/sidepanel.css index 7b61c29703..a3d2af5e51 100644 --- a/desktop/frontend-next/src/styles/sidepanel.css +++ b/desktop/frontend-next/src/styles/sidepanel.css @@ -37,3 +37,33 @@ .sidecollapse svg { width: 16px; height: 16px; } .sidecollapse[aria-expanded="false"] svg { transform: rotate(180deg); } .sidepanel[data-collapsed] > header { border-bottom: 0; } +.sidepanel { width: min(400px, 100%); background: var(--float); } +.sidepanel > header { align-items: center; justify-content: flex-start; gap: 8px; border-bottom-color: var(--hair); } +.sidepanel > header > .studio-icon { width: 16px; height: 16px; flex: none; color: var(--muted); } +.sidepanel > header b { font: var(--w-semi) var(--fs-ui)/1.3 var(--ui); } +.sidepanel > header .sidebadge { display: inline-grid; place-items: center; height: 20px; padding: 0 7px; border-radius: var(--r-pill); background: var(--overlay); color: var(--muted); font: var(--w-med) var(--fs-micro) var(--ui); white-space: nowrap; } +.sidepanel > header .sideactions { margin-left: auto; gap: 2px; } +.sidepanel > header .sideactions button { display: inline-grid; place-items: center; width: 28px; height: 28px; padding: 0; border-radius: var(--r-sm); } +.sidepanel > header .sideactions button:hover { background: var(--overlay); } +.sidepanel > header .sideactions .studio-icon { width: 16px; height: 16px; } +.sidebody { gap: 10px; padding: 12px; } +.sidehint.sideempty { padding: 6px 4px; text-align: center; color: var(--muted); font: var(--w-med) var(--fs-ui)/1.6 var(--ui); } +.sideturn[data-role="me"] { justify-self: end; max-width: 80%; padding: 8px 12px; border-radius: var(--r-md); background: var(--overlay); } +.sideturn[data-role="me"] p { color: var(--text); font: var(--w-med) var(--fs-ui)/1.5 var(--ui); } +.sideturn[data-role="side"] { font: var(--w-med) var(--fs-ui)/1.6 var(--ui); } +.sideturn blockquote { padding: 0; border-left: 0; color: var(--muted); font: var(--w-med) var(--fs-meta)/1.5 var(--mono); } +.sidetomain { justify-self: start; display: inline-flex; align-items: center; gap: 5px; height: 24px; padding: 0 10px; border: 1px solid transparent; border-radius: var(--r-pill); background: transparent; color: var(--muted); font: var(--w-med) var(--fs-meta) var(--ui); cursor: pointer; } +.sidetomain:hover { background: var(--overlay); color: var(--text); } +.sidetomain .studio-icon { width: 14px; height: 14px; } +.sidequotes { padding: 0 12px 8px; } +.sidequotes li { align-items: center; gap: 6px; height: 28px; padding: 0 6px 0 10px; box-sizing: border-box; border-radius: var(--r-sm); background: var(--raised); font: var(--w-med) var(--fs-meta) var(--ui); } +.sidequotes li em { flex: none; font-style: normal; color: var(--muted); } +.sidequotes li span { max-height: none; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font: var(--w-med) var(--fs-meta) var(--mono); } +.sidequotes li button { display: inline-grid; place-items: center; width: 20px; height: 20px; padding: 0; color: var(--faint); } +.sidequotes li button .studio-icon { width: 12px; height: 12px; } +.sidecontent > footer { grid-template-columns: 1fr auto; align-items: center; gap: 8px; padding: 10px 12px; border-top-color: var(--hair); } +.sidecontent > footer .sidenote { grid-column: 1 / -1; margin: 0; padding: 8px 10px; border: 1px dashed var(--border); border-radius: var(--r-sm); color: var(--faint); font: var(--w-med) var(--fs-meta)/1.4 var(--ui); } +.sidecontent > footer .sidequote { padding: 0 10px; height: 24px; border-color: transparent; } +.sidepanel textarea { height: 32px; min-height: 32px; padding: 5px 10px; border-radius: var(--r-sm); font: var(--w-med) var(--fs-ui)/1.5 var(--ui); } +.sidesend { height: 32px; padding: 0 14px; border-color: var(--border); border-radius: var(--r-sm); background: var(--raised); color: var(--text); font: var(--w-med) var(--fs-ui)/1 var(--ui); } +.sidesend:not(:disabled) { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); } diff --git a/desktop/frontend-next/src/styles/studio.css b/desktop/frontend-next/src/styles/studio.css index 1d811e0dfa..bb390d96e9 100644 --- a/desktop/frontend-next/src/styles/studio.css +++ b/desktop/frontend-next/src/styles/studio.css @@ -3384,30 +3384,6 @@ button, input, textarea { font-size: var(--fs-ui); } .studio-refine[aria-disabled="true"] { cursor: default; } .studio-refine[aria-disabled="true"] > .studio-icon { opacity: .35; } .studio-refine[aria-busy="true"] > .studio-icon { animation: m-pulse var(--t-live) ease-in-out infinite; } -.refine-card { - display: grid; - gap: 8px; - margin: 0 0 8px; - padding: 10px 12px; - border: 1px solid var(--border); - border-radius: var(--r-md); - background: var(--hair); -} -.refine-card header { display: flex; align-items: center; gap: 6px; color: var(--muted); font: var(--w-med) var(--fs-micro)/1.4 var(--ui); } -.refine-card header .studio-icon { width: 13px; height: 13px; stroke-width: 1.6; } -.refine-card header b { color: var(--text); font-weight: var(--w-med); } -.refine-card header small { margin-left: auto; color: var(--ghost); font: var(--w-reg) var(--fs-micro)/1.4 var(--ui); } -.refine-text { - max-height: 240px; - overflow: auto; - color: var(--text); - font: var(--w-reg) var(--read)/1.6 var(--ui); - white-space: pre-wrap; - overflow-wrap: anywhere; -} -.refine-wait { margin: 0; color: var(--faint); font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); } -.refine-error { margin: 0; color: var(--err-ink); font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); } -.refine-card footer { display: flex; justify-content: flex-end; gap: 6px; } .commit-card { display: grid; gap: 8px; margin: 8px 0 0; } .commit-open { margin-left: auto; } .workbench-explorer-head .commit-open { height: 24px; padding: 0 10px; font-size: var(--fs-meta); } diff --git a/desktop/frontend-next/src/styles/toasts.css b/desktop/frontend-next/src/styles/toasts.css index e118f24659..e26462c5d4 100644 --- a/desktop/frontend-next/src/styles/toasts.css +++ b/desktop/frontend-next/src/styles/toasts.css @@ -1,11 +1,14 @@ -.toasts { position: fixed; right: 16px; bottom: 16px; z-index: 21; display: flex; flex-direction: column; gap: 8px; align-items: flex-end; width: min(380px, calc(100vw - 32px)); pointer-events: none; } -.toast { pointer-events: auto; box-sizing: border-box; width: 100%; display: flex; align-items: flex-start; gap: 10px; padding: 10px 10px 10px 12px; border-radius: var(--r-md); border: 1px solid var(--border); background: var(--float); box-shadow: var(--shadow-float); font: var(--w-reg) var(--fs-ui)/1.55 var(--ui); color: var(--text); animation: m-card var(--t-card) var(--ease-out) both; } -.toast[data-kind="error"] { border-color: color-mix(in srgb, var(--err) 40%, var(--border)); } -.toast-icon { display: flex; flex: none; margin-top: 2px; color: var(--muted); } -.toast-icon[data-kind="error"] { color: var(--err-ink); } -.toast-icon .studio-icon { width: 15px; height: 15px; } -.toast-body { flex: 1; min-width: 0; word-break: break-word; } -.toast-actions { display: flex; flex: none; align-items: center; gap: 2px; margin: -2px 0; } +.toasts { position: fixed; right: 16px; bottom: 16px; z-index: 21; display: flex; flex-direction: column; gap: 8px; align-items: flex-end; width: min(760px, calc(100vw - 32px)); pointer-events: none; } +.toast { pointer-events: auto; box-sizing: border-box; width: 100%; display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: var(--r-md); border: 1px solid var(--border); background: var(--raised); font: var(--w-med) var(--fs-ui)/1.5 var(--ui); color: var(--text); animation: m-card var(--t-card) var(--ease-out) both; } +.toast-icon { display: flex; flex: none; color: var(--muted); } +.toast-icon[data-tone="ok"] { color: var(--ok-ink); } +.toast-icon[data-tone="warn"] { color: var(--warn-ink); } +.toast-icon[data-tone="net"] { color: var(--net-ink); } +.toast-icon .studio-icon { width: 16px; height: 16px; } +.toast-body { flex: 1; min-width: 0; word-break: break-word; font-weight: var(--w-med); } +.toast-actions { display: flex; flex: none; align-items: center; gap: 6px; } +.toast-actions .act { height: 24px; padding: 0 10px; border: 1px solid transparent; border-radius: var(--r-pill); background: transparent; color: var(--muted); font: var(--w-med) var(--fs-meta) var(--ui); white-space: nowrap; cursor: pointer; } +.toast-actions .act:hover { background: var(--overlay); color: var(--text); } .toast[data-stacked] { flex-wrap: wrap; } .toast[data-stacked] .toast-body { flex-basis: calc(100% - 40px); } .toast[data-stacked] .toast-actions { flex-basis: 100%; margin: 0 0 0 25px; gap: 6px; } diff --git a/desktop/frontend-next/src/ui/AgentCompositionPicker.tsx b/desktop/frontend-next/src/ui/AgentCompositionPicker.tsx index 473a04aed7..0fdd223b41 100644 --- a/desktop/frontend-next/src/ui/AgentCompositionPicker.tsx +++ b/desktop/frontend-next/src/ui/AgentCompositionPicker.tsx @@ -43,42 +43,43 @@ export function AgentCompositionPicker({ port, binding, locked: statusLocked, ru return () => { active = false; }; }, [enabled, port, running, binding?.digest, binding?.started]); if (!enabled) return null; - if (unavailable) return ; if (!catalog) return null; const selected = catalog.presets.find((v) => v.definition.id === (current?.id ?? "builtin:general"))?.definition; const locked = running || statusLocked || !!current?.started; + const currentId = current?.id ?? "builtin:general"; const label = current ? compositionName(current.id, current.name) : t(selected?.name ?? "persona.everyday_partner"); const items: MenuItem[] = locked && !createNew ? [] : catalog.presets.map(({ definition }) => ({ value: definition.id, label: compositionName(definition.id, definition.name), meta: compositionSource(definition.id), desc: definition.id.startsWith("builtin:") ? t(definition.description) : definition.description, - right: locked ? t("common.start_a_new_session") : undefined, + right: locked ? t(definition.id === currentId ? "common.now" : "common.start_a_new_session") : undefined, disabled: busy, })); items.push({ value: MANAGE, label: `${t("persona.manage_agent_presets")}…`, plain: true, divide: true }); const choose = (id: string) => { if (id === MANAGE) { onSettings(); return; } if (busy) return; - if (locked) { createNew?.(id); return; } + if (locked) { if (id !== currentId) createNew?.(id); return; } setBusy(true); port.selectAgentComposition(id).then((binding) => { setCurrent(binding); onChanged(); }) .catch((e) => onError(reason(e))).finally(() => setBusy(false)); }; return id === MANAGE ? "agent-composition.manage" : locked ? "agent-composition.new" : "agent-composition.select"} title={locked ? t("persona.this_conversation_has_started_and") : selected?.description ? (selected.id.startsWith("builtin:") ? t(selected.description) : selected.description) : t("persona.choose_an_agent_composition_before")} pending={busy} - label={<>{label}} + label={<>{locked && }{label}{!locked && }} menuTitle={<>{t("persona.agent_presets")}{t(locked ? "persona.locked" : "persona.switch_before_the_first_message")}} head={<>{locked &&

{t("persona.this_conversation_has_started_and")}

}{current && current.resourceMode !== "live" &&

{t("persona.resources_fixed_to_a_saved")}

}} items={items} - current={locked ? undefined : current?.id ?? "builtin:general"} + current={currentId} onPick={choose} place="bottom" /> diff --git a/desktop/frontend-next/src/ui/CompactNow.tsx b/desktop/frontend-next/src/ui/CompactNow.tsx index fe6dff715b..9ca987e45b 100644 --- a/desktop/frontend-next/src/ui/CompactNow.tsx +++ b/desktop/frontend-next/src/ui/CompactNow.tsx @@ -4,47 +4,124 @@ import type { AgentPort } from "../port/port"; import type { WireEvent } from "../port/wire"; import { useDismiss } from "./dismiss"; +const tokenLabel = (n: number): string => + n >= 1_000_000 + ? `${(n / 1_000_000).toFixed(1)}M` + : n >= 1_000 + ? `${Math.round(n / 100) / 10}k` + : String(n); + export interface CompactAction { busy: boolean; pending: boolean; run: () => void; } -export function useCompactNow(port: AgentPort, blocked: boolean, onError: (error: unknown) => void) { +export function useCompactNow( + port: AgentPort, + blocked: boolean, + onError: (error: unknown) => void, +) { const [pending, setPending] = useState(false); const held = useRef(false); - const release = useCallback(() => { held.current = false; setPending(false); }, []); - const onEvent = useCallback((event: WireEvent) => { - if (event.kind === "notice" && (event.code === "compacted" || event.code === "compact_declined" || event.code === "compact_failed")) release(); - }, [release]); + const release = useCallback(() => { + held.current = false; + setPending(false); + }, []); + const onEvent = useCallback( + (event: WireEvent) => { + if ( + event.kind === "notice" && + (event.code === "compacted" || + event.code === "compact_declined" || + event.code === "compact_failed") + ) + release(); + }, + [release], + ); const run = useCallback(() => { if (blocked || held.current) return; held.current = true; setPending(true); // /submit acknowledges admission; the typed notice settles the operation. - void port.submit("/compact").catch((error) => { release(); onError(error); }); + void port.submit("/compact").catch((error) => { + release(); + onError(error); + }); }, [port, blocked, onError, release]); - return useMemo(() => ({ busy: blocked || pending, pending, run, onEvent, release }), [blocked, pending, run, onEvent, release]); + return useMemo( + () => ({ busy: blocked || pending, pending, run, onEvent, release }), + [blocked, pending, run, onEvent, release], + ); } -export function CompactNow({ action }: { action: CompactAction }) { +export function CompactNow({ + action, + usage, +}: { + action: CompactAction; + usage?: { used: number; window: number }; +}) { const [asking, setAsking] = useState(false); const box = useRef(null); const trigger = useRef(null); const close = useCallback(() => setAsking(false), []); useDismiss(asking, box, close); - const cancel = () => { close(); trigger.current?.focus(); }; + const cancel = () => { + close(); + trigger.current?.focus(); + }; return (
- {asking && ( -
-
{t("composer.compact_the_current_context")}{t("composer.the_model_will_continue_from")}
+
+
+ + {t("composer.compact_the_current_context")} + + + {t("composer.the_model_will_continue_from")} + + {usage && usage.window > 0 && ( + + {t("common.used")} {tokenLabel(usage.used)} + + {" "} + · {t("common.model_capacity")} {tokenLabel(usage.window)} + + + )} +
- - + +
)} diff --git a/desktop/frontend-next/src/ui/ContextSummaryCard.tsx b/desktop/frontend-next/src/ui/ContextSummaryCard.tsx index 5e2dd231e7..d74eb17571 100644 --- a/desktop/frontend-next/src/ui/ContextSummaryCard.tsx +++ b/desktop/frontend-next/src/ui/ContextSummaryCard.tsx @@ -57,7 +57,7 @@ export function ContextSummaryCard({ context, mcp, percent, onManage, compact: a )} ) :

{t("composer.no_context_data_yet")}

} - {action && } + {action && }
MCP{ready.length} / {active.length} {t("composer.connected")} · {tools} {t("composer.tools")}
{active.slice(0, 4).map((server) =>
{server.name}{server.state === "ready" ? `${server.tools} ${t("composer.tools")}` : t("composer.waiting_to_connect")}
)} diff --git a/desktop/frontend-next/src/ui/Find.tsx b/desktop/frontend-next/src/ui/Find.tsx index ac3f5156b4..9ee846b86c 100644 --- a/desktop/frontend-next/src/ui/Find.tsx +++ b/desktop/frontend-next/src/ui/Find.tsx @@ -1,13 +1,54 @@ import { useEffect, useRef } from "react"; import { t } from "../i18n"; +import type { FindScope } from "../state/find"; import type { Finding } from "./usefind"; -export function Find({ find, pending = false, failed = false }: { find: Finding; pending?: boolean; failed?: boolean }) { - return
{find.open && }
; +const SCOPES: [FindScope, string][] = [ + ["all", "chat.find_scope_all"], + ["user", "chat.find_scope_user"], + ["reply", "chat.find_scope_reply"], + ["tool", "chat.find_scope_tool"], +]; + +export function Find({ + find, + pending = false, + failed = false, +}: { + find: Finding; + pending?: boolean; + failed?: boolean; +}) { + return ( +
+ {find.open && } +
+ ); } -function Bar({ find, pending, failed }: { find: Finding; pending: boolean; failed: boolean }) { - const { query, total, index, capped, focus, regex, caseSensitive, setMode, ask: onQuery, step: onStep, close: onClose } = find; +function Bar({ + find, + pending, + failed, +}: { + find: Finding; + pending: boolean; + failed: boolean; +}) { + const { + query, + total, + index, + capped, + focus, + regex, + caseSensitive, + scope, + setMode, + ask: onQuery, + step: onStep, + close: onClose, + } = find; const box = useRef(null); useEffect(() => { box.current?.focus(); @@ -15,91 +56,165 @@ function Bar({ find, pending, failed }: { find: Finding; pending: boolean; faile }, [focus]); const asked = query.trim().length > 0; - const count = pending && asked ? failed ? t("chat.some_history_has_not_been") : t("chat.searching_earlier_messages") : total > 0 ? `${index + 1} / ${total}${capped ? "+" : ""}` : asked ? t("chat.no_matches") : ""; + const count = + pending && asked + ? failed + ? t("chat.some_history_has_not_been") + : t("chat.searching_earlier_messages") + : total > 0 + ? `${index + 1} / ${total}${capped ? "+" : ""}` + : asked + ? t("chat.no_matches") + : ""; + + const missed = asked && total === 0 && !pending; + const advice = + scope !== "all" + ? "chat.find_try_all_scope" + : caseSensitive + ? "chat.find_try_without_case" + : regex + ? "chat.find_try_without_regex" + : ""; + const hint = missed + ? [t("chat.find_searched_everything"), advice && t(advice)] + .filter(Boolean) + .join(" · ") + : ""; return ( -
- - onQuery(ev.target.value)} - onKeyDown={(ev) => { - if (ev.nativeEvent.isComposing) return; - if (ev.key === "Enter" || ev.key === "ArrowDown" || ev.key === "ArrowUp") { - ev.preventDefault(); - onStep(ev.key === "ArrowUp" || (ev.key === "Enter" && ev.shiftKey) ? -1 : 1); - } else if (ev.key === "Escape") { - ev.preventDefault(); - ev.stopPropagation(); - onClose(); - } - }} - /> - - - - {count} - - - - + onQuery(ev.target.value)} + onKeyDown={(ev) => { + if (ev.nativeEvent.isComposing) return; + if ( + ev.key === "Enter" || + ev.key === "ArrowDown" || + ev.key === "ArrowUp" + ) { + ev.preventDefault(); + onStep( + ev.key === "ArrowUp" || (ev.key === "Enter" && ev.shiftKey) + ? -1 + : 1, + ); + } else if (ev.key === "Escape") { + ev.preventDefault(); + ev.stopPropagation(); + onClose(); + } + }} + /> + + {count} + + + +
+ {missed ? ( +
{hint}
+ ) : ( +
+ {SCOPES.map(([id, label]) => ( + + ))} +
+ )}
); } diff --git a/desktop/frontend-next/src/ui/ImagePreview.tsx b/desktop/frontend-next/src/ui/ImagePreview.tsx index 90844917fe..6c9dcbe58f 100644 --- a/desktop/frontend-next/src/ui/ImagePreview.tsx +++ b/desktop/frontend-next/src/ui/ImagePreview.tsx @@ -1,11 +1,22 @@ -import { useEffect, useRef, useState, type MouseEvent, type ReactNode } from "react"; +import { + useEffect, + useRef, + useState, + type MouseEvent, + type ReactNode, +} from "react"; import { createPortal } from "react-dom"; import { t } from "../i18n"; import { useEscape } from "./dismiss"; import { StudioIcon } from "./StudioIcon"; import "./image-preview.css"; -export function ImagePreview({ src, name, className, children }: { +export function ImagePreview({ + src, + name, + className, + children, +}: { src?: string; name: string; className?: string; @@ -13,24 +24,61 @@ export function ImagePreview({ src, name, className, children }: { }) { const [open, setOpen] = useState(false); useEffect(() => setOpen(false), [src]); - return <> - - {open && src && setOpen(false)} />} - ; + return ( + <> + + {open && src && ( + setOpen(false)} /> + )} + + ); } -function ImageViewer({ src, name, onClose }: { src: string; name: string; onClose: () => void }) { +function ImageViewer({ + src, + name, + onClose, +}: { + src: string; + name: string; + onClose: () => void; +}) { const close = useRef(null); const [failed, setFailed] = useState(false); + const [scale, setScale] = useState(1); + const [pan, setPan] = useState({ x: 0, y: 0 }); + const [size, setSize] = useState<[number, number] | null>(null); + const drag = useRef<{ + x: number; + y: number; + from: { x: number; y: number }; + } | null>(null); useEscape(true, onClose); const backdrop = (ev: MouseEvent) => { if (ev.target === ev.currentTarget && ev.detail < 2) onClose(); }; + const download = () => { + const link = document.createElement("a"); + link.href = src; + link.download = name; + link.click(); + }; + const zoom = (factor: number) => + setScale((was) => Math.min(8, Math.max(0.2, was * factor))); useEffect(() => { const previous = document.activeElement; const overflow = document.body.style.overflow; @@ -38,30 +86,130 @@ function ImageViewer({ src, name, onClose }: { src: string; name: string; onClos close.current?.focus(); return () => { document.body.style.overflow = overflow; - if (previous instanceof HTMLElement && previous.isConnected) previous.focus(); + if (previous instanceof HTMLElement && previous.isConnected) + previous.focus(); }; }, []); // Transcript and composer ancestors can scroll or carry backdrop filters; // the body portal keeps the viewer outside those containing blocks. return createPortal( -
{ if (!close.current?.contains(ev.target as Node)) ev.preventDefault(); }} + onMouseDown={(ev) => { + if (!(ev.target as HTMLElement).closest("button")) ev.preventDefault(); + }} onKeyDown={(ev) => { if (ev.key === "Tab") { ev.preventDefault(); close.current?.focus(); } - }}> - -
- {failed ?

{t("chat.image_unavailable")}

- : {name} setFailed(true)} />} + }} + > +
+ {name} + {size && ( + + {size[0]} × {size[1]} + + )} + + + + +
-
{name}
-
, document.body, +
zoom(ev.deltaY < 0 ? 1.1 : 1 / 1.1)} + onPointerDown={(ev) => { + if (ev.target instanceof HTMLImageElement) { + drag.current = { x: ev.clientX, y: ev.clientY, from: pan }; + ev.currentTarget.setPointerCapture(ev.pointerId); + } + }} + onPointerMove={(ev) => { + const d = drag.current; + if (d) + setPan({ + x: d.from.x + ev.clientX - d.x, + y: d.from.y + ev.clientY - d.y, + }); + }} + onPointerUp={() => { + drag.current = null; + }} + > + {failed ? ( +

{t("chat.image_unavailable")}

+ ) : ( + {name} + setSize([ + ev.currentTarget.naturalWidth, + ev.currentTarget.naturalHeight, + ]) + } + onError={() => setFailed(true)} + /> + )} +
+
{t("chat.image_preview_hint")}
+
, + document.body, ); } diff --git a/desktop/frontend-next/src/ui/Pane.tsx b/desktop/frontend-next/src/ui/Pane.tsx index 42077a6a0c..fb216b98fd 100644 --- a/desktop/frontend-next/src/ui/Pane.tsx +++ b/desktop/frontend-next/src/ui/Pane.tsx @@ -662,7 +662,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc {alert &&
{alert}
} {pill && } - 0} worktreeTarget={worktreeTarget} onWorktreePhase={setWorktreePhase} port={port} workspace={workspace} fresh={ready && !historyPaging.hasMore && !historyPaging.busy && !historyPaging.failed && !rt.host && s.items.length === 0} status={status} running={running} quote={quote} restore={restored} inject={inserted} focus={askFocus} onSubmit={submit} onChanged={refreshStatus} onError={notify} onSettings={onSettings} side={sideAsk} onSide={() => setSideAsk((on) => !on)} sidePanel={sideAsk ? setSideAsk(false)} /> : null} changeCount={changeCount} countElsewhere={pill} git={git} onTreeChanged={refreshTree} pulse={pulse} draftKey={draftKey(rt.host ?? "", rt.root, rt.sessionPath || status?.sessionPath || "")} /> + 0} worktreeTarget={worktreeTarget} onWorktreePhase={setWorktreePhase} port={port} workspace={workspace} fresh={ready && !historyPaging.hasMore && !historyPaging.busy && !historyPaging.failed && !rt.host && s.items.length === 0} status={status} running={running} quote={quote} restore={restored} inject={inserted} focus={askFocus} onSubmit={submit} onChanged={refreshStatus} onError={notify} onSettings={onSettings} side={sideAsk} onSide={() => setSideAsk((on) => !on)} sidePanel={sideAsk ? setSideAsk(false)} onQuote={(text) => setInserted((was) => ({ n: was.n + 1, text }))} /> : null} changeCount={changeCount} countElsewhere={pill} git={git} onTreeChanged={refreshTree} pulse={pulse} draftKey={draftKey(rt.host ?? "", rt.root, rt.sessionPath || status?.sessionPath || "")} /> , text: str
{t("composer.refined_prompt")} - {state.phase !== "working" && state.draft !== text && {t("composer.the_box_has_changed_adopting")}} + + {state.phase !== "working" && state.draft !== text + ? t("composer.the_box_has_changed_adopting") + : t("composer.refine_note")} +
- {state.phase === "working" &&

{t("composer.refining")}

} + {state.phase === "working" && ( +

+ + {t("composer.refining")} + +

+ )} {state.phase === "failed" &&

{state.error}

} {state.phase === "ready" &&
{state.text}
} -
- {state.phase === "ready" && ( - - )} - {state.phase !== "working" && ( - - )} - -
+ {state.phase === "ready" && ( + + )} + + )} ); diff --git a/desktop/frontend-next/src/ui/RootMissingBanner.tsx b/desktop/frontend-next/src/ui/RootMissingBanner.tsx index db04fcf20f..50e90c9a07 100644 --- a/desktop/frontend-next/src/ui/RootMissingBanner.tsx +++ b/desktop/frontend-next/src/ui/RootMissingBanner.tsx @@ -1,5 +1,6 @@ import { t } from "../i18n"; import { say } from "../i18n/kernel"; +import { StudioIcon } from "./StudioIcon"; import type { Item } from "../state/session"; const ROOT_MISSING = "tool.root_missing"; @@ -14,13 +15,30 @@ export function rootMissing(items: Item[]): boolean { return false; } -export function RootMissingBanner({ items, onPick }: { items: Item[]; onPick: () => void }) { +export function RootMissingBanner({ + items, + onPick, +}: { + items: Item[]; + onPick: () => void; +}) { if (!rootMissing(items)) return null; return (
-
- {say({ code: ROOT_MISSING }, "")} - +
+ + + {t("app.project_folder_not_found")} + {say({ code: ROOT_MISSING }, "")} + +
); diff --git a/desktop/frontend-next/src/ui/SidePanel.interaction.test.tsx b/desktop/frontend-next/src/ui/SidePanel.interaction.test.tsx index eaff59f366..52d04687cc 100644 --- a/desktop/frontend-next/src/ui/SidePanel.interaction.test.tsx +++ b/desktop/frontend-next/src/ui/SidePanel.interaction.test.tsx @@ -54,6 +54,17 @@ describe("the side conversation panel", () => { expect(screen.getByText("这个函数做什么?")).toBeTruthy(); }); + it("carries an answer back to the main input as a quotation", async () => { + const onQuote = vi.fn(); + const port = new MockPort() as unknown as AgentPort; + render(); + await userEvent.type(await screen.findByRole("textbox", { name: "向辅助会话提问" }), "这个函数做什么?"); + await userEvent.click(screen.getByRole("button", { name: "发送" })); + await userEvent.click(await screen.findByRole("button", { name: "把这段回答引用到主输入框" })); + expect(onQuote).toHaveBeenCalledOnce(); + expect(onQuote.mock.calls[0]![0]).toMatch(/^> .*只读回答/); + }); + it("draws the answer as markdown but leaves the question as typed", async () => { const { port } = draw(); vi.spyOn(port, "askSide").mockResolvedValue("先看 **入口函数** 再看 `main`"); diff --git a/desktop/frontend-next/src/ui/SidePanel.tsx b/desktop/frontend-next/src/ui/SidePanel.tsx index 08c3a88f8e..732647be34 100644 --- a/desktop/frontend-next/src/ui/SidePanel.tsx +++ b/desktop/frontend-next/src/ui/SidePanel.tsx @@ -28,7 +28,15 @@ const QUOTE_CAP = 20000; // A question asked beside the session: read-only, kept only for as long as this // panel is open, and never part of the main conversation. The kernel opens the // conversation at the first question and drops it when the panel closes. -export function SidePanel({ port, onClose }: { port: AgentPort; onClose: () => void }) { +export function SidePanel({ + port, + onClose, + onQuote, +}: { + port: AgentPort; + onClose: () => void; + onQuote?: (text: string) => void; +}) { const [turns, setTurns] = useState([]); const [quotes, setQuotes] = useState([]); const [draft, setDraft] = useState(""); @@ -63,7 +71,10 @@ export function SidePanel({ port, onClose }: { port: AgentPort; onClose: () => v const bounds = host.getBoundingClientRect(); const scale = bounds.width / host.offsetWidth || 1; const room = (bounds.top - pane.getBoundingClientRect().top) / scale - 18; - el.style.setProperty("--side-room", `${Math.max(0, Math.min(520, Math.floor(room)))}px`); + el.style.setProperty( + "--side-room", + `${Math.max(0, Math.min(520, Math.floor(room)))}px`, + ); }; fit(); const watch = new ResizeObserver(fit); @@ -79,45 +90,73 @@ export function SidePanel({ port, onClose }: { port: AgentPort; onClose: () => v const remember = () => { const sel = window.getSelection(); const text = sel?.toString().trim() ?? ""; - if (text && !(sel?.anchorNode && panel.current?.contains(sel.anchorNode))) lastSelected.current = text; + if (text && !(sel?.anchorNode && panel.current?.contains(sel.anchorNode))) + lastSelected.current = text; }; - return listenAction(document, "selectionchange", { action: "side.quote", listener: remember }); + return listenAction(document, "selectionchange", { + action: "side.quote", + listener: remember, + }); }, []); const quoteSelection = () => { const sel = window.getSelection(); const live = sel?.toString().trim() ?? ""; - const text = live && !(sel?.anchorNode && panel.current?.contains(sel.anchorNode)) ? live : lastSelected.current; + const text = + live && !(sel?.anchorNode && panel.current?.contains(sel.anchorNode)) + ? live + : lastSelected.current; if (!text) { setError(t("composer.select_some_text_in_the")); return; } lastSelected.current = ""; setError(""); - setQuotes((all) => (all.includes(text) ? all : [...all, text.slice(0, QUOTE_CAP)])); + setQuotes((all) => + all.includes(text) ? all : [...all, text.slice(0, QUOTE_CAP)], + ); }; const send = async () => { const question = draft.trim(); if (!question || busy) return; const sent = quotes; - setPending({ question, quotes: sent, text: "", phase: "waiting", since: Date.now() }); + setPending({ + question, + quotes: sent, + text: "", + phase: "waiting", + since: Date.now(), + }); setError(""); try { - opened.current ??= port.openSide().then((held) => { id.current = held; return held; }); + opened.current ??= port.openSide().then((held) => { + id.current = held; + return held; + }); const held = await opened.current; if (!live.current) return; const answer = await port.askSide(held, question, sent, (progress) => { if (!live.current) return; - setPending((current) => current && { - ...current, - text: progress.kind === "text" ? current.text + (progress.text ?? "") : current.text, - phase: progress.kind, - tool: progress.tool, - }); + setPending( + (current) => + current && { + ...current, + text: + progress.kind === "text" + ? current.text + (progress.text ?? "") + : current.text, + phase: progress.kind, + tool: progress.tool, + }, + ); }); if (!live.current) return; - setTurns((all) => [...all, { role: "me", text: question, quotes: sent }, { role: "side", text: answer }]); + setTurns((all) => [ + ...all, + { role: "me", text: question, quotes: sent }, + { role: "side", text: answer }, + ]); setPending(null); setDraft(""); setQuotes([]); @@ -152,72 +191,191 @@ export function SidePanel({ port, onClose }: { port: AgentPort; onClose: () => v }} >
- {t("composer.side_question")}{t("composer.read_only_not_in_the")} + + {t("composer.side_question")} + {( + [ + "composer.side_badge_read_only", + "composer.side_badge_not_main", + "composer.side_badge_gone", + ] as const + ).map((key) => ( + + {t(key)} + + ))}
- - + +