authorgravatar for maria@kuuro.netmaria-rcks <maria@kuuro.net> 2026-07-23 00:28:05-04:00
committergravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-07-23 01:13:29-04:00
log9c9916aefe18586b305e1d973d609e60bd6de4c0
treed7209f60070a95bd3b4f4cf9562218d4310aed06
parent936394b6f2cdd430d809f09a28f14b9a7c230a28

fix(web): address redesign review findings


6 files changed, 159 insertions(+), 16 deletions(-)

apps/web/src/components/Sidebar.logic.test.ts+51
...@@ -20,6 +20,7 @@ import {...@@ -20,6 +20,7 @@ import {
20 resolveThreadRowClassName,20 resolveThreadRowClassName,
21 resolveSidebarV2Status,21 resolveSidebarV2Status,
22 resolveThreadStatusPill,22 resolveThreadStatusPill,
23 shouldNavigateAfterProjectRemoval,
23 shouldClearThreadSelectionOnMouseDown,24 shouldClearThreadSelectionOnMouseDown,
24 sortThreadsForSidebarV2,25 sortThreadsForSidebarV2,
25 sortProjectsForSidebar,26 sortProjectsForSidebar,
...@@ -42,6 +43,56 @@ import {...@@ -42,6 +43,56 @@ import {
4243
43const localEnvironmentId = EnvironmentId.make("environment-local");44const localEnvironmentId = EnvironmentId.make("environment-local");
4445
46describe("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
45describe("archiveSelectedThreadEntries", () => {96describe("archiveSelectedThreadEntries", () => {
46 const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const;97 const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const;
47 const success = { _tag: "Success" } as const;98 const success = { _tag: "Success" } as const;
apps/web/src/components/Sidebar.logic.ts+23
...@@ -8,6 +8,7 @@ import {...@@ -8,6 +8,7 @@ import {
8 type ThreadSortInput,8 type ThreadSortInput,
9} from "../lib/threadSort";9} from "../lib/threadSort";
10import type { SidebarThreadSummary, Thread } from "../types";10import type { SidebarThreadSummary, Thread } from "../types";
11import type { ThreadRouteTarget } from "../threadRoutes";
11import { cn } from "../lib/utils";12import { cn } from "../lib/utils";
12import { isLatestTurnSettled } from "../session-logic";13import { isLatestTurnSettled } from "../session-logic";
13import { resolveServerBackedAppStageLabel } from "../branding.logic";14import { resolveServerBackedAppStageLabel } from "../branding.logic";
...@@ -377,6 +378,28 @@ export function resolveAdjacentThreadId<T>(input: {...@@ -377,6 +378,28 @@ export function resolveAdjacentThreadId<T>(input: {
377 return currentIndex < threadIds.length - 1 ? (threadIds[currentIndex + 1] ?? null) : null;378 return currentIndex < threadIds.length - 1 ? (threadIds[currentIndex + 1] ?? null) : null;
378}379}
379380
381export 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
380export function isContextMenuPointerDown(input: {403export function isContextMenuPointerDown(input: {
381 button: number;404 button: number;
382 ctrlKey: boolean;405 ctrlKey: boolean;
apps/web/src/components/SidebarV2.tsx+14-12
...@@ -76,7 +76,7 @@ import { threadEnvironment } from "../state/threads";...@@ -76,7 +76,7 @@ import { threadEnvironment } from "../state/threads";
76import { projectEnvironment } from "../state/projects";76import { projectEnvironment } from "../state/projects";
77import { useEnvironmentQuery } from "../state/query";77import { useEnvironmentQuery } from "../state/query";
78import { useAtomCommand } from "../state/use-atom-command";78import { useAtomCommand } from "../state/use-atom-command";
79import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";79import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes";
80import { formatRelativeTimeLabel } from "../timestampFormat";80import { formatRelativeTimeLabel } from "../timestampFormat";
81import type { SidebarThreadSummary } from "../types";81import type { SidebarThreadSummary } from "../types";
82import { cn } from "~/lib/utils";82import { cn } from "~/lib/utils";
...@@ -86,6 +86,7 @@ import {...@@ -86,6 +86,7 @@ import {
86 isTrailingDoubleClick,86 isTrailingDoubleClick,
87 resolveAdjacentThreadId,87 resolveAdjacentThreadId,
88 resolveSidebarV2Status,88 resolveSidebarV2Status,
89 shouldNavigateAfterProjectRemoval,
89 sortThreadsForSidebarV2,90 sortThreadsForSidebarV2,
90} from "./Sidebar.logic";91} from "./Sidebar.logic";
91import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";92import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";
...@@ -740,11 +741,14 @@ export default function SidebarV2() {...@@ -740,11 +741,14 @@ export default function SidebarV2() {
740 const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread);741 const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread);
741 const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo);742 const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo);
742 const markThreadUnread = useUiStateStore((s) => s.markThreadUnread);743 const markThreadUnread = useUiStateStore((s) => s.markThreadUnread);
743 const routeThreadRef = useParams({744 const routeTarget = useParams({
744 strict: false,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 const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null;749 const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null;
750 const routeTargetRef = useRef(routeTarget);
751 routeTargetRef.current = routeTarget;
748 // Post-settle navigation validates against the CURRENT route, not the one752 // Post-settle navigation validates against the CURRENT route, not the one
749 // captured when the settle started: if the user navigated elsewhere while753 // captured when the settle started: if the user navigated elsewhere while
750 // the command was in flight, completing it must not yank them away.754 // the command was in flight, completing it must not yank them away.
...@@ -894,23 +898,21 @@ export default function SidebarV2() {...@@ -894,23 +898,21 @@ export default function SidebarV2() {
894898
895 const draftStore = useComposerDraftStore.getState();899 const draftStore = useComposerDraftStore.getState();
896 const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef);900 const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef);
901 const shouldNavigate = shouldNavigateAfterProjectRemoval({
902 routeTarget: routeTargetRef.current,
903 projectThreads,
904 projectDraftId: projectDraftThread?.draftId ?? null,
905 });
897 if (projectDraftThread) {906 if (projectDraftThread) {
898 draftStore.clearDraftThread(projectDraftThread.draftId);907 draftStore.clearDraftThread(projectDraftThread.draftId);
899 }908 }
900 draftStore.clearProjectDraftThreadId(projectRef);909 draftStore.clearProjectDraftThreadId(projectRef);
901910
902 if (911 if (shouldNavigate) {
903 routeThreadRef &&
904 projectThreads.some(
905 (thread) =>
906 thread.environmentId === routeThreadRef.environmentId &&
907 thread.id === routeThreadRef.threadId,
908 )
909 ) {
910 void router.navigate({ to: "/" });912 void router.navigate({ to: "/" });
911 }913 }
912 },914 },
913 [deleteProject, routeThreadRef, router, threads],915 [deleteProject, router, threads],
914 );916 );
915917
916 // Settled threads stay in the live shell stream (settled ≠ archived), so918 // 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 @@
1import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
2import { renderToStaticMarkup } from "react-dom/server";
3import { describe, expect, it } from "vite-plus/test";
4
5import { ProviderStatusBanner } from "./ProviderStatusBanner";
6
7function 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
25describe("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,6 +1,6 @@
1import { type ServerProvider } from "@t3tools/contracts";1import { type ServerProvider } from "@t3tools/contracts";
2import { memo } from "react";2import { memo, useEffect, useState } from "react";
3import { InfoIcon } from "lucide-react";3import { InfoIcon, XIcon } from "lucide-react";
4import { cn } from "~/lib/utils";4import { cn } from "~/lib/utils";
5import { formatProviderDriverKindLabel } from "../../providerModels";5import { formatProviderDriverKindLabel } from "../../providerModels";
6import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";6import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
...@@ -10,9 +10,24 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({...@@ -10,9 +10,24 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({
10}: {10}: {
11 status: ServerProvider | null;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 if (!status || status.status === "ready" || status.status === "disabled") {25 if (!status || status.status === "ready" || status.status === "disabled") {
14 return null;26 return null;
15 }27 }
28 if (bannerKey === dismissedBannerKey) {
29 return null;
30 }
1631
17 const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver);32 const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver);
18 const isUnauthenticated = status.status === "error" && status.auth.status === "unauthenticated";33 const isUnauthenticated = status.status === "error" && status.auth.status === "unauthenticated";
...@@ -30,7 +45,7 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({...@@ -30,7 +45,7 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({
30 <div className="pointer-events-auto mx-auto w-fit max-w-[calc(100%-2rem)] pt-3">45 <div className="pointer-events-auto mx-auto w-fit max-w-[calc(100%-2rem)] pt-3">
31 <div46 <div
32 className={cn(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 status.status === "warning"49 status.status === "warning"
35 ? "border-warning/32 bg-warning/4 [&_svg]:text-warning"50 ? "border-warning/32 bg-warning/4 [&_svg]:text-warning"
36 : "border-destructive/32 bg-destructive/4 text-destructive-foreground [&_svg]:text-destructive",51 : "border-destructive/32 bg-destructive/4 text-destructive-foreground [&_svg]:text-destructive",
...@@ -49,6 +64,14 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({...@@ -49,6 +64,14 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({
49 </TooltipPopup>64 </TooltipPopup>
50 </Tooltip>65 </Tooltip>
51 </div>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 </div>75 </div>
53 </div>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,6 +591,17 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
591 appearance: auto;591 appearance: auto;
592 accent-color: Highlight;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 }
595606
596 @media (min-width: 40rem) {607 @media (min-width: 40rem) {
...@@ -620,7 +631,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -620,7 +631,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
620 .command-menu-surface,631 .command-menu-surface,
621 .command-palette-dialog-surface,632 .command-palette-dialog-surface,
622 .workflow-dialog-surface {633 .workflow-dialog-surface {
623 background: var(--popover);634 background: var(--popover) !important;
624 }635 }
625 }636 }
626}637}