authorclover caruso <git@paperclover.net> 2026-10-13 09:47:14-07:00
committerclover caruso <git@paperclover.net> 2026-10-16 16:52:04-07:00
log99e6ff629afe3d401757ea3b304d861b1b64f1e5
treee46758fb78500e8a4cd3fa07d2c419d0090e98df
parent1dced964e7161946a640107004968697e5b258b8
signature Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover)

feat: disable automatic Git setup and tune fork defaults

Clover uses Jujutsu and wants project creation without Git setup. New project creates an ordinary folder; the command palette and source-control menu no longer offer initializing or publishing repositories. Existing repositories retain their commit, push, pull, and PR actions. Replace the sidebar artwork with a transparent Knife icon beside the collapse control. Keep the Knife artwork at 16 px inside its existing hover target. Align provider and settings icon columns, outline clickable update actions, and divide updates from the labeled settings section. Its compact HTML dropdown shows the effective build version, one app-update action, provider versions and live status, and two columns of direct settings links. Compatible UI updates reload without a prompt; full app and server restarts require confirmation. Run provider-only Update all directly and keep closed dropdown contents inert. Use the same update action in Settings and the legacy sidebar; remove the separate local UI update command. Open Markdown files in formatted preview by default, preserving explicit source preferences and requested source-line reveals. Keep the thread details card closed by default while retaining explicitly saved visibility choices. Use locally bundled AT Name Sans for desktop/web UI and AT Name Mono for code and terminals. Keep explicit font preferences and system fallbacks; refresh Settings font labels after web fonts load or fail. Private regular and italic variable fonts ship in builds while their local directory stays ignored. Focus the enabled confirmation action when a danger dialog opens so Enter confirms and Escape cancels, as Clover requested. Preserve explicit focus targets and disabled actions. Clover wants to recognize project icon files without memorizing filenames. Show actual per-file thumbnails with fixed dimensions, lazy image loading, a checkerboard for transparency, and a broken-image fallback. Preserve search and keyboard and pointer selection. Serve ICNS candidates as embedded PNGs through existing signed asset claims, retaining workspace confinement and external file identity checks. Assisted-by: gpt-6.1-sol Assisted-by: claude-opus-5.5

39 files changed, 1755 insertions(+), 2286 deletions(-)

apps/server/src/assets/AssetAccess.test.ts+85-7
...@@ -54,6 +54,12 @@ screenshotPng.set([...@@ -54,6 +54,12 @@ screenshotPng.set([
54]);54]);
55new DataView(screenshotPng.buffer).setUint32(16, 390);55new DataView(screenshotPng.buffer).setUint32(16, 390);
56new DataView(screenshotPng.buffer).setUint32(20, 844);56new DataView(screenshotPng.buffer).setUint32(20, 844);
57const screenshotIcns = new Uint8Array(16 + screenshotPng.length);
58screenshotIcns.set(new TextEncoder().encode("icns"));
59new DataView(screenshotIcns.buffer).setUint32(4, screenshotIcns.length);
60screenshotIcns.set(new TextEncoder().encode("ic07"), 8);
61new DataView(screenshotIcns.buffer).setUint32(12, 8 + screenshotPng.length);
62screenshotIcns.set(screenshotPng, 16);
57const screenshotItem = {63const screenshotItem = {
58 id: TurnItemId.make("tool-screenshot"),64 id: TurnItemId.make("tool-screenshot"),
59 type: "dynamic_tool" as const,65 type: "dynamic_tool" as const,
...@@ -816,6 +822,84 @@ describe("AssetAccess", () => {...@@ -816,6 +822,84 @@ describe("AssetAccess", () => {
816 }).pipe(Effect.provide(layerTest)),822 }).pipe(Effect.provide(layerTest)),
817 );823 );
818824
825 it.effect("previews ICNS candidates without selecting a project favicon", () =>
826 Effect.gen(function* () {
827 const fileSystem = yield* FileSystem.FileSystem;
828 const path = yield* Path.Path;
829 const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-icon-candidates-" });
830 const external = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-icon-external-" });
831 yield* fileSystem.writeFileString(path.join(root, "favicon.svg"), "<svg>saved</svg>");
832
833 for (const requestedPath of ["Candidate.icns", path.join(external, "Candidate.icns")]) {
834 const filePath = path.isAbsolute(requestedPath)
835 ? requestedPath
836 : path.join(root, requestedPath);
837 yield* fileSystem.writeFile(filePath, screenshotIcns);
838 const result = yield* issueAssetUrl({
839 resource: { _tag: "draft-workspace-file", cwd: root, path: requestedPath },
840 });
841 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
842 const token = suffix.slice(0, suffix.indexOf("/"));
843 const canonicalPath = yield* fileSystem.realPath(filePath);
844 const asset = yield* resolveAsset(token, "Candidate.icns").pipe(
845 Effect.provideService(
846 FileSystem.FileSystem,
847 FileSystem.FileSystem.of({
848 ...fileSystem,
849 readFile: (requested) =>
850 path.isAbsolute(requestedPath) && requested === canonicalPath
851 ? Effect.succeed(new Uint8Array())
852 : fileSystem.readFile(requested),
853 }),
854 ),
855 );
856
857 expect(asset?.kind === "bytes" ? asset.mimeType : null).toBe("image/png");
858 expect(asset?.kind === "bytes" ? Uint8Array.from(asset.bytes) : null).toEqual(
859 screenshotPng,
860 );
861 expect(yield* resolveAsset(token, "favicon.svg")).toBeNull();
862 expect(yield* resolveAsset(token, "../Candidate.icns")).toBeNull();
863
864 if (path.isAbsolute(requestedPath)) {
865 const replacement = path.join(external, "replacement.icns");
866 yield* fileSystem.writeFile(replacement, screenshotIcns);
867 yield* fileSystem.rename(replacement, filePath);
868 expect(yield* resolveAsset(token, "Candidate.icns")).toBeNull();
869 }
870 }
871 const saved = yield* issueAssetUrl({
872 resource: { _tag: "project-favicon", cwd: root, path: "Candidate.icns" },
873 });
874 expect(saved.sourcePath).toBe("favicon.svg");
875 }).pipe(Effect.provide(layerTest)),
876 );
877
878 it.effect.skipIf(!symlinksSupported)("revalidates ICNS candidate workspace confinement", () =>
879 Effect.gen(function* () {
880 const fileSystem = yield* FileSystem.FileSystem;
881 const path = yield* Path.Path;
882 const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-icon-contained-" });
883 const outside = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-icon-private-" });
884 const filePath = path.join(root, "Candidate.icns");
885 const secret = path.join(outside, "Private.icns");
886 yield* fileSystem.writeFile(filePath, screenshotPng);
887 yield* fileSystem.writeFile(secret, screenshotPng);
888 const result = yield* issueAssetUrl({
889 resource: { _tag: "draft-workspace-file", cwd: root, path: "Candidate.icns" },
890 });
891 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
892 const token = suffix.slice(0, suffix.indexOf("/"));
893 yield* fileSystem.remove(filePath);
894 yield* fileSystem.symlink(secret, filePath);
895 expect(yield* resolveAsset(token, "Candidate.icns")).toBeNull();
896 const error = yield* issueAssetUrl({
897 resource: { _tag: "draft-workspace-file", cwd: root, path: "Candidate.icns" },
898 }).pipe(Effect.flip);
899 expect(error._tag).toBe("AssetWorkspaceAssetNotFoundError");
900 }).pipe(Effect.provide(layerTest)),
901 );
902
819 it.effect("falls back to the resource cwd for relative draft paths", () =>903 it.effect("falls back to the resource cwd for relative draft paths", () =>
820 Effect.gen(function* () {904 Effect.gen(function* () {
821 const fileSystem = yield* FileSystem.FileSystem;905 const fileSystem = yield* FileSystem.FileSystem;
...@@ -1335,14 +1419,8 @@ describe("AssetAccess", () => {...@@ -1335,14 +1419,8 @@ describe("AssetAccess", () => {
1335 const root = yield* fileSystem.makeTempDirectoryScoped({1419 const root = yield* fileSystem.makeTempDirectoryScoped({
1336 prefix: "t3-asset-favicon-icns-",1420 prefix: "t3-asset-favicon-icns-",
1337 });1421 });
1338 const icns = new Uint8Array(8 + 8 + screenshotPng.length);
1339 icns.set(new TextEncoder().encode("icns"));
1340 new DataView(icns.buffer).setUint32(4, icns.length);
1341 icns.set(new TextEncoder().encode("ic07"), 8);
1342 new DataView(icns.buffer).setUint32(12, 8 + screenshotPng.length);
1343 icns.set(screenshotPng, 16);
1344 yield* fileSystem.makeDirectory(path.join(root, "Resources"));1422 yield* fileSystem.makeDirectory(path.join(root, "Resources"));
1345 yield* fileSystem.writeFile(path.join(root, "Resources", "AppIcon.icns"), icns);1423 yield* fileSystem.writeFile(path.join(root, "Resources", "AppIcon.icns"), screenshotIcns);
13461424
1347 const result = yield* issueAssetUrl({1425 const result = yield* issueAssetUrl({
1348 resource: { _tag: "project-favicon", cwd: root },1426 resource: { _tag: "project-favicon", cwd: root },
apps/server/src/assets/AssetAccess.ts+28-15
...@@ -262,15 +262,20 @@ const readToolOutputImage = Effect.fn("AssetAccess.readToolOutputImage")(functio...@@ -262,15 +262,20 @@ const readToolOutputImage = Effect.fn("AssetAccess.readToolOutputImage")(functio
262 : { mimeType: image.mimeType, bytes: Buffer.from(image.data, "base64") };262 : { mimeType: image.mimeType, bytes: Buffer.from(image.data, "base64") };
263});263});
264264
265/** Serves an `.icns` favicon as its embedded PNG, since browsers cannot draw it. */265/** Browsers display the embedded PNG in an `.icns` image. */
266const projectFaviconAsset = Effect.fn("AssetAccess.projectFaviconAsset")(function* (266const imagePreviewAsset = Effect.fn("AssetAccess.imagePreviewAsset")(function* (
267 filePath: string,267 filePath: string,
268 file?: OpenMediaFile,
268) {269) {
269 if (!filePath.toLowerCase().endsWith(".icns")) {270 if (!filePath.toLowerCase().endsWith(".icns")) {
270 return { kind: "file", path: filePath } satisfies ResolvedAsset;271 return { kind: "file", path: filePath } satisfies ResolvedAsset;
271 }272 }
272 const fileSystem = yield* FileSystem.FileSystem;273 const fileSystem = yield* FileSystem.FileSystem;
273 const bytes = yield* fileSystem.readFile(filePath).pipe(274 const bytes = yield* Effect.gen(function* () {
275 return file
276 ? yield* readMediaFileHeader(filePath, file, Number(file.info.size))
277 : yield* fileSystem.readFile(filePath);
278 }).pipe(
274 Effect.tapError((cause) =>279 Effect.tapError((cause) =>
275 Effect.logError("Failed to read project icon image.", { filePath, cause }),280 Effect.logError("Failed to read project icon image.", { filePath, cause }),
276 ),281 ),
...@@ -378,7 +383,10 @@ const finalizeAbsoluteMediaFileAsset = Effect.fn("AssetAccess.finalizeAbsoluteMe...@@ -378,7 +383,10 @@ const finalizeAbsoluteMediaFileAsset = Effect.fn("AssetAccess.finalizeAbsoluteMe
378 if (!canonicalFile) {383 if (!canonicalFile) {
379 return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource });384 return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource });
380 }385 }
381 if (hostPreviewMimeTypeFromExtension(path.extname(canonicalFile)) === null) {386 if (
387 hostPreviewMimeTypeFromExtension(path.extname(canonicalFile)) === null &&
388 !canonicalFile.toLowerCase().endsWith(".icns")
389 ) {
382 return yield* new AssetPreviewTypeValidationError({ resource: input.resource });390 return yield* new AssetPreviewTypeValidationError({ resource: input.resource });
383 }391 }
384 const wantsDimensions = HEADER_IMAGE_EXTENSIONS.has(path.extname(canonicalFile).toLowerCase());392 const wantsDimensions = HEADER_IMAGE_EXTENSIONS.has(path.extname(canonicalFile).toLowerCase());
...@@ -442,7 +450,10 @@ const finalizeWorkspaceFileAsset = Effect.fn("AssetAccess.finalizeWorkspaceFileA...@@ -442,7 +450,10 @@ const finalizeWorkspaceFileAsset = Effect.fn("AssetAccess.finalizeWorkspaceFileA
442 }),450 }),
443 ),451 ),
444 );452 );
445 if (!isWorkspacePreviewEntryPath(resolved.relativePath)) {453 const isImage =
454 isWorkspaceImagePreviewPath(resolved.relativePath) ||
455 resolved.relativePath.toLowerCase().endsWith(".icns");
456 if (!isWorkspacePreviewEntryPath(resolved.relativePath) && !isImage) {
446 return yield* new AssetPreviewTypeValidationError({457 return yield* new AssetPreviewTypeValidationError({
447 resource: input.resource,458 resource: input.resource,
448 });459 });
...@@ -479,7 +490,7 @@ const finalizeWorkspaceFileAsset = Effect.fn("AssetAccess.finalizeWorkspaceFileA...@@ -479,7 +490,7 @@ const finalizeWorkspaceFileAsset = Effect.fn("AssetAccess.finalizeWorkspaceFileA
479 ? yield* readImageDimensionsFromHeader(canonicalFile)490 ? yield* readImageDimensionsFromHeader(canonicalFile)
480 : null;491 : null;
481 return {492 return {
482 claims: isWorkspaceImagePreviewPath(resolved.relativePath)493 claims: isImage
483 ? {494 ? {
484 version: 1 as const,495 version: 1 as const,
485 kind: "workspace-file-exact" as const,496 kind: "workspace-file-exact" as const,
...@@ -889,7 +900,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (...@@ -889,7 +900,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
889 workspaceRoot: claims.workspaceRoot,900 workspaceRoot: claims.workspaceRoot,
890 relativePath: claims.relativePath,901 relativePath: claims.relativePath,
891 });902 });
892 return faviconPath ? yield* projectFaviconAsset(faviconPath) : null;903 return faviconPath ? yield* imagePreviewAsset(faviconPath) : null;
893 }904 }
894905
895 if (claims.kind === "project-favicon-external") {906 if (claims.kind === "project-favicon-external") {
...@@ -902,7 +913,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (...@@ -902,7 +913,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
902 ),913 ),
903 Effect.orElseSucceed(() => null),914 Effect.orElseSucceed(() => null),
904 );915 );
905 return faviconPath === claims.filePath ? yield* projectFaviconAsset(faviconPath) : null;916 return faviconPath === claims.filePath ? yield* imagePreviewAsset(faviconPath) : null;
906 }917 }
907918
908 if (claims.kind === "github-media") {919 if (claims.kind === "github-media") {
...@@ -949,7 +960,10 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (...@@ -949,7 +960,10 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
949 Effect.orElseSucceed(() => null),960 Effect.orElseSucceed(() => null),
950 );961 );
951 if (canonicalFile !== claims.filePath) return null;962 if (canonicalFile !== claims.filePath) return null;
952 const mimeType = hostPreviewMimeTypeFromExtension(path.extname(canonicalFile));963 const isIcns = canonicalFile.toLowerCase().endsWith(".icns");
964 const mimeType = isIcns
965 ? "image/png"
966 : hostPreviewMimeTypeFromExtension(path.extname(canonicalFile));
953 if (!mimeType) return null;967 if (!mimeType) return null;
954 const file = yield* openMediaFile(canonicalFile, claims).pipe(968 const file = yield* openMediaFile(canonicalFile, claims).pipe(
955 Effect.tapError((cause) =>969 Effect.tapError((cause) =>
...@@ -957,9 +971,10 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (...@@ -957,9 +971,10 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
957 ),971 ),
958 Effect.orElseSucceed(() => null),972 Effect.orElseSucceed(() => null),
959 );973 );
960 return file974 if (!file) return null;
961 ? ({ kind: "file", path: canonicalFile, mimeType, file } satisfies ResolvedAsset)975 return isIcns
962 : null;976 ? yield* imagePreviewAsset(canonicalFile, file)
977 : ({ kind: "file", path: canonicalFile, mimeType, file } satisfies ResolvedAsset);
963 }978 }
964 if (claims.kind === "workspace-file-exact") {979 if (claims.kind === "workspace-file-exact") {
965 if (decodedPath !== path.basename(claims.relativePath)) return null;980 if (decodedPath !== path.basename(claims.relativePath)) return null;
...@@ -967,9 +982,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (...@@ -967,9 +982,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
967 workspaceRoot: claims.workspaceRoot,982 workspaceRoot: claims.workspaceRoot,
968 relativePath: claims.relativePath,983 relativePath: claims.relativePath,
969 });984 });
970 return exactWorkspaceFile985 return exactWorkspaceFile ? yield* imagePreviewAsset(exactWorkspaceFile) : null;
971 ? ({ kind: "file", path: exactWorkspaceFile } satisfies ResolvedAsset)
972 : null;
973 }986 }
974 const segments = decodedPath.split(/[\\/]/);987 const segments = decodedPath.split(/[\\/]/);
975 if (988 if (
apps/web/src/appearanceFonts.ts+2-2
...@@ -18,12 +18,12 @@ import {...@@ -18,12 +18,12 @@ import {
18} from "@t3tools/contracts";18} from "@t3tools/contracts";
1919
20export const DEFAULT_SANS_FONT_STACK =20export const DEFAULT_SANS_FONT_STACK =
21 '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif';21 '"AT Name Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif';
2222
23// Concrete names first: some engines alias `ui-monospace` to the23// Concrete names first: some engines alias `ui-monospace` to the
24// proportional system UI font, which would break every code surface.24// proportional system UI font, which would break every code surface.
25export const DEFAULT_CODE_FONT_STACK =25export const DEFAULT_CODE_FONT_STACK =
26 '"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace';26 '"AT Name Mono", "SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace';
2727
28export const TYPOGRAPHY_ADVANCED_STORAGE_KEY = "t3code:typography-advanced";28export const TYPOGRAPHY_ADVANCED_STORAGE_KEY = "t3code:typography-advanced";
2929
apps/web/src/components/AppSidebarLayout.tsx+13-24
...@@ -25,7 +25,7 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina...@@ -25,7 +25,7 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina
25import { resolveThreadRouteRef } from "../threadRoutes";25import { resolveThreadRouteRef } from "../threadRoutes";
26import { cn, isMacPlatform } from "../lib/utils";26import { cn, isMacPlatform } from "../lib/utils";
27import { primaryServerKeybindingsAtom } from "../state/server";27import { primaryServerKeybindingsAtom } from "../state/server";
28import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings";28import { useLegacySidebarEnabled } from "../hooks/useSettings";
29import {29import {
30 PanelAnimationSuppressionProvider,30 PanelAnimationSuppressionProvider,
31 usePanelAnimationSettings,31 usePanelAnimationSettings,
...@@ -35,15 +35,15 @@ import LegacyThreadSidebar from "./LegacySidebar";...@@ -35,15 +35,15 @@ import LegacyThreadSidebar from "./LegacySidebar";
35import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration";35import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration";
36import ThreadSidebar from "./Sidebar";36import ThreadSidebar from "./Sidebar";
37import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";37import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
38import { SidebarBrandWidthProbe, SidebarChromeHeader } from "./sidebar/SidebarChrome";38import { AppMenu } from "./sidebar/AppMenu";
39import { SidebarChromeHeader } from "./sidebar/SidebarChrome";
39import { MainAppLocationTracker } from "./sidebar/mainAppLocation";40import { MainAppLocationTracker } from "./sidebar/mainAppLocation";
40import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop";
41import { useProjects } from "../state/entities";41import { useProjects } from "../state/entities";
42import {42import {
43 clampThreadSidebarWidth,43 clampThreadSidebarWidth,
44 resolveInitialThreadSidebarWidth,44 resolveInitialThreadSidebarWidth,
45 resolveThreadSidebarMaximumWidth,45 resolveThreadSidebarMaximumWidth,
46 resolveThreadSidebarMinimumWidth,46 THREAD_SIDEBAR_MIN_WIDTH,
47 THREAD_MAIN_CONTENT_MIN_WIDTH,47 THREAD_MAIN_CONTENT_MIN_WIDTH,
48 THREAD_SIDEBAR_WIDTH_STORAGE_KEY,48 THREAD_SIDEBAR_WIDTH_STORAGE_KEY,
49} from "./threadSidebarWidth";49} from "./threadSidebarWidth";
...@@ -85,10 +85,6 @@ function SidebarControl() {...@@ -85,10 +85,6 @@ function SidebarControl() {
85 const keybindings = useAtomValue(primaryServerKeybindingsAtom);85 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
86 const { toggleSidebar, isMobile, mobileSheetMounted } = useSidebar();86 const { toggleSidebar, isMobile, mobileSheetMounted } = useSidebar();
87 const isSidebarVisible = useSidebarVisibility();87 const isSidebarVisible = useSidebarVisibility();
88 const environmentIdentificationMode = useEnvironmentIdentificationMode();
89 const stageBackdropVariant = useSidebarStageBackdropVariant(
90 environmentIdentificationMode === "artwork",
91 );
92 const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle", {88 const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle", {
93 context: { usagePageOpen },89 context: { usagePageOpen },
94 });90 });
...@@ -146,19 +142,13 @@ function SidebarControl() {...@@ -146,19 +142,13 @@ function SidebarControl() {
146 )}142 )}
147 data-sidebar-control=""143 data-sidebar-control=""
148 >144 >
145 <div className="pointer-events-auto flex items-center [-webkit-app-region:no-drag]">
146 <AppMenu />
147 </div>
149 <Tooltip>148 <Tooltip>
150 <TooltipTrigger149 <TooltipTrigger
151 render={150 render={
152 <SidebarTrigger151 <SidebarTrigger className="pointer-events-auto" aria-label="Toggle main sidebar" />
153 // Over the stage artwork the trigger is a control on imagery, like the media
154 // viewer's arrows; that variant positions itself, so the layout is reset here.
155 variant={isSidebarVisible && stageBackdropVariant ? "media-navigation" : "ghost"}
156 className={cn(
157 "pointer-events-auto",
158 isSidebarVisible && stageBackdropVariant && "relative top-auto translate-y-0",
159 )}
160 aria-label="Toggle main sidebar"
161 />
162 }152 }
163 />153 />
164 <TooltipPopup side="bottom">154 <TooltipPopup side="bottom">
...@@ -246,9 +236,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {...@@ -246,9 +236,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
246 // and a clamped drag ends with an unchanged width, which skips the re-render236 // and a clamped drag ends with an unchanged width, which skips the re-render
247 // that would otherwise refresh a render-time snapshot.237 // that would otherwise refresh a render-time snapshot.
248 const viewportWidth = useSyncExternalStore(subscribeToViewportWidth, readViewportWidth);238 const viewportWidth = useSyncExternalStore(subscribeToViewportWidth, readViewportWidth);
249 const [brandWidth, setBrandWidth] = useState(0);239 const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(viewportWidth);
250 const sidebarMinimumWidth = resolveThreadSidebarMinimumWidth(brandWidth);
251 const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(viewportWidth, sidebarMinimumWidth);
252 const resetSidebarWidth = () => {240 const resetSidebarWidth = () => {
253 try {241 try {
254 removeLocalStorageItem(THREAD_SIDEBAR_WIDTH_STORAGE_KEY);242 removeLocalStorageItem(THREAD_SIDEBAR_WIDTH_STORAGE_KEY);
...@@ -269,8 +257,10 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {...@@ -269,8 +257,10 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
269 : {}257 : {}
270 ) as CSSProperties;258 ) as CSSProperties;
271 const sidebarProviderStyle = {259 const sidebarProviderStyle = {
272 "--sidebar-width": `${clampThreadSidebarWidth(sidebarWidth, sidebarMinimumWidth, sidebarMaximumWidth)}px`,260 "--sidebar-width": `${clampThreadSidebarWidth(sidebarWidth, sidebarMaximumWidth)}px`,
273 "--panel-animation-duration": `${panelAnimationDurationMs}ms`,261 "--panel-animation-duration": `${panelAnimationDurationMs}ms`,
262 "--workspace-titlebar-content-left":
263 "calc(var(--workspace-controls-left) + 2 * var(--workspace-titlebar-control-size) + var(--workspace-titlebar-control-gap))",
274 ...windowControlsInsetStyle,264 ...windowControlsInsetStyle,
275 } as CSSProperties;265 } as CSSProperties;
276266
...@@ -319,7 +309,6 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {...@@ -319,7 +309,6 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
319 defaultOpen309 defaultOpen
320 style={sidebarProviderStyle}310 style={sidebarProviderStyle}
321 >311 >
322 <SidebarBrandWidthProbe onWidthChange={setBrandWidth} />
323 <ProjectProjectionRetention />312 <ProjectProjectionRetention />
324 <Sidebar313 <Sidebar
325 side="left"314 side="left"
...@@ -329,7 +318,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {...@@ -329,7 +318,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
329 aria-label={isOnSettings ? "Settings" : "Threads"}318 aria-label={isOnSettings ? "Settings" : "Threads"}
330 resizable={{319 resizable={{
331 maxWidth: sidebarMaximumWidth,320 maxWidth: sidebarMaximumWidth,
332 minWidth: sidebarMinimumWidth,321 minWidth: THREAD_SIDEBAR_MIN_WIDTH,
333 shouldAcceptWidth: ({ currentWidth, nextWidth, wrapper }) =>322 shouldAcceptWidth: ({ currentWidth, nextWidth, wrapper }) =>
334 nextWidth <= currentWidth ||323 nextWidth <= currentWidth ||
335 wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH,324 wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH,
apps/web/src/components/CommandPalette.tsx+14-144
...@@ -11,10 +11,6 @@ import {...@@ -11,10 +11,6 @@ import {
11 getCloneDirectoryName,11 getCloneDirectoryName,
12 getDefaultCloneUrl,12 getDefaultCloneUrl,
13 getNewProjectPathPreview,13 getNewProjectPathPreview,
14 getNewProjectPublishTarget,
15 getNewProjectPublishTargets,
16 getNewProjectRepository,
17 type NewProjectPublishTarget,
18 normalizePastedCloneUrl,14 normalizePastedCloneUrl,
19 addProjectRemoteSourceLabel,15 addProjectRemoteSourceLabel,
20 addProjectRemoteSourcePathHint,16 addProjectRemoteSourcePathHint,
...@@ -49,13 +45,11 @@ import {...@@ -49,13 +45,11 @@ import {
49 type FilesystemBrowseResult,45 type FilesystemBrowseResult,
50 type ProjectId,46 type ProjectId,
51 type SourceControlDiscoveryResult,47 type SourceControlDiscoveryResult,
52 SourceControlProviderKind,
53 type SourceControlRepositoryInfo,48 type SourceControlRepositoryInfo,
54 PRIMARY_LOCAL_ENVIRONMENT_ID,49 PRIMARY_LOCAL_ENVIRONMENT_ID,
55 resolveEnvironmentMachineKind,50 resolveEnvironmentMachineKind,
56} from "@t3tools/contracts";51} from "@t3tools/contracts";
57import { useLocation, useNavigate, useParams } from "@tanstack/react-router";52import { useLocation, useNavigate, useParams } from "@tanstack/react-router";
58import * as Option from "effect/Option";
59import {53import {
60 ArrowLeftIcon,54 ArrowLeftIcon,
61 ChartNoAxesColumnIcon,55 ChartNoAxesColumnIcon,
...@@ -63,7 +57,6 @@ import {...@@ -63,7 +57,6 @@ import {
63 ChevronRightIcon,57 ChevronRightIcon,
64 CornerLeftUpIcon,58 CornerLeftUpIcon,
65 FileSearchIcon,59 FileSearchIcon,
66 FolderGit2Icon,
67 FolderIcon,60 FolderIcon,
68 FolderPlusIcon,61 FolderPlusIcon,
69 MessageSquareDashedIcon,62 MessageSquareDashedIcon,
...@@ -127,7 +120,6 @@ import { sourceControlEnvironment } from "../state/sourceControl";...@@ -127,7 +120,6 @@ import { sourceControlEnvironment } from "../state/sourceControl";
127import { useAtomCommand } from "../state/use-atom-command";120import { useAtomCommand } from "../state/use-atom-command";
128import { useAtomQueryRunner } from "../state/use-atom-query-runner";121import { useAtomQueryRunner } from "../state/use-atom-query-runner";
129import { useScratchProject } from "../hooks/useScratchProject";122import { useScratchProject } from "../hooks/useScratchProject";
130import { useNewProject } from "../hooks/useNewProject";
131import { isScratchProject } from "@t3tools/client-runtime/state/projects";123import { isScratchProject } from "@t3tools/client-runtime/state/projects";
132import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";124import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
133import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities";125import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities";
...@@ -204,7 +196,6 @@ import {...@@ -204,7 +196,6 @@ import {
204} from "./CommandPaletteResults";196} from "./CommandPaletteResults";
205import { sourceControlIcon } from "~/sourceControlPresentation";197import { sourceControlIcon } from "~/sourceControlPresentation";
206import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";198import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
207import { Checkbox } from "./ui/checkbox";
208import { ProjectFavicon } from "./ProjectFavicon";199import { ProjectFavicon } from "./ProjectFavicon";
209import { ProjectFilePicker } from "./files/ProjectFilePicker";200import { ProjectFilePicker } from "./files/ProjectFilePicker";
210import { openLinkPullRequestDialog } from "./pullRequest/LinkPullRequestDialog";201import { openLinkPullRequestDialog } from "./pullRequest/LinkPullRequestDialog";
...@@ -231,7 +222,6 @@ import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindin...@@ -231,7 +222,6 @@ import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindin
231import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "./ui/command";222import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "./ui/command";
232import { Button } from "./ui/button";223import { Button } from "./ui/button";
233import { Kbd, KbdGroup } from "./ui/kbd";224import { Kbd, KbdGroup } from "./ui/kbd";
234import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
235import { stackedThreadToast, toastManager } from "./ui/toast";225import { stackedThreadToast, toastManager } from "./ui/toast";
236import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";226import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
237import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext";227import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext";
...@@ -291,14 +281,6 @@ function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: stri...@@ -291,14 +281,6 @@ function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: stri
291 return <Icon className={className} />;281 return <Icon className={className} />;
292}282}
293283
294function sourceControlHostIcon(
295 definition: NewProjectPublishTarget["definition"],
296 className: string,
297): ReactNode {
298 const Icon = sourceControlIcon(definition);
299 return <Icon className={className} />;
300}
301
302function projectFaviconIcon(project: Project): ReactNode {284function projectFaviconIcon(project: Project): ReactNode {
303 return <ProjectFavicon project={project} className={ITEM_ICON_CLASS} />;285 return <ProjectFavicon project={project} className={ITEM_ICON_CLASS} />;
304}286}
...@@ -367,10 +349,6 @@ function notifyThemeSaveFailure(): void {...@@ -367,10 +349,6 @@ function notifyThemeSaveFailure(): void {
367 );349 );
368}350}
369351
370function projectFavicon(project: Project) {
371 return <ProjectFavicon project={project} className="size-4" />;
372}
373
374export function CommandPalette({ children }: { children: ReactNode }) {352export function CommandPalette({ children }: { children: ReactNode }) {
375 const navigate = useNavigate();353 const navigate = useNavigate();
376 const [state, dispatch] = useReducer(reduceCommandPaletteUiState, {354 const [state, dispatch] = useReducer(reduceCommandPaletteUiState, {
...@@ -788,15 +766,9 @@ function OpenCommandPaletteDialog(props: {...@@ -788,15 +766,9 @@ function OpenCommandPaletteDialog(props: {
788 /** Machine of the Add project sources view under this step; null from the palette root. */766 /** Machine of the Add project sources view under this step; null from the palette root. */
789 readonly sourcesEnvironmentId: EnvironmentId | null;767 readonly sourcesEnvironmentId: EnvironmentId | null;
790 } | null>(null);768 } | null>(null);
791 const [newProjectPublishes, setNewProjectPublishes] = useState(false);
792 // The host picked beside the publish toggle; null takes the first ready host, GitHub when ready.
793 const [newProjectPublishKind, setNewProjectPublishKind] =
794 useState<SourceControlProviderKind | null>(null);
795 const [newProjectHostPickerOpen, setNewProjectHostPickerOpen] = useState(false);
796 const [isCreatingNewProject, setIsCreatingNewProject] = useState(false);769 const [isCreatingNewProject, setIsCreatingNewProject] = useState(false);
797 // State lags a render behind, so a repeated Enter could start a second create.770 // State lags a render behind, so a repeated Enter could start a second create.
798 const newProjectSubmittingRef = useRef(false);771 const newProjectSubmittingRef = useRef(false);
799 const createNewProject = useNewProject();
800 const cloneLookupGeneration = useRef(0);772 const cloneLookupGeneration = useRef(0);
801 const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false);773 const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false);
802 const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false);774 const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false);
...@@ -1522,10 +1494,8 @@ function OpenCommandPaletteDialog(props: {...@@ -1522,10 +1494,8 @@ function OpenCommandPaletteDialog(props: {
1522 setAddProjectEnvironmentId(environmentId);1494 setAddProjectEnvironmentId(environmentId);
1523 setAddProjectCloneFlow(null);1495 setAddProjectCloneFlow(null);
1524 setNewProjectFlow({ environmentId, sourcesEnvironmentId });1496 setNewProjectFlow({ environmentId, sourcesEnvironmentId });
1525 setNewProjectPublishes(false);
1526 setNewProjectPublishKind(null);
1527 pushPaletteView({1497 pushPaletteView({
1528 addonIcon: <FolderGit2Icon className={ADDON_ICON_CLASS} />,1498 addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />,
1529 groups: [],1499 groups: [],
1530 });1500 });
1531 },1501 },
...@@ -1561,10 +1531,10 @@ function OpenCommandPaletteDialog(props: {...@@ -1561,10 +1531,10 @@ function OpenCommandPaletteDialog(props: {
1561 sourceItems.unshift({1531 sourceItems.unshift({
1562 kind: "action",1532 kind: "action",
1563 value: `action:add-project:${environmentId}:new`,1533 value: `action:add-project:${environmentId}:new`,
1564 searchTerms: ["new project", "create", "empty", "repository", "git init"],1534 searchTerms: ["new project", "create", "empty", "folder"],
1565 title: "New project",1535 title: "New project",
1566 description: "Start a new Git repository from a name",1536 description: "Create a project folder from a name",
1567 icon: <FolderGit2Icon className={ITEM_ICON_CLASS} />,1537 icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />,
1568 keepOpen: true,1538 keepOpen: true,
1569 run: async () => {1539 run: async () => {
1570 startNewProject(environmentId, environmentId);1540 startNewProject(environmentId, environmentId);
...@@ -2037,9 +2007,9 @@ function OpenCommandPaletteDialog(props: {...@@ -2037,9 +2007,9 @@ function OpenCommandPaletteDialog(props: {
2037 actionItems.push({2007 actionItems.push({
2038 kind: "action",2008 kind: "action",
2039 value: "action:new-project",2009 value: "action:new-project",
2040 searchTerms: ["new project", "create project", "empty", "repository", "repo", "git init"],2010 searchTerms: ["new project", "create project", "empty", "folder"],
2041 title: "New project",2011 title: "New project",
2042 icon: <FolderGit2Icon className={ITEM_ICON_CLASS} />,2012 icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />,
2043 keepOpen: true,2013 keepOpen: true,
2044 run: async () => {2014 run: async () => {
2045 openNewProjectFlow();2015 openNewProjectFlow();
...@@ -2508,12 +2478,6 @@ function OpenCommandPaletteDialog(props: {...@@ -2508,12 +2478,6 @@ function OpenCommandPaletteDialog(props: {
2508 ],2478 ],
2509 );2479 );
25102480
2511 const newProjectPublishTargets =
2512 newProjectFlow === null ? [] : getNewProjectPublishTargets(sourceControlDiscovery.data ?? null);
2513 const newProjectPublishTarget = getNewProjectPublishTarget(
2514 newProjectPublishTargets,
2515 newProjectPublishKind,
2516 );
2517 const newProjectName = query.trim();2481 const newProjectName = query.trim();
2518 const canSubmitNewProject =2482 const canSubmitNewProject =
2519 newProjectFlow !== null &&2483 newProjectFlow !== null &&
...@@ -2528,12 +2492,14 @@ function OpenCommandPaletteDialog(props: {...@@ -2528,12 +2492,14 @@ function OpenCommandPaletteDialog(props: {
2528 newProjectSubmittingRef.current = true;2492 newProjectSubmittingRef.current = true;
2529 setIsCreatingNewProject(true);2493 setIsCreatingNewProject(true);
2530 try {2494 try {
2531 const created = await createNewProject({2495 const root = newProjectsRootFor(newProjectFlow.environmentId);
2496 if (root === null) return;
2497 await handleAddProjectForEnvironment({
2532 environmentId: newProjectFlow.environmentId,2498 environmentId: newProjectFlow.environmentId,
2533 name: newProjectName,2499 rawCwd: getNewProjectPathPreview(root, newProjectName),
2534 publishTo: newProjectPublishes ? newProjectPublishTarget : null,2500 platform: browseEnvironmentPlatform,
2501 currentProjectCwd: null,
2535 });2502 });
2536 if (created) setOpen(false);
2537 } finally {2503 } finally {
2538 newProjectSubmittingRef.current = false;2504 newProjectSubmittingRef.current = false;
2539 setIsCreatingNewProject(false);2505 setIsCreatingNewProject(false);
...@@ -2837,7 +2803,6 @@ function OpenCommandPaletteDialog(props: {...@@ -2837,7 +2803,6 @@ function OpenCommandPaletteDialog(props: {
2837 const newProjectsRoot = newProjectFlow ? newProjectsRootFor(newProjectFlow.environmentId) : null;2803 const newProjectsRoot = newProjectFlow ? newProjectsRootFor(newProjectFlow.environmentId) : null;
2838 const newProjectPathPreview =2804 const newProjectPathPreview =
2839 newProjectsRoot === null ? null : getNewProjectPathPreview(newProjectsRoot, newProjectName);2805 newProjectsRoot === null ? null : getNewProjectPathPreview(newProjectsRoot, newProjectName);
2840 const newProjectPublishToggleValue = "new-project:publish";
2841 // The name step's way out to folders and clones, for the selected machine.2806 // The name step's way out to folders and clones, for the selected machine.
2842 // It replaces the name step (and a sources view for another machine under2807 // It replaces the name step (and a sources view for another machine under
2843 // it), so Back returns to wherever New project was opened from.2808 // it), so Back returns to wherever New project was opened from.
...@@ -2853,8 +2818,6 @@ function OpenCommandPaletteDialog(props: {...@@ -2853,8 +2818,6 @@ function OpenCommandPaletteDialog(props: {
2853 popView();2818 popView();
2854 startAddProjectSourceSelection(environmentId);2819 startAddProjectSourceSelection(environmentId);
2855 };2820 };
2856 // Switching machines keeps the typed name; the GitHub option follows the
2857 // machine because source control discovery reads addProjectEnvironmentId.
2858 const switchNewProjectEnvironment = (environmentId: EnvironmentId) => {2821 const switchNewProjectEnvironment = (environmentId: EnvironmentId) => {
2859 setNewProjectFlow((flow) => (flow === null ? flow : { ...flow, environmentId }));2822 setNewProjectFlow((flow) => (flow === null ? flow : { ...flow, environmentId }));
2860 setAddProjectEnvironmentId(environmentId);2823 setAddProjectEnvironmentId(environmentId);
...@@ -2923,102 +2886,11 @@ function OpenCommandPaletteDialog(props: {...@@ -2923,102 +2886,11 @@ function OpenCommandPaletteDialog(props: {
2923 },2886 },
2924 ],2887 ],
2925 };2888 };
2926 const newProjectOptionGroups: CommandPaletteView["groups"] =
2927 newProjectPublishTarget === null || newProjectPathPreview === null
2928 ? []
2929 : [
2930 {
2931 value: "new-project-options",
2932 label: "Options",
2933 items: [
2934 {
2935 kind: "action",
2936 value: newProjectPublishToggleValue,
2937 searchTerms: [],
2938 title:
2939 newProjectPublishTargets.length > 1
2940 ? "Create private repository"
2941 : `Create private repository on ${newProjectPublishTarget.definition.label}`,
2942 description:
2943 newProjectName.length > 0
2944 ? getNewProjectRepository(newProjectPublishTarget, newProjectPathPreview)
2945 : (newProjectPublishTarget.owner ??
2946 `Your ${newProjectPublishTarget.definition.label} account`),
2947 icon: sourceControlHostIcon(newProjectPublishTarget.definition, ITEM_ICON_CLASS),
2948 titleTrailingContent: (
2949 <span className="ms-auto flex items-center gap-2">
2950 {newProjectPublishTargets.length > 1 ? (
2951 // The row toggles on click, so the picker keeps its clicks to itself. The
2952 // row also cancels pointerdown to keep focus in the input, which drops the
2953 // mousedown Select opens on, so the picker opens on click instead.
2954 <span
2955 className="flex"
2956 onClick={(event) => {
2957 event.stopPropagation();
2958 // React bubbles the popup's clicks here through its portal; only the
2959 // trigger opens the picker.
2960 if (!event.currentTarget.contains(event.target as Node)) return;
2961 if (!isCreatingNewProject) setNewProjectHostPickerOpen(true);
2962 }}
2963 >
2964 <Select
2965 disabled={isCreatingNewProject}
2966 open={newProjectHostPickerOpen}
2967 onOpenChange={setNewProjectHostPickerOpen}
2968 value={newProjectPublishTarget.definition.kind}
2969 onValueChange={(kind) => {
2970 if (kind !== null) setNewProjectPublishKind(kind);
2971 }}
2972 >
2973 <SelectTrigger
2974 size="xs"
2975 variant="ghost"
2976 aria-label="Host for the new repository"
2977 >
2978 <SelectValue>
2979 {(kind: SourceControlProviderKind) =>
2980 sourceControlClients.get(kind).label
2981 }
2982 </SelectValue>
2983 </SelectTrigger>
2984 <SelectPopup align="end" alignItemWithTrigger={false}>
2985 {newProjectPublishTargets.map((target) => (
2986 <SelectItem
2987 key={target.definition.kind}
2988 hideIndicator
2989 value={target.definition.kind}
2990 >
2991 <span className="inline-flex items-center gap-1.5">
2992 {sourceControlHostIcon(target.definition, "size-3.5")}
2993 {target.definition.label}
2994 </span>
2995 </SelectItem>
2996 ))}
2997 </SelectPopup>
2998 </Select>
2999 </span>
3000 ) : null}
3001 <span className="pointer-events-none flex">
3002 <Checkbox checked={newProjectPublishes} tabIndex={-1} aria-hidden />
3003 </span>
3004 </span>
3005 ),
3006 // The create in flight keeps the choice it started with.
3007 ...(isCreatingNewProject ? { disabled: true } : {}),
3008 keepOpen: true,
3009 run: async () => {
3010 setNewProjectPublishes((publishes) => !publishes);
3011 },
3012 },
3013 ],
3014 },
3015 ];
30162889
3017 let displayedGroups: CommandPaletteView["groups"] = filteredGroups;2890 let displayedGroups: CommandPaletteView["groups"] = filteredGroups;
3018 if (newProjectFlow !== null) {2891 if (newProjectFlow !== null) {
3019 displayedGroups = [2892 displayedGroups = [
3020 ...(newProjectMachineGroup ? [newProjectMachineGroup] : []),2893 ...(newProjectMachineGroup ? [newProjectMachineGroup] : []),
3021 ...newProjectOptionGroups,
3022 ...(newProjectExistingGroup ? [newProjectExistingGroup] : []),2894 ...(newProjectExistingGroup ? [newProjectExistingGroup] : []),
3023 ];2895 ];
3024 } else if (addProjectCloneFlow?.step === "repository") {2896 } else if (addProjectCloneFlow?.step === "repository") {
...@@ -3487,9 +3359,7 @@ function OpenCommandPaletteDialog(props: {...@@ -3487,9 +3359,7 @@ function OpenCommandPaletteDialog(props: {
3487 newProjectFlow !== null3359 newProjectFlow !== null
3488 ? highlightedItemValue === null3360 ? highlightedItemValue === null
3489 ? "Create"3361 ? "Create"
3490 : highlightedItemValue === newProjectPublishToggleValue3362 : "Select"
3491 ? "Toggle"
3492 : "Select"
3493 : addProjectCloneFlow?.step === "repository"3363 : addProjectCloneFlow?.step === "repository"
3494 ? (remoteProjectButtonLabel ?? "Continue")3364 ? (remoteProjectButtonLabel ?? "Continue")
3495 : !canSubmitBrowsePath || hasHighlightedBrowseItem3365 : !canSubmitBrowsePath || hasHighlightedBrowseItem
...@@ -3566,7 +3436,7 @@ function OpenCommandPaletteDialog(props: {...@@ -3566,7 +3436,7 @@ function OpenCommandPaletteDialog(props: {
3566 {newProjectPathPreview !== null ? (3436 {newProjectPathPreview !== null ? (
3567 <div className="p-2 pb-0">3437 <div className="p-2 pb-0">
3568 <div className="flex min-h-8 items-center gap-2 rounded-sm px-2 py-1.5">3438 <div className="flex min-h-8 items-center gap-2 rounded-sm px-2 py-1.5">
3569 <FolderGit2Icon className={ITEM_ICON_CLASS} />3439 <FolderPlusIcon className={ITEM_ICON_CLASS} />
3570 <span className="flex min-w-0 flex-1 flex-col">3440 <span className="flex min-w-0 flex-1 flex-col">
3571 <span className="truncate text-foreground text-sm">3441 <span className="truncate text-foreground text-sm">
3572 {newProjectName.length > 0 ? newProjectName : "New project"}3442 {newProjectName.length > 0 ? newProjectName : "New project"}
apps/web/src/components/GitActionsControl.logic.test.ts+5-4
...@@ -823,7 +823,7 @@ describe("when: ref has no upstream configured", () => {...@@ -823,7 +823,7 @@ describe("when: ref has no upstream configured", () => {
823 });823 });
824 });824 });
825825
826 it("resolveQuickAction publishes when no origin remote exists", () => {826 it("resolveQuickAction rests without offering repository creation when no remote exists", () => {
827 const quick = resolveQuickAction(827 const quick = resolveQuickAction(
828 status({828 status({
829 hasUpstream: false,829 hasUpstream: false,
...@@ -835,9 +835,10 @@ describe("when: ref has no upstream configured", () => {...@@ -835,9 +835,10 @@ describe("when: ref has no upstream configured", () => {
835 false,835 false,
836 );836 );
837 assert.deepEqual(quick, {837 assert.deepEqual(quick, {
838 kind: "open_publish",838 kind: "show_hint",
839 label: "Publish repository",839 label: "Commit",
840 disabled: false,840 disabled: true,
841 hint: "No changes to commit.",
841 });842 });
842 });843 });
843844
apps/web/src/components/GitActionsControl.logic.ts+5-4
...@@ -23,7 +23,7 @@ export interface GitActionMenuItem {...@@ -23,7 +23,7 @@ export interface GitActionMenuItem {
23export interface GitQuickAction {23export interface GitQuickAction {
24 label: string;24 label: string;
25 disabled: boolean;25 disabled: boolean;
26 kind: "run_action" | "run_pull" | "open_publish" | "show_hint";26 kind: "run_action" | "run_pull" | "show_hint";
27 action?: GitStackedAction;27 action?: GitStackedAction;
28 hint?: string;28 hint?: string;
29}29}
...@@ -281,9 +281,10 @@ export function resolveQuickAction(...@@ -281,9 +281,10 @@ export function resolveQuickAction(
281 if (!gitStatus.hasUpstream) {281 if (!gitStatus.hasUpstream) {
282 if (!hasPrimaryRemote) {282 if (!hasPrimaryRemote) {
283 return {283 return {
284 label: "Publish repository",284 label: "Commit",
285 disabled: false,285 disabled: true,
286 kind: "open_publish",286 kind: "show_hint",
287 hint: "No changes to commit.",
287 };288 };
288 }289 }
289 if (!isAhead) {290 if (!isAhead) {
apps/web/src/components/GitActionsControl.tsx+35-701
...@@ -3,7 +3,6 @@ import { useAtomValue } from "@effect/atom-react";...@@ -3,7 +3,6 @@ import { useAtomValue } from "@effect/atom-react";
3import {3import {
4 AuthOrchestrationOperateScope,4 AuthOrchestrationOperateScope,
5 AuthSourceControlWriteScope,5 AuthSourceControlWriteScope,
6 SourceControlProviderKind,
7 type ScopedThreadRef,6 type ScopedThreadRef,
8} from "@t3tools/contracts";7} from "@t3tools/contracts";
9import {8import {
...@@ -13,32 +12,27 @@ import {...@@ -13,32 +12,27 @@ import {
13import type {12import type {
14 GitRunStackedActionResult,13 GitRunStackedActionResult,
15 GitStackedAction,14 GitStackedAction,
16 SourceControlCloneProtocol,
17 SourceControlProviderDiscoveryItem,
18 SourceControlPublishRepositoryResult,
19 SourceControlRepositoryVisibility,
20 VcsStatusResult,15 VcsStatusResult,
21} from "@t3tools/contracts";16} from "@t3tools/contracts";
22import { useNavigate } from "@tanstack/react-router";17import {
23import * as Option from "effect/Option";18 type MouseEvent,
24import { type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";19 useCallback,
25import { flushSync } from "react-dom";20 useEffect,
21 useEffectEvent,
22 useMemo,
23 useRef,
24 useState,
25} from "react";
26import {26import {
27 CheckIcon,27 CheckIcon,
28 ChevronDownIcon,28 ChevronDownIcon,
29 CloudDownloadIcon,29 CloudDownloadIcon,
30 CloudUploadIcon,30 CloudUploadIcon,
31 FileDiffIcon,31 FileDiffIcon,
32 GitBranchPlusIcon,
33 GitCommitIcon,32 GitCommitIcon,
34 InfoIcon,33 InfoIcon,
35 LockIcon,
36 GlobeIcon,
37} from "lucide-react";34} from "lucide-react";
38import { Radio as RadioPrimitive } from "@base-ui/react/radio";
39import { RadioGroup } from "~/components/ui/radio-group";
40import { Spinner } from "~/components/ui/spinner";35import { Spinner } from "~/components/ui/spinner";
41import { Toggle, ToggleGroup } from "~/components/ui/toggle-group";
42import { cn } from "~/lib/utils";36import { cn } from "~/lib/utils";
43import { useOpenPrLink } from "~/lib/openPullRequestLink";37import { useOpenPrLink } from "~/lib/openPullRequestLink";
44import {38import {
...@@ -59,7 +53,6 @@ import {...@@ -59,7 +53,6 @@ import {
59 resolveQuickAction,53 resolveQuickAction,
60 resolveThreadBranchUpdate,54 resolveThreadBranchUpdate,
61} from "./GitActionsControl.logic";55} from "./GitActionsControl.logic";
62import { WizardPopup, WizardHeader, WizardSteps, WizardPanel, WizardFooter } from "./ui/wizard";
63import { StartTruncatedPath } from "./StartTruncatedPath";56import { StartTruncatedPath } from "./StartTruncatedPath";
64import { Button } from "~/components/ui/button";57import { Button } from "~/components/ui/button";
65import { Checkbox } from "~/components/ui/checkbox";58import { Checkbox } from "~/components/ui/checkbox";
...@@ -73,7 +66,6 @@ import {...@@ -73,7 +66,6 @@ import {
73 DialogTitle,66 DialogTitle,
74} from "~/components/ui/dialog";67} from "~/components/ui/dialog";
75import { Group, GroupSeparator } from "~/components/ui/group";68import { Group, GroupSeparator } from "~/components/ui/group";
76import { Input } from "~/components/ui/input";
77import {69import {
78 Menu,70 Menu,
79 MenuItem,71 MenuItem,
...@@ -93,15 +85,12 @@ import { useOpenInPreferredEditor } from "~/editorPreferences";...@@ -93,15 +85,12 @@ import { useOpenInPreferredEditor } from "~/editorPreferences";
93import {85import {
94 useGitStackedAction,86 useGitStackedAction,
95 useSourceControlActionRunning,87 useSourceControlActionRunning,
96 useSourceControlPublishRepositoryAction,
97 useVcsInitAction,
98 useVcsPullAction,88 useVcsPullAction,
99} from "~/lib/sourceControlActions";89} from "~/lib/sourceControlActions";
100import { useThreadProjection, useThreadShell } from "~/state/entities";90import { useThreadProjection, useThreadShell } from "~/state/entities";
101import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";91import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
102import { useEnvironmentQuery } from "~/state/query";92import { useEnvironmentQuery } from "~/state/query";
103import { serverEnvironment } from "~/state/server";93import { serverEnvironment } from "~/state/server";
104import { sourceControlEnvironment } from "~/state/sourceControl";
105import { threadEnvironment } from "~/state/threads";94import { threadEnvironment } from "~/state/threads";
106import { useAtomCommand } from "~/state/use-atom-command";95import { useAtomCommand } from "~/state/use-atom-command";
107import { vcsActionManager, vcsEnvironment } from "~/state/vcs";96import { vcsActionManager, vcsEnvironment } from "~/state/vcs";
...@@ -115,10 +104,8 @@ import {...@@ -115,10 +104,8 @@ import {
115 THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS,104 THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS,
116 THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS,105 THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS,
117} from "./chat/threadDetailsPanelStyles";106} from "./chat/threadDetailsPanelStyles";
118import { getSourceControlPresentation, sourceControlIcon } from "~/sourceControlPresentation";107import { getSourceControlPresentation } from "~/sourceControlPresentation";
119import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
120import { formatDiffCount } from "@t3tools/client-runtime/diff-count";108import { formatDiffCount } from "@t3tools/client-runtime/diff-count";
121import { useOpenLink } from "~/browser/useOpenLink";
122109
123interface GitActionsControlProps {110interface GitActionsControlProps {
124 presentation?: "toolbar" | "menu";111 presentation?: "toolbar" | "menu";
...@@ -182,49 +169,6 @@ function requestVcsStatusRefresh(...@@ -182,49 +169,6 @@ function requestVcsStatusRefresh(
182const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const;169const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const;
183170
184/** Every built-in host, in the order the built-in definitions list them. */171/** Every built-in host, in the order the built-in definitions list them. */
185const PUBLISH_PROVIDER_OPTIONS = sourceControlClients.definitions.map((definition) => ({
186 value: definition.kind,
187 label: definition.pickerLabel,
188 description: definition.publishDescription,
189 pathPlaceholder: definition.repositoryPathHint,
190 definition,
191 Icon: sourceControlIcon(definition),
192}));
193
194function publishProviderOption(provider: SourceControlProviderKind) {
195 return (
196 PUBLISH_PROVIDER_OPTIONS.find((option) => option.value === provider) ??
197 PUBLISH_PROVIDER_OPTIONS[0]!
198 );
199}
200
201function getPublishProviderReadiness(input: {
202 provider: SourceControlProviderKind;
203 sourceControlProviders: ReadonlyArray<SourceControlProviderDiscoveryItem>;
204}): { readonly ready: boolean; readonly hint: string | null } {
205 const discovered = input.sourceControlProviders.find(
206 (provider) => provider.kind === input.provider,
207 );
208 if (!discovered) {
209 return {
210 ready: false,
211 hint: "Provider status unavailable. Open Settings -> Source Control and rescan.",
212 };
213 }
214 if (discovered.status !== "available") {
215 return { ready: false, hint: discovered.installHint };
216 }
217 if (discovered.auth.status === "unauthenticated") {
218 return {
219 ready: false,
220 hint:
221 Option.getOrNull(discovered.auth.detail) ??
222 `${discovered.label} is not authenticated. Open Settings -> Source Control for setup guidance.`,
223 };
224 }
225 return { ready: true, hint: null };
226}
227
228function getMenuActionDisabledReason({172function getMenuActionDisabledReason({
229 item,173 item,
230 gitStatus,174 gitStatus,
...@@ -312,7 +256,6 @@ function GitQuickActionIcon({...@@ -312,7 +256,6 @@ function GitQuickActionIcon({
312 className?: string;256 className?: string;
313 SourceControlIcon: ReturnType<typeof getSourceControlPresentation>["Icon"];257 SourceControlIcon: ReturnType<typeof getSourceControlPresentation>["Icon"];
314}) {258}) {
315 if (quickAction.kind === "open_publish") return <CloudUploadIcon className={className} />;
316 if (quickAction.kind === "run_pull") return <CloudDownloadIcon className={className} />;259 if (quickAction.kind === "run_pull") return <CloudDownloadIcon className={className} />;
317 if (quickAction.kind === "run_action") {260 if (quickAction.kind === "run_action") {
318 if (quickAction.action === "commit") return <GitCommitIcon className={className} />;261 if (quickAction.action === "commit") return <GitCommitIcon className={className} />;
...@@ -458,539 +401,6 @@ function GitActionSuccessButtonContent({ success }: { success: InlineGitActionSu...@@ -458,539 +401,6 @@ function GitActionSuccessButtonContent({ success }: { success: InlineGitActionSu
458 );401 );
459}402}
460403
461interface PublishRepositoryDialogProps {
462 readonly open: boolean;
463 readonly onOpenChange: (open: boolean) => void;
464 readonly environmentId: ScopedThreadRef["environmentId"] | null;
465 /** Thread the dialog was opened from, so the new repository can open beside it. */
466 readonly threadRef: ScopedThreadRef | null;
467 readonly gitCwd: string;
468}
469
470function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
471 const openLink = useOpenLink(props.threadRef);
472 const navigate = useNavigate();
473 const sourceControlDiscovery = useEnvironmentQuery(
474 props.environmentId === null
475 ? null
476 : sourceControlEnvironment.discovery({
477 environmentId: props.environmentId,
478 input: {},
479 }),
480 );
481 const [selectedPublishProvider, setSelectedPublishProvider] =
482 useState<SourceControlProviderKind | null>(null);
483 const [publishRepositoryOverride, setPublishRepositoryOverride] = useState<string | null>(null);
484 const [publishVisibility, setPublishVisibility] =
485 useState<SourceControlRepositoryVisibility>("private");
486 const [publishRemoteName, setPublishRemoteName] = useState("origin");
487 const [publishProtocol, setPublishProtocol] = useState<SourceControlCloneProtocol>("ssh");
488 const [publishWizardStep, setPublishWizardStep] = useState(0);
489 const [publishAdvancedOpen, setPublishAdvancedOpen] = useState(false);
490 const [publishError, setPublishError] = useState<string | null>(null);
491 const [publishResult, setPublishResult] = useState<SourceControlPublishRepositoryResult | null>(
492 null,
493 );
494 const sourceControlScope = useMemo(
495 () => ({
496 environmentId: props.environmentId,
497 cwd: props.gitCwd,
498 }),
499 [props.environmentId, props.gitCwd],
500 );
501 const publishRepositoryAction = useSourceControlPublishRepositoryAction(sourceControlScope);
502 const publishAccountByProvider = useMemo(() => {
503 const accounts = new Map<string, string | null>();
504 for (const provider of sourceControlDiscovery.data?.sourceControlProviders ?? []) {
505 accounts.set(provider.kind, Option.getOrNull(provider.auth.account));
506 }
507 return accounts;
508 }, [sourceControlDiscovery.data]);
509 const publishProviderReadiness = useMemo(() => {
510 const sourceControlProviders = sourceControlDiscovery.data?.sourceControlProviders ?? [];
511 const readiness = new Map(
512 PUBLISH_PROVIDER_OPTIONS.map((option) => [
513 option.value,
514 getPublishProviderReadiness({ provider: option.value, sourceControlProviders }),
515 ]),
516 );
517 return (provider: SourceControlProviderKind) =>
518 readiness.get(provider) ?? { ready: false, hint: null };
519 }, [sourceControlDiscovery.data]);
520 const hasReadyPublishProvider = useMemo(
521 () => PUBLISH_PROVIDER_OPTIONS.some((option) => publishProviderReadiness(option.value).ready),
522 [publishProviderReadiness],
523 );
524 const sortedPublishProviderOptions = useMemo(
525 () =>
526 PUBLISH_PROVIDER_OPTIONS.toSorted((left, right) => {
527 const leftReady = publishProviderReadiness(left.value).ready;
528 const rightReady = publishProviderReadiness(right.value).ready;
529 if (leftReady !== rightReady) {
530 return leftReady ? -1 : 1;
531 }
532 return left.label.localeCompare(right.label);
533 }),
534 [publishProviderReadiness],
535 );
536 const firstReadyPublishProvider = sortedPublishProviderOptions.find(
537 (option) => publishProviderReadiness(option.value).ready,
538 )?.value;
539 const publishProvider =
540 selectedPublishProvider !== null && publishProviderReadiness(selectedPublishProvider).ready
541 ? selectedPublishProvider
542 : (firstReadyPublishProvider ??
543 selectedPublishProvider ??
544 sourceControlClients.get(undefined).kind);
545 const selectedPublishProviderReadiness = publishProviderReadiness(publishProvider);
546 const publishAccount = publishAccountByProvider.get(publishProvider) ?? null;
547 const publishRepositoryPrefill = publishAccount ? `${publishAccount}/` : "";
548 const publishRepository = publishRepositoryOverride ?? publishRepositoryPrefill;
549 const currentPublishProvider = publishProviderOption(publishProvider);
550 const publishHost = currentPublishProvider.definition.publishHost(
551 Option.getOrNull(
552 sourceControlDiscovery.data?.sourceControlProviders.find(
553 (provider) => provider.kind === publishProvider,
554 )?.auth.host ?? Option.none(),
555 ),
556 );
557 const publishPathPlaceholder = currentPublishProvider.pathPlaceholder;
558 const publishProviderLabel = currentPublishProvider.label;
559 const publishWizardSteps = ["Provider", "Repository", "Summary"] as const;
560 const publishWizardStepSummaries = [
561 publishProviderLabel,
562 publishResult?.repository.nameWithOwner ?? null,
563 null,
564 ] as const;
565
566 const canSubmitPublishRepository = useMemo(() => {
567 if (!publishRepositoryAction.isAllowed || !selectedPublishProviderReadiness.ready) return false;
568 if (publishRepositoryAction.isPending) return false;
569 const repositoryParts = publishRepository.trim().split("/");
570 const owner = repositoryParts[0]?.trim() ?? "";
571 const rest = repositoryParts.slice(1);
572 const name = rest.join("/").trim();
573 return owner.length > 0 && name.length > 0;
574 }, [
575 publishRepository,
576 publishRepositoryAction.isAllowed,
577 publishRepositoryAction.isPending,
578 selectedPublishProviderReadiness,
579 ]);
580
581 const submitPublishRepository = useCallback(() => {
582 if (!canSubmitPublishRepository) {
583 return;
584 }
585
586 setPublishError(null);
587
588 void (async () => {
589 const result = await publishRepositoryAction.run({
590 provider: publishProvider,
591 repository: publishRepository.trim(),
592 visibility: publishVisibility,
593 remoteName: publishRemoteName.trim() || "origin",
594 protocol: publishProtocol,
595 });
596
597 if (result._tag === "Failure") {
598 if (!isAtomCommandInterrupted(result)) {
599 const error = squashAtomCommandFailure(result);
600 setPublishError(error instanceof Error ? error.message : "An error occurred.");
601 }
602 return;
603 }
604
605 flushSync(() => {
606 setPublishResult(result.value);
607 setPublishWizardStep(2);
608 });
609 })();
610 }, [
611 canSubmitPublishRepository,
612 props.environmentId,
613 props.gitCwd,
614 publishProtocol,
615 publishProvider,
616 publishRemoteName,
617 publishRepository,
618 publishRepositoryAction,
619 publishVisibility,
620 ]);
621
622 const resetState = useCallback(() => {
623 setPublishRemoteName("origin");
624 setPublishRepositoryOverride(null);
625 setPublishWizardStep(0);
626 setPublishAdvancedOpen(false);
627 setPublishError(null);
628 setPublishResult(null);
629 }, []);
630
631 const handleOpenChange = useCallback(
632 (open: boolean) => {
633 props.onOpenChange(open);
634 if (!open) {
635 resetState();
636 }
637 },
638 [props, resetState],
639 );
640
641 const openSourceControlSettings = useCallback(() => {
642 handleOpenChange(false);
643 void navigate({ to: "/settings/source-control" });
644 }, [handleOpenChange, navigate]);
645
646 return (
647 <Dialog open={props.open} onOpenChange={handleOpenChange}>
648 <WizardPopup>
649 <WizardHeader
650 title="Publish repository"
651 description="Pick where to host it, then point us at a repo to push to."
652 >
653 <WizardSteps
654 steps={publishWizardSteps}
655 currentStep={publishWizardStep}
656 summaries={publishWizardStepSummaries}
657 showSummaries
658 isStepDisabled={(index) =>
659 publishWizardStep === 2 ||
660 index >= publishWizardSteps.length - 1 ||
661 index > publishWizardStep
662 }
663 onStepChange={setPublishWizardStep}
664 />
665 </WizardHeader>
666
667 <WizardPanel>
668 <div className={cn("space-y-2", publishWizardStep !== 0 && "hidden")}>
669 <span id="publish-provider-cards-label" className="text-xs font-medium text-foreground">
670 Provider
671 </span>
672 <RadioGroup
673 value={publishProvider}
674 onValueChange={(value) => {
675 setSelectedPublishProvider(value as SourceControlProviderKind);
676 setPublishRepositoryOverride(null);
677 }}
678 aria-labelledby="publish-provider-cards-label"
679 className="grid grid-cols-2"
680 >
681 {sortedPublishProviderOptions.map((option) => {
682 const readiness = publishProviderReadiness(option.value);
683 const isSelected = publishProvider === option.value && readiness.ready;
684 if (!readiness.ready) {
685 return (
686 <div
687 key={option.value}
688 className="relative flex cursor-not-allowed items-center gap-3 rounded-lg border border-border bg-background px-3 py-3 text-left opacity-64 dark:border-transparent dark:bg-white/[0.035]"
689 >
690 <option.Icon className="size-5 shrink-0 text-muted-foreground" aria-hidden />
691 <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
692 {option.label}
693 </span>
694 <Tooltip>
695 <TooltipTrigger
696 render={
697 <Button
698 variant="warning-outline"
699 size="micro"
700 onClick={(event) => {
701 event.preventDefault();
702 event.stopPropagation();
703 openSourceControlSettings();
704 }}
705 >
706 Setup Required
707 </Button>
708 }
709 />
710 <TooltipPopup side="top" align="end">
711 {readiness.hint ??
712 "Open Settings -> Source Control to configure this provider."}
713 </TooltipPopup>
714 </Tooltip>
715 </div>
716 );
717 }
718
719 return (
720 <RadioPrimitive.Root
721 key={option.value}
722 value={option.value}
723 className={cn(
724 "relative flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-3 text-left outline-none transition-[background-color,border-color,box-shadow]",
725 "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
726 isSelected
727 ? "border-primary bg-background shadow-sm ring-2 ring-inset ring-primary/35 dark:border-transparent dark:bg-primary/10 dark:shadow-none dark:ring-1 dark:ring-primary/30"
728 : "border-border bg-background hover:border-foreground/20 hover:bg-muted/50 dark:border-transparent dark:bg-white/[0.035] dark:hover:bg-accent",
729 )}
730 >
731 <option.Icon className="size-5 shrink-0" aria-hidden />
732 <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
733 {option.label}
734 </span>
735 </RadioPrimitive.Root>
736 );
737 })}
738 </RadioGroup>
739 </div>
740
741 <div className={cn("space-y-5", publishWizardStep !== 1 && "hidden")}>
742 <div className="space-y-2">
743 <label
744 htmlFor="publish-repository-path"
745 className="text-xs font-medium text-foreground"
746 >
747 Repository
748 </label>
749 <div className="flex items-stretch overflow-hidden rounded-md border border-input bg-background focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-ring">
750 <span className="flex shrink-0 items-center gap-1.5 border-r border-input bg-muted/50 px-2.5 font-mono text-xs text-muted-foreground">
751 <currentPublishProvider.Icon className="size-3.5" />
752 {publishHost}/
753 </span>
754 <input
755 id="publish-repository-path"
756 name="publish-repository-path"
757 value={publishRepository}
758 onChange={(event) => {
759 setPublishRepositoryOverride(event.target.value);
760 }}
761 onKeyDown={(event) => {
762 if (event.key === "Enter") {
763 event.preventDefault();
764 submitPublishRepository();
765 }
766 }}
767 placeholder={publishPathPlaceholder}
768 disabled={publishRepositoryAction.isPending}
769 className="w-full bg-transparent px-3 py-2 font-mono text-sm placeholder:text-muted-foreground/60 focus:outline-none"
770 />
771 </div>
772 </div>
773
774 <div className="space-y-2">
775 <span
776 id="publish-visibility-cards-label"
777 className="text-xs font-medium text-foreground"
778 >
779 Visibility
780 </span>
781 <RadioGroup
782 value={publishVisibility}
783 onValueChange={(value) =>
784 setPublishVisibility(value as SourceControlRepositoryVisibility)
785 }
786 aria-labelledby="publish-visibility-cards-label"
787 disabled={publishRepositoryAction.isPending}
788 className="grid grid-cols-2"
789 >
790 {[
791 {
792 value: "private" as const,
793 label: "Private",
794 description: "Only invited people",
795 Icon: LockIcon,
796 },
797 {
798 value: "public" as const,
799 label: "Public",
800 description: "Anyone on the web",
801 Icon: GlobeIcon,
802 },
803 ].map((option) => {
804 const isSelected = publishVisibility === option.value;
805 return (
806 <RadioPrimitive.Root
807 key={option.value}
808 value={option.value}
809 className={cn(
810 "relative flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-2.5 text-left outline-none transition-[background-color,border-color,box-shadow]",
811 "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
812 isSelected
813 ? "border-primary bg-background shadow-sm ring-2 ring-inset ring-primary/35 dark:border-transparent dark:bg-primary/10 dark:shadow-none dark:ring-1 dark:ring-primary/30"
814 : "border-border bg-background hover:border-foreground/20 hover:bg-muted/50 dark:border-transparent dark:bg-white/[0.035] dark:hover:bg-accent",
815 )}
816 >
817 <option.Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
818 <span className="min-w-0 flex-1">
819 <span className="block text-sm font-medium text-foreground">
820 {option.label}
821 </span>
822 <span className="block text-xs text-muted-foreground">
823 {option.description}
824 </span>
825 </span>
826 </RadioPrimitive.Root>
827 );
828 })}
829 </RadioGroup>
830 </div>
831
832 <div>
833 <button
834 type="button"
835 onClick={() => setPublishAdvancedOpen((prev) => !prev)}
836 aria-expanded={publishAdvancedOpen}
837 className="flex items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
838 >
839 <ChevronDownIcon
840 className={cn(
841 "size-3.5 transition-transform",
842 publishAdvancedOpen ? "" : "-rotate-90",
843 )}
844 />
845 Advanced
846 </button>
847 {publishAdvancedOpen ? (
848 <div className="mt-3 grid gap-3 sm:grid-cols-2">
849 <label className="space-y-1.5" htmlFor="publish-remote-name">
850 <span className="text-xs font-medium text-foreground">Remote</span>
851 <Input
852 id="publish-remote-name"
853 value={publishRemoteName}
854 onChange={(event) => setPublishRemoteName(event.target.value)}
855 placeholder="origin"
856 disabled={publishRepositoryAction.isPending}
857 />
858 </label>
859 <div className="space-y-1.5">
860 <span
861 id="publish-protocol-label"
862 className="text-xs font-medium text-foreground"
863 >
864 Protocol
865 </span>
866 <ToggleGroup
867 value={[publishProtocol]}
868 onValueChange={(next) => {
869 const protocol = next[0];
870 if (protocol === "ssh" || protocol === "https") {
871 setPublishProtocol(protocol);
872 }
873 }}
874 aria-labelledby="publish-protocol-label"
875 disabled={publishRepositoryAction.isPending}
876 >
877 <Toggle value="ssh">SSH</Toggle>
878 <Toggle value="https">HTTPS</Toggle>
879 </ToggleGroup>
880 </div>
881 </div>
882 ) : null}
883 </div>
884
885 {publishRepositoryAction.isPending ? (
886 <div
887 role="status"
888 aria-live="polite"
889 className="flex items-center gap-2 rounded-md border border-input bg-muted/40 px-3 py-2 text-xs text-muted-foreground dark:border-transparent dark:bg-white/[0.035]"
890 >
891 <Spinner size="sm" aria-hidden />
892 Publishing repository to {publishProviderLabel}...
893 </div>
894 ) : null}
895 {publishError && !publishRepositoryAction.isPending ? (
896 <div
897 role="alert"
898 className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive"
899 >
900 <p className="font-medium">Publish failed</p>
901 <p className="mt-0.5 text-destructive/90">{publishError}</p>
902 </div>
903 ) : null}
904 </div>
905
906 <div className={cn("space-y-4", publishWizardStep !== 2 && "hidden")}>
907 {publishResult ? (
908 <>
909 <div className="flex flex-col items-center gap-2 py-1 text-center">
910 <span className="grid size-8 place-items-center rounded-full bg-success/15 text-success">
911 <CheckIcon className="size-4" aria-hidden />
912 </span>
913 <h3 className="text-sm font-semibold text-foreground">
914 {publishResult.status === "pushed"
915 ? "Repository published"
916 : "Repository created"}
917 </h3>
918 <p className="max-w-xs text-pretty text-xs text-muted-foreground">
919 {publishResult.status === "pushed"
920 ? `${publishResult.branch} is now live on ${publishProviderLabel}.`
921 : `Remote "${publishResult.remoteName}" is set up. Make a commit and push it to share your code.`}
922 </p>
923 </div>
924 <div className="flex items-center gap-2 rounded-lg border border-input bg-muted/40 px-3 py-2 dark:border-transparent dark:bg-white/[0.035]">
925 <currentPublishProvider.Icon className="size-3.5 shrink-0 text-muted-foreground" />
926 <span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">
927 {publishResult.repository.nameWithOwner}
928 </span>
929 </div>
930 <Button
931 variant="outline"
932 size="sm"
933 className="w-full"
934 onClick={() => {
935 void openLink(publishResult.repository.url).catch(() => undefined);
936 }}
937 >
938 Open on {publishProviderLabel}
939 </Button>
940 </>
941 ) : (
942 <div className="rounded-md border border-input bg-background px-3 py-2 text-xs text-muted-foreground dark:border-transparent dark:bg-white/[0.035]">
943 Publish result unavailable.
944 </div>
945 )}
946 </div>
947 </WizardPanel>
948
949 <WizardFooter>
950 {publishWizardStep === 2 ? (
951 <Button onClick={() => handleOpenChange(false)}>Done</Button>
952 ) : (
953 <>
954 <Button
955 variant="outline"
956 disabled={publishRepositoryAction.isPending}
957 onClick={() => {
958 if (publishWizardStep === 0) {
959 handleOpenChange(false);
960 return;
961 }
962 setPublishWizardStep((step) => Math.max(0, step - 1));
963 }}
964 >
965 {publishWizardStep === 0 ? "Cancel" : "Back"}
966 </Button>
967 {publishWizardStep < 1 ? (
968 <Button
969 disabled={!hasReadyPublishProvider || !selectedPublishProviderReadiness.ready}
970 onClick={() => setPublishWizardStep((step) => Math.min(1, step + 1))}
971 >
972 Next
973 </Button>
974 ) : (
975 <Button disabled={!canSubmitPublishRepository} onClick={submitPublishRepository}>
976 {publishRepositoryAction.isPending ? (
977 <>
978 <Spinner size="sm" aria-hidden />
979 Publishing...
980 </>
981 ) : (
982 "Publish"
983 )}
984 </Button>
985 )}
986 </>
987 )}
988 </WizardFooter>
989 </WizardPopup>
990 </Dialog>
991 );
992}
993
994export default function GitActionsControl({404export default function GitActionsControl({
995 presentation = "toolbar",405 presentation = "toolbar",
996 gitCwd,406 gitCwd,
...@@ -1044,7 +454,6 @@ export default function GitActionsControl({...@@ -1044,7 +454,6 @@ export default function GitActionsControl({
1044 const [dialogCommitMessage, setDialogCommitMessage] = useState("");454 const [dialogCommitMessage, setDialogCommitMessage] = useState("");
1045 const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set());455 const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set());
1046 const [isEditingFiles, setIsEditingFiles] = useState(false);456 const [isEditingFiles, setIsEditingFiles] = useState(false);
1047 const [isPublishDialogOpen, setIsPublishDialogOpen] = useState(false);
1048 const [inlineSuccess, setInlineSuccess] = useState<InlineGitActionSuccess | null>(null);457 const [inlineSuccess, setInlineSuccess] = useState<InlineGitActionSuccess | null>(null);
1049 const [pendingDefaultBranchAction, setPendingDefaultBranchAction] =458 const [pendingDefaultBranchAction, setPendingDefaultBranchAction] =
1050 useState<PendingDefaultBranchAction | null>(null);459 useState<PendingDefaultBranchAction | null>(null);
...@@ -1153,7 +562,6 @@ export default function GitActionsControl({...@@ -1153,7 +562,6 @@ export default function GitActionsControl({
1153 const allSelected = excludedFiles.size === 0;562 const allSelected = excludedFiles.size === 0;
1154 const noneSelected = selectedFiles.length === 0;563 const noneSelected = selectedFiles.length === 0;
1155564
1156 const initAction = useVcsInitAction(sourceControlScope);
1157 const runImmediateGitAction = useGitStackedAction(sourceControlScope);565 const runImmediateGitAction = useGitStackedAction(sourceControlScope);
1158 const pullAction = useVcsPullAction(sourceControlScope);566 const pullAction = useVcsPullAction(sourceControlScope);
1159 const isGitActionRunning = useSourceControlActionRunning(567 const isGitActionRunning = useSourceControlActionRunning(
...@@ -1470,10 +878,6 @@ export default function GitActionsControl({...@@ -1470,10 +878,6 @@ export default function GitActionsControl({
1470 };878 };
1471879
1472 const runQuickAction = () => {880 const runQuickAction = () => {
1473 if (quickAction.kind === "open_publish") {
1474 setIsPublishDialogOpen(true);
1475 return;
1476 }
1477 if (quickAction.kind === "run_pull") {881 if (quickAction.kind === "run_pull") {
1478 void (async () => {882 void (async () => {
1479 setInlineSuccess(null);883 setInlineSuccess(null);
...@@ -1595,26 +999,6 @@ export default function GitActionsControl({...@@ -1595,26 +999,6 @@ export default function GitActionsControl({
1595 [gitCwd, openInPreferredEditor, threadToastData],999 [gitCwd, openInPreferredEditor, threadToastData],
1596 );1000 );
15971001
1598 const canPublishRepository =
1599 canWriteSourceControl && isRepo && gitStatusForActions !== null && !hasPrimaryRemote;
1600
1601 const initializeGit = () => {
1602 void (async () => {
1603 const result = await initAction.run();
1604 if (result._tag === "Success" || isAtomCommandInterrupted(result)) {
1605 return;
1606 }
1607 const error = squashAtomCommandFailure(result);
1608 toastManager.add(
1609 stackedThreadToast({
1610 type: "error",
1611 title: "Git initialization failed",
1612 description: error instanceof Error ? error.message : "An error occurred.",
1613 ...(threadToastData !== undefined ? { data: threadToastData } : {}),
1614 }),
1615 );
1616 })();
1617 };
1618 const gitItems = (1002 const gitItems = (
1619 <>1003 <>
1620 {gitActionMenuItems.map((item) => {1004 {gitActionMenuItems.map((item) => {
...@@ -1673,18 +1057,6 @@ export default function GitActionsControl({...@@ -1673,18 +1057,6 @@ export default function GitActionsControl({
1673 </MenuItem>1057 </MenuItem>
1674 );1058 );
1675 })}1059 })}
1676 {canPublishRepository ? (
1677 <MenuItem
1678 density={presentation === "menu" ? "touch" : "default"}
1679 disabled={isGitActionRunning}
1680 onClick={() => {
1681 setIsPublishDialogOpen(true);
1682 }}
1683 >
1684 <CloudUploadIcon />
1685 <MenuItemLabel>Publish repository...</MenuItemLabel>
1686 </MenuItem>
1687 ) : null}
1688 {gitStatusForActions?.refName === null && (1060 {gitStatusForActions?.refName === null && (
1689 <p className="px-2 py-1.5 text-xs text-warning">1061 <p className="px-2 py-1.5 text-xs text-warning">
1690 Detached HEAD: create and check out a branch to enable push and pull request actions.1062 Detached HEAD: create and check out a branch to enable push and pull request actions.
...@@ -1701,70 +1073,40 @@ export default function GitActionsControl({...@@ -1701,70 +1073,40 @@ export default function GitActionsControl({
1701 </>1073 </>
1702 );1074 );
17031075
1704 if (!gitCwd) return null;1076 if (!gitCwd || !isRepo) return null;
17051077
1706 return (1078 return (
1707 <>1079 <>
1708 {presentation === "menu" ? (1080 {presentation === "menu" ? (
1709 !isRepo ? (1081 <>
1710 <MenuItem1082 <MenuItem
1711 density={presentation === "menu" ? "touch" : "default"}1083 density={presentation === "menu" ? "touch" : "default"}
17121084
1713 disabled={initAction.isPending}1085 disabled={isGitActionRunning || quickAction.disabled || !!quickActionDisabledReason}
1714 onClick={initializeGit}1086 onClick={runQuickAction}
1715 >1087 >
1716 <GitBranchPlusIcon className="size-4" />1088 <GitQuickActionIcon
1717 <MenuItemLabel>1089 className="size-4"
1718 {initAction.isPending ? "Initializing..." : "Initialize Git"}1090 quickAction={quickAction}
1719 </MenuItemLabel>1091 SourceControlIcon={SourceControlIcon}
1092 />
1093 <MenuItemLabel>{quickAction.label}</MenuItemLabel>
1720 </MenuItem>1094 </MenuItem>
1721 ) : (1095 {quickActionDisabledReason && (
1722 <>1096 <p className="max-w-64 px-2 py-1.5 text-xs text-warning">{quickActionDisabledReason}</p>
1723 <MenuItem1097 )}
1724 density={presentation === "menu" ? "touch" : "default"}1098 <MenuSub
17251099 onOpenChange={(open) => {
1726 disabled={isGitActionRunning || quickAction.disabled || !!quickActionDisabledReason}1100 if (open) requestVcsStatusRefresh(refreshVcsStatus, activeEnvironmentId, gitCwd);
1727 onClick={runQuickAction}1101 }}
1728 >1102 >
1729 <GitQuickActionIcon1103 <MenuSubTrigger density="touch" disabled={isGitActionRunning}>
1730 className="size-4"1104 <SourceControlIcon className="size-4" />
1731 quickAction={quickAction}1105 <MenuItemLabel>Git actions</MenuItemLabel>
1732 SourceControlIcon={SourceControlIcon}1106 </MenuSubTrigger>
1733 />1107 <MenuSubPopup>{gitItems}</MenuSubPopup>
1734 <MenuItemLabel>{quickAction.label}</MenuItemLabel>1108 </MenuSub>
1735 </MenuItem>1109 </>
1736 {quickActionDisabledReason && (
1737 <p className="max-w-64 px-2 py-1.5 text-xs text-warning">
1738 {quickActionDisabledReason}
1739 </p>
1740 )}
1741 <MenuSub
1742 onOpenChange={(open) => {
1743 if (open) requestVcsStatusRefresh(refreshVcsStatus, activeEnvironmentId, gitCwd);
1744 }}
1745 >
1746 <MenuSubTrigger density="touch" disabled={isGitActionRunning}>
1747 <SourceControlIcon className="size-4" />
1748 <MenuItemLabel>Git actions</MenuItemLabel>
1749 </MenuSubTrigger>
1750 <MenuSubPopup>{gitItems}</MenuSubPopup>
1751 </MenuSub>
1752 </>
1753 )
1754 ) : !isRepo ? (
1755 <ThreadDetailsControl
1756 size="xs"
1757 variant={isPanel ? "ghost" : "outline"}
1758 part="row"
1759 panel={isPanel}
1760 disabled={!canWriteSourceControl || initAction.isPending}
1761 onClick={initializeGit}
1762 >
1763 <GitBranchPlusIcon className="size-3.5" aria-hidden />
1764 <span className={cn("ml-0.5", isPanel && THREAD_DETAILS_PANEL_LABEL_CLASS)}>
1765 {initAction.isPending ? "Initializing..." : "Initialize Git"}
1766 </span>
1767 </ThreadDetailsControl>
1768 ) : compact && !gitActionProgress && !visibleInlineSuccess ? null : (1110 ) : compact && !gitActionProgress && !visibleInlineSuccess ? null : (
1769 <ActionGroup1111 <ActionGroup
1770 role="group"1112 role="group"
...@@ -2102,14 +1444,6 @@ export default function GitActionsControl({...@@ -2102,14 +1444,6 @@ export default function GitActionsControl({
2102 </DialogPopup>1444 </DialogPopup>
2103 </Dialog>1445 </Dialog>
21041446
2105 <PublishRepositoryDialog
2106 open={isPublishDialogOpen}
2107 onOpenChange={setIsPublishDialogOpen}
2108 environmentId={activeEnvironmentId}
2109 threadRef={activeThreadRef}
2110 gitCwd={gitCwd}
2111 />
2112
2113 <Dialog1447 <Dialog
2114 open={pendingDefaultBranchAction !== null}1448 open={pendingDefaultBranchAction !== null}
2115 onOpenChange={(open) => {1449 onOpenChange={(open) => {
apps/web/src/components/LegacySidebar.tsx+4-81
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { performDesktopUpdate } from "./desktopUpdate.actions";
1import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";2import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";
2import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests";3import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests";
3import { Spinner } from "~/components/ui/spinner";4import { Spinner } from "~/components/ui/spinner";
...@@ -138,14 +139,10 @@ import { formatRelativeTimeLabel } from "../timestampFormat";...@@ -138,14 +139,10 @@ import { formatRelativeTimeLabel } from "../timestampFormat";
138import { Kbd } from "./ui/kbd";139import { Kbd } from "./ui/kbd";
139import {140import {
140 getArm64IntelBuildWarningDescription,141 getArm64IntelBuildWarningDescription,
141 getDesktopUpdateActionError,
142 getDesktopUpdateInstallConfirmationMessage,
143 isDesktopUpdateButtonDisabled,142 isDesktopUpdateButtonDisabled,
144 resolveDesktopUpdateButtonAction,143 resolveDesktopUpdateButtonAction,
145 shouldShowArm64IntelBuildWarning,144 shouldShowArm64IntelBuildWarning,
146 shouldToastDesktopUpdateActionResult,
147} from "./desktopUpdate.logic";145} from "./desktopUpdate.logic";
148import { showDesktopUpdateDownloadedToast } from "./desktopUpdate.toast";
149import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";146import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
150import { Button } from "./ui/button";147import { Button } from "./ui/button";
151import {148import {
...@@ -3746,83 +3743,9 @@ export default function LegacySidebar() {...@@ -3746,83 +3743,9 @@ export default function LegacySidebar() {
37463743
3747 setDesktopUpdateActionPending(true);3744 setDesktopUpdateActionPending(true);
37483745
3749 if (desktopUpdateButtonAction === "download") {3746 void performDesktopUpdate(bridge, desktopUpdateState).finally(() =>
3750 void bridge3747 setDesktopUpdateActionPending(false),
3751 .downloadUpdate()3748 );
3752 .then((result) => {
3753 if (result.completed) {
3754 showDesktopUpdateDownloadedToast(bridge, result.state);
3755 }
3756 if (!shouldToastDesktopUpdateActionResult(result)) return;
3757 const actionError = getDesktopUpdateActionError(result);
3758 if (!actionError) return;
3759 toastManager.add(
3760 stackedThreadToast({
3761 type: "error",
3762 title: "Could not download update",
3763 description: actionError,
3764 }),
3765 );
3766 })
3767 .catch((error) => {
3768 toastManager.add(
3769 stackedThreadToast({
3770 type: "error",
3771 title: "Could not start update download",
3772 description: error instanceof Error ? error.message : "An unexpected error occurred.",
3773 }),
3774 );
3775 })
3776 .finally(() => setDesktopUpdateActionPending(false));
3777 return;
3778 }
3779
3780 if (desktopUpdateButtonAction === "install") {
3781 let confirmed = false;
3782 try {
3783 confirmed = await ensureLocalApi().dialogs.confirm(
3784 getDesktopUpdateInstallConfirmationMessage(desktopUpdateState),
3785 );
3786 } catch (error) {
3787 setDesktopUpdateActionPending(false);
3788 toastManager.add(
3789 stackedThreadToast({
3790 type: "error",
3791 title: "Could not confirm update",
3792 description: error instanceof Error ? error.message : "Update confirmation failed.",
3793 }),
3794 );
3795 return;
3796 }
3797 if (!confirmed) {
3798 setDesktopUpdateActionPending(false);
3799 return;
3800 }
3801 void bridge
3802 .installUpdate()
3803 .then((result) => {
3804 if (!shouldToastDesktopUpdateActionResult(result)) return;
3805 const actionError = getDesktopUpdateActionError(result);
3806 if (!actionError) return;
3807 toastManager.add(
3808 stackedThreadToast({
3809 type: "error",
3810 title: "Could not install update",
3811 description: actionError,
3812 }),
3813 );
3814 })
3815 .catch((error) => {
3816 toastManager.add(
3817 stackedThreadToast({
3818 type: "error",
3819 title: "Could not install update",
3820 description: error instanceof Error ? error.message : "An unexpected error occurred.",
3821 }),
3822 );
3823 })
3824 .finally(() => setDesktopUpdateActionPending(false));
3825 }
3826 }, [3749 }, [
3827 desktopUpdateActionPending,3750 desktopUpdateActionPending,
3828 desktopUpdateButtonAction,3751 desktopUpdateButtonAction,
apps/web/src/components/ProviderUpdatesAction.test.tsx created+116
...@@ -0,0 +1,116 @@
1// @vitest-environment jsdom
2import {
3 EnvironmentId,
4 ProviderDriverKind,
5 ProviderInstanceId,
6 type ServerProvider,
7} from "@t3tools/contracts";
8import { AsyncResult, Atom } from "effect/reactivity";
9import { act } from "react";
10import { createRoot, type Root } from "react-dom/client";
11import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
12
13import { appAtomRegistry, AppAtomRegistryProvider } from "~/rpc/atomRegistry";
14
15const mocks = vi.hoisted(() => ({
16 environments: [] as Array<{
17 environmentId: EnvironmentId;
18 label: string;
19 connection: { phase: "connected" };
20 serverConfig: { providers: ServerProvider[] };
21 }>,
22 permissionAtom: vi.fn(),
23 update: vi.fn(),
24}));
25vi.mock("~/state/environments", () => ({
26 useEnvironments: () => ({ environments: mocks.environments }),
27}));
28vi.mock("~/state/server", () => ({
29 serverEnvironment: { updateProvider: { permissionAtom: mocks.permissionAtom } },
30}));
31vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => mocks.update }));
32vi.mock("./ui/toast", () => ({ toastManager: { add: vi.fn() }, stackedThreadToast: vi.fn() }));
33
34import { useProviderUpdates } from "./ProviderUpdatesAction";
35
36const home = EnvironmentId.make("home");
37const work = EnvironmentId.make("work");
38const homeAllowed = Atom.make(false).pipe(Atom.keepAlive);
39const workAllowed = Atom.make(false).pipe(Atom.keepAlive);
40let root: Root;
41let container: HTMLDivElement;
42
43function Updates() {
44 const { machines, updateAll } = useProviderUpdates();
45 return machines.length > 0 ? (
46 <button onClick={() => void updateAll({ quiet: true })}>Update all</button>
47 ) : null;
48}
49
50beforeEach(() => {
51 const provider: ServerProvider = {
52 instanceId: ProviderInstanceId.make("codex-home"),
53 driver: ProviderDriverKind.make("codex"),
54 enabled: true,
55 installed: true,
56 version: "1.0.0",
57 status: "ready",
58 auth: { status: "authenticated" },
59 checkedAt: "2026-10-15T12:00:00Z",
60 models: [],
61 slashCommands: [],
62 skills: [],
63 versionAdvisory: {
64 status: "behind_latest",
65 currentVersion: "1.0.0",
66 latestVersion: "1.1.0",
67 updateCommand: "npm install -g provider",
68 canUpdate: true,
69 checkedAt: "2026-10-15T12:00:00Z",
70 message: "Update available.",
71 },
72 };
73 mocks.environments = [home, work].map((environmentId) => ({
74 environmentId,
75 label: environmentId,
76 connection: { phase: "connected" },
77 serverConfig: { providers: [provider] },
78 }));
79 mocks.permissionAtom.mockImplementation((id: EnvironmentId) =>
80 id === home ? homeAllowed : workAllowed,
81 );
82 mocks.update.mockReset().mockResolvedValue(AsyncResult.success(undefined));
83 appAtomRegistry.set(homeAllowed, false);
84 appAtomRegistry.set(workAllowed, false);
85 container = document.createElement("div");
86 document.body.append(container);
87 root = createRoot(container);
88 Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
89});
90
91afterEach(async () => {
92 await act(async () => root.unmount());
93 container.remove();
94});
95
96it("offers and updates only permitted machines, reacting to permission changes", async () => {
97 await act(async () =>
98 root.render(
99 <AppAtomRegistryProvider>
100 <Updates />
101 </AppAtomRegistryProvider>,
102 ),
103 );
104 expect(container.querySelector("button")).toBeNull();
105
106 await act(async () => appAtomRegistry.set(workAllowed, true));
107 expect(container.querySelector("button")).not.toBeNull();
108 await act(async () => container.querySelector("button")?.click());
109 expect(mocks.update).toHaveBeenCalledExactlyOnceWith({
110 environmentId: work,
111 input: { provider: "codex", instanceId: "codex-home" },
112 });
113
114 await act(async () => appAtomRegistry.set(workAllowed, false));
115 expect(container.querySelector("button")).toBeNull();
116});
apps/web/src/components/ProviderUpdatesAction.tsx+23-15
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import { useAtomValue } from "@effect/atom-react";
2import { Atom } from "effect/reactivity";
1import { PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts";3import { PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts";
2import { useMemo, useRef, useState } from "react";4import { useMemo, useRef, useState } from "react";
35
...@@ -18,8 +20,8 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";...@@ -18,8 +20,8 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
18 * Every outdated provider with a one-click update on every connected machine,20 * Every outdated provider with a one-click update on every connected machine,
19 * plus a function that updates them all and reports the results in one toast.21 * plus a function that updates them all and reports the results in one toast.
20 * Each server queues updates that share an installer, so sending them all22 * Each server queues updates that share an installer, so sending them all
21 * together is safe. The server still checks permissions: a session that23 * together is safe. Only permitted machines are offered; the server checks
22 * cannot operate a machine gets a failure line for it. The returned promise24 * permission again at execution. The returned promise
23 * settles with every result when every update has finished. `quiet` skips the25 * settles with every result when every update has finished. `quiet` skips the
24 * toast, for a caller that reports the results itself.26 * toast, for a caller that reports the results itself.
25 */27 */
...@@ -28,22 +30,28 @@ export function useProviderUpdates() {...@@ -28,22 +30,28 @@ export function useProviderUpdates() {
28 const updateProvider = useAtomCommand(serverEnvironment.updateProvider, {30 const updateProvider = useAtomCommand(serverEnvironment.updateProvider, {
29 reportFailure: false,31 reportFailure: false,
30 });32 });
31 const machines = useMemo(33 const machinesAtom = useMemo(
32 () =>34 () =>
33 environments.flatMap((environment) => {35 Atom.make((get) =>
34 const providers = environment.serverConfig?.providers ?? [];36 environments.flatMap((environment) => {
35 if (environment.connection.phase !== "connected") {37 const providers = environment.serverConfig?.providers ?? [];
36 return [];38 if (
37 }39 environment.connection.phase !== "connected" ||
38 const candidates = collectProviderUpdateCandidates(providers).filter((candidate) =>40 !get(serverEnvironment.updateProvider.permissionAtom(environment.environmentId))
39 canOneClickUpdateProviderCandidate(candidate, providers),41 ) {
40 );42 return [];
41 return candidates.length > 043 }
42 ? [{ environmentId: environment.environmentId, label: environment.label, candidates }]44 const candidates = collectProviderUpdateCandidates(providers).filter((candidate) =>
43 : [];45 canOneClickUpdateProviderCandidate(candidate, providers),
44 }),46 );
47 return candidates.length > 0
48 ? [{ environmentId: environment.environmentId, label: environment.label, candidates }]
49 : [];
50 }),
51 ),
45 [environments],52 [environments],
46 );53 );
54 const machines = useAtomValue(machinesAtom);
4755
48 const updateAll = async ({ quiet = false }: { readonly quiet?: boolean } = {}) => {56 const updateAll = async ({ quiet = false }: { readonly quiet?: boolean } = {}) => {
49 const runs = await Promise.all(57 const runs = await Promise.all(
apps/web/src/components/SidebarStageBackdrop.tsx-12
...@@ -46,18 +46,6 @@ export function useSidebarStageBackdropVariant(enabled = true): SidebarStageBack...@@ -46,18 +46,6 @@ export function useSidebarStageBackdropVariant(enabled = true): SidebarStageBack
46 return resolveSidebarStageBackdropVariant(useEnvironmentStageLabel(), enabled);46 return resolveSidebarStageBackdropVariant(useEnvironmentStageLabel(), enabled);
47}47}
4848
49/** Stage-channel header art; palettes mirror the per-channel app icons in `assets/`. */
50export function SidebarStageBackdrop({ variant }: { variant: SidebarStageBackdropVariant }) {
51 return (
52 <div
53 aria-hidden
54 className="sidebar-stage-backdrop pointer-events-none absolute inset-x-0 top-0 z-0 h-20 select-none overflow-hidden"
55 >
56 <StageBackdropArt variant={variant} />
57 </div>
58 );
59}
60
61export function StageBackdropArt({ variant }: { variant: SidebarStageBackdropVariant }) {49export function StageBackdropArt({ variant }: { variant: SidebarStageBackdropVariant }) {
62 return variant === "nightly" ? <NightlySkyArt /> : <DevBlueprintArt />;50 return variant === "nightly" ? <NightlySkyArt /> : <DevBlueprintArt />;
63}51}
apps/web/src/components/desktopUpdate.actions.ts created+102
...@@ -0,0 +1,102 @@
1import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts";
2import { ensureLocalApi } from "../localApi";
3import { stackedThreadToast, toastManager } from "./ui/toast";
4import {
5 getDesktopUpdateActionError,
6 getDesktopUpdateInstallConfirmationMessage,
7 resolveDesktopUpdateButtonAction,
8 shouldToastDesktopUpdateActionResult,
9} from "./desktopUpdate.logic";
10import { showDesktopUpdateDownloadedToast } from "./desktopUpdate.toast";
11import type { ReportLocalUpdateError } from "./sidebar/useUpdateEverything";
12
13export const toastUpdateError: ReportLocalUpdateError = (title, description) => {
14 toastManager.add(stackedThreadToast({ type: "error", title, description }));
15};
16
17/** Prepares a full-app update or applies a compatible UI without stopping tasks. */
18export async function downloadLocalUpdate(
19 bridge: DesktopBridge,
20 {
21 notifyDownloaded,
22 reportError = toastUpdateError,
23 }: { readonly notifyDownloaded: boolean; readonly reportError?: ReportLocalUpdateError },
24): Promise<boolean> {
25 try {
26 const result = await bridge.downloadUpdate();
27 if (result.completed && result.state.installKind === "ui") {
28 return await installLocalUpdate(bridge, reportError);
29 }
30 if (result.completed && notifyDownloaded) {
31 showDesktopUpdateDownloadedToast(bridge, result.state);
32 }
33 const actionError = shouldToastDesktopUpdateActionResult(result)
34 ? getDesktopUpdateActionError(result)
35 : null;
36 if (actionError) reportError("Could not download update", actionError);
37 return result.completed;
38 } catch (error) {
39 reportError(
40 "Could not start update download",
41 error instanceof Error ? error.message : "An unexpected error occurred.",
42 );
43 return false;
44 }
45}
46
47/** Applies a prepared UI in place or starts an app relaunch after the caller confirms. */
48export async function installLocalUpdate(
49 bridge: DesktopBridge,
50 reportError: ReportLocalUpdateError = toastUpdateError,
51): Promise<boolean> {
52 try {
53 const result = await bridge.installUpdate();
54 if (!result.accepted) {
55 showDesktopUpdateDownloadedToast(bridge, result.state);
56 return false;
57 }
58 const actionError = shouldToastDesktopUpdateActionResult(result)
59 ? getDesktopUpdateActionError(result)
60 : null;
61 if (!actionError) {
62 if (result.completed && result.state.uiVersion) window.location.reload();
63 return true;
64 }
65 reportError("Could not install update", actionError);
66 return false;
67 } catch (error) {
68 reportError(
69 "Could not install update",
70 error instanceof Error ? error.message : "An unexpected error occurred.",
71 );
72 return false;
73 }
74}
75
76export async function performDesktopUpdate(
77 bridge: DesktopBridge,
78 state: DesktopUpdateState,
79): Promise<void> {
80 const action = resolveDesktopUpdateButtonAction(state);
81 try {
82 if (action === "download") {
83 await downloadLocalUpdate(bridge, { notifyDownloaded: true });
84 } else if (action === "install") {
85 if (
86 state.installKind === "ui" ||
87 (await ensureLocalApi().dialogs.confirm(getDesktopUpdateInstallConfirmationMessage(state)))
88 ) {
89 await installLocalUpdate(bridge);
90 }
91 } else {
92 const result = await bridge.checkForUpdate();
93 if (!result.checked)
94 toastUpdateError(
95 "Could not check for updates",
96 result.state.message ?? "Automatic updates are not available in this build.",
97 );
98 }
99 } catch (error) {
100 toastUpdateError("Could not update app", error instanceof Error ? error.message : "Try again.");
101 }
102}
apps/web/src/components/desktopUpdate.logic.ts+3-1
...@@ -87,7 +87,9 @@ export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string...@@ -87,7 +87,9 @@ export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string
87 return `Downloading update${progress}`;87 return `Downloading update${progress}`;
88 }88 }
89 if (state.status === "downloaded") {89 if (state.status === "downloaded") {
90 return `Update ${state.downloadedVersion ?? state.availableVersion ?? "ready"} downloaded. Click to restart and install.`;90 return state.installKind === "ui"
91 ? `UI update ${state.downloadedVersion ?? state.availableVersion ?? "ready"} downloaded. Reload to apply without interrupting running tasks.`
92 : `Update ${state.downloadedVersion ?? state.availableVersion ?? "ready"} downloaded. Click to restart and install.`;
91 }93 }
92 if (state.status === "error") {94 if (state.status === "error") {
93 if (state.errorContext === "download" && state.availableVersion) {95 if (state.errorContext === "download" && state.availableVersion) {
apps/web/src/components/desktopUpdate.toast.tsx+3-1
...@@ -56,7 +56,9 @@ export function showDesktopUpdateDownloadedToast(...@@ -56,7 +56,9 @@ export function showDesktopUpdateDownloadedToast(
56 title: "Update downloaded",56 title: "Update downloaded",
57 description: (57 description: (
58 <>58 <>
59 Restart the app from the update button to install it.59 {state.installKind === "ui"
60 ? "Reload the UI from the update button to apply it."
61 : "Restart the app from the update button to install it."}
60 {releaseUrl ? <ReleaseNotesLink releaseUrl={releaseUrl} shell={shell} /> : null}62 {releaseUrl ? <ReleaseNotesLink releaseUrl={releaseUrl} shell={shell} /> : null}
61 </>63 </>
62 ),64 ),
apps/web/src/components/files/FilePreviewPanel.tsx+1-1
...@@ -1101,7 +1101,7 @@ export default function FilePreviewPanel({...@@ -1101,7 +1101,7 @@ export default function FilePreviewPanel({
1101 // it on the panel meant a thread switch dropped it and forced source back.1101 // it on the panel meant a thread switch dropped it and forced source back.
1102 const [renderMarkdownPreferred, setRenderMarkdownPreferred] = useLocalStorage(1102 const [renderMarkdownPreferred, setRenderMarkdownPreferred] = useLocalStorage(
1103 RENDER_MARKDOWN_STORAGE_KEY,1103 RENDER_MARKDOWN_STORAGE_KEY,
1104 false,1104 true,
1105 Schema.Boolean,1105 Schema.Boolean,
1106 );1106 );
1107 const [renderBrowserFilePreferred, setRenderBrowserFilePreferred] = useLocalStorage(1107 const [renderBrowserFilePreferred, setRenderBrowserFilePreferred] = useLocalStorage(
apps/web/src/components/settings/ProjectFaviconPickerDialog.test.tsx+2-2
...@@ -28,8 +28,8 @@ vi.mock("~/state/server", () => ({...@@ -28,8 +28,8 @@ vi.mock("~/state/server", () => ({
28 primaryServerKeybindingsAtom: Symbol("keybindings"),28 primaryServerKeybindingsAtom: Symbol("keybindings"),
29}));29}));
3030
31vi.mock("~/hooks/useTheme", () => ({31vi.mock("~/assets/assetUrls", () => ({
32 useTheme: () => ({ resolvedTheme: "dark" }),32 useAssetUrlState: () => ({ _tag: "Loading" }),
33}));33}));
3434
35vi.mock("../files/projectFilesQueryState", () => ({35vi.mock("../files/projectFilesQueryState", () => ({
apps/web/src/components/settings/ProjectFaviconPickerDialog.tsx+53-8
...@@ -1,15 +1,15 @@...@@ -1,15 +1,15 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentId } from "@t3tools/contracts";2import type { EnvironmentId } from "@t3tools/contracts";
3import { isWindowsAbsolutePath } from "@t3tools/shared/path";3import { isWindowsAbsolutePath } from "@t3tools/shared/path";
4import { ImageIcon } from "lucide-react";
4import { useMemo, useState } from "react";5import { useMemo, useState } from "react";
56
7import { useAssetUrlState } from "~/assets/assetUrls";
6import { primaryServerKeybindingsAtom } from "~/state/server";8import { primaryServerKeybindingsAtom } from "~/state/server";
7import { useTheme } from "~/hooks/useTheme";
8import { getLocalFileManagerName, isWindowsPlatform } from "~/lib/utils";9import { getLocalFileManagerName, isWindowsPlatform } from "~/lib/utils";
9import { CommandPaletteContent } from "../CommandPaletteContent";10import { CommandPaletteContent } from "../CommandPaletteContent";
10import type { CommandPaletteActionItem } from "../CommandPalette.logic";11import type { CommandPaletteActionItem } from "../CommandPalette.logic";
11import { CommandPaletteResults } from "../CommandPaletteResults";12import { CommandPaletteResults } from "../CommandPaletteResults";
12import { PierreEntryIcon } from "../chat/PierreEntryIcon";
13import {13import {
14 getProjectFilePickerMatches,14 getProjectFilePickerMatches,
15 PROJECT_FILE_PICKER_RESULT_LIMIT,15 PROJECT_FILE_PICKER_RESULT_LIMIT,
...@@ -27,6 +27,51 @@ export function canPickExternalProjectFavicon(cwd: string, platform: string): bo...@@ -27,6 +27,51 @@ export function canPickExternalProjectFavicon(cwd: string, platform: string): bo
27 return !isWindowsPlatform(platform) || isWindowsAbsolutePath(cwd);27 return !isWindowsPlatform(platform) || isWindowsAbsolutePath(cwd);
28}28}
2929
30function ProjectIconFilePreview(props: {
31 readonly environmentId: EnvironmentId;
32 readonly cwd: string;
33 readonly path: string;
34}) {
35 const asset = useAssetUrlState(props.environmentId, {
36 _tag: "draft-workspace-file",
37 cwd: props.cwd,
38 path: props.path,
39 });
40 const [failedUrl, setFailedUrl] = useState<string | null>(null);
41 const src = asset._tag === "Success" && asset.url !== failedUrl ? asset.url : null;
42
43 return (
44 <span
45 aria-hidden="true"
46 className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-sm bg-muted"
47 style={
48 src
49 ? {
50 backgroundImage:
51 "repeating-conic-gradient(color-mix(in srgb, var(--foreground) 40%, var(--background)) 0 25%, color-mix(in srgb, var(--foreground) 60%, var(--background)) 0 50%)",
52 backgroundSize: "8px 8px",
53 }
54 : undefined
55 }
56 >
57 {src ? (
58 <img
59 src={src}
60 alt=""
61 width={32}
62 height={32}
63 loading="lazy"
64 decoding="async"
65 className="size-full object-contain"
66 onError={() => setFailedUrl(src)}
67 />
68 ) : (
69 <ImageIcon className="size-4 text-muted-foreground" />
70 )}
71 </span>
72 );
73}
74
30export function ProjectFaviconPickerDialog(props: {75export function ProjectFaviconPickerDialog(props: {
31 readonly cwd: string;76 readonly cwd: string;
32 readonly environmentId: EnvironmentId;77 readonly environmentId: EnvironmentId;
...@@ -36,17 +81,17 @@ export function ProjectFaviconPickerDialog(props: {...@@ -36,17 +81,17 @@ export function ProjectFaviconPickerDialog(props: {
36 readonly open: boolean;81 readonly open: boolean;
37 readonly projectName: string;82 readonly projectName: string;
38}) {83}) {
84 const { cwd, environmentId, onSelect } = props;
39 const [query, setQuery] = useState("");85 const [query, setQuery] = useState("");
40 const [highlightedItemValue, setHighlightedItemValue] = useState<string | null>(null);86 const [highlightedItemValue, setHighlightedItemValue] = useState<string | null>(null);
41 const [isPickingExternal, setIsPickingExternal] = useState(false);87 const [isPickingExternal, setIsPickingExternal] = useState(false);
42 const result = useProjectFilePickerQuery(88 const result = useProjectFilePickerQuery(
43 props.environmentId,89 environmentId,
44 props.cwd,90 cwd,
45 query,91 query,
46 PROJECT_FILE_PICKER_RESULT_LIMIT,92 PROJECT_FILE_PICKER_RESULT_LIMIT,
47 { imageOnly: true },93 { imageOnly: true },
48 );94 );
49 const { resolvedTheme } = useTheme();
50 const keybindings = useAtomValue(primaryServerKeybindingsAtom);95 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
51 const pickExternal = props.onPickExternal;96 const pickExternal = props.onPickExternal;
52 const fileManagerName = getLocalFileManagerName(97 const fileManagerName = getLocalFileManagerName(
...@@ -60,10 +105,10 @@ export function ProjectFaviconPickerDialog(props: {...@@ -60,10 +105,10 @@ export function ProjectFaviconPickerDialog(props: {
60 searchTerms: [match.name, match.path],105 searchTerms: [match.name, match.path],
61 title: match.name,106 title: match.name,
62 description: match.path,107 description: match.path,
63 icon: <PierreEntryIcon pathValue={match.path} kind="file" theme={resolvedTheme} />,108 icon: <ProjectIconFilePreview environmentId={environmentId} cwd={cwd} path={match.path} />,
64 run: async () => props.onSelect(match.path),109 run: async () => onSelect(match.path),
65 })),110 })),
66 [props.onSelect, resolvedTheme, result.entries, result.matchedQuery],111 [environmentId, cwd, onSelect, result.entries, result.matchedQuery],
67 );112 );
68113
69 return (114 return (
apps/web/src/components/settings/SettingsPanels.tsx+37-95
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { performDesktopUpdate } from "../desktopUpdate.actions";
1import { SettingsGroup } from "./SettingsGroup";2import { SettingsGroup } from "./SettingsGroup";
2import { useScopedSettingsWriteAllowed } from "./useScopedSettings";3import { useScopedSettingsWriteAllowed } from "./useScopedSettings";
3import { Spinner } from "~/components/ui/spinner";4import { Spinner } from "~/components/ui/spinner";
...@@ -6,7 +7,7 @@ import { PRIVACY_POLICY_URL } from "../../legalLinks";...@@ -6,7 +7,7 @@ import { PRIVACY_POLICY_URL } from "../../legalLinks";
6import { ArchiveIcon, ArchiveX, CheckIcon, ChevronRightIcon, SettingsIcon } from "lucide-react";7import { ArchiveIcon, ArchiveX, CheckIcon, ChevronRightIcon, SettingsIcon } from "lucide-react";
7import { Link, useNavigate } from "@tanstack/react-router";8import { Link, useNavigate } from "@tanstack/react-router";
8import type { CSSProperties, ReactNode } from "react";9import type { CSSProperties, ReactNode } from "react";
9import { useCallback, useEffect, useMemo, useRef, useState } from "react";10import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
10import {11import {
11 type BackgroundActivityProfile,12 type BackgroundActivityProfile,
12 type DesktopUpdateChannel,13 type DesktopUpdateChannel,
...@@ -59,7 +60,6 @@ import { CliCommandSettingsRow } from "./CliCommandSettingsRow";...@@ -59,7 +60,6 @@ import { CliCommandSettingsRow } from "./CliCommandSettingsRow";
59import {60import {
60 canCheckForUpdate,61 canCheckForUpdate,
61 getDesktopUpdateButtonTooltip,62 getDesktopUpdateButtonTooltip,
62 getDesktopUpdateInstallConfirmationMessage,
63 isDesktopUpdateButtonDisabled,63 isDesktopUpdateButtonDisabled,
64 resolveDesktopUpdateButtonAction,64 resolveDesktopUpdateButtonAction,
65} from "../../components/desktopUpdate.logic";65} from "../../components/desktopUpdate.logic";
...@@ -270,10 +270,13 @@ function backgroundActivityProfileSettings(profile: BackgroundActivityProfile) {...@@ -270,10 +270,13 @@ function backgroundActivityProfileSettings(profile: BackgroundActivityProfile) {
270}270}
271271
272function AboutVersionTitle() {272function AboutVersionTitle() {
273 const updateState = useDesktopUpdateState();
274 const version = updateState?.uiVersion ?? updateState?.currentVersion ?? APP_VERSION;
275
273 return (276 return (
274 <span className="inline-flex items-baseline gap-2">277 <span className="inline-flex items-baseline gap-2">
275 <span>Version</span>278 <span>Version</span>
276 <code className="text-2xs font-medium text-muted-foreground">{APP_VERSION}</code>279 <code className="text-2xs font-medium text-muted-foreground">{version}</code>
277 </span>280 </span>
278 );281 );
279}282}
...@@ -324,85 +327,13 @@ function AboutVersionSection() {...@@ -324,85 +327,13 @@ function AboutVersionSection() {
324 const bridge = window.desktopBridge;327 const bridge = window.desktopBridge;
325 if (!bridge) return;328 if (!bridge) return;
326329
327 const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none";330 if (!updateState || isUpdateActionPending) return;
328331 setIsUpdateActionPending(true);
329 if (action === "download") {332 try {
330 void bridge.downloadUpdate().catch((error: unknown) => {333 await performDesktopUpdate(bridge, updateState);
331 toastManager.add(334 } finally {
332 stackedThreadToast({335 setIsUpdateActionPending(false);
333 type: "error",
334 title: "Could not download update",
335 description: error instanceof Error ? error.message : "Download failed.",
336 }),
337 );
338 });
339 return;
340 }
341
342 if (action === "install") {
343 if (isUpdateActionPending) return;
344 setIsUpdateActionPending(true);
345 let confirmed = false;
346 try {
347 confirmed = await ensureLocalApi().dialogs.confirm(
348 getDesktopUpdateInstallConfirmationMessage(
349 updateState ?? { availableVersion: null, downloadedVersion: null },
350 ),
351 );
352 } catch (error) {
353 setIsUpdateActionPending(false);
354 toastManager.add(
355 stackedThreadToast({
356 type: "error",
357 title: "Could not confirm update",
358 description: error instanceof Error ? error.message : "Update confirmation failed.",
359 }),
360 );
361 return;
362 }
363 if (!confirmed) {
364 setIsUpdateActionPending(false);
365 return;
366 }
367 void bridge
368 .installUpdate()
369 .catch((error: unknown) => {
370 toastManager.add(
371 stackedThreadToast({
372 type: "error",
373 title: "Could not install update",
374 description: error instanceof Error ? error.message : "Install failed.",
375 }),
376 );
377 })
378 .finally(() => setIsUpdateActionPending(false));
379 return;
380 }336 }
381
382 if (typeof bridge.checkForUpdate !== "function") return;
383 void bridge
384 .checkForUpdate()
385 .then((result) => {
386 if (!result.checked) {
387 toastManager.add(
388 stackedThreadToast({
389 type: "error",
390 title: "Could not check for updates",
391 description:
392 result.state.message ?? "Automatic updates are not available in this build.",
393 }),
394 );
395 }
396 })
397 .catch((error: unknown) => {
398 toastManager.add(
399 stackedThreadToast({
400 type: "error",
401 title: "Could not check for updates",
402 description: error instanceof Error ? error.message : "Update check failed.",
403 }),
404 );
405 });
406 }, [isUpdateActionPending, updateState]);337 }, [isUpdateActionPending, updateState]);
407338
408 const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none";339 const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none";
...@@ -411,8 +342,10 @@ function AboutVersionSection() {...@@ -411,8 +342,10 @@ function AboutVersionSection() {
411 action === "none"342 action === "none"
412 ? !canCheckForUpdate(updateState)343 ? !canCheckForUpdate(updateState)
413 : isDesktopUpdateButtonDisabled(updateState);344 : isDesktopUpdateButtonDisabled(updateState);
414345 const actionLabel: Record<string, string> = {
415 const actionLabel: Record<string, string> = { download: "Download", install: "Install" };346 download: "Download",
347 install: updateState?.installKind === "ui" ? "Reload" : "Restart",
348 };
416 const statusLabel: Record<string, string> = {349 const statusLabel: Record<string, string> = {
417 checking: "Checking…",350 checking: "Checking…",
418 downloading: "Downloading…",351 downloading: "Downloading…",
...@@ -1525,23 +1458,32 @@ export function AppearanceSettingsPanel() {...@@ -1525,23 +1458,32 @@ export function AppearanceSettingsPanel() {
1525 );1458 );
1526}1459}
15271460
1461function subscribeToFontLoading(onChange: () => void) {
1462 document.fonts.addEventListener("loadingdone", onChange);
1463 document.fonts.addEventListener("loadingerror", onChange);
1464
1465 return () => {
1466 document.fonts.removeEventListener("loadingdone", onChange);
1467 document.fonts.removeEventListener("loadingerror", onChange);
1468 };
1469}
1470
1528function useFontDefaultFamilies() {1471function useFontDefaultFamilies() {
1529 const settings = useScopedSettings();1472 const settings = useScopedSettings();
1530 // An unset preference shows the font it resolves to on this machine; the1473 const sans = useSyncExternalStore(
1531 // default stacks are the platform's own faces, so the name is probed, not1474 subscribeToFontLoading,
1532 // hardcoded.1475 () => resolveDefaultFamilyLabel(DEFAULT_SANS_FONT_STACK) ?? "System default",
1533 const defaults = useMemo(
1534 () => ({
1535 sans: resolveDefaultFamilyLabel(DEFAULT_SANS_FONT_STACK) ?? "System default",
1536 code: resolveDefaultFamilyLabel(DEFAULT_CODE_FONT_STACK) ?? "System monospace",
1537 }),
1538 [],
1539 );1476 );
1477 const code = useSyncExternalStore(
1478 subscribeToFontLoading,
1479 () => resolveDefaultFamilyLabel(DEFAULT_CODE_FONT_STACK) ?? "System monospace",
1480 );
1481
1540 return {1482 return {
1541 sans: defaults.sans,1483 sans,
1542 code: defaults.code,1484 code,
1543 // The composer inherits whatever the interface preference resolves to.1485 // The composer inherits whatever the interface preference resolves to.
1544 interfaceFamily: settings.fontFamilySans.trim() || defaults.sans,1486 interfaceFamily: settings.fontFamilySans.trim() || sans,
1545 };1487 };
1546}1488}
15471489
apps/web/src/components/settings/SettingsSidebarNav.tsx+3-63
...@@ -6,25 +6,9 @@ import {...@@ -6,25 +6,9 @@ import {
6 useMemo,6 useMemo,
7 useRef,7 useRef,
8 useState,8 useState,
9 type ComponentType,
10 type KeyboardEvent,9 type KeyboardEvent,
11} from "react";10} from "react";
12import {11import { SearchIcon, XIcon } from "lucide-react";
13 ArchiveIcon,
14 BlocksIcon,
15 BotIcon,
16 createLucideIcon,
17 CalendarClockIcon,
18 GitBranchIcon,
19 HardDriveIcon,
20 PanelsTopLeftIcon,
21 KeyboardIcon,
22 Link2Icon,
23 PaletteIcon,
24 SearchIcon,
25 Settings2Icon,
26 XIcon,
27} from "lucide-react";
28import { useLocation, useNavigate } from "@tanstack/react-router";12import { useLocation, useNavigate } from "@tanstack/react-router";
2913
30import { Button } from "../ui/button";14import { Button } from "../ui/button";
...@@ -43,25 +27,13 @@ import { SidebarUtilityMenu } from "../sidebar/SidebarChrome";...@@ -43,25 +27,13 @@ import { SidebarUtilityMenu } from "../sidebar/SidebarChrome";
43import { scrollToSettingsTarget } from "./settingsLayout";27import { scrollToSettingsTarget } from "./settingsLayout";
44import {28import {
45 searchSettings,29 searchSettings,
46 isSettingsOverviewVisible,
47 SETTINGS_SECTION_LABELS,30 SETTINGS_SECTION_LABELS,
48 type SettingsPath,31 type SettingsPath,
49 type SettingsSearchItem,32 type SettingsSearchItem,
50} from "./settingsSearch";33} from "./settingsSearch";
51import { useAvailableSettingsSearchItems } from "./useAvailableSettingsSearchItems";34import { useAvailableSettingsSearchItems } from "./useAvailableSettingsSearchItems";
52import { validateSettingsScopeSearch } from "./settingsScope";35import { validateSettingsScopeSearch } from "./settingsScope";
5336import { getSettingsNavigationItems, SETTINGS_SECTION_ICONS } from "./settingsNavigation";
54const SnapShotIcon = createLucideIcon("snap-shot", [
55 [
56 "path",
57 {
58 d: "M8 3H6a3 3 0 0 0-3 3v2M16 3h2a3 3 0 0 1 3 3v2M21 16v2a3 3 0 0 1-3 3h-2M8 21H6a3 3 0 0 1-3-3v-2",
59 key: "capture-frame",
60 },
61 ],
62 ["rect", { width: "10", height: "8", x: "7", y: "8", rx: "2", key: "window" }],
63 ["circle", { cx: "12", cy: "12", r: "1.5", key: "lens" }],
64]);
6537
66const T3ConnectSidebarSignIn = lazy(() =>38const T3ConnectSidebarSignIn = lazy(() =>
67 import("../clerk/T3ConnectSidebarSignIn").then((module) => ({39 import("../clerk/T3ConnectSidebarSignIn").then((module) => ({
...@@ -74,36 +46,6 @@ const T3ConnectSidebarAvatar = lazy(() =>...@@ -74,36 +46,6 @@ const T3ConnectSidebarAvatar = lazy(() =>
74 })),46 })),
75);47);
7648
77const SETTINGS_SECTION_ICONS: Readonly<
78 Record<SettingsPath, ComponentType<{ className?: string }>>
79> = {
80 "/settings/general": Settings2Icon,
81 "/settings/diagnostics": Settings2Icon,
82 "/settings/appearance": PaletteIcon,
83 "/settings/projects": PanelsTopLeftIcon,
84 "/settings/keybindings": KeyboardIcon,
85 "/settings/snap-shot": SnapShotIcon,
86 "/settings/providers": BotIcon,
87 "/settings/integrations": BlocksIcon,
88 "/settings/scheduled-tasks": CalendarClockIcon,
89 "/settings/source-control": GitBranchIcon,
90 "/settings/storage": HardDriveIcon,
91 "/settings/connections": Link2Icon,
92 "/settings/archived": ArchiveIcon,
93};
94
95const SETTINGS_NAV_ITEMS: ReadonlyArray<{
96 label: string;
97 to: SettingsPath;
98 icon: ComponentType<{ className?: string }>;
99}> = (Object.keys(SETTINGS_SECTION_LABELS) as SettingsPath[])
100 .filter((to) => to !== "/settings/diagnostics")
101 .map((to) => ({
102 to,
103 label: SETTINGS_SECTION_LABELS[to],
104 icon: SETTINGS_SECTION_ICONS[to],
105 }));
106
107function SettingsSectionIcon({ to }: { to: SettingsPath }) {49function SettingsSectionIcon({ to }: { to: SettingsPath }) {
108 const Icon = SETTINGS_SECTION_ICONS[to];50 const Icon = SETTINGS_SECTION_ICONS[to];
109 return <Icon className="mt-0.5 size-3.5 shrink-0 text-sidebar-muted-foreground/60" />;51 return <Icon className="mt-0.5 size-3.5 shrink-0 text-sidebar-muted-foreground/60" />;
...@@ -114,9 +56,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {...@@ -114,9 +56,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
114 const currentHash = useLocation({ select: (location) => location.hash });56 const currentHash = useLocation({ select: (location) => location.hash });
115 const currentSearch = useLocation({ select: (location) => location.search });57 const currentSearch = useLocation({ select: (location) => location.search });
116 const scopeSearch = useMemo(() => validateSettingsScopeSearch(currentSearch), [currentSearch]);58 const scopeSearch = useMemo(() => validateSettingsScopeSearch(currentSearch), [currentSearch]);
117 const navItems = SETTINGS_NAV_ITEMS.filter(59 const navItems = getSettingsNavigationItems(scopeSearch);
118 (item) => item.to !== "/settings/projects" || isSettingsOverviewVisible(scopeSearch),
119 );
120 const { isMobile, setOpenMobile, open, setOpen } = useSidebar();60 const { isMobile, setOpenMobile, open, setOpen } = useSidebar();
121 const searchInputRef = useRef<HTMLInputElement>(null);61 const searchInputRef = useRef<HTMLInputElement>(null);
122 const [query, setQuery] = useState("");62 const [query, setQuery] = useState("");
apps/web/src/components/settings/settingsNavigation.tsx created+70
...@@ -0,0 +1,70 @@
1import type { ComponentType } from "react";
2import {
3 ArchiveIcon,
4 BlocksIcon,
5 BotIcon,
6 CalendarClockIcon,
7 createLucideIcon,
8 GitBranchIcon,
9 HardDriveIcon,
10 KeyboardIcon,
11 Link2Icon,
12 PaletteIcon,
13 PanelsTopLeftIcon,
14 Settings2Icon,
15} from "lucide-react";
16
17import {
18 isSettingsOverviewVisible,
19 SETTINGS_SECTION_LABELS,
20 type SettingsPath,
21} from "./settingsSearch";
22import type { SettingsScopeSearch } from "./settingsScope";
23
24const SnapShotIcon = createLucideIcon("snap-shot", [
25 [
26 "path",
27 {
28 d: "M8 3H6a3 3 0 0 0-3 3v2M16 3h2a3 3 0 0 1 3 3v2M21 16v2a3 3 0 0 1-3 3h-2M8 21H6a3 3 0 0 1-3-3v-2",
29 key: "capture-frame",
30 },
31 ],
32 ["rect", { width: "10", height: "8", x: "7", y: "8", rx: "2", key: "window" }],
33 ["circle", { cx: "12", cy: "12", r: "1.5", key: "lens" }],
34]);
35
36export const SETTINGS_SECTION_ICONS: Readonly<
37 Record<SettingsPath, ComponentType<{ className?: string }>>
38> = {
39 "/settings/general": Settings2Icon,
40 "/settings/diagnostics": Settings2Icon,
41 "/settings/appearance": PaletteIcon,
42 "/settings/projects": PanelsTopLeftIcon,
43 "/settings/keybindings": KeyboardIcon,
44 "/settings/snap-shot": SnapShotIcon,
45 "/settings/providers": BotIcon,
46 "/settings/integrations": BlocksIcon,
47 "/settings/scheduled-tasks": CalendarClockIcon,
48 "/settings/source-control": GitBranchIcon,
49 "/settings/storage": HardDriveIcon,
50 "/settings/connections": Link2Icon,
51 "/settings/archived": ArchiveIcon,
52};
53
54const SETTINGS_NAV_ITEMS: ReadonlyArray<{
55 label: string;
56 to: SettingsPath;
57 icon: ComponentType<{ className?: string }>;
58}> = (Object.keys(SETTINGS_SECTION_LABELS) as SettingsPath[])
59 .filter((to) => to !== "/settings/diagnostics")
60 .map((to) => ({
61 to,
62 label: SETTINGS_SECTION_LABELS[to],
63 icon: SETTINGS_SECTION_ICONS[to],
64 }));
65
66export function getSettingsNavigationItems(scopeSearch: SettingsScopeSearch) {
67 return SETTINGS_NAV_ITEMS.filter(
68 (item) => item.to !== "/settings/projects" || isSettingsOverviewVisible(scopeSearch),
69 );
70}
apps/web/src/components/sidebar/AppMenu.tsx created+80
...@@ -0,0 +1,80 @@
1import { Link, useLocation } from "@tanstack/react-router";
2import { useId, useState } from "react";
3
4import { APP_BASE_NAME, APP_VERSION } from "../../branding";
5import { useDesktopUpdateState } from "../../state/desktopUpdate";
6import { KnifeMark } from "../KnifeMark";
7import { useEnvironmentStageLabel } from "../SidebarStageBackdrop";
8import { getSettingsNavigationItems } from "../settings/settingsNavigation";
9import { validateSettingsScopeSearch } from "../settings/settingsScope";
10import { Button } from "../ui/button";
11import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover";
12import { Separator } from "../ui/separator";
13import { useSidebar } from "../ui/sidebar";
14import { AppMenuProviderUpdates } from "./AppMenuProviderUpdates";
15import { AppMenuUpdates } from "./SidebarUpdatePill";
16
17export function AppMenu() {
18 const [open, setOpen] = useState(false);
19 const settingsHeadingId = useId();
20 const { isMobile, setOpenMobile } = useSidebar();
21 const { pathname, search } = useLocation();
22 const scopeSearch = validateSettingsScopeSearch(pathname.startsWith("/settings") ? search : {});
23 const stageLabel = useEnvironmentStageLabel();
24 const updateState = useDesktopUpdateState();
25 const version = updateState?.uiVersion ?? updateState?.currentVersion ?? APP_VERSION;
26 const closeForNavigation = () => {
27 setOpen(false);
28 if (isMobile) setOpenMobile(false);
29 };
30
31 return (
32 <Popover open={open} onOpenChange={setOpen}>
33 <PopoverTrigger
34 render={
35 <Button variant="ghost" size="icon-sm" aria-label={`${APP_BASE_NAME} menu`}>
36 <KnifeMark className="size-4" />
37 </Button>
38 }
39 />
40 <PopoverPopup align="start" width="lg" padding="compact" keepMounted inert={!open}>
41 <div className="flex items-baseline justify-between gap-3 px-1.5 pt-1 pb-2">
42 <PopoverTitle>{APP_BASE_NAME}</PopoverTitle>
43 <span className="text-2xs text-muted-foreground">
44 {version} <span className="ml-1">{stageLabel}</span>
45 </span>
46 </div>
47 <AppMenuUpdates />
48 <AppMenuProviderUpdates onNavigate={closeForNavigation} />
49 <Separator className="my-2" />
50 <h3
51 id={settingsHeadingId}
52 className="flex min-h-7 items-center px-1.5 text-xs font-medium text-muted-foreground"
53 >
54 Settings
55 </h3>
56 <nav aria-labelledby={settingsHeadingId} className="grid grid-cols-2 gap-x-2">
57 {getSettingsNavigationItems(scopeSearch).map(({ to, label, icon: Icon }) => (
58 <Link
59 key={to}
60 to={to}
61 search={scopeSearch}
62 onClick={closeForNavigation}
63 aria-current={
64 pathname === to ||
65 pathname.startsWith(`${to}/`) ||
66 (to === "/settings/general" && pathname === "/settings/open-source-licenses")
67 ? "page"
68 : undefined
69 }
70 className="flex min-h-7 items-center gap-2 rounded-md px-1.5 py-1 text-xs text-foreground outline-none hover:bg-accent focus-visible:bg-accent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring aria-[current=page]:bg-accent"
71 >
72 <Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
73 {label}
74 </Link>
75 ))}
76 </nav>
77 </PopoverPopup>
78 </Popover>
79 );
80}
apps/web/src/components/sidebar/AppMenuProviderUpdates.test.tsx created+284
...@@ -0,0 +1,284 @@
1// @vitest-environment jsdom
2import { act } from "react";
3import { createRoot, type Root } from "react-dom/client";
4import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
5import {
6 createMemoryHistory,
7 createRootRoute,
8 createRoute,
9 createRouter,
10 RouterContextProvider,
11 type AnyRouter,
12} from "@tanstack/react-router";
13import * as Cause from "effect/Cause";
14import { AsyncResult } from "effect/reactivity";
15import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
16
17const state = vi.hoisted(() => ({
18 environments: [] as Array<{
19 environmentId: string;
20 label: string;
21 connection: { phase: "connected" | "offline" };
22 serverConfig: { providers: ServerProvider[] };
23 }>,
24 permitted: new Set<string>(),
25 updateProvider: vi.fn(),
26}));
27
28vi.mock("~/state/environments", () => ({ useEnvironments: () => state }));
29vi.mock("~/state/server", () => ({
30 serverEnvironment: { updateProvider: { permissionAtom: (id: string) => id } },
31}));
32vi.mock("@effect/atom-react", () => ({
33 useAtomValue: (id: string) => state.permitted.has(id),
34}));
35vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => state.updateProvider }));
36vi.mock("../chat/ProviderInstanceIcon", () => ({ ProviderInstanceIcon: () => null }));
37vi.mock("../ui/spinner", () => ({ Spinner: () => null }));
38vi.mock("../settings/providerDriverMeta", () => ({ providerClients: new Map() }));
39
40import { AppMenuProviderUpdates } from "./AppMenuProviderUpdates";
41
42let root: Root;
43let container: HTMLDivElement;
44let router: AnyRouter;
45
46function provider(overrides: Partial<ServerProvider> = {}): ServerProvider {
47 return {
48 driver: ProviderDriverKind.make("codex"),
49 instanceId: ProviderInstanceId.make("codex"),
50 enabled: true,
51 installed: true,
52 version: "1.0.0",
53 status: "ready",
54 auth: { status: "authenticated" },
55 checkedAt: "2026-10-15T12:00:00.000Z",
56 models: [],
57 slashCommands: [],
58 skills: [],
59 versionAdvisory: {
60 status: "behind_latest",
61 currentVersion: "1.0.0",
62 latestVersion: "1.1.0",
63 updateCommand: "npm install -g provider",
64 canUpdate: true,
65 checkedAt: "2026-10-15T12:00:00.000Z",
66 message: "Update available.",
67 },
68 ...overrides,
69 };
70}
71
72function connect(id: string, providers: ServerProvider[]) {
73 state.environments.push({
74 environmentId: id,
75 label: id,
76 connection: { phase: "connected" },
77 serverConfig: { providers },
78 });
79 state.permitted.add(id);
80}
81
82function button(name: string) {
83 const node = container.querySelector<HTMLButtonElement>(`button[aria-label="${name}"]`);
84 if (!node) throw new Error(`Missing button: ${name}`);
85 return node;
86}
87
88async function render() {
89 await act(() =>
90 root.render(
91 <RouterContextProvider router={router}>
92 <AppMenuProviderUpdates />
93 </RouterContextProvider>,
94 ),
95 );
96}
97
98beforeEach(async () => {
99 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
100 state.environments = [];
101 state.permitted.clear();
102 state.updateProvider.mockReset();
103 state.updateProvider.mockResolvedValue(AsyncResult.success({ providers: [] }));
104 container = document.createElement("div");
105 document.body.append(container);
106 root = createRoot(container);
107 const rootRoute = createRootRoute();
108 router = createRouter({
109 routeTree: rootRoute.addChildren([
110 createRoute({ getParentRoute: () => rootRoute, path: "/" }),
111 createRoute({ getParentRoute: () => rootRoute, path: "/settings/providers" }),
112 ]),
113 history: createMemoryHistory({ initialEntries: ["/"] }),
114 });
115 await router.load();
116});
117
118afterEach(async () => {
119 await act(() => root.unmount());
120 container.remove();
121 vi.unstubAllGlobals();
122});
123
124it("updates one representative per installation and routes each machine independently", async () => {
125 connect("laptop", [
126 provider({ instanceId: ProviderInstanceId.make("codex-personal") }),
127 provider(),
128 provider({ driver: ProviderDriverKind.make("claudeAgent"), enabled: false }),
129 provider({ driver: ProviderDriverKind.make("cursor"), installed: false }),
130 ]);
131 connect("desktop", [provider()]);
132 state.environments.push({
133 environmentId: "offline",
134 label: "offline",
135 connection: { phase: "offline" },
136 serverConfig: { providers: [provider()] },
137 });
138 await render();
139
140 await act(async () => button("Update Codex on laptop").click());
141 await act(async () => button("Update Codex on desktop").click());
142
143 expect(state.updateProvider.mock.calls.map(([target]) => target)).toEqual([
144 { environmentId: "laptop", input: { provider: "codex", instanceId: "codex" } },
145 { environmentId: "desktop", input: { provider: "codex", instanceId: "codex" } },
146 ]);
147 expect(container.querySelectorAll("button")).toHaveLength(2);
148});
149
150it("keeps an installation disabled until its direct update settles, including rapid clicks", async () => {
151 connect("laptop", [provider()]);
152 const run = Promise.withResolvers<unknown>();
153 state.updateProvider.mockReturnValue(run.promise);
154 await render();
155 const update = button("Update Codex");
156 await act(() => {
157 update.click();
158 update.click();
159 });
160 expect(state.updateProvider).toHaveBeenCalledOnce();
161 expect(button("Updating Codex").disabled).toBe(true);
162
163 await act(() => run.resolve(AsyncResult.success({ providers: [] })));
164 expect(button("Update Codex").disabled).toBe(false);
165});
166
167it("reports a transport failure inline, permits retry, and clears it once the installation is current", async () => {
168 const outdated = provider();
169 connect("laptop", [outdated]);
170 state.updateProvider.mockResolvedValueOnce(
171 AsyncResult.failure(Cause.fail(new Error("Host disconnected. Reconnect and retry."))),
172 );
173 await render();
174 await act(async () => button("Update Codex").click());
175 expect(container.querySelector('[role="alert"]')?.textContent).toBe(
176 "Host disconnected. Reconnect and retry.",
177 );
178
179 await act(async () => button("Retry Codex").click());
180 expect(state.updateProvider).toHaveBeenCalledTimes(2);
181 expect(container.querySelector('[role="alert"]')).toBeNull();
182
183 state.environments[0]!.serverConfig.providers = [
184 provider({
185 version: "1.1.0",
186 versionAdvisory: { ...outdated.versionAdvisory!, status: "current", currentVersion: "1.1.0" },
187 }),
188 ];
189 await render();
190 expect(container.querySelector('[role="img"][aria-label="Up to date"]')).not.toBeNull();
191 expect(container.querySelector("button")).toBeNull();
192});
193
194it("follows the guarded command permission and never dispatches through a disabled connection", async () => {
195 connect("laptop", [provider()]);
196 state.permitted.delete("laptop");
197 await render();
198 await act(async () => button("Update Codex").click());
199 expect(state.updateProvider).not.toHaveBeenCalled();
200 expect(container.textContent).toContain("This connection cannot update providers.");
201
202 state.permitted.add("laptop");
203 await render();
204 await act(async () => button("Update Codex").click());
205 expect(state.updateProvider).toHaveBeenCalledOnce();
206});
207
208it("requires provider settings when profiles disagree on their installer", async () => {
209 const first = provider();
210 connect("laptop", [
211 first,
212 provider({
213 instanceId: ProviderInstanceId.make("codex-personal"),
214 versionAdvisory: { ...first.versionAdvisory!, updateCommand: "brew upgrade provider" },
215 }),
216 ]);
217 await render();
218 expect(container.querySelector("button")).toBeNull();
219 const details = container.querySelector<HTMLAnchorElement>("a");
220 expect(details).not.toBeNull();
221 await act(async () => details!.click());
222 expect(router.state.location.pathname).toBe("/settings/providers");
223 expect(router.state.location.search).toEqual({ machine: "laptop" });
224 expect(state.updateProvider).not.toHaveBeenCalled();
225});
226
227it("blocks incompatible latest versions and explains the reason", async () => {
228 connect("laptop", [
229 provider({
230 compatibilityAdvisory: {
231 status: "supported",
232 latestVersionStatus: "broken",
233 message: "The latest CLI cannot start sessions.",
234 recommendedVersion: "1.0.0",
235 recommendedRange: null,
236 },
237 }),
238 ]);
239 await render();
240 expect(container.textContent).toContain("Update blocked: The latest CLI cannot start sessions.");
241 expect(container.querySelector("button")).toBeNull();
242 expect(container.querySelector("a")?.getAttribute("href")).toBe(
243 "/settings/providers?machine=laptop",
244 );
245 expect(state.updateProvider).not.toHaveBeenCalled();
246});
247
248it("honors a sibling profile's queued update and keeps failed server results retryable", async () => {
249 connect("laptop", [
250 provider(),
251 provider({
252 instanceId: ProviderInstanceId.make("codex-personal"),
253 updateState: {
254 status: "queued",
255 startedAt: "2026-10-15T12:01:00.000Z",
256 finishedAt: null,
257 message: null,
258 output: null,
259 },
260 }),
261 ]);
262 await render();
263 expect(button("Updating Codex").disabled).toBe(true);
264 expect(container.textContent).toContain("Queued");
265 expect(state.updateProvider).not.toHaveBeenCalled();
266
267 state.environments[0]!.serverConfig.providers = [
268 provider({
269 updateState: {
270 status: "failed",
271 startedAt: "2026-10-15T12:01:00.000Z",
272 finishedAt: "2026-10-15T12:02:00.000Z",
273 message: "Installer unavailable. Try again.",
274 output: null,
275 },
276 }),
277 ];
278 await render();
279 expect(container.querySelector('[role="alert"]')?.textContent).toBe(
280 "Installer unavailable. Try again.",
281 );
282 await act(async () => button("Retry Codex").click());
283 expect(state.updateProvider).toHaveBeenCalledOnce();
284});
apps/web/src/components/sidebar/AppMenuProviderUpdates.tsx created+226
...@@ -0,0 +1,226 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 isAtomCommandInterrupted,
4 squashAtomCommandFailure,
5} from "@t3tools/client-runtime/state/runtime";
6import {
7 PROVIDER_DISPLAY_NAMES,
8 type EnvironmentId,
9 type ServerProvider,
10} from "@t3tools/contracts";
11import { Link } from "@tanstack/react-router";
12import { CheckIcon } from "lucide-react";
13import { useRef, useState } from "react";
14
15import { useEnvironments } from "~/state/environments";
16import { serverEnvironment } from "~/state/server";
17import { useAtomCommand } from "~/state/use-atom-command";
18import {
19 canOneClickUpdateProviderCandidate,
20 collectProviderUpdateCandidates,
21 isProviderUpdateActive,
22} from "../ProviderUpdateLaunchNotification.logic";
23import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
24import { providerClients } from "../settings/providerDriverMeta";
25import { Button } from "../ui/button";
26import { Spinner } from "../ui/spinner";
27import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
28
29function ProviderInstallationRow({
30 environmentId,
31 environmentLabel,
32 providers,
33 onNavigate,
34}: {
35 readonly environmentId: EnvironmentId;
36 readonly environmentLabel: string | null;
37 readonly providers: ReadonlyArray<ServerProvider>;
38 readonly onNavigate: (() => void) | undefined;
39}) {
40 const allowed = useAtomValue(serverEnvironment.updateProvider.permissionAtom(environmentId));
41 const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { reportFailure: false });
42 const inFlight = useRef(false);
43 const [pending, setPending] = useState(false);
44 const [requestError, setRequestError] = useState<string | null>(null);
45 const candidate = collectProviderUpdateCandidates(providers)[0];
46 const provider =
47 candidate ??
48 providers.find((entry) => entry.versionAdvisory?.status === "behind_latest") ??
49 providers.toSorted((a, b) => b.checkedAt.localeCompare(a.checkedAt))[0];
50 if (!provider) return null;
51
52 const name =
53 providerClients.get(provider.driver)?.label ??
54 PROVIDER_DISPLAY_NAMES[provider.driver] ??
55 provider.driver;
56 const active = providers.find(isProviderUpdateActive);
57 const updating = pending || active !== undefined;
58 const canUpdate =
59 candidate !== undefined && canOneClickUpdateProviderCandidate(candidate, providers);
60 const compatibility = provider.compatibilityAdvisory;
61 const latestBlocked =
62 compatibility?.latestVersionStatus === "broken" ||
63 compatibility?.latestVersionStatus === "unsupported";
64 const latestUpdate = providers
65 .flatMap((entry) => (entry.updateState ? [entry.updateState] : []))
66 .toSorted((a, b) =>
67 (b.finishedAt ?? b.startedAt ?? "").localeCompare(a.finishedAt ?? a.startedAt ?? ""),
68 )[0];
69 const current = providers.every((entry) => entry.versionAdvisory?.status === "current");
70 const error =
71 !current && !updating
72 ? (requestError ??
73 (latestUpdate?.status === "failed"
74 ? (latestUpdate.message ?? "Provider update failed. Try again.")
75 : null))
76 : null;
77 const versions = [
78 ...new Set(providers.map((entry) => entry.version).filter((version) => version !== null)),
79 ];
80 const installedVersion = versions.length > 0 ? versions.join(" / ") : "Version unknown";
81 const latestVersion = provider.versionAdvisory?.latestVersion;
82 const versionLabel =
83 latestVersion && !versions.every((version) => version === latestVersion)
84 ? `${installedVersion} → ${latestVersion}`
85 : installedVersion;
86
87 const handleUpdate = async () => {
88 if (!candidate || !allowed || !canUpdate || inFlight.current) return;
89
90 inFlight.current = true;
91 setPending(true);
92 setRequestError(null);
93 try {
94 const result = await updateProvider({
95 environmentId,
96 input: { provider: candidate.driver, instanceId: candidate.instanceId },
97 });
98 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
99 const failure = squashAtomCommandFailure(result);
100 setRequestError(
101 failure instanceof Error ? failure.message : "Provider update failed. Try again.",
102 );
103 }
104 } catch (failure) {
105 setRequestError(
106 failure instanceof Error ? failure.message : "Provider update failed. Try again.",
107 );
108 } finally {
109 inFlight.current = false;
110 setPending(false);
111 }
112 };
113
114 return (
115 <div
116 className="flex items-start gap-2 px-1.5 py-0.5"
117 aria-label={environmentLabel ? `${name} on ${environmentLabel}` : name}
118 >
119 <div className="flex h-6 w-4 shrink-0 items-center justify-center" aria-hidden="true">
120 <ProviderInstanceIcon
121 driverKind={provider.driver}
122 displayName={name}
123 iconClassName="size-4"
124 />
125 </div>
126 <div className="min-w-0 flex-1">
127 <div className="flex min-h-6 items-center gap-2 text-xs">
128 <span className="truncate font-medium">{name}</span>
129 {environmentLabel ? (
130 <span className="truncate text-muted-foreground">{environmentLabel}</span>
131 ) : null}
132 <Tooltip>
133 <TooltipTrigger
134 render={
135 <span className="ml-auto truncate text-2xs text-muted-foreground tabular-nums" />
136 }
137 >
138 {versionLabel}
139 </TooltipTrigger>
140 <TooltipPopup>{versionLabel}</TooltipPopup>
141 </Tooltip>
142 </div>
143 {latestBlocked ? (
144 <p className="mt-0.5 break-words text-2xs text-warning">
145 {compatibility?.message
146 ? `Update blocked: ${compatibility.message}`
147 : "Latest version blocked"}
148 </p>
149 ) : null}
150 {error ? (
151 <p role="alert" className="mt-0.5 break-words text-xs text-destructive">
152 {error}
153 </p>
154 ) : null}
155 {candidate && canUpdate && !allowed ? (
156 <p className="mt-0.5 text-2xs text-muted-foreground">
157 This connection cannot update providers.
158 </p>
159 ) : null}
160 </div>
161 <div className="shrink-0" aria-live="polite">
162 {updating ? (
163 <Button size="xs" variant="ghost-muted" disabled aria-label={`Updating ${name}`}>
164 <Spinner size="xs" aria-hidden="true" />
165 {active?.updateState?.status === "queued" ? "Queued" : "Updating…"}
166 </Button>
167 ) : canUpdate ? (
168 <Button
169 size="xs"
170 variant="outline"
171 disabled={!allowed}
172 onClick={() => void handleUpdate()}
173 aria-label={`${error ? "Retry" : "Update"} ${name}${environmentLabel ? ` on ${environmentLabel}` : ""}`}
174 >
175 {error ? "Retry" : "Update"}
176 </Button>
177 ) : provider.versionAdvisory?.status === "behind_latest" ? (
178 <Button
179 size="xs"
180 variant="outline"
181 render={
182 <Link
183 to="/settings/providers"
184 search={{ machine: environmentId }}
185 onClick={onNavigate}
186 />
187 }
188 aria-label={`View ${name} update details`}
189 >
190 Details
191 </Button>
192 ) : current ? (
193 <span
194 className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground"
195 role="img"
196 aria-label="Up to date"
197 >
198 <CheckIcon className="size-3.5" aria-hidden="true" />
199 </span>
200 ) : null}
201 </div>
202 </div>
203 );
204}
205
206export function AppMenuProviderUpdates({ onNavigate }: { readonly onNavigate?: () => void }) {
207 const { environments } = useEnvironments();
208 const connected = environments.filter(
209 (environment) => environment.connection.phase === "connected",
210 );
211
212 return connected.flatMap((environment) => {
213 const providers = (environment.serverConfig?.providers ?? []).filter(
214 (provider) => provider.enabled && provider.installed,
215 );
216 return [...Map.groupBy(providers, (provider) => provider.driver)].map(([driver, profiles]) => (
217 <ProviderInstallationRow
218 key={`${environment.environmentId}:${driver}`}
219 environmentId={environment.environmentId}
220 environmentLabel={connected.length > 1 ? environment.label : null}
221 providers={profiles}
222 onNavigate={onNavigate}
223 />
224 ));
225 });
226}
apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx+2-32
...@@ -1,6 +1,5 @@...@@ -1,6 +1,5 @@
1import { RefreshIcon } from "~/components/ui/refresh-icon";1import { RefreshIcon } from "~/components/ui/refresh-icon";
2import { CheckIcon, CircleArrowUpIcon, DownloadIcon, RotateCwIcon } from "lucide-react";2import { CheckIcon, DownloadIcon, RotateCwIcon } from "lucide-react";
3import type { AnimationEventHandler } from "react";
43
5const DOWNLOAD_PROGRESS_RADIUS = 14;4const DOWNLOAD_PROGRESS_RADIUS = 14;
6const DOWNLOAD_PROGRESS_CIRCUMFERENCE = 2 * Math.PI * DOWNLOAD_PROGRESS_RADIUS;5const DOWNLOAD_PROGRESS_CIRCUMFERENCE = 2 * Math.PI * DOWNLOAD_PROGRESS_RADIUS;
...@@ -10,38 +9,13 @@ export type DesktopUpdateStatusIconState =...@@ -10,38 +9,13 @@ export type DesktopUpdateStatusIconState =
10 | "checking"9 | "checking"
11 | "available"10 | "available"
12 | "downloading"11 | "downloading"
13 | "downloaded"12 | "downloaded";
14 // Other machines or providers have updates. Matches the server update icon
15 // in Connections settings; the download arrow stays for this app's own file.
16 | "everything";
1713
18function normalizeDesktopUpdateDownloadPercent(percent: number | null): number {14function normalizeDesktopUpdateDownloadPercent(percent: number | null): number {
19 if (percent === null || !Number.isFinite(percent)) return 0;15 if (percent === null || !Number.isFinite(percent)) return 0;
20 return Math.min(100, Math.max(0, percent));16 return Math.min(100, Math.max(0, percent));
21}17}
2218
23export function shouldShowDesktopUpdateCheckIcon({
24 isAnimationLatched,
25 isChecking,
26 prefersReducedMotion,
27}: {
28 readonly isAnimationLatched: boolean;
29 readonly isChecking: boolean;
30 readonly prefersReducedMotion: boolean;
31}): boolean {
32 return isChecking || (isAnimationLatched && !prefersReducedMotion);
33}
34
35export function shouldContinueDesktopUpdateCheckAnimation({
36 isChecking,
37 prefersReducedMotion,
38}: {
39 readonly isChecking: boolean;
40 readonly prefersReducedMotion: boolean;
41}): boolean {
42 return isChecking && !prefersReducedMotion;
43}
44
45function DesktopUpdateAvailableIcon() {19function DesktopUpdateAvailableIcon() {
46 return (20 return (
47 <span className="relative grid size-4 place-items-center">21 <span className="relative grid size-4 place-items-center">
...@@ -105,12 +79,10 @@ function DesktopUpdateDownloadedIcon() {...@@ -105,12 +79,10 @@ function DesktopUpdateDownloadedIcon() {
105export function DesktopUpdateStatusIcon({79export function DesktopUpdateStatusIcon({
106 downloadPercent,80 downloadPercent,
107 isCheckAnimating,81 isCheckAnimating,
108 onCheckAnimationIteration,
109 status,82 status,
110}: {83}: {
111 readonly downloadPercent?: number | null;84 readonly downloadPercent?: number | null;
112 readonly isCheckAnimating?: boolean;85 readonly isCheckAnimating?: boolean;
113 readonly onCheckAnimationIteration?: AnimationEventHandler<SVGSVGElement>;
114 readonly status: DesktopUpdateStatusIconState;86 readonly status: DesktopUpdateStatusIconState;
115}) {87}) {
116 if (status === "available") return <DesktopUpdateAvailableIcon />;88 if (status === "available") return <DesktopUpdateAvailableIcon />;
...@@ -118,13 +90,11 @@ export function DesktopUpdateStatusIcon({...@@ -118,13 +90,11 @@ export function DesktopUpdateStatusIcon({
118 return <DesktopUpdateDownloadingIcon percent={downloadPercent ?? null} />;90 return <DesktopUpdateDownloadingIcon percent={downloadPercent ?? null} />;
119 }91 }
120 if (status === "downloaded") return <DesktopUpdateDownloadedIcon />;92 if (status === "downloaded") return <DesktopUpdateDownloadedIcon />;
121 if (status === "everything") return <CircleArrowUpIcon className="size-4" />;
12293
123 return (94 return (
124 <RefreshIcon95 <RefreshIcon
125 className="size-4"96 className="size-4"
126 refreshing={status === "checking" && isCheckAnimating === true}97 refreshing={status === "checking" && isCheckAnimating === true}
127 onAnimationIteration={onCheckAnimationIteration}
128 />98 />
129 );99 );
130}100}
apps/web/src/components/sidebar/SidebarChrome.tsx+14-106
...@@ -1,16 +1,13 @@...@@ -1,16 +1,13 @@
1import { ArrowLeftIcon, ChartNoAxesColumnIcon, SettingsIcon } from "lucide-react";1import { ArrowLeftIcon, ChartNoAxesColumnIcon } from "lucide-react";
2import type { ReactNode } from "react";2import type { ReactNode } from "react";
3import { memo, useCallback } from "react";3import { memo, useCallback } from "react";
4import { Link, useLocation, useNavigate } from "@tanstack/react-router";4import { useLocation, useNavigate } from "@tanstack/react-router";
55
6import { useEnvironmentIdentificationMode } from "../../hooks/useSettings";6import { useEnvironmentIdentificationMode } from "../../hooks/useSettings";
7import { cn } from "../../lib/utils";7import { cn } from "../../lib/utils";
8import { usePullRequestsSupported } from "../../state/environments";8import { usePullRequestsSupported } from "../../state/environments";
9import { T3Wordmark } from "../T3Wordmark";
10import {9import {
11 resolveEnvironmentIdentificationPillLabel,10 resolveEnvironmentIdentificationPillLabel,
12 resolveSidebarStageBackdropVariant,
13 SidebarStageBackdrop,
14 useEnvironmentStageLabel,11 useEnvironmentStageLabel,
15} from "../SidebarStageBackdrop";12} from "../SidebarStageBackdrop";
16import { Badge } from "../ui/badge";13import { Badge } from "../ui/badge";
...@@ -26,10 +23,8 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";...@@ -26,10 +23,8 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
26import { readPullRequestListPreferences } from "../pullRequest/pullRequestListPreferences";23import { readPullRequestListPreferences } from "../pullRequest/pullRequestListPreferences";
27import { isSidebarUtilityPage, useNavigateToMainApp } from "./mainAppLocation";24import { isSidebarUtilityPage, useNavigateToMainApp } from "./mainAppLocation";
28import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice";25import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice";
29import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill";26import { AppMenu } from "./AppMenu";
30import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill";
31import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";27import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
32import { observeResize } from "~/lib/observeResize";
3328
34export const SidebarChromeHeader = memo(function SidebarChromeHeader({29export const SidebarChromeHeader = memo(function SidebarChromeHeader({
35 isElectron,30 isElectron,
...@@ -38,10 +33,6 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({...@@ -38,10 +33,6 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({
38}) {33}) {
39 const stageLabel = useEnvironmentStageLabel();34 const stageLabel = useEnvironmentStageLabel();
40 const environmentIdentificationMode = useEnvironmentIdentificationMode();35 const environmentIdentificationMode = useEnvironmentIdentificationMode();
41 const backdropVariant = resolveSidebarStageBackdropVariant(
42 stageLabel,
43 environmentIdentificationMode === "artwork",
44 );
45 const pillLabel =36 const pillLabel =
46 environmentIdentificationMode === "pill"37 environmentIdentificationMode === "pill"
47 ? resolveEnvironmentIdentificationPillLabel(stageLabel)38 ? resolveEnvironmentIdentificationPillLabel(stageLabel)
...@@ -55,94 +46,24 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({...@@ -55,94 +46,24 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({
55 isElectron && "drag-region",46 isElectron && "drag-region",
56 )}47 )}
57 >48 >
58 {backdropVariant ? <SidebarStageBackdrop variant={backdropVariant} /> : null}
59 {/* Match the fixed control's inset and border compensation inside the sheet's safe area. */}49 {/* Match the fixed control's inset and border compensation inside the sheet's safe area. */}
60 <div className="relative top-[calc(var(--workspace-controls-top)-env(safe-area-inset-top))] z-10 ml-[calc(var(--workspace-controls-left)+1px-env(safe-area-inset-left))] flex shrink-0 items-center md:hidden">50 <div className="relative top-[calc(var(--workspace-controls-top)-env(safe-area-inset-top))] z-10 ml-[calc(var(--workspace-controls-left)+1px-env(safe-area-inset-left))] flex shrink-0 items-center md:hidden">
61 <SidebarTrigger51 <div className="flex items-center [-webkit-app-region:no-drag]">
62 // Over the stage artwork: the media viewer's control-on-imagery treatment.52 <AppMenu />
63 variant={backdropVariant ? "media-navigation" : "ghost"}53 </div>
64 className="relative top-auto translate-y-0"54 <SidebarTrigger aria-label="Toggle main sidebar" />
65 aria-label="Toggle main sidebar"
66 />
67 </div>
68 {/* One visible line: the pill wraps onto the clipped second line once it no longer fits.
69 The padding keeps the brand's focus ring inside the clip. */}
70 <div className="relative z-10 flex h-8 min-w-0 flex-1 flex-wrap content-start items-center gap-x-2 overflow-hidden py-0.5">
71 <SidebarBrand onBackdrop={backdropVariant !== null} />
72 {pillLabel ? (
73 <div className="ml-1 flex h-7 items-center">
74 <Badge data-environment-identification="pill" size="sm" variant="secondary">
75 {pillLabel}
76 </Badge>
77 </div>
78 ) : null}
79 </div>55 </div>
56 {pillLabel ? (
57 <div className="relative z-10 ml-[var(--workspace-titlebar-content-left)] flex h-7 min-w-0 items-center">
58 <Badge data-environment-identification="pill" size="sm" variant="secondary">
59 {pillLabel}
60 </Badge>
61 </div>
62 ) : null}
80 </div>63 </div>
81 );64 );
82});65});
8366
84// Measures the brand at its titlebar inset, plus the header's right padding and the
85// sidebar border, so the sidebar minimum follows font size, zoom and macOS window controls.
86export function SidebarBrandWidthProbe({
87 onWidthChange,
88}: {
89 onWidthChange: (width: number) => void;
90}) {
91 const observeWidth = useCallback(
92 (probe: HTMLDivElement) => {
93 return observeResize(probe, ([entry]) => {
94 if (entry) onWidthChange(entry.borderBoxSize[0]?.inlineSize ?? probe.offsetWidth);
95 });
96 },
97 [onWidthChange],
98 );
99
100 return (
101 <div
102 aria-hidden="true"
103 className="pointer-events-none invisible fixed top-0 left-0 flex w-max border-r border-transparent pr-3"
104 ref={observeWidth}
105 >
106 <div className="ml-[var(--workspace-titlebar-content-left)] flex">
107 <SidebarBrandMark onBackdrop={false} />
108 </div>
109 </div>
110 );
111}
112
113function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) {
114 return (
115 <Link
116 aria-label="Go to threads"
117 className={cn(
118 "relative z-10 ml-[var(--workspace-titlebar-content-left)] hidden h-7 w-fit min-w-0 shrink-0 items-center overflow-hidden rounded-md outline-hidden ring-ring focus-visible:ring-2 focus-visible:ring-inset md:flex",
119 onBackdrop ? "text-white" : "text-foreground",
120 )}
121 to="/"
122 >
123 <SidebarBrandMark onBackdrop={onBackdrop} />
124 </Link>
125 );
126}
127
128function SidebarBrandMark({ onBackdrop }: { onBackdrop: boolean }) {
129 return (
130 // Center the visible capitals, without the font's ascender/descender space.
131 // Padding keeps ascenders and round-letter overshoot inside the truncation clip.
132 <span className="inline-flex min-w-0 items-baseline gap-1 text-sm font-medium tracking-tight">
133 <T3Wordmark aria-label="T3" className="h-[1cap] w-auto shrink-0" />
134 <span
135 className={cn(
136 "truncate [text-box:trim-both_cap_alphabetic] supports-[text-box:trim-both_cap_alphabetic]:py-1",
137 onBackdrop ? "text-white/70" : "text-muted-foreground",
138 )}
139 >
140 Code
141 </span>
142 </span>
143 );
144}
145
146function SidebarUtilityItem({67function SidebarUtilityItem({
147 icon,68 icon,
148 label,69 label,
...@@ -188,11 +109,6 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {...@@ -188,11 +109,6 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {
188 search: readPullRequestListPreferences(),109 search: readPullRequestListPreferences(),
189 });110 });
190 }, [closeMobileSidebar, navigate]);111 }, [closeMobileSidebar, navigate]);
191 const handleSettingsClick = useCallback(() => {
192 closeMobileSidebar();
193 void navigate({ to: "/settings" });
194 }, [closeMobileSidebar, navigate]);
195
196 const handleUsageClick = useCallback(() => {112 const handleUsageClick = useCallback(() => {
197 if (isMobile) {113 if (isMobile) {
198 setOpenMobile(false);114 setOpenMobile(false);
...@@ -216,11 +132,6 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {...@@ -216,11 +132,6 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {
216 </SidebarMenuItem>132 </SidebarMenuItem>
217 ) : (133 ) : (
218 <>134 <>
219 <SidebarUtilityItem
220 icon={<SettingsIcon />}
221 label="Settings"
222 onClick={handleSettingsClick}
223 />
224 {pullRequestsSupported ? (135 {pullRequestsSupported ? (
225 <SidebarUtilityItem136 <SidebarUtilityItem
226 icon={<PullRequestGlyph.pullRequest />}137 icon={<PullRequestGlyph.pullRequest />}
...@@ -235,7 +146,6 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {...@@ -235,7 +146,6 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {
235 />146 />
236 </>147 </>
237 )}148 )}
238 <SidebarUpdatePill />
239 </SidebarMenu>149 </SidebarMenu>
240 );150 );
241});151});
...@@ -244,8 +154,6 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() {...@@ -244,8 +154,6 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() {
244 return (154 return (
245 <SidebarFooter>155 <SidebarFooter>
246 <SidebarThreadUndoNotice />156 <SidebarThreadUndoNotice />
247 <SidebarProviderUpdatePill />
248 <SidebarUpdateArchitectureWarning />
249 <SidebarUtilityMenu />157 <SidebarUtilityMenu />
250 </SidebarFooter>158 </SidebarFooter>
251 );159 );
apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx deleted-210
...@@ -1,210 +0,0 @@
1import { Spinner } from "~/components/ui/spinner";
2import { useNavigate } from "@tanstack/react-router";
3import { useAtomValue } from "@effect/atom-react";
4import type { ServerProvider } from "@t3tools/contracts";
5import { CircleCheckIcon, DownloadIcon, TriangleAlertIcon, XIcon } from "lucide-react";
6import { useCallback, useEffect, useState, type CSSProperties } from "react";
7
8import { primaryServerProvidersAtom } from "../../state/server";
9import {
10 getProviderUpdateSidebarPillView,
11 type ProviderUpdateSidebarPillView,
12} from "../ProviderUpdateLaunchNotification.logic";
13import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
14
15const PROVIDER_UPDATE_PILL_STYLES = {
16 loading:
17 "bg-sidebar-control-surface text-sidebar-foreground group-has-[button[data-provider-update-main]:hover]/provider-update:bg-sidebar-row-hover",
18 success:
19 "bg-sidebar-control-surface text-sidebar-foreground group-has-[button[data-provider-update-main]:hover]/provider-update:bg-sidebar-row-hover",
20 warning:
21 "bg-warning/12 text-warning group-has-[button[data-provider-update-main]:hover]/provider-update:bg-warning/18",
22 error:
23 "bg-destructive/12 text-destructive group-has-[button[data-provider-update-main]:hover]/provider-update:bg-destructive/18",
24} as const;
25
26const PROVIDER_UPDATE_PILL_PROGRESS_STYLES = {
27 success: "bg-foreground/8",
28 warning: "bg-warning/14",
29 error: "bg-destructive/14",
30} as const;
31
32function latestProviderCheckedAt(
33 providers: ReadonlyArray<Pick<ServerProvider, "checkedAt">>,
34): string | undefined {
35 return providers.reduce<string | undefined>(
36 (latest, provider) =>
37 latest === undefined || provider.checkedAt > latest ? provider.checkedAt : latest,
38 undefined,
39 );
40}
41
42export function SidebarProviderUpdatePill() {
43 const navigate = useNavigate();
44 const providers = useAtomValue(primaryServerProvidersAtom);
45 const [dismissedKeys, setDismissedKeys] = useState<ReadonlySet<string>>(() => new Set());
46 const [renderedView, setRenderedView] = useState<ProviderUpdateSidebarPillView | null>(null);
47 const [pendingView, setPendingView] = useState<ProviderUpdateSidebarPillView | null>(null);
48 const [exitingKey, setExitingKey] = useState<string | null>(null);
49 const [dismissAfterExitKey, setDismissAfterExitKey] = useState<string | null>(null);
50 const [visibleAfterIso, setVisibleAfterIso] = useState<string | undefined>();
51 const effectiveVisibleAfterIso = visibleAfterIso ?? latestProviderCheckedAt(providers);
52 const view = getProviderUpdateSidebarPillView(providers, {
53 ...(effectiveVisibleAfterIso !== undefined
54 ? { visibleAfterIso: effectiveVisibleAfterIso }
55 : {}),
56 dismissedKeys,
57 });
58
59 useEffect(() => {
60 if (visibleAfterIso === undefined && effectiveVisibleAfterIso !== undefined) {
61 setVisibleAfterIso(effectiveVisibleAfterIso);
62 }
63 }, [effectiveVisibleAfterIso, visibleAfterIso]);
64
65 const openProviderSettings = useCallback(() => {
66 void navigate({ to: "/settings/providers" });
67 }, [navigate]);
68 const displayedView = renderedView ?? view;
69 const dismissAfterVisibleMs = displayedView?.dismissAfterVisibleMs;
70 const viewKey = displayedView?.key ?? null;
71 const showDismissProgress =
72 dismissAfterVisibleMs !== undefined &&
73 displayedView?.tone !== "loading" &&
74 exitingKey !== viewKey;
75
76 const startExit = useCallback(
77 (key: string, nextView: ProviderUpdateSidebarPillView | null, dismissKey?: string) => {
78 if (exitingKey === key) {
79 return;
80 }
81 setPendingView(nextView);
82 setExitingKey(key);
83 setDismissAfterExitKey(dismissKey ?? null);
84 },
85 [exitingKey],
86 );
87
88 useEffect(() => {
89 if (exitingKey !== null) {
90 return;
91 }
92 if (!renderedView) {
93 if (view) {
94 setRenderedView(view);
95 }
96 return;
97 }
98 if (!view) {
99 startExit(renderedView.key, null);
100 return;
101 }
102 if (view.key !== renderedView.key) {
103 startExit(renderedView.key, view);
104 return;
105 }
106 }, [exitingKey, renderedView, startExit, view]);
107
108 useEffect(() => {
109 if (!dismissAfterVisibleMs || !viewKey) {
110 return;
111 }
112 if (exitingKey === viewKey) {
113 return;
114 }
115 const timeoutId = window.setTimeout(() => {
116 startExit(viewKey, null, viewKey);
117 }, dismissAfterVisibleMs);
118
119 return () => window.clearTimeout(timeoutId);
120 }, [dismissAfterVisibleMs, exitingKey, startExit, viewKey]);
121
122 if (!displayedView) {
123 return null;
124 }
125
126 return (
127 <div
128 className={`group/provider-update relative flex min-h-7 w-full shrink-0 items-center overflow-hidden rounded-lg text-2xs leading-4 font-medium transform-gpu transition-all duration-180 ease-drawer will-change-transform ${
129 PROVIDER_UPDATE_PILL_STYLES[displayedView.tone]
130 } ${
131 exitingKey === displayedView.key
132 ? "pointer-events-none translate-y-1.5 opacity-0"
133 : "translate-y-0 opacity-100"
134 }`}
135 onTransitionEnd={(event) => {
136 if (event.target !== event.currentTarget) {
137 return;
138 }
139 if (!displayedView || exitingKey !== displayedView.key) {
140 return;
141 }
142 if (dismissAfterExitKey === displayedView.key) {
143 setDismissedKeys((previous) => new Set(previous).add(displayedView.key));
144 }
145 setRenderedView(pendingView);
146 setPendingView(null);
147 setExitingKey(null);
148 setDismissAfterExitKey(null);
149 }}
150 >
151 {showDismissProgress ? (
152 <div
153 key={displayedView.key}
154 aria-hidden="true"
155 className={`pointer-events-none absolute inset-y-0 left-0 w-full origin-left scale-x-0 transition-transform duration-(--provider-update-pill-dismiss-ms) ease-linear starting:scale-x-100 border-r border-current/15 inset-shadow-2xs inset-shadow-white/8 ${
156 PROVIDER_UPDATE_PILL_PROGRESS_STYLES[displayedView.tone]
157 }`}
158 style={
159 {
160 "--provider-update-pill-dismiss-ms": `${dismissAfterVisibleMs}ms`,
161 } as CSSProperties
162 }
163 />
164 ) : null}
165 <div className="pointer-events-none absolute inset-0 rounded-lg transition-colors" />
166 <Tooltip>
167 <TooltipTrigger
168 render={
169 <button
170 type="button"
171 aria-label={displayedView.description}
172 data-provider-update-main
173 className="relative z-[1] flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left"
174 onClick={openProviderSettings}
175 >
176 {displayedView.tone === "loading" ? (
177 <Spinner size="sm" className="shrink-0" />
178 ) : displayedView.tone === "success" ? (
179 <CircleCheckIcon className="size-3.5 shrink-0" />
180 ) : displayedView.tone === "error" ? (
181 <TriangleAlertIcon className="size-3.5 shrink-0" />
182 ) : (
183 <DownloadIcon className="size-3.5 shrink-0" />
184 )}
185 <span className="min-w-0 wrap-break-word">{displayedView.title}</span>
186 </button>
187 }
188 />
189 <TooltipPopup side="top">{displayedView.description}</TooltipPopup>
190 </Tooltip>
191 {displayedView.dismissible && (
192 <Tooltip>
193 <TooltipTrigger
194 render={
195 <button
196 type="button"
197 aria-label="Dismiss provider update notice"
198 className="relative z-[1] mr-1 flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-md opacity-70 outline-none hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
199 onClick={() => startExit(displayedView.key, null, displayedView.key)}
200 >
201 <XIcon className="size-3.5 shrink-0" />
202 </button>
203 }
204 />
205 <TooltipPopup side="top">Dismiss until provider status changes</TooltipPopup>
206 </Tooltip>
207 )}
208 </div>
209 );
210}
apps/web/src/components/sidebar/SidebarUpdatePill.test.tsx+200-56
...@@ -1,23 +1,56 @@...@@ -1,23 +1,56 @@
1// @vitest-environment jsdom
1import type { DesktopUpdateState } from "@t3tools/contracts";2import type { DesktopUpdateState } from "@t3tools/contracts";
2import { describe, expect, it, vi } from "vite-plus/test";3import { act } from "react";
4import { createRoot, type Root } from "react-dom/client";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
36
4import {7const test = vi.hoisted(() => ({
5 handleSidebarUpdateReleaseNotesPopoverOpenChange,8 state: null as DesktopUpdateState | null,
6 openSidebarUpdateReleaseNotesPopoverOnForwardTab,9 run: vi.fn().mockResolvedValue(undefined),
7 shouldUseSidebarUpdateReleaseNotesPopover,10 hasUpdates: true,
8} from "./SidebarUpdatePill";11 restarts: [] as Array<{ environmentId: string; continuesThreads: boolean }>,
12 toast: vi.fn(),
13}));
914
10const nightlyState: DesktopUpdateState = {15vi.mock("../../env", () => ({ isElectron: true }));
16vi.mock("../../hooks/useMediaQuery", () => ({ useMediaQuery: () => true }));
17vi.mock("../../state/desktopUpdate", () => ({ useDesktopUpdateState: () => test.state }));
18vi.mock("../../state/server", () => ({ serverEnvironment: {} }));
19vi.mock("~/state/threads", () => ({ environmentThreadShells: {} }));
20vi.mock("@effect/atom-react", () => ({ useAtomValue: () => 0 }));
21vi.mock("./useUpdateEverything", () => ({
22 useUpdateEverything: () => ({
23 hasUpdates: test.hasUpdates,
24 servers: [],
25 summary: "Update providers",
26 lines: ["Home: Codex"],
27 skipped: [],
28 restarts: test.restarts,
29 run: test.run,
30 }),
31}));
32vi.mock("./updateEverythingRun", () => ({ useUpdateEverythingRun: () => null }));
33vi.mock("../ui/toast", () => ({
34 toastManager: { add: test.toast },
35 stackedThreadToast: (toast: unknown) => toast,
36}));
37vi.mock("../desktopUpdate.toast", () => ({ showDesktopUpdateDownloadedToast: vi.fn() }));
38vi.mock("../../localApi", () => ({ ensureLocalApi: vi.fn() }));
39
40import { ensureLocalApi } from "../../localApi";
41import { AppMenuUpdates } from "./SidebarUpdatePill";
42
43const baseState: DesktopUpdateState = {
11 enabled: true,44 enabled: true,
12 status: "available",45 status: "idle",
13 channel: "nightly",46 channel: "latest",
14 currentVersion: "0.0.35",47 currentVersion: "1.0.0",
15 hostArch: "arm64",48 hostArch: "arm64",
16 appArch: "arm64",49 appArch: "arm64",
17 runningUnderArm64Translation: false,50 runningUnderArm64Translation: false,
18 availableVersion: "0.0.36-nightly.3",51 availableVersion: null,
19 downloadedVersion: null,52 downloadedVersion: null,
20 releaseNotes: [{ version: "0.0.36-nightly.3", items: ["Newest change"], totalItems: 1 }],53 releaseNotes: [],
21 omittedReleaseCount: 0,54 omittedReleaseCount: 0,
22 downloadPercent: null,55 downloadPercent: null,
23 checkedAt: null,56 checkedAt: null,
...@@ -26,61 +59,172 @@ const nightlyState: DesktopUpdateState = {...@@ -26,61 +59,172 @@ const nightlyState: DesktopUpdateState = {
26 canRetry: false,59 canRetry: false,
27};60};
2861
29describe("sidebar update release notes popover", () => {62let root: Root;
30 it("uses the popover only for visible nightly release notes", () => {63let container: HTMLDivElement;
31 expect(shouldUseSidebarUpdateReleaseNotesPopover(true, nightlyState)).toBe(true);64
32 expect(shouldUseSidebarUpdateReleaseNotesPopover(false, nightlyState)).toBe(false);65async function click(label: string) {
33 expect(66 const button = Array.from(document.querySelectorAll("button")).find(
34 shouldUseSidebarUpdateReleaseNotesPopover(true, {67 (element) => element.textContent?.trim() === label,
35 ...nightlyState,68 );
36 channel: "latest",69 expect(button, `Button ${label}`).toBeDefined();
37 }),70 await act(async () => button?.click());
38 ).toBe(false);71}
39 expect(72
40 shouldUseSidebarUpdateReleaseNotesPopover(true, {73beforeEach(() => {
41 ...nightlyState,74 test.state = baseState;
42 releaseNotes: [],75 test.hasUpdates = true;
43 }),76 test.restarts = [];
44 ).toBe(false);77 test.run.mockReset().mockResolvedValue(undefined);
78 test.toast.mockReset();
79 vi.mocked(ensureLocalApi).mockReset();
80 container = document.createElement("div");
81 document.body.append(container);
82 root = createRoot(container);
83 Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
84});
85
86afterEach(async () => {
87 await act(async () => root.unmount());
88 container.remove();
89 Reflect.deleteProperty(window, "desktopBridge");
90 vi.unstubAllGlobals();
91});
92
93describe("app menu update execution", () => {
94 it("updates providers directly when no app or server will restart", async () => {
95 await act(async () => root.render(<AppMenuUpdates />));
96 await click("Update all");
97
98 expect(test.run).toHaveBeenCalledExactlyOnceWith({});
99 expect(document.querySelector('[role="alertdialog"]')).toBeNull();
45 });100 });
46101
47 it("cancels trigger presses without canceling other open reasons", () => {102 it("waits for confirmation when a remote server will restart", async () => {
48 const cancelTriggerPress = vi.fn();103 test.restarts = [{ environmentId: "home", continuesThreads: false }];
49 const cancelHover = vi.fn();104 await act(async () => root.render(<AppMenuUpdates />));
105 await click("Update all");
50106
51 handleSidebarUpdateReleaseNotesPopoverOpenChange(true, {107 expect(test.run).not.toHaveBeenCalled();
52 reason: "trigger-press",108 expect(document.querySelector('[role="alertdialog"]')).not.toBeNull();
53 cancel: cancelTriggerPress,109 await click("Update");
54 });110 expect(test.run).toHaveBeenCalledOnce();
55 handleSidebarUpdateReleaseNotesPopoverOpenChange(true, {111 });
56 reason: "trigger-hover",
57 cancel: cancelHover,
58 });
59112
60 expect(cancelTriggerPress).toHaveBeenCalledOnce();113 it("keeps an app update behind restart confirmation even without connected servers", async () => {
61 expect(cancelHover).not.toHaveBeenCalled();114 test.state = { ...baseState, status: "available", availableVersion: "1.1.0" };
115 test.hasUpdates = false;
116 await act(async () => root.render(<AppMenuUpdates />));
117 await click("Update all");
118
119 expect(test.run).not.toHaveBeenCalled();
120 expect(document.querySelector('[role="alertdialog"]')).not.toBeNull();
121 await click("Cancel");
122 expect(test.run).not.toHaveBeenCalled();
62 });123 });
63124
64 it("promotes forward Tab without preventing native navigation", () => {125 it("reports a failed update run and allows retry", async () => {
65 const open = vi.fn();126 test.run.mockRejectedValueOnce(new Error("Disconnected"));
66 const preventDefault = vi.fn();127 await act(async () => root.render(<AppMenuUpdates />));
67 const event = { key: "Tab", shiftKey: false, preventDefault };128 await click("Update all");
68129
69 openSidebarUpdateReleaseNotesPopoverOnForwardTab(event, { open }, "nightly-release-notes");130 expect(test.toast).toHaveBeenCalledWith({
131 type: "error",
132 title: "Could not update everything",
133 description: "Disconnected",
134 });
135 await click("Update all");
136 expect(test.run).toHaveBeenCalledTimes(2);
137 });
138 it("retries a failed app download without restarting", async () => {
139 test.state = {
140 ...baseState,
141 status: "error",
142 errorContext: "download",
143 availableVersion: "1.1.0",
144 message: "Offline",
145 canRetry: true,
146 };
147 const downloadUpdate = vi.fn().mockResolvedValue({
148 accepted: true,
149 completed: true,
150 state: { ...test.state, status: "downloaded", downloadedVersion: "1.1.0" },
151 });
152 const installUpdate = vi.fn();
153 Object.defineProperty(window, "desktopBridge", {
154 configurable: true,
155 value: { downloadUpdate, installUpdate },
156 });
157 await act(async () => root.render(<AppMenuUpdates />));
158 await click("Retry download");
70159
71 expect(open).toHaveBeenCalledWith("nightly-release-notes");160 expect(downloadUpdate).toHaveBeenCalledOnce();
72 expect(preventDefault).not.toHaveBeenCalled();161 expect(installUpdate).not.toHaveBeenCalled();
73 });162 });
74163
75 it("does not promote backward Tab", () => {164 it("reloads a compatible UI update without asking to interrupt work", async () => {
76 const open = vi.fn();165 test.state = {
166 ...baseState,
167 status: "downloaded",
168 downloadedVersion: "1.1.0",
169 installKind: "ui",
170 };
171 const installUpdate = vi
172 .fn()
173 .mockResolvedValue({ accepted: true, completed: true, state: { ...baseState } });
174 Object.defineProperty(window, "desktopBridge", {
175 configurable: true,
176 value: { installUpdate },
177 });
178 await act(async () => root.render(<AppMenuUpdates />));
179 await click("Reload");
180 expect(installUpdate).toHaveBeenCalledOnce();
181 expect(ensureLocalApi).not.toHaveBeenCalled();
182 });
77183
78 openSidebarUpdateReleaseNotesPopoverOnForwardTab(184 it.each([false, true])(
79 { key: "Tab", shiftKey: true },185 "requires confirmation before a full-app restart (confirm=%s)",
80 { open },186 async (accepted) => {
81 "nightly-release-notes",187 test.state = {
82 );188 ...baseState,
189 status: "downloaded",
190 downloadedVersion: "1.1.0",
191 installKind: "app",
192 };
193 const confirm = vi.fn().mockResolvedValue(accepted);
194 const { createLocalApi } =
195 await vi.importActual<typeof import("../../localApi")>("../../localApi");
196 const api = createLocalApi();
197 vi.mocked(ensureLocalApi).mockReturnValue({ ...api, dialogs: { ...api.dialogs, confirm } });
198 const installUpdate = vi
199 .fn()
200 .mockResolvedValue({ accepted: true, completed: false, state: test.state });
201 Object.defineProperty(window, "desktopBridge", {
202 configurable: true,
203 value: { installUpdate },
204 });
205 await act(async () => root.render(<AppMenuUpdates />));
206 await click("Restart");
207 expect(confirm).toHaveBeenCalledOnce();
208 expect(installUpdate).toHaveBeenCalledTimes(accepted ? 1 : 0);
209 },
210 );
83211
84 expect(open).not.toHaveBeenCalled();212 it("reports an unavailable app update check", async () => {
213 const checkForUpdate = vi.fn().mockResolvedValue({
214 checked: false,
215 state: { ...baseState, message: "Updates disabled" },
216 });
217 Object.defineProperty(window, "desktopBridge", {
218 configurable: true,
219 value: { checkForUpdate },
220 });
221 await act(async () => root.render(<AppMenuUpdates />));
222 await click("Check");
223
224 expect(test.toast).toHaveBeenCalledWith({
225 type: "error",
226 title: "Could not check for updates",
227 description: "Updates disabled",
228 });
85 });229 });
86});230});
apps/web/src/components/sidebar/SidebarUpdatePill.tsx+171-469
...@@ -1,47 +1,30 @@...@@ -1,47 +1,30 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts";2import type { DesktopUpdateState } from "@t3tools/contracts";
3import { Atom } from "effect/reactivity";3import { Atom } from "effect/reactivity";
4import { TriangleAlertIcon } from "lucide-react";4import { TriangleAlertIcon } from "lucide-react";
5import {5import { useMemo, useRef, useState } from "react";
6 type ComponentProps,6
7 useCallback,
8 useEffect,
9 useId,
10 useMemo,
11 useRef,
12 useState,
13} from "react";
14import { flushSync } from "react-dom";
15import { isElectron } from "../../env";7import { isElectron } from "../../env";
16import { useMediaQuery } from "../../hooks/useMediaQuery";8import { useMediaQuery } from "../../hooks/useMediaQuery";
17import { cn } from "../../lib/utils";
18import { ensureLocalApi } from "../../localApi";
19import { useDesktopUpdateState } from "../../state/desktopUpdate";9import { useDesktopUpdateState } from "../../state/desktopUpdate";
20import { serverEnvironment } from "../../state/server";10import { serverEnvironment } from "../../state/server";
21import { stackedThreadToast, toastManager } from "../ui/toast";11import { Button } from "../ui/button";
22import {12import {
23 canCheckForUpdate,13 canCheckForUpdate,
24 getArm64IntelBuildWarningDescription,14 getArm64IntelBuildWarningDescription,
25 getDesktopUpdateActionError,
26 getDesktopUpdateButtonTooltip,15 getDesktopUpdateButtonTooltip,
27 getDesktopUpdateDownloadedVersion,16 getDesktopUpdateDownloadedVersion,
28 getDesktopUpdateInstallConfirmationMessage,
29 isDesktopUpdateButtonDisabled,17 isDesktopUpdateButtonDisabled,
30 resolveDesktopUpdateButtonAction,18 resolveDesktopUpdateButtonAction,
31 shouldShowArm64IntelBuildWarning,19 shouldShowArm64IntelBuildWarning,
32 shouldToastDesktopUpdateActionResult,
33} from "../desktopUpdate.logic";20} from "../desktopUpdate.logic";
34import { showDesktopUpdateDownloadedToast } from "../desktopUpdate.toast";
35import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
36import { Popover, PopoverCreateHandle, PopoverPopup, PopoverTrigger } from "../ui/popover";
37import { SidebarMenuItem } from "../ui/sidebar";
38import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
39import {21import {
40 DesktopUpdateStatusIcon,22 downloadLocalUpdate,
41 type DesktopUpdateStatusIconState,23 installLocalUpdate,
42 shouldContinueDesktopUpdateCheckAnimation,24 performDesktopUpdate,
43 shouldShowDesktopUpdateCheckIcon,25 toastUpdateError,
44} from "./DesktopUpdateStatusIcon";26} from "../desktopUpdate.actions";
27import { DesktopUpdateStatusIcon } from "./DesktopUpdateStatusIcon";
45import { SidebarUpdateReleaseNotes } from "./SidebarUpdateReleaseNotes";28import { SidebarUpdateReleaseNotes } from "./SidebarUpdateReleaseNotes";
46import { UpdateEverythingDialog } from "./UpdateEverythingDialog";29import { UpdateEverythingDialog } from "./UpdateEverythingDialog";
47import {30import {
...@@ -50,163 +33,11 @@ import {...@@ -50,163 +33,11 @@ import {
50 type UpdateEverythingRun,33 type UpdateEverythingRun,
51} from "./updateEverything.logic";34} from "./updateEverything.logic";
52import { useUpdateEverythingRun } from "./updateEverythingRun";35import { useUpdateEverythingRun } from "./updateEverythingRun";
53import { type ReportLocalUpdateError, useUpdateEverything } from "./useUpdateEverything";36import { useUpdateEverything } from "./useUpdateEverything";
54
55type SidebarUpdatePopoverChangeDetails = Parameters<
56 NonNullable<ComponentProps<typeof Popover>["onOpenChange"]>
57>[1];
58type SidebarUpdatePopoverHandle = ReturnType<typeof PopoverCreateHandle>;
59
60export function shouldUseSidebarUpdateReleaseNotesPopover(
61 showUpdateDetails: boolean,
62 state: DesktopUpdateState | null,
63): boolean {
64 return showUpdateDetails && state?.channel === "nightly" && state.releaseNotes.length > 0;
65}
66
67export function handleSidebarUpdateReleaseNotesPopoverOpenChange(
68 _open: boolean,
69 details: Pick<SidebarUpdatePopoverChangeDetails, "reason" | "cancel">,
70): void {
71 // The trigger is the update action, so its presses must not also toggle the Popover.
72 if (details.reason === "trigger-press") details.cancel();
73}
74
75export function openSidebarUpdateReleaseNotesPopoverOnForwardTab(
76 event: { readonly key: string; readonly shiftKey: boolean },
77 handle: Pick<SidebarUpdatePopoverHandle, "open">,
78 triggerId: string,
79): void {
80 if (event.key !== "Tab" || event.shiftKey) return;
81 // Hover-open popovers do not manage focus. Promote this one before native Tab runs.
82 flushSync(() => handle.open(triggerId));
83}
84
85function resolveSidebarUpdatePresentation({
86 action,
87 showsEverything,
88 run,
89 isDownloading,
90 showCheckIcon,
91}: {
92 readonly action: ReturnType<typeof resolveDesktopUpdateButtonAction>;
93 /** Remote work is waiting, and this app is not downloading. */
94 readonly showsEverything: boolean;
95 /** The running "update everything" pass. Its state sets the icon. */
96 readonly run: UpdateEverythingRun | null;
97 readonly isDownloading: boolean;
98 readonly showCheckIcon: boolean;
99}) {
100 const showUpdateDetails = action !== "none" || showsEverything || run !== null || isDownloading;
101 const iconStatus: DesktopUpdateStatusIconState = showCheckIcon
102 ? "checking"
103 : isDownloading
104 ? "downloading"
105 : run !== null
106 ? run.localVersion !== null && action === "install"
107 ? "downloaded"
108 : "everything"
109 : showsEverything
110 ? "everything"
111 : action === "install"
112 ? "downloaded"
113 : action === "download"
114 ? "available"
115 : "idle";
116
117 return {
118 iconStatus,
119 showUpdateDetails,
120 showUpdateIconState: showUpdateDetails && !showCheckIcon,
121 } as const;
122}
123
124export function SidebarUpdateArchitectureWarning() {
125 return isElectron ? <SidebarUpdateArchitectureWarningContent /> : null;
126}
127
128function SidebarUpdateArchitectureWarningContent() {
129 const state = useDesktopUpdateState();
130 const visible = shouldShowArm64IntelBuildWarning(state);
131 const description = state && visible ? getArm64IntelBuildWarningDescription(state) : null;
132
133 if (!visible || !description) return null;
13437
135 return (38function useUpdateRunStatus(
136 <Alert variant="warning">
137 <TriangleAlertIcon />
138 <AlertTitle>Intel build on Apple Silicon</AlertTitle>
139 <AlertDescription>{description}</AlertDescription>
140 </Alert>
141 );
142}
143
144const toastUpdateError: ReportLocalUpdateError = (title, description) => {
145 toastManager.add(stackedThreadToast({ type: "error", title, description }));
146};
147
148/** Downloads this app's update. Resolves true when it is ready to install;
149 failures go to `reportError` (a toast by default) and resolve false. */
150async function downloadLocalUpdate(
151 bridge: DesktopBridge,
152 {
153 notifyDownloaded,
154 reportError = toastUpdateError,
155 }: { readonly notifyDownloaded: boolean; readonly reportError?: ReportLocalUpdateError },
156): Promise<boolean> {
157 try {
158 const result = await bridge.downloadUpdate();
159 if (result.completed && notifyDownloaded) {
160 showDesktopUpdateDownloadedToast(bridge, result.state);
161 }
162 const actionError = shouldToastDesktopUpdateActionResult(result)
163 ? getDesktopUpdateActionError(result)
164 : null;
165 if (actionError) reportError("Could not download update", actionError);
166 return result.completed;
167 } catch (error) {
168 reportError(
169 "Could not start update download",
170 error instanceof Error ? error.message : "An unexpected error occurred.",
171 );
172 return false;
173 }
174}
175
176/** Installs this app's downloaded update, which relaunches the app. Resolves
177 true when the relaunch is under way. Failures go to `reportError` (a toast
178 by default). A refused install (the download is not marked ready yet)
179 leaves the restart to the user. */
180async function installLocalUpdate(
181 bridge: DesktopBridge,
182 reportError: ReportLocalUpdateError = toastUpdateError,
183): Promise<boolean> {
184 try {
185 const result = await bridge.installUpdate();
186 if (!result.accepted) {
187 showDesktopUpdateDownloadedToast(bridge, result.state);
188 return false;
189 }
190 const actionError = shouldToastDesktopUpdateActionResult(result)
191 ? getDesktopUpdateActionError(result)
192 : null;
193 if (!actionError) return true;
194 reportError("Could not install update", actionError);
195 return false;
196 } catch (error) {
197 reportError(
198 "Could not install update",
199 error instanceof Error ? error.message : "An unexpected error occurred.",
200 );
201 return false;
202 }
203}
204
205/** Tooltip lines for a running pass: this app first, then one per machine. */
206function useUpdateRunTooltip(
207 run: UpdateEverythingRun | null,39 run: UpdateEverythingRun | null,
208 state: DesktopUpdateState | null,40 state: DesktopUpdateState | null,
209 localReady: boolean,
210): string | null {41): string | null {
211 const statusesAtom = useMemo(42 const statusesAtom = useMemo(
212 () =>43 () =>
...@@ -233,178 +64,49 @@ function useUpdateRunTooltip(...@@ -233,178 +64,49 @@ function useUpdateRunTooltip(
233 ? `downloading${state.downloadPercent === null ? "" : ` ${Math.floor(state.downloadPercent)}%`}`64 ? `downloading${state.downloadPercent === null ? "" : ` ${Math.floor(state.downloadPercent)}%`}`
234 : run.phase === "installing"65 : run.phase === "installing"
235 ? "restarting"66 ? "restarting"
236 : localReady67 : state && resolveDesktopUpdateButtonAction(state) === "install"
237 ? "restarts last"68 ? "restarts last"
238 : "waiting";69 : "waiting";
239 return describeUpdateRun(statuses, local).join("\n");70 return describeUpdateRun(statuses, local).join("\n");
240}71}
24172
242/**73export function AppMenuUpdates() {
243 * The sidebar update button. With only a local app update it downloads, then
244 * installs. When other machines' servers or any providers are behind, one
245 * click updates everything: providers, then servers, then this app last. On
246 * web there is no local app, so the button shows only for remote updates.
247 */
248export function SidebarUpdatePill() {
249 return <SidebarUpdateControl />;
250}
251
252function SidebarUpdateControl() {
253 const state = useDesktopUpdateState();74 const state = useDesktopUpdateState();
254 const [isActionPending, setIsActionPending] = useState(false);75 const [isActionPending, setIsActionPending] = useState(false);
255 const [isConfirmingEverything, setIsConfirmingEverything] = useState(false);76 const [isConfirmingEverything, setIsConfirmingEverything] = useState(false);
256 const [checkAnimationKey, setCheckAnimationKey] = useState(0);77 const pending = useRef(false);
257 const [isCheckAnimationLatched, setIsCheckAnimationLatched] = useState(false);
258 const [releaseNotesPopoverHandle] = useState(() => PopoverCreateHandle());
259 const suppressReleaseNotesFocusOpen = useRef(false);
260 const releaseNotesPopupRef = useRef<HTMLDivElement>(null);
261 const releaseNotesTriggerId = useId();
262 const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");78 const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
263
264 useEffect(() => {
265 if (prefersReducedMotion) {
266 setIsCheckAnimationLatched(false);
267 } else if (state?.status === "checking") {
268 setIsCheckAnimationLatched(true);
269 }
270 }, [prefersReducedMotion, state?.status]);
271
272 const action = state ? resolveDesktopUpdateButtonAction(state) : "none";79 const action = state ? resolveDesktopUpdateButtonAction(state) : "none";
273 const isDownloading = state?.status === "downloading";
274 // Remote servers follow this app to the version it is about to install.
275 const localUpdateVersion =80 const localUpdateVersion =
276 state && action !== "none" ? getDesktopUpdateDownloadedVersion(state) : null;81 state && action !== "none" ? getDesktopUpdateDownloadedVersion(state) : null;
277 const everything = useUpdateEverything(localUpdateVersion);82 const restartsApp = state?.installKind !== "ui";
278 const runsEverything = everything.hasUpdates && !isDownloading;83 const everything = useUpdateEverything(restartsApp ? localUpdateVersion : null);
279 // Providers leave the list once queued, so the run keeps its own state.
280 const run = useUpdateEverythingRun();84 const run = useUpdateEverythingRun();
281 const isRunningEverything = run !== null;85 const runStatus = useUpdateRunStatus(run, state);
282 const runTooltip = useUpdateRunTooltip(run, state, action === "install");86 const isDownloading = state?.status === "downloading";
283 const showCheckIcon = shouldShowDesktopUpdateCheckIcon({87 const isBusy = isActionPending || run !== null;
284 isAnimationLatched: isCheckAnimationLatched,88 const hasUpdates = localUpdateVersion !== null || everything.hasUpdates;
285 isChecking: state?.status === "checking",89 const appActionLabel =
286 prefersReducedMotion,90 state?.status === "checking"
287 });91 ? "Checking…"
288 const { iconStatus, showUpdateDetails, showUpdateIconState } = resolveSidebarUpdatePresentation({92 : isDownloading
289 action,93 ? "Downloading…"
290 showsEverything: runsEverything,94 : action === "download"
291 run,95 ? state?.status === "error"
292 isDownloading,96 ? "Retry download"
293 showCheckIcon,97 : "Download"
294 });98 : action === "install"
295 const tooltip =99 ? restartsApp
296 runTooltip !== null100 ? "Restart"
297 ? runTooltip101 : "Reload"
298 : runsEverything102 : "Check";
299 ? everything.summary
300 : showUpdateDetails
301 ? state
302 ? getDesktopUpdateButtonTooltip(state)
303 : "Update available"
304 : showCheckIcon
305 ? "Checking for updates…"
306 : "Check for updates";
307 const disabled = showCheckIcon
308 ? true
309 : runsEverything
310 ? false
311 : showUpdateDetails
312 ? isDesktopUpdateButtonDisabled(state)
313 : !canCheckForUpdate(state);
314 const isInteractionDisabled = disabled || isActionPending || isRunningEverything;
315 // During a run the tooltip shows its progress instead of release notes.
316 const showReleaseNotesPopover =
317 !isRunningEverything && shouldUseSidebarUpdateReleaseNotesPopover(showUpdateDetails, state);
318
319 useEffect(() => {
320 if (!showReleaseNotesPopover) {
321 releaseNotesPopoverHandle.close();
322 return;
323 }
324
325 const trigger = document.getElementById(releaseNotesTriggerId);
326 if (trigger?.matches(":focus-visible")) {
327 releaseNotesPopoverHandle.open(releaseNotesTriggerId);
328 }
329 }, [releaseNotesPopoverHandle, releaseNotesTriggerId, showReleaseNotesPopover]);
330
331 const handleAction = useCallback(async () => {
332 const bridge = window.desktopBridge;
333 if (isInteractionDisabled) return;
334
335 if (runsEverything) {
336 setIsConfirmingEverything(true);
337 return;
338 }
339
340 if (!bridge || !state) return;
341 setIsActionPending(true);
342
343 if (action === "download") {
344 void downloadLocalUpdate(bridge, { notifyDownloaded: true }).finally(() =>
345 setIsActionPending(false),
346 );
347 return;
348 }
349
350 if (action === "install") {
351 let confirmed = false;
352 try {
353 confirmed = await ensureLocalApi().dialogs.confirm(
354 getDesktopUpdateInstallConfirmationMessage(state),
355 );
356 } catch (error) {
357 setIsActionPending(false);
358 toastManager.add(
359 stackedThreadToast({
360 type: "error",
361 title: "Could not confirm update",
362 description: error instanceof Error ? error.message : "Update confirmation failed.",
363 }),
364 );
365 return;
366 }
367 if (!confirmed) {
368 setIsActionPending(false);
369 return;
370 }
371 void installLocalUpdate(bridge).finally(() => setIsActionPending(false));
372 return;
373 }
374
375 if (!prefersReducedMotion) {
376 setIsCheckAnimationLatched(true);
377 setCheckAnimationKey((key) => key + 1);
378 }
379 void bridge
380 .checkForUpdate()
381 .then((result) => {
382 if (result.checked) return;
383 toastManager.add(
384 stackedThreadToast({
385 type: "error",
386 title: "Could not check for updates",
387 description:
388 result.state.message ?? "Automatic updates are not available in this build.",
389 }),
390 );
391 })
392 .catch((error) => {
393 toastManager.add(
394 stackedThreadToast({
395 type: "error",
396 title: "Could not check for updates",
397 description: error instanceof Error ? error.message : "Update check failed.",
398 }),
399 );
400 })
401 .finally(() => setIsActionPending(false));
402 }, [action, isInteractionDisabled, prefersReducedMotion, runsEverything, state]);
403103
404 const runEverything = useCallback(async () => {104 const runEverything = async () => {
405 const bridge = window.desktopBridge;105 if (pending.current || run !== null) return;
106 pending.current = true;
406 setIsConfirmingEverything(false);107 setIsConfirmingEverything(false);
407 setIsActionPending(true);108 setIsActionPending(true);
109 const bridge = window.desktopBridge;
408 try {110 try {
409 await everything.run({111 await everything.run({
410 ...(bridge && action === "download"112 ...(bridge && action === "download"
...@@ -413,152 +115,152 @@ function SidebarUpdateControl() {...@@ -413,152 +115,152 @@ function SidebarUpdateControl() {
413 downloadLocalUpdate(bridge, { notifyDownloaded: false, reportError }),115 downloadLocalUpdate(bridge, { notifyDownloaded: false, reportError }),
414 }116 }
415 : {}),117 : {}),
416 ...(bridge && action !== "none"118 ...(bridge && restartsApp && action !== "none"
417 ? { installLocal: (reportError) => installLocalUpdate(bridge, reportError) }119 ? {
120 installLocal: (reportError) => installLocalUpdate(bridge, reportError),
121 }
418 : {}),122 : {}),
419 });123 });
124 if (bridge && !restartsApp && action === "install") await installLocalUpdate(bridge);
420 } catch (error) {125 } catch (error) {
421 toastManager.add(126 toastUpdateError(
422 stackedThreadToast({127 "Could not update everything",
423 type: "error",128 error instanceof Error ? error.message : "Try again.",
424 title: "Could not update everything",
425 description: error instanceof Error ? error.message : "An unexpected error occurred.",
426 }),
427 );129 );
428 } finally {130 } finally {
131 pending.current = false;
429 setIsActionPending(false);132 setIsActionPending(false);
430 }133 }
431 }, [action, everything]);134 };
432
433 const handleCheckAnimationIteration = useCallback(() => {
434 setIsCheckAnimationLatched(
435 shouldContinueDesktopUpdateCheckAnimation({
436 isChecking: state?.status === "checking",
437 prefersReducedMotion,
438 }),
439 );
440 }, [prefersReducedMotion, state?.status]);
441135
442 // Web has no local app to check, so it only shows the button for remote work.136 const handleAppAction = async () => {
443 if (!isElectron && !runsEverything && !isRunningEverything) return null;137 const bridge = window.desktopBridge;
138 if (!bridge || !state || pending.current || isBusy) return;
139 pending.current = true;
140 setIsActionPending(true);
141 try {
142 await performDesktopUpdate(bridge, state);
143 } finally {
144 pending.current = false;
145 setIsActionPending(false);
146 }
147 };
444148
445 const updateButton = (149 const showArchitectureWarning = shouldShowArm64IntelBuildWarning(state);
446 <button150 const appStatus =
447 type="button"151 state?.status === "checking"
448 aria-label={tooltip}152 ? "Checking for updates…"
449 aria-disabled={isInteractionDisabled || undefined}153 : state
450 className={cn(154 ? getDesktopUpdateButtonTooltip(state)
451 "inline-flex size-8 items-center justify-center rounded-full outline-hidden ring-ring transition-colors focus-visible:ring-2 focus-visible:ring-inset",155 : "Loading update status…";
452 isInteractionDisabled ? "cursor-not-allowed" : "cursor-pointer",156 const iconStatus =
453 showUpdateIconState157 state?.status === "checking"
454 ? cn(158 ? "checking"
455 "bg-sidebar-control-surface text-sidebar-foreground",159 : isDownloading
456 !isInteractionDisabled && "hover:bg-sidebar-row-hover",160 ? "downloading"
457 )161 : action === "install"
458 : cn(162 ? "downloaded"
459 "text-(--sidebar-icon-color)",163 : action === "download"
460 !isInteractionDisabled && "hover:bg-sidebar-row-hover hover:text-sidebar-foreground",164 ? "available"
461 ),165 : "idle";
462 disabled && !showUpdateIconState && "opacity-60",
463 )}
464 onClick={handleAction}
465 onBlur={() => {
466 suppressReleaseNotesFocusOpen.current = false;
467 }}
468 onFocus={(event) => {
469 if (!showReleaseNotesPopover || !event.currentTarget.matches(":focus-visible")) return;
470 if (suppressReleaseNotesFocusOpen.current) {
471 suppressReleaseNotesFocusOpen.current = false;
472 return;
473 }
474 flushSync(() => releaseNotesPopoverHandle.open(releaseNotesTriggerId));
475 }}
476 onKeyDown={(event) => {
477 if (!showReleaseNotesPopover) return;
478 openSidebarUpdateReleaseNotesPopoverOnForwardTab(
479 event,
480 releaseNotesPopoverHandle,
481 releaseNotesTriggerId,
482 );
483 }}
484 >
485 <DesktopUpdateStatusIcon
486 key={showCheckIcon ? checkAnimationKey : iconStatus}
487 downloadPercent={state?.downloadPercent ?? null}
488 isCheckAnimating={showCheckIcon && !prefersReducedMotion}
489 onCheckAnimationIteration={handleCheckAnimationIteration}
490 status={iconStatus}
491 />
492 </button>
493 );
494166
495 return (167 return (
496 <SidebarMenuItem className="ml-auto shrink-0">168 <section aria-label="Updates" className="space-y-1">
497 <Popover169 <div className="flex min-h-7 items-center justify-between gap-2 px-1.5">
498 handle={releaseNotesPopoverHandle}170 <h3 className="text-xs font-medium text-muted-foreground">Updates</h3>
499 onOpenChange={(open, details) => {171 {hasUpdates || run !== null ? (
500 if (open && !showReleaseNotesPopover) {172 <Button
501 details.cancel();173 size="xs"
502 return;174 variant="outline"
503 }175 disabled={isBusy || isDownloading}
504 handleSidebarUpdateReleaseNotesPopoverOpenChange(open, details);176 onClick={() => {
505 }}177 if (everything.restarts.length > 0 || (restartsApp && localUpdateVersion !== null)) {
506 >178 setIsConfirmingEverything(true);
507 <Tooltip disabled={showReleaseNotesPopover}>179 } else {
508 <TooltipTrigger180 void runEverything();
509 id={releaseNotesTriggerId}
510 render={
511 <PopoverTrigger
512 {...(!showReleaseNotesPopover
513 ? {
514 "aria-controls": undefined,
515 "aria-expanded": undefined,
516 "aria-haspopup": undefined,
517 }
518 : {})}
519 closeDelay={150}
520 handle={releaseNotesPopoverHandle}
521 id={releaseNotesTriggerId}
522 openOnHover={showReleaseNotesPopover}
523 render={updateButton}
524 />
525 }
526 />
527 {!showReleaseNotesPopover ? (
528 <TooltipPopup
529 align="center"
530 side="top"
531 variant={showUpdateDetails ? "glass" : "default"}
532 >
533 <span className="whitespace-pre-line">{tooltip}</span>
534 </TooltipPopup>
535 ) : null}
536 </Tooltip>
537 {showReleaseNotesPopover && state ? (
538 <PopoverPopup
539 align="center"
540 aria-label="Nightly update release notes"
541 initialFocus={false}
542 onKeyDownCapture={(event) => {
543 if (
544 event.key === "Escape" &&
545 releaseNotesPopupRef.current?.contains(document.activeElement)
546 ) {
547 suppressReleaseNotesFocusOpen.current = true;
548 }181 }
549 }}182 }}
550 ref={releaseNotesPopupRef}
551 side="top"
552 tooltipStyle
553 >183 >
554 <SidebarUpdateReleaseNotes184 {run !== null ? "Updating…" : "Update all"}
555 shell={window.desktopBridge}185 </Button>
556 state={state}
557 tooltip={tooltip}
558 />
559 </PopoverPopup>
560 ) : null}186 ) : null}
561 </Popover>187 </div>
188 {isElectron ? (
189 <div className="flex min-h-9 items-center gap-2 px-1.5">
190 {/* Icon column width; the download ring overflows it evenly. */}
191 <span aria-hidden="true" className="flex size-4 shrink-0 items-center justify-center">
192 <DesktopUpdateStatusIcon
193 status={iconStatus}
194 downloadPercent={state?.downloadPercent ?? null}
195 isCheckAnimating={!prefersReducedMotion}
196 />
197 </span>
198 <div className="min-w-0 flex-1">
199 <div className="text-xs font-medium">App</div>
200 <p role="status" className="text-2xs leading-4 text-muted-foreground">
201 {appStatus}
202 </p>
203 </div>
204 <Button
205 size="xs"
206 variant="outline"
207 disabled={
208 isBusy ||
209 (action === "none" ? !canCheckForUpdate(state) : isDesktopUpdateButtonDisabled(state))
210 }
211 aria-label={`${appActionLabel} app update`}
212 onClick={() => void handleAppAction()}
213 >
214 {appActionLabel}
215 </Button>
216 </div>
217 ) : null}
218 {showArchitectureWarning && state ? (
219 <p className="flex gap-2 px-1.5 text-xs text-warning-foreground">
220 <TriangleAlertIcon aria-hidden="true" className="size-4 shrink-0" />
221 {getArm64IntelBuildWarningDescription(state)}
222 </p>
223 ) : null}
224 {state?.channel === "nightly" && state.releaseNotes.length > 0 ? (
225 <details className="px-1.5 text-xs">
226 <summary className="cursor-pointer rounded-sm py-1 text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring">
227 Release notes
228 </summary>
229 <SidebarUpdateReleaseNotes
230 shell={window.desktopBridge}
231 state={state}
232 tooltip={appStatus}
233 />
234 </details>
235 ) : null}
236 {everything.servers.map((server) => (
237 <div
238 key={server.environmentId}
239 className="flex items-center justify-between gap-2 px-1.5 py-1 text-xs"
240 >
241 <span>{server.serverLabel}</span>
242 <span className="text-2xs text-muted-foreground">Server {server.targetVersion}</span>
243 </div>
244 ))}
245 {runStatus ? (
246 <p role="status" className="whitespace-pre-line px-1.5 text-xs text-muted-foreground">
247 {runStatus}
248 </p>
249 ) : null}
250 {everything.skipped.length > 0 ? (
251 <details className="px-1.5 text-xs text-muted-foreground">
252 <summary className="cursor-pointer rounded-sm py-1 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring">
253 Unavailable machines ({everything.skipped.length})
254 </summary>
255 <ul className="space-y-1 py-1">
256 {everything.skipped.map((machine) => (
257 <li key={machine.environmentId}>
258 {machine.label}: {machine.reason}
259 </li>
260 ))}
261 </ul>
262 </details>
263 ) : null}
562 <UpdateEverythingDialog264 <UpdateEverythingDialog
563 open={isConfirmingEverything}265 open={isConfirmingEverything}
564 onOpenChange={setIsConfirmingEverything}266 onOpenChange={setIsConfirmingEverything}
...@@ -568,6 +270,6 @@ function SidebarUpdateControl() {...@@ -568,6 +270,6 @@ function SidebarUpdateControl() {
568 skipped={everything.skipped}270 skipped={everything.skipped}
569 onConfirm={() => void runEverything()}271 onConfirm={() => void runEverything()}
570 />272 />
571 </SidebarMenuItem>273 </section>
572 );274 );
573}275}
apps/web/src/components/sidebar/updateEverythingRun.tsx+2-2
...@@ -15,7 +15,7 @@ import {...@@ -15,7 +15,7 @@ import {
15 type UpdateEverythingRun,15 type UpdateEverythingRun,
16} from "./updateEverything.logic";16} from "./updateEverything.logic";
1717
18/** The running "update everything" pass, or null. Outlives the sidebar button. */18/** The running "update everything" pass, or null. Outlives the app menu. */
19const updateEverythingRunAtom = Atom.make<UpdateEverythingRun | null>(null).pipe(19const updateEverythingRunAtom = Atom.make<UpdateEverythingRun | null>(null).pipe(
20 Atom.keepAlive,20 Atom.keepAlive,
21 Atom.withLabel("update-everything-run"),21 Atom.withLabel("update-everything-run"),
...@@ -133,7 +133,7 @@ export function DesktopUpdateLaunchNotice() {...@@ -133,7 +133,7 @@ export function DesktopUpdateLaunchNotice() {
133 stackedThreadToast({133 stackedThreadToast({
134 type: "error",134 type: "error",
135 title: "Update did not apply",135 title: "Update did not apply",
136 description: `The Knife is still on ${APP_VERSION}. Install ${notAppliedVersion} again from the update button.`,136 description: `The Knife is still on ${APP_VERSION}. Install ${notAppliedVersion} again from the app menu.`,
137 }),137 }),
138 );138 );
139 }, [notAppliedVersion]);139 }, [notAppliedVersion]);
apps/web/src/components/sidebar/useUpdateEverything.ts+2-1
...@@ -34,7 +34,7 @@ import {...@@ -34,7 +34,7 @@ import {
34export type ReportLocalUpdateError = (title: string, description: string) => void;34export type ReportLocalUpdateError = (title: string, description: string) => void;
3535
36/**36/**
37 * The remote half of the sidebar update button: outdated providers on every37 * The remote half of the app menu updater: outdated providers on every
38 * connected machine, and servers behind the version this client runs or is38 * connected machine, and servers behind the version this client runs or is
39 * about to install (`localUpdateVersion`). Servers that install from npm appear39 * about to install (`localUpdateVersion`). Servers that install from npm appear
40 * only once npm confirms that version. `run` performs the whole pass, including40 * only once npm confirms that version. `run` performs the whole pass, including
...@@ -104,6 +104,7 @@ export function useUpdateEverything(localUpdateVersion: string | null) {...@@ -104,6 +104,7 @@ export function useUpdateEverything(localUpdateVersion: string | null) {
104 });104 });
105105
106 return {106 return {
107 servers,
107 hasUpdates: servers.length > 0 || providers.machines.length > 0,108 hasUpdates: servers.length > 0 || providers.machines.length > 0,
108 /** Short label for the tooltip and the confirmation title. */109 /** Short label for the tooltip and the confirmation title. */
109 summary,110 summary,
apps/web/src/components/threadSidebarWidth.test.ts+7-19
...@@ -2,35 +2,23 @@ import { describe, expect, it } from "vite-plus/test";...@@ -2,35 +2,23 @@ import { describe, expect, it } from "vite-plus/test";
2import {2import {
3 clampThreadSidebarWidth,3 clampThreadSidebarWidth,
4 resolveThreadSidebarMaximumWidth,4 resolveThreadSidebarMaximumWidth,
5 resolveThreadSidebarMinimumWidth,
6 THREAD_SIDEBAR_MIN_WIDTH,5 THREAD_SIDEBAR_MIN_WIDTH,
7} from "./threadSidebarWidth";6} from "./threadSidebarWidth";
87
9describe("resolveThreadSidebarMinimumWidth", () => {
10 it("keeps the default minimum when the brand fits", () => {
11 expect(resolveThreadSidebarMinimumWidth(0)).toBe(THREAD_SIDEBAR_MIN_WIDTH);
12 expect(resolveThreadSidebarMinimumWidth(194)).toBe(THREAD_SIDEBAR_MIN_WIDTH);
13 });
14
15 it("grows to a brand wider than the default, rounding up", () => {
16 expect(resolveThreadSidebarMinimumWidth(237.2)).toBe(238);
17 });
18});
19
20describe("resolveThreadSidebarMaximumWidth", () => {8describe("resolveThreadSidebarMaximumWidth", () => {
21 it("never drops below a raised minimum on a narrow viewport", () => {9 it("keeps the sidebar usable on a narrow viewport", () => {
22 expect(resolveThreadSidebarMaximumWidth(800, 238)).toBe(238);10 expect(resolveThreadSidebarMaximumWidth(800)).toBe(THREAD_SIDEBAR_MIN_WIDTH);
23 expect(resolveThreadSidebarMaximumWidth(1200, 238)).toBe(560);11 expect(resolveThreadSidebarMaximumWidth(1200)).toBe(560);
24 });12 });
25});13});
2614
27describe("clampThreadSidebarWidth", () => {15describe("clampThreadSidebarWidth", () => {
28 it("widens a stored width below a raised minimum", () => {16 it("widens a stored width below the minimum", () => {
29 expect(clampThreadSidebarWidth(208, 238, 560)).toBe(238);17 expect(clampThreadSidebarWidth(180, 560)).toBe(THREAD_SIDEBAR_MIN_WIDTH);
30 });18 });
3119
32 it("keeps widths inside the range and caps wide ones", () => {20 it("keeps widths inside the range and caps wide ones", () => {
33 expect(clampThreadSidebarWidth(300, 238, 560)).toBe(300);21 expect(clampThreadSidebarWidth(300, 560)).toBe(300);
34 expect(clampThreadSidebarWidth(900, 238, 560)).toBe(560);22 expect(clampThreadSidebarWidth(900, 560)).toBe(560);
35 });23 });
36});24});
apps/web/src/components/threadSidebarWidth.ts+7-16
...@@ -3,24 +3,15 @@ const THREAD_SIDEBAR_DEFAULT_WIDTH = 16 * 16;...@@ -3,24 +3,15 @@ const THREAD_SIDEBAR_DEFAULT_WIDTH = 16 * 16;
3export const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16;3export const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16;
4export const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16;4export const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16;
55
6// The brand's measured width can raise the minimum so "T3 Code" never clips.6export function resolveThreadSidebarMaximumWidth(viewportWidth: number): number {
7export function resolveThreadSidebarMinimumWidth(brandWidth: number): number {7 return Math.max(
8 return Math.max(THREAD_SIDEBAR_MIN_WIDTH, Math.ceil(brandWidth));8 THREAD_SIDEBAR_MIN_WIDTH,
9 Math.floor(viewportWidth) - THREAD_MAIN_CONTENT_MIN_WIDTH,
10 );
9}11}
1012
11export function resolveThreadSidebarMaximumWidth(13export function clampThreadSidebarWidth(width: number, maximumWidth: number): number {
12 viewportWidth: number,14 return Math.min(maximumWidth, Math.max(THREAD_SIDEBAR_MIN_WIDTH, width));
13 minimumWidth = THREAD_SIDEBAR_MIN_WIDTH,
14): number {
15 return Math.max(minimumWidth, Math.floor(viewportWidth) - THREAD_MAIN_CONTENT_MIN_WIDTH);
16}
17
18export function clampThreadSidebarWidth(
19 width: number,
20 minimumWidth: number,
21 maximumWidth: number,
22): number {
23 return Math.min(maximumWidth, Math.max(minimumWidth, width));
24}15}
2516
26export function resolveInitialThreadSidebarWidth(17export function resolveInitialThreadSidebarWidth(
apps/web/src/components/ui/alert-dialog.focus.test.tsx created+88
...@@ -0,0 +1,88 @@
1// @vitest-environment jsdom
2
3import { act, createRef, useState } from "react";
4import { createRoot, type Root } from "react-dom/client";
5import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
6
7import {
8 AlertDialog,
9 AlertDialogClose,
10 AlertDialogFooter,
11 AlertDialogPopup,
12 AlertDialogTitle,
13} from "./alert-dialog";
14import { Button } from "./button";
15
16let root: Root;
17let container: HTMLDivElement;
18const cancel = createRef<HTMLButtonElement>();
19
20beforeEach(() => {
21 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
22 container = document.createElement("div");
23 document.body.append(container);
24 root = createRoot(container);
25});
26
27afterEach(async () => {
28 await act(async () => root.unmount());
29 container.remove();
30 vi.unstubAllGlobals();
31});
32
33function Confirmation({ disabled = false, explicitFocus = false }) {
34 const [open, setOpen] = useState(false);
35 return (
36 <>
37 <button onClick={() => setOpen(true)}>Open danger dialog</button>
38 <AlertDialog open={open} onOpenChange={setOpen}>
39 <AlertDialogPopup {...(explicitFocus ? { initialFocus: cancel } : {})}>
40 <AlertDialogTitle>Remove fixture?</AlertDialogTitle>
41 <AlertDialogFooter>
42 <AlertDialogClose ref={cancel} render={<Button variant="outline" />}>
43 Cancel
44 </AlertDialogClose>
45 <Button variant="destructive" disabled={disabled} onClick={() => setOpen(false)}>
46 Remove
47 </Button>
48 </AlertDialogFooter>
49 </AlertDialogPopup>
50 </AlertDialog>
51 </>
52 );
53}
54
55async function open() {
56 const trigger = container.querySelector("button")!;
57 await act(async () => {
58 trigger.focus();
59 trigger.click();
60 });
61}
62
63it("focuses confirmation on every opening and lets Escape cancel", async () => {
64 await act(async () => root.render(<Confirmation />));
65 await open();
66 await vi.waitFor(() => expect(document.activeElement?.textContent).toBe("Remove"));
67 await act(async () => {
68 document.activeElement?.dispatchEvent(
69 new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
70 );
71 });
72 await vi.waitFor(() => expect(document.querySelector('[role="alertdialog"]')).toBeNull());
73 expect(document.activeElement?.textContent).toBe("Open danger dialog");
74 await open();
75 await vi.waitFor(() => expect(document.activeElement?.textContent).toBe("Remove"));
76});
77
78it("keeps disabled confirmation out of the default keyboard action", async () => {
79 await act(async () => root.render(<Confirmation disabled />));
80 await open();
81 await vi.waitFor(() => expect(document.activeElement?.textContent).toBe("Cancel"));
82});
83
84it("preserves an explicit initial focus target", async () => {
85 await act(async () => root.render(<Confirmation explicitFocus />));
86 await open();
87 await vi.waitFor(() => expect(document.activeElement?.textContent).toBe("Cancel"));
88});
apps/web/src/components/ui/alert-dialog.tsx+19
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1"use client";1"use client";
22
3import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog";3import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog";
4import { useRef } from "react";
45
5import { cn } from "~/lib/utils";6import { cn } from "~/lib/utils";
6import {7import {
...@@ -41,11 +42,14 @@ function AlertDialogPopup({...@@ -41,11 +42,14 @@ function AlertDialogPopup({
41 className,42 className,
42 bottomStickOnMobile = true,43 bottomStickOnMobile = true,
43 portalContainer,44 portalContainer,
45 initialFocus,
46 ref,
44 ...props47 ...props
45}: AlertDialogPrimitive.Popup.Props & {48}: AlertDialogPrimitive.Popup.Props & {
46 bottomStickOnMobile?: boolean;49 bottomStickOnMobile?: boolean;
47 portalContainer?: AlertDialogPrimitive.Portal.Props["container"];50 portalContainer?: AlertDialogPrimitive.Portal.Props["container"];
48}) {51}) {
52 const popup = useRef<HTMLDivElement | null>(null);
49 return (53 return (
50 <AlertDialogPortal container={portalContainer}>54 <AlertDialogPortal container={portalContainer}>
51 <AlertDialogBackdrop />55 <AlertDialogBackdrop />
...@@ -53,6 +57,21 @@ function AlertDialogPopup({...@@ -53,6 +57,21 @@ function AlertDialogPopup({
53 className={cn(bottomStickOnMobile && "max-sm:grid-rows-[1fr_auto] max-sm:p-0 max-sm:pt-12")}57 className={cn(bottomStickOnMobile && "max-sm:grid-rows-[1fr_auto] max-sm:p-0 max-sm:pt-12")}
54 >58 >
55 <AlertDialogPrimitive.Popup59 <AlertDialogPrimitive.Popup
60 ref={(element) => {
61 popup.current = element;
62 if (typeof ref === "function") return ref(element);
63 if (ref) ref.current = element;
64 }}
65 initialFocus={
66 initialFocus ??
67 (() => {
68 return (
69 popup.current?.querySelector<HTMLButtonElement>(
70 '[data-slot="alert-dialog-footer"] button:not([data-slot="alert-dialog-close"]):not(:disabled):not([aria-disabled="true"])',
71 ) ?? true
72 );
73 })
74 }
56 className={cn(75 className={cn(
57 DIALOG_POPUP_CLASS,76 DIALOG_POPUP_CLASS,
58 "row-start-2 max-h-full max-w-lg text-popover-foreground",77 "row-start-2 max-h-full max-w-lg text-popover-foreground",
apps/web/src/hooks/useNewProject.ts deleted-151
...@@ -1,151 +0,0 @@
1import { scopeProjectRef } from "@t3tools/client-runtime/environment";
2import {
3 getNewProjectRepository,
4 type NewProjectPublishTarget,
5} from "@t3tools/client-runtime/operations/projects";
6import {
7 isAtomCommandInterrupted,
8 squashAtomCommandFailure,
9} from "@t3tools/client-runtime/state/runtime";
10import type { EnvironmentId } from "@t3tools/contracts";
11import { useCallback } from "react";
12
13import { stackedThreadToast, toastManager } from "~/components/ui/toast";
14import { waitForProject } from "~/state/entities";
15import { projectEnvironment } from "~/state/projects";
16import { sourceControlEnvironment } from "~/state/sourceControl";
17import { useAtomCommand } from "~/state/use-atom-command";
18import { useNewThreadHandler } from "./useHandleNewThread";
19
20function errorMessage(error: unknown): string {
21 return error instanceof Error && error.message.trim().length > 0
22 ? error.message
23 : "An error occurred.";
24}
25
26/**
27 * Starts a project from just a name. The server makes a folder under its
28 * `newProjectsRoot` with a README, an icon, and a first commit; this then
29 * opens a new thread draft in it. With `publishTo`, it also publishes the
30 * repository as private on that host, without holding up the draft.
31 *
32 * Resolves to whether the project was created.
33 */
34export function useNewProject() {
35 const createNew = useAtomCommand(projectEnvironment.createNew, { reportFailure: false });
36 const publishRepository = useAtomCommand(sourceControlEnvironment.publishRepository, {
37 reportFailure: false,
38 });
39 const handleNewThread = useNewThreadHandler();
40
41 const publishNewRepository = useCallback(
42 async (input: {
43 readonly environmentId: EnvironmentId;
44 readonly workspaceRoot: string;
45 readonly target: NewProjectPublishTarget;
46 }) => {
47 const { label } = input.target.definition;
48 const result = await publishRepository({
49 environmentId: input.environmentId,
50 input: {
51 cwd: input.workspaceRoot,
52 provider: input.target.definition.kind,
53 repository: getNewProjectRepository(input.target, input.workspaceRoot),
54 visibility: "private",
55 },
56 });
57 if (result._tag === "Failure") {
58 if (!isAtomCommandInterrupted(result)) {
59 toastManager.add(
60 stackedThreadToast({
61 type: "error",
62 title: `Could not create the ${label} repository`,
63 description: `${errorMessage(squashAtomCommandFailure(result))} Use Publish Repository in the Git menu to try again.`,
64 }),
65 );
66 }
67 return;
68 }
69 toastManager.add(
70 stackedThreadToast({
71 type: "success",
72 title: `Published to ${label}`,
73 description: result.value.repository.nameWithOwner,
74 }),
75 );
76 },
77 [publishRepository],
78 );
79
80 return useCallback(
81 async (input: {
82 readonly environmentId: EnvironmentId;
83 readonly name: string;
84 readonly publishTo: NewProjectPublishTarget | null;
85 }): Promise<boolean> => {
86 const result = await createNew({
87 environmentId: input.environmentId,
88 input: { name: input.name },
89 });
90 if (result._tag === "Failure") {
91 if (!isAtomCommandInterrupted(result)) {
92 toastManager.add(
93 stackedThreadToast({
94 type: "error",
95 title: "Could not create the project",
96 description: errorMessage(squashAtomCommandFailure(result)),
97 }),
98 );
99 }
100 return false;
101 }
102
103 const { projectId, workspaceRoot, commitError } = result.value;
104 // The folder sits in T3 Code's data directory, so always say where.
105 toastManager.add(
106 stackedThreadToast(
107 commitError === undefined
108 ? { type: "success", title: `Created ${input.name}`, description: workspaceRoot }
109 : {
110 type: "warning",
111 title: `Created ${input.name} without a first commit`,
112 description: `${commitError} The project is in ${workspaceRoot}.`,
113 },
114 ),
115 );
116 if (input.publishTo) {
117 void publishNewRepository({
118 environmentId: input.environmentId,
119 workspaceRoot,
120 target: input.publishTo,
121 });
122 }
123
124 const projectRef = scopeProjectRef(input.environmentId, projectId);
125 // Drafts key off the project's stored path, so wait for the create event
126 // to reach the store before opening one.
127 const project = await waitForProject(projectRef).catch((error: unknown) => {
128 toastManager.add(
129 stackedThreadToast({
130 type: "error",
131 title: "Failed to open project",
132 description: `${errorMessage(error)} It will appear in the sidebar once this client catches up.`,
133 }),
134 );
135 return null;
136 });
137 if (project === null) return true;
138 await handleNewThread(projectRef).catch((error: unknown) => {
139 toastManager.add(
140 stackedThreadToast({
141 type: "error",
142 title: "Failed to open project",
143 description: errorMessage(error),
144 }),
145 );
146 });
147 return true;
148 },
149 [createNew, handleNewThread, publishNewRepository],
150 );
151}
apps/web/src/index.css+35-33
...@@ -1,5 +1,37 @@...@@ -1,5 +1,37 @@
1@import "tailwindcss";1@import "tailwindcss";
22
3@font-face {
4 font-family: "AT Name Sans";
5 src: url("/fonts/private/ATNameSans-Regular.woff2") format("woff2");
6 font-weight: 1 1000;
7 font-style: normal;
8 font-display: swap;
9}
10
11@font-face {
12 font-family: "AT Name Sans";
13 src: url("/fonts/private/ATNameSans-Italic.woff2") format("woff2");
14 font-weight: 1 1000;
15 font-style: italic;
16 font-display: swap;
17}
18
19@font-face {
20 font-family: "AT Name Mono";
21 src: url("/fonts/private/ATNameMono-Regular.woff2") format("woff2");
22 font-weight: 400 900;
23 font-style: normal;
24 font-display: swap;
25}
26
27@font-face {
28 font-family: "AT Name Mono";
29 src: url("/fonts/private/ATNameMono-Italic.woff2") format("woff2");
30 font-weight: 400 900;
31 font-style: italic;
32 font-display: swap;
33}
34
3@custom-variant dark (&:is(.dark, .dark *));35@custom-variant dark (&:is(.dark, .dark *));
4@custom-variant light (&:not(.dark, .dark *));36@custom-variant light (&:not(.dark, .dark *));
5/* Window Controls Overlay: active when Electron exposes native titlebar control geometry. */37/* Window Controls Overlay: active when Electron exposes native titlebar control geometry. */
...@@ -87,6 +119,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -87,6 +119,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
87}119}
88120
89:root {121:root {
122 --clo-term-font: "AT Name Mono";
90 --app-scrollbar-width: 6px;123 --app-scrollbar-width: 6px;
91 --appearance-contrast-base: 100%;124 --appearance-contrast-base: 100%;
92 --appearance-contrast-boost: 0%;125 --appearance-contrast-boost: 0%;
...@@ -164,9 +197,9 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -164,9 +197,9 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
164 the variables and Settings -> Appearance can override them at runtime. The197 the variables and Settings -> Appearance can override them at runtime. The
165 default stacks are mirrored in `appearanceFonts.ts`. */198 default stacks are mirrored in `appearanceFonts.ts`. */
166@theme {199@theme {
167 --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;200 --font-sans: "AT Name Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
168 --font-mono:201 --font-mono:
169 ui-monospace, "SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace;202 "AT Name Mono", "SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace;
170}203}
171204
172@theme inline {205@theme inline {
...@@ -493,31 +526,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -493,31 +526,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
493 var(--app-scrollbar-width) 100%;526 var(--app-scrollbar-width) 100%;
494}527}
495528
496/* Stage-channel art needs a mask and pseudo-element gradient, so keep the
497 behavior composable without tying it to the global components layer. */
498@utility sidebar-stage-backdrop {
499 --stage-fade: var(--sidebar-stage-fade, var(--app-chrome-background));
500 mask-image: linear-gradient(to bottom, black 0%, black 55%, transparent 92%);
501 -webkit-mask-image: linear-gradient(to bottom, black 0%, black 55%, transparent 92%);
502
503 &::after {
504 content: "";
505 position: absolute;
506 inset: 0;
507 background: linear-gradient(
508 to bottom,
509 transparent 0%,
510 transparent 28%,
511 color-mix(in srgb, var(--stage-fade) 10%, transparent) 40%,
512 color-mix(in srgb, var(--stage-fade) 30%, transparent) 52%,
513 color-mix(in srgb, var(--stage-fade) 58%, transparent) 64%,
514 color-mix(in srgb, var(--stage-fade) 82%, transparent) 75%,
515 color-mix(in srgb, var(--stage-fade) 96%, transparent) 85%,
516 var(--stage-fade) 93%
517 );
518 }
519}
520
521@keyframes live-tool-shine {529@keyframes live-tool-shine {
522 from {530 from {
523 background-position:531 background-position:
...@@ -1146,7 +1154,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -1146,7 +1154,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
1146 --sidebar-row-active: var(--color-white);1154 --sidebar-row-active: var(--color-white);
1147 --sidebar-row-selected: var(--color-white);1155 --sidebar-row-selected: var(--color-white);
1148 --sidebar-border: var(--border);1156 --sidebar-border: var(--border);
1149 --sidebar-stage-fade: var(--sidebar);
1150 --code-background: color-mix(in srgb, var(--card) 90%, var(--background));1157 --code-background: color-mix(in srgb, var(--card) 90%, var(--background));
1151 --code-foreground: var(--foreground);1158 --code-foreground: var(--foreground);
1152 --terminal-background: var(--background);1159 --terminal-background: var(--background);
...@@ -1189,7 +1196,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -1189,7 +1196,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
1189 --sidebar-row-hover: var(--accent);1196 --sidebar-row-hover: var(--accent);
1190 --sidebar-row-active: var(--accent);1197 --sidebar-row-active: var(--accent);
1191 --sidebar-row-selected: var(--muted);1198 --sidebar-row-selected: var(--muted);
1192 --sidebar-stage-fade: var(--card);
1193 --terminal-cursor: rgb(180 203 255);1199 --terminal-cursor: rgb(180 203 255);
1194 --terminal-selection-background: rgb(180 203 255 / 25%);1200 --terminal-selection-background: rgb(180 203 255 / 25%);
1195 }1201 }
...@@ -1217,7 +1223,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -1217,7 +1223,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
1217 --sidebar-row-active: var(--color-white);1223 --sidebar-row-active: var(--color-white);
1218 --sidebar-row-selected: var(--color-white);1224 --sidebar-row-selected: var(--color-white);
1219 --sidebar-border: var(--color-zinc-200);1225 --sidebar-border: var(--color-zinc-200);
1220 --sidebar-stage-fade: var(--sidebar);
12211226
1222 @variant dark {1227 @variant dark {
1223 --background: #000;1228 --background: #000;
...@@ -1238,7 +1243,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -1238,7 +1243,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
1238 --sidebar-row-active: color-mix(in srgb, var(--contrast-foreground) 11%, transparent);1243 --sidebar-row-active: color-mix(in srgb, var(--contrast-foreground) 11%, transparent);
1239 --sidebar-row-selected: color-mix(in srgb, var(--contrast-foreground) 7%, transparent);1244 --sidebar-row-selected: color-mix(in srgb, var(--contrast-foreground) 7%, transparent);
1240 --sidebar-border: var(--border);1245 --sidebar-border: var(--border);
1241 --sidebar-stage-fade: var(--card);
1242 }1246 }
1243}1247}
12441248
...@@ -1307,7 +1311,6 @@ html[data-theme-id]:not([data-theme-id=""]) {...@@ -1307,7 +1311,6 @@ html[data-theme-id]:not([data-theme-id=""]) {
1307 --sidebar-row-active: var(--app-theme-sidebar-row-active);1311 --sidebar-row-active: var(--app-theme-sidebar-row-active);
1308 --sidebar-row-selected: var(--app-theme-sidebar-row-selected);1312 --sidebar-row-selected: var(--app-theme-sidebar-row-selected);
1309 --sidebar-border: var(--app-theme-sidebar-border);1313 --sidebar-border: var(--app-theme-sidebar-border);
1310 --sidebar-stage-fade: var(--app-theme-sidebar);
1311 --code-background: var(--app-theme-code-background);1314 --code-background: var(--app-theme-code-background);
1312 --code-foreground: var(--app-theme-code-foreground);1315 --code-foreground: var(--app-theme-code-foreground);
1313 --terminal-background: var(--app-theme-terminal-background);1316 --terminal-background: var(--app-theme-terminal-background);
...@@ -1507,7 +1510,6 @@ html[data-theme-id] [data-app-sidebar] {...@@ -1507,7 +1510,6 @@ html[data-theme-id] [data-app-sidebar] {
1507 --sidebar-row-active: var(--app-theme-sidebar-row-active);1510 --sidebar-row-active: var(--app-theme-sidebar-row-active);
1508 --sidebar-row-selected: var(--app-theme-sidebar-row-selected);1511 --sidebar-row-selected: var(--app-theme-sidebar-row-selected);
1509 --sidebar-border: var(--app-theme-sidebar-border);1512 --sidebar-border: var(--app-theme-sidebar-border);
1510 --sidebar-stage-fade: var(--app-theme-sidebar);
1511 border-color: color-mix(in srgb, var(--contrast-sidebar-foreground) 10%, transparent);1513 border-color: color-mix(in srgb, var(--contrast-sidebar-foreground) 10%, transparent);
15121514
1513 @variant dark {1515 @variant dark {
apps/web/src/rightPanelStore.test.ts+6-6
...@@ -565,7 +565,7 @@ describe("rightPanelStore", () => {...@@ -565,7 +565,7 @@ describe("rightPanelStore", () => {
565 ).toEqual({565 ).toEqual({
566 byThreadKey: {},566 byThreadKey: {},
567 threadPanelVisibilityByThreadKey: {567 threadPanelVisibilityByThreadKey: {
568 "env-1:thread-A": { inlineOpen: false, popoverOpen: false },568 "env-1:thread-B": { inlineOpen: true, popoverOpen: false },
569 },569 },
570 });570 });
571 });571 });
...@@ -574,13 +574,13 @@ describe("rightPanelStore", () => {...@@ -574,13 +574,13 @@ describe("rightPanelStore", () => {
574 const store = useRightPanelStore.getState();574 const store = useRightPanelStore.getState();
575575
576 expect(selectThreadPanelOpen(store.threadPanelVisibilityByThreadKey, refA, "inline")).toBe(576 expect(selectThreadPanelOpen(store.threadPanelVisibilityByThreadKey, refA, "inline")).toBe(
577 true,577 false,
578 );578 );
579 expect(selectThreadPanelOpen(store.threadPanelVisibilityByThreadKey, refA, "popover")).toBe(579 expect(selectThreadPanelOpen(store.threadPanelVisibilityByThreadKey, refA, "popover")).toBe(
580 false,580 false,
581 );581 );
582582
583 store.setThreadPanelOpen(refA, "inline", false);583 store.toggleThreadPanel(refA, "inline");
584 store.toggleThreadPanel(refA, "popover");584 store.toggleThreadPanel(refA, "popover");
585585
586 expect(586 expect(
...@@ -588,13 +588,13 @@ describe("rightPanelStore", () => {...@@ -588,13 +588,13 @@ describe("rightPanelStore", () => {
588 useRightPanelStore.getState().threadPanelVisibilityByThreadKey,588 useRightPanelStore.getState().threadPanelVisibilityByThreadKey,
589 refA,589 refA,
590 ),590 ),
591 ).toEqual({ inlineOpen: false, popoverOpen: true });591 ).toEqual({ inlineOpen: true, popoverOpen: true });
592 expect(592 expect(
593 selectThreadPanelVisibility(593 selectThreadPanelVisibility(
594 useRightPanelStore.getState().threadPanelVisibilityByThreadKey,594 useRightPanelStore.getState().threadPanelVisibilityByThreadKey,
595 refB,595 refB,
596 ),596 ),
597 ).toEqual({ inlineOpen: true, popoverOpen: false });597 ).toEqual({ inlineOpen: false, popoverOpen: false });
598 });598 });
599599
600 it("closes the popover atomically when the real right panel opens", () => {600 it("closes the popover atomically when the real right panel opens", () => {
...@@ -606,7 +606,7 @@ describe("rightPanelStore", () => {...@@ -606,7 +606,7 @@ describe("rightPanelStore", () => {
606 useRightPanelStore.getState().threadPanelVisibilityByThreadKey,606 useRightPanelStore.getState().threadPanelVisibilityByThreadKey,
607 refA,607 refA,
608 ),608 ),
609 ).toEqual({ inlineOpen: true, popoverOpen: false });609 ).toEqual({ inlineOpen: false, popoverOpen: false });
610 });610 });
611611
612 it("keeps an open popover visible by promoting it to inline when the real panel closes", () => {612 it("keeps an open popover visible by promoting it to inline when the real panel closes", () => {
apps/web/src/rightPanelStore.ts+8-5
...@@ -209,7 +209,7 @@ const EMPTY_THREAD_STATE: ThreadRightPanelState = {...@@ -209,7 +209,7 @@ const EMPTY_THREAD_STATE: ThreadRightPanelState = {
209};209};
210210
211const DEFAULT_THREAD_PANEL_VISIBILITY: ThreadPanelVisibility = {211const DEFAULT_THREAD_PANEL_VISIBILITY: ThreadPanelVisibility = {
212 inlineOpen: true,212 inlineOpen: false,
213 popoverOpen: false,213 popoverOpen: false,
214};214};
215215
...@@ -340,7 +340,7 @@ const updateThreadPanelVisibilityMap = (...@@ -340,7 +340,7 @@ const updateThreadPanelVisibilityMap = (
340): Record<string, ThreadPanelVisibility> => {340): Record<string, ThreadPanelVisibility> => {
341 const current = byThreadKey[threadKey] ?? DEFAULT_THREAD_PANEL_VISIBILITY;341 const current = byThreadKey[threadKey] ?? DEFAULT_THREAD_PANEL_VISIBILITY;
342 const next = updater(current);342 const next = updater(current);
343 if (next.inlineOpen && !next.popoverOpen) {343 if (next.inlineOpen === DEFAULT_THREAD_PANEL_VISIBILITY.inlineOpen && !next.popoverOpen) {
344 if (!(threadKey in byThreadKey)) return byThreadKey;344 if (!(threadKey in byThreadKey)) return byThreadKey;
345 const { [threadKey]: _removed, ...rest } = byThreadKey;345 const { [threadKey]: _removed, ...rest } = byThreadKey;
346 return rest;346 return rest;
...@@ -589,8 +589,9 @@ export function migratePersistedRightPanelState(persistedState: unknown): {...@@ -589,8 +589,9 @@ export function migratePersistedRightPanelState(persistedState: unknown): {
589 persistedState.threadPanelVisibilityByThreadKey as Record<string, unknown>,589 persistedState.threadPanelVisibilityByThreadKey as Record<string, unknown>,
590 ).flatMap(([threadKey, value]) => {590 ).flatMap(([threadKey, value]) => {
591 if (!value || typeof value !== "object" || !("inlineOpen" in value)) return [];591 if (!value || typeof value !== "object" || !("inlineOpen" in value)) return [];
592 return value.inlineOpen === false592 return typeof value.inlineOpen === "boolean" &&
593 ? [[threadKey, { inlineOpen: false, popoverOpen: false }]]593 value.inlineOpen !== DEFAULT_THREAD_PANEL_VISIBILITY.inlineOpen
594 ? [[threadKey, { inlineOpen: value.inlineOpen, popoverOpen: false }]]
594 : [];595 : [];
595 }),596 }),
596 )597 )
...@@ -1068,7 +1069,9 @@ export const useRightPanelStore = create<RightPanelStoreState>()(...@@ -1068,7 +1069,9 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
1068 threadPanelVisibilityByThreadKey: Object.fromEntries(1069 threadPanelVisibilityByThreadKey: Object.fromEntries(
1069 Object.entries(state.threadPanelVisibilityByThreadKey).flatMap(1070 Object.entries(state.threadPanelVisibilityByThreadKey).flatMap(
1070 ([threadKey, visibility]) =>1071 ([threadKey, visibility]) =>
1071 visibility.inlineOpen ? [] : [[threadKey, { inlineOpen: false, popoverOpen: false }]],1072 visibility.inlineOpen === DEFAULT_THREAD_PANEL_VISIBILITY.inlineOpen
1073 ? []
1074 : [[threadKey, { inlineOpen: visibility.inlineOpen, popoverOpen: false }]],
1072 ),1075 ),
1073 ),1076 ),
1074 }),1077 }),