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 {
2020 resolveThreadRowClassName,
2121 resolveSidebarV2Status,
2222 resolveThreadStatusPill,
23 shouldNavigateAfterProjectRemoval,
2324 shouldClearThreadSelectionOnMouseDown,
2425 sortThreadsForSidebarV2,
2526 sortProjectsForSidebar,
......@@ -42,6 +43,56 @@ import {
4243
4344const 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
4596describe("archiveSelectedThreadEntries", () => {
4697 const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const;
4798 const success = { _tag: "Success" } as const;
apps/web/src/components/Sidebar.logic.ts+23
......@@ -8,6 +8,7 @@ import {
88 type ThreadSortInput,
99} from "../lib/threadSort";
1010import type { SidebarThreadSummary, Thread } from "../types";
11import type { ThreadRouteTarget } from "../threadRoutes";
1112import { cn } from "../lib/utils";
1213import { isLatestTurnSettled } from "../session-logic";
1314import { resolveServerBackedAppStageLabel } from "../branding.logic";
......@@ -377,6 +378,28 @@ export function resolveAdjacentThreadId<T>(input: {
377378 return currentIndex < threadIds.length - 1 ? (threadIds[currentIndex + 1] ?? null) : null;
378379}
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
380403export function isContextMenuPointerDown(input: {
381404 button: number;
382405 ctrlKey: boolean;
apps/web/src/components/SidebarV2.tsx+14-12
......@@ -76,7 +76,7 @@ import { threadEnvironment } from "../state/threads";
7676import { projectEnvironment } from "../state/projects";
7777import { useEnvironmentQuery } from "../state/query";
7878import { useAtomCommand } from "../state/use-atom-command";
79import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes";
79import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes";
8080import { formatRelativeTimeLabel } from "../timestampFormat";
8181import type { SidebarThreadSummary } from "../types";
8282import { cn } from "~/lib/utils";
......@@ -86,6 +86,7 @@ import {
8686 isTrailingDoubleClick,
8787 resolveAdjacentThreadId,
8888 resolveSidebarV2Status,
89 shouldNavigateAfterProjectRemoval,
8990 sortThreadsForSidebarV2,
9091} from "./Sidebar.logic";
9192import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";
......@@ -740,11 +741,14 @@ export default function SidebarV2() {
740741 const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread);
741742 const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo);
742743 const markThreadUnread = useUiStateStore((s) => s.markThreadUnread);
743 const routeThreadRef = useParams({
744 const routeTarget = useParams({
744745 strict: false,
745 select: (params) => resolveThreadRouteRef(params),
746 select: (params) => resolveThreadRouteTarget(params),
746747 });
748 const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null;
747749 const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null;
750 const routeTargetRef = useRef(routeTarget);
751 routeTargetRef.current = routeTarget;
748752 // Post-settle navigation validates against the CURRENT route, not the one
749753 // captured when the settle started: if the user navigated elsewhere while
750754 // the command was in flight, completing it must not yank them away.
......@@ -894,23 +898,21 @@ export default function SidebarV2() {
894898
895899 const draftStore = useComposerDraftStore.getState();
896900 const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef);
901 const shouldNavigate = shouldNavigateAfterProjectRemoval({
902 routeTarget: routeTargetRef.current,
903 projectThreads,
904 projectDraftId: projectDraftThread?.draftId ?? null,
905 });
897906 if (projectDraftThread) {
898907 draftStore.clearDraftThread(projectDraftThread.draftId);
899908 }
900909 draftStore.clearProjectDraftThreadId(projectRef);
901910
902 if (
903 routeThreadRef &&
904 projectThreads.some(
905 (thread) =>
906 thread.environmentId === routeThreadRef.environmentId &&
907 thread.id === routeThreadRef.threadId,
908 )
909 ) {
911 if (shouldNavigate) {
910912 void router.navigate({ to: "/" });
911913 }
912914 },
913 [deleteProject, routeThreadRef, router, threads],
915 [deleteProject, router, threads],
914916 );
915917
916918 // 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 @@
11import { type ServerProvider } from "@t3tools/contracts";
2import { memo } from "react";
3import { InfoIcon } from "lucide-react";
2import { memo, useEffect, useState } from "react";
3import { InfoIcon, XIcon } from "lucide-react";
44import { cn } from "~/lib/utils";
55import { formatProviderDriverKindLabel } from "../../providerModels";
66import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
......@@ -10,9 +10,24 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({
1010}: {
1111 status: ServerProvider | null;
1212}) {
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
1325 if (!status || status.status === "ready" || status.status === "disabled") {
1426 return null;
1527 }
28 if (bannerKey === dismissedBannerKey) {
29 return null;
30 }
1631
1732 const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver);
1833 const isUnauthenticated = status.status === "error" && status.auth.status === "unauthenticated";
......@@ -30,7 +45,7 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({
3045 <div className="pointer-events-auto mx-auto w-fit max-w-[calc(100%-2rem)] pt-3">
3146 <div
3247 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",
3449 status.status === "warning"
3550 ? "border-warning/32 bg-warning/4 [&_svg]:text-warning"
3651 : "border-destructive/32 bg-destructive/4 text-destructive-foreground [&_svg]:text-destructive",
......@@ -49,6 +64,14 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({
4964 </TooltipPopup>
5065 </Tooltip>
5166 </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>
5275 </div>
5376 </div>
5477 );
apps/web/src/index.css+12-1
......@@ -591,6 +591,17 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
591591 appearance: auto;
592592 accent-color: Highlight;
593593 }
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 }
594605 }
595606
596607 @media (min-width: 40rem) {
......@@ -620,7 +631,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
620631 .command-menu-surface,
621632 .command-palette-dialog-surface,
622633 .workflow-dialog-surface {
623 background: var(--popover);
634 background: var(--popover) !important;
624635 }
625636 }
626637}