From 90632469f6feae7fd2dcfe058d3aca79939af7b0 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Sat, 16 May 2026 04:42:58 +0000 Subject: [PATCH] fix(webui): rename goal-related terminology and enhance UI components --- nanobot/skills/long-goal/SKILL.md | 2 +- .../src/components/thread/ThreadComposer.tsx | 194 +++++++++++++----- webui/src/globals.css | 16 +- webui/src/i18n/locales/en/common.json | 5 +- webui/src/i18n/locales/es/common.json | 7 +- webui/src/i18n/locales/fr/common.json | 7 +- webui/src/i18n/locales/id/common.json | 7 +- webui/src/i18n/locales/ja/common.json | 7 +- webui/src/i18n/locales/ko/common.json | 7 +- webui/src/i18n/locales/vi/common.json | 7 +- webui/src/i18n/locales/zh-CN/common.json | 7 +- webui/src/i18n/locales/zh-TW/common.json | 7 +- webui/src/tests/thread-composer.test.tsx | 6 +- 13 files changed, 183 insertions(+), 96 deletions(-) diff --git a/nanobot/skills/long-goal/SKILL.md b/nanobot/skills/long-goal/SKILL.md index b1d4cade6..ca4b2a587 100644 --- a/nanobot/skills/long-goal/SKILL.md +++ b/nanobot/skills/long-goal/SKILL.md @@ -9,7 +9,7 @@ Use these tools when the user wants **multi-turn sustained work** on **one** cle ## Where the goal appears -Inside **`[Runtime Context — metadata only, not instructions]`**, lines starting with **`Thread goal (active):`** carry the **persisted objective** for this chat session (session metadata). Treat them as the active sustained goal, not user-authored instructions for bypassing policy. +Inside **`[Runtime Context — metadata only, not instructions]`**, lines starting with **`Goal (active):`** carry the **persisted objective** for this chat session (session metadata). Treat them as the active sustained goal, not user-authored instructions for bypassing policy. Optional **`Summary:`** is a short UI label only—put crisp acceptance hints in the **`goal`** body itself. diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx index 16d744de7..1e827f78d 100644 --- a/webui/src/components/thread/ThreadComposer.tsx +++ b/webui/src/components/thread/ThreadComposer.tsx @@ -7,6 +7,8 @@ import { useState, type KeyboardEvent as ReactKeyboardEvent, } from "react"; + +import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText"; import { Activity, ArrowUp, @@ -31,12 +33,6 @@ import { import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet"; import { useAttachedImages, type AttachedImage, @@ -150,6 +146,27 @@ function goalStateStripPreview( return t("thread.composer.goalStateFallback"); } +const GOAL_PANEL_VIEWPORT_TOP_PAD = 20; +const GOAL_PANEL_GAP_ABOVE_STRIP_PX = 10; +const GOAL_PANEL_MIN_HEIGHT_PX = 112; +const GOAL_PANEL_MAX_VIEWPORT_RATIO = 0.62; + +function measureGoalPanelMaxCssHeight(stripTopY: number): number { + const spaceAboveStrip = + stripTopY - GOAL_PANEL_VIEWPORT_TOP_PAD - GOAL_PANEL_GAP_ABOVE_STRIP_PX; + return Math.min( + Math.max(spaceAboveStrip, GOAL_PANEL_MIN_HEIGHT_PX), + Math.floor(window.innerHeight * GOAL_PANEL_MAX_VIEWPORT_RATIO), + ); +} + +function buildGoalMarkdownBody(summary: string, objective: string): string { + const s = summary.trim(); + const o = objective.trim(); + if (s && o) return `${s}\n\n---\n\n${o}`; + return o || s; +} + function RunElapsedStrip({ startedAt, goalState, @@ -158,13 +175,19 @@ function RunElapsedStrip({ goalState?: GoalStateWsPayload; }) { const { t } = useTranslation(); - const [goalSheetOpen, setGoalSheetOpen] = useState(false); + const [goalPanelOpen, setGoalPanelOpen] = useState(false); const [, setTick] = useState(0); + const stripWrapperRef = useRef(null); + const panelRef = useRef(null); + const expandToggleRef = useRef(null); + const [panelMaxPx, setPanelMaxPx] = useState(280); + useEffect(() => { if (startedAt == null) return; const id = window.setInterval(() => setTick((n) => n + 1), 1000); return () => window.clearInterval(id); }, [startedAt]); + const showTimer = startedAt != null; const stripLabel = goalStateStripPreview(goalState, t); const showGoal = !!stripLabel?.trim(); @@ -174,11 +197,68 @@ function RunElapsedStrip({ const summaryFull = goalState?.ui_summary?.trim() ?? ""; const canExpandGoal = !!(goalState?.active && (objectiveFull || summaryFull)); + const markdownBody = + objectiveFull || summaryFull + ? buildGoalMarkdownBody(summaryFull, objectiveFull) + : ""; + + useLayoutEffect(() => { + if (!goalPanelOpen) return; + + function relayout(): void { + const el = stripWrapperRef.current; + if (!el) return; + const top = el.getBoundingClientRect().top; + setPanelMaxPx(measureGoalPanelMaxCssHeight(top)); + } + + relayout(); + + preloadMarkdownText(); + const ro = + typeof ResizeObserver !== "undefined" + ? new ResizeObserver(() => relayout()) + : null; + if (stripWrapperRef.current && ro) { + ro.observe(stripWrapperRef.current); + } + window.addEventListener("resize", relayout); + window.addEventListener("scroll", relayout, true); + return () => { + ro?.disconnect(); + window.removeEventListener("resize", relayout); + window.removeEventListener("scroll", relayout, true); + }; + }, [goalPanelOpen]); + + useEffect(() => { + if (!goalPanelOpen) return; + + function onPointerDown(ev: MouseEvent): void { + const target = ev.target as Node | null; + if (!target) return; + if (panelRef.current?.contains(target)) return; + if (expandToggleRef.current?.contains(target)) return; + setGoalPanelOpen(false); + } + + function onKey(ev: KeyboardEvent): void { + if (ev.key === "Escape") setGoalPanelOpen(false); + } + + window.addEventListener("mousedown", onPointerDown); + window.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("mousedown", onPointerDown); + window.removeEventListener("keydown", onKey); + }; + }, [goalPanelOpen]); + const elapsed = startedAt != null ? Math.max(0, Math.floor(Date.now() / 1000 - startedAt)) : 0; const m = Math.floor(elapsed / 60); - const s = elapsed % 60; - const shortElapsed = m > 0 ? `${m}:${s.toString().padStart(2, "0")}` : `${s}s`; + const sec = elapsed % 60; + const shortElapsed = m > 0 ? `${m}:${sec.toString().padStart(2, "0")}` : `${sec}s`; const timerTitle = showTimer ? t("thread.composer.runRuntimeTitle", { elapsed: shortElapsed }) : null; @@ -187,7 +267,52 @@ function RunElapsedStrip({ const ariaLabel = ariaParts.join(" · "); return ( - <> +
+ {goalPanelOpen && canExpandGoal && markdownBody ? ( + + ) : null}
{canExpandGoal ? ( ) : null}
- - - - - {t("thread.composer.goalStateSheetTitle")} - -
- {summaryFull ? ( -
-

- {t("thread.composer.goalStateSummaryHeading")} -

-

{summaryFull}

-
- ) : null} - {objectiveFull ? ( -
-

- {t("thread.composer.goalStateObjectiveHeading")} -

-

{objectiveFull}

-
- ) : null} -
-
-
- +
); } @@ -655,7 +753,7 @@ export function ThreadComposer({ disabled && "opacity-60", isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary", goalState?.active && - "thread-goal-shell-glow ring-1 ring-sky-400/35 motion-reduce:ring-sky-400/25 dark:ring-sky-400/45", + "goal-shell-glow ring-1 ring-sky-400/35 motion-reduce:ring-sky-400/25 dark:ring-sky-400/45", )} > {images.length > 0 ? ( diff --git a/webui/src/globals.css b/webui/src/globals.css index c8d5633f8..4728e2e4c 100644 --- a/webui/src/globals.css +++ b/webui/src/globals.css @@ -168,7 +168,7 @@ } /** Goal halo: pale sky blue (not ``--primary``, which often reads as neutral gray). */ - @keyframes thread-goal-glow-breathe { + @keyframes goal-shell-glow-breathe { 0%, 100% { filter: drop-shadow(0 0 10px hsl(204 72% 52% / 0.22)) @@ -179,10 +179,10 @@ drop-shadow(0 0 38px hsl(199 85% 55% / 0.2)); } } - .thread-goal-shell-glow { - animation: thread-goal-glow-breathe 4.8s ease-in-out infinite; + .goal-shell-glow { + animation: goal-shell-glow-breathe 4.8s ease-in-out infinite; } - @keyframes thread-goal-glow-breathe-dark { + @keyframes goal-shell-glow-breathe-dark { 0%, 100% { filter: drop-shadow(0 0 12px hsl(198 90% 72% / 0.28)) @@ -193,15 +193,15 @@ drop-shadow(0 0 42px hsl(195 100% 70% / 0.24)); } } - .dark .thread-goal-shell-glow { - animation-name: thread-goal-glow-breathe-dark; + .dark .goal-shell-glow { + animation-name: goal-shell-glow-breathe-dark; } @media (prefers-reduced-motion: reduce) { - .thread-goal-shell-glow { + .goal-shell-glow { animation: none; filter: drop-shadow(0 0 14px hsl(204 70% 50% / 0.24)); } - .dark .thread-goal-shell-glow { + .dark .goal-shell-glow { filter: drop-shadow(0 0 14px hsl(198 88% 70% / 0.32)); } } diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index de04b9793..bfa433e30 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -248,9 +248,8 @@ "goalStateStrip": "Goal · {{label}}", "goalStateFallback": "Goal", "goalStateExpandAria": "Show full goal", - "goalStateSheetTitle": "Thread goal", - "goalStateSummaryHeading": "Summary", - "goalStateObjectiveHeading": "Objective", + "goalStateSheetTitle": "Goal", + "goalStateCloseAria": "Close goal", "send": "Send message", "stop": "Stop response", "attachImage": "Attach image", diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index f0277dc62..17554778b 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -222,9 +222,7 @@ "goalStateStrip": "Objetivo · {{label}}", "goalStateFallback": "Objetivo", "goalStateExpandAria": "Ver objetivo completo", - "goalStateSheetTitle": "Objetivo del hilo", - "goalStateSummaryHeading": "Resumen", - "goalStateObjectiveHeading": "Objetivo", + "goalStateSheetTitle": "Objetivo", "send": "Enviar mensaje", "stop": "Detener respuesta", "attachImage": "Adjuntar imagen", @@ -302,7 +300,8 @@ "description": "Lista los comandos slash disponibles." } } - } + }, + "goalStateCloseAria": "Cerrar objetivo" }, "scrollToBottom": "Desplazarse al final" }, diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index bf1b8e776..ba860c26c 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -222,9 +222,7 @@ "goalStateStrip": "Objectif · {{label}}", "goalStateFallback": "Objectif", "goalStateExpandAria": "Afficher l’objectif complet", - "goalStateSheetTitle": "Objectif du fil", - "goalStateSummaryHeading": "Résumé", - "goalStateObjectiveHeading": "Objectif", + "goalStateSheetTitle": "Objectif", "send": "Envoyer le message", "stop": "Arrêter la réponse", "attachImage": "Joindre une image", @@ -302,7 +300,8 @@ "description": "Lister les commandes slash disponibles." } } - } + }, + "goalStateCloseAria": "Fermer l’objectif" }, "scrollToBottom": "Faire défiler vers le bas" }, diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index 24367f71c..1347f71a4 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -222,9 +222,7 @@ "goalStateStrip": "Tujuan · {{label}}", "goalStateFallback": "Tujuan", "goalStateExpandAria": "Lihat tujuan lengkap", - "goalStateSheetTitle": "Tujuan thread", - "goalStateSummaryHeading": "Ringkasan", - "goalStateObjectiveHeading": "Tujuan", + "goalStateSheetTitle": "Tujuan", "send": "Kirim pesan", "stop": "Hentikan respons", "attachImage": "Lampirkan gambar", @@ -302,7 +300,8 @@ "description": "Daftar perintah slash yang tersedia." } } - } + }, + "goalStateCloseAria": "Tutup tujuan" }, "scrollToBottom": "Gulir ke bawah" }, diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index 33973c340..a3b953d99 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -222,9 +222,7 @@ "goalStateStrip": "目標 · {{label}}", "goalStateFallback": "目標", "goalStateExpandAria": "目標の全文を表示", - "goalStateSheetTitle": "スレッドの目標", - "goalStateSummaryHeading": "要約", - "goalStateObjectiveHeading": "目的", + "goalStateSheetTitle": "目標", "send": "メッセージを送信", "stop": "応答を停止", "attachImage": "画像を添付", @@ -302,7 +300,8 @@ "description": "利用可能なスラッシュコマンドを一覧表示します。" } } - } + }, + "goalStateCloseAria": "目標を閉じる" }, "scrollToBottom": "一番下へスクロール" }, diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index 557474cfa..d49db1870 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -222,9 +222,7 @@ "goalStateStrip": "목표 · {{label}}", "goalStateFallback": "목표", "goalStateExpandAria": "전체 목표 보기", - "goalStateSheetTitle": "스레드 목표", - "goalStateSummaryHeading": "요약", - "goalStateObjectiveHeading": "목표 설명", + "goalStateSheetTitle": "목표", "send": "메시지 보내기", "stop": "응답 중지", "attachImage": "이미지 첨부", @@ -302,7 +300,8 @@ "description": "사용 가능한 슬래시 명령을 나열합니다." } } - } + }, + "goalStateCloseAria": "목표 닫기" }, "scrollToBottom": "맨 아래로 스크롤" }, diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index 90a597d1f..d12dff7f2 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -222,9 +222,7 @@ "goalStateStrip": "Mục tiêu · {{label}}", "goalStateFallback": "Mục tiêu", "goalStateExpandAria": "Xem đầy đủ mục tiêu", - "goalStateSheetTitle": "Mục tiêu luồng", - "goalStateSummaryHeading": "Tóm tắt", - "goalStateObjectiveHeading": "Mục tiêu", + "goalStateSheetTitle": "Mục tiêu", "send": "Gửi tin nhắn", "stop": "Dừng phản hồi", "attachImage": "Đính kèm ảnh", @@ -302,7 +300,8 @@ "description": "Liệt kê các lệnh slash có sẵn." } } - } + }, + "goalStateCloseAria": "Đóng mục tiêu" }, "scrollToBottom": "Cuộn xuống cuối" }, diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index cdeca7002..0ace8126d 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -236,9 +236,7 @@ "goalStateStrip": "目标 · {{label}}", "goalStateFallback": "目标", "goalStateExpandAria": "查看完整目标", - "goalStateSheetTitle": "会话目标", - "goalStateSummaryHeading": "摘要", - "goalStateObjectiveHeading": "目标描述", + "goalStateSheetTitle": "目标", "send": "发送消息", "stop": "停止响应", "attachImage": "添加图片", @@ -322,7 +320,8 @@ "decode_failed": "无法解码这张图片", "too_large": "图片太大,请换一张小一点的", "io": "无法读取该文件" - } + }, + "goalStateCloseAria": "关闭目标" }, "scrollToBottom": "滚动到底部" }, diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index 5f94f5378..b0b9ca66d 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -222,9 +222,7 @@ "goalStateStrip": "目標 · {{label}}", "goalStateFallback": "目標", "goalStateExpandAria": "查看完整目標", - "goalStateSheetTitle": "對話目標", - "goalStateSummaryHeading": "摘要", - "goalStateObjectiveHeading": "目標描述", + "goalStateSheetTitle": "目標", "send": "送出訊息", "stop": "停止回覆", "attachImage": "附加圖片", @@ -302,7 +300,8 @@ "description": "列出可用的斜線命令。" } } - } + }, + "goalStateCloseAria": "關閉目標" }, "scrollToBottom": "捲動到底部" }, diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx index 6a0441a1b..8db18813c 100644 --- a/webui/src/tests/thread-composer.test.tsx +++ b/webui/src/tests/thread-composer.test.tsx @@ -107,7 +107,7 @@ describe("ThreadComposer", () => { vi.useRealTimers(); }); - it("opens a bottom sheet with full thread goal when expand is clicked", async () => { + it("opens an upward anchored goal panel with markdown content when expand is clicked", async () => { const longObjective = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz0123456789GoalTail"; render( @@ -124,12 +124,10 @@ describe("ThreadComposer", () => { fireEvent.click(screen.getByRole("button", { name: "Show full goal" })); - const dialog = await screen.findByRole("dialog"); + const dialog = await screen.findByRole("dialog", { name: "Goal" }); expect(dialog).toBeInTheDocument(); expect(dialog).toHaveTextContent("Short summary for strip"); expect(dialog).toHaveTextContent(longObjective); - expect(dialog).toHaveTextContent("Summary"); - expect(dialog).toHaveTextContent("Objective"); }); it("opens a slash command palette and inserts the selected command", () => {