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";
3636import { threadEnvironment } from "../../state/threads";
3737import { useAtomCommand } from "../../state/use-atom-command";
3838import { ProjectFavicon } from "../ProjectFavicon";
39import {
40 WorkspaceBreadcrumb,
41 WorkspaceBreadcrumbItem,
42 WorkspaceBreadcrumbSeparator,
43} from "../WorkspaceBreadcrumb";
3944import { cn } from "~/lib/utils";
4045
4146interface ChatHeaderProps {
......@@ -211,91 +216,88 @@ export const ChatHeader = memo(function ChatHeader({
211216 className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3"
212217 onContextMenu={handleHeaderContextMenu}
213218 >
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">
215220 {/* The project always leads the header: knowing which project a
216221 thread lives in is priority zero, and the thread title alone
217222 doesn't answer it. */}
218223 {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 ? (
220266 <Tooltip>
221267 <TooltipTrigger
222268 render={
223269 <button
270 ref={titleButtonRef}
224271 type="button"
225 aria-label={`New thread in ${activeProjectName}`}
226 onClick={onNewThreadInProject}
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"
272 aria-label={`Thread actions for ${activeThreadTitle}`}
273 aria-haspopup="menu"
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"
228276 />
229277 }
230278 >
231 <ProjectFavicon
232 environmentId={activeThreadEnvironmentId}
233 cwd={activeProjectCwd ?? ""}
234 faviconPath={activeProjectFaviconPath}
235 className="size-3.5"
279 <h2 className="min-w-0 truncate">{activeThreadTitle}</h2>
280 <ChevronDownIcon
281 aria-hidden
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"
236283 />
237 <span className="max-w-40 truncate text-sm font-medium">{activeProjectName}</span>
238284 </TooltipTrigger>
239 <TooltipPopup side="top">New thread in {activeProjectName}</TooltipPopup>
285 <TooltipPopup side="top">{activeThreadTitle}</TooltipPopup>
240286 </Tooltip>
241 <span aria-hidden className="text-icon-muted">
242 /
243 </span>
244 </span>
245 ) : null}
246 {renamingTitle !== null ? (
247 <input
248 autoFocus
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"
287 ) : (
288 <Tooltip>
289 <TooltipTrigger
290 render={
291 <h2 aria-label={activeThreadTitle} className="min-w-0 flex-1 truncate">
292 {activeThreadTitle}
293 </h2>
294 }
279295 />
280 </TooltipTrigger>
281 <TooltipPopup side="top">{activeThreadTitle}</TooltipPopup>
282 </Tooltip>
283 ) : (
284 <Tooltip>
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>
296 <TooltipPopup side="top">{activeThreadTitle}</TooltipPopup>
297 </Tooltip>
298 )}
299 </WorkspaceBreadcrumbItem>
300 </WorkspaceBreadcrumb>
299301 <div
300302 data-chat-header-actions
301303 className={cn(
apps/web/src/components/settings/ProjectSettingsPanel.tsx+56-39
......@@ -13,6 +13,7 @@ import {
1313 selectProjectGroupingSettings,
1414} from "../../logicalProject";
1515import type {
16 ContextMenuItem,
1617 ModelSelection,
1718 ProviderDriverKind,
1819 SidebarProjectGroupingMode,
......@@ -25,7 +26,14 @@ import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
2526import { useCanGoBack, useNavigate } from "@tanstack/react-router";
2627import * as Cause from "effect/Cause";
2728import { 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
3038import { useComposerDraftStore } from "../../composerDraftStore";
3139import { isElectron } from "../../env";
......@@ -89,6 +97,11 @@ import {
8997import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
9098import { SidebarInset } from "../ui/sidebar";
9199import { stackedThreadToast, toastManager } from "../ui/toast";
100import {
101 WorkspaceBreadcrumb,
102 WorkspaceBreadcrumbItem,
103 WorkspaceBreadcrumbSeparator,
104} from "../WorkspaceBreadcrumb";
92105import {
93106 SettingResetButton,
94107 SettingsPageContainer,
......@@ -191,48 +204,52 @@ function ProjectSettingsBreadcrumb({ projectKey }: { projectKey: string }) {
191204 const groups = useSettingsProjectGroups();
192205 const navigate = useNavigate();
193206 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
195229 return (
196 <nav
197 aria-label="Project settings breadcrumb"
198 className="flex min-w-0 items-center gap-2 overflow-hidden [-webkit-app-region:no-drag] sm:gap-3"
199 >
200 <span className="shrink-0 text-sm font-medium text-muted-foreground">Projects</span>
201 <span aria-hidden="true" className="shrink-0 text-icon-muted">
202 /
203 </span>
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
230 <WorkspaceBreadcrumb ariaLabel="Project settings breadcrumb">
231 <WorkspaceBreadcrumbItem>Projects</WorkspaceBreadcrumbItem>
232 <WorkspaceBreadcrumbSeparator />
233 <WorkspaceBreadcrumbItem current>
234 {selected ? (
235 <button
236 type="button"
237 aria-haspopup="menu"
217238 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"
219 size="xs"
220 variant="ghost"
239 onClick={openProjectMenu}
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"
221241 >
222 <SelectValue>{selected.displayName}</SelectValue>
223 </SelectTrigger>
224 <SelectPopup align="start" alignItemWithTrigger={false}>
225 {groups.map((candidate) => (
226 <SelectItem key={candidate.projectKey} hideIndicator value={candidate.projectKey}>
227 {candidate.displayName}
228 </SelectItem>
229 ))}
230 </SelectPopup>
231 </Select>
232 ) : (
233 <span className="truncate text-sm text-muted-foreground">Unavailable project</span>
234 )}
235 </nav>
242 <span className="min-w-0 truncate">{selected.displayName}</span>
243 <ChevronDownIcon
244 aria-hidden
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"
246 />
247 </button>
248 ) : (
249 <span className="truncate text-muted-foreground">Unavailable project</span>
250 )}
251 </WorkspaceBreadcrumbItem>
252 </WorkspaceBreadcrumb>
236253 );
237254}
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 {
1616} from "@t3tools/shared/usageFormat";
1717import { ScrollArea } from "../ui/scroll-area";
1818import { SidebarInset } from "../ui/sidebar";
19import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem } from "../WorkspaceBreadcrumb";
1920import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar";
2021import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart";
2122import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders";
......@@ -71,7 +72,9 @@ export function UsagePage() {
7172 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
7273 )}
7374 >
74 <h1 className="text-sm font-medium text-foreground">Usage</h1>
75 <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb">
76 <WorkspaceBreadcrumbItem current>Usage</WorkspaceBreadcrumbItem>
77 </WorkspaceBreadcrumb>
7578 </header>
7679 )}
7780
......@@ -82,7 +85,9 @@ export function UsagePage() {
8285 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
8386 )}
8487 >
85 <h1 className="text-sm font-medium text-foreground">Usage</h1>
88 <WorkspaceBreadcrumb ariaLabel="Usage breadcrumb">
89 <WorkspaceBreadcrumbItem current>Usage</WorkspaceBreadcrumbItem>
90 </WorkspaceBreadcrumb>
8691 </div>
8792 )}
8893
apps/web/src/routes/settings.tsx+10-9
......@@ -10,6 +10,7 @@ import {
1010import { useCallback, useEffect, useState } from "react";
1111
1212import { useSettingsRestore } from "../components/settings/SettingsPanels";
13import { SettingsBreadcrumb } from "../components/settings/SettingsBreadcrumb";
1314import { Button } from "../components/ui/button";
1415import { SidebarInset } from "../components/ui/sidebar";
1516import { isElectron } from "../env";
......@@ -79,7 +80,7 @@ function SettingsContentLayout() {
7980 )}
8081 >
8182 <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} />
8384 {showRestoreDefaults ? (
8485 <div className="ms-auto flex items-center gap-2">
8586 <RestoreDefaultsButton onRestored={handleRestored} />
......@@ -96,14 +97,14 @@ function SettingsContentLayout() {
9697 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
9798 )}
9899 >
99 <span className="text-xs font-medium tracking-wide text-muted-foreground/70">
100 Settings
101 </span>
102 {showRestoreDefaults ? (
103 <div className="ms-auto flex items-center gap-2">
104 <RestoreDefaultsButton onRestored={handleRestored} />
105 </div>
106 ) : null}
100 <div className="flex w-full items-center gap-2">
101 <SettingsBreadcrumb pathname={location.pathname} />
102 {showRestoreDefaults ? (
103 <div className="ms-auto flex items-center gap-2">
104 <RestoreDefaultsButton onRestored={handleRestored} />
105 </div>
106 ) : null}
107 </div>
107108 </div>
108109 )}
109110