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