authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-08-10 04:12:19+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-10 02:12:19+00:00
log96906805f1677a3b064ebd2c40902cbb48337a23
tree98a53b8da83377d5c92e0e697bd6d63251ed62bf
parent73b2e8fdd47807fcc0fc5bfe4928187035b71242
signature Signed by PGP key B5690EEEBB952194

Add settings and usage breadcrumbs (#5929)


6 files changed, 232 insertions(+), 119 deletions(-)

apps/web/src/components/WorkspaceBreadcrumb.tsx created+54
...@@ -0,0 +1,54 @@
1import type { ReactNode } from "react";
2
3import { cn } from "../lib/utils";
4
5interface WorkspaceBreadcrumbProps {
6 readonly ariaLabel: string;
7 readonly children: ReactNode;
8 readonly className?: string;
9}
10
11export function WorkspaceBreadcrumb({ ariaLabel, children, className }: WorkspaceBreadcrumbProps) {
12 return (
13 <nav aria-label={ariaLabel} className={cn("min-w-0", className)}>
14 {/* Keep the flexible container draggable in Electron. Interactive
15 descendants are excluded by the shared .drag-region CSS rules. */}
16 <ol className="m-0 flex min-w-0 list-none items-center gap-2 p-0 text-sm sm:gap-3">
17 {children}
18 </ol>
19 </nav>
20 );
21}
22
23interface WorkspaceBreadcrumbItemProps {
24 readonly children: ReactNode;
25 readonly className?: string;
26 readonly current?: boolean;
27}
28
29export function WorkspaceBreadcrumbItem({
30 children,
31 className,
32 current = false,
33}: WorkspaceBreadcrumbItemProps) {
34 return (
35 <li
36 aria-current={current ? "page" : undefined}
37 className={cn(
38 "flex min-w-0 items-center font-medium",
39 current ? "text-foreground" : "shrink-0 text-muted-foreground",
40 className,
41 )}
42 >
43 {children}
44 </li>
45 );
46}
47
48export function WorkspaceBreadcrumbSeparator() {
49 return (
50 <li aria-hidden="true" className="flex shrink-0 items-center text-icon-muted">
51 /
52 </li>
53 );
54}
apps/web/src/components/chat/ChatHeader.tsx+71-69
...@@ -36,6 +36,11 @@ import { useThreadActionMenu } from "~/hooks/useThreadActionMenu";...@@ -36,6 +36,11 @@ import { useThreadActionMenu } from "~/hooks/useThreadActionMenu";
36import { threadEnvironment } from "../../state/threads";36import { threadEnvironment } from "../../state/threads";
37import { useAtomCommand } from "../../state/use-atom-command";37import { useAtomCommand } from "../../state/use-atom-command";
38import { ProjectFavicon } from "../ProjectFavicon";38import { ProjectFavicon } from "../ProjectFavicon";
39import {
40 WorkspaceBreadcrumb,
41 WorkspaceBreadcrumbItem,
42 WorkspaceBreadcrumbSeparator,
43} from "../WorkspaceBreadcrumb";
39import { cn } from "~/lib/utils";44import { cn } from "~/lib/utils";
4045
41interface ChatHeaderProps {46interface ChatHeaderProps {
...@@ -211,91 +216,88 @@ export const ChatHeader = memo(function ChatHeader({...@@ -211,91 +216,88 @@ export const ChatHeader = memo(function ChatHeader({
211 className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3"216 className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3"
212 onContextMenu={handleHeaderContextMenu}217 onContextMenu={handleHeaderContextMenu}
213 >218 >
214 <div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden sm:gap-3">219 <WorkspaceBreadcrumb ariaLabel="Thread breadcrumb" className="flex-1">
215 {/* The project always leads the header: knowing which project a220 {/* The project always leads the header: knowing which project a
216 thread lives in is priority zero, and the thread title alone221 thread lives in is priority zero, and the thread title alone
217 doesn't answer it. */}222 doesn't answer it. */}
218 {activeProjectName ? (223 {activeProjectName ? (
219 <span className="inline-flex shrink-0 items-center gap-2">224 <>
225 <WorkspaceBreadcrumbItem>
226 <Tooltip>
227 <TooltipTrigger
228 render={
229 <button
230 type="button"
231 aria-label={`New thread in ${activeProjectName}`}
232 onClick={onNewThreadInProject}
233 className="inline-flex min-w-0 cursor-pointer items-center gap-1.5 rounded-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
234 />
235 }
236 >
237 <ProjectFavicon
238 environmentId={activeThreadEnvironmentId}
239 cwd={activeProjectCwd ?? ""}
240 faviconPath={activeProjectFaviconPath}
241 className="size-3.5"
242 />
243 <span className="max-w-40 truncate">{activeProjectName}</span>
244 </TooltipTrigger>
245 <TooltipPopup side="top">New thread in {activeProjectName}</TooltipPopup>
246 </Tooltip>
247 </WorkspaceBreadcrumbItem>
248 <WorkspaceBreadcrumbSeparator />
249 </>
250 ) : null}
251 <WorkspaceBreadcrumbItem current className="flex-1">
252 {renamingTitle !== null ? (
253 <input
254 autoFocus
255 aria-label="Thread title"
256 className="min-w-0 flex-1 rounded-sm bg-transparent text-sm font-medium text-foreground outline-none ring-1 ring-ring/50 focus:ring-ring"
257 defaultValue={renamingTitle}
258 onBlur={(event) => {
259 if (renameCommittedRef.current) return;
260 commitRename(event.currentTarget.value);
261 }}
262 onFocus={(event) => event.currentTarget.select()}
263 onKeyDown={handleRenameKeyDown}
264 />
265 ) : isServerThread ? (
220 <Tooltip>266 <Tooltip>
221 <TooltipTrigger267 <TooltipTrigger
222 render={268 render={
223 <button269 <button
270 ref={titleButtonRef}
224 type="button"271 type="button"
225 aria-label={`New thread in ${activeProjectName}`}272 aria-label={`Thread actions for ${activeThreadTitle}`}
226 onClick={onNewThreadInProject}273 aria-haspopup="menu"
227 className="inline-flex min-w-0 cursor-pointer items-center gap-1.5 rounded-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"274 onClick={openMenuFromTitle}
275 className="group/thread-title inline-flex min-w-0 max-w-full cursor-pointer items-center gap-1 rounded-sm text-left focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
228 />276 />
229 }277 }
230 >278 >
231 <ProjectFavicon279 <h2 className="min-w-0 truncate">{activeThreadTitle}</h2>
232 environmentId={activeThreadEnvironmentId}280 <ChevronDownIcon
233 cwd={activeProjectCwd ?? ""}281 aria-hidden
234 faviconPath={activeProjectFaviconPath}282 className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/thread-title:opacity-100 group-focus-visible/thread-title:opacity-100"
235 className="size-3.5"
236 />283 />
237 <span className="max-w-40 truncate text-sm font-medium">{activeProjectName}</span>
238 </TooltipTrigger>284 </TooltipTrigger>
239 <TooltipPopup side="top">New thread in {activeProjectName}</TooltipPopup>285 <TooltipPopup side="top">{activeThreadTitle}</TooltipPopup>
240 </Tooltip>286 </Tooltip>
241 <span aria-hidden className="text-icon-muted">287 ) : (
242 /288 <Tooltip>
243 </span>289 <TooltipTrigger
244 </span>290 render={
245 ) : null}291 <h2 aria-label={activeThreadTitle} className="min-w-0 flex-1 truncate">
246 {renamingTitle !== null ? (292 {activeThreadTitle}
247 <input293 </h2>
248 autoFocus294 }
249 aria-label="Thread title"
250 className="min-w-0 flex-1 rounded-sm bg-transparent text-sm font-medium text-foreground outline-none ring-1 ring-ring/50 focus:ring-ring"
251 defaultValue={renamingTitle}
252 onBlur={(event) => {
253 if (renameCommittedRef.current) return;
254 commitRename(event.currentTarget.value);
255 }}
256 onFocus={(event) => event.currentTarget.select()}
257 onKeyDown={handleRenameKeyDown}
258 />
259 ) : isServerThread ? (
260 <Tooltip>
261 <TooltipTrigger
262 render={
263 <button
264 ref={titleButtonRef}
265 type="button"
266 aria-label={`Thread actions for ${activeThreadTitle}`}
267 aria-haspopup="menu"
268 onClick={openMenuFromTitle}
269 className="group/thread-title inline-flex min-w-0 cursor-pointer items-center gap-1 rounded-sm text-left focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
270 />
271 }
272 >
273 <h2 className="min-w-0 truncate text-sm font-medium text-foreground">
274 {activeThreadTitle}
275 </h2>
276 <ChevronDownIcon
277 aria-hidden
278 className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/thread-title:opacity-100 group-focus-visible/thread-title:opacity-100"
279 />295 />
280 </TooltipTrigger>296 <TooltipPopup side="top">{activeThreadTitle}</TooltipPopup>
281 <TooltipPopup side="top">{activeThreadTitle}</TooltipPopup>297 </Tooltip>
282 </Tooltip>298 )}
283 ) : (299 </WorkspaceBreadcrumbItem>
284 <Tooltip>300 </WorkspaceBreadcrumb>
285 <TooltipTrigger
286 render={
287 <h2
288 aria-label={activeThreadTitle}
289 className="min-w-0 flex-1 truncate text-sm font-medium text-foreground"
290 >
291 {activeThreadTitle}
292 </h2>
293 }
294 />
295 <TooltipPopup side="top">{activeThreadTitle}</TooltipPopup>
296 </Tooltip>
297 )}
298 </div>
299 <div301 <div
300 data-chat-header-actions302 data-chat-header-actions
301 className={cn(303 className={cn(
apps/web/src/components/settings/ProjectSettingsPanel.tsx+56-39
...@@ -13,6 +13,7 @@ import {...@@ -13,6 +13,7 @@ import {
13 selectProjectGroupingSettings,13 selectProjectGroupingSettings,
14} from "../../logicalProject";14} from "../../logicalProject";
15import type {15import type {
16 ContextMenuItem,
16 ModelSelection,17 ModelSelection,
17 ProviderDriverKind,18 ProviderDriverKind,
18 SidebarProjectGroupingMode,19 SidebarProjectGroupingMode,
...@@ -25,7 +26,14 @@ import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";...@@ -25,7 +26,14 @@ import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
25import { useCanGoBack, useNavigate } from "@tanstack/react-router";26import { useCanGoBack, useNavigate } from "@tanstack/react-router";
26import * as Cause from "effect/Cause";27import * as Cause from "effect/Cause";
27import { ChevronDownIcon, CopyIcon, PlusIcon, SettingsIcon, Trash2Icon } from "lucide-react";28import { ChevronDownIcon, CopyIcon, PlusIcon, SettingsIcon, Trash2Icon } from "lucide-react";
28import { useCallback, useEffect, useMemo, useRef, useState } from "react";29import {
30 useCallback,
31 useEffect,
32 useMemo,
33 useRef,
34 useState,
35 type MouseEvent as ReactMouseEvent,
36} from "react";
2937
30import { useComposerDraftStore } from "../../composerDraftStore";38import { useComposerDraftStore } from "../../composerDraftStore";
31import { isElectron } from "../../env";39import { isElectron } from "../../env";
...@@ -89,6 +97,11 @@ import {...@@ -89,6 +97,11 @@ import {
89import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";97import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
90import { SidebarInset } from "../ui/sidebar";98import { SidebarInset } from "../ui/sidebar";
91import { stackedThreadToast, toastManager } from "../ui/toast";99import { stackedThreadToast, toastManager } from "../ui/toast";
100import {
101 WorkspaceBreadcrumb,
102 WorkspaceBreadcrumbItem,
103 WorkspaceBreadcrumbSeparator,
104} from "../WorkspaceBreadcrumb";
92import {105import {
93 SettingResetButton,106 SettingResetButton,
94 SettingsPageContainer,107 SettingsPageContainer,
...@@ -191,48 +204,52 @@ function ProjectSettingsBreadcrumb({ projectKey }: { projectKey: string }) {...@@ -191,48 +204,52 @@ function ProjectSettingsBreadcrumb({ projectKey }: { projectKey: string }) {
191 const groups = useSettingsProjectGroups();204 const groups = useSettingsProjectGroups();
192 const navigate = useNavigate();205 const navigate = useNavigate();
193 const selected = groups.find((group) => group.projectKey === projectKey) ?? null;206 const selected = groups.find((group) => group.projectKey === projectKey) ?? null;
207 const openProjectMenu = (event: ReactMouseEvent<HTMLButtonElement>) => {
208 const api = readLocalApi();
209 if (!api) return;
210
211 const rect = event.currentTarget.getBoundingClientRect();
212 const items: ContextMenuItem<string>[] = groups.map((group) => ({
213 id: group.projectKey,
214 label: group.displayName,
215 }));
216 void settlePromise(() =>
217 api.contextMenu.show(items, { x: rect.left, y: rect.bottom + 4 }),
218 ).then((clicked) => {
219 if (clicked._tag === "Failure" || clicked.value === null) return;
220 void navigate({
221 to: "/projects/$projectKey",
222 params: { projectKey: clicked.value },
223 replace: true,
224 hashScrollIntoView: false,
225 });
226 });
227 };
194228
195 return (229 return (
196 <nav230 <WorkspaceBreadcrumb ariaLabel="Project settings breadcrumb">
197 aria-label="Project settings breadcrumb"231 <WorkspaceBreadcrumbItem>Projects</WorkspaceBreadcrumbItem>
198 className="flex min-w-0 items-center gap-2 overflow-hidden [-webkit-app-region:no-drag] sm:gap-3"232 <WorkspaceBreadcrumbSeparator />
199 >233 <WorkspaceBreadcrumbItem current>
200 <span className="shrink-0 text-sm font-medium text-muted-foreground">Projects</span>234 {selected ? (
201 <span aria-hidden="true" className="shrink-0 text-icon-muted">235 <button
202 /236 type="button"
203 </span>237 aria-haspopup="menu"
204 {selected ? (
205 <Select
206 value={selected.projectKey}
207 onValueChange={(value) =>
208 void navigate({
209 to: "/projects/$projectKey",
210 params: { projectKey: String(value) },
211 replace: true,
212 hashScrollIntoView: false,
213 })
214 }
215 >
216 <SelectTrigger
217 aria-label="Switch project"238 aria-label="Switch project"
218 className="h-auto min-h-0 min-w-0 max-w-64 gap-1 border-0 px-0 py-0 text-sm font-medium text-foreground sm:h-auto sm:min-h-0 sm:text-sm"239 onClick={openProjectMenu}
219 size="xs"240 className="group/project-title inline-flex min-w-0 max-w-64 cursor-pointer items-center gap-1 rounded-sm text-left focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
220 variant="ghost"
221 >241 >
222 <SelectValue>{selected.displayName}</SelectValue>242 <span className="min-w-0 truncate">{selected.displayName}</span>
223 </SelectTrigger>243 <ChevronDownIcon
224 <SelectPopup align="start" alignItemWithTrigger={false}>244 aria-hidden
225 {groups.map((candidate) => (245 className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/project-title:opacity-100 group-focus-visible/project-title:opacity-100"
226 <SelectItem key={candidate.projectKey} hideIndicator value={candidate.projectKey}>246 />
227 {candidate.displayName}247 </button>
228 </SelectItem>248 ) : (
229 ))}249 <span className="truncate text-muted-foreground">Unavailable project</span>
230 </SelectPopup>250 )}
231 </Select>251 </WorkspaceBreadcrumbItem>
232 ) : (252 </WorkspaceBreadcrumb>
233 <span className="truncate text-sm text-muted-foreground">Unavailable project</span>
234 )}
235 </nav>
236 );253 );
237}254}
238255
apps/web/src/components/settings/SettingsBreadcrumb.tsx created+34
...@@ -0,0 +1,34 @@
1import {
2 WorkspaceBreadcrumb,
3 WorkspaceBreadcrumbItem,
4 WorkspaceBreadcrumbSeparator,
5} from "../WorkspaceBreadcrumb";
6import { SETTINGS_SECTION_LABELS } from "./settingsSearch";
7
8const SETTINGS_BREADCRUMB_LABELS: Readonly<Record<string, string>> = {
9 ...SETTINGS_SECTION_LABELS,
10 "/settings/diagnostics": "Diagnostics",
11};
12
13function settingsBreadcrumbLabel(pathname: string): string | null {
14 const normalizedPathname = pathname.replace(/\/+$/, "") || "/";
15 return SETTINGS_BREADCRUMB_LABELS[normalizedPathname] ?? null;
16}
17
18export function SettingsBreadcrumb({ pathname }: { pathname: string }) {
19 const sectionLabel = settingsBreadcrumbLabel(pathname);
20
21 return (
22 <WorkspaceBreadcrumb ariaLabel="Settings breadcrumb">
23 {sectionLabel ? (
24 <>
25 <WorkspaceBreadcrumbItem>Settings</WorkspaceBreadcrumbItem>
26 <WorkspaceBreadcrumbSeparator />
27 </>
28 ) : null}
29 <WorkspaceBreadcrumbItem current className="truncate">
30 {sectionLabel ?? "Settings"}
31 </WorkspaceBreadcrumbItem>
32 </WorkspaceBreadcrumb>
33 );
34}
apps/web/src/components/usage/UsagePage.tsx+7-2
...@@ -16,6 +16,7 @@ import {...@@ -16,6 +16,7 @@ import {
16} from "@t3tools/shared/usageFormat";16} from "@t3tools/shared/usageFormat";
17import { ScrollArea } from "../ui/scroll-area";17import { ScrollArea } from "../ui/scroll-area";
18import { SidebarInset } from "../ui/sidebar";18import { SidebarInset } from "../ui/sidebar";
19import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem } from "../WorkspaceBreadcrumb";
19import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar";20import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar";
20import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart";21import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart";
21import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders";22import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders";
...@@ -71,7 +72,9 @@ export function UsagePage() {...@@ -71,7 +72,9 @@ export function UsagePage() {
71 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,72 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
72 )}73 )}
73 >74 >
74 <h1 className="text-sm font-medium text-foreground">Usage</h1>75 <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb">
76 <WorkspaceBreadcrumbItem current>Usage</WorkspaceBreadcrumbItem>
77 </WorkspaceBreadcrumb>
75 </header>78 </header>
76 )}79 )}
7780
...@@ -82,7 +85,9 @@ export function UsagePage() {...@@ -82,7 +85,9 @@ export function UsagePage() {
82 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,85 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
83 )}86 )}
84 >87 >
85 <h1 className="text-sm font-medium text-foreground">Usage</h1>88 <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb">
89 <WorkspaceBreadcrumbItem current>Usage</WorkspaceBreadcrumbItem>
90 </WorkspaceBreadcrumb>
86 </div>91 </div>
87 )}92 )}
8893
apps/web/src/routes/settings.tsx+10-9
...@@ -10,6 +10,7 @@ import {...@@ -10,6 +10,7 @@ import {
10import { useCallback, useEffect, useState } from "react";10import { useCallback, useEffect, useState } from "react";
1111
12import { useSettingsRestore } from "../components/settings/SettingsPanels";12import { useSettingsRestore } from "../components/settings/SettingsPanels";
13import { SettingsBreadcrumb } from "../components/settings/SettingsBreadcrumb";
13import { Button } from "../components/ui/button";14import { Button } from "../components/ui/button";
14import { SidebarInset } from "../components/ui/sidebar";15import { SidebarInset } from "../components/ui/sidebar";
15import { isElectron } from "../env";16import { isElectron } from "../env";
...@@ -79,7 +80,7 @@ function SettingsContentLayout() {...@@ -79,7 +80,7 @@ function SettingsContentLayout() {
79 )}80 )}
80 >81 >
81 <div className="flex w-full items-center gap-2">82 <div className="flex w-full items-center gap-2">
82 <span className="text-sm font-medium text-foreground">Settings</span>83 <SettingsBreadcrumb pathname={location.pathname} />
83 {showRestoreDefaults ? (84 {showRestoreDefaults ? (
84 <div className="ms-auto flex items-center gap-2">85 <div className="ms-auto flex items-center gap-2">
85 <RestoreDefaultsButton onRestored={handleRestored} />86 <RestoreDefaultsButton onRestored={handleRestored} />
...@@ -96,14 +97,14 @@ function SettingsContentLayout() {...@@ -96,14 +97,14 @@ function SettingsContentLayout() {
96 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,97 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
97 )}98 )}
98 >99 >
99 <span className="text-xs font-medium tracking-wide text-muted-foreground/70">100 <div className="flex w-full items-center gap-2">
100 Settings101 <SettingsBreadcrumb pathname={location.pathname} />
101 </span>102 {showRestoreDefaults ? (
102 {showRestoreDefaults ? (103 <div className="ms-auto flex items-center gap-2">
103 <div className="ms-auto flex items-center gap-2">104 <RestoreDefaultsButton onRestored={handleRestored} />
104 <RestoreDefaultsButton onRestored={handleRestored} />105 </div>
105 </div>106 ) : null}
106 ) : null}107 </div>
107 </div>108 </div>
108 )}109 )}
109110