fix(webui): rename goal-related terminology and enhance UI components

This commit is contained in:
Xubin Ren 2026-05-16 04:42:58 +00:00
parent e14c0310ad
commit 90632469f6
13 changed files with 183 additions and 96 deletions

View File

@ -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.

View File

@ -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<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const expandToggleRef = useRef<HTMLButtonElement>(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 (
<>
<div ref={stripWrapperRef} className="relative z-30">
{goalPanelOpen && canExpandGoal && markdownBody ? (
<div
ref={panelRef}
id="nanobot-goal-panel-root"
role="dialog"
aria-modal="false"
aria-labelledby="nanobot-goal-panel-title"
tabIndex={-1}
className={cn(
"absolute bottom-[calc(100%+8px)] left-3 right-3 z-[50] flex max-w-none flex-col overflow-hidden",
"rounded-2xl border border-black/[0.08] bg-card shadow-[0_12px_40px_rgba(15,23,42,0.14)]",
"backdrop-blur-sm dark:border-white/[0.1] dark:shadow-[0_16px_48px_rgba(0,0,0,0.45)]",
)}
style={{ maxHeight: `${Math.round(panelMaxPx)}px` }}
>
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-black/[0.06] px-3 py-2 dark:border-white/[0.08]">
<h2
id="nanobot-goal-panel-title"
className="min-w-0 truncate text-[13px] font-semibold tracking-tight text-foreground"
>
{t("thread.composer.goalStateSheetTitle")}
</h2>
<button
type="button"
className={cn(
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
"text-muted-foreground transition-colors hover:bg-muted/65 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
aria-label={t("thread.composer.goalStateCloseAria")}
onClick={() => setGoalPanelOpen(false)}
>
<X className="h-4 w-4" aria-hidden />
</button>
</div>
<div
id="nanobot-goal-panel-scroll"
className="min-h-0 flex-1 overflow-y-auto scrollbar-thin px-3 pb-3 pt-2"
>
<MarkdownText className="max-w-none text-[13.5px] leading-relaxed text-foreground/90">
{markdownBody}
</MarkdownText>
</div>
</div>
) : null}
<div
className="flex min-h-[36px] items-center gap-2 border-b border-black/[0.04] px-3 py-2 dark:border-white/[0.06]"
role="status"
@ -213,55 +338,28 @@ function RunElapsedStrip({
</span>
{canExpandGoal ? (
<button
ref={expandToggleRef}
type="button"
className={cn(
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
"text-muted-foreground transition-colors hover:bg-muted/55 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
aria-expanded={goalPanelOpen}
aria-controls={goalPanelOpen ? "nanobot-goal-panel-root" : undefined}
aria-label={t("thread.composer.goalStateExpandAria")}
title={t("thread.composer.goalStateExpandAria")}
onClick={() => setGoalSheetOpen(true)}
onClick={() => setGoalPanelOpen((o) => !o)}
>
<ChevronUp className="h-4 w-4" aria-hidden />
{goalPanelOpen ? (
<ChevronDown className="h-4 w-4" aria-hidden />
) : (
<ChevronUp className="h-4 w-4" aria-hidden />
)}
</button>
) : null}
</div>
<Sheet open={goalSheetOpen} onOpenChange={setGoalSheetOpen}>
<SheetContent
side="bottom"
showCloseButton
aria-describedby={undefined}
className={cn(
"max-h-[min(85vh,560px)] rounded-t-2xl border-t px-4 pb-6 pt-4",
"gap-3 sm:max-w-lg sm:rounded-t-2xl",
)}
>
<SheetHeader className="space-y-1 text-left">
<SheetTitle>{t("thread.composer.goalStateSheetTitle")}</SheetTitle>
</SheetHeader>
<div className="flex max-h-[min(58vh,420px)] flex-col gap-4 overflow-y-auto pr-0.5 text-[14px] leading-relaxed">
{summaryFull ? (
<section>
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{t("thread.composer.goalStateSummaryHeading")}
</p>
<p className="whitespace-pre-wrap text-foreground/90">{summaryFull}</p>
</section>
) : null}
{objectiveFull ? (
<section>
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{t("thread.composer.goalStateObjectiveHeading")}
</p>
<p className="whitespace-pre-wrap text-foreground/90">{objectiveFull}</p>
</section>
) : null}
</div>
</SheetContent>
</Sheet>
</>
</div>
);
}
@ -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 ? (

View File

@ -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));
}
}

View File

@ -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",

View File

@ -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"
},

View File

@ -222,9 +222,7 @@
"goalStateStrip": "Objectif · {{label}}",
"goalStateFallback": "Objectif",
"goalStateExpandAria": "Afficher lobjectif 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 lobjectif"
},
"scrollToBottom": "Faire défiler vers le bas"
},

View File

@ -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"
},

View File

@ -222,9 +222,7 @@
"goalStateStrip": "目標 · {{label}}",
"goalStateFallback": "目標",
"goalStateExpandAria": "目標の全文を表示",
"goalStateSheetTitle": "スレッドの目標",
"goalStateSummaryHeading": "要約",
"goalStateObjectiveHeading": "目的",
"goalStateSheetTitle": "目標",
"send": "メッセージを送信",
"stop": "応答を停止",
"attachImage": "画像を添付",
@ -302,7 +300,8 @@
"description": "利用可能なスラッシュコマンドを一覧表示します。"
}
}
}
},
"goalStateCloseAria": "目標を閉じる"
},
"scrollToBottom": "一番下へスクロール"
},

View File

@ -222,9 +222,7 @@
"goalStateStrip": "목표 · {{label}}",
"goalStateFallback": "목표",
"goalStateExpandAria": "전체 목표 보기",
"goalStateSheetTitle": "스레드 목표",
"goalStateSummaryHeading": "요약",
"goalStateObjectiveHeading": "목표 설명",
"goalStateSheetTitle": "목표",
"send": "메시지 보내기",
"stop": "응답 중지",
"attachImage": "이미지 첨부",
@ -302,7 +300,8 @@
"description": "사용 가능한 슬래시 명령을 나열합니다."
}
}
}
},
"goalStateCloseAria": "목표 닫기"
},
"scrollToBottom": "맨 아래로 스크롤"
},

View File

@ -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"
},

View File

@ -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": "滚动到底部"
},

View File

@ -222,9 +222,7 @@
"goalStateStrip": "目標 · {{label}}",
"goalStateFallback": "目標",
"goalStateExpandAria": "查看完整目標",
"goalStateSheetTitle": "對話目標",
"goalStateSummaryHeading": "摘要",
"goalStateObjectiveHeading": "目標描述",
"goalStateSheetTitle": "目標",
"send": "送出訊息",
"stop": "停止回覆",
"attachImage": "附加圖片",
@ -302,7 +300,8 @@
"description": "列出可用的斜線命令。"
}
}
}
},
"goalStateCloseAria": "關閉目標"
},
"scrollToBottom": "捲動到底部"
},

View File

@ -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", () => {