feat(webui): polish sidebar and session transitions (#5393)

This commit is contained in:
chengyongru
2026-08-14 17:03:11 +08:00
committed by GitHub
parent 057c5e849b
commit 221e8a4e4a
17 changed files with 1152 additions and 560 deletions
+16 -56
View File
@@ -83,7 +83,6 @@ import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
import { useLogoFallback } from "@/hooks/useLogoFallback";
import { useMediaQuery } from "@/hooks/useMediaQuery";
import type { SendAttachment, SendOptions } from "@/hooks/useNanobotStream";
import { usePageVisibility } from "@/hooks/usePageVisibility";
import { useVoiceRecorder, type VoiceRecorderErrorKey } from "@/hooks/useVoiceRecorder";
import type {
CliAppInfo,
@@ -207,8 +206,6 @@ interface ThreadComposerProps {
onStop?: () => void;
surfaceRef?: Ref<HTMLDivElement>;
onTranscribeAudio?: (dataUrl: string, options?: { durationMs?: number }) => Promise<string>;
/** Unix seconds from server; turn elapsed timer above input while set. */
runStartedAt?: number | null;
/** Sustained objective for this chat (WebSocket ``goal_state``). */
goalState?: GoalStateWsPayload;
workspaceScope?: WorkspaceScopePayload | null;
@@ -695,63 +692,38 @@ function mcpPresetMentionPayload(preset: McpPresetInfo): OutboundMcpPresetMentio
};
}
function RunPulseIcon() {
return (
<span className="run-pulse-icon relative flex h-4 w-4 shrink-0 items-center justify-center" aria-hidden>
<span className="run-pulse-icon__ring" />
<span className="run-pulse-icon__dot" />
</span>
);
}
function RunElapsedStrip({
startedAt,
function GoalStateStrip({
goalState,
}: {
startedAt: number | null;
goalState?: GoalStateWsPayload;
}) {
const { t } = useTranslation();
const pageVisible = usePageVisibility();
const [goalPanelOpen, setGoalPanelOpen] = useState(false);
const showTimer = startedAt != null;
const stripLabel = goalStateStripPreview(goalState, t);
const showGoal = !!stripLabel?.trim();
const active = showTimer || showGoal;
const active = !!stripLabel?.trim();
const [, setTick] = useState(0);
const stripWrapperRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const expandToggleRef = useRef<HTMLButtonElement>(null);
const stripSnapshotRef = useRef<{
startedAt: number | null;
goalState?: GoalStateWsPayload;
stripLabel: string | null;
} | null>(null);
const [panelMaxPx, setPanelMaxPx] = useState(280);
if (active) {
stripSnapshotRef.current = { startedAt, goalState, stripLabel };
stripSnapshotRef.current = { goalState, stripLabel };
}
useEffect(() => {
if (!active) setGoalPanelOpen(false);
}, [active]);
useEffect(() => {
if (startedAt == null || !pageVisible) return;
setTick((n) => n + 1);
const id = window.setInterval(() => setTick((n) => n + 1), 1000);
return () => window.clearInterval(id);
}, [pageVisible, startedAt]);
const display = active
? { startedAt, goalState, stripLabel }
? { goalState, stripLabel }
: stripSnapshotRef.current;
const displayStartedAt = display?.startedAt ?? null;
const displayGoalState = display?.goalState;
const displayStripLabel = display?.stripLabel ?? null;
const displayShowTimer = displayStartedAt != null;
const displayShowGoal = !!displayStripLabel?.trim();
const objectiveFull = displayGoalState?.objective?.trim() ?? "";
const summaryFull = displayGoalState?.ui_summary?.trim() ?? "";
@@ -819,17 +791,11 @@ function RunElapsedStrip({
};
}, [goalPanelOpen]);
const elapsed =
displayStartedAt != null ? Math.max(0, Math.floor(Date.now() / 1000 - displayStartedAt)) : 0;
const m = Math.floor(elapsed / 60);
const sec = elapsed % 60;
const shortElapsed = m > 0 ? `${m}:${sec.toString().padStart(2, "0")}` : `${sec}s`;
const timerTitle = displayShowTimer
? t("thread.composer.runRuntimeTitle", { elapsed: shortElapsed })
: null;
if (!display) return null;
const ariaParts = [timerTitle, displayShowGoal ? displayStripLabel : null].filter(Boolean);
const ariaLabel = ariaParts.join(" · ");
const ariaLabel = displayStripLabel
? t("thread.composer.goalStateStrip", { label: displayStripLabel })
: t("thread.composer.goalStateFallback");
return (
<div
@@ -838,6 +804,11 @@ function RunElapsedStrip({
data-composer-status-drawer=""
data-state={active ? "open" : "closed"}
aria-hidden={active ? undefined : true}
onTransitionEnd={(event) => {
if (active || event.target !== event.currentTarget) return;
stripSnapshotRef.current = null;
setTick((n) => n + 1);
}}
>
{goalPanelOpen && canExpandGoal && markdownBody ? (
<div
@@ -891,19 +862,9 @@ function RunElapsedStrip({
role="status"
aria-label={ariaLabel}
>
{displayShowTimer ? (
<RunPulseIcon />
) : (
<Target className="h-4 w-4 shrink-0 text-primary/75" aria-hidden />
)}
<Target className="h-4 w-4 shrink-0 text-primary/75" aria-hidden />
<span className="flex min-w-0 flex-1 items-center gap-1.5 text-[12px] font-medium text-foreground/75">
{timerTitle ? <span className="shrink-0">{timerTitle}</span> : null}
{timerTitle && displayShowGoal ? (
<span className="shrink-0 text-muted-foreground/45" aria-hidden>
·
</span>
) : null}
{displayShowGoal ? (
{displayStripLabel ? (
<span className="truncate">
{t("thread.composer.goalStateStrip", { label: displayStripLabel })}
</span>
@@ -963,7 +924,6 @@ export function ThreadComposer({
onStop,
surfaceRef,
onTranscribeAudio,
runStartedAt = null,
goalState,
workspaceScope = null,
workspaceControlsHidden = false,
@@ -2370,7 +2330,7 @@ export function ThreadComposer({
</button>
</div>
) : null}
<RunElapsedStrip startedAt={runStartedAt} goalState={goalState} />
<GoalStateStrip goalState={goalState} />
<div className="relative">
{hasMentionDecorations ? (
<ComposerCliMentionOverlay