diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index d0ad5ba908c301413946c905063077b4ee8b0d96..9c71c7beaf9bf38dfdfa670e6080096a0d7e4c64 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -20,6 +20,7 @@ import { resolveThreadRowClassName, resolveSidebarV2Status, resolveThreadStatusPill, + shouldNavigateAfterProjectRemoval, shouldClearThreadSelectionOnMouseDown, sortThreadsForSidebarV2, sortProjectsForSidebar, @@ -42,6 +43,56 @@ import { const localEnvironmentId = EnvironmentId.make("environment-local"); +describe("shouldNavigateAfterProjectRemoval", () => { + const projectThreads = [{ environmentId: "environment-local", id: "thread-1" }]; + + it("navigates away from a draft route owned by the removed project", () => { + expect( + shouldNavigateAfterProjectRemoval({ + routeTarget: { kind: "draft", draftId: "draft-1" as never }, + projectThreads, + projectDraftId: "draft-1", + }), + ).toBe(true); + }); + + it("does not navigate away from a different draft route", () => { + expect( + shouldNavigateAfterProjectRemoval({ + routeTarget: { kind: "draft", draftId: "draft-2" as never }, + projectThreads, + projectDraftId: "draft-1", + }), + ).toBe(false); + }); + + it("navigates away from a server thread owned by the removed project", () => { + expect( + shouldNavigateAfterProjectRemoval({ + routeTarget: { + kind: "server", + threadRef: { + environmentId: EnvironmentId.make("environment-local"), + threadId: ThreadId.make("thread-1"), + }, + }, + projectThreads, + projectDraftId: null, + }), + ).toBe(true); + }); + + it("does not navigate from an unrelated route", () => { + expect( + shouldNavigateAfterProjectRemoval({ + routeTarget: null, + projectThreads, + projectDraftId: null, + }), + ).toBe(false); + }); +}); + describe("archiveSelectedThreadEntries", () => { const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const; const success = { _tag: "Success" } as const; diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index f17ba8bb3e0d484f5322a54a2e53e2b06ff2054c..feb0689190108a2ba1d2e5cdc163583ca857bc5b 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -8,6 +8,7 @@ import { type ThreadSortInput, } from "../lib/threadSort"; import type { SidebarThreadSummary, Thread } from "../types"; +import type { ThreadRouteTarget } from "../threadRoutes"; import { cn } from "../lib/utils"; import { isLatestTurnSettled } from "../session-logic"; import { resolveServerBackedAppStageLabel } from "../branding.logic"; @@ -377,6 +378,28 @@ export function resolveAdjacentThreadId(input: { return currentIndex < threadIds.length - 1 ? (threadIds[currentIndex + 1] ?? null) : null; } +export function shouldNavigateAfterProjectRemoval(input: { + routeTarget: ThreadRouteTarget | null; + projectThreads: readonly { + environmentId: string; + id: string; + }[]; + projectDraftId: string | null; +}): boolean { + const { projectDraftId, projectThreads, routeTarget } = input; + if (routeTarget?.kind === "draft") { + return projectDraftId === routeTarget.draftId; + } + if (routeTarget?.kind !== "server") { + return false; + } + return projectThreads.some( + (thread) => + thread.environmentId === routeTarget.threadRef.environmentId && + thread.id === routeTarget.threadRef.threadId, + ); +} + export function isContextMenuPointerDown(input: { button: number; ctrlKey: boolean; diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 467693254374f8b8eb3c3e50bb1c7429778f5bd5..46b6a3d06240031237a3b7fb182f8700985c282d 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -76,7 +76,7 @@ import { threadEnvironment } from "../state/threads"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; import { useAtomCommand } from "../state/use-atom-command"; -import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; +import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; import { formatRelativeTimeLabel } from "../timestampFormat"; import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; @@ -86,6 +86,7 @@ import { isTrailingDoubleClick, resolveAdjacentThreadId, resolveSidebarV2Status, + shouldNavigateAfterProjectRemoval, sortThreadsForSidebarV2, } from "./Sidebar.logic"; import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; @@ -740,11 +741,14 @@ export default function SidebarV2() { const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo); const markThreadUnread = useUiStateStore((s) => s.markThreadUnread); - const routeThreadRef = useParams({ + const routeTarget = useParams({ strict: false, - select: (params) => resolveThreadRouteRef(params), + select: (params) => resolveThreadRouteTarget(params), }); + const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; + const routeTargetRef = useRef(routeTarget); + routeTargetRef.current = routeTarget; // Post-settle navigation validates against the CURRENT route, not the one // captured when the settle started: if the user navigated elsewhere while // the command was in flight, completing it must not yank them away. @@ -894,23 +898,21 @@ export default function SidebarV2() { const draftStore = useComposerDraftStore.getState(); const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef); + const shouldNavigate = shouldNavigateAfterProjectRemoval({ + routeTarget: routeTargetRef.current, + projectThreads, + projectDraftId: projectDraftThread?.draftId ?? null, + }); if (projectDraftThread) { draftStore.clearDraftThread(projectDraftThread.draftId); } draftStore.clearProjectDraftThreadId(projectRef); - if ( - routeThreadRef && - projectThreads.some( - (thread) => - thread.environmentId === routeThreadRef.environmentId && - thread.id === routeThreadRef.threadId, - ) - ) { + if (shouldNavigate) { void router.navigate({ to: "/" }); } }, - [deleteProject, routeThreadRef, router, threads], + [deleteProject, router, threads], ); // Settled threads stay in the live shell stream (settled ≠ archived), so diff --git a/apps/web/src/components/chat/ProviderStatusBanner.test.tsx b/apps/web/src/components/chat/ProviderStatusBanner.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2bd217bfe42e19a942a894293418a62341b30d6f --- /dev/null +++ b/apps/web/src/components/chat/ProviderStatusBanner.test.tsx @@ -0,0 +1,33 @@ +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { ProviderStatusBanner } from "./ProviderStatusBanner"; + +function warningProvider(): ServerProvider { + return { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + displayName: "Codex", + enabled: true, + installed: true, + version: "1.0.0", + status: "warning", + auth: { status: "authenticated" }, + checkedAt: "2026-07-23T12:00:00.000Z", + message: "Provider is temporarily degraded.", + models: [], + slashCommands: [], + skills: [], + }; +} + +describe("ProviderStatusBanner", () => { + it("renders an accessible dismiss control for provider warnings", () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('role="alert"'); + expect(markup).toContain('aria-label="Dismiss Codex provider warning"'); + expect(markup).toContain("absolute top-2 right-2"); + }); +}); diff --git a/apps/web/src/components/chat/ProviderStatusBanner.tsx b/apps/web/src/components/chat/ProviderStatusBanner.tsx index 205157008dd29159f6649f4d3beeb47b6b321891..a900129aace7c6061a092debe4ad7886d22cca61 100644 --- a/apps/web/src/components/chat/ProviderStatusBanner.tsx +++ b/apps/web/src/components/chat/ProviderStatusBanner.tsx @@ -1,6 +1,6 @@ import { type ServerProvider } from "@t3tools/contracts"; -import { memo } from "react"; -import { InfoIcon } from "lucide-react"; +import { memo, useEffect, useState } from "react"; +import { InfoIcon, XIcon } from "lucide-react"; import { cn } from "~/lib/utils"; import { formatProviderDriverKindLabel } from "../../providerModels"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -10,9 +10,24 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({ }: { status: ServerProvider | null; }) { + const bannerKey = + !status || status.status === "ready" || status.status === "disabled" + ? null + : [status.instanceId, status.status, status.auth.status, status.message ?? ""].join("\u0000"); + const [dismissedBannerKey, setDismissedBannerKey] = useState(null); + + useEffect(() => { + if (bannerKey === null && dismissedBannerKey !== null) { + setDismissedBannerKey(null); + } + }, [bannerKey, dismissedBannerKey]); + if (!status || status.status === "ready" || status.status === "disabled") { return null; } + if (bannerKey === dismissedBannerKey) { + return null; + } const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver); const isUnauthenticated = status.status === "error" && status.auth.status === "unauthenticated"; @@ -30,7 +45,7 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({
+
); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 1d1a1b8231f3ba252bfaa501c183a12aeceb743d..92bfc8a6ad507045eabb6c0a5e189e3d74f17395 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -591,6 +591,17 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil appearance: auto; accent-color: Highlight; } + + .glass-opacity-slider::-webkit-slider-runnable-track, + .glass-opacity-slider::-webkit-slider-thumb { + all: revert; + } + + .glass-opacity-slider::-moz-range-track, + .glass-opacity-slider::-moz-range-progress, + .glass-opacity-slider::-moz-range-thumb { + all: revert; + } } @media (min-width: 40rem) { @@ -620,7 +631,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil .command-menu-surface, .command-palette-dialog-surface, .workflow-dialog-surface { - background: var(--popover); + background: var(--popover) !important; } } }