diff --git a/apps/web/src/components/WorkspaceBreadcrumb.tsx b/apps/web/src/components/WorkspaceBreadcrumb.tsx new file mode 100644 index 00000000000..3c67e4d3269 --- /dev/null +++ b/apps/web/src/components/WorkspaceBreadcrumb.tsx @@ -0,0 +1,54 @@ +import type { ReactNode } from "react"; + +import { cn } from "../lib/utils"; + +interface WorkspaceBreadcrumbProps { + readonly ariaLabel: string; + readonly children: ReactNode; + readonly className?: string; +} + +export function WorkspaceBreadcrumb({ ariaLabel, children, className }: WorkspaceBreadcrumbProps) { + return ( + + ); +} + +interface WorkspaceBreadcrumbItemProps { + readonly children: ReactNode; + readonly className?: string; + readonly current?: boolean; +} + +export function WorkspaceBreadcrumbItem({ + children, + className, + current = false, +}: WorkspaceBreadcrumbItemProps) { + return ( +
  • + {children} +
  • + ); +} + +export function WorkspaceBreadcrumbSeparator() { + return ( + + ); +} diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index c5307ee7482..58d5ba5395d 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -36,6 +36,11 @@ import { useThreadActionMenu } from "~/hooks/useThreadActionMenu"; import { threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; import { ProjectFavicon } from "../ProjectFavicon"; +import { + WorkspaceBreadcrumb, + WorkspaceBreadcrumbItem, + WorkspaceBreadcrumbSeparator, +} from "../WorkspaceBreadcrumb"; import { cn } from "~/lib/utils"; interface ChatHeaderProps { @@ -211,91 +216,88 @@ export const ChatHeader = memo(function ChatHeader({ className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3" onContextMenu={handleHeaderContextMenu} > -
    + {/* The project always leads the header: knowing which project a thread lives in is priority zero, and the thread title alone doesn't answer it. */} {activeProjectName ? ( - + <> + + + + } + > + + {activeProjectName} + + New thread in {activeProjectName} + + + + + ) : null} + + {renamingTitle !== null ? ( + { + if (renameCommittedRef.current) return; + commitRename(event.currentTarget.value); + }} + onFocus={(event) => event.currentTarget.select()} + onKeyDown={handleRenameKeyDown} + /> + ) : isServerThread ? ( } > - {activeThreadTitle} + - {activeProjectName} - New thread in {activeProjectName} + {activeThreadTitle} - - / - - - ) : null} - {renamingTitle !== null ? ( - { - if (renameCommittedRef.current) return; - commitRename(event.currentTarget.value); - }} - onFocus={(event) => event.currentTarget.select()} - onKeyDown={handleRenameKeyDown} - /> - ) : isServerThread ? ( - - - } - > -

    - {activeThreadTitle} -

    - + + {activeThreadTitle} + + } /> - - {activeThreadTitle} -
    - ) : ( - - - {activeThreadTitle} - - } - /> - {activeThreadTitle} - - )} -
    + {activeThreadTitle} + + )} + +
    group.projectKey === projectKey) ?? null; + const openProjectMenu = (event: ReactMouseEvent) => { + const api = readLocalApi(); + if (!api) return; + + const rect = event.currentTarget.getBoundingClientRect(); + const items: ContextMenuItem[] = groups.map((group) => ({ + id: group.projectKey, + label: group.displayName, + })); + void settlePromise(() => + api.contextMenu.show(items, { x: rect.left, y: rect.bottom + 4 }), + ).then((clicked) => { + if (clicked._tag === "Failure" || clicked.value === null) return; + void navigate({ + to: "/projects/$projectKey", + params: { projectKey: clicked.value }, + replace: true, + hashScrollIntoView: false, + }); + }); + }; return ( - + {selected.displayName} + + + ) : ( + Unavailable project + )} + + ); } diff --git a/apps/web/src/components/settings/SettingsBreadcrumb.tsx b/apps/web/src/components/settings/SettingsBreadcrumb.tsx new file mode 100644 index 00000000000..bb631187cb1 --- /dev/null +++ b/apps/web/src/components/settings/SettingsBreadcrumb.tsx @@ -0,0 +1,34 @@ +import { + WorkspaceBreadcrumb, + WorkspaceBreadcrumbItem, + WorkspaceBreadcrumbSeparator, +} from "../WorkspaceBreadcrumb"; +import { SETTINGS_SECTION_LABELS } from "./settingsSearch"; + +const SETTINGS_BREADCRUMB_LABELS: Readonly> = { + ...SETTINGS_SECTION_LABELS, + "/settings/diagnostics": "Diagnostics", +}; + +function settingsBreadcrumbLabel(pathname: string): string | null { + const normalizedPathname = pathname.replace(/\/+$/, "") || "/"; + return SETTINGS_BREADCRUMB_LABELS[normalizedPathname] ?? null; +} + +export function SettingsBreadcrumb({ pathname }: { pathname: string }) { + const sectionLabel = settingsBreadcrumbLabel(pathname); + + return ( + + {sectionLabel ? ( + <> + Settings + + + ) : null} + + {sectionLabel ?? "Settings"} + + + ); +} diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 4357bbd7e00..b550d673f6e 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -16,6 +16,7 @@ import { } from "@t3tools/shared/usageFormat"; import { ScrollArea } from "../ui/scroll-area"; import { SidebarInset } from "../ui/sidebar"; +import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem } from "../WorkspaceBreadcrumb"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar"; import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; @@ -71,7 +72,9 @@ export function UsagePage() { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, )} > -

    Usage

    + + Usage + )} @@ -82,7 +85,9 @@ export function UsagePage() { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, )} > -

    Usage

    + + Usage +
    )} diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index ed2c132aae4..f14793ba544 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -10,6 +10,7 @@ import { import { useCallback, useEffect, useState } from "react"; import { useSettingsRestore } from "../components/settings/SettingsPanels"; +import { SettingsBreadcrumb } from "../components/settings/SettingsBreadcrumb"; import { Button } from "../components/ui/button"; import { SidebarInset } from "../components/ui/sidebar"; import { isElectron } from "../env"; @@ -79,7 +80,7 @@ function SettingsContentLayout() { )} >
    - Settings + {showRestoreDefaults ? (
    @@ -96,14 +97,14 @@ function SettingsContentLayout() { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, )} > - - Settings - - {showRestoreDefaults ? ( -
    - -
    - ) : null} +
    + + {showRestoreDefaults ? ( +
    + +
    + ) : null} +
    )}