feat(desktop): polish desktop shell and shared WebUI surfaces (#4195)

* feat(desktop): add native host scaffold

* feat(webui): track turns and usage in gateway

* feat(webui): polish desktop chat experience

* feat(apps): add ArcGIS and Joplin logos

* feat(desktop): polish shell and shared surfaces

* fix(webui): avoid preview chips for glob references

* test: align CI expectations for token fallback

* feat(webui): preview prompt rail entries

* feat(webui): add prompt navigator drawer

* style(webui): refine prompt navigator placement

* style(webui): align prompt navigator with header actions

* style(webui): simplify prompt navigator header

* refactor(webui): clean thread resource refresh

* feat(desktop): add native reply notifications

* fix(webui): preserve desktop restart and replay state

* fix(desktop): harden gateway proxy startup

* fix(web): fall back when readability is unavailable

* fix(desktop): hide window instead of closing on macos

* fix(webui): unify desktop header actions

* fix(webui): simplify prompt history rows

* fix(desktop): log notification delivery failures

* chore(desktop): clean source package artifacts

* fix(cron): support one-time relative reminders

* fix(webui): reveal scroll button in place

* Revert "fix(cron): support one-time relative reminders"

This reverts commit 4c4661da120a3c7283e0768412bae48604e7390b.

* refactor(webui): extract token usage heatmap

* docs(desktop): clarify contributor guides

---------

Co-authored-by: chengyongru <2755839590@qq.com>
This commit is contained in:
Xubin Ren
2026-06-06 19:49:33 +08:00
committed by GitHub
co-authored by chengyongru
parent a1b9577224
commit ab9f49970d
103 changed files with 10483 additions and 1003 deletions
@@ -173,6 +173,7 @@ interface AgentActivityClusterProps {
turnLatencyMs?: number;
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
onOpenFilePreview?: (path: string) => void;
}
/**
@@ -186,6 +187,7 @@ export function AgentActivityCluster({
turnLatencyMs,
cliApps = [],
mcpPresets = [],
onOpenFilePreview,
}: AgentActivityClusterProps) {
const { t } = useTranslation();
const fileEdits = useMemo(
@@ -423,6 +425,7 @@ export function AgentActivityCluster({
added={added}
deleted={deleted}
hasDiffStats={hasDiffStats}
onOpenFilePreview={onOpenFilePreview}
/>
);
}
@@ -449,6 +452,8 @@ export function AgentActivityCluster({
<FileReferenceChip
path={singleFilePath}
tooltipPath={singleFileTooltipPath}
previewPath={singleFileTooltipPath || singleFilePath}
onOpen={onOpenFilePreview}
active={hasLiveEditingFiles}
className="-my-0.5 min-w-0"
textClassName="text-xs"
@@ -494,6 +499,7 @@ export function AgentActivityCluster({
key={m.id}
text={m.reasoning ?? ""}
streaming={isTurnStreaming && !!m.reasoningStreaming}
onOpenFilePreview={onOpenFilePreview}
/>
);
}
@@ -510,7 +516,12 @@ export function AgentActivityCluster({
}
return null;
})}
{fileEdits.length ? <FileEditGroup edits={fileEdits} /> : null}
{fileEdits.length ? (
<FileEditGroup
edits={fileEdits}
onOpenFilePreview={onOpenFilePreview}
/>
) : null}
</div>
</div>
</div>
@@ -537,6 +548,7 @@ function FileEditFlatActivity({
added,
deleted,
hasDiffStats,
onOpenFilePreview,
}: {
edits: FileEditSummary[];
active: boolean;
@@ -550,6 +562,7 @@ function FileEditFlatActivity({
added: number;
deleted: number;
hasDiffStats: boolean;
onOpenFilePreview?: (path: string) => void;
}) {
const showRows = edits.length > 1 || edits.some((edit) => edit.status === "error" || edit.pending);
return (
@@ -569,6 +582,8 @@ function FileEditFlatActivity({
<FileReferenceChip
path={singleFilePath}
tooltipPath={singleFileTooltipPath}
previewPath={singleFileTooltipPath || singleFilePath}
onOpen={onOpenFilePreview}
active={hasLiveEditingFiles}
className="-my-0.5 min-w-0"
textClassName="text-xs"
@@ -583,7 +598,7 @@ function FileEditFlatActivity({
</div>
{showRows ? (
<div className="mt-0.5 pl-4">
<FileEditGroup edits={edits} />
<FileEditGroup edits={edits} onOpenFilePreview={onOpenFilePreview} />
</div>
) : null}
</div>
@@ -0,0 +1,149 @@
import { useMemo, useState } from "react";
import { ListTree, Search } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetTitle,
} from "@/components/ui/sheet";
import {
type PromptAnchor,
userPromptAnchors,
} from "@/components/thread/promptNavigation";
import { fmtDateTime } from "@/lib/format";
import type { UIMessage } from "@/lib/types";
import { cn } from "@/lib/utils";
interface PromptNavigatorProps {
messages: UIMessage[];
onJumpToPrompt: (promptId: string) => void;
}
export function PromptNavigator({
messages,
onJumpToPrompt,
}: PromptNavigatorProps) {
const { i18n, t } = useTranslation();
const prompts = useMemo(() => userPromptAnchors(messages), [messages]);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const filteredPrompts = useMemo(() => {
const needle = query.trim().toLocaleLowerCase();
if (!needle) return prompts;
return prompts.filter((prompt) =>
`${prompt.label}\n${prompt.preview}`.toLocaleLowerCase().includes(needle),
);
}, [prompts, query]);
if (prompts.length === 0) return null;
const jump = (promptId: string) => {
setOpen(false);
onJumpToPrompt(promptId);
};
return (
<>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"host-no-drag h-8 w-8 rounded-full text-muted-foreground/80",
"hover:bg-accent/40 hover:text-foreground",
)}
aria-label={t("thread.promptNavigator.open")}
onClick={() => setOpen(true)}
>
<ListTree className="h-4 w-4" />
</Button>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent
side="right"
aria-describedby={undefined}
className="w-[min(92vw,24rem)] gap-0 p-0 sm:max-w-[24rem]"
>
<div className="border-b px-5 pb-4 pt-5">
<SheetTitle className="text-base font-medium">
{t("thread.promptNavigator.title")}
</SheetTitle>
<div className="relative mt-4">
<Search
aria-hidden
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
aria-label={t("thread.promptNavigator.search")}
placeholder={t("thread.promptNavigator.search")}
className={cn(
"h-10 w-full rounded-full border border-border bg-background pl-9 pr-3 text-sm",
"outline-none transition focus:border-ring focus:ring-2 focus:ring-ring/20",
)}
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-2">
{filteredPrompts.length > 0 ? (
<div className="space-y-1">
{filteredPrompts.map((prompt) => (
<PromptNavigatorRow
key={prompt.id}
locale={i18n.resolvedLanguage || i18n.language}
prompt={prompt}
onJump={jump}
/>
))}
</div>
) : (
<div className="px-3 py-10 text-center text-sm text-muted-foreground">
{t("thread.promptNavigator.noResults")}
</div>
)}
</div>
</SheetContent>
</Sheet>
</>
);
}
interface PromptNavigatorRowProps {
locale: string;
onJump: (promptId: string) => void;
prompt: PromptAnchor;
}
function PromptNavigatorRow({
locale,
onJump,
prompt,
}: PromptNavigatorRowProps) {
const { t } = useTranslation();
const timestamp = fmtDateTime(prompt.createdAt, locale);
return (
<button
type="button"
className={cn(
"w-full rounded-xl px-3 py-3 text-left transition",
"hover:bg-accent focus-visible:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30",
)}
aria-label={t("thread.promptNavigator.jumpTo", { label: prompt.label })}
onClick={() => onJump(prompt.id)}
>
<div className="max-h-20 overflow-hidden whitespace-pre-wrap break-words text-sm leading-5 text-foreground">
{prompt.preview}
</div>
{timestamp ? (
<div className="mt-1 text-[10px] leading-4 text-muted-foreground/75">
{timestamp}
</div>
) : null}
</button>
);
}
+95 -62
View File
@@ -9,6 +9,13 @@ import {
import { cn } from "@/lib/utils";
import type { UIMessage } from "@/lib/types";
import {
findPromptElement,
jumpToPrompt,
type PromptAnchor,
promptTop,
userPromptAnchors,
} from "@/components/thread/promptNavigation";
interface PromptRailProps {
bottomOffset: number;
@@ -16,11 +23,6 @@ interface PromptRailProps {
scrollRef: RefObject<HTMLDivElement>;
}
interface PromptAnchor {
id: string;
label: string;
}
interface MeasuredPrompt extends PromptAnchor {
top: number;
topPercent: number;
@@ -30,18 +32,21 @@ interface PromptMarker {
count: number;
ids: string[];
label: string;
preview: string;
topPercent: number;
}
const MIN_PROMPTS_FOR_RAIL = 3;
const RAIL_MIN_SCROLL_RANGE_PX = 240;
const RAIL_MIN_SCROLL_RANGE_PX = 80;
const DENSE_PROMPT_THRESHOLD = 30;
const DENSE_BUCKET_HEIGHT_PX = 12;
const DENSE_BUCKET_FALLBACK_COUNT = 32;
const DENSE_BUCKET_MAX_COUNT = 42;
const MARKER_MIN_GAP_PX = 9;
const MARKER_BASE_WIDTH_PX = 26;
const MARKER_MAX_WIDTH_PX = 42;
const MARKER_BASE_WIDTH_PX = 16;
const MARKER_MAX_WIDTH_PX = 28;
const MEASURE_RETRY_FRAMES = 4;
const RAIL_REVEAL_MS = 1400;
export function PromptRail({
bottomOffset,
@@ -52,6 +57,19 @@ export function PromptRail({
const promptAnchors = useMemo(() => userPromptAnchors(messages), [messages]);
const [markers, setMarkers] = useState<PromptMarker[]>([]);
const [activePromptId, setActivePromptId] = useState<string | null>(null);
const [revealed, setRevealed] = useState(false);
const revealTimeoutRef = useRef<number | null>(null);
const revealTemporarily = useCallback(() => {
setRevealed(true);
if (revealTimeoutRef.current !== null) {
window.clearTimeout(revealTimeoutRef.current);
}
revealTimeoutRef.current = window.setTimeout(() => {
setRevealed(false);
revealTimeoutRef.current = null;
}, RAIL_REVEAL_MS);
}, []);
const updateMarkers = useCallback(() => {
const scrollEl = scrollRef.current;
@@ -74,8 +92,18 @@ export function PromptRail({
}, [promptAnchors, scrollRef]);
useEffect(() => {
updateMarkers();
}, [updateMarkers]);
let frame = 0;
let remainingFrames = MEASURE_RETRY_FRAMES;
const measure = () => {
updateMarkers();
remainingFrames -= 1;
if (remainingFrames > 0) {
frame = window.requestAnimationFrame(measure);
}
};
measure();
return () => window.cancelAnimationFrame(frame);
}, [bottomOffset, updateMarkers]);
useEffect(() => {
const scrollEl = scrollRef.current;
@@ -84,6 +112,7 @@ export function PromptRail({
let frame = 0;
const schedule = () => {
window.cancelAnimationFrame(frame);
revealTemporarily();
frame = window.requestAnimationFrame(updateMarkers);
};
@@ -94,7 +123,7 @@ export function PromptRail({
scrollEl.removeEventListener("scroll", schedule);
window.removeEventListener("resize", schedule);
};
}, [scrollRef, updateMarkers]);
}, [revealTemporarily, scrollRef, updateMarkers]);
useEffect(() => {
const scrollEl = scrollRef.current;
@@ -105,63 +134,85 @@ export function PromptRail({
return () => observer.disconnect();
}, [scrollRef, updateMarkers]);
useEffect(() => {
return () => {
if (revealTimeoutRef.current !== null) {
window.clearTimeout(revealTimeoutRef.current);
}
};
}, []);
if (markers.length === 0) return null;
const maxMarkerCount = Math.max(...markers.map((marker) => marker.count));
const activeMarkerIndex = markers.findIndex((marker) =>
marker.ids.includes(activePromptId ?? ""),
);
return (
<div
ref={railRef}
aria-label="User prompt navigation"
className={cn(
"pointer-events-none absolute right-6 top-12 z-20 hidden w-12 md:block",
"group pointer-events-auto absolute right-4 top-14 z-20 hidden w-8 opacity-70 md:block",
"transition-opacity duration-200 hover:opacity-100",
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-200",
)}
style={{ bottom: Math.max(80, bottomOffset) }}
>
{markers.map((marker) => {
{markers.map((marker, index) => {
const active = marker.ids.includes(activePromptId ?? "");
const nearActive = activeMarkerIndex < 0 || Math.abs(index - activeMarkerIndex) <= 1;
return (
<button
key={marker.ids.join("|")}
type="button"
title={marker.label}
aria-label={`Jump to prompt: ${marker.label}`}
onClick={() => jumpToPrompt(scrollRef.current, marker.ids[marker.ids.length - 1])}
className={cn(
"pointer-events-auto absolute right-0 h-1.5 -translate-y-1/2 rounded-full",
"bg-muted-foreground/30 transition-all duration-150",
"hover:bg-blue-500/80 focus-visible:bg-blue-500",
"group/marker absolute right-0 h-5 -translate-y-1/2 overflow-visible rounded-full",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60",
marker.count > 1 && "bg-muted-foreground/45",
active && "bg-foreground shadow-sm",
)}
style={{
top: `${marker.topPercent}%`,
width: markerWidth(marker.count, maxMarkerCount, active),
}}
/>
>
<span
aria-hidden
className={cn(
"absolute right-0 top-1/2 h-[3px] w-full -translate-y-1/2 rounded-full",
"bg-foreground/20 transition-[background-color,opacity,transform,height] duration-200",
"group-hover/marker:bg-blue-500/70 group-hover/marker:opacity-100 group-hover/marker:scale-x-110",
"group-focus-visible/marker:bg-blue-500 group-focus-visible/marker:opacity-100 group-focus-visible/marker:scale-x-110",
marker.count > 1 && "bg-foreground/30",
active && "h-1 bg-foreground/65 opacity-80 shadow-sm",
!active && nearActive && "opacity-25 group-hover:opacity-55",
!active && !nearActive && !revealed && "opacity-0 group-hover:opacity-40",
!active && !nearActive && revealed && "opacity-35",
)}
/>
<span
aria-hidden
className={cn(
"pointer-events-none absolute right-9 top-1/2 z-30 w-64 -translate-y-1/2 rounded-lg px-3 py-2 text-left",
"bg-background/95 text-xs leading-5 text-foreground shadow-lg ring-1 ring-border/80 backdrop-blur",
"opacity-0 translate-x-1 transition-[opacity,transform] duration-150",
"group-hover/marker:opacity-100 group-hover/marker:translate-x-0",
"group-focus-visible/marker:opacity-100 group-focus-visible/marker:translate-x-0",
)}
>
<span className="block max-h-24 overflow-hidden whitespace-pre-wrap break-words">
{marker.preview}
</span>
</span>
</button>
);
})}
</div>
);
}
function userPromptAnchors(messages: UIMessage[]): PromptAnchor[] {
return messages
.filter((message) => message.role === "user")
.map((message, index) => ({
id: message.id,
label: promptLabel(message.content, index),
}));
}
function promptLabel(content: string, index: number): string {
const text = content.replace(/\s+/g, " ").trim();
if (!text) return `Prompt ${index + 1}`;
return text.length > 80 ? `${text.slice(0, 77)}...` : text;
}
function measurePrompts(
scrollEl: HTMLElement,
anchors: PromptAnchor[],
@@ -199,12 +250,14 @@ function groupPromptMarkers(
last.count += 1;
last.ids.push(prompt.id);
last.label = groupedPromptLabel(last.count, prompt.label);
last.preview = groupedPromptPreview(last.count, prompt.preview);
continue;
}
groups.push({
count: 1,
ids: [prompt.id],
label: prompt.label,
preview: prompt.preview,
topPercent: prompt.topPercent,
});
}
@@ -245,6 +298,9 @@ function bucketPromptMarkers(
label: bucket.length === 1
? latest.label
: groupedPromptLabel(bucket.length, latest.label),
preview: bucket.length === 1
? latest.preview
: groupedPromptPreview(bucket.length, latest.preview),
topPercent,
}];
});
@@ -271,6 +327,10 @@ function groupedPromptLabel(count: number, latestLabel: string): string {
return `${count} prompts, latest: ${latestLabel}`;
}
function groupedPromptPreview(count: number, latestPreview: string): string {
return `${count} prompts\n\n${latestPreview}`;
}
function markerWidth(count: number, maxCount: number, active: boolean): number {
if (maxCount <= 1) return active ? 34 : MARKER_BASE_WIDTH_PX;
const density = Math.log2(count + 1) / Math.log2(maxCount + 1);
@@ -279,33 +339,6 @@ function markerWidth(count: number, maxCount: number, active: boolean): number {
return Math.round(active ? width + 4 : width);
}
function jumpToPrompt(scrollEl: HTMLElement | null, promptId: string | undefined): void {
if (!scrollEl || !promptId) return;
const target = findPromptElement(scrollEl, promptId);
if (!target) return;
scrollEl.scrollTo({
top: Math.max(0, promptTop(scrollEl, target) - 16),
behavior: "smooth",
});
}
function findPromptElement(scrollEl: HTMLElement, promptId: string): HTMLElement | null {
const candidates = scrollEl.querySelectorAll<HTMLElement>("[data-user-prompt-id]");
return Array.from(candidates).find(
(candidate) => candidate.dataset.userPromptId === promptId,
) ?? null;
}
function promptTop(scrollEl: HTMLElement, target: HTMLElement): number {
const scrollRect = scrollEl.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const hasLayoutRect = scrollRect.top !== 0 || targetRect.top !== 0;
if (hasLayoutRect) {
return targetRect.top - scrollRect.top + scrollEl.scrollTop;
}
return target.offsetTop;
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
@@ -0,0 +1,224 @@
import { useState } from "react";
import {
CalendarClock,
CircleAlert,
ListTodo,
RefreshCcw,
} from "lucide-react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useSessionAutomationJobs } from "@/hooks/useSessionAutomationJobs";
import { currentLocale } from "@/i18n";
import { fmtDateTime } from "@/lib/format";
import type { SessionAutomationJob } from "@/lib/types";
import { cn } from "@/lib/utils";
const RELATIVE_THRESHOLDS: [number, Intl.RelativeTimeFormatUnit][] = [
[60, "second"],
[60, "minute"],
[24, "hour"],
[7, "day"],
[4.345, "week"],
[12, "month"],
[Number.POSITIVE_INFINITY, "year"],
];
interface SessionInfoPopoverProps {
sessionKey: string;
token: string;
title: string;
}
export function SessionInfoPopover({ sessionKey, token, title }: SessionInfoPopoverProps) {
const { t } = useTranslation("common");
const [open, setOpen] = useState(false);
const { jobs, loading, loadFailed, now } = useSessionAutomationJobs(open, token, sessionKey);
const automationContent = loading ? (
<div className="flex items-center gap-2 rounded-[16px] bg-muted/45 px-3 py-3 text-[12.5px] text-muted-foreground">
<RefreshCcw className="h-3.5 w-3.5 animate-spin" />
{t("thread.sessionInfo.loading")}
</div>
) : loadFailed ? (
<div className="flex items-center gap-2 rounded-[16px] bg-destructive/10 px-3 py-3 text-[12.5px] text-destructive">
<CircleAlert className="h-3.5 w-3.5" />
{t("thread.sessionInfo.loadFailed")}
</div>
) : jobs.length ? (
<div className="space-y-1.5">
{jobs.map((job) => (
<AutomationRow key={job.id} job={job} now={now} />
))}
</div>
) : (
<div className="rounded-[16px] bg-muted/35 px-3 py-3 text-[12.5px] leading-relaxed text-muted-foreground">
{t("thread.sessionInfo.empty")}
</div>
);
return (
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label={t("thread.header.sessionInfo")}
className={cn(
"host-no-drag h-8 w-8 rounded-full text-muted-foreground/85",
"hover:bg-accent/40 hover:text-foreground",
)}
>
<ListTodo className="h-4 w-4 stroke-[1.75]" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={8}
className="w-[min(23rem,calc(100vw-1.5rem))] rounded-[24px] p-0"
>
<div className="space-y-3 px-4 py-3.5">
<div className="min-w-0">
<div className="text-[12px] font-normal text-muted-foreground/75">
{t("thread.sessionInfo.title")}
</div>
<div className="mt-0.5 truncate text-[14px] font-medium text-foreground">
{title || t("thread.sessionInfo.untitled")}
</div>
</div>
<div className="h-px bg-border/45" />
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<CalendarClock className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" />
<span className="truncate text-[13px] font-medium text-foreground">
{t("thread.sessionInfo.automations")}
</span>
</div>
<span className="rounded-full bg-muted/70 px-2 py-0.5 text-[11px] text-muted-foreground">
{t("thread.sessionInfo.count", { count: jobs.length })}
</span>
</div>
{automationContent}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
function AutomationRow({ job, now }: { job: SessionAutomationJob; now: number }) {
const { t } = useTranslation("common");
const schedule = formatSchedule(job, t);
const nextRun = formatNextRun(job, t, now);
const statusClass = job.enabled
? job.state.last_status === "error"
? "bg-destructive"
: "bg-emerald-500"
: "bg-muted-foreground/35";
return (
<div className="rounded-[16px] px-3 py-2.5 transition-colors hover:bg-muted/40">
<div className="flex items-start gap-2.5">
<span className={cn("mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full", statusClass)} />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-[13px] font-medium text-foreground">{job.name}</span>
{!job.enabled ? (
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10.5px] text-muted-foreground">
{t("thread.sessionInfo.disabled")}
</span>
) : null}
</div>
<div className="mt-1 line-clamp-2 text-[12px] leading-snug text-muted-foreground">
{job.payload.message}
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11.5px] text-muted-foreground/80">
<span>{schedule}</span>
<span aria-hidden>·</span>
<span title={nextRun.title}>{nextRun.label}</span>
</div>
</div>
</div>
</div>
);
}
function formatSchedule(job: SessionAutomationJob, t: TFunction) {
const locale = currentLocale();
if (job.schedule.kind === "at" && job.schedule.at_ms) {
return t("thread.sessionInfo.schedule.at", { time: fmtDateTime(job.schedule.at_ms, locale) });
}
if (job.schedule.kind === "every" && job.schedule.every_ms) {
return t("thread.sessionInfo.schedule.every", {
duration: formatDuration(job.schedule.every_ms, locale),
});
}
if (job.schedule.kind === "cron" && job.schedule.expr) {
return job.schedule.tz
? t("thread.sessionInfo.schedule.cronWithTz", {
expr: job.schedule.expr,
tz: job.schedule.tz,
})
: t("thread.sessionInfo.schedule.cron", { expr: job.schedule.expr });
}
return t("thread.sessionInfo.schedule.unknown");
}
function formatNextRun(job: SessionAutomationJob, t: TFunction, now: number) {
const locale = currentLocale();
if (!job.enabled) {
return { label: t("thread.sessionInfo.next.disabled"), title: "" };
}
const next = job.state.next_run_at_ms;
if (!next) {
return { label: t("thread.sessionInfo.next.none"), title: "" };
}
return {
label: t("thread.sessionInfo.next.label", { time: relativeTimeFrom(next, now, locale) }),
title: fmtDateTime(next, locale),
};
}
function relativeTimeFrom(value: number, now: number, locale: string): string {
let delta = (value - now) / 1000;
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
for (const [step, unit] of RELATIVE_THRESHOLDS) {
if (Math.abs(delta) < step) {
return formatter.format(Math.round(delta), unit);
}
delta /= step;
}
return formatter.format(Math.round(delta), "year");
}
function formatDuration(ms: number, locale: string): string {
const units: Array<[Intl.NumberFormatOptions["unit"], number]> = [
["day", 86_400_000],
["hour", 3_600_000],
["minute", 60_000],
["second", 1000],
];
for (const [unit, size] of units) {
if (ms >= size && ms % size === 0) {
return new Intl.NumberFormat(locale, {
style: "unit",
unit,
unitDisplay: "long",
maximumFractionDigits: 0,
}).format(ms / size);
}
}
return new Intl.NumberFormat(locale, {
style: "unit",
unit: "minute",
unitDisplay: "long",
maximumFractionDigits: 1,
}).format(ms / 60_000);
}
+54 -17
View File
@@ -94,6 +94,8 @@ interface ThreadComposerProps {
modelLabel?: string | null;
modelProvider?: string | null;
modelProviderLabel?: string | null;
modelNeedsSetup?: boolean;
onModelBadgeClick?: () => void;
variant?: "thread" | "hero";
slashCommands?: SlashCommand[];
cliApps?: CliAppInfo[];
@@ -647,6 +649,8 @@ export function ThreadComposer({
modelLabel = null,
modelProvider = null,
modelProviderLabel = null,
modelNeedsSetup = false,
onModelBadgeClick,
variant = "thread",
slashCommands = [],
cliApps = [],
@@ -759,17 +763,21 @@ export function ThreadComposer({
);
const hasErrors = images.some((img) => img.status === "error");
const hasComposerContent = value.trim().length > 0 || readyImages.length > 0;
const canSend =
!disabled
&& !modelNeedsSetup
&& !encoding
&& !hasErrors
&& (value.trim().length > 0 || readyImages.length > 0);
&& hasComposerContent;
const canOpenModelSettings = Boolean(modelNeedsSetup && onModelBadgeClick && !disabled);
const canQueueGuidance =
isStreaming
&& !disabled
&& !modelNeedsSetup
&& !encoding
&& !hasErrors
&& (value.trim().length > 0 || readyImages.length > 0)
&& hasComposerContent
&& !value.trimStart().startsWith("/");
const slashQuery = useMemo(() => {
@@ -1181,6 +1189,10 @@ export function ThreadComposer({
}, [onStop, queuedPrompts.length]);
const submit = useCallback(() => {
if (modelNeedsSetup) {
onModelBadgeClick?.();
return;
}
if (!canSend) return;
const trimmed = value.trim();
const content = trimmed;
@@ -1219,6 +1231,8 @@ export function ThreadComposer({
canSend,
clear,
clearComposerText,
modelNeedsSetup,
onModelBadgeClick,
onSend,
readyImages,
value,
@@ -1533,24 +1547,32 @@ export function ThreadComposer({
label={modelLabel}
provider={modelProvider}
providerLabel={modelProviderLabel}
needsSetup={modelNeedsSetup}
isHero={isHero}
onClick={modelNeedsSetup ? onModelBadgeClick : undefined}
/>
) : null}
<Button
type={showStopButton ? "button" : "submit"}
type={showStopButton || modelNeedsSetup ? "button" : "submit"}
size="icon"
disabled={showStopButton ? disabled : !canSend}
aria-label={showStopButton ? t("thread.composer.stop") : t("thread.composer.send")}
onClick={showStopButton ? handleStop : undefined}
disabled={showStopButton ? disabled : !canSend && !canOpenModelSettings}
aria-label={
showStopButton
? t("thread.composer.stop")
: modelNeedsSetup
? t("thread.composer.configureModel", { defaultValue: "Configure model" })
: t("thread.composer.send")
}
onClick={showStopButton ? handleStop : modelNeedsSetup ? onModelBadgeClick : undefined}
className={cn(
"rounded-full transition-transform",
showStopButton
? "border border-border/70 bg-card text-foreground/85 shadow-[0_3px_10px_rgba(15,23,42,0.08)] hover:bg-muted/65 hover:text-foreground disabled:text-muted-foreground/50"
: isHero
? "border border-foreground bg-foreground text-background shadow-[0_4px_12px_rgba(15,23,42,0.20)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80"
: "border border-foreground bg-foreground text-background shadow-[0_3px_10px_rgba(15,23,42,0.18)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80",
? "border border-foreground bg-foreground text-background shadow-[0_4px_12px_rgba(15,23,42,0.20)] hover:bg-foreground/90 disabled:border-foreground disabled:bg-foreground disabled:text-background"
: "border border-foreground bg-foreground text-background shadow-[0_3px_10px_rgba(15,23,42,0.18)] hover:bg-foreground/90 disabled:border-foreground disabled:bg-foreground disabled:text-background",
isHero ? "h-8 w-8" : "h-9 w-9",
(canSend || showStopButton) && "hover:scale-[1.03] active:scale-95",
(canSend || canOpenModelSettings || showStopButton) && "hover:scale-[1.03] active:scale-95",
)}
>
{showStopButton ? (
@@ -1766,44 +1788,59 @@ function ComposerModelBadge({
label,
provider,
providerLabel,
needsSetup,
isHero,
onClick,
}: {
label: string;
provider?: string | null;
providerLabel?: string | null;
needsSetup?: boolean;
isHero: boolean;
onClick?: () => void;
}) {
const inferredProvider = provider || inferProviderFromModelName(label);
const inferredProvider = needsSetup ? null : provider || inferProviderFromModelName(label);
const brand = providerBrand(inferredProvider);
const [logoIndex, setLogoIndex] = useState(0);
const logoUrl = brand?.logoUrls[logoIndex];
const showLogo = !!logoUrl;
const title = providerLabel ? `${label} · ${providerLabel}` : label;
const interactive = Boolean(onClick);
const Container = interactive ? "button" : "span";
useEffect(() => setLogoIndex(0), [inferredProvider]);
return (
<span
<Container
title={title}
type={interactive ? "button" : undefined}
onClick={onClick}
className={cn(
"inline-flex min-w-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/82",
"shadow-[0_2px_8px_rgba(15,23,42,0.045)]",
interactive && "cursor-pointer hover:bg-accent/55 hover:text-foreground",
needsSetup && "border-amber-500/35 bg-amber-50/70 text-amber-900 dark:bg-amber-500/10 dark:text-amber-200",
isHero ? "h-8 max-w-[12.5rem] gap-1.5 px-2 text-[11.5px]" : "h-9 max-w-[12rem] gap-2 px-2.5 text-[12px]",
)}
>
<span
data-testid={inferredProvider ? `composer-model-logo-${inferredProvider}` : "composer-model-logo"}
data-testid={needsSetup ? "composer-model-setup-icon" : inferredProvider ? `composer-model-logo-${inferredProvider}` : "composer-model-logo"}
className={cn(
"grid shrink-0 place-items-center overflow-hidden rounded-full border bg-background",
"grid shrink-0 place-items-center overflow-hidden",
needsSetup
? "text-amber-800 dark:text-amber-200"
: "rounded-full border bg-background",
isHero ? "h-[18px] w-[18px]" : "h-5 w-5",
)}
style={{
borderColor: brand ? `${brand.color}28` : undefined,
boxShadow: brand ? `inset 0 0 0 1px ${brand.color}18` : undefined,
borderColor: !needsSetup && brand ? `${brand.color}28` : undefined,
boxShadow: !needsSetup && brand ? `inset 0 0 0 1px ${brand.color}18` : undefined,
}}
aria-hidden
>
{showLogo ? (
{needsSetup ? (
<CircleHelp className={cn(isHero ? "h-3 w-3" : "h-3.5 w-3.5")} strokeWidth={1.8} />
) : showLogo ? (
<img
src={logoUrl}
alt=""
@@ -1825,7 +1862,7 @@ function ComposerModelBadge({
)}
</span>
<span className="truncate">{label}</span>
</span>
</Container>
);
}
+33 -39
View File
@@ -1,4 +1,5 @@
import { Menu, Moon, Sun } from "lucide-react";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
@@ -10,8 +11,11 @@ interface ThreadHeaderProps {
theme: "light" | "dark";
onToggleTheme: () => void;
hideSidebarToggleForHostChrome?: boolean;
hostChromeTitleInset?: boolean;
hideThemeButton?: boolean;
minimal?: boolean;
promptNavigatorAction?: ReactNode;
sessionInfoAction?: ReactNode;
}
export function ThreadHeader({
@@ -20,39 +24,22 @@ export function ThreadHeader({
theme,
onToggleTheme,
hideSidebarToggleForHostChrome = false,
hostChromeTitleInset = false,
hideThemeButton = false,
minimal = false,
promptNavigatorAction,
sessionInfoAction,
}: ThreadHeaderProps) {
const { t } = useTranslation();
if (minimal) {
return (
<div className="relative z-10 flex h-11 items-center justify-between gap-3 px-3 py-2">
<Button
variant="ghost"
size="icon"
aria-label={t("thread.header.toggleSidebar")}
onClick={onToggleSidebar}
className={cn(
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
hideSidebarToggleForHostChrome && "lg:hidden",
)}
>
<Menu className="h-3.5 w-3.5" />
</Button>
{!hideThemeButton ? (
<ThemeButton
theme={theme}
onToggleTheme={onToggleTheme}
label={t("thread.header.toggleTheme")}
className="ml-auto"
/>
) : null}
</div>
);
}
return (
<div className="relative z-10 flex items-center justify-between gap-3 px-3 py-2">
<div
className={cn(
"relative z-10 flex items-center justify-between gap-3 px-3 py-2",
minimal && "h-11",
!minimal && hostChromeTitleInset && "lg:pl-[128px]",
)}
>
<div className="relative flex min-w-0 items-center gap-2">
<Button
variant="ghost"
@@ -66,21 +53,28 @@ export function ThreadHeader({
>
<Menu className="h-3.5 w-3.5" />
</Button>
<div className="flex min-w-0 items-center rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground">
<span className="max-w-[min(60vw,32rem)] truncate">{title}</span>
</div>
{!minimal ? (
<div className="flex min-w-0 items-center rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground">
<span className="max-w-[min(60vw,32rem)] truncate">{title}</span>
</div>
) : null}
</div>
{!hideThemeButton ? (
<ThemeButton
theme={theme}
onToggleTheme={onToggleTheme}
label={t("thread.header.toggleTheme")}
className="ml-auto shrink-0"
/>
) : null}
<div className="ml-auto flex shrink-0 items-center gap-1">
{sessionInfoAction}
{promptNavigatorAction}
{!hideThemeButton ? (
<ThemeButton
theme={theme}
onToggleTheme={onToggleTheme}
label={t("thread.header.toggleTheme")}
/>
) : null}
</div>
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-full h-4" />
{!minimal ? (
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-full h-4" />
) : null}
</div>
);
}
+12 -3
View File
@@ -14,6 +14,7 @@ interface ThreadMessagesProps {
onLoadEarlier?: () => void;
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
onOpenFilePreview?: (path: string) => void;
}
export type DisplayUnit = TurnUnit;
@@ -33,8 +34,13 @@ export function isFinalAssistantSliceBeforeNextUser(
return true;
}
export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
return normalizeActivityTimeline(messages);
export function buildDisplayUnits(
messages: UIMessage[],
isStreaming = false,
): DisplayUnit[] {
return normalizeActivityTimeline(messages, {
preserveTrailingActivity: isStreaming,
});
}
export function assistantCopyFlags(units: DisplayUnit[]): boolean[] {
@@ -61,9 +67,10 @@ export function ThreadMessages({
onLoadEarlier,
cliApps = [],
mcpPresets = [],
onOpenFilePreview,
}: ThreadMessagesProps) {
const { t } = useTranslation();
const units = useMemo(() => buildDisplayUnits(messages), [messages]);
const units = useMemo(() => buildDisplayUnits(messages, isStreaming), [isStreaming, messages]);
const copyFlags = useMemo(() => assistantCopyFlags(units), [units]);
const liveActivityClusterIndices = useMemo(
() => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(),
@@ -117,6 +124,7 @@ export function ThreadMessages({
turnLatencyMs={unit.turnLatencyMs}
cliApps={cliApps}
mcpPresets={mcpPresets}
onOpenFilePreview={onOpenFilePreview}
/>
) : (
<MessageBubble
@@ -128,6 +136,7 @@ export function ThreadMessages({
}
cliApps={cliApps}
mcpPresets={mcpPresets}
onOpenFilePreview={onOpenFilePreview}
/>
)}
</div>
+281 -118
View File
@@ -1,10 +1,14 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import { useTranslation } from "react-i18next";
import { FilePreviewPanel } from "@/components/FilePreviewPanel";
import { PromptNavigator } from "@/components/thread/PromptNavigator";
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
import { ThreadComposer } from "@/components/thread/ThreadComposer";
import { ThreadHeader } from "@/components/thread/ThreadHeader";
import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
import { ThreadViewport } from "@/components/thread/ThreadViewport";
import { ThreadViewport, type ThreadViewportHandle } from "@/components/thread/ThreadViewport";
import { useNanobotStream, type SendImage, type SendOptions } from "@/hooks/useNanobotStream";
import { useSessionHistory } from "@/hooks/useSessions";
import { fetchCliApps, fetchMcpPresets, fetchSettings, listSlashCommands } from "@/lib/api";
@@ -21,8 +25,6 @@ import {
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
import type {
ChatSummary,
CliAppInfo,
McpPresetInfo,
SettingsPayload,
SlashCommand,
UIMessage,
@@ -51,6 +53,23 @@ function isStaleThreadSnapshot(current: UIMessage[], snapshot: UIMessage[]): boo
return snapshot.every((message, index) => sameMessageShape(current[index], message));
}
const FILE_PREVIEW_DEFAULT_WIDTH = 544;
const FILE_PREVIEW_MIN_WIDTH = 360;
const FILE_PREVIEW_MAX_WIDTH = 860;
const FILE_PREVIEW_MIN_MAIN_WIDTH = 420;
const FILE_PREVIEW_CLOSE_ANIMATION_MS = 320;
function clampFilePreviewWidth(width: number, maxWidth: number): number {
return Math.min(Math.max(width, FILE_PREVIEW_MIN_WIDTH), maxWidth);
}
function maxFilePreviewWidth(containerWidth: number): number {
return Math.max(
FILE_PREVIEW_MIN_WIDTH,
Math.min(FILE_PREVIEW_MAX_WIDTH, containerWidth - FILE_PREVIEW_MIN_MAIN_WIDTH),
);
}
interface ThreadShellProps {
session: ChatSummary | null;
title: string;
@@ -62,6 +81,7 @@ interface ThreadShellProps {
theme?: "light" | "dark";
onToggleTheme?: () => void;
hideSidebarToggleForHostChrome?: boolean;
hostChromeTitleInset?: boolean;
hideThemeButton?: boolean;
hideHeader?: boolean;
workspaceScope?: WorkspaceScopePayload | null;
@@ -71,6 +91,7 @@ interface ThreadShellProps {
workspaceError?: string | null;
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
settingsSnapshot?: SettingsPayload | null;
onOpenModelSettings?: () => void;
}
function toModelBadgeLabel(modelName: string | null): string | null {
@@ -85,6 +106,7 @@ interface ModelBadgeInfo {
label: string | null;
provider: string | null;
providerLabel: string | null;
needsSetup: boolean;
}
function activeModelPreset(settings: SettingsPayload | null): SettingsPayload["model_presets"][number] | null {
@@ -107,12 +129,20 @@ function resolvedModelProvider(settings: SettingsPayload | null, modelName: stri
}
function toModelBadgeInfo(modelName: string | null, settings: SettingsPayload | null): ModelBadgeInfo {
const label = toModelBadgeLabel(modelName || settings?.agent.model || null);
const provider = resolvedModelProvider(settings, modelName || settings?.agent.model || null);
const model = modelName || settings?.agent.model || null;
const label = toModelBadgeLabel(model);
const provider = resolvedModelProvider(settings, model);
const providerRow = provider
? settings?.providers.find((item) => item.name === provider)
: null;
const needsSetup = Boolean(
settings && (!model || !provider || !providerRow || !providerRow.configured),
);
return {
label,
provider,
providerLabel: provider ? providerDisplayLabel(settings?.providers ?? [], provider) : null,
needsSetup,
};
}
@@ -134,6 +164,63 @@ interface PendingFirstMessage {
options?: SendOptions;
}
interface InstalledSettingItemsOptions<Payload, Item> {
token: string;
eventName: string;
fetchPayload: (token: string) => Promise<Payload>;
isPayload: (value: unknown) => value is Payload;
selectItems: (payload: Payload) => Item[];
}
function useInstalledSettingItems<Payload, Item>({
token,
eventName,
fetchPayload,
isPayload,
selectItems,
}: InstalledSettingItemsOptions<Payload, Item>): Item[] {
const [items, setItems] = useState<Item[]>([]);
const refresh = useCallback(async (isCancelled?: () => boolean) => {
try {
const payload = await fetchPayload(token);
if (!isCancelled?.()) setItems(selectItems(payload));
} catch {
if (!isCancelled?.()) setItems([]);
}
}, [fetchPayload, selectItems, token]);
useEffect(() => {
let cancelled = false;
void refresh(() => cancelled);
const refreshOnFocus = () => {
if (document.visibilityState === "hidden") return;
void refresh();
};
const refreshOnChanged = (event: Event) => {
const payload = (event as CustomEvent<unknown>).detail;
if (isPayload(payload)) {
setItems(selectItems(payload));
return;
}
void refresh();
};
window.addEventListener("focus", refreshOnFocus);
document.addEventListener("visibilitychange", refreshOnFocus);
window.addEventListener(eventName, refreshOnChanged);
return () => {
cancelled = true;
window.removeEventListener("focus", refreshOnFocus);
document.removeEventListener("visibilitychange", refreshOnFocus);
window.removeEventListener(eventName, refreshOnChanged);
};
}, [eventName, isPayload, refresh, selectItems]);
return items;
}
export function ThreadShell({
session,
title,
@@ -143,6 +230,7 @@ export function ThreadShell({
theme = "light",
onToggleTheme = () => {},
hideSidebarToggleForHostChrome = false,
hostChromeTitleInset = false,
hideThemeButton = false,
hideHeader = false,
workspaceScope = null,
@@ -152,6 +240,7 @@ export function ThreadShell({
workspaceError = null,
onWorkspaceScopeChange,
settingsSnapshot = null,
onOpenModelSettings,
}: ThreadShellProps) {
const { t } = useTranslation();
const chatId = session?.chatId ?? null;
@@ -166,12 +255,31 @@ export function ThreadShell({
const { client, modelName, token } = useClient();
const [booting, setBooting] = useState(false);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const [cliApps, setCliApps] = useState<CliAppInfo[]>([]);
const [mcpPresets, setMcpPresets] = useState<McpPresetInfo[]>([]);
const cliApps = useInstalledSettingItems({
token,
eventName: CLI_APPS_CHANGED_EVENT,
fetchPayload: fetchCliApps,
isPayload: isCliAppsPayload,
selectItems: installedCliAppsFromPayload,
});
const mcpPresets = useInstalledSettingItems({
token,
eventName: MCP_PRESETS_CHANGED_EVENT,
fetchPayload: fetchMcpPresets,
isPayload: isMcpPresetsPayload,
selectItems: installedMcpPresetsFromPayload,
});
const [settings, setSettings] = useState<SettingsPayload | null>(settingsSnapshot);
const [heroGreetingKey, setHeroGreetingKey] = useState(randomHeroGreetingKey);
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
const [filePreviewPath, setFilePreviewPath] = useState<string | null>(null);
const [filePreviewClosing, setFilePreviewClosing] = useState(false);
const [filePreviewWidth, setFilePreviewWidth] = useState(FILE_PREVIEW_DEFAULT_WIDTH);
const shellRef = useRef<HTMLElement | null>(null);
const filePreviewWidthRef = useRef(FILE_PREVIEW_DEFAULT_WIDTH);
const filePreviewCloseTimerRef = useRef<number | null>(null);
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
const viewportRef = useRef<ThreadViewportHandle | null>(null);
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
/** Last chatId we associated with the in-memory thread (for cache-on-switch). */
const prevChatIdForCacheRef = useRef<string | null>(null);
@@ -204,6 +312,27 @@ export function ThreadShell({
if (chatId && historyKey) sessionKeyByChatIdRef.current.set(chatId, historyKey);
}, [chatId, historyKey]);
useEffect(() => {
filePreviewWidthRef.current = filePreviewWidth;
}, [filePreviewWidth]);
useEffect(() => {
if (filePreviewCloseTimerRef.current !== null) {
window.clearTimeout(filePreviewCloseTimerRef.current);
filePreviewCloseTimerRef.current = null;
}
setFilePreviewClosing(false);
setFilePreviewPath(null);
}, [historyKey]);
useEffect(() => {
return () => {
if (filePreviewCloseTimerRef.current !== null) {
window.clearTimeout(filePreviewCloseTimerRef.current);
}
};
}, []);
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
const showHeroComposer = messages.length === 0 && !loading;
@@ -212,6 +341,9 @@ export function ThreadShell({
() => toModelBadgeInfo(modelName, settings),
[modelName, settings],
);
const modelBadgeLabel = modelBadge.needsSetup
? t("thread.composer.modelNotConfigured", { defaultValue: "Model not configured" })
: modelBadge.label;
useEffect(() => {
if (showHeroComposer && !wasShowingHeroComposerRef.current) {
setHeroGreetingKey(randomHeroGreetingKey());
@@ -372,94 +504,6 @@ export function ThreadShell({
};
}, [token]);
const refreshCliApps = useCallback(async () => {
try {
const payload = await fetchCliApps(token);
setCliApps(installedCliAppsFromPayload(payload));
} catch {
setCliApps([]);
}
}, [token]);
const refreshMcpPresets = useCallback(async () => {
try {
const payload = await fetchMcpPresets(token);
setMcpPresets(installedMcpPresetsFromPayload(payload));
} catch {
setMcpPresets([]);
}
}, [token]);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const payload = await fetchCliApps(token);
if (!cancelled) setCliApps(installedCliAppsFromPayload(payload));
} catch {
if (!cancelled) setCliApps([]);
}
};
load();
const refreshOnFocus = () => {
if (document.visibilityState === "hidden") return;
void refreshCliApps();
};
window.addEventListener("focus", refreshOnFocus);
document.addEventListener("visibilitychange", refreshOnFocus);
const refreshOnCliAppsChanged = (event: Event) => {
const payload = (event as CustomEvent<unknown>).detail;
if (isCliAppsPayload(payload)) {
setCliApps(installedCliAppsFromPayload(payload));
return;
}
void refreshCliApps();
};
window.addEventListener(CLI_APPS_CHANGED_EVENT, refreshOnCliAppsChanged);
return () => {
cancelled = true;
window.removeEventListener("focus", refreshOnFocus);
document.removeEventListener("visibilitychange", refreshOnFocus);
window.removeEventListener(CLI_APPS_CHANGED_EVENT, refreshOnCliAppsChanged);
};
}, [refreshCliApps, token]);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const payload = await fetchMcpPresets(token);
if (!cancelled) setMcpPresets(installedMcpPresetsFromPayload(payload));
} catch {
if (!cancelled) setMcpPresets([]);
}
};
load();
const refreshOnFocus = () => {
if (document.visibilityState === "hidden") return;
void refreshMcpPresets();
};
window.addEventListener("focus", refreshOnFocus);
document.addEventListener("visibilitychange", refreshOnFocus);
const refreshOnMcpPresetsChanged = (event: Event) => {
const payload = (event as CustomEvent<unknown>).detail;
if (isMcpPresetsPayload(payload)) {
setMcpPresets(installedMcpPresetsFromPayload(payload));
return;
}
void refreshMcpPresets();
};
window.addEventListener(MCP_PRESETS_CHANGED_EVENT, refreshOnMcpPresetsChanged);
return () => {
cancelled = true;
window.removeEventListener("focus", refreshOnFocus);
document.removeEventListener("visibilitychange", refreshOnFocus);
window.removeEventListener(MCP_PRESETS_CHANGED_EVENT, refreshOnMcpPresetsChanged);
};
}, [refreshMcpPresets, token]);
const handleWelcomeSend = useCallback(
async (content: string, images?: SendImage[], options?: SendOptions) => {
if (booting) return;
@@ -482,6 +526,94 @@ export function ThreadShell({
[send, withWorkspaceScope],
);
const handleOpenFilePreview = useCallback((path: string) => {
if (filePreviewCloseTimerRef.current !== null) {
window.clearTimeout(filePreviewCloseTimerRef.current);
filePreviewCloseTimerRef.current = null;
}
setFilePreviewClosing(false);
setFilePreviewPath(path);
}, []);
const handleCloseFilePreview = useCallback(() => {
if (!filePreviewPath || filePreviewClosing) return;
setFilePreviewClosing(true);
filePreviewCloseTimerRef.current = window.setTimeout(() => {
filePreviewCloseTimerRef.current = null;
setFilePreviewPath(null);
setFilePreviewClosing(false);
}, FILE_PREVIEW_CLOSE_ANIMATION_MS);
}, [filePreviewClosing, filePreviewPath]);
const handleFilePreviewResizeStart = useCallback((event: ReactPointerEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
const panel = event.currentTarget.closest<HTMLElement>("[data-file-preview-panel]");
const shellRect = shellRef.current?.getBoundingClientRect();
const rightEdge = shellRect?.right ?? window.innerWidth;
const maxWidth = maxFilePreviewWidth(shellRect?.width ?? window.innerWidth);
const originalBodyCursor = document.body.style.cursor;
const originalBodyUserSelect = document.body.style.userSelect;
const originalPanelTransition = panel?.style.transition ?? "";
let nextWidth = filePreviewWidthRef.current;
let frame: number | null = null;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
if (panel) panel.style.transition = "none";
const applyWidth = (clientX: number) => {
nextWidth = clampFilePreviewWidth(rightEdge - clientX, maxWidth);
filePreviewWidthRef.current = nextWidth;
if (frame !== null) return;
frame = window.requestAnimationFrame(() => {
frame = null;
panel?.style.setProperty("--file-preview-width", `${nextWidth}px`);
panel?.style.setProperty("--file-preview-slot-width", `${nextWidth}px`);
});
};
const handlePointerMove = (moveEvent: PointerEvent) => {
moveEvent.preventDefault();
applyWidth(moveEvent.clientX);
};
const handlePointerUp = () => {
if (frame !== null) {
window.cancelAnimationFrame(frame);
frame = null;
}
panel?.style.setProperty("--file-preview-width", `${nextWidth}px`);
panel?.style.setProperty("--file-preview-slot-width", `${nextWidth}px`);
if (panel) panel.style.transition = originalPanelTransition;
setFilePreviewWidth(nextWidth);
document.body.style.cursor = originalBodyCursor;
document.body.style.userSelect = originalBodyUserSelect;
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
};
applyWidth(event.clientX);
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
}, []);
useEffect(() => {
if (!filePreviewPath) return;
const clampToShell = () => {
const shellWidth = shellRef.current?.getBoundingClientRect().width ?? window.innerWidth;
const maxWidth = maxFilePreviewWidth(shellWidth);
const nextWidth = clampFilePreviewWidth(filePreviewWidthRef.current, maxWidth);
filePreviewWidthRef.current = nextWidth;
setFilePreviewWidth(nextWidth);
};
clampToShell();
window.addEventListener("resize", clampToShell);
return () => {
window.removeEventListener("resize", clampToShell);
};
}, [filePreviewPath]);
const composer = (
<>
{streamError ? (
@@ -500,9 +632,11 @@ export function ThreadShell({
? t("thread.composer.placeholderHero")
: t("thread.composer.placeholderThread")
}
modelLabel={modelBadge.label}
modelLabel={modelBadgeLabel}
modelProvider={modelBadge.provider}
modelProviderLabel={modelBadge.providerLabel}
modelNeedsSetup={modelBadge.needsSetup}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
variant={showHeroComposer ? "hero" : "thread"}
slashCommands={slashCommands}
cliApps={cliApps}
@@ -528,9 +662,11 @@ export function ThreadShell({
? t("thread.composer.placeholderOpening")
: t("thread.composer.placeholderHero")
}
modelLabel={modelBadge.label}
modelLabel={modelBadgeLabel}
modelProvider={modelBadge.provider}
modelProviderLabel={modelBadge.providerLabel}
modelNeedsSetup={modelBadge.needsSetup}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
variant="hero"
slashCommands={slashCommands}
cliApps={cliApps}
@@ -559,31 +695,58 @@ export function ThreadShell({
</h1>
</div>
);
const sessionInfoAction = historyKey ? (
<SessionInfoPopover sessionKey={historyKey} token={token} title={title} />
) : undefined;
const promptNavigatorAction = historyKey ? (
<PromptNavigator
messages={displayMessages}
onJumpToPrompt={(promptId) => viewportRef.current?.jumpToUserPrompt(promptId)}
/>
) : undefined;
return (
<section className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
{!hideHeader ? (
<ThreadHeader
title={title}
onToggleSidebar={onToggleSidebar}
theme={theme}
onToggleTheme={onToggleTheme}
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
hideThemeButton={hideThemeButton}
minimal={!session && !loading}
<section ref={shellRef} className="relative flex min-h-0 flex-1 overflow-hidden">
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
{!hideHeader ? (
<ThreadHeader
title={title}
onToggleSidebar={onToggleSidebar}
theme={theme}
onToggleTheme={onToggleTheme}
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
hostChromeTitleInset={hostChromeTitleInset}
hideThemeButton={hideThemeButton}
minimal={!session && !loading}
promptNavigatorAction={promptNavigatorAction}
sessionInfoAction={sessionInfoAction}
/>
) : null}
<ThreadViewport
ref={viewportRef}
messages={displayMessages}
isStreaming={isStreaming}
emptyState={emptyState}
composer={composer}
scrollToBottomSignal={scrollToBottomSignal}
conversationKey={historyKey}
showScrollToBottomButton={!!session}
cliApps={cliApps}
mcpPresets={mcpPresets}
onOpenFilePreview={historyKey ? handleOpenFilePreview : undefined}
/>
</div>
{filePreviewPath && historyKey ? (
<FilePreviewPanel
sessionKey={historyKey}
path={filePreviewPath}
token={token}
desktopWidth={filePreviewWidth}
isClosing={filePreviewClosing}
onResizeStart={handleFilePreviewResizeStart}
onClose={handleCloseFilePreview}
/>
) : null}
<ThreadViewport
messages={displayMessages}
isStreaming={isStreaming}
emptyState={emptyState}
composer={composer}
scrollToBottomSignal={scrollToBottomSignal}
conversationKey={historyKey}
showScrollToBottomButton={!!session}
cliApps={cliApps}
mcpPresets={mcpPresets}
/>
</section>
);
}
+58 -16
View File
@@ -1,7 +1,9 @@
import {
forwardRef,
type ReactNode,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
@@ -14,9 +16,17 @@ import { PromptRail } from "@/components/thread/PromptRail";
import { ThreadMessages } from "@/components/thread/ThreadMessages";
import { isAgentActivityMember } from "@/components/thread/AgentActivityCluster";
import { Button } from "@/components/ui/button";
import {
findPromptElement,
jumpToPrompt,
} from "@/components/thread/promptNavigation";
import { cn } from "@/lib/utils";
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
export interface ThreadViewportHandle {
jumpToUserPrompt: (promptId: string) => void;
}
interface ThreadViewportProps {
messages: UIMessage[];
isStreaming: boolean;
@@ -27,6 +37,7 @@ interface ThreadViewportProps {
showScrollToBottomButton?: boolean;
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
onOpenFilePreview?: (path: string) => void;
}
const NEAR_BOTTOM_PX = 48;
@@ -48,7 +59,7 @@ export function windowMessages(messages: UIMessage[], visibleCount: number): UIM
return messages.slice(start);
}
export function ThreadViewport({
export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportProps>(function ThreadViewport({
messages,
isStreaming,
composer,
@@ -58,7 +69,8 @@ export function ThreadViewport({
showScrollToBottomButton = true,
cliApps = [],
mcpPresets = [],
}: ThreadViewportProps) {
onOpenFilePreview,
}, ref) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
@@ -66,6 +78,7 @@ export function ThreadViewport({
const bottomRef = useRef<HTMLDivElement>(null);
const lastConversationKeyRef = useRef<string | null>(conversationKey);
const pendingConversationScrollRef = useRef(true);
const pendingPromptJumpRef = useRef<string | null>(null);
const scrollFrameIdsRef = useRef<number[]>([]);
const restoreScrollAfterPrependRef =
useRef<{ height: number; top: number } | null>(null);
@@ -139,6 +152,22 @@ export function ThreadViewport({
);
}, [messages.length]);
const jumpToUserPrompt = useCallback((promptId: string) => {
const scrollEl = scrollRef.current;
if (scrollEl && findPromptElement(scrollEl, promptId)) {
jumpToPrompt(scrollEl, promptId);
return;
}
const index = messages.findIndex((message) => message.id === promptId);
if (index < 0) return;
pendingPromptJumpRef.current = promptId;
userReadingHistoryRef.current = true;
setAtBottom(false);
setVisibleMessageCount((count) => Math.max(count, messages.length - index));
}, [messages]);
useImperativeHandle(ref, () => ({ jumpToUserPrompt }), [jumpToUserPrompt]);
const measureComposerDock = useCallback(() => {
const el = composerDockRef.current;
if (!el) return;
@@ -180,6 +209,15 @@ export function ThreadViewport({
el.scrollTop = pending.top + delta;
}, [visibleMessages.length]);
useLayoutEffect(() => {
const promptId = pendingPromptJumpRef.current;
const scrollEl = scrollRef.current;
if (!promptId || !scrollEl || !findPromptElement(scrollEl, promptId)) return;
pendingPromptJumpRef.current = null;
const frame = window.requestAnimationFrame(() => jumpToPrompt(scrollEl, promptId));
return () => window.cancelAnimationFrame(frame);
}, [visibleMessages.length]);
useLayoutEffect(() => {
if (!pendingConversationScrollRef.current) return;
if (!conversationKey) {
@@ -256,6 +294,7 @@ export function ThreadViewport({
onLoadEarlier={loadEarlierMessages}
cliApps={cliApps}
mcpPresets={mcpPresets}
onOpenFilePreview={onOpenFilePreview}
/>
</div>
</div>
@@ -299,22 +338,25 @@ export function ThreadViewport({
) : null}
{showScrollToBottomButton && !atBottom && (
<Button
variant="outline"
size="icon"
onClick={() => scrollToBottom(true, 1, { force: true })}
className={cn(
/* Keep clear of sticky composer (textarea + toolbar + optional goal strip). */
"absolute left-1/2 z-20 h-8 w-8 -translate-x-1/2 rounded-full shadow-md",
"bg-background/90 backdrop-blur",
"animate-in fade-in-0 zoom-in-95",
)}
<div
className="absolute left-1/2 z-20 -translate-x-1/2"
style={{ bottom: scrollButtonBottom }}
aria-label={t("thread.scrollToBottom")}
>
<ArrowDown className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => scrollToBottom(true, 1, { force: true })}
className={cn(
"h-8 w-8 rounded-full shadow-md",
"bg-background/90 backdrop-blur",
"animate-in fade-in-0 zoom-in-95",
)}
aria-label={t("thread.scrollToBottom")}
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
)}
</div>
);
}
});
@@ -106,7 +106,7 @@ export function WorkspaceProjectPicker({
if (nativeProjectPicker) {
return (
<div className="flex items-center border-t border-border/25 bg-muted/60 px-4 py-1.5 dark:bg-white/[0.055]">
<div className="flex items-center rounded-b-[28px] border-t border-border/25 bg-muted/60 px-4 py-1.5 dark:bg-white/[0.055]">
<button
type="button"
disabled={disabled || pickingFolder}
@@ -133,7 +133,7 @@ export function WorkspaceProjectPicker({
}
return (
<div className="flex items-center border-t border-border/25 bg-muted/60 px-4 py-1.5 dark:bg-white/[0.055]">
<div className="flex items-center rounded-b-[28px] border-t border-border/25 bg-muted/60 px-4 py-1.5 dark:bg-white/[0.055]">
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<button
@@ -22,18 +22,34 @@ export interface FileEditSummary {
error?: string;
}
export function FileEditGroup({ edits }: { edits: FileEditSummary[] }) {
export function FileEditGroup({
edits,
onOpenFilePreview,
}: {
edits: FileEditSummary[];
onOpenFilePreview?: (path: string) => void;
}) {
if (edits.length === 0) return null;
return (
<ul className="space-y-1">
{edits.map((edit) => (
<FileEditRow key={edit.key} edit={edit} />
<FileEditRow
key={edit.key}
edit={edit}
onOpenFilePreview={onOpenFilePreview}
/>
))}
</ul>
);
}
function FileEditRow({ edit }: { edit: FileEditSummary }) {
function FileEditRow({
edit,
onOpenFilePreview,
}: {
edit: FileEditSummary;
onOpenFilePreview?: (path: string) => void;
}) {
const { t } = useTranslation();
const editing = edit.status === "editing";
const failed = edit.status === "error";
@@ -76,6 +92,8 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
<FileReferenceChip
path={edit.path}
tooltipPath={edit.absolute_path}
previewPath={edit.absolute_path || edit.path}
onOpen={onOpenFilePreview}
display="path"
active={editing}
className="min-w-0"
@@ -10,9 +10,11 @@ import { ActivityStep } from "./ActivityStep";
export function ReasoningRow({
text,
streaming,
onOpenFilePreview,
}: {
text: string;
streaming: boolean;
onOpenFilePreview?: (path: string) => void;
}) {
const { t } = useTranslation();
useEffect(() => {
@@ -30,6 +32,7 @@ export function ReasoningRow({
{text.trim() ? (
<MarkdownText
streaming={streaming}
onOpenFilePreview={onOpenFilePreview}
className={cn(
"min-w-0 text-[12.5px] italic text-muted-foreground/78",
"prose-p:my-1 prose-li:my-0.5",
@@ -0,0 +1,64 @@
import type { UIMessage } from "@/lib/types";
export interface PromptAnchor {
id: string;
label: string;
preview: string;
createdAt: number;
index: number;
}
export function userPromptAnchors(messages: UIMessage[]): PromptAnchor[] {
let index = 0;
return messages.flatMap((message) => {
if (message.role !== "user") return [];
const anchor: PromptAnchor = {
id: message.id,
label: promptLabel(message.content, index),
preview: promptPreview(message.content, index),
createdAt: message.createdAt,
index,
};
index += 1;
return [anchor];
});
}
export function promptLabel(content: string, index: number): string {
const text = content.replace(/\s+/g, " ").trim();
if (!text) return `Prompt ${index + 1}`;
return text.length > 80 ? `${text.slice(0, 77)}...` : text;
}
export function promptPreview(content: string, index: number): string {
const text = content.replace(/\n{3,}/g, "\n\n").trim();
if (!text) return `Prompt ${index + 1}`;
return text.length > 320 ? `${text.slice(0, 317)}...` : text;
}
export function jumpToPrompt(scrollEl: HTMLElement | null, promptId: string | undefined): void {
if (!scrollEl || !promptId) return;
const target = findPromptElement(scrollEl, promptId);
if (!target) return;
scrollEl.scrollTo({
top: Math.max(0, promptTop(scrollEl, target) - 16),
behavior: "smooth",
});
}
export function findPromptElement(scrollEl: HTMLElement, promptId: string): HTMLElement | null {
const candidates = scrollEl.querySelectorAll<HTMLElement>("[data-user-prompt-id]");
return Array.from(candidates).find(
(candidate) => candidate.dataset.userPromptId === promptId,
) ?? null;
}
export function promptTop(scrollEl: HTMLElement, target: HTMLElement): number {
const scrollRect = scrollEl.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const hasLayoutRect = scrollRect.top !== 0 || targetRect.top !== 0;
if (hasLayoutRect) {
return targetRect.top - scrollRect.top + scrollEl.scrollTop;
}
return target.offsetTop;
}