| author | |
| committer | |
| log | 99e6ff629afe3d401757ea3b304d861b1b64f1e5 |
| tree | e46758fb78500e8a4cd3fa07d2c419d0090e98df |
| parent | 1dced964e7161946a640107004968697e5b258b8 |
| signature | Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover) |
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.539 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 | ]); |
| 55 | new DataView(screenshotPng.buffer).setUint32(16, 390); | 55 | new DataView(screenshotPng.buffer).setUint32(16, 390); |
| 56 | new DataView(screenshotPng.buffer).setUint32(20, 844); | 56 | new DataView(screenshotPng.buffer).setUint32(20, 844); |
| 57 | const screenshotIcns = new Uint8Array(16 + screenshotPng.length); | ||
| 58 | screenshotIcns.set(new TextEncoder().encode("icns")); | ||
| 59 | new DataView(screenshotIcns.buffer).setUint32(4, screenshotIcns.length); | ||
| 60 | screenshotIcns.set(new TextEncoder().encode("ic07"), 8); | ||
| 61 | new DataView(screenshotIcns.buffer).setUint32(12, 8 + screenshotPng.length); | ||
| 62 | screenshotIcns.set(screenshotPng, 16); | ||
| 57 | const screenshotItem = { | 63 | const 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 | ); |
| 818 | 824 | ||
| 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); |
| 1346 | 1424 | ||
| 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 | }); |
| 264 | 264 | ||
| 265 | /** Serves an `.icns` favicon as its embedded PNG, since browsers cannot draw it. */ | 265 | /** Browsers display the embedded PNG in an `.icns` image. */ |
| 266 | const projectFaviconAsset = Effect.fn("AssetAccess.projectFaviconAsset")(function* ( | 266 | const 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 | } |
| 894 | 905 | ||
| 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 | } |
| 907 | 918 | ||
| 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 file | 974 | 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 exactWorkspaceFile | 985 | 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"; |
| 19 | 19 | ||
| 20 | export const DEFAULT_SANS_FONT_STACK = | 20 | export 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'; |
| 22 | 22 | ||
| 23 | // Concrete names first: some engines alias `ui-monospace` to the | 23 | // 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. |
| 25 | export const DEFAULT_CODE_FONT_STACK = | 25 | export 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'; |
| 27 | 27 | ||
| 28 | export const TYPOGRAPHY_ADVANCED_STORAGE_KEY = "t3code:typography-advanced"; | 28 | export const TYPOGRAPHY_ADVANCED_STORAGE_KEY = "t3code:typography-advanced"; |
| 29 | 29 |
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 |
| 25 | import { resolveThreadRouteRef } from "../threadRoutes"; | 25 | import { resolveThreadRouteRef } from "../threadRoutes"; |
| 26 | import { cn, isMacPlatform } from "../lib/utils"; | 26 | import { cn, isMacPlatform } from "../lib/utils"; |
| 27 | import { primaryServerKeybindingsAtom } from "../state/server"; | 27 | import { primaryServerKeybindingsAtom } from "../state/server"; |
| 28 | import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings"; | 28 | import { useLegacySidebarEnabled } from "../hooks/useSettings"; |
| 29 | import { | 29 | import { |
| 30 | PanelAnimationSuppressionProvider, | 30 | PanelAnimationSuppressionProvider, |
| 31 | usePanelAnimationSettings, | 31 | usePanelAnimationSettings, |
| ... | @@ -35,15 +35,15 @@ import LegacyThreadSidebar from "./LegacySidebar"; | ... | @@ -35,15 +35,15 @@ import LegacyThreadSidebar from "./LegacySidebar"; |
| 35 | import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration"; | 35 | import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration"; |
| 36 | import ThreadSidebar from "./Sidebar"; | 36 | import ThreadSidebar from "./Sidebar"; |
| 37 | import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; | 37 | import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; |
| 38 | import { SidebarBrandWidthProbe, SidebarChromeHeader } from "./sidebar/SidebarChrome"; | 38 | import { AppMenu } from "./sidebar/AppMenu"; |
| 39 | import { SidebarChromeHeader } from "./sidebar/SidebarChrome"; | ||
| 39 | import { MainAppLocationTracker } from "./sidebar/mainAppLocation"; | 40 | import { MainAppLocationTracker } from "./sidebar/mainAppLocation"; |
| 40 | import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; | ||
| 41 | import { useProjects } from "../state/entities"; | 41 | import { useProjects } from "../state/entities"; |
| 42 | import { | 42 | import { |
| 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 | <TooltipTrigger | 149 | <TooltipTrigger |
| 151 | render={ | 150 | render={ |
| 152 | <SidebarTrigger | 151 | <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-render | 236 | // 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; |
| 276 | 266 | ||
| ... | @@ -319,7 +309,6 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { | ... | @@ -319,7 +309,6 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { |
| 319 | defaultOpen | 309 | defaultOpen |
| 320 | style={sidebarProviderStyle} | 310 | style={sidebarProviderStyle} |
| 321 | > | 311 | > |
| 322 | <SidebarBrandWidthProbe onWidthChange={setBrandWidth} /> | ||
| 323 | <ProjectProjectionRetention /> | 312 | <ProjectProjectionRetention /> |
| 324 | <Sidebar | 313 | <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"; |
| 57 | import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; | 52 | import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; |
| 58 | import * as Option from "effect/Option"; | ||
| 59 | import { | 53 | import { |
| 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"; |
| 127 | import { useAtomCommand } from "../state/use-atom-command"; | 120 | import { useAtomCommand } from "../state/use-atom-command"; |
| 128 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; | 121 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; |
| 129 | import { useScratchProject } from "../hooks/useScratchProject"; | 122 | import { useScratchProject } from "../hooks/useScratchProject"; |
| 130 | import { useNewProject } from "../hooks/useNewProject"; | ||
| 131 | import { isScratchProject } from "@t3tools/client-runtime/state/projects"; | 123 | import { isScratchProject } from "@t3tools/client-runtime/state/projects"; |
| 132 | import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; | 124 | import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; |
| 133 | import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities"; | 125 | import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities"; |
| ... | @@ -204,7 +196,6 @@ import { | ... | @@ -204,7 +196,6 @@ import { |
| 204 | } from "./CommandPaletteResults"; | 196 | } from "./CommandPaletteResults"; |
| 205 | import { sourceControlIcon } from "~/sourceControlPresentation"; | 197 | import { sourceControlIcon } from "~/sourceControlPresentation"; |
| 206 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; | 198 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; |
| 207 | import { Checkbox } from "./ui/checkbox"; | ||
| 208 | import { ProjectFavicon } from "./ProjectFavicon"; | 199 | import { ProjectFavicon } from "./ProjectFavicon"; |
| 209 | import { ProjectFilePicker } from "./files/ProjectFilePicker"; | 200 | import { ProjectFilePicker } from "./files/ProjectFilePicker"; |
| 210 | import { openLinkPullRequestDialog } from "./pullRequest/LinkPullRequestDialog"; | 201 | import { openLinkPullRequestDialog } from "./pullRequest/LinkPullRequestDialog"; |
| ... | @@ -231,7 +222,6 @@ import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindin | ... | @@ -231,7 +222,6 @@ import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindin |
| 231 | import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "./ui/command"; | 222 | import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "./ui/command"; |
| 232 | import { Button } from "./ui/button"; | 223 | import { Button } from "./ui/button"; |
| 233 | import { Kbd, KbdGroup } from "./ui/kbd"; | 224 | import { Kbd, KbdGroup } from "./ui/kbd"; |
| 234 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; | ||
| 235 | import { stackedThreadToast, toastManager } from "./ui/toast"; | 225 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 236 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | 226 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 237 | import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; | 227 | import { 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 | } |
| 293 | 283 | ||
| 294 | function sourceControlHostIcon( | ||
| 295 | definition: NewProjectPublishTarget["definition"], | ||
| 296 | className: string, | ||
| 297 | ): ReactNode { | ||
| 298 | const Icon = sourceControlIcon(definition); | ||
| 299 | return <Icon className={className} />; | ||
| 300 | } | ||
| 301 | |||
| 302 | function projectFaviconIcon(project: Project): ReactNode { | 284 | function 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 | } |
| 369 | 351 | ||
| 370 | function projectFavicon(project: Project) { | ||
| 371 | return <ProjectFavicon project={project} className="size-4" />; | ||
| 372 | } | ||
| 373 | |||
| 374 | export function CommandPalette({ children }: { children: ReactNode }) { | 352 | export 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 | ); |
| 2510 | 2480 | ||
| 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 under | 2807 | // 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 | ]; | ||
| 3016 | 2889 | ||
| 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 !== null | 3359 | newProjectFlow !== null |
| 3488 | ? highlightedItemValue === null | 3360 | ? highlightedItemValue === null |
| 3489 | ? "Create" | 3361 | ? "Create" |
| 3490 | : highlightedItemValue === newProjectPublishToggleValue | 3362 | : "Select" |
| 3491 | ? "Toggle" | ||
| 3492 | : "Select" | ||
| 3493 | : addProjectCloneFlow?.step === "repository" | 3363 | : addProjectCloneFlow?.step === "repository" |
| 3494 | ? (remoteProjectButtonLabel ?? "Continue") | 3364 | ? (remoteProjectButtonLabel ?? "Continue") |
| 3495 | : !canSubmitBrowsePath || hasHighlightedBrowseItem | 3365 | : !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 | }); |
| 825 | 825 | ||
| 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 | }); |
| 843 | 844 |
apps/web/src/components/GitActionsControl.logic.ts+5-4| ... | @@ -23,7 +23,7 @@ export interface GitActionMenuItem { | ... | @@ -23,7 +23,7 @@ export interface GitActionMenuItem { |
| 23 | export interface GitQuickAction { | 23 | export 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"; |
| 3 | import { | 3 | import { |
| 4 | AuthOrchestrationOperateScope, | 4 | AuthOrchestrationOperateScope, |
| 5 | AuthSourceControlWriteScope, | 5 | AuthSourceControlWriteScope, |
| 6 | SourceControlProviderKind, | ||
| 7 | type ScopedThreadRef, | 6 | type ScopedThreadRef, |
| 8 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 9 | import { | 8 | import { |
| ... | @@ -13,32 +12,27 @@ import { | ... | @@ -13,32 +12,27 @@ import { |
| 13 | import type { | 12 | import 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"; |
| 22 | import { useNavigate } from "@tanstack/react-router"; | 17 | import { |
| 23 | import * as Option from "effect/Option"; | 18 | type MouseEvent, |
| 24 | import { type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; | 19 | useCallback, |
| 25 | import { flushSync } from "react-dom"; | 20 | useEffect, |
| 21 | useEffectEvent, | ||
| 22 | useMemo, | ||
| 23 | useRef, | ||
| 24 | useState, | ||
| 25 | } from "react"; | ||
| 26 | import { | 26 | import { |
| 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"; |
| 38 | import { Radio as RadioPrimitive } from "@base-ui/react/radio"; | ||
| 39 | import { RadioGroup } from "~/components/ui/radio-group"; | ||
| 40 | import { Spinner } from "~/components/ui/spinner"; | 35 | import { Spinner } from "~/components/ui/spinner"; |
| 41 | import { Toggle, ToggleGroup } from "~/components/ui/toggle-group"; | ||
| 42 | import { cn } from "~/lib/utils"; | 36 | import { cn } from "~/lib/utils"; |
| 43 | import { useOpenPrLink } from "~/lib/openPullRequestLink"; | 37 | import { useOpenPrLink } from "~/lib/openPullRequestLink"; |
| 44 | import { | 38 | import { |
| ... | @@ -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"; |
| 62 | import { WizardPopup, WizardHeader, WizardSteps, WizardPanel, WizardFooter } from "./ui/wizard"; | ||
| 63 | import { StartTruncatedPath } from "./StartTruncatedPath"; | 56 | import { StartTruncatedPath } from "./StartTruncatedPath"; |
| 64 | import { Button } from "~/components/ui/button"; | 57 | import { Button } from "~/components/ui/button"; |
| 65 | import { Checkbox } from "~/components/ui/checkbox"; | 58 | import { 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"; |
| 75 | import { Group, GroupSeparator } from "~/components/ui/group"; | 68 | import { Group, GroupSeparator } from "~/components/ui/group"; |
| 76 | import { Input } from "~/components/ui/input"; | ||
| 77 | import { | 69 | import { |
| 78 | Menu, | 70 | Menu, |
| 79 | MenuItem, | 71 | MenuItem, |
| ... | @@ -93,15 +85,12 @@ import { useOpenInPreferredEditor } from "~/editorPreferences"; | ... | @@ -93,15 +85,12 @@ import { useOpenInPreferredEditor } from "~/editorPreferences"; |
| 93 | import { | 85 | import { |
| 94 | useGitStackedAction, | 86 | useGitStackedAction, |
| 95 | useSourceControlActionRunning, | 87 | useSourceControlActionRunning, |
| 96 | useSourceControlPublishRepositoryAction, | ||
| 97 | useVcsInitAction, | ||
| 98 | useVcsPullAction, | 88 | useVcsPullAction, |
| 99 | } from "~/lib/sourceControlActions"; | 89 | } from "~/lib/sourceControlActions"; |
| 100 | import { useThreadProjection, useThreadShell } from "~/state/entities"; | 90 | import { useThreadProjection, useThreadShell } from "~/state/entities"; |
| 101 | import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; | 91 | import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; |
| 102 | import { useEnvironmentQuery } from "~/state/query"; | 92 | import { useEnvironmentQuery } from "~/state/query"; |
| 103 | import { serverEnvironment } from "~/state/server"; | 93 | import { serverEnvironment } from "~/state/server"; |
| 104 | import { sourceControlEnvironment } from "~/state/sourceControl"; | ||
| 105 | import { threadEnvironment } from "~/state/threads"; | 94 | import { threadEnvironment } from "~/state/threads"; |
| 106 | import { useAtomCommand } from "~/state/use-atom-command"; | 95 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 107 | import { vcsActionManager, vcsEnvironment } from "~/state/vcs"; | 96 | import { 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"; |
| 118 | import { getSourceControlPresentation, sourceControlIcon } from "~/sourceControlPresentation"; | 107 | import { getSourceControlPresentation } from "~/sourceControlPresentation"; |
| 119 | import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; | ||
| 120 | import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; | 108 | import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; |
| 121 | import { useOpenLink } from "~/browser/useOpenLink"; | ||
| 122 | 109 | ||
| 123 | interface GitActionsControlProps { | 110 | interface GitActionsControlProps { |
| 124 | presentation?: "toolbar" | "menu"; | 111 | presentation?: "toolbar" | "menu"; |
| ... | @@ -182,49 +169,6 @@ function requestVcsStatusRefresh( | ... | @@ -182,49 +169,6 @@ function requestVcsStatusRefresh( |
| 182 | const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const; | 169 | const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const; |
| 183 | 170 | ||
| 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. */ |
| 185 | const 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 | |||
| 194 | function publishProviderOption(provider: SourceControlProviderKind) { | ||
| 195 | return ( | ||
| 196 | PUBLISH_PROVIDER_OPTIONS.find((option) => option.value === provider) ?? | ||
| 197 | PUBLISH_PROVIDER_OPTIONS[0]! | ||
| 198 | ); | ||
| 199 | } | ||
| 200 | |||
| 201 | function 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 | |||
| 228 | function getMenuActionDisabledReason({ | 172 | function 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 | } |
| 460 | 403 | ||
| 461 | interface 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 | |||
| 470 | function 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 | |||
| 994 | export default function GitActionsControl({ | 404 | export 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; |
| 1155 | 564 | ||
| 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 | }; |
| 1471 | 879 | ||
| 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 | ); |
| 1597 | 1001 | ||
| 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 | ); |
| 1703 | 1075 | ||
| 1704 | if (!gitCwd) return null; | 1076 | if (!gitCwd || !isRepo) return null; |
| 1705 | 1077 | ||
| 1706 | return ( | 1078 | return ( |
| 1707 | <> | 1079 | <> |
| 1708 | {presentation === "menu" ? ( | 1080 | {presentation === "menu" ? ( |
| 1709 | !isRepo ? ( | 1081 | <> |
| 1710 | <MenuItem | 1082 | <MenuItem |
| 1711 | density={presentation === "menu" ? "touch" : "default"} | 1083 | density={presentation === "menu" ? "touch" : "default"} |
| 1712 | 1084 | ||
| 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 | <MenuItem | 1097 | )} |
| 1724 | density={presentation === "menu" ? "touch" : "default"} | 1098 | <MenuSub |
| 1725 | 1099 | onOpenChange={(open) => { | |
| 1726 | disabled={isGitActionRunning || quickAction.disabled || !!quickActionDisabledReason} | 1100 | if (open) requestVcsStatusRefresh(refreshVcsStatus, activeEnvironmentId, gitCwd); |
| 1727 | onClick={runQuickAction} | 1101 | }} |
| 1728 | > | 1102 | > |
| 1729 | <GitQuickActionIcon | 1103 | <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 | <ActionGroup | 1111 | <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> |
| 2104 | 1446 | ||
| 2105 | <PublishRepositoryDialog | ||
| 2106 | open={isPublishDialogOpen} | ||
| 2107 | onOpenChange={setIsPublishDialogOpen} | ||
| 2108 | environmentId={activeEnvironmentId} | ||
| 2109 | threadRef={activeThreadRef} | ||
| 2110 | gitCwd={gitCwd} | ||
| 2111 | /> | ||
| 2112 | |||
| 2113 | <Dialog | 1447 | <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 @@ |
| 1 | import { performDesktopUpdate } from "./desktopUpdate.actions"; | ||
| 1 | import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; | 2 | import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; |
| 2 | import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; | 3 | import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; |
| 3 | import { Spinner } from "~/components/ui/spinner"; | 4 | import { Spinner } from "~/components/ui/spinner"; |
| ... | @@ -138,14 +139,10 @@ import { formatRelativeTimeLabel } from "../timestampFormat"; | ... | @@ -138,14 +139,10 @@ import { formatRelativeTimeLabel } from "../timestampFormat"; |
| 138 | import { Kbd } from "./ui/kbd"; | 139 | import { Kbd } from "./ui/kbd"; |
| 139 | import { | 140 | import { |
| 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"; |
| 148 | import { showDesktopUpdateDownloadedToast } from "./desktopUpdate.toast"; | ||
| 149 | import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; | 146 | import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; |
| 150 | import { Button } from "./ui/button"; | 147 | import { Button } from "./ui/button"; |
| 151 | import { | 148 | import { |
| ... | @@ -3746,83 +3743,9 @@ export default function LegacySidebar() { | ... | @@ -3746,83 +3743,9 @@ export default function LegacySidebar() { |
| 3746 | 3743 | ||
| 3747 | setDesktopUpdateActionPending(true); | 3744 | setDesktopUpdateActionPending(true); |
| 3748 | 3745 | ||
| 3749 | if (desktopUpdateButtonAction === "download") { | 3746 | void performDesktopUpdate(bridge, desktopUpdateState).finally(() => |
| 3750 | void bridge | 3747 | 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 | ||
| 2 | import { | ||
| 3 | EnvironmentId, | ||
| 4 | ProviderDriverKind, | ||
| 5 | ProviderInstanceId, | ||
| 6 | type ServerProvider, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import { AsyncResult, Atom } from "effect/reactivity"; | ||
| 9 | import { act } from "react"; | ||
| 10 | import { createRoot, type Root } from "react-dom/client"; | ||
| 11 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; | ||
| 12 | |||
| 13 | import { appAtomRegistry, AppAtomRegistryProvider } from "~/rpc/atomRegistry"; | ||
| 14 | |||
| 15 | const 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 | })); | ||
| 25 | vi.mock("~/state/environments", () => ({ | ||
| 26 | useEnvironments: () => ({ environments: mocks.environments }), | ||
| 27 | })); | ||
| 28 | vi.mock("~/state/server", () => ({ | ||
| 29 | serverEnvironment: { updateProvider: { permissionAtom: mocks.permissionAtom } }, | ||
| 30 | })); | ||
| 31 | vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => mocks.update })); | ||
| 32 | vi.mock("./ui/toast", () => ({ toastManager: { add: vi.fn() }, stackedThreadToast: vi.fn() })); | ||
| 33 | |||
| 34 | import { useProviderUpdates } from "./ProviderUpdatesAction"; | ||
| 35 | |||
| 36 | const home = EnvironmentId.make("home"); | ||
| 37 | const work = EnvironmentId.make("work"); | ||
| 38 | const homeAllowed = Atom.make(false).pipe(Atom.keepAlive); | ||
| 39 | const workAllowed = Atom.make(false).pipe(Atom.keepAlive); | ||
| 40 | let root: Root; | ||
| 41 | let container: HTMLDivElement; | ||
| 42 | |||
| 43 | function 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 | |||
| 50 | beforeEach(() => { | ||
| 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 | |||
| 91 | afterEach(async () => { | ||
| 92 | await act(async () => root.unmount()); | ||
| 93 | container.remove(); | ||
| 94 | }); | ||
| 95 | |||
| 96 | it("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 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { Atom } from "effect/reactivity"; | ||
| 1 | import { PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts"; | 3 | import { PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts"; |
| 2 | import { useMemo, useRef, useState } from "react"; | 4 | import { useMemo, useRef, useState } from "react"; |
| 3 | 5 | ||
| ... | @@ -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 all | 22 | * Each server queues updates that share an installer, so sending them all |
| 21 | * together is safe. The server still checks permissions: a session that | 23 | * together is safe. Only permitted machines are offered; the server checks |
| 22 | * cannot operate a machine gets a failure line for it. The returned promise | 24 | * permission again at execution. The returned promise |
| 23 | * settles with every result when every update has finished. `quiet` skips the | 25 | * 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 > 0 | 43 | } |
| 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); | ||
| 47 | 55 | ||
| 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 | } |
| 48 | 48 | ||
| 49 | /** Stage-channel header art; palettes mirror the per-channel app icons in `assets/`. */ | ||
| 50 | export 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 | |||
| 61 | export function StageBackdropArt({ variant }: { variant: SidebarStageBackdropVariant }) { | 49 | export 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 @@ | ||
| 1 | import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts"; | ||
| 2 | import { ensureLocalApi } from "../localApi"; | ||
| 3 | import { stackedThreadToast, toastManager } from "./ui/toast"; | ||
| 4 | import { | ||
| 5 | getDesktopUpdateActionError, | ||
| 6 | getDesktopUpdateInstallConfirmationMessage, | ||
| 7 | resolveDesktopUpdateButtonAction, | ||
| 8 | shouldToastDesktopUpdateActionResult, | ||
| 9 | } from "./desktopUpdate.logic"; | ||
| 10 | import { showDesktopUpdateDownloadedToast } from "./desktopUpdate.toast"; | ||
| 11 | import type { ReportLocalUpdateError } from "./sidebar/useUpdateEverything"; | ||
| 12 | |||
| 13 | export 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. */ | ||
| 18 | export 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. */ | ||
| 48 | export 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 | |||
| 76 | export 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 | })); |
| 30 | 30 | ||
| 31 | vi.mock("~/hooks/useTheme", () => ({ | 31 | vi.mock("~/assets/assetUrls", () => ({ |
| 32 | useTheme: () => ({ resolvedTheme: "dark" }), | 32 | useAssetUrlState: () => ({ _tag: "Loading" }), |
| 33 | })); | 33 | })); |
| 34 | 34 | ||
| 35 | vi.mock("../files/projectFilesQueryState", () => ({ | 35 | vi.mock("../files/projectFilesQueryState", () => ({ |
apps/web/src/components/settings/ProjectFaviconPickerDialog.tsx+53-8| ... | @@ -1,15 +1,15 @@ | ... | @@ -1,15 +1,15 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import type { EnvironmentId } from "@t3tools/contracts"; | 2 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 3 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; | 3 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; |
| 4 | import { ImageIcon } from "lucide-react"; | ||
| 4 | import { useMemo, useState } from "react"; | 5 | import { useMemo, useState } from "react"; |
| 5 | 6 | ||
| 7 | import { useAssetUrlState } from "~/assets/assetUrls"; | ||
| 6 | import { primaryServerKeybindingsAtom } from "~/state/server"; | 8 | import { primaryServerKeybindingsAtom } from "~/state/server"; |
| 7 | import { useTheme } from "~/hooks/useTheme"; | ||
| 8 | import { getLocalFileManagerName, isWindowsPlatform } from "~/lib/utils"; | 9 | import { getLocalFileManagerName, isWindowsPlatform } from "~/lib/utils"; |
| 9 | import { CommandPaletteContent } from "../CommandPaletteContent"; | 10 | import { CommandPaletteContent } from "../CommandPaletteContent"; |
| 10 | import type { CommandPaletteActionItem } from "../CommandPalette.logic"; | 11 | import type { CommandPaletteActionItem } from "../CommandPalette.logic"; |
| 11 | import { CommandPaletteResults } from "../CommandPaletteResults"; | 12 | import { CommandPaletteResults } from "../CommandPaletteResults"; |
| 12 | import { PierreEntryIcon } from "../chat/PierreEntryIcon"; | ||
| 13 | import { | 13 | import { |
| 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 | } |
| 29 | 29 | ||
| 30 | function 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 | |||
| 30 | export function ProjectFaviconPickerDialog(props: { | 75 | export 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 | ); |
| 68 | 113 | ||
| 69 | return ( | 114 | return ( |
apps/web/src/components/settings/SettingsPanels.tsx+37-95| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { performDesktopUpdate } from "../desktopUpdate.actions"; | ||
| 1 | import { SettingsGroup } from "./SettingsGroup"; | 2 | import { SettingsGroup } from "./SettingsGroup"; |
| 2 | import { useScopedSettingsWriteAllowed } from "./useScopedSettings"; | 3 | import { useScopedSettingsWriteAllowed } from "./useScopedSettings"; |
| 3 | import { Spinner } from "~/components/ui/spinner"; | 4 | import { 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"; |
| 6 | import { ArchiveIcon, ArchiveX, CheckIcon, ChevronRightIcon, SettingsIcon } from "lucide-react"; | 7 | import { ArchiveIcon, ArchiveX, CheckIcon, ChevronRightIcon, SettingsIcon } from "lucide-react"; |
| 7 | import { Link, useNavigate } from "@tanstack/react-router"; | 8 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 8 | import type { CSSProperties, ReactNode } from "react"; | 9 | import type { CSSProperties, ReactNode } from "react"; |
| 9 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 10 | import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; |
| 10 | import { | 11 | import { |
| 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"; |
| 59 | import { | 60 | import { |
| 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 | } |
| 271 | 271 | ||
| 272 | function AboutVersionTitle() { | 272 | function 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; |
| 326 | 329 | ||
| 327 | const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none"; | 330 | if (!updateState || isUpdateActionPending) return; |
| 328 | 331 | 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]); |
| 407 | 338 | ||
| 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); |
| 414 | 345 | 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 | } |
| 1527 | 1460 | ||
| 1461 | function 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 | |||
| 1528 | function useFontDefaultFamilies() { | 1471 | function useFontDefaultFamilies() { |
| 1529 | const settings = useScopedSettings(); | 1472 | const settings = useScopedSettings(); |
| 1530 | // An unset preference shows the font it resolves to on this machine; the | 1473 | const sans = useSyncExternalStore( |
| 1531 | // default stacks are the platform's own faces, so the name is probed, not | 1474 | 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 | } |
| 1547 | 1489 |
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"; |
| 12 | import { | 11 | import { 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"; | ||
| 28 | import { useLocation, useNavigate } from "@tanstack/react-router"; | 12 | import { useLocation, useNavigate } from "@tanstack/react-router"; |
| 29 | 13 | ||
| 30 | import { Button } from "../ui/button"; | 14 | import { Button } from "../ui/button"; |
| ... | @@ -43,25 +27,13 @@ import { SidebarUtilityMenu } from "../sidebar/SidebarChrome"; | ... | @@ -43,25 +27,13 @@ import { SidebarUtilityMenu } from "../sidebar/SidebarChrome"; |
| 43 | import { scrollToSettingsTarget } from "./settingsLayout"; | 27 | import { scrollToSettingsTarget } from "./settingsLayout"; |
| 44 | import { | 28 | import { |
| 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"; |
| 51 | import { useAvailableSettingsSearchItems } from "./useAvailableSettingsSearchItems"; | 34 | import { useAvailableSettingsSearchItems } from "./useAvailableSettingsSearchItems"; |
| 52 | import { validateSettingsScopeSearch } from "./settingsScope"; | 35 | import { validateSettingsScopeSearch } from "./settingsScope"; |
| 53 | 36 | import { getSettingsNavigationItems, SETTINGS_SECTION_ICONS } from "./settingsNavigation"; | |
| 54 | const 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 | ]); | ||
| 65 | 37 | ||
| 66 | const T3ConnectSidebarSignIn = lazy(() => | 38 | const 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 | ); |
| 76 | 48 | ||
| 77 | const 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 | |||
| 95 | const 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 | |||
| 107 | function SettingsSectionIcon({ to }: { to: SettingsPath }) { | 49 | function 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 @@ | ||
| 1 | import type { ComponentType } from "react"; | ||
| 2 | import { | ||
| 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 | |||
| 17 | import { | ||
| 18 | isSettingsOverviewVisible, | ||
| 19 | SETTINGS_SECTION_LABELS, | ||
| 20 | type SettingsPath, | ||
| 21 | } from "./settingsSearch"; | ||
| 22 | import type { SettingsScopeSearch } from "./settingsScope"; | ||
| 23 | |||
| 24 | const 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 | |||
| 36 | export 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 | |||
| 54 | const 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 | |||
| 66 | export 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 @@ | ||
| 1 | import { Link, useLocation } from "@tanstack/react-router"; | ||
| 2 | import { useId, useState } from "react"; | ||
| 3 | |||
| 4 | import { APP_BASE_NAME, APP_VERSION } from "../../branding"; | ||
| 5 | import { useDesktopUpdateState } from "../../state/desktopUpdate"; | ||
| 6 | import { KnifeMark } from "../KnifeMark"; | ||
| 7 | import { useEnvironmentStageLabel } from "../SidebarStageBackdrop"; | ||
| 8 | import { getSettingsNavigationItems } from "../settings/settingsNavigation"; | ||
| 9 | import { validateSettingsScopeSearch } from "../settings/settingsScope"; | ||
| 10 | import { Button } from "../ui/button"; | ||
| 11 | import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover"; | ||
| 12 | import { Separator } from "../ui/separator"; | ||
| 13 | import { useSidebar } from "../ui/sidebar"; | ||
| 14 | import { AppMenuProviderUpdates } from "./AppMenuProviderUpdates"; | ||
| 15 | import { AppMenuUpdates } from "./SidebarUpdatePill"; | ||
| 16 | |||
| 17 | export 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 | ||
| 2 | import { act } from "react"; | ||
| 3 | import { createRoot, type Root } from "react-dom/client"; | ||
| 4 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; | ||
| 5 | import { | ||
| 6 | createMemoryHistory, | ||
| 7 | createRootRoute, | ||
| 8 | createRoute, | ||
| 9 | createRouter, | ||
| 10 | RouterContextProvider, | ||
| 11 | type AnyRouter, | ||
| 12 | } from "@tanstack/react-router"; | ||
| 13 | import * as Cause from "effect/Cause"; | ||
| 14 | import { AsyncResult } from "effect/reactivity"; | ||
| 15 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; | ||
| 16 | |||
| 17 | const 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 | |||
| 28 | vi.mock("~/state/environments", () => ({ useEnvironments: () => state })); | ||
| 29 | vi.mock("~/state/server", () => ({ | ||
| 30 | serverEnvironment: { updateProvider: { permissionAtom: (id: string) => id } }, | ||
| 31 | })); | ||
| 32 | vi.mock("@effect/atom-react", () => ({ | ||
| 33 | useAtomValue: (id: string) => state.permitted.has(id), | ||
| 34 | })); | ||
| 35 | vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => state.updateProvider })); | ||
| 36 | vi.mock("../chat/ProviderInstanceIcon", () => ({ ProviderInstanceIcon: () => null })); | ||
| 37 | vi.mock("../ui/spinner", () => ({ Spinner: () => null })); | ||
| 38 | vi.mock("../settings/providerDriverMeta", () => ({ providerClients: new Map() })); | ||
| 39 | |||
| 40 | import { AppMenuProviderUpdates } from "./AppMenuProviderUpdates"; | ||
| 41 | |||
| 42 | let root: Root; | ||
| 43 | let container: HTMLDivElement; | ||
| 44 | let router: AnyRouter; | ||
| 45 | |||
| 46 | function 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 | |||
| 72 | function 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 | |||
| 82 | function 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 | |||
| 88 | async function render() { | ||
| 89 | await act(() => | ||
| 90 | root.render( | ||
| 91 | <RouterContextProvider router={router}> | ||
| 92 | <AppMenuProviderUpdates /> | ||
| 93 | </RouterContextProvider>, | ||
| 94 | ), | ||
| 95 | ); | ||
| 96 | } | ||
| 97 | |||
| 98 | beforeEach(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 | |||
| 118 | afterEach(async () => { | ||
| 119 | await act(() => root.unmount()); | ||
| 120 | container.remove(); | ||
| 121 | vi.unstubAllGlobals(); | ||
| 122 | }); | ||
| 123 | |||
| 124 | it("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 | |||
| 150 | it("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 | |||
| 167 | it("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 | |||
| 194 | it("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 | |||
| 208 | it("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 | |||
| 227 | it("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 | |||
| 248 | it("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 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | isAtomCommandInterrupted, | ||
| 4 | squashAtomCommandFailure, | ||
| 5 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 6 | import { | ||
| 7 | PROVIDER_DISPLAY_NAMES, | ||
| 8 | type EnvironmentId, | ||
| 9 | type ServerProvider, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | import { Link } from "@tanstack/react-router"; | ||
| 12 | import { CheckIcon } from "lucide-react"; | ||
| 13 | import { useRef, useState } from "react"; | ||
| 14 | |||
| 15 | import { useEnvironments } from "~/state/environments"; | ||
| 16 | import { serverEnvironment } from "~/state/server"; | ||
| 17 | import { useAtomCommand } from "~/state/use-atom-command"; | ||
| 18 | import { | ||
| 19 | canOneClickUpdateProviderCandidate, | ||
| 20 | collectProviderUpdateCandidates, | ||
| 21 | isProviderUpdateActive, | ||
| 22 | } from "../ProviderUpdateLaunchNotification.logic"; | ||
| 23 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; | ||
| 24 | import { providerClients } from "../settings/providerDriverMeta"; | ||
| 25 | import { Button } from "../ui/button"; | ||
| 26 | import { Spinner } from "../ui/spinner"; | ||
| 27 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | ||
| 28 | |||
| 29 | function 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 | |||
| 206 | export 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 @@ |
| 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; | 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; |
| 2 | import { CheckIcon, CircleArrowUpIcon, DownloadIcon, RotateCwIcon } from "lucide-react"; | 2 | import { CheckIcon, DownloadIcon, RotateCwIcon } from "lucide-react"; |
| 3 | import type { AnimationEventHandler } from "react"; | ||
| 4 | 3 | ||
| 5 | const DOWNLOAD_PROGRESS_RADIUS = 14; | 4 | const DOWNLOAD_PROGRESS_RADIUS = 14; |
| 6 | const DOWNLOAD_PROGRESS_CIRCUMFERENCE = 2 * Math.PI * DOWNLOAD_PROGRESS_RADIUS; | 5 | const 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"; | ||
| 17 | 13 | ||
| 18 | function normalizeDesktopUpdateDownloadPercent(percent: number | null): number { | 14 | function 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 | } |
| 22 | 18 | ||
| 23 | export 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 | |||
| 35 | export function shouldContinueDesktopUpdateCheckAnimation({ | ||
| 36 | isChecking, | ||
| 37 | prefersReducedMotion, | ||
| 38 | }: { | ||
| 39 | readonly isChecking: boolean; | ||
| 40 | readonly prefersReducedMotion: boolean; | ||
| 41 | }): boolean { | ||
| 42 | return isChecking && !prefersReducedMotion; | ||
| 43 | } | ||
| 44 | |||
| 45 | function DesktopUpdateAvailableIcon() { | 19 | function 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() { |
| 105 | export function DesktopUpdateStatusIcon({ | 79 | export 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" />; | ||
| 122 | 93 | ||
| 123 | return ( | 94 | return ( |
| 124 | <RefreshIcon | 95 | <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 @@ |
| 1 | import { ArrowLeftIcon, ChartNoAxesColumnIcon, SettingsIcon } from "lucide-react"; | 1 | import { ArrowLeftIcon, ChartNoAxesColumnIcon } from "lucide-react"; |
| 2 | import type { ReactNode } from "react"; | 2 | import type { ReactNode } from "react"; |
| 3 | import { memo, useCallback } from "react"; | 3 | import { memo, useCallback } from "react"; |
| 4 | import { Link, useLocation, useNavigate } from "@tanstack/react-router"; | 4 | import { useLocation, useNavigate } from "@tanstack/react-router"; |
| 5 | 5 | ||
| 6 | import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; | 6 | import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; |
| 7 | import { cn } from "../../lib/utils"; | 7 | import { cn } from "../../lib/utils"; |
| 8 | import { usePullRequestsSupported } from "../../state/environments"; | 8 | import { usePullRequestsSupported } from "../../state/environments"; |
| 9 | import { T3Wordmark } from "../T3Wordmark"; | ||
| 10 | import { | 9 | import { |
| 11 | resolveEnvironmentIdentificationPillLabel, | 10 | resolveEnvironmentIdentificationPillLabel, |
| 12 | resolveSidebarStageBackdropVariant, | ||
| 13 | SidebarStageBackdrop, | ||
| 14 | useEnvironmentStageLabel, | 11 | useEnvironmentStageLabel, |
| 15 | } from "../SidebarStageBackdrop"; | 12 | } from "../SidebarStageBackdrop"; |
| 16 | import { Badge } from "../ui/badge"; | 13 | import { 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"; |
| 26 | import { readPullRequestListPreferences } from "../pullRequest/pullRequestListPreferences"; | 23 | import { readPullRequestListPreferences } from "../pullRequest/pullRequestListPreferences"; |
| 27 | import { isSidebarUtilityPage, useNavigateToMainApp } from "./mainAppLocation"; | 24 | import { isSidebarUtilityPage, useNavigateToMainApp } from "./mainAppLocation"; |
| 28 | import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice"; | 25 | import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice"; |
| 29 | import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill"; | 26 | import { AppMenu } from "./AppMenu"; |
| 30 | import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill"; | ||
| 31 | import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; | 27 | import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; |
| 32 | import { observeResize } from "~/lib/observeResize"; | ||
| 33 | 28 | ||
| 34 | export const SidebarChromeHeader = memo(function SidebarChromeHeader({ | 29 | export 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 | <SidebarTrigger | 51 | <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 | }); |
| 83 | 66 | ||
| 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. | ||
| 86 | export 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 | |||
| 113 | function 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 | |||
| 128 | function 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 | |||
| 146 | function SidebarUtilityItem({ | 67 | function 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 | <SidebarUtilityItem | 136 | <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 @@ | ||
| 1 | import { Spinner } from "~/components/ui/spinner"; | ||
| 2 | import { useNavigate } from "@tanstack/react-router"; | ||
| 3 | import { useAtomValue } from "@effect/atom-react"; | ||
| 4 | import type { ServerProvider } from "@t3tools/contracts"; | ||
| 5 | import { CircleCheckIcon, DownloadIcon, TriangleAlertIcon, XIcon } from "lucide-react"; | ||
| 6 | import { useCallback, useEffect, useState, type CSSProperties } from "react"; | ||
| 7 | |||
| 8 | import { primaryServerProvidersAtom } from "../../state/server"; | ||
| 9 | import { | ||
| 10 | getProviderUpdateSidebarPillView, | ||
| 11 | type ProviderUpdateSidebarPillView, | ||
| 12 | } from "../ProviderUpdateLaunchNotification.logic"; | ||
| 13 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | ||
| 14 | |||
| 15 | const 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 | |||
| 26 | const PROVIDER_UPDATE_PILL_PROGRESS_STYLES = { | ||
| 27 | success: "bg-foreground/8", | ||
| 28 | warning: "bg-warning/14", | ||
| 29 | error: "bg-destructive/14", | ||
| 30 | } as const; | ||
| 31 | |||
| 32 | function 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 | |||
| 42 | export 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 | ||
| 1 | import type { DesktopUpdateState } from "@t3tools/contracts"; | 2 | import type { DesktopUpdateState } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it, vi } from "vite-plus/test"; | 3 | import { act } from "react"; |
| 4 | import { createRoot, type Root } from "react-dom/client"; | ||
| 5 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 3 | 6 | ||
| 4 | import { | 7 | const 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 | })); | ||
| 9 | 14 | ||
| 10 | const nightlyState: DesktopUpdateState = { | 15 | vi.mock("../../env", () => ({ isElectron: true })); |
| 16 | vi.mock("../../hooks/useMediaQuery", () => ({ useMediaQuery: () => true })); | ||
| 17 | vi.mock("../../state/desktopUpdate", () => ({ useDesktopUpdateState: () => test.state })); | ||
| 18 | vi.mock("../../state/server", () => ({ serverEnvironment: {} })); | ||
| 19 | vi.mock("~/state/threads", () => ({ environmentThreadShells: {} })); | ||
| 20 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => 0 })); | ||
| 21 | vi.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 | })); | ||
| 32 | vi.mock("./updateEverythingRun", () => ({ useUpdateEverythingRun: () => null })); | ||
| 33 | vi.mock("../ui/toast", () => ({ | ||
| 34 | toastManager: { add: test.toast }, | ||
| 35 | stackedThreadToast: (toast: unknown) => toast, | ||
| 36 | })); | ||
| 37 | vi.mock("../desktopUpdate.toast", () => ({ showDesktopUpdateDownloadedToast: vi.fn() })); | ||
| 38 | vi.mock("../../localApi", () => ({ ensureLocalApi: vi.fn() })); | ||
| 39 | |||
| 40 | import { ensureLocalApi } from "../../localApi"; | ||
| 41 | import { AppMenuUpdates } from "./SidebarUpdatePill"; | ||
| 42 | |||
| 43 | const 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 | }; |
| 28 | 61 | ||
| 29 | describe("sidebar update release notes popover", () => { | 62 | let root: Root; |
| 30 | it("uses the popover only for visible nightly release notes", () => { | 63 | let container: HTMLDivElement; |
| 31 | expect(shouldUseSidebarUpdateReleaseNotesPopover(true, nightlyState)).toBe(true); | 64 | |
| 32 | expect(shouldUseSidebarUpdateReleaseNotesPopover(false, nightlyState)).toBe(false); | 65 | async 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, { | 73 | beforeEach(() => { |
| 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 | |||
| 86 | afterEach(async () => { | ||
| 87 | await act(async () => root.unmount()); | ||
| 88 | container.remove(); | ||
| 89 | Reflect.deleteProperty(window, "desktopBridge"); | ||
| 90 | vi.unstubAllGlobals(); | ||
| 91 | }); | ||
| 92 | |||
| 93 | describe("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 | }); |
| 46 | 101 | ||
| 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"); | ||
| 50 | 106 | ||
| 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 | }); | ||
| 59 | 112 | ||
| 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 | }); |
| 63 | 124 | ||
| 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"); |
| 68 | 129 | ||
| 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"); | ||
| 70 | 159 | ||
| 71 | expect(open).toHaveBeenCalledWith("nightly-release-notes"); | 160 | expect(downloadUpdate).toHaveBeenCalledOnce(); |
| 72 | expect(preventDefault).not.toHaveBeenCalled(); | 161 | expect(installUpdate).not.toHaveBeenCalled(); |
| 73 | }); | 162 | }); |
| 74 | 163 | ||
| 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 | }); | ||
| 77 | 183 | ||
| 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 | ); | ||
| 83 | 211 | ||
| 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 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts"; | 2 | import type { DesktopUpdateState } from "@t3tools/contracts"; |
| 3 | import { Atom } from "effect/reactivity"; | 3 | import { Atom } from "effect/reactivity"; |
| 4 | import { TriangleAlertIcon } from "lucide-react"; | 4 | import { TriangleAlertIcon } from "lucide-react"; |
| 5 | import { | 5 | import { useMemo, useRef, useState } from "react"; |
| 6 | type ComponentProps, | 6 | |
| 7 | useCallback, | ||
| 8 | useEffect, | ||
| 9 | useId, | ||
| 10 | useMemo, | ||
| 11 | useRef, | ||
| 12 | useState, | ||
| 13 | } from "react"; | ||
| 14 | import { flushSync } from "react-dom"; | ||
| 15 | import { isElectron } from "../../env"; | 7 | import { isElectron } from "../../env"; |
| 16 | import { useMediaQuery } from "../../hooks/useMediaQuery"; | 8 | import { useMediaQuery } from "../../hooks/useMediaQuery"; |
| 17 | import { cn } from "../../lib/utils"; | ||
| 18 | import { ensureLocalApi } from "../../localApi"; | ||
| 19 | import { useDesktopUpdateState } from "../../state/desktopUpdate"; | 9 | import { useDesktopUpdateState } from "../../state/desktopUpdate"; |
| 20 | import { serverEnvironment } from "../../state/server"; | 10 | import { serverEnvironment } from "../../state/server"; |
| 21 | import { stackedThreadToast, toastManager } from "../ui/toast"; | 11 | import { Button } from "../ui/button"; |
| 22 | import { | 12 | import { |
| 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"; |
| 34 | import { showDesktopUpdateDownloadedToast } from "../desktopUpdate.toast"; | ||
| 35 | import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; | ||
| 36 | import { Popover, PopoverCreateHandle, PopoverPopup, PopoverTrigger } from "../ui/popover"; | ||
| 37 | import { SidebarMenuItem } from "../ui/sidebar"; | ||
| 38 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | ||
| 39 | import { | 21 | import { |
| 40 | DesktopUpdateStatusIcon, | 22 | downloadLocalUpdate, |
| 41 | type DesktopUpdateStatusIconState, | 23 | installLocalUpdate, |
| 42 | shouldContinueDesktopUpdateCheckAnimation, | 24 | performDesktopUpdate, |
| 43 | shouldShowDesktopUpdateCheckIcon, | 25 | toastUpdateError, |
| 44 | } from "./DesktopUpdateStatusIcon"; | 26 | } from "../desktopUpdate.actions"; |
| 27 | import { DesktopUpdateStatusIcon } from "./DesktopUpdateStatusIcon"; | ||
| 45 | import { SidebarUpdateReleaseNotes } from "./SidebarUpdateReleaseNotes"; | 28 | import { SidebarUpdateReleaseNotes } from "./SidebarUpdateReleaseNotes"; |
| 46 | import { UpdateEverythingDialog } from "./UpdateEverythingDialog"; | 29 | import { UpdateEverythingDialog } from "./UpdateEverythingDialog"; |
| 47 | import { | 30 | import { |
| ... | @@ -50,163 +33,11 @@ import { | ... | @@ -50,163 +33,11 @@ import { |
| 50 | type UpdateEverythingRun, | 33 | type UpdateEverythingRun, |
| 51 | } from "./updateEverything.logic"; | 34 | } from "./updateEverything.logic"; |
| 52 | import { useUpdateEverythingRun } from "./updateEverythingRun"; | 35 | import { useUpdateEverythingRun } from "./updateEverythingRun"; |
| 53 | import { type ReportLocalUpdateError, useUpdateEverything } from "./useUpdateEverything"; | 36 | import { useUpdateEverything } from "./useUpdateEverything"; |
| 54 | |||
| 55 | type SidebarUpdatePopoverChangeDetails = Parameters< | ||
| 56 | NonNullable<ComponentProps<typeof Popover>["onOpenChange"]> | ||
| 57 | >[1]; | ||
| 58 | type SidebarUpdatePopoverHandle = ReturnType<typeof PopoverCreateHandle>; | ||
| 59 | |||
| 60 | export function shouldUseSidebarUpdateReleaseNotesPopover( | ||
| 61 | showUpdateDetails: boolean, | ||
| 62 | state: DesktopUpdateState | null, | ||
| 63 | ): boolean { | ||
| 64 | return showUpdateDetails && state?.channel === "nightly" && state.releaseNotes.length > 0; | ||
| 65 | } | ||
| 66 | |||
| 67 | export 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 | |||
| 75 | export 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 | |||
| 85 | function 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 | |||
| 124 | export function SidebarUpdateArchitectureWarning() { | ||
| 125 | return isElectron ? <SidebarUpdateArchitectureWarningContent /> : null; | ||
| 126 | } | ||
| 127 | |||
| 128 | function 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; | ||
| 134 | 37 | ||
| 135 | return ( | 38 | function useUpdateRunStatus( |
| 136 | <Alert variant="warning"> | ||
| 137 | <TriangleAlertIcon /> | ||
| 138 | <AlertTitle>Intel build on Apple Silicon</AlertTitle> | ||
| 139 | <AlertDescription>{description}</AlertDescription> | ||
| 140 | </Alert> | ||
| 141 | ); | ||
| 142 | } | ||
| 143 | |||
| 144 | const 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. */ | ||
| 150 | async 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. */ | ||
| 180 | async 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. */ | ||
| 206 | function 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 | : localReady | 67 | : 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 | } |
| 241 | 72 | ||
| 242 | /** | 73 | export 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 | */ | ||
| 248 | export function SidebarUpdatePill() { | ||
| 249 | return <SidebarUpdateControl />; | ||
| 250 | } | ||
| 251 | |||
| 252 | function 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 !== null | 100 | ? "Restart" |
| 297 | ? runTooltip | 101 | : "Reload" |
| 298 | : runsEverything | 102 | : "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]); | ||
| 403 | 103 | ||
| 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]); | ||
| 441 | 135 | ||
| 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 | }; | ||
| 444 | 148 | ||
| 445 | const updateButton = ( | 149 | const showArchitectureWarning = shouldShowArm64IntelBuildWarning(state); |
| 446 | <button | 150 | 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 | showUpdateIconState | 157 | 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 | ); | ||
| 494 | 166 | ||
| 495 | return ( | 167 | return ( |
| 496 | <SidebarMenuItem className="ml-auto shrink-0"> | 168 | <section aria-label="Updates" className="space-y-1"> |
| 497 | <Popover | 169 | <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 | <TooltipTrigger | 180 | 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 | <SidebarUpdateReleaseNotes | 184 | {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 | <UpdateEverythingDialog | 264 | <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"; |
| 17 | 17 | ||
| 18 | /** The running "update everything" pass, or null. Outlives the sidebar button. */ | 18 | /** The running "update everything" pass, or null. Outlives the app menu. */ |
| 19 | const updateEverythingRunAtom = Atom.make<UpdateEverythingRun | null>(null).pipe( | 19 | const 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 { |
| 34 | export type ReportLocalUpdateError = (title: string, description: string) => void; | 34 | export type ReportLocalUpdateError = (title: string, description: string) => void; |
| 35 | 35 | ||
| 36 | /** | 36 | /** |
| 37 | * The remote half of the sidebar update button: outdated providers on every | 37 | * The remote half of the app menu updater: outdated providers on every |
| 38 | * connected machine, and servers behind the version this client runs or is | 38 | * connected machine, and servers behind the version this client runs or is |
| 39 | * about to install (`localUpdateVersion`). Servers that install from npm appear | 39 | * about to install (`localUpdateVersion`). Servers that install from npm appear |
| 40 | * only once npm confirms that version. `run` performs the whole pass, including | 40 | * 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 | }); |
| 105 | 105 | ||
| 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"; |
| 2 | import { | 2 | import { |
| 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"; |
| 8 | 7 | ||
| 9 | describe("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 | |||
| 20 | describe("resolveThreadSidebarMaximumWidth", () => { | 8 | describe("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 | }); |
| 26 | 14 | ||
| 27 | describe("clampThreadSidebarWidth", () => { | 15 | describe("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 | }); |
| 31 | 19 | ||
| 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; |
| 3 | export const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; | 3 | export const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; |
| 4 | export const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; | 4 | export const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; |
| 5 | 5 | ||
| 6 | // The brand's measured width can raise the minimum so "T3 Code" never clips. | 6 | export function resolveThreadSidebarMaximumWidth(viewportWidth: number): number { |
| 7 | export 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 | } |
| 10 | 12 | ||
| 11 | export function resolveThreadSidebarMaximumWidth( | 13 | export 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 | |||
| 18 | export function clampThreadSidebarWidth( | ||
| 19 | width: number, | ||
| 20 | minimumWidth: number, | ||
| 21 | maximumWidth: number, | ||
| 22 | ): number { | ||
| 23 | return Math.min(maximumWidth, Math.max(minimumWidth, width)); | ||
| 24 | } | 15 | } |
| 25 | 16 | ||
| 26 | export function resolveInitialThreadSidebarWidth( | 17 | export function resolveInitialThreadSidebarWidth( |
apps/web/src/components/ui/alert-dialog.focus.test.tsx created+88| ... | @@ -0,0 +1,88 @@ | ||
| 1 | // @vitest-environment jsdom | ||
| 2 | |||
| 3 | import { act, createRef, useState } from "react"; | ||
| 4 | import { createRoot, type Root } from "react-dom/client"; | ||
| 5 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; | ||
| 6 | |||
| 7 | import { | ||
| 8 | AlertDialog, | ||
| 9 | AlertDialogClose, | ||
| 10 | AlertDialogFooter, | ||
| 11 | AlertDialogPopup, | ||
| 12 | AlertDialogTitle, | ||
| 13 | } from "./alert-dialog"; | ||
| 14 | import { Button } from "./button"; | ||
| 15 | |||
| 16 | let root: Root; | ||
| 17 | let container: HTMLDivElement; | ||
| 18 | const cancel = createRef<HTMLButtonElement>(); | ||
| 19 | |||
| 20 | beforeEach(() => { | ||
| 21 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | ||
| 22 | container = document.createElement("div"); | ||
| 23 | document.body.append(container); | ||
| 24 | root = createRoot(container); | ||
| 25 | }); | ||
| 26 | |||
| 27 | afterEach(async () => { | ||
| 28 | await act(async () => root.unmount()); | ||
| 29 | container.remove(); | ||
| 30 | vi.unstubAllGlobals(); | ||
| 31 | }); | ||
| 32 | |||
| 33 | function 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 | |||
| 55 | async function open() { | ||
| 56 | const trigger = container.querySelector("button")!; | ||
| 57 | await act(async () => { | ||
| 58 | trigger.focus(); | ||
| 59 | trigger.click(); | ||
| 60 | }); | ||
| 61 | } | ||
| 62 | |||
| 63 | it("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 | |||
| 78 | it("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 | |||
| 84 | it("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"; |
| 2 | 2 | ||
| 3 | import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"; | 3 | import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"; |
| 4 | import { useRef } from "react"; | ||
| 4 | 5 | ||
| 5 | import { cn } from "~/lib/utils"; | 6 | import { cn } from "~/lib/utils"; |
| 6 | import { | 7 | import { |
| ... | @@ -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 | ...props | 47 | ...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.Popup | 59 | <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 @@ | ||
| 1 | import { scopeProjectRef } from "@t3tools/client-runtime/environment"; | ||
| 2 | import { | ||
| 3 | getNewProjectRepository, | ||
| 4 | type NewProjectPublishTarget, | ||
| 5 | } from "@t3tools/client-runtime/operations/projects"; | ||
| 6 | import { | ||
| 7 | isAtomCommandInterrupted, | ||
| 8 | squashAtomCommandFailure, | ||
| 9 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 10 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 11 | import { useCallback } from "react"; | ||
| 12 | |||
| 13 | import { stackedThreadToast, toastManager } from "~/components/ui/toast"; | ||
| 14 | import { waitForProject } from "~/state/entities"; | ||
| 15 | import { projectEnvironment } from "~/state/projects"; | ||
| 16 | import { sourceControlEnvironment } from "~/state/sourceControl"; | ||
| 17 | import { useAtomCommand } from "~/state/use-atom-command"; | ||
| 18 | import { useNewThreadHandler } from "./useHandleNewThread"; | ||
| 19 | |||
| 20 | function 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 | */ | ||
| 34 | export 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"; |
| 2 | 2 | ||
| 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 | } |
| 88 | 120 | ||
| 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. The | 197 | 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 | } |
| 171 | 204 | ||
| 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 | } |
| 495 | 528 | ||
| 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); | ||
| 1221 | 1226 | ||
| 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 | } |
| 1244 | 1248 | ||
| ... | @@ -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); |
| 1512 | 1514 | ||
| 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(); |
| 575 | 575 | ||
| 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 | ); |
| 582 | 582 | ||
| 583 | store.setThreadPanelOpen(refA, "inline", false); | 583 | store.toggleThreadPanel(refA, "inline"); |
| 584 | store.toggleThreadPanel(refA, "popover"); | 584 | store.toggleThreadPanel(refA, "popover"); |
| 585 | 585 | ||
| 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 | }); |
| 599 | 599 | ||
| 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 | }); |
| 611 | 611 | ||
| 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 | }; |
| 210 | 210 | ||
| 211 | const DEFAULT_THREAD_PANEL_VISIBILITY: ThreadPanelVisibility = { | 211 | const DEFAULT_THREAD_PANEL_VISIBILITY: ThreadPanelVisibility = { |
| 212 | inlineOpen: true, | 212 | inlineOpen: false, |
| 213 | popoverOpen: false, | 213 | popoverOpen: false, |
| 214 | }; | 214 | }; |
| 215 | 215 | ||
| ... | @@ -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 === false | 592 | 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 | }), |