| 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 | 54 | ]); |
| 55 | 55 | new DataView(screenshotPng.buffer).setUint32(16, 390); |
| 56 | 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 | 63 | const screenshotItem = { |
| 58 | 64 | id: TurnItemId.make("tool-screenshot"), |
| 59 | 65 | type: "dynamic_tool" as const, |
| ... | ... | @@ -816,6 +822,84 @@ describe("AssetAccess", () => { |
| 816 | 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 | 903 | it.effect("falls back to the resource cwd for relative draft paths", () => |
| 820 | 904 | Effect.gen(function* () { |
| 821 | 905 | const fileSystem = yield* FileSystem.FileSystem; |
| ... | ... | @@ -1335,14 +1419,8 @@ describe("AssetAccess", () => { |
| 1335 | 1419 | const root = yield* fileSystem.makeTempDirectoryScoped({ |
| 1336 | 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 | 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 | 1425 | const result = yield* issueAssetUrl({ |
| 1348 | 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 | 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. */ | |
| 266 | const projectFaviconAsset = Effect.fn("AssetAccess.projectFaviconAsset")(function* ( | |
| 265 | /** Browsers display the embedded PNG in an `.icns` image. */ | |
| 266 | const imagePreviewAsset = Effect.fn("AssetAccess.imagePreviewAsset")(function* ( | |
| 267 | 267 | filePath: string, |
| 268 | file?: OpenMediaFile, | |
| 268 | 269 | ) { |
| 269 | 270 | if (!filePath.toLowerCase().endsWith(".icns")) { |
| 270 | 271 | return { kind: "file", path: filePath } satisfies ResolvedAsset; |
| 271 | 272 | } |
| 272 | 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 | 279 | Effect.tapError((cause) => |
| 275 | 280 | Effect.logError("Failed to read project icon image.", { filePath, cause }), |
| 276 | 281 | ), |
| ... | ... | @@ -378,7 +383,10 @@ const finalizeAbsoluteMediaFileAsset = Effect.fn("AssetAccess.finalizeAbsoluteMe |
| 378 | 383 | if (!canonicalFile) { |
| 379 | 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 | 390 | return yield* new AssetPreviewTypeValidationError({ resource: input.resource }); |
| 383 | 391 | } |
| 384 | 392 | const wantsDimensions = HEADER_IMAGE_EXTENSIONS.has(path.extname(canonicalFile).toLowerCase()); |
| ... | ... | @@ -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 | 457 | return yield* new AssetPreviewTypeValidationError({ |
| 447 | 458 | resource: input.resource, |
| 448 | 459 | }); |
| ... | ... | @@ -479,7 +490,7 @@ const finalizeWorkspaceFileAsset = Effect.fn("AssetAccess.finalizeWorkspaceFileA |
| 479 | 490 | ? yield* readImageDimensionsFromHeader(canonicalFile) |
| 480 | 491 | : null; |
| 481 | 492 | return { |
| 482 | claims: isWorkspaceImagePreviewPath(resolved.relativePath) | |
| 493 | claims: isImage | |
| 483 | 494 | ? { |
| 484 | 495 | version: 1 as const, |
| 485 | 496 | kind: "workspace-file-exact" as const, |
| ... | ... | @@ -889,7 +900,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( |
| 889 | 900 | workspaceRoot: claims.workspaceRoot, |
| 890 | 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 | 906 | if (claims.kind === "project-favicon-external") { |
| ... | ... | @@ -902,7 +913,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( |
| 902 | 913 | ), |
| 903 | 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 | 919 | if (claims.kind === "github-media") { |
| ... | ... | @@ -949,7 +960,10 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( |
| 949 | 960 | Effect.orElseSucceed(() => null), |
| 950 | 961 | ); |
| 951 | 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 | 967 | if (!mimeType) return null; |
| 954 | 968 | const file = yield* openMediaFile(canonicalFile, claims).pipe( |
| 955 | 969 | Effect.tapError((cause) => |
| ... | ... | @@ -957,9 +971,10 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( |
| 957 | 971 | ), |
| 958 | 972 | Effect.orElseSucceed(() => null), |
| 959 | 973 | ); |
| 960 | return file | |
| 961 | ? ({ kind: "file", path: canonicalFile, mimeType, file } satisfies ResolvedAsset) | |
| 962 | : null; | |
| 974 | if (!file) return null; | |
| 975 | return isIcns | |
| 976 | ? yield* imagePreviewAsset(canonicalFile, file) | |
| 977 | : ({ kind: "file", path: canonicalFile, mimeType, file } satisfies ResolvedAsset); | |
| 963 | 978 | } |
| 964 | 979 | if (claims.kind === "workspace-file-exact") { |
| 965 | 980 | if (decodedPath !== path.basename(claims.relativePath)) return null; |
| ... | ... | @@ -967,9 +982,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( |
| 967 | 982 | workspaceRoot: claims.workspaceRoot, |
| 968 | 983 | relativePath: claims.relativePath, |
| 969 | 984 | }); |
| 970 | return exactWorkspaceFile | |
| 971 | ? ({ kind: "file", path: exactWorkspaceFile } satisfies ResolvedAsset) | |
| 972 | : null; | |
| 985 | return exactWorkspaceFile ? yield* imagePreviewAsset(exactWorkspaceFile) : null; | |
| 973 | 986 | } |
| 974 | 987 | const segments = decodedPath.split(/[\\/]/); |
| 975 | 988 | if ( |
apps/web/src/appearanceFonts.ts+2-2| ... | ... | @@ -18,12 +18,12 @@ import { |
| 18 | 18 | } from "@t3tools/contracts"; |
| 19 | 19 | |
| 20 | 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 | 23 | // Concrete names first: some engines alias `ui-monospace` to the |
| 24 | 24 | // proportional system UI font, which would break every code surface. |
| 25 | 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 | 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 | 25 | import { resolveThreadRouteRef } from "../threadRoutes"; |
| 26 | 26 | import { cn, isMacPlatform } from "../lib/utils"; |
| 27 | 27 | import { primaryServerKeybindingsAtom } from "../state/server"; |
| 28 | import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings"; | |
| 28 | import { useLegacySidebarEnabled } from "../hooks/useSettings"; | |
| 29 | 29 | import { |
| 30 | 30 | PanelAnimationSuppressionProvider, |
| 31 | 31 | usePanelAnimationSettings, |
| ... | ... | @@ -35,15 +35,15 @@ import LegacyThreadSidebar from "./LegacySidebar"; |
| 35 | 35 | import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration"; |
| 36 | 36 | import ThreadSidebar from "./Sidebar"; |
| 37 | 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 | 40 | import { MainAppLocationTracker } from "./sidebar/mainAppLocation"; |
| 40 | import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; | |
| 41 | 41 | import { useProjects } from "../state/entities"; |
| 42 | 42 | import { |
| 43 | 43 | clampThreadSidebarWidth, |
| 44 | 44 | resolveInitialThreadSidebarWidth, |
| 45 | 45 | resolveThreadSidebarMaximumWidth, |
| 46 | resolveThreadSidebarMinimumWidth, | |
| 46 | THREAD_SIDEBAR_MIN_WIDTH, | |
| 47 | 47 | THREAD_MAIN_CONTENT_MIN_WIDTH, |
| 48 | 48 | THREAD_SIDEBAR_WIDTH_STORAGE_KEY, |
| 49 | 49 | } from "./threadSidebarWidth"; |
| ... | ... | @@ -85,10 +85,6 @@ function SidebarControl() { |
| 85 | 85 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 86 | 86 | const { toggleSidebar, isMobile, mobileSheetMounted } = useSidebar(); |
| 87 | 87 | const isSidebarVisible = useSidebarVisibility(); |
| 88 | const environmentIdentificationMode = useEnvironmentIdentificationMode(); | |
| 89 | const stageBackdropVariant = useSidebarStageBackdropVariant( | |
| 90 | environmentIdentificationMode === "artwork", | |
| 91 | ); | |
| 92 | 88 | const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle", { |
| 93 | 89 | context: { usagePageOpen }, |
| 94 | 90 | }); |
| ... | ... | @@ -146,19 +142,13 @@ function SidebarControl() { |
| 146 | 142 | )} |
| 147 | 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 | 148 | <Tooltip> |
| 150 | 149 | <TooltipTrigger |
| 151 | 150 | render={ |
| 152 | <SidebarTrigger | |
| 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 | /> | |
| 151 | <SidebarTrigger className="pointer-events-auto" aria-label="Toggle main sidebar" /> | |
| 162 | 152 | } |
| 163 | 153 | /> |
| 164 | 154 | <TooltipPopup side="bottom"> |
| ... | ... | @@ -246,9 +236,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { |
| 246 | 236 | // and a clamped drag ends with an unchanged width, which skips the re-render |
| 247 | 237 | // that would otherwise refresh a render-time snapshot. |
| 248 | 238 | const viewportWidth = useSyncExternalStore(subscribeToViewportWidth, readViewportWidth); |
| 249 | const [brandWidth, setBrandWidth] = useState(0); | |
| 250 | const sidebarMinimumWidth = resolveThreadSidebarMinimumWidth(brandWidth); | |
| 251 | const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(viewportWidth, sidebarMinimumWidth); | |
| 239 | const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(viewportWidth); | |
| 252 | 240 | const resetSidebarWidth = () => { |
| 253 | 241 | try { |
| 254 | 242 | removeLocalStorageItem(THREAD_SIDEBAR_WIDTH_STORAGE_KEY); |
| ... | ... | @@ -269,8 +257,10 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { |
| 269 | 257 | : {} |
| 270 | 258 | ) as CSSProperties; |
| 271 | 259 | const sidebarProviderStyle = { |
| 272 | "--sidebar-width": `${clampThreadSidebarWidth(sidebarWidth, sidebarMinimumWidth, sidebarMaximumWidth)}px`, | |
| 260 | "--sidebar-width": `${clampThreadSidebarWidth(sidebarWidth, sidebarMaximumWidth)}px`, | |
| 273 | 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 | 264 | ...windowControlsInsetStyle, |
| 275 | 265 | } as CSSProperties; |
| 276 | 266 | |
| ... | ... | @@ -319,7 +309,6 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { |
| 319 | 309 | defaultOpen |
| 320 | 310 | style={sidebarProviderStyle} |
| 321 | 311 | > |
| 322 | <SidebarBrandWidthProbe onWidthChange={setBrandWidth} /> | |
| 323 | 312 | <ProjectProjectionRetention /> |
| 324 | 313 | <Sidebar |
| 325 | 314 | side="left" |
| ... | ... | @@ -329,7 +318,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { |
| 329 | 318 | aria-label={isOnSettings ? "Settings" : "Threads"} |
| 330 | 319 | resizable={{ |
| 331 | 320 | maxWidth: sidebarMaximumWidth, |
| 332 | minWidth: sidebarMinimumWidth, | |
| 321 | minWidth: THREAD_SIDEBAR_MIN_WIDTH, | |
| 333 | 322 | shouldAcceptWidth: ({ currentWidth, nextWidth, wrapper }) => |
| 334 | 323 | nextWidth <= currentWidth || |
| 335 | 324 | wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, |
apps/web/src/components/CommandPalette.tsx+14-144| ... | ... | @@ -11,10 +11,6 @@ import { |
| 11 | 11 | getCloneDirectoryName, |
| 12 | 12 | getDefaultCloneUrl, |
| 13 | 13 | getNewProjectPathPreview, |
| 14 | getNewProjectPublishTarget, | |
| 15 | getNewProjectPublishTargets, | |
| 16 | getNewProjectRepository, | |
| 17 | type NewProjectPublishTarget, | |
| 18 | 14 | normalizePastedCloneUrl, |
| 19 | 15 | addProjectRemoteSourceLabel, |
| 20 | 16 | addProjectRemoteSourcePathHint, |
| ... | ... | @@ -49,13 +45,11 @@ import { |
| 49 | 45 | type FilesystemBrowseResult, |
| 50 | 46 | type ProjectId, |
| 51 | 47 | type SourceControlDiscoveryResult, |
| 52 | SourceControlProviderKind, | |
| 53 | 48 | type SourceControlRepositoryInfo, |
| 54 | 49 | PRIMARY_LOCAL_ENVIRONMENT_ID, |
| 55 | 50 | resolveEnvironmentMachineKind, |
| 56 | 51 | } from "@t3tools/contracts"; |
| 57 | 52 | import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; |
| 58 | import * as Option from "effect/Option"; | |
| 59 | 53 | import { |
| 60 | 54 | ArrowLeftIcon, |
| 61 | 55 | ChartNoAxesColumnIcon, |
| ... | ... | @@ -63,7 +57,6 @@ import { |
| 63 | 57 | ChevronRightIcon, |
| 64 | 58 | CornerLeftUpIcon, |
| 65 | 59 | FileSearchIcon, |
| 66 | FolderGit2Icon, | |
| 67 | 60 | FolderIcon, |
| 68 | 61 | FolderPlusIcon, |
| 69 | 62 | MessageSquareDashedIcon, |
| ... | ... | @@ -127,7 +120,6 @@ import { sourceControlEnvironment } from "../state/sourceControl"; |
| 127 | 120 | import { useAtomCommand } from "../state/use-atom-command"; |
| 128 | 121 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; |
| 129 | 122 | import { useScratchProject } from "../hooks/useScratchProject"; |
| 130 | import { useNewProject } from "../hooks/useNewProject"; | |
| 131 | 123 | import { isScratchProject } from "@t3tools/client-runtime/state/projects"; |
| 132 | 124 | import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; |
| 133 | 125 | import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities"; |
| ... | ... | @@ -204,7 +196,6 @@ import { |
| 204 | 196 | } from "./CommandPaletteResults"; |
| 205 | 197 | import { sourceControlIcon } from "~/sourceControlPresentation"; |
| 206 | 198 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; |
| 207 | import { Checkbox } from "./ui/checkbox"; | |
| 208 | 199 | import { ProjectFavicon } from "./ProjectFavicon"; |
| 209 | 200 | import { ProjectFilePicker } from "./files/ProjectFilePicker"; |
| 210 | 201 | import { openLinkPullRequestDialog } from "./pullRequest/LinkPullRequestDialog"; |
| ... | ... | @@ -231,7 +222,6 @@ import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindin |
| 231 | 222 | import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "./ui/command"; |
| 232 | 223 | import { Button } from "./ui/button"; |
| 233 | 224 | import { Kbd, KbdGroup } from "./ui/kbd"; |
| 234 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; | |
| 235 | 225 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 236 | 226 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 237 | 227 | import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; |
| ... | ... | @@ -291,14 +281,6 @@ function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: stri |
| 291 | 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 | 284 | function projectFaviconIcon(project: Project): ReactNode { |
| 303 | 285 | return <ProjectFavicon project={project} className={ITEM_ICON_CLASS} />; |
| 304 | 286 | } |
| ... | ... | @@ -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 | 352 | export function CommandPalette({ children }: { children: ReactNode }) { |
| 375 | 353 | const navigate = useNavigate(); |
| 376 | 354 | const [state, dispatch] = useReducer(reduceCommandPaletteUiState, { |
| ... | ... | @@ -788,15 +766,9 @@ function OpenCommandPaletteDialog(props: { |
| 788 | 766 | /** Machine of the Add project sources view under this step; null from the palette root. */ |
| 789 | 767 | readonly sourcesEnvironmentId: EnvironmentId | null; |
| 790 | 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 | 769 | const [isCreatingNewProject, setIsCreatingNewProject] = useState(false); |
| 797 | 770 | // State lags a render behind, so a repeated Enter could start a second create. |
| 798 | 771 | const newProjectSubmittingRef = useRef(false); |
| 799 | const createNewProject = useNewProject(); | |
| 800 | 772 | const cloneLookupGeneration = useRef(0); |
| 801 | 773 | const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false); |
| 802 | 774 | const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false); |
| ... | ... | @@ -1522,10 +1494,8 @@ function OpenCommandPaletteDialog(props: { |
| 1522 | 1494 | setAddProjectEnvironmentId(environmentId); |
| 1523 | 1495 | setAddProjectCloneFlow(null); |
| 1524 | 1496 | setNewProjectFlow({ environmentId, sourcesEnvironmentId }); |
| 1525 | setNewProjectPublishes(false); | |
| 1526 | setNewProjectPublishKind(null); | |
| 1527 | 1497 | pushPaletteView({ |
| 1528 | addonIcon: <FolderGit2Icon className={ADDON_ICON_CLASS} />, | |
| 1498 | addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />, | |
| 1529 | 1499 | groups: [], |
| 1530 | 1500 | }); |
| 1531 | 1501 | }, |
| ... | ... | @@ -1561,10 +1531,10 @@ function OpenCommandPaletteDialog(props: { |
| 1561 | 1531 | sourceItems.unshift({ |
| 1562 | 1532 | kind: "action", |
| 1563 | 1533 | value: `action:add-project:${environmentId}:new`, |
| 1564 | searchTerms: ["new project", "create", "empty", "repository", "git init"], | |
| 1534 | searchTerms: ["new project", "create", "empty", "folder"], | |
| 1565 | 1535 | title: "New project", |
| 1566 | description: "Start a new Git repository from a name", | |
| 1567 | icon: <FolderGit2Icon className={ITEM_ICON_CLASS} />, | |
| 1536 | description: "Create a project folder from a name", | |
| 1537 | icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />, | |
| 1568 | 1538 | keepOpen: true, |
| 1569 | 1539 | run: async () => { |
| 1570 | 1540 | startNewProject(environmentId, environmentId); |
| ... | ... | @@ -2037,9 +2007,9 @@ function OpenCommandPaletteDialog(props: { |
| 2037 | 2007 | actionItems.push({ |
| 2038 | 2008 | kind: "action", |
| 2039 | 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 | 2011 | title: "New project", |
| 2042 | icon: <FolderGit2Icon className={ITEM_ICON_CLASS} />, | |
| 2012 | icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />, | |
| 2043 | 2013 | keepOpen: true, |
| 2044 | 2014 | run: async () => { |
| 2045 | 2015 | openNewProjectFlow(); |
| ... | ... | @@ -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 | 2481 | const newProjectName = query.trim(); |
| 2518 | 2482 | const canSubmitNewProject = |
| 2519 | 2483 | newProjectFlow !== null && |
| ... | ... | @@ -2528,12 +2492,14 @@ function OpenCommandPaletteDialog(props: { |
| 2528 | 2492 | newProjectSubmittingRef.current = true; |
| 2529 | 2493 | setIsCreatingNewProject(true); |
| 2530 | 2494 | try { |
| 2531 | const created = await createNewProject({ | |
| 2495 | const root = newProjectsRootFor(newProjectFlow.environmentId); | |
| 2496 | if (root === null) return; | |
| 2497 | await handleAddProjectForEnvironment({ | |
| 2532 | 2498 | environmentId: newProjectFlow.environmentId, |
| 2533 | name: newProjectName, | |
| 2534 | publishTo: newProjectPublishes ? newProjectPublishTarget : null, | |
| 2499 | rawCwd: getNewProjectPathPreview(root, newProjectName), | |
| 2500 | platform: browseEnvironmentPlatform, | |
| 2501 | currentProjectCwd: null, | |
| 2535 | 2502 | }); |
| 2536 | if (created) setOpen(false); | |
| 2537 | 2503 | } finally { |
| 2538 | 2504 | newProjectSubmittingRef.current = false; |
| 2539 | 2505 | setIsCreatingNewProject(false); |
| ... | ... | @@ -2837,7 +2803,6 @@ function OpenCommandPaletteDialog(props: { |
| 2837 | 2803 | const newProjectsRoot = newProjectFlow ? newProjectsRootFor(newProjectFlow.environmentId) : null; |
| 2838 | 2804 | const newProjectPathPreview = |
| 2839 | 2805 | newProjectsRoot === null ? null : getNewProjectPathPreview(newProjectsRoot, newProjectName); |
| 2840 | const newProjectPublishToggleValue = "new-project:publish"; | |
| 2841 | 2806 | // The name step's way out to folders and clones, for the selected machine. |
| 2842 | 2807 | // It replaces the name step (and a sources view for another machine under |
| 2843 | 2808 | // it), so Back returns to wherever New project was opened from. |
| ... | ... | @@ -2853,8 +2818,6 @@ function OpenCommandPaletteDialog(props: { |
| 2853 | 2818 | popView(); |
| 2854 | 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 | 2821 | const switchNewProjectEnvironment = (environmentId: EnvironmentId) => { |
| 2859 | 2822 | setNewProjectFlow((flow) => (flow === null ? flow : { ...flow, environmentId })); |
| 2860 | 2823 | setAddProjectEnvironmentId(environmentId); |
| ... | ... | @@ -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 | 2890 | let displayedGroups: CommandPaletteView["groups"] = filteredGroups; |
| 3018 | 2891 | if (newProjectFlow !== null) { |
| 3019 | 2892 | displayedGroups = [ |
| 3020 | 2893 | ...(newProjectMachineGroup ? [newProjectMachineGroup] : []), |
| 3021 | ...newProjectOptionGroups, | |
| 3022 | 2894 | ...(newProjectExistingGroup ? [newProjectExistingGroup] : []), |
| 3023 | 2895 | ]; |
| 3024 | 2896 | } else if (addProjectCloneFlow?.step === "repository") { |
| ... | ... | @@ -3487,9 +3359,7 @@ function OpenCommandPaletteDialog(props: { |
| 3487 | 3359 | newProjectFlow !== null |
| 3488 | 3360 | ? highlightedItemValue === null |
| 3489 | 3361 | ? "Create" |
| 3490 | : highlightedItemValue === newProjectPublishToggleValue | |
| 3491 | ? "Toggle" | |
| 3492 | : "Select" | |
| 3362 | : "Select" | |
| 3493 | 3363 | : addProjectCloneFlow?.step === "repository" |
| 3494 | 3364 | ? (remoteProjectButtonLabel ?? "Continue") |
| 3495 | 3365 | : !canSubmitBrowsePath || hasHighlightedBrowseItem |
| ... | ... | @@ -3566,7 +3436,7 @@ function OpenCommandPaletteDialog(props: { |
| 3566 | 3436 | {newProjectPathPreview !== null ? ( |
| 3567 | 3437 | <div className="p-2 pb-0"> |
| 3568 | 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 | 3440 | <span className="flex min-w-0 flex-1 flex-col"> |
| 3571 | 3441 | <span className="truncate text-foreground text-sm"> |
| 3572 | 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 | 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 | 827 | const quick = resolveQuickAction( |
| 828 | 828 | status({ |
| 829 | 829 | hasUpstream: false, |
| ... | ... | @@ -835,9 +835,10 @@ describe("when: ref has no upstream configured", () => { |
| 835 | 835 | false, |
| 836 | 836 | ); |
| 837 | 837 | assert.deepEqual(quick, { |
| 838 | kind: "open_publish", | |
| 839 | label: "Publish repository", | |
| 840 | disabled: false, | |
| 838 | kind: "show_hint", | |
| 839 | label: "Commit", | |
| 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 | 23 | export interface GitQuickAction { |
| 24 | 24 | label: string; |
| 25 | 25 | disabled: boolean; |
| 26 | kind: "run_action" | "run_pull" | "open_publish" | "show_hint"; | |
| 26 | kind: "run_action" | "run_pull" | "show_hint"; | |
| 27 | 27 | action?: GitStackedAction; |
| 28 | 28 | hint?: string; |
| 29 | 29 | } |
| ... | ... | @@ -281,9 +281,10 @@ export function resolveQuickAction( |
| 281 | 281 | if (!gitStatus.hasUpstream) { |
| 282 | 282 | if (!hasPrimaryRemote) { |
| 283 | 283 | return { |
| 284 | label: "Publish repository", | |
| 285 | disabled: false, | |
| 286 | kind: "open_publish", | |
| 284 | label: "Commit", | |
| 285 | disabled: true, | |
| 286 | kind: "show_hint", | |
| 287 | hint: "No changes to commit.", | |
| 287 | 288 | }; |
| 288 | 289 | } |
| 289 | 290 | if (!isAhead) { |
apps/web/src/components/GitActionsControl.tsx+35-701| ... | ... | @@ -3,7 +3,6 @@ import { useAtomValue } from "@effect/atom-react"; |
| 3 | 3 | import { |
| 4 | 4 | AuthOrchestrationOperateScope, |
| 5 | 5 | AuthSourceControlWriteScope, |
| 6 | SourceControlProviderKind, | |
| 7 | 6 | type ScopedThreadRef, |
| 8 | 7 | } from "@t3tools/contracts"; |
| 9 | 8 | import { |
| ... | ... | @@ -13,32 +12,27 @@ import { |
| 13 | 12 | import type { |
| 14 | 13 | GitRunStackedActionResult, |
| 15 | 14 | GitStackedAction, |
| 16 | SourceControlCloneProtocol, | |
| 17 | SourceControlProviderDiscoveryItem, | |
| 18 | SourceControlPublishRepositoryResult, | |
| 19 | SourceControlRepositoryVisibility, | |
| 20 | 15 | VcsStatusResult, |
| 21 | 16 | } from "@t3tools/contracts"; |
| 22 | import { useNavigate } from "@tanstack/react-router"; | |
| 23 | import * as Option from "effect/Option"; | |
| 24 | import { type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 25 | import { flushSync } from "react-dom"; | |
| 17 | import { | |
| 18 | type MouseEvent, | |
| 19 | useCallback, | |
| 20 | useEffect, | |
| 21 | useEffectEvent, | |
| 22 | useMemo, | |
| 23 | useRef, | |
| 24 | useState, | |
| 25 | } from "react"; | |
| 26 | 26 | import { |
| 27 | 27 | CheckIcon, |
| 28 | 28 | ChevronDownIcon, |
| 29 | 29 | CloudDownloadIcon, |
| 30 | 30 | CloudUploadIcon, |
| 31 | 31 | FileDiffIcon, |
| 32 | GitBranchPlusIcon, | |
| 33 | 32 | GitCommitIcon, |
| 34 | 33 | InfoIcon, |
| 35 | LockIcon, | |
| 36 | GlobeIcon, | |
| 37 | 34 | } from "lucide-react"; |
| 38 | import { Radio as RadioPrimitive } from "@base-ui/react/radio"; | |
| 39 | import { RadioGroup } from "~/components/ui/radio-group"; | |
| 40 | 35 | import { Spinner } from "~/components/ui/spinner"; |
| 41 | import { Toggle, ToggleGroup } from "~/components/ui/toggle-group"; | |
| 42 | 36 | import { cn } from "~/lib/utils"; |
| 43 | 37 | import { useOpenPrLink } from "~/lib/openPullRequestLink"; |
| 44 | 38 | import { |
| ... | ... | @@ -59,7 +53,6 @@ import { |
| 59 | 53 | resolveQuickAction, |
| 60 | 54 | resolveThreadBranchUpdate, |
| 61 | 55 | } from "./GitActionsControl.logic"; |
| 62 | import { WizardPopup, WizardHeader, WizardSteps, WizardPanel, WizardFooter } from "./ui/wizard"; | |
| 63 | 56 | import { StartTruncatedPath } from "./StartTruncatedPath"; |
| 64 | 57 | import { Button } from "~/components/ui/button"; |
| 65 | 58 | import { Checkbox } from "~/components/ui/checkbox"; |
| ... | ... | @@ -73,7 +66,6 @@ import { |
| 73 | 66 | DialogTitle, |
| 74 | 67 | } from "~/components/ui/dialog"; |
| 75 | 68 | import { Group, GroupSeparator } from "~/components/ui/group"; |
| 76 | import { Input } from "~/components/ui/input"; | |
| 77 | 69 | import { |
| 78 | 70 | Menu, |
| 79 | 71 | MenuItem, |
| ... | ... | @@ -93,15 +85,12 @@ import { useOpenInPreferredEditor } from "~/editorPreferences"; |
| 93 | 85 | import { |
| 94 | 86 | useGitStackedAction, |
| 95 | 87 | useSourceControlActionRunning, |
| 96 | useSourceControlPublishRepositoryAction, | |
| 97 | useVcsInitAction, | |
| 98 | 88 | useVcsPullAction, |
| 99 | 89 | } from "~/lib/sourceControlActions"; |
| 100 | 90 | import { useThreadProjection, useThreadShell } from "~/state/entities"; |
| 101 | 91 | import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; |
| 102 | 92 | import { useEnvironmentQuery } from "~/state/query"; |
| 103 | 93 | import { serverEnvironment } from "~/state/server"; |
| 104 | import { sourceControlEnvironment } from "~/state/sourceControl"; | |
| 105 | 94 | import { threadEnvironment } from "~/state/threads"; |
| 106 | 95 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 107 | 96 | import { vcsActionManager, vcsEnvironment } from "~/state/vcs"; |
| ... | ... | @@ -115,10 +104,8 @@ import { |
| 115 | 104 | THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, |
| 116 | 105 | THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS, |
| 117 | 106 | } from "./chat/threadDetailsPanelStyles"; |
| 118 | import { getSourceControlPresentation, sourceControlIcon } from "~/sourceControlPresentation"; | |
| 119 | import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; | |
| 107 | import { getSourceControlPresentation } from "~/sourceControlPresentation"; | |
| 120 | 108 | import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; |
| 121 | import { useOpenLink } from "~/browser/useOpenLink"; | |
| 122 | 109 | |
| 123 | 110 | interface GitActionsControlProps { |
| 124 | 111 | presentation?: "toolbar" | "menu"; |
| ... | ... | @@ -182,49 +169,6 @@ function requestVcsStatusRefresh( |
| 182 | 169 | const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const; |
| 183 | 170 | |
| 184 | 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 | 172 | function getMenuActionDisabledReason({ |
| 229 | 173 | item, |
| 230 | 174 | gitStatus, |
| ... | ... | @@ -312,7 +256,6 @@ function GitQuickActionIcon({ |
| 312 | 256 | className?: string; |
| 313 | 257 | SourceControlIcon: ReturnType<typeof getSourceControlPresentation>["Icon"]; |
| 314 | 258 | }) { |
| 315 | if (quickAction.kind === "open_publish") return <CloudUploadIcon className={className} />; | |
| 316 | 259 | if (quickAction.kind === "run_pull") return <CloudDownloadIcon className={className} />; |
| 317 | 260 | if (quickAction.kind === "run_action") { |
| 318 | 261 | if (quickAction.action === "commit") return <GitCommitIcon className={className} />; |
| ... | ... | @@ -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 | 404 | export default function GitActionsControl({ |
| 995 | 405 | presentation = "toolbar", |
| 996 | 406 | gitCwd, |
| ... | ... | @@ -1044,7 +454,6 @@ export default function GitActionsControl({ |
| 1044 | 454 | const [dialogCommitMessage, setDialogCommitMessage] = useState(""); |
| 1045 | 455 | const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set()); |
| 1046 | 456 | const [isEditingFiles, setIsEditingFiles] = useState(false); |
| 1047 | const [isPublishDialogOpen, setIsPublishDialogOpen] = useState(false); | |
| 1048 | 457 | const [inlineSuccess, setInlineSuccess] = useState<InlineGitActionSuccess | null>(null); |
| 1049 | 458 | const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = |
| 1050 | 459 | useState<PendingDefaultBranchAction | null>(null); |
| ... | ... | @@ -1153,7 +562,6 @@ export default function GitActionsControl({ |
| 1153 | 562 | const allSelected = excludedFiles.size === 0; |
| 1154 | 563 | const noneSelected = selectedFiles.length === 0; |
| 1155 | 564 | |
| 1156 | const initAction = useVcsInitAction(sourceControlScope); | |
| 1157 | 565 | const runImmediateGitAction = useGitStackedAction(sourceControlScope); |
| 1158 | 566 | const pullAction = useVcsPullAction(sourceControlScope); |
| 1159 | 567 | const isGitActionRunning = useSourceControlActionRunning( |
| ... | ... | @@ -1470,10 +878,6 @@ export default function GitActionsControl({ |
| 1470 | 878 | }; |
| 1471 | 879 | |
| 1472 | 880 | const runQuickAction = () => { |
| 1473 | if (quickAction.kind === "open_publish") { | |
| 1474 | setIsPublishDialogOpen(true); | |
| 1475 | return; | |
| 1476 | } | |
| 1477 | 881 | if (quickAction.kind === "run_pull") { |
| 1478 | 882 | void (async () => { |
| 1479 | 883 | setInlineSuccess(null); |
| ... | ... | @@ -1595,26 +999,6 @@ export default function GitActionsControl({ |
| 1595 | 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 | 1002 | const gitItems = ( |
| 1619 | 1003 | <> |
| 1620 | 1004 | {gitActionMenuItems.map((item) => { |
| ... | ... | @@ -1673,18 +1057,6 @@ export default function GitActionsControl({ |
| 1673 | 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 | 1060 | {gitStatusForActions?.refName === null && ( |
| 1689 | 1061 | <p className="px-2 py-1.5 text-xs text-warning"> |
| 1690 | 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 | 1073 | </> |
| 1702 | 1074 | ); |
| 1703 | 1075 | |
| 1704 | if (!gitCwd) return null; | |
| 1076 | if (!gitCwd || !isRepo) return null; | |
| 1705 | 1077 | |
| 1706 | 1078 | return ( |
| 1707 | 1079 | <> |
| 1708 | 1080 | {presentation === "menu" ? ( |
| 1709 | !isRepo ? ( | |
| 1081 | <> | |
| 1710 | 1082 | <MenuItem |
| 1711 | 1083 | density={presentation === "menu" ? "touch" : "default"} |
| 1712 | 1084 | |
| 1713 | disabled={initAction.isPending} | |
| 1714 | onClick={initializeGit} | |
| 1085 | disabled={isGitActionRunning || quickAction.disabled || !!quickActionDisabledReason} | |
| 1086 | onClick={runQuickAction} | |
| 1715 | 1087 | > |
| 1716 | <GitBranchPlusIcon className="size-4" /> | |
| 1717 | <MenuItemLabel> | |
| 1718 | {initAction.isPending ? "Initializing..." : "Initialize Git"} | |
| 1719 | </MenuItemLabel> | |
| 1088 | <GitQuickActionIcon | |
| 1089 | className="size-4" | |
| 1090 | quickAction={quickAction} | |
| 1091 | SourceControlIcon={SourceControlIcon} | |
| 1092 | /> | |
| 1093 | <MenuItemLabel>{quickAction.label}</MenuItemLabel> | |
| 1720 | 1094 | </MenuItem> |
| 1721 | ) : ( | |
| 1722 | <> | |
| 1723 | <MenuItem | |
| 1724 | density={presentation === "menu" ? "touch" : "default"} | |
| 1725 | ||
| 1726 | disabled={isGitActionRunning || quickAction.disabled || !!quickActionDisabledReason} | |
| 1727 | onClick={runQuickAction} | |
| 1728 | > | |
| 1729 | <GitQuickActionIcon | |
| 1730 | className="size-4" | |
| 1731 | quickAction={quickAction} | |
| 1732 | SourceControlIcon={SourceControlIcon} | |
| 1733 | /> | |
| 1734 | <MenuItemLabel>{quickAction.label}</MenuItemLabel> | |
| 1735 | </MenuItem> | |
| 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> | |
| 1095 | {quickActionDisabledReason && ( | |
| 1096 | <p className="max-w-64 px-2 py-1.5 text-xs text-warning">{quickActionDisabledReason}</p> | |
| 1097 | )} | |
| 1098 | <MenuSub | |
| 1099 | onOpenChange={(open) => { | |
| 1100 | if (open) requestVcsStatusRefresh(refreshVcsStatus, activeEnvironmentId, gitCwd); | |
| 1101 | }} | |
| 1102 | > | |
| 1103 | <MenuSubTrigger density="touch" disabled={isGitActionRunning}> | |
| 1104 | <SourceControlIcon className="size-4" /> | |
| 1105 | <MenuItemLabel>Git actions</MenuItemLabel> | |
| 1106 | </MenuSubTrigger> | |
| 1107 | <MenuSubPopup>{gitItems}</MenuSubPopup> | |
| 1108 | </MenuSub> | |
| 1109 | </> | |
| 1768 | 1110 | ) : compact && !gitActionProgress && !visibleInlineSuccess ? null : ( |
| 1769 | 1111 | <ActionGroup |
| 1770 | 1112 | role="group" |
| ... | ... | @@ -2102,14 +1444,6 @@ export default function GitActionsControl({ |
| 2102 | 1444 | </DialogPopup> |
| 2103 | 1445 | </Dialog> |
| 2104 | 1446 | |
| 2105 | <PublishRepositoryDialog | |
| 2106 | open={isPublishDialogOpen} | |
| 2107 | onOpenChange={setIsPublishDialogOpen} | |
| 2108 | environmentId={activeEnvironmentId} | |
| 2109 | threadRef={activeThreadRef} | |
| 2110 | gitCwd={gitCwd} | |
| 2111 | /> | |
| 2112 | ||
| 2113 | 1447 | <Dialog |
| 2114 | 1448 | open={pendingDefaultBranchAction !== null} |
| 2115 | 1449 | onOpenChange={(open) => { |
apps/web/src/components/LegacySidebar.tsx+4-81| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { performDesktopUpdate } from "./desktopUpdate.actions"; | |
| 1 | 2 | import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; |
| 2 | 3 | import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; |
| 3 | 4 | import { Spinner } from "~/components/ui/spinner"; |
| ... | ... | @@ -138,14 +139,10 @@ import { formatRelativeTimeLabel } from "../timestampFormat"; |
| 138 | 139 | import { Kbd } from "./ui/kbd"; |
| 139 | 140 | import { |
| 140 | 141 | getArm64IntelBuildWarningDescription, |
| 141 | getDesktopUpdateActionError, | |
| 142 | getDesktopUpdateInstallConfirmationMessage, | |
| 143 | 142 | isDesktopUpdateButtonDisabled, |
| 144 | 143 | resolveDesktopUpdateButtonAction, |
| 145 | 144 | shouldShowArm64IntelBuildWarning, |
| 146 | shouldToastDesktopUpdateActionResult, | |
| 147 | 145 | } from "./desktopUpdate.logic"; |
| 148 | import { showDesktopUpdateDownloadedToast } from "./desktopUpdate.toast"; | |
| 149 | 146 | import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; |
| 150 | 147 | import { Button } from "./ui/button"; |
| 151 | 148 | import { |
| ... | ... | @@ -3746,83 +3743,9 @@ export default function LegacySidebar() { |
| 3746 | 3743 | |
| 3747 | 3744 | setDesktopUpdateActionPending(true); |
| 3748 | 3745 | |
| 3749 | if (desktopUpdateButtonAction === "download") { | |
| 3750 | void bridge | |
| 3751 | .downloadUpdate() | |
| 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 | } | |
| 3746 | void performDesktopUpdate(bridge, desktopUpdateState).finally(() => | |
| 3747 | setDesktopUpdateActionPending(false), | |
| 3748 | ); | |
| 3826 | 3749 | }, [ |
| 3827 | 3750 | desktopUpdateActionPending, |
| 3828 | 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 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | import { Atom } from "effect/reactivity"; | |
| 1 | 3 | import { PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts"; |
| 2 | 4 | import { useMemo, useRef, useState } from "react"; |
| 3 | 5 | |
| ... | ... | @@ -18,8 +20,8 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 18 | 20 | * Every outdated provider with a one-click update on every connected machine, |
| 19 | 21 | * plus a function that updates them all and reports the results in one toast. |
| 20 | 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 | |
| 22 | * cannot operate a machine gets a failure line for it. The returned promise | |
| 23 | * together is safe. Only permitted machines are offered; the server checks | |
| 24 | * permission again at execution. The returned promise | |
| 23 | 25 | * settles with every result when every update has finished. `quiet` skips the |
| 24 | 26 | * toast, for a caller that reports the results itself. |
| 25 | 27 | */ |
| ... | ... | @@ -28,22 +30,28 @@ export function useProviderUpdates() { |
| 28 | 30 | const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { |
| 29 | 31 | reportFailure: false, |
| 30 | 32 | }); |
| 31 | const machines = useMemo( | |
| 33 | const machinesAtom = useMemo( | |
| 32 | 34 | () => |
| 33 | environments.flatMap((environment) => { | |
| 34 | const providers = environment.serverConfig?.providers ?? []; | |
| 35 | if (environment.connection.phase !== "connected") { | |
| 36 | return []; | |
| 37 | } | |
| 38 | const candidates = collectProviderUpdateCandidates(providers).filter((candidate) => | |
| 39 | canOneClickUpdateProviderCandidate(candidate, providers), | |
| 40 | ); | |
| 41 | return candidates.length > 0 | |
| 42 | ? [{ environmentId: environment.environmentId, label: environment.label, candidates }] | |
| 43 | : []; | |
| 44 | }), | |
| 35 | Atom.make((get) => | |
| 36 | environments.flatMap((environment) => { | |
| 37 | const providers = environment.serverConfig?.providers ?? []; | |
| 38 | if ( | |
| 39 | environment.connection.phase !== "connected" || | |
| 40 | !get(serverEnvironment.updateProvider.permissionAtom(environment.environmentId)) | |
| 41 | ) { | |
| 42 | return []; | |
| 43 | } | |
| 44 | const candidates = collectProviderUpdateCandidates(providers).filter((candidate) => | |
| 45 | canOneClickUpdateProviderCandidate(candidate, providers), | |
| 46 | ); | |
| 47 | return candidates.length > 0 | |
| 48 | ? [{ environmentId: environment.environmentId, label: environment.label, candidates }] | |
| 49 | : []; | |
| 50 | }), | |
| 51 | ), | |
| 45 | 52 | [environments], |
| 46 | 53 | ); |
| 54 | const machines = useAtomValue(machinesAtom); | |
| 47 | 55 | |
| 48 | 56 | const updateAll = async ({ quiet = false }: { readonly quiet?: boolean } = {}) => { |
| 49 | 57 | const runs = await Promise.all( |
apps/web/src/components/SidebarStageBackdrop.tsx-12| ... | ... | @@ -46,18 +46,6 @@ export function useSidebarStageBackdropVariant(enabled = true): SidebarStageBack |
| 46 | 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 | 49 | export function StageBackdropArt({ variant }: { variant: SidebarStageBackdropVariant }) { |
| 62 | 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 | 87 | return `Downloading update${progress}`; |
| 88 | 88 | } |
| 89 | 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 | 94 | if (state.status === "error") { |
| 93 | 95 | if (state.errorContext === "download" && state.availableVersion) { |
apps/web/src/components/desktopUpdate.toast.tsx+3-1| ... | ... | @@ -56,7 +56,9 @@ export function showDesktopUpdateDownloadedToast( |
| 56 | 56 | title: "Update downloaded", |
| 57 | 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 | 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 | 1101 | // it on the panel meant a thread switch dropped it and forced source back. |
| 1102 | 1102 | const [renderMarkdownPreferred, setRenderMarkdownPreferred] = useLocalStorage( |
| 1103 | 1103 | RENDER_MARKDOWN_STORAGE_KEY, |
| 1104 | false, | |
| 1104 | true, | |
| 1105 | 1105 | Schema.Boolean, |
| 1106 | 1106 | ); |
| 1107 | 1107 | const [renderBrowserFilePreferred, setRenderBrowserFilePreferred] = useLocalStorage( |
apps/web/src/components/settings/ProjectFaviconPickerDialog.test.tsx+2-2| ... | ... | @@ -28,8 +28,8 @@ vi.mock("~/state/server", () => ({ |
| 28 | 28 | primaryServerKeybindingsAtom: Symbol("keybindings"), |
| 29 | 29 | })); |
| 30 | 30 | |
| 31 | vi.mock("~/hooks/useTheme", () => ({ | |
| 32 | useTheme: () => ({ resolvedTheme: "dark" }), | |
| 31 | vi.mock("~/assets/assetUrls", () => ({ | |
| 32 | useAssetUrlState: () => ({ _tag: "Loading" }), | |
| 33 | 33 | })); |
| 34 | 34 | |
| 35 | 35 | vi.mock("../files/projectFilesQueryState", () => ({ |
apps/web/src/components/settings/ProjectFaviconPickerDialog.tsx+53-8| ... | ... | @@ -1,15 +1,15 @@ |
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 2 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 3 | 3 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; |
| 4 | import { ImageIcon } from "lucide-react"; | |
| 4 | 5 | import { useMemo, useState } from "react"; |
| 5 | 6 | |
| 7 | import { useAssetUrlState } from "~/assets/assetUrls"; | |
| 6 | 8 | import { primaryServerKeybindingsAtom } from "~/state/server"; |
| 7 | import { useTheme } from "~/hooks/useTheme"; | |
| 8 | 9 | import { getLocalFileManagerName, isWindowsPlatform } from "~/lib/utils"; |
| 9 | 10 | import { CommandPaletteContent } from "../CommandPaletteContent"; |
| 10 | 11 | import type { CommandPaletteActionItem } from "../CommandPalette.logic"; |
| 11 | 12 | import { CommandPaletteResults } from "../CommandPaletteResults"; |
| 12 | import { PierreEntryIcon } from "../chat/PierreEntryIcon"; | |
| 13 | 13 | import { |
| 14 | 14 | getProjectFilePickerMatches, |
| 15 | 15 | PROJECT_FILE_PICKER_RESULT_LIMIT, |
| ... | ... | @@ -27,6 +27,51 @@ export function canPickExternalProjectFavicon(cwd: string, platform: string): bo |
| 27 | 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 | 75 | export function ProjectFaviconPickerDialog(props: { |
| 31 | 76 | readonly cwd: string; |
| 32 | 77 | readonly environmentId: EnvironmentId; |
| ... | ... | @@ -36,17 +81,17 @@ export function ProjectFaviconPickerDialog(props: { |
| 36 | 81 | readonly open: boolean; |
| 37 | 82 | readonly projectName: string; |
| 38 | 83 | }) { |
| 84 | const { cwd, environmentId, onSelect } = props; | |
| 39 | 85 | const [query, setQuery] = useState(""); |
| 40 | 86 | const [highlightedItemValue, setHighlightedItemValue] = useState<string | null>(null); |
| 41 | 87 | const [isPickingExternal, setIsPickingExternal] = useState(false); |
| 42 | 88 | const result = useProjectFilePickerQuery( |
| 43 | props.environmentId, | |
| 44 | props.cwd, | |
| 89 | environmentId, | |
| 90 | cwd, | |
| 45 | 91 | query, |
| 46 | 92 | PROJECT_FILE_PICKER_RESULT_LIMIT, |
| 47 | 93 | { imageOnly: true }, |
| 48 | 94 | ); |
| 49 | const { resolvedTheme } = useTheme(); | |
| 50 | 95 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 51 | 96 | const pickExternal = props.onPickExternal; |
| 52 | 97 | const fileManagerName = getLocalFileManagerName( |
| ... | ... | @@ -60,10 +105,10 @@ export function ProjectFaviconPickerDialog(props: { |
| 60 | 105 | searchTerms: [match.name, match.path], |
| 61 | 106 | title: match.name, |
| 62 | 107 | description: match.path, |
| 63 | icon: <PierreEntryIcon pathValue={match.path} kind="file" theme={resolvedTheme} />, | |
| 64 | run: async () => props.onSelect(match.path), | |
| 108 | icon: <ProjectIconFilePreview environmentId={environmentId} cwd={cwd} path={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 | 114 | return ( |
apps/web/src/components/settings/SettingsPanels.tsx+37-95| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { performDesktopUpdate } from "../desktopUpdate.actions"; | |
| 1 | 2 | import { SettingsGroup } from "./SettingsGroup"; |
| 2 | 3 | import { useScopedSettingsWriteAllowed } from "./useScopedSettings"; |
| 3 | 4 | import { Spinner } from "~/components/ui/spinner"; |
| ... | ... | @@ -6,7 +7,7 @@ import { PRIVACY_POLICY_URL } from "../../legalLinks"; |
| 6 | 7 | import { ArchiveIcon, ArchiveX, CheckIcon, ChevronRightIcon, SettingsIcon } from "lucide-react"; |
| 7 | 8 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 8 | 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 | 11 | import { |
| 11 | 12 | type BackgroundActivityProfile, |
| 12 | 13 | type DesktopUpdateChannel, |
| ... | ... | @@ -59,7 +60,6 @@ import { CliCommandSettingsRow } from "./CliCommandSettingsRow"; |
| 59 | 60 | import { |
| 60 | 61 | canCheckForUpdate, |
| 61 | 62 | getDesktopUpdateButtonTooltip, |
| 62 | getDesktopUpdateInstallConfirmationMessage, | |
| 63 | 63 | isDesktopUpdateButtonDisabled, |
| 64 | 64 | resolveDesktopUpdateButtonAction, |
| 65 | 65 | } from "../../components/desktopUpdate.logic"; |
| ... | ... | @@ -270,10 +270,13 @@ function backgroundActivityProfileSettings(profile: BackgroundActivityProfile) { |
| 270 | 270 | } |
| 271 | 271 | |
| 272 | 272 | function AboutVersionTitle() { |
| 273 | const updateState = useDesktopUpdateState(); | |
| 274 | const version = updateState?.uiVersion ?? updateState?.currentVersion ?? APP_VERSION; | |
| 275 | ||
| 273 | 276 | return ( |
| 274 | 277 | <span className="inline-flex items-baseline gap-2"> |
| 275 | 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 | 280 | </span> |
| 278 | 281 | ); |
| 279 | 282 | } |
| ... | ... | @@ -324,85 +327,13 @@ function AboutVersionSection() { |
| 324 | 327 | const bridge = window.desktopBridge; |
| 325 | 328 | if (!bridge) return; |
| 326 | 329 | |
| 327 | const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none"; | |
| 328 | ||
| 329 | if (action === "download") { | |
| 330 | void bridge.downloadUpdate().catch((error: unknown) => { | |
| 331 | toastManager.add( | |
| 332 | stackedThreadToast({ | |
| 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; | |
| 330 | if (!updateState || isUpdateActionPending) return; | |
| 331 | setIsUpdateActionPending(true); | |
| 332 | try { | |
| 333 | await performDesktopUpdate(bridge, updateState); | |
| 334 | } finally { | |
| 335 | setIsUpdateActionPending(false); | |
| 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 | 337 | }, [isUpdateActionPending, updateState]); |
| 407 | 338 | |
| 408 | 339 | const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none"; |
| ... | ... | @@ -411,8 +342,10 @@ function AboutVersionSection() { |
| 411 | 342 | action === "none" |
| 412 | 343 | ? !canCheckForUpdate(updateState) |
| 413 | 344 | : isDesktopUpdateButtonDisabled(updateState); |
| 414 | ||
| 415 | const actionLabel: Record<string, string> = { download: "Download", install: "Install" }; | |
| 345 | const actionLabel: Record<string, string> = { | |
| 346 | download: "Download", | |
| 347 | install: updateState?.installKind === "ui" ? "Reload" : "Restart", | |
| 348 | }; | |
| 416 | 349 | const statusLabel: Record<string, string> = { |
| 417 | 350 | checking: "Checking…", |
| 418 | 351 | downloading: "Downloading…", |
| ... | ... | @@ -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 | 1471 | function useFontDefaultFamilies() { |
| 1529 | 1472 | const settings = useScopedSettings(); |
| 1530 | // An unset preference shows the font it resolves to on this machine; the | |
| 1531 | // default stacks are the platform's own faces, so the name is probed, not | |
| 1532 | // hardcoded. | |
| 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 | [], | |
| 1473 | const sans = useSyncExternalStore( | |
| 1474 | subscribeToFontLoading, | |
| 1475 | () => resolveDefaultFamilyLabel(DEFAULT_SANS_FONT_STACK) ?? "System default", | |
| 1539 | 1476 | ); |
| 1477 | const code = useSyncExternalStore( | |
| 1478 | subscribeToFontLoading, | |
| 1479 | () => resolveDefaultFamilyLabel(DEFAULT_CODE_FONT_STACK) ?? "System monospace", | |
| 1480 | ); | |
| 1481 | ||
| 1540 | 1482 | return { |
| 1541 | sans: defaults.sans, | |
| 1542 | code: defaults.code, | |
| 1483 | sans, | |
| 1484 | code, | |
| 1543 | 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 | 6 | useMemo, |
| 7 | 7 | useRef, |
| 8 | 8 | useState, |
| 9 | type ComponentType, | |
| 10 | 9 | type KeyboardEvent, |
| 11 | 10 | } from "react"; |
| 12 | import { | |
| 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"; | |
| 11 | import { SearchIcon, XIcon } from "lucide-react"; | |
| 28 | 12 | import { useLocation, useNavigate } from "@tanstack/react-router"; |
| 29 | 13 | |
| 30 | 14 | import { Button } from "../ui/button"; |
| ... | ... | @@ -43,25 +27,13 @@ import { SidebarUtilityMenu } from "../sidebar/SidebarChrome"; |
| 43 | 27 | import { scrollToSettingsTarget } from "./settingsLayout"; |
| 44 | 28 | import { |
| 45 | 29 | searchSettings, |
| 46 | isSettingsOverviewVisible, | |
| 47 | 30 | SETTINGS_SECTION_LABELS, |
| 48 | 31 | type SettingsPath, |
| 49 | 32 | type SettingsSearchItem, |
| 50 | 33 | } from "./settingsSearch"; |
| 51 | 34 | import { useAvailableSettingsSearchItems } from "./useAvailableSettingsSearchItems"; |
| 52 | 35 | import { validateSettingsScopeSearch } from "./settingsScope"; |
| 53 | ||
| 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 | ]); | |
| 36 | import { getSettingsNavigationItems, SETTINGS_SECTION_ICONS } from "./settingsNavigation"; | |
| 65 | 37 | |
| 66 | 38 | const T3ConnectSidebarSignIn = lazy(() => |
| 67 | 39 | import("../clerk/T3ConnectSidebarSignIn").then((module) => ({ |
| ... | ... | @@ -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 | 49 | function SettingsSectionIcon({ to }: { to: SettingsPath }) { |
| 108 | 50 | const Icon = SETTINGS_SECTION_ICONS[to]; |
| 109 | 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 | 56 | const currentHash = useLocation({ select: (location) => location.hash }); |
| 115 | 57 | const currentSearch = useLocation({ select: (location) => location.search }); |
| 116 | 58 | const scopeSearch = useMemo(() => validateSettingsScopeSearch(currentSearch), [currentSearch]); |
| 117 | const navItems = SETTINGS_NAV_ITEMS.filter( | |
| 118 | (item) => item.to !== "/settings/projects" || isSettingsOverviewVisible(scopeSearch), | |
| 119 | ); | |
| 59 | const navItems = getSettingsNavigationItems(scopeSearch); | |
| 120 | 60 | const { isMobile, setOpenMobile, open, setOpen } = useSidebar(); |
| 121 | 61 | const searchInputRef = useRef<HTMLInputElement>(null); |
| 122 | 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 | 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; |
| 2 | import { CheckIcon, CircleArrowUpIcon, DownloadIcon, RotateCwIcon } from "lucide-react"; | |
| 3 | import type { AnimationEventHandler } from "react"; | |
| 2 | import { CheckIcon, DownloadIcon, RotateCwIcon } from "lucide-react"; | |
| 4 | 3 | |
| 5 | 4 | const DOWNLOAD_PROGRESS_RADIUS = 14; |
| 6 | 5 | const DOWNLOAD_PROGRESS_CIRCUMFERENCE = 2 * Math.PI * DOWNLOAD_PROGRESS_RADIUS; |
| ... | ... | @@ -10,38 +9,13 @@ export type DesktopUpdateStatusIconState = |
| 10 | 9 | | "checking" |
| 11 | 10 | | "available" |
| 12 | 11 | | "downloading" |
| 13 | | "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"; | |
| 12 | | "downloaded"; | |
| 17 | 13 | |
| 18 | 14 | function normalizeDesktopUpdateDownloadPercent(percent: number | null): number { |
| 19 | 15 | if (percent === null || !Number.isFinite(percent)) return 0; |
| 20 | 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 | 19 | function DesktopUpdateAvailableIcon() { |
| 46 | 20 | return ( |
| 47 | 21 | <span className="relative grid size-4 place-items-center"> |
| ... | ... | @@ -105,12 +79,10 @@ function DesktopUpdateDownloadedIcon() { |
| 105 | 79 | export function DesktopUpdateStatusIcon({ |
| 106 | 80 | downloadPercent, |
| 107 | 81 | isCheckAnimating, |
| 108 | onCheckAnimationIteration, | |
| 109 | 82 | status, |
| 110 | 83 | }: { |
| 111 | 84 | readonly downloadPercent?: number | null; |
| 112 | 85 | readonly isCheckAnimating?: boolean; |
| 113 | readonly onCheckAnimationIteration?: AnimationEventHandler<SVGSVGElement>; | |
| 114 | 86 | readonly status: DesktopUpdateStatusIconState; |
| 115 | 87 | }) { |
| 116 | 88 | if (status === "available") return <DesktopUpdateAvailableIcon />; |
| ... | ... | @@ -118,13 +90,11 @@ export function DesktopUpdateStatusIcon({ |
| 118 | 90 | return <DesktopUpdateDownloadingIcon percent={downloadPercent ?? null} />; |
| 119 | 91 | } |
| 120 | 92 | if (status === "downloaded") return <DesktopUpdateDownloadedIcon />; |
| 121 | if (status === "everything") return <CircleArrowUpIcon className="size-4" />; | |
| 122 | 93 | |
| 123 | 94 | return ( |
| 124 | 95 | <RefreshIcon |
| 125 | 96 | className="size-4" |
| 126 | 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 | import { ArrowLeftIcon, ChartNoAxesColumnIcon, SettingsIcon } from "lucide-react"; | |
| 1 | import { ArrowLeftIcon, ChartNoAxesColumnIcon } from "lucide-react"; | |
| 2 | 2 | import type { ReactNode } from "react"; |
| 3 | 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 | 6 | import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; |
| 7 | 7 | import { cn } from "../../lib/utils"; |
| 8 | 8 | import { usePullRequestsSupported } from "../../state/environments"; |
| 9 | import { T3Wordmark } from "../T3Wordmark"; | |
| 10 | 9 | import { |
| 11 | 10 | resolveEnvironmentIdentificationPillLabel, |
| 12 | resolveSidebarStageBackdropVariant, | |
| 13 | SidebarStageBackdrop, | |
| 14 | 11 | useEnvironmentStageLabel, |
| 15 | 12 | } from "../SidebarStageBackdrop"; |
| 16 | 13 | import { Badge } from "../ui/badge"; |
| ... | ... | @@ -26,10 +23,8 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 26 | 23 | import { readPullRequestListPreferences } from "../pullRequest/pullRequestListPreferences"; |
| 27 | 24 | import { isSidebarUtilityPage, useNavigateToMainApp } from "./mainAppLocation"; |
| 28 | 25 | import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice"; |
| 29 | import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill"; | |
| 30 | import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill"; | |
| 26 | import { AppMenu } from "./AppMenu"; | |
| 31 | 27 | import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; |
| 32 | import { observeResize } from "~/lib/observeResize"; | |
| 33 | 28 | |
| 34 | 29 | export const SidebarChromeHeader = memo(function SidebarChromeHeader({ |
| 35 | 30 | isElectron, |
| ... | ... | @@ -38,10 +33,6 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({ |
| 38 | 33 | }) { |
| 39 | 34 | const stageLabel = useEnvironmentStageLabel(); |
| 40 | 35 | const environmentIdentificationMode = useEnvironmentIdentificationMode(); |
| 41 | const backdropVariant = resolveSidebarStageBackdropVariant( | |
| 42 | stageLabel, | |
| 43 | environmentIdentificationMode === "artwork", | |
| 44 | ); | |
| 45 | 36 | const pillLabel = |
| 46 | 37 | environmentIdentificationMode === "pill" |
| 47 | 38 | ? resolveEnvironmentIdentificationPillLabel(stageLabel) |
| ... | ... | @@ -55,94 +46,24 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({ |
| 55 | 46 | isElectron && "drag-region", |
| 56 | 47 | )} |
| 57 | 48 | > |
| 58 | {backdropVariant ? <SidebarStageBackdrop variant={backdropVariant} /> : null} | |
| 59 | 49 | {/* Match the fixed control's inset and border compensation inside the sheet's safe area. */} |
| 60 | 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 | |
| 62 | // Over the stage artwork: the media viewer's control-on-imagery treatment. | |
| 63 | variant={backdropVariant ? "media-navigation" : "ghost"} | |
| 64 | className="relative top-auto translate-y-0" | |
| 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} | |
| 51 | <div className="flex items-center [-webkit-app-region:no-drag]"> | |
| 52 | <AppMenu /> | |
| 53 | </div> | |
| 54 | <SidebarTrigger aria-label="Toggle main sidebar" /> | |
| 79 | 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 | 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 | 67 | function SidebarUtilityItem({ |
| 147 | 68 | icon, |
| 148 | 69 | label, |
| ... | ... | @@ -188,11 +109,6 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { |
| 188 | 109 | search: readPullRequestListPreferences(), |
| 189 | 110 | }); |
| 190 | 111 | }, [closeMobileSidebar, navigate]); |
| 191 | const handleSettingsClick = useCallback(() => { | |
| 192 | closeMobileSidebar(); | |
| 193 | void navigate({ to: "/settings" }); | |
| 194 | }, [closeMobileSidebar, navigate]); | |
| 195 | ||
| 196 | 112 | const handleUsageClick = useCallback(() => { |
| 197 | 113 | if (isMobile) { |
| 198 | 114 | setOpenMobile(false); |
| ... | ... | @@ -216,11 +132,6 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { |
| 216 | 132 | </SidebarMenuItem> |
| 217 | 133 | ) : ( |
| 218 | 134 | <> |
| 219 | <SidebarUtilityItem | |
| 220 | icon={<SettingsIcon />} | |
| 221 | label="Settings" | |
| 222 | onClick={handleSettingsClick} | |
| 223 | /> | |
| 224 | 135 | {pullRequestsSupported ? ( |
| 225 | 136 | <SidebarUtilityItem |
| 226 | 137 | icon={<PullRequestGlyph.pullRequest />} |
| ... | ... | @@ -235,7 +146,6 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { |
| 235 | 146 | /> |
| 236 | 147 | </> |
| 237 | 148 | )} |
| 238 | <SidebarUpdatePill /> | |
| 239 | 149 | </SidebarMenu> |
| 240 | 150 | ); |
| 241 | 151 | }); |
| ... | ... | @@ -244,8 +154,6 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() { |
| 244 | 154 | return ( |
| 245 | 155 | <SidebarFooter> |
| 246 | 156 | <SidebarThreadUndoNotice /> |
| 247 | <SidebarProviderUpdatePill /> | |
| 248 | <SidebarUpdateArchitectureWarning /> | |
| 249 | 157 | <SidebarUtilityMenu /> |
| 250 | 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 | // @vitest-environment jsdom | |
| 1 | 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 { | |
| 5 | handleSidebarUpdateReleaseNotesPopoverOpenChange, | |
| 6 | openSidebarUpdateReleaseNotesPopoverOnForwardTab, | |
| 7 | shouldUseSidebarUpdateReleaseNotesPopover, | |
| 8 | } from "./SidebarUpdatePill"; | |
| 7 | const test = vi.hoisted(() => ({ | |
| 8 | state: null as DesktopUpdateState | null, | |
| 9 | run: vi.fn().mockResolvedValue(undefined), | |
| 10 | hasUpdates: true, | |
| 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 | 44 | enabled: true, |
| 12 | status: "available", | |
| 13 | channel: "nightly", | |
| 14 | currentVersion: "0.0.35", | |
| 45 | status: "idle", | |
| 46 | channel: "latest", | |
| 47 | currentVersion: "1.0.0", | |
| 15 | 48 | hostArch: "arm64", |
| 16 | 49 | appArch: "arm64", |
| 17 | 50 | runningUnderArm64Translation: false, |
| 18 | availableVersion: "0.0.36-nightly.3", | |
| 51 | availableVersion: null, | |
| 19 | 52 | downloadedVersion: null, |
| 20 | releaseNotes: [{ version: "0.0.36-nightly.3", items: ["Newest change"], totalItems: 1 }], | |
| 53 | releaseNotes: [], | |
| 21 | 54 | omittedReleaseCount: 0, |
| 22 | 55 | downloadPercent: null, |
| 23 | 56 | checkedAt: null, |
| ... | ... | @@ -26,61 +59,172 @@ const nightlyState: DesktopUpdateState = { |
| 26 | 59 | canRetry: false, |
| 27 | 60 | }; |
| 28 | 61 | |
| 29 | describe("sidebar update release notes popover", () => { | |
| 30 | it("uses the popover only for visible nightly release notes", () => { | |
| 31 | expect(shouldUseSidebarUpdateReleaseNotesPopover(true, nightlyState)).toBe(true); | |
| 32 | expect(shouldUseSidebarUpdateReleaseNotesPopover(false, nightlyState)).toBe(false); | |
| 33 | expect( | |
| 34 | shouldUseSidebarUpdateReleaseNotesPopover(true, { | |
| 35 | ...nightlyState, | |
| 36 | channel: "latest", | |
| 37 | }), | |
| 38 | ).toBe(false); | |
| 39 | expect( | |
| 40 | shouldUseSidebarUpdateReleaseNotesPopover(true, { | |
| 41 | ...nightlyState, | |
| 42 | releaseNotes: [], | |
| 43 | }), | |
| 44 | ).toBe(false); | |
| 62 | let root: Root; | |
| 63 | let container: HTMLDivElement; | |
| 64 | ||
| 65 | async function click(label: string) { | |
| 66 | const button = Array.from(document.querySelectorAll("button")).find( | |
| 67 | (element) => element.textContent?.trim() === label, | |
| 68 | ); | |
| 69 | expect(button, `Button ${label}`).toBeDefined(); | |
| 70 | await act(async () => button?.click()); | |
| 71 | } | |
| 72 | ||
| 73 | beforeEach(() => { | |
| 74 | test.state = baseState; | |
| 75 | test.hasUpdates = true; | |
| 76 | test.restarts = []; | |
| 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", () => { | |
| 48 | const cancelTriggerPress = vi.fn(); | |
| 49 | const cancelHover = vi.fn(); | |
| 102 | it("waits for confirmation when a remote server will restart", async () => { | |
| 103 | test.restarts = [{ environmentId: "home", continuesThreads: false }]; | |
| 104 | await act(async () => root.render(<AppMenuUpdates />)); | |
| 105 | await click("Update all"); | |
| 50 | 106 | |
| 51 | handleSidebarUpdateReleaseNotesPopoverOpenChange(true, { | |
| 52 | reason: "trigger-press", | |
| 53 | cancel: cancelTriggerPress, | |
| 54 | }); | |
| 55 | handleSidebarUpdateReleaseNotesPopoverOpenChange(true, { | |
| 56 | reason: "trigger-hover", | |
| 57 | cancel: cancelHover, | |
| 58 | }); | |
| 107 | expect(test.run).not.toHaveBeenCalled(); | |
| 108 | expect(document.querySelector('[role="alertdialog"]')).not.toBeNull(); | |
| 109 | await click("Update"); | |
| 110 | expect(test.run).toHaveBeenCalledOnce(); | |
| 111 | }); | |
| 59 | 112 | |
| 60 | expect(cancelTriggerPress).toHaveBeenCalledOnce(); | |
| 61 | expect(cancelHover).not.toHaveBeenCalled(); | |
| 113 | it("keeps an app update behind restart confirmation even without connected servers", async () => { | |
| 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", () => { | |
| 65 | const open = vi.fn(); | |
| 66 | const preventDefault = vi.fn(); | |
| 67 | const event = { key: "Tab", shiftKey: false, preventDefault }; | |
| 125 | it("reports a failed update run and allows retry", async () => { | |
| 126 | test.run.mockRejectedValueOnce(new Error("Disconnected")); | |
| 127 | await act(async () => root.render(<AppMenuUpdates />)); | |
| 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"); | |
| 72 | expect(preventDefault).not.toHaveBeenCalled(); | |
| 160 | expect(downloadUpdate).toHaveBeenCalledOnce(); | |
| 161 | expect(installUpdate).not.toHaveBeenCalled(); | |
| 73 | 162 | }); |
| 74 | 163 | |
| 75 | it("does not promote backward Tab", () => { | |
| 76 | const open = vi.fn(); | |
| 164 | it("reloads a compatible UI update without asking to interrupt work", async () => { | |
| 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( | |
| 79 | { key: "Tab", shiftKey: true }, | |
| 80 | { open }, | |
| 81 | "nightly-release-notes", | |
| 82 | ); | |
| 184 | it.each([false, true])( | |
| 185 | "requires confirmation before a full-app restart (confirm=%s)", | |
| 186 | async (accepted) => { | |
| 187 | test.state = { | |
| 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 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts"; | |
| 2 | import type { DesktopUpdateState } from "@t3tools/contracts"; | |
| 3 | 3 | import { Atom } from "effect/reactivity"; |
| 4 | 4 | import { TriangleAlertIcon } from "lucide-react"; |
| 5 | import { | |
| 6 | type ComponentProps, | |
| 7 | useCallback, | |
| 8 | useEffect, | |
| 9 | useId, | |
| 10 | useMemo, | |
| 11 | useRef, | |
| 12 | useState, | |
| 13 | } from "react"; | |
| 14 | import { flushSync } from "react-dom"; | |
| 5 | import { useMemo, useRef, useState } from "react"; | |
| 6 | ||
| 15 | 7 | import { isElectron } from "../../env"; |
| 16 | 8 | import { useMediaQuery } from "../../hooks/useMediaQuery"; |
| 17 | import { cn } from "../../lib/utils"; | |
| 18 | import { ensureLocalApi } from "../../localApi"; | |
| 19 | 9 | import { useDesktopUpdateState } from "../../state/desktopUpdate"; |
| 20 | 10 | import { serverEnvironment } from "../../state/server"; |
| 21 | import { stackedThreadToast, toastManager } from "../ui/toast"; | |
| 11 | import { Button } from "../ui/button"; | |
| 22 | 12 | import { |
| 23 | 13 | canCheckForUpdate, |
| 24 | 14 | getArm64IntelBuildWarningDescription, |
| 25 | getDesktopUpdateActionError, | |
| 26 | 15 | getDesktopUpdateButtonTooltip, |
| 27 | 16 | getDesktopUpdateDownloadedVersion, |
| 28 | getDesktopUpdateInstallConfirmationMessage, | |
| 29 | 17 | isDesktopUpdateButtonDisabled, |
| 30 | 18 | resolveDesktopUpdateButtonAction, |
| 31 | 19 | shouldShowArm64IntelBuildWarning, |
| 32 | shouldToastDesktopUpdateActionResult, | |
| 33 | 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 | 21 | import { |
| 40 | DesktopUpdateStatusIcon, | |
| 41 | type DesktopUpdateStatusIconState, | |
| 42 | shouldContinueDesktopUpdateCheckAnimation, | |
| 43 | shouldShowDesktopUpdateCheckIcon, | |
| 44 | } from "./DesktopUpdateStatusIcon"; | |
| 22 | downloadLocalUpdate, | |
| 23 | installLocalUpdate, | |
| 24 | performDesktopUpdate, | |
| 25 | toastUpdateError, | |
| 26 | } from "../desktopUpdate.actions"; | |
| 27 | import { DesktopUpdateStatusIcon } from "./DesktopUpdateStatusIcon"; | |
| 45 | 28 | import { SidebarUpdateReleaseNotes } from "./SidebarUpdateReleaseNotes"; |
| 46 | 29 | import { UpdateEverythingDialog } from "./UpdateEverythingDialog"; |
| 47 | 30 | import { |
| ... | ... | @@ -50,163 +33,11 @@ import { |
| 50 | 33 | type UpdateEverythingRun, |
| 51 | 34 | } from "./updateEverything.logic"; |
| 52 | 35 | import { useUpdateEverythingRun } from "./updateEverythingRun"; |
| 53 | import { type ReportLocalUpdateError, 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; | |
| 36 | import { useUpdateEverything } from "./useUpdateEverything"; | |
| 134 | 37 | |
| 135 | return ( | |
| 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( | |
| 38 | function useUpdateRunStatus( | |
| 207 | 39 | run: UpdateEverythingRun | null, |
| 208 | 40 | state: DesktopUpdateState | null, |
| 209 | localReady: boolean, | |
| 210 | 41 | ): string | null { |
| 211 | 42 | const statusesAtom = useMemo( |
| 212 | 43 | () => |
| ... | ... | @@ -233,178 +64,49 @@ function useUpdateRunTooltip( |
| 233 | 64 | ? `downloading${state.downloadPercent === null ? "" : ` ${Math.floor(state.downloadPercent)}%`}` |
| 234 | 65 | : run.phase === "installing" |
| 235 | 66 | ? "restarting" |
| 236 | : localReady | |
| 67 | : state && resolveDesktopUpdateButtonAction(state) === "install" | |
| 237 | 68 | ? "restarts last" |
| 238 | 69 | : "waiting"; |
| 239 | 70 | return describeUpdateRun(statuses, local).join("\n"); |
| 240 | 71 | } |
| 241 | 72 | |
| 242 | /** | |
| 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() { | |
| 73 | export function AppMenuUpdates() { | |
| 253 | 74 | const state = useDesktopUpdateState(); |
| 254 | 75 | const [isActionPending, setIsActionPending] = useState(false); |
| 255 | 76 | const [isConfirmingEverything, setIsConfirmingEverything] = useState(false); |
| 256 | const [checkAnimationKey, setCheckAnimationKey] = useState(0); | |
| 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(); | |
| 77 | const pending = useRef(false); | |
| 262 | 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 | 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 | 80 | const localUpdateVersion = |
| 276 | 81 | state && action !== "none" ? getDesktopUpdateDownloadedVersion(state) : null; |
| 277 | const everything = useUpdateEverything(localUpdateVersion); | |
| 278 | const runsEverything = everything.hasUpdates && !isDownloading; | |
| 279 | // Providers leave the list once queued, so the run keeps its own state. | |
| 82 | const restartsApp = state?.installKind !== "ui"; | |
| 83 | const everything = useUpdateEverything(restartsApp ? localUpdateVersion : null); | |
| 280 | 84 | const run = useUpdateEverythingRun(); |
| 281 | const isRunningEverything = run !== null; | |
| 282 | const runTooltip = useUpdateRunTooltip(run, state, action === "install"); | |
| 283 | const showCheckIcon = shouldShowDesktopUpdateCheckIcon({ | |
| 284 | isAnimationLatched: isCheckAnimationLatched, | |
| 285 | isChecking: state?.status === "checking", | |
| 286 | prefersReducedMotion, | |
| 287 | }); | |
| 288 | const { iconStatus, showUpdateDetails, showUpdateIconState } = resolveSidebarUpdatePresentation({ | |
| 289 | action, | |
| 290 | showsEverything: runsEverything, | |
| 291 | run, | |
| 292 | isDownloading, | |
| 293 | showCheckIcon, | |
| 294 | }); | |
| 295 | const tooltip = | |
| 296 | runTooltip !== null | |
| 297 | ? runTooltip | |
| 298 | : runsEverything | |
| 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]); | |
| 85 | const runStatus = useUpdateRunStatus(run, state); | |
| 86 | const isDownloading = state?.status === "downloading"; | |
| 87 | const isBusy = isActionPending || run !== null; | |
| 88 | const hasUpdates = localUpdateVersion !== null || everything.hasUpdates; | |
| 89 | const appActionLabel = | |
| 90 | state?.status === "checking" | |
| 91 | ? "Checking…" | |
| 92 | : isDownloading | |
| 93 | ? "Downloading…" | |
| 94 | : action === "download" | |
| 95 | ? state?.status === "error" | |
| 96 | ? "Retry download" | |
| 97 | : "Download" | |
| 98 | : action === "install" | |
| 99 | ? restartsApp | |
| 100 | ? "Restart" | |
| 101 | : "Reload" | |
| 102 | : "Check"; | |
| 403 | 103 | |
| 404 | const runEverything = useCallback(async () => { | |
| 405 | const bridge = window.desktopBridge; | |
| 104 | const runEverything = async () => { | |
| 105 | if (pending.current || run !== null) return; | |
| 106 | pending.current = true; | |
| 406 | 107 | setIsConfirmingEverything(false); |
| 407 | 108 | setIsActionPending(true); |
| 109 | const bridge = window.desktopBridge; | |
| 408 | 110 | try { |
| 409 | 111 | await everything.run({ |
| 410 | 112 | ...(bridge && action === "download" |
| ... | ... | @@ -413,152 +115,152 @@ function SidebarUpdateControl() { |
| 413 | 115 | downloadLocalUpdate(bridge, { notifyDownloaded: false, reportError }), |
| 414 | 116 | } |
| 415 | 117 | : {}), |
| 416 | ...(bridge && action !== "none" | |
| 417 | ? { installLocal: (reportError) => installLocalUpdate(bridge, reportError) } | |
| 118 | ...(bridge && restartsApp && action !== "none" | |
| 119 | ? { | |
| 120 | installLocal: (reportError) => installLocalUpdate(bridge, reportError), | |
| 121 | } | |
| 418 | 122 | : {}), |
| 419 | 123 | }); |
| 124 | if (bridge && !restartsApp && action === "install") await installLocalUpdate(bridge); | |
| 420 | 125 | } catch (error) { |
| 421 | toastManager.add( | |
| 422 | stackedThreadToast({ | |
| 423 | type: "error", | |
| 424 | title: "Could not update everything", | |
| 425 | description: error instanceof Error ? error.message : "An unexpected error occurred.", | |
| 426 | }), | |
| 126 | toastUpdateError( | |
| 127 | "Could not update everything", | |
| 128 | error instanceof Error ? error.message : "Try again.", | |
| 427 | 129 | ); |
| 428 | 130 | } finally { |
| 131 | pending.current = false; | |
| 429 | 132 | setIsActionPending(false); |
| 430 | 133 | } |
| 431 | }, [action, everything]); | |
| 432 | ||
| 433 | const handleCheckAnimationIteration = useCallback(() => { | |
| 434 | setIsCheckAnimationLatched( | |
| 435 | shouldContinueDesktopUpdateCheckAnimation({ | |
| 436 | isChecking: state?.status === "checking", | |
| 437 | prefersReducedMotion, | |
| 438 | }), | |
| 439 | ); | |
| 440 | }, [prefersReducedMotion, state?.status]); | |
| 134 | }; | |
| 441 | 135 | |
| 442 | // Web has no local app to check, so it only shows the button for remote work. | |
| 443 | if (!isElectron && !runsEverything && !isRunningEverything) return null; | |
| 136 | const handleAppAction = async () => { | |
| 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 = ( | |
| 446 | <button | |
| 447 | type="button" | |
| 448 | aria-label={tooltip} | |
| 449 | aria-disabled={isInteractionDisabled || undefined} | |
| 450 | className={cn( | |
| 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", | |
| 452 | isInteractionDisabled ? "cursor-not-allowed" : "cursor-pointer", | |
| 453 | showUpdateIconState | |
| 454 | ? cn( | |
| 455 | "bg-sidebar-control-surface text-sidebar-foreground", | |
| 456 | !isInteractionDisabled && "hover:bg-sidebar-row-hover", | |
| 457 | ) | |
| 458 | : cn( | |
| 459 | "text-(--sidebar-icon-color)", | |
| 460 | !isInteractionDisabled && "hover:bg-sidebar-row-hover hover:text-sidebar-foreground", | |
| 461 | ), | |
| 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 | ); | |
| 149 | const showArchitectureWarning = shouldShowArm64IntelBuildWarning(state); | |
| 150 | const appStatus = | |
| 151 | state?.status === "checking" | |
| 152 | ? "Checking for updates…" | |
| 153 | : state | |
| 154 | ? getDesktopUpdateButtonTooltip(state) | |
| 155 | : "Loading update status…"; | |
| 156 | const iconStatus = | |
| 157 | state?.status === "checking" | |
| 158 | ? "checking" | |
| 159 | : isDownloading | |
| 160 | ? "downloading" | |
| 161 | : action === "install" | |
| 162 | ? "downloaded" | |
| 163 | : action === "download" | |
| 164 | ? "available" | |
| 165 | : "idle"; | |
| 494 | 166 | |
| 495 | 167 | return ( |
| 496 | <SidebarMenuItem className="ml-auto shrink-0"> | |
| 497 | <Popover | |
| 498 | handle={releaseNotesPopoverHandle} | |
| 499 | onOpenChange={(open, details) => { | |
| 500 | if (open && !showReleaseNotesPopover) { | |
| 501 | details.cancel(); | |
| 502 | return; | |
| 503 | } | |
| 504 | handleSidebarUpdateReleaseNotesPopoverOpenChange(open, details); | |
| 505 | }} | |
| 506 | > | |
| 507 | <Tooltip disabled={showReleaseNotesPopover}> | |
| 508 | <TooltipTrigger | |
| 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; | |
| 168 | <section aria-label="Updates" className="space-y-1"> | |
| 169 | <div className="flex min-h-7 items-center justify-between gap-2 px-1.5"> | |
| 170 | <h3 className="text-xs font-medium text-muted-foreground">Updates</h3> | |
| 171 | {hasUpdates || run !== null ? ( | |
| 172 | <Button | |
| 173 | size="xs" | |
| 174 | variant="outline" | |
| 175 | disabled={isBusy || isDownloading} | |
| 176 | onClick={() => { | |
| 177 | if (everything.restarts.length > 0 || (restartsApp && localUpdateVersion !== null)) { | |
| 178 | setIsConfirmingEverything(true); | |
| 179 | } else { | |
| 180 | void runEverything(); | |
| 548 | 181 | } |
| 549 | 182 | }} |
| 550 | ref={releaseNotesPopupRef} | |
| 551 | side="top" | |
| 552 | tooltipStyle | |
| 553 | 183 | > |
| 554 | <SidebarUpdateReleaseNotes | |
| 555 | shell={window.desktopBridge} | |
| 556 | state={state} | |
| 557 | tooltip={tooltip} | |
| 558 | /> | |
| 559 | </PopoverPopup> | |
| 184 | {run !== null ? "Updating…" : "Update all"} | |
| 185 | </Button> | |
| 560 | 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 | 264 | <UpdateEverythingDialog |
| 563 | 265 | open={isConfirmingEverything} |
| 564 | 266 | onOpenChange={setIsConfirmingEverything} |
| ... | ... | @@ -568,6 +270,6 @@ function SidebarUpdateControl() { |
| 568 | 270 | skipped={everything.skipped} |
| 569 | 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 | 15 | type UpdateEverythingRun, |
| 16 | 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 | 19 | const updateEverythingRunAtom = Atom.make<UpdateEverythingRun | null>(null).pipe( |
| 20 | 20 | Atom.keepAlive, |
| 21 | 21 | Atom.withLabel("update-everything-run"), |
| ... | ... | @@ -133,7 +133,7 @@ export function DesktopUpdateLaunchNotice() { |
| 133 | 133 | stackedThreadToast({ |
| 134 | 134 | type: "error", |
| 135 | 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 | 139 | }, [notAppliedVersion]); |
apps/web/src/components/sidebar/useUpdateEverything.ts+2-1| ... | ... | @@ -34,7 +34,7 @@ import { |
| 34 | 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 | 38 | * connected machine, and servers behind the version this client runs or is |
| 39 | 39 | * about to install (`localUpdateVersion`). Servers that install from npm appear |
| 40 | 40 | * only once npm confirms that version. `run` performs the whole pass, including |
| ... | ... | @@ -104,6 +104,7 @@ export function useUpdateEverything(localUpdateVersion: string | null) { |
| 104 | 104 | }); |
| 105 | 105 | |
| 106 | 106 | return { |
| 107 | servers, | |
| 107 | 108 | hasUpdates: servers.length > 0 || providers.machines.length > 0, |
| 108 | 109 | /** Short label for the tooltip and the confirmation title. */ |
| 109 | 110 | summary, |
apps/web/src/components/threadSidebarWidth.test.ts+7-19| ... | ... | @@ -2,35 +2,23 @@ import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | import { |
| 3 | 3 | clampThreadSidebarWidth, |
| 4 | 4 | resolveThreadSidebarMaximumWidth, |
| 5 | resolveThreadSidebarMinimumWidth, | |
| 6 | 5 | THREAD_SIDEBAR_MIN_WIDTH, |
| 7 | 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 | 8 | describe("resolveThreadSidebarMaximumWidth", () => { |
| 21 | it("never drops below a raised minimum on a narrow viewport", () => { | |
| 22 | expect(resolveThreadSidebarMaximumWidth(800, 238)).toBe(238); | |
| 23 | expect(resolveThreadSidebarMaximumWidth(1200, 238)).toBe(560); | |
| 9 | it("keeps the sidebar usable on a narrow viewport", () => { | |
| 10 | expect(resolveThreadSidebarMaximumWidth(800)).toBe(THREAD_SIDEBAR_MIN_WIDTH); | |
| 11 | expect(resolveThreadSidebarMaximumWidth(1200)).toBe(560); | |
| 24 | 12 | }); |
| 25 | 13 | }); |
| 26 | 14 | |
| 27 | 15 | describe("clampThreadSidebarWidth", () => { |
| 28 | it("widens a stored width below a raised minimum", () => { | |
| 29 | expect(clampThreadSidebarWidth(208, 238, 560)).toBe(238); | |
| 16 | it("widens a stored width below the minimum", () => { | |
| 17 | expect(clampThreadSidebarWidth(180, 560)).toBe(THREAD_SIDEBAR_MIN_WIDTH); | |
| 30 | 18 | }); |
| 31 | 19 | |
| 32 | 20 | it("keeps widths inside the range and caps wide ones", () => { |
| 33 | expect(clampThreadSidebarWidth(300, 238, 560)).toBe(300); | |
| 34 | expect(clampThreadSidebarWidth(900, 238, 560)).toBe(560); | |
| 21 | expect(clampThreadSidebarWidth(300, 560)).toBe(300); | |
| 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 | 3 | export const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; |
| 4 | 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. | |
| 7 | export function resolveThreadSidebarMinimumWidth(brandWidth: number): number { | |
| 8 | return Math.max(THREAD_SIDEBAR_MIN_WIDTH, Math.ceil(brandWidth)); | |
| 6 | export function resolveThreadSidebarMaximumWidth(viewportWidth: number): number { | |
| 7 | return Math.max( | |
| 8 | THREAD_SIDEBAR_MIN_WIDTH, | |
| 9 | Math.floor(viewportWidth) - THREAD_MAIN_CONTENT_MIN_WIDTH, | |
| 10 | ); | |
| 9 | 11 | } |
| 10 | 12 | |
| 11 | export function resolveThreadSidebarMaximumWidth( | |
| 12 | viewportWidth: number, | |
| 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)); | |
| 13 | export function clampThreadSidebarWidth(width: number, maximumWidth: number): number { | |
| 14 | return Math.min(maximumWidth, Math.max(THREAD_SIDEBAR_MIN_WIDTH, width)); | |
| 24 | 15 | } |
| 25 | 16 | |
| 26 | 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 | 1 | "use client"; |
| 2 | 2 | |
| 3 | 3 | import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"; |
| 4 | import { useRef } from "react"; | |
| 4 | 5 | |
| 5 | 6 | import { cn } from "~/lib/utils"; |
| 6 | 7 | import { |
| ... | ... | @@ -41,11 +42,14 @@ function AlertDialogPopup({ |
| 41 | 42 | className, |
| 42 | 43 | bottomStickOnMobile = true, |
| 43 | 44 | portalContainer, |
| 45 | initialFocus, | |
| 46 | ref, | |
| 44 | 47 | ...props |
| 45 | 48 | }: AlertDialogPrimitive.Popup.Props & { |
| 46 | 49 | bottomStickOnMobile?: boolean; |
| 47 | 50 | portalContainer?: AlertDialogPrimitive.Portal.Props["container"]; |
| 48 | 51 | }) { |
| 52 | const popup = useRef<HTMLDivElement | null>(null); | |
| 49 | 53 | return ( |
| 50 | 54 | <AlertDialogPortal container={portalContainer}> |
| 51 | 55 | <AlertDialogBackdrop /> |
| ... | ... | @@ -53,6 +57,21 @@ function AlertDialogPopup({ |
| 53 | 57 | className={cn(bottomStickOnMobile && "max-sm:grid-rows-[1fr_auto] max-sm:p-0 max-sm:pt-12")} |
| 54 | 58 | > |
| 55 | 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 | 75 | className={cn( |
| 57 | 76 | DIALOG_POPUP_CLASS, |
| 58 | 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 | 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 | 35 | @custom-variant dark (&:is(.dark, .dark *)); |
| 4 | 36 | @custom-variant light (&:not(.dark, .dark *)); |
| 5 | 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 | 119 | } |
| 88 | 120 | |
| 89 | 121 | :root { |
| 122 | --clo-term-font: "AT Name Mono"; | |
| 90 | 123 | --app-scrollbar-width: 6px; |
| 91 | 124 | --appearance-contrast-base: 100%; |
| 92 | 125 | --appearance-contrast-boost: 0%; |
| ... | ... | @@ -164,9 +197,9 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 164 | 197 | the variables and Settings -> Appearance can override them at runtime. The |
| 165 | 198 | default stacks are mirrored in `appearanceFonts.ts`. */ |
| 166 | 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 | 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 | 205 | @theme inline { |
| ... | ... | @@ -493,31 +526,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 493 | 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 | 529 | @keyframes live-tool-shine { |
| 522 | 530 | from { |
| 523 | 531 | background-position: |
| ... | ... | @@ -1146,7 +1154,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 1146 | 1154 | --sidebar-row-active: var(--color-white); |
| 1147 | 1155 | --sidebar-row-selected: var(--color-white); |
| 1148 | 1156 | --sidebar-border: var(--border); |
| 1149 | --sidebar-stage-fade: var(--sidebar); | |
| 1150 | 1157 | --code-background: color-mix(in srgb, var(--card) 90%, var(--background)); |
| 1151 | 1158 | --code-foreground: var(--foreground); |
| 1152 | 1159 | --terminal-background: var(--background); |
| ... | ... | @@ -1189,7 +1196,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 1189 | 1196 | --sidebar-row-hover: var(--accent); |
| 1190 | 1197 | --sidebar-row-active: var(--accent); |
| 1191 | 1198 | --sidebar-row-selected: var(--muted); |
| 1192 | --sidebar-stage-fade: var(--card); | |
| 1193 | 1199 | --terminal-cursor: rgb(180 203 255); |
| 1194 | 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 | 1223 | --sidebar-row-active: var(--color-white); |
| 1218 | 1224 | --sidebar-row-selected: var(--color-white); |
| 1219 | 1225 | --sidebar-border: var(--color-zinc-200); |
| 1220 | --sidebar-stage-fade: var(--sidebar); | |
| 1221 | 1226 | |
| 1222 | 1227 | @variant dark { |
| 1223 | 1228 | --background: #000; |
| ... | ... | @@ -1238,7 +1243,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 1238 | 1243 | --sidebar-row-active: color-mix(in srgb, var(--contrast-foreground) 11%, transparent); |
| 1239 | 1244 | --sidebar-row-selected: color-mix(in srgb, var(--contrast-foreground) 7%, transparent); |
| 1240 | 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 | 1311 | --sidebar-row-active: var(--app-theme-sidebar-row-active); |
| 1308 | 1312 | --sidebar-row-selected: var(--app-theme-sidebar-row-selected); |
| 1309 | 1313 | --sidebar-border: var(--app-theme-sidebar-border); |
| 1310 | --sidebar-stage-fade: var(--app-theme-sidebar); | |
| 1311 | 1314 | --code-background: var(--app-theme-code-background); |
| 1312 | 1315 | --code-foreground: var(--app-theme-code-foreground); |
| 1313 | 1316 | --terminal-background: var(--app-theme-terminal-background); |
| ... | ... | @@ -1507,7 +1510,6 @@ html[data-theme-id] [data-app-sidebar] { |
| 1507 | 1510 | --sidebar-row-active: var(--app-theme-sidebar-row-active); |
| 1508 | 1511 | --sidebar-row-selected: var(--app-theme-sidebar-row-selected); |
| 1509 | 1512 | --sidebar-border: var(--app-theme-sidebar-border); |
| 1510 | --sidebar-stage-fade: var(--app-theme-sidebar); | |
| 1511 | 1513 | border-color: color-mix(in srgb, var(--contrast-sidebar-foreground) 10%, transparent); |
| 1512 | 1514 | |
| 1513 | 1515 | @variant dark { |
apps/web/src/rightPanelStore.test.ts+6-6| ... | ... | @@ -565,7 +565,7 @@ describe("rightPanelStore", () => { |
| 565 | 565 | ).toEqual({ |
| 566 | 566 | byThreadKey: {}, |
| 567 | 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 | 574 | const store = useRightPanelStore.getState(); |
| 575 | 575 | |
| 576 | 576 | expect(selectThreadPanelOpen(store.threadPanelVisibilityByThreadKey, refA, "inline")).toBe( |
| 577 | true, | |
| 577 | false, | |
| 578 | 578 | ); |
| 579 | 579 | expect(selectThreadPanelOpen(store.threadPanelVisibilityByThreadKey, refA, "popover")).toBe( |
| 580 | 580 | false, |
| 581 | 581 | ); |
| 582 | 582 | |
| 583 | store.setThreadPanelOpen(refA, "inline", false); | |
| 583 | store.toggleThreadPanel(refA, "inline"); | |
| 584 | 584 | store.toggleThreadPanel(refA, "popover"); |
| 585 | 585 | |
| 586 | 586 | expect( |
| ... | ... | @@ -588,13 +588,13 @@ describe("rightPanelStore", () => { |
| 588 | 588 | useRightPanelStore.getState().threadPanelVisibilityByThreadKey, |
| 589 | 589 | refA, |
| 590 | 590 | ), |
| 591 | ).toEqual({ inlineOpen: false, popoverOpen: true }); | |
| 591 | ).toEqual({ inlineOpen: true, popoverOpen: true }); | |
| 592 | 592 | expect( |
| 593 | 593 | selectThreadPanelVisibility( |
| 594 | 594 | useRightPanelStore.getState().threadPanelVisibilityByThreadKey, |
| 595 | 595 | refB, |
| 596 | 596 | ), |
| 597 | ).toEqual({ inlineOpen: true, popoverOpen: false }); | |
| 597 | ).toEqual({ inlineOpen: false, popoverOpen: false }); | |
| 598 | 598 | }); |
| 599 | 599 | |
| 600 | 600 | it("closes the popover atomically when the real right panel opens", () => { |
| ... | ... | @@ -606,7 +606,7 @@ describe("rightPanelStore", () => { |
| 606 | 606 | useRightPanelStore.getState().threadPanelVisibilityByThreadKey, |
| 607 | 607 | refA, |
| 608 | 608 | ), |
| 609 | ).toEqual({ inlineOpen: true, popoverOpen: false }); | |
| 609 | ).toEqual({ inlineOpen: false, popoverOpen: false }); | |
| 610 | 610 | }); |
| 611 | 611 | |
| 612 | 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 | 209 | }; |
| 210 | 210 | |
| 211 | 211 | const DEFAULT_THREAD_PANEL_VISIBILITY: ThreadPanelVisibility = { |
| 212 | inlineOpen: true, | |
| 212 | inlineOpen: false, | |
| 213 | 213 | popoverOpen: false, |
| 214 | 214 | }; |
| 215 | 215 | |
| ... | ... | @@ -340,7 +340,7 @@ const updateThreadPanelVisibilityMap = ( |
| 340 | 340 | ): Record<string, ThreadPanelVisibility> => { |
| 341 | 341 | const current = byThreadKey[threadKey] ?? DEFAULT_THREAD_PANEL_VISIBILITY; |
| 342 | 342 | const next = updater(current); |
| 343 | if (next.inlineOpen && !next.popoverOpen) { | |
| 343 | if (next.inlineOpen === DEFAULT_THREAD_PANEL_VISIBILITY.inlineOpen && !next.popoverOpen) { | |
| 344 | 344 | if (!(threadKey in byThreadKey)) return byThreadKey; |
| 345 | 345 | const { [threadKey]: _removed, ...rest } = byThreadKey; |
| 346 | 346 | return rest; |
| ... | ... | @@ -589,8 +589,9 @@ export function migratePersistedRightPanelState(persistedState: unknown): { |
| 589 | 589 | persistedState.threadPanelVisibilityByThreadKey as Record<string, unknown>, |
| 590 | 590 | ).flatMap(([threadKey, value]) => { |
| 591 | 591 | if (!value || typeof value !== "object" || !("inlineOpen" in value)) return []; |
| 592 | return value.inlineOpen === false | |
| 593 | ? [[threadKey, { inlineOpen: false, popoverOpen: false }]] | |
| 592 | return typeof value.inlineOpen === "boolean" && | |
| 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 | 1069 | threadPanelVisibilityByThreadKey: Object.fromEntries( |
| 1069 | 1070 | Object.entries(state.threadPanelVisibilityByThreadKey).flatMap( |
| 1070 | 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 | }), |