| author | |
| committer | |
| log | 9c9916aefe18586b305e1d973d609e60bd6de4c0 |
| tree | d7209f60070a95bd3b4f4cf9562218d4310aed06 |
| parent | 936394b6f2cdd430d809f09a28f14b9a7c230a28 |
6 files changed, 159 insertions(+), 16 deletions(-)
apps/web/src/components/Sidebar.logic.test.ts+51| ... | ... | @@ -20,6 +20,7 @@ import { |
| 20 | 20 | resolveThreadRowClassName, |
| 21 | 21 | resolveSidebarV2Status, |
| 22 | 22 | resolveThreadStatusPill, |
| 23 | shouldNavigateAfterProjectRemoval, | |
| 23 | 24 | shouldClearThreadSelectionOnMouseDown, |
| 24 | 25 | sortThreadsForSidebarV2, |
| 25 | 26 | sortProjectsForSidebar, |
| ... | ... | @@ -42,6 +43,56 @@ import { |
| 42 | 43 | |
| 43 | 44 | const localEnvironmentId = EnvironmentId.make("environment-local"); |
| 44 | 45 | |
| 46 | describe("shouldNavigateAfterProjectRemoval", () => { | |
| 47 | const projectThreads = [{ environmentId: "environment-local", id: "thread-1" }]; | |
| 48 | ||
| 49 | it("navigates away from a draft route owned by the removed project", () => { | |
| 50 | expect( | |
| 51 | shouldNavigateAfterProjectRemoval({ | |
| 52 | routeTarget: { kind: "draft", draftId: "draft-1" as never }, | |
| 53 | projectThreads, | |
| 54 | projectDraftId: "draft-1", | |
| 55 | }), | |
| 56 | ).toBe(true); | |
| 57 | }); | |
| 58 | ||
| 59 | it("does not navigate away from a different draft route", () => { | |
| 60 | expect( | |
| 61 | shouldNavigateAfterProjectRemoval({ | |
| 62 | routeTarget: { kind: "draft", draftId: "draft-2" as never }, | |
| 63 | projectThreads, | |
| 64 | projectDraftId: "draft-1", | |
| 65 | }), | |
| 66 | ).toBe(false); | |
| 67 | }); | |
| 68 | ||
| 69 | it("navigates away from a server thread owned by the removed project", () => { | |
| 70 | expect( | |
| 71 | shouldNavigateAfterProjectRemoval({ | |
| 72 | routeTarget: { | |
| 73 | kind: "server", | |
| 74 | threadRef: { | |
| 75 | environmentId: EnvironmentId.make("environment-local"), | |
| 76 | threadId: ThreadId.make("thread-1"), | |
| 77 | }, | |
| 78 | }, | |
| 79 | projectThreads, | |
| 80 | projectDraftId: null, | |
| 81 | }), | |
| 82 | ).toBe(true); | |
| 83 | }); | |
| 84 | ||
| 85 | it("does not navigate from an unrelated route", () => { | |
| 86 | expect( | |
| 87 | shouldNavigateAfterProjectRemoval({ | |
| 88 | routeTarget: null, | |
| 89 | projectThreads, | |
| 90 | projectDraftId: null, | |
| 91 | }), | |
| 92 | ).toBe(false); | |
| 93 | }); | |
| 94 | }); | |
| 95 | ||
| 45 | 96 | describe("archiveSelectedThreadEntries", () => { |
| 46 | 97 | const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const; |
| 47 | 98 | const success = { _tag: "Success" } as const; |
apps/web/src/components/Sidebar.logic.ts+23| ... | ... | @@ -8,6 +8,7 @@ import { |
| 8 | 8 | type ThreadSortInput, |
| 9 | 9 | } from "../lib/threadSort"; |
| 10 | 10 | import type { SidebarThreadSummary, Thread } from "../types"; |
| 11 | import type { ThreadRouteTarget } from "../threadRoutes"; | |
| 11 | 12 | import { cn } from "../lib/utils"; |
| 12 | 13 | import { isLatestTurnSettled } from "../session-logic"; |
| 13 | 14 | import { resolveServerBackedAppStageLabel } from "../branding.logic"; |
| ... | ... | @@ -377,6 +378,28 @@ export function resolveAdjacentThreadId<T>(input: { |
| 377 | 378 | return currentIndex < threadIds.length - 1 ? (threadIds[currentIndex + 1] ?? null) : null; |
| 378 | 379 | } |
| 379 | 380 | |
| 381 | export function shouldNavigateAfterProjectRemoval(input: { | |
| 382 | routeTarget: ThreadRouteTarget | null; | |
| 383 | projectThreads: readonly { | |
| 384 | environmentId: string; | |
| 385 | id: string; | |
| 386 | }[]; | |
| 387 | projectDraftId: string | null; | |
| 388 | }): boolean { | |
| 389 | const { projectDraftId, projectThreads, routeTarget } = input; | |
| 390 | if (routeTarget?.kind === "draft") { | |
| 391 | return projectDraftId === routeTarget.draftId; | |
| 392 | } | |
| 393 | if (routeTarget?.kind !== "server") { | |
| 394 | return false; | |
| 395 | } | |
| 396 | return projectThreads.some( | |
| 397 | (thread) => | |
| 398 | thread.environmentId === routeTarget.threadRef.environmentId && | |
| 399 | thread.id === routeTarget.threadRef.threadId, | |
| 400 | ); | |
| 401 | } | |
| 402 | ||
| 380 | 403 | export function isContextMenuPointerDown(input: { |
| 381 | 404 | button: number; |
| 382 | 405 | ctrlKey: boolean; |
apps/web/src/components/SidebarV2.tsx+14-12| ... | ... | @@ -76,7 +76,7 @@ import { threadEnvironment } from "../state/threads"; |
| 76 | 76 | import { projectEnvironment } from "../state/projects"; |
| 77 | 77 | import { useEnvironmentQuery } from "../state/query"; |
| 78 | 78 | import { useAtomCommand } from "../state/use-atom-command"; |
| 79 | import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; | |
| 79 | import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; | |
| 80 | 80 | import { formatRelativeTimeLabel } from "../timestampFormat"; |
| 81 | 81 | import type { SidebarThreadSummary } from "../types"; |
| 82 | 82 | import { cn } from "~/lib/utils"; |
| ... | ... | @@ -86,6 +86,7 @@ import { |
| 86 | 86 | isTrailingDoubleClick, |
| 87 | 87 | resolveAdjacentThreadId, |
| 88 | 88 | resolveSidebarV2Status, |
| 89 | shouldNavigateAfterProjectRemoval, | |
| 89 | 90 | sortThreadsForSidebarV2, |
| 90 | 91 | } from "./Sidebar.logic"; |
| 91 | 92 | import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; |
| ... | ... | @@ -740,11 +741,14 @@ export default function SidebarV2() { |
| 740 | 741 | const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); |
| 741 | 742 | const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo); |
| 742 | 743 | const markThreadUnread = useUiStateStore((s) => s.markThreadUnread); |
| 743 | const routeThreadRef = useParams({ | |
| 744 | const routeTarget = useParams({ | |
| 744 | 745 | strict: false, |
| 745 | select: (params) => resolveThreadRouteRef(params), | |
| 746 | select: (params) => resolveThreadRouteTarget(params), | |
| 746 | 747 | }); |
| 748 | const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; | |
| 747 | 749 | const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; |
| 750 | const routeTargetRef = useRef(routeTarget); | |
| 751 | routeTargetRef.current = routeTarget; | |
| 748 | 752 | // Post-settle navigation validates against the CURRENT route, not the one |
| 749 | 753 | // captured when the settle started: if the user navigated elsewhere while |
| 750 | 754 | // the command was in flight, completing it must not yank them away. |
| ... | ... | @@ -894,23 +898,21 @@ export default function SidebarV2() { |
| 894 | 898 | |
| 895 | 899 | const draftStore = useComposerDraftStore.getState(); |
| 896 | 900 | const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef); |
| 901 | const shouldNavigate = shouldNavigateAfterProjectRemoval({ | |
| 902 | routeTarget: routeTargetRef.current, | |
| 903 | projectThreads, | |
| 904 | projectDraftId: projectDraftThread?.draftId ?? null, | |
| 905 | }); | |
| 897 | 906 | if (projectDraftThread) { |
| 898 | 907 | draftStore.clearDraftThread(projectDraftThread.draftId); |
| 899 | 908 | } |
| 900 | 909 | draftStore.clearProjectDraftThreadId(projectRef); |
| 901 | 910 | |
| 902 | if ( | |
| 903 | routeThreadRef && | |
| 904 | projectThreads.some( | |
| 905 | (thread) => | |
| 906 | thread.environmentId === routeThreadRef.environmentId && | |
| 907 | thread.id === routeThreadRef.threadId, | |
| 908 | ) | |
| 909 | ) { | |
| 911 | if (shouldNavigate) { | |
| 910 | 912 | void router.navigate({ to: "/" }); |
| 911 | 913 | } |
| 912 | 914 | }, |
| 913 | [deleteProject, routeThreadRef, router, threads], | |
| 915 | [deleteProject, router, threads], | |
| 914 | 916 | ); |
| 915 | 917 | |
| 916 | 918 | // Settled threads stay in the live shell stream (settled ≠ archived), so |
apps/web/src/components/chat/ProviderStatusBanner.test.tsx created+33| ... | ... | @@ -0,0 +1,33 @@ |
| 1 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; | |
| 2 | import { renderToStaticMarkup } from "react-dom/server"; | |
| 3 | import { describe, expect, it } from "vite-plus/test"; | |
| 4 | ||
| 5 | import { ProviderStatusBanner } from "./ProviderStatusBanner"; | |
| 6 | ||
| 7 | function warningProvider(): ServerProvider { | |
| 8 | return { | |
| 9 | instanceId: ProviderInstanceId.make("codex"), | |
| 10 | driver: ProviderDriverKind.make("codex"), | |
| 11 | displayName: "Codex", | |
| 12 | enabled: true, | |
| 13 | installed: true, | |
| 14 | version: "1.0.0", | |
| 15 | status: "warning", | |
| 16 | auth: { status: "authenticated" }, | |
| 17 | checkedAt: "2026-07-23T12:00:00.000Z", | |
| 18 | message: "Provider is temporarily degraded.", | |
| 19 | models: [], | |
| 20 | slashCommands: [], | |
| 21 | skills: [], | |
| 22 | }; | |
| 23 | } | |
| 24 | ||
| 25 | describe("ProviderStatusBanner", () => { | |
| 26 | it("renders an accessible dismiss control for provider warnings", () => { | |
| 27 | const markup = renderToStaticMarkup(<ProviderStatusBanner status={warningProvider()} />); | |
| 28 | ||
| 29 | expect(markup).toContain('role="alert"'); | |
| 30 | expect(markup).toContain('aria-label="Dismiss Codex provider warning"'); | |
| 31 | expect(markup).toContain("absolute top-2 right-2"); | |
| 32 | }); | |
| 33 | }); |
apps/web/src/components/chat/ProviderStatusBanner.tsx+26-3| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { type ServerProvider } from "@t3tools/contracts"; |
| 2 | import { memo } from "react"; | |
| 3 | import { InfoIcon } from "lucide-react"; | |
| 2 | import { memo, useEffect, useState } from "react"; | |
| 3 | import { InfoIcon, XIcon } from "lucide-react"; | |
| 4 | 4 | import { cn } from "~/lib/utils"; |
| 5 | 5 | import { formatProviderDriverKindLabel } from "../../providerModels"; |
| 6 | 6 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| ... | ... | @@ -10,9 +10,24 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({ |
| 10 | 10 | }: { |
| 11 | 11 | status: ServerProvider | null; |
| 12 | 12 | }) { |
| 13 | const bannerKey = | |
| 14 | !status || status.status === "ready" || status.status === "disabled" | |
| 15 | ? null | |
| 16 | : [status.instanceId, status.status, status.auth.status, status.message ?? ""].join("\u0000"); | |
| 17 | const [dismissedBannerKey, setDismissedBannerKey] = useState<string | null>(null); | |
| 18 | ||
| 19 | useEffect(() => { | |
| 20 | if (bannerKey === null && dismissedBannerKey !== null) { | |
| 21 | setDismissedBannerKey(null); | |
| 22 | } | |
| 23 | }, [bannerKey, dismissedBannerKey]); | |
| 24 | ||
| 13 | 25 | if (!status || status.status === "ready" || status.status === "disabled") { |
| 14 | 26 | return null; |
| 15 | 27 | } |
| 28 | if (bannerKey === dismissedBannerKey) { | |
| 29 | return null; | |
| 30 | } | |
| 16 | 31 | |
| 17 | 32 | const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver); |
| 18 | 33 | const isUnauthenticated = status.status === "error" && status.auth.status === "unauthenticated"; |
| ... | ... | @@ -30,7 +45,7 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({ |
| 30 | 45 | <div className="pointer-events-auto mx-auto w-fit max-w-[calc(100%-2rem)] pt-3"> |
| 31 | 46 | <div |
| 32 | 47 | className={cn( |
| 33 | "inline-flex items-center gap-3 rounded-xl border px-3.5 py-3 text-card-foreground text-sm", | |
| 48 | "relative inline-flex items-center gap-3 rounded-xl border py-3 ps-3.5 pe-10 text-card-foreground text-sm", | |
| 34 | 49 | status.status === "warning" |
| 35 | 50 | ? "border-warning/32 bg-warning/4 [&_svg]:text-warning" |
| 36 | 51 | : "border-destructive/32 bg-destructive/4 text-destructive-foreground [&_svg]:text-destructive", |
| ... | ... | @@ -49,6 +64,14 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({ |
| 49 | 64 | </TooltipPopup> |
| 50 | 65 | </Tooltip> |
| 51 | 66 | </div> |
| 67 | <button | |
| 68 | type="button" | |
| 69 | aria-label={`Dismiss ${providerName} provider warning`} | |
| 70 | className="absolute top-2 right-2 inline-flex size-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-foreground/8 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring" | |
| 71 | onClick={() => setDismissedBannerKey(bannerKey)} | |
| 72 | > | |
| 73 | <XIcon aria-hidden className="size-3.5" /> | |
| 74 | </button> | |
| 52 | 75 | </div> |
| 53 | 76 | </div> |
| 54 | 77 | ); |
apps/web/src/index.css+12-1| ... | ... | @@ -591,6 +591,17 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 591 | 591 | appearance: auto; |
| 592 | 592 | accent-color: Highlight; |
| 593 | 593 | } |
| 594 | ||
| 595 | .glass-opacity-slider::-webkit-slider-runnable-track, | |
| 596 | .glass-opacity-slider::-webkit-slider-thumb { | |
| 597 | all: revert; | |
| 598 | } | |
| 599 | ||
| 600 | .glass-opacity-slider::-moz-range-track, | |
| 601 | .glass-opacity-slider::-moz-range-progress, | |
| 602 | .glass-opacity-slider::-moz-range-thumb { | |
| 603 | all: revert; | |
| 604 | } | |
| 594 | 605 | } |
| 595 | 606 | |
| 596 | 607 | @media (min-width: 40rem) { |
| ... | ... | @@ -620,7 +631,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 620 | 631 | .command-menu-surface, |
| 621 | 632 | .command-palette-dialog-surface, |
| 622 | 633 | .workflow-dialog-surface { |
| 623 | background: var(--popover); | |
| 634 | background: var(--popover) !important; | |
| 624 | 635 | } |
| 625 | 636 | } |
| 626 | 637 | } |