From 99e6ff629afe3d401757ea3b304d861b1b64f1e5 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Tue, 13 Oct 2026 09:47:14 -0700 Subject: [PATCH] feat: disable automatic Git setup and tune fork defaults Clover uses Jujutsu and wants project creation without Git setup. New project creates an ordinary folder; the command palette and source-control menu no longer offer initializing or publishing repositories. Existing repositories retain their commit, push, pull, and PR actions. Replace the sidebar artwork with a transparent Knife icon beside the collapse control. Keep the Knife artwork at 16 px inside its existing hover target. Align provider and settings icon columns, outline clickable update actions, and divide updates from the labeled settings section. Its compact HTML dropdown shows the effective build version, one app-update action, provider versions and live status, and two columns of direct settings links. Compatible UI updates reload without a prompt; full app and server restarts require confirmation. Run provider-only Update all directly and keep closed dropdown contents inert. Use the same update action in Settings and the legacy sidebar; remove the separate local UI update command. Open Markdown files in formatted preview by default, preserving explicit source preferences and requested source-line reveals. Keep the thread details card closed by default while retaining explicitly saved visibility choices. Use locally bundled AT Name Sans for desktop/web UI and AT Name Mono for code and terminals. Keep explicit font preferences and system fallbacks; refresh Settings font labels after web fonts load or fail. Private regular and italic variable fonts ship in builds while their local directory stays ignored. Focus the enabled confirmation action when a danger dialog opens so Enter confirms and Escape cancels, as Clover requested. Preserve explicit focus targets and disabled actions. Clover wants to recognize project icon files without memorizing filenames. Show actual per-file thumbnails with fixed dimensions, lazy image loading, a checkerboard for transparency, and a broken-image fallback. Preserve search and keyboard and pointer selection. Serve ICNS candidates as embedded PNGs through existing signed asset claims, retaining workspace confinement and external file identity checks. Assisted-by: gpt-6.1-sol Assisted-by: claude-opus-5.5 --- apps/server/src/assets/AssetAccess.test.ts | 92 ++- apps/server/src/assets/AssetAccess.ts | 43 +- apps/web/src/appearanceFonts.ts | 4 +- apps/web/src/components/AppSidebarLayout.tsx | 37 +- apps/web/src/components/CommandPalette.tsx | 158 +--- .../GitActionsControl.logic.test.ts | 9 +- .../src/components/GitActionsControl.logic.ts | 9 +- apps/web/src/components/GitActionsControl.tsx | 736 +----------------- apps/web/src/components/LegacySidebar.tsx | 85 +- .../components/ProviderUpdatesAction.test.tsx | 116 +++ .../src/components/ProviderUpdatesAction.tsx | 38 +- .../src/components/SidebarStageBackdrop.tsx | 12 - .../src/components/desktopUpdate.actions.ts | 102 +++ .../web/src/components/desktopUpdate.logic.ts | 4 +- .../src/components/desktopUpdate.toast.tsx | 4 +- .../src/components/files/FilePreviewPanel.tsx | 2 +- .../ProjectFaviconPickerDialog.test.tsx | 4 +- .../settings/ProjectFaviconPickerDialog.tsx | 61 +- .../components/settings/SettingsPanels.tsx | 132 +--- .../settings/SettingsSidebarNav.tsx | 66 +- .../settings/settingsNavigation.tsx | 70 ++ apps/web/src/components/sidebar/AppMenu.tsx | 80 ++ .../sidebar/AppMenuProviderUpdates.test.tsx | 284 +++++++ .../sidebar/AppMenuProviderUpdates.tsx | 226 ++++++ .../sidebar/DesktopUpdateStatusIcon.tsx | 34 +- .../src/components/sidebar/SidebarChrome.tsx | 120 +-- .../sidebar/SidebarProviderUpdatePill.tsx | 210 ----- .../sidebar/SidebarUpdatePill.test.tsx | 256 ++++-- .../components/sidebar/SidebarUpdatePill.tsx | 642 ++++----------- .../sidebar/updateEverythingRun.tsx | 4 +- .../components/sidebar/useUpdateEverything.ts | 3 +- .../src/components/threadSidebarWidth.test.ts | 26 +- apps/web/src/components/threadSidebarWidth.ts | 23 +- .../components/ui/alert-dialog.focus.test.tsx | 88 +++ apps/web/src/components/ui/alert-dialog.tsx | 19 + apps/web/src/hooks/useNewProject.ts | 151 ---- apps/web/src/index.css | 68 +- apps/web/src/rightPanelStore.test.ts | 12 +- apps/web/src/rightPanelStore.ts | 13 +- 39 files changed, 1756 insertions(+), 2287 deletions(-) create mode 100644 apps/web/src/components/ProviderUpdatesAction.test.tsx create mode 100644 apps/web/src/components/desktopUpdate.actions.ts create mode 100644 apps/web/src/components/settings/settingsNavigation.tsx create mode 100644 apps/web/src/components/sidebar/AppMenu.tsx create mode 100644 apps/web/src/components/sidebar/AppMenuProviderUpdates.test.tsx create mode 100644 apps/web/src/components/sidebar/AppMenuProviderUpdates.tsx delete mode 100644 apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx create mode 100644 apps/web/src/components/ui/alert-dialog.focus.test.tsx delete mode 100644 apps/web/src/hooks/useNewProject.ts diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index e32f41372528f4f4083283ddea4387e1e82084ea..9f4f16722ea8e84e30c8418fd7589a7ec4f8d7d4 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -54,6 +54,12 @@ screenshotPng.set([ ]); new DataView(screenshotPng.buffer).setUint32(16, 390); new DataView(screenshotPng.buffer).setUint32(20, 844); +const screenshotIcns = new Uint8Array(16 + screenshotPng.length); +screenshotIcns.set(new TextEncoder().encode("icns")); +new DataView(screenshotIcns.buffer).setUint32(4, screenshotIcns.length); +screenshotIcns.set(new TextEncoder().encode("ic07"), 8); +new DataView(screenshotIcns.buffer).setUint32(12, 8 + screenshotPng.length); +screenshotIcns.set(screenshotPng, 16); const screenshotItem = { id: TurnItemId.make("tool-screenshot"), type: "dynamic_tool" as const, @@ -816,6 +822,84 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(layerTest)), ); + it.effect("previews ICNS candidates without selecting a project favicon", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-icon-candidates-" }); + const external = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-icon-external-" }); + yield* fileSystem.writeFileString(path.join(root, "favicon.svg"), "saved"); + + for (const requestedPath of ["Candidate.icns", path.join(external, "Candidate.icns")]) { + const filePath = path.isAbsolute(requestedPath) + ? requestedPath + : path.join(root, requestedPath); + yield* fileSystem.writeFile(filePath, screenshotIcns); + const result = yield* issueAssetUrl({ + resource: { _tag: "draft-workspace-file", cwd: root, path: requestedPath }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const token = suffix.slice(0, suffix.indexOf("/")); + const canonicalPath = yield* fileSystem.realPath(filePath); + const asset = yield* resolveAsset(token, "Candidate.icns").pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.FileSystem.of({ + ...fileSystem, + readFile: (requested) => + path.isAbsolute(requestedPath) && requested === canonicalPath + ? Effect.succeed(new Uint8Array()) + : fileSystem.readFile(requested), + }), + ), + ); + + expect(asset?.kind === "bytes" ? asset.mimeType : null).toBe("image/png"); + expect(asset?.kind === "bytes" ? Uint8Array.from(asset.bytes) : null).toEqual( + screenshotPng, + ); + expect(yield* resolveAsset(token, "favicon.svg")).toBeNull(); + expect(yield* resolveAsset(token, "../Candidate.icns")).toBeNull(); + + if (path.isAbsolute(requestedPath)) { + const replacement = path.join(external, "replacement.icns"); + yield* fileSystem.writeFile(replacement, screenshotIcns); + yield* fileSystem.rename(replacement, filePath); + expect(yield* resolveAsset(token, "Candidate.icns")).toBeNull(); + } + } + const saved = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root, path: "Candidate.icns" }, + }); + expect(saved.sourcePath).toBe("favicon.svg"); + }).pipe(Effect.provide(layerTest)), + ); + + it.effect.skipIf(!symlinksSupported)("revalidates ICNS candidate workspace confinement", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-icon-contained-" }); + const outside = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-icon-private-" }); + const filePath = path.join(root, "Candidate.icns"); + const secret = path.join(outside, "Private.icns"); + yield* fileSystem.writeFile(filePath, screenshotPng); + yield* fileSystem.writeFile(secret, screenshotPng); + const result = yield* issueAssetUrl({ + resource: { _tag: "draft-workspace-file", cwd: root, path: "Candidate.icns" }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const token = suffix.slice(0, suffix.indexOf("/")); + yield* fileSystem.remove(filePath); + yield* fileSystem.symlink(secret, filePath); + expect(yield* resolveAsset(token, "Candidate.icns")).toBeNull(); + const error = yield* issueAssetUrl({ + resource: { _tag: "draft-workspace-file", cwd: root, path: "Candidate.icns" }, + }).pipe(Effect.flip); + expect(error._tag).toBe("AssetWorkspaceAssetNotFoundError"); + }).pipe(Effect.provide(layerTest)), + ); + it.effect("falls back to the resource cwd for relative draft paths", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1335,14 +1419,8 @@ describe("AssetAccess", () => { const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-asset-favicon-icns-", }); - const icns = new Uint8Array(8 + 8 + screenshotPng.length); - icns.set(new TextEncoder().encode("icns")); - new DataView(icns.buffer).setUint32(4, icns.length); - icns.set(new TextEncoder().encode("ic07"), 8); - new DataView(icns.buffer).setUint32(12, 8 + screenshotPng.length); - icns.set(screenshotPng, 16); yield* fileSystem.makeDirectory(path.join(root, "Resources")); - yield* fileSystem.writeFile(path.join(root, "Resources", "AppIcon.icns"), icns); + yield* fileSystem.writeFile(path.join(root, "Resources", "AppIcon.icns"), screenshotIcns); const result = yield* issueAssetUrl({ resource: { _tag: "project-favicon", cwd: root }, diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 6b736c68f74efbb1359145328b121f4bd6658bac..54ee6d605749ccd7b7e61869d0beb87cee7ce70b 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -262,15 +262,20 @@ const readToolOutputImage = Effect.fn("AssetAccess.readToolOutputImage")(functio : { mimeType: image.mimeType, bytes: Buffer.from(image.data, "base64") }; }); -/** Serves an `.icns` favicon as its embedded PNG, since browsers cannot draw it. */ -const projectFaviconAsset = Effect.fn("AssetAccess.projectFaviconAsset")(function* ( +/** Browsers display the embedded PNG in an `.icns` image. */ +const imagePreviewAsset = Effect.fn("AssetAccess.imagePreviewAsset")(function* ( filePath: string, + file?: OpenMediaFile, ) { if (!filePath.toLowerCase().endsWith(".icns")) { return { kind: "file", path: filePath } satisfies ResolvedAsset; } const fileSystem = yield* FileSystem.FileSystem; - const bytes = yield* fileSystem.readFile(filePath).pipe( + const bytes = yield* Effect.gen(function* () { + return file + ? yield* readMediaFileHeader(filePath, file, Number(file.info.size)) + : yield* fileSystem.readFile(filePath); + }).pipe( Effect.tapError((cause) => Effect.logError("Failed to read project icon image.", { filePath, cause }), ), @@ -378,7 +383,10 @@ const finalizeAbsoluteMediaFileAsset = Effect.fn("AssetAccess.finalizeAbsoluteMe if (!canonicalFile) { return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource }); } - if (hostPreviewMimeTypeFromExtension(path.extname(canonicalFile)) === null) { + if ( + hostPreviewMimeTypeFromExtension(path.extname(canonicalFile)) === null && + !canonicalFile.toLowerCase().endsWith(".icns") + ) { return yield* new AssetPreviewTypeValidationError({ resource: input.resource }); } const wantsDimensions = HEADER_IMAGE_EXTENSIONS.has(path.extname(canonicalFile).toLowerCase()); @@ -442,7 +450,10 @@ const finalizeWorkspaceFileAsset = Effect.fn("AssetAccess.finalizeWorkspaceFileA }), ), ); - if (!isWorkspacePreviewEntryPath(resolved.relativePath)) { + const isImage = + isWorkspaceImagePreviewPath(resolved.relativePath) || + resolved.relativePath.toLowerCase().endsWith(".icns"); + if (!isWorkspacePreviewEntryPath(resolved.relativePath) && !isImage) { return yield* new AssetPreviewTypeValidationError({ resource: input.resource, }); @@ -479,7 +490,7 @@ const finalizeWorkspaceFileAsset = Effect.fn("AssetAccess.finalizeWorkspaceFileA ? yield* readImageDimensionsFromHeader(canonicalFile) : null; return { - claims: isWorkspaceImagePreviewPath(resolved.relativePath) + claims: isImage ? { version: 1 as const, kind: "workspace-file-exact" as const, @@ -889,7 +900,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( workspaceRoot: claims.workspaceRoot, relativePath: claims.relativePath, }); - return faviconPath ? yield* projectFaviconAsset(faviconPath) : null; + return faviconPath ? yield* imagePreviewAsset(faviconPath) : null; } if (claims.kind === "project-favicon-external") { @@ -902,7 +913,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( ), Effect.orElseSucceed(() => null), ); - return faviconPath === claims.filePath ? yield* projectFaviconAsset(faviconPath) : null; + return faviconPath === claims.filePath ? yield* imagePreviewAsset(faviconPath) : null; } if (claims.kind === "github-media") { @@ -949,7 +960,10 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( Effect.orElseSucceed(() => null), ); if (canonicalFile !== claims.filePath) return null; - const mimeType = hostPreviewMimeTypeFromExtension(path.extname(canonicalFile)); + const isIcns = canonicalFile.toLowerCase().endsWith(".icns"); + const mimeType = isIcns + ? "image/png" + : hostPreviewMimeTypeFromExtension(path.extname(canonicalFile)); if (!mimeType) return null; const file = yield* openMediaFile(canonicalFile, claims).pipe( Effect.tapError((cause) => @@ -957,9 +971,10 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( ), Effect.orElseSucceed(() => null), ); - return file - ? ({ kind: "file", path: canonicalFile, mimeType, file } satisfies ResolvedAsset) - : null; + if (!file) return null; + return isIcns + ? yield* imagePreviewAsset(canonicalFile, file) + : ({ kind: "file", path: canonicalFile, mimeType, file } satisfies ResolvedAsset); } if (claims.kind === "workspace-file-exact") { if (decodedPath !== path.basename(claims.relativePath)) return null; @@ -967,9 +982,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( workspaceRoot: claims.workspaceRoot, relativePath: claims.relativePath, }); - return exactWorkspaceFile - ? ({ kind: "file", path: exactWorkspaceFile } satisfies ResolvedAsset) - : null; + return exactWorkspaceFile ? yield* imagePreviewAsset(exactWorkspaceFile) : null; } const segments = decodedPath.split(/[\\/]/); if ( diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts index 6053e5fb0dd4136f0896b591078d34ff7f5a05fe..b842ee5fd1cf512c33e827055110defea48d8564 100644 --- a/apps/web/src/appearanceFonts.ts +++ b/apps/web/src/appearanceFonts.ts @@ -18,12 +18,12 @@ import { } from "@t3tools/contracts"; export const DEFAULT_SANS_FONT_STACK = - '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif'; + '"AT Name Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif'; // Concrete names first: some engines alias `ui-monospace` to the // proportional system UI font, which would break every code surface. export const DEFAULT_CODE_FONT_STACK = - '"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace'; + '"AT Name Mono", "SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace'; export const TYPOGRAPHY_ADVANCED_STORAGE_KEY = "t3code:typography-advanced"; diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index fd56ee8ca5ac9de20a7cf284361ff8465a048b0f..edcc4e7bf5fcd3978af51fb2f128020d26bff46d 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -25,7 +25,7 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina import { resolveThreadRouteRef } from "../threadRoutes"; import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; -import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings"; +import { useLegacySidebarEnabled } from "../hooks/useSettings"; import { PanelAnimationSuppressionProvider, usePanelAnimationSettings, @@ -35,15 +35,15 @@ import LegacyThreadSidebar from "./LegacySidebar"; import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration"; import ThreadSidebar from "./Sidebar"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; -import { SidebarBrandWidthProbe, SidebarChromeHeader } from "./sidebar/SidebarChrome"; +import { AppMenu } from "./sidebar/AppMenu"; +import { SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { MainAppLocationTracker } from "./sidebar/mainAppLocation"; -import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; import { useProjects } from "../state/entities"; import { clampThreadSidebarWidth, resolveInitialThreadSidebarWidth, resolveThreadSidebarMaximumWidth, - resolveThreadSidebarMinimumWidth, + THREAD_SIDEBAR_MIN_WIDTH, THREAD_MAIN_CONTENT_MIN_WIDTH, THREAD_SIDEBAR_WIDTH_STORAGE_KEY, } from "./threadSidebarWidth"; @@ -85,10 +85,6 @@ function SidebarControl() { const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { toggleSidebar, isMobile, mobileSheetMounted } = useSidebar(); const isSidebarVisible = useSidebarVisibility(); - const environmentIdentificationMode = useEnvironmentIdentificationMode(); - const stageBackdropVariant = useSidebarStageBackdropVariant( - environmentIdentificationMode === "artwork", - ); const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle", { context: { usagePageOpen }, }); @@ -146,19 +142,13 @@ function SidebarControl() { )} data-sidebar-control="" > +
+ +
+ } /> @@ -246,9 +236,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { // and a clamped drag ends with an unchanged width, which skips the re-render // that would otherwise refresh a render-time snapshot. const viewportWidth = useSyncExternalStore(subscribeToViewportWidth, readViewportWidth); - const [brandWidth, setBrandWidth] = useState(0); - const sidebarMinimumWidth = resolveThreadSidebarMinimumWidth(brandWidth); - const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(viewportWidth, sidebarMinimumWidth); + const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(viewportWidth); const resetSidebarWidth = () => { try { removeLocalStorageItem(THREAD_SIDEBAR_WIDTH_STORAGE_KEY); @@ -269,8 +257,10 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { : {} ) as CSSProperties; const sidebarProviderStyle = { - "--sidebar-width": `${clampThreadSidebarWidth(sidebarWidth, sidebarMinimumWidth, sidebarMaximumWidth)}px`, + "--sidebar-width": `${clampThreadSidebarWidth(sidebarWidth, sidebarMaximumWidth)}px`, "--panel-animation-duration": `${panelAnimationDurationMs}ms`, + "--workspace-titlebar-content-left": + "calc(var(--workspace-controls-left) + 2 * var(--workspace-titlebar-control-size) + var(--workspace-titlebar-control-gap))", ...windowControlsInsetStyle, } as CSSProperties; @@ -319,7 +309,6 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { defaultOpen style={sidebarProviderStyle} > - nextWidth <= currentWidth || wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 6596e451e3fc60f92bb01523b613f1d34a4f978d..43416cff3a0e29c8ed844b49ed7aa13b0da86030 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -11,10 +11,6 @@ import { getCloneDirectoryName, getDefaultCloneUrl, getNewProjectPathPreview, - getNewProjectPublishTarget, - getNewProjectPublishTargets, - getNewProjectRepository, - type NewProjectPublishTarget, normalizePastedCloneUrl, addProjectRemoteSourceLabel, addProjectRemoteSourcePathHint, @@ -49,13 +45,11 @@ import { type FilesystemBrowseResult, type ProjectId, type SourceControlDiscoveryResult, - SourceControlProviderKind, type SourceControlRepositoryInfo, PRIMARY_LOCAL_ENVIRONMENT_ID, resolveEnvironmentMachineKind, } from "@t3tools/contracts"; import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; -import * as Option from "effect/Option"; import { ArrowLeftIcon, ChartNoAxesColumnIcon, @@ -63,7 +57,6 @@ import { ChevronRightIcon, CornerLeftUpIcon, FileSearchIcon, - FolderGit2Icon, FolderIcon, FolderPlusIcon, MessageSquareDashedIcon, @@ -127,7 +120,6 @@ import { sourceControlEnvironment } from "../state/sourceControl"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { useScratchProject } from "../hooks/useScratchProject"; -import { useNewProject } from "../hooks/useNewProject"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities"; @@ -204,7 +196,6 @@ import { } from "./CommandPaletteResults"; import { sourceControlIcon } from "~/sourceControlPresentation"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; -import { Checkbox } from "./ui/checkbox"; import { ProjectFavicon } from "./ProjectFavicon"; import { ProjectFilePicker } from "./files/ProjectFilePicker"; import { openLinkPullRequestDialog } from "./pullRequest/LinkPullRequestDialog"; @@ -231,7 +222,6 @@ import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindin import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "./ui/command"; import { Button } from "./ui/button"; import { Kbd, KbdGroup } from "./ui/kbd"; -import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; @@ -291,14 +281,6 @@ function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: stri return ; } -function sourceControlHostIcon( - definition: NewProjectPublishTarget["definition"], - className: string, -): ReactNode { - const Icon = sourceControlIcon(definition); - return ; -} - function projectFaviconIcon(project: Project): ReactNode { return ; } @@ -367,10 +349,6 @@ function notifyThemeSaveFailure(): void { ); } -function projectFavicon(project: Project) { - return ; -} - export function CommandPalette({ children }: { children: ReactNode }) { const navigate = useNavigate(); const [state, dispatch] = useReducer(reduceCommandPaletteUiState, { @@ -788,15 +766,9 @@ function OpenCommandPaletteDialog(props: { /** Machine of the Add project sources view under this step; null from the palette root. */ readonly sourcesEnvironmentId: EnvironmentId | null; } | null>(null); - const [newProjectPublishes, setNewProjectPublishes] = useState(false); - // The host picked beside the publish toggle; null takes the first ready host, GitHub when ready. - const [newProjectPublishKind, setNewProjectPublishKind] = - useState(null); - const [newProjectHostPickerOpen, setNewProjectHostPickerOpen] = useState(false); const [isCreatingNewProject, setIsCreatingNewProject] = useState(false); // State lags a render behind, so a repeated Enter could start a second create. const newProjectSubmittingRef = useRef(false); - const createNewProject = useNewProject(); const cloneLookupGeneration = useRef(0); const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false); const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false); @@ -1522,10 +1494,8 @@ function OpenCommandPaletteDialog(props: { setAddProjectEnvironmentId(environmentId); setAddProjectCloneFlow(null); setNewProjectFlow({ environmentId, sourcesEnvironmentId }); - setNewProjectPublishes(false); - setNewProjectPublishKind(null); pushPaletteView({ - addonIcon: , + addonIcon: , groups: [], }); }, @@ -1561,10 +1531,10 @@ function OpenCommandPaletteDialog(props: { sourceItems.unshift({ kind: "action", value: `action:add-project:${environmentId}:new`, - searchTerms: ["new project", "create", "empty", "repository", "git init"], + searchTerms: ["new project", "create", "empty", "folder"], title: "New project", - description: "Start a new Git repository from a name", - icon: , + description: "Create a project folder from a name", + icon: , keepOpen: true, run: async () => { startNewProject(environmentId, environmentId); @@ -2037,9 +2007,9 @@ function OpenCommandPaletteDialog(props: { actionItems.push({ kind: "action", value: "action:new-project", - searchTerms: ["new project", "create project", "empty", "repository", "repo", "git init"], + searchTerms: ["new project", "create project", "empty", "folder"], title: "New project", - icon: , + icon: , keepOpen: true, run: async () => { openNewProjectFlow(); @@ -2508,12 +2478,6 @@ function OpenCommandPaletteDialog(props: { ], ); - const newProjectPublishTargets = - newProjectFlow === null ? [] : getNewProjectPublishTargets(sourceControlDiscovery.data ?? null); - const newProjectPublishTarget = getNewProjectPublishTarget( - newProjectPublishTargets, - newProjectPublishKind, - ); const newProjectName = query.trim(); const canSubmitNewProject = newProjectFlow !== null && @@ -2528,12 +2492,14 @@ function OpenCommandPaletteDialog(props: { newProjectSubmittingRef.current = true; setIsCreatingNewProject(true); try { - const created = await createNewProject({ + const root = newProjectsRootFor(newProjectFlow.environmentId); + if (root === null) return; + await handleAddProjectForEnvironment({ environmentId: newProjectFlow.environmentId, - name: newProjectName, - publishTo: newProjectPublishes ? newProjectPublishTarget : null, + rawCwd: getNewProjectPathPreview(root, newProjectName), + platform: browseEnvironmentPlatform, + currentProjectCwd: null, }); - if (created) setOpen(false); } finally { newProjectSubmittingRef.current = false; setIsCreatingNewProject(false); @@ -2837,7 +2803,6 @@ function OpenCommandPaletteDialog(props: { const newProjectsRoot = newProjectFlow ? newProjectsRootFor(newProjectFlow.environmentId) : null; const newProjectPathPreview = newProjectsRoot === null ? null : getNewProjectPathPreview(newProjectsRoot, newProjectName); - const newProjectPublishToggleValue = "new-project:publish"; // The name step's way out to folders and clones, for the selected machine. // It replaces the name step (and a sources view for another machine under // it), so Back returns to wherever New project was opened from. @@ -2853,8 +2818,6 @@ function OpenCommandPaletteDialog(props: { popView(); startAddProjectSourceSelection(environmentId); }; - // Switching machines keeps the typed name; the GitHub option follows the - // machine because source control discovery reads addProjectEnvironmentId. const switchNewProjectEnvironment = (environmentId: EnvironmentId) => { setNewProjectFlow((flow) => (flow === null ? flow : { ...flow, environmentId })); setAddProjectEnvironmentId(environmentId); @@ -2923,102 +2886,11 @@ function OpenCommandPaletteDialog(props: { }, ], }; - const newProjectOptionGroups: CommandPaletteView["groups"] = - newProjectPublishTarget === null || newProjectPathPreview === null - ? [] - : [ - { - value: "new-project-options", - label: "Options", - items: [ - { - kind: "action", - value: newProjectPublishToggleValue, - searchTerms: [], - title: - newProjectPublishTargets.length > 1 - ? "Create private repository" - : `Create private repository on ${newProjectPublishTarget.definition.label}`, - description: - newProjectName.length > 0 - ? getNewProjectRepository(newProjectPublishTarget, newProjectPathPreview) - : (newProjectPublishTarget.owner ?? - `Your ${newProjectPublishTarget.definition.label} account`), - icon: sourceControlHostIcon(newProjectPublishTarget.definition, ITEM_ICON_CLASS), - titleTrailingContent: ( - - {newProjectPublishTargets.length > 1 ? ( - // The row toggles on click, so the picker keeps its clicks to itself. The - // row also cancels pointerdown to keep focus in the input, which drops the - // mousedown Select opens on, so the picker opens on click instead. - { - event.stopPropagation(); - // React bubbles the popup's clicks here through its portal; only the - // trigger opens the picker. - if (!event.currentTarget.contains(event.target as Node)) return; - if (!isCreatingNewProject) setNewProjectHostPickerOpen(true); - }} - > - - - ) : null} - - - - - ), - // The create in flight keeps the choice it started with. - ...(isCreatingNewProject ? { disabled: true } : {}), - keepOpen: true, - run: async () => { - setNewProjectPublishes((publishes) => !publishes); - }, - }, - ], - }, - ]; let displayedGroups: CommandPaletteView["groups"] = filteredGroups; if (newProjectFlow !== null) { displayedGroups = [ ...(newProjectMachineGroup ? [newProjectMachineGroup] : []), - ...newProjectOptionGroups, ...(newProjectExistingGroup ? [newProjectExistingGroup] : []), ]; } else if (addProjectCloneFlow?.step === "repository") { @@ -3487,9 +3359,7 @@ function OpenCommandPaletteDialog(props: { newProjectFlow !== null ? highlightedItemValue === null ? "Create" - : highlightedItemValue === newProjectPublishToggleValue - ? "Toggle" - : "Select" + : "Select" : addProjectCloneFlow?.step === "repository" ? (remoteProjectButtonLabel ?? "Continue") : !canSubmitBrowsePath || hasHighlightedBrowseItem @@ -3566,7 +3436,7 @@ function OpenCommandPaletteDialog(props: { {newProjectPathPreview !== null ? (
- + {newProjectName.length > 0 ? newProjectName : "New project"} diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 12aee26c757ed118e811f6948b65160a43c4c6c6..adecc5d7e1e0b57a3bb0643def97ba8588406055 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -823,7 +823,7 @@ describe("when: ref has no upstream configured", () => { }); }); - it("resolveQuickAction publishes when no origin remote exists", () => { + it("resolveQuickAction rests without offering repository creation when no remote exists", () => { const quick = resolveQuickAction( status({ hasUpstream: false, @@ -835,9 +835,10 @@ describe("when: ref has no upstream configured", () => { false, ); assert.deepEqual(quick, { - kind: "open_publish", - label: "Publish repository", - disabled: false, + kind: "show_hint", + label: "Commit", + disabled: true, + hint: "No changes to commit.", }); }); diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index af6206625c4112aa909eb3a1343638deef9e41d3..444900573be6581042444e174337289ad843360d 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -23,7 +23,7 @@ export interface GitActionMenuItem { export interface GitQuickAction { label: string; disabled: boolean; - kind: "run_action" | "run_pull" | "open_publish" | "show_hint"; + kind: "run_action" | "run_pull" | "show_hint"; action?: GitStackedAction; hint?: string; } @@ -281,9 +281,10 @@ export function resolveQuickAction( if (!gitStatus.hasUpstream) { if (!hasPrimaryRemote) { return { - label: "Publish repository", - disabled: false, - kind: "open_publish", + label: "Commit", + disabled: true, + kind: "show_hint", + hint: "No changes to commit.", }; } if (!isAhead) { diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 8164537ee3a248cf204b53a038286741d4685e8e..cd3e701a76b58f4bd6203fb00ac78607013ec526 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -3,7 +3,6 @@ import { useAtomValue } from "@effect/atom-react"; import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope, - SourceControlProviderKind, type ScopedThreadRef, } from "@t3tools/contracts"; import { @@ -13,32 +12,27 @@ import { import type { GitRunStackedActionResult, GitStackedAction, - SourceControlCloneProtocol, - SourceControlProviderDiscoveryItem, - SourceControlPublishRepositoryResult, - SourceControlRepositoryVisibility, VcsStatusResult, } from "@t3tools/contracts"; -import { useNavigate } from "@tanstack/react-router"; -import * as Option from "effect/Option"; -import { type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { flushSync } from "react-dom"; +import { + type MouseEvent, + useCallback, + useEffect, + useEffectEvent, + useMemo, + useRef, + useState, +} from "react"; import { CheckIcon, ChevronDownIcon, CloudDownloadIcon, CloudUploadIcon, FileDiffIcon, - GitBranchPlusIcon, GitCommitIcon, InfoIcon, - LockIcon, - GlobeIcon, } from "lucide-react"; -import { Radio as RadioPrimitive } from "@base-ui/react/radio"; -import { RadioGroup } from "~/components/ui/radio-group"; import { Spinner } from "~/components/ui/spinner"; -import { Toggle, ToggleGroup } from "~/components/ui/toggle-group"; import { cn } from "~/lib/utils"; import { useOpenPrLink } from "~/lib/openPullRequestLink"; import { @@ -59,7 +53,6 @@ import { resolveQuickAction, resolveThreadBranchUpdate, } from "./GitActionsControl.logic"; -import { WizardPopup, WizardHeader, WizardSteps, WizardPanel, WizardFooter } from "./ui/wizard"; import { StartTruncatedPath } from "./StartTruncatedPath"; import { Button } from "~/components/ui/button"; import { Checkbox } from "~/components/ui/checkbox"; @@ -73,7 +66,6 @@ import { DialogTitle, } from "~/components/ui/dialog"; import { Group, GroupSeparator } from "~/components/ui/group"; -import { Input } from "~/components/ui/input"; import { Menu, MenuItem, @@ -93,15 +85,12 @@ import { useOpenInPreferredEditor } from "~/editorPreferences"; import { useGitStackedAction, useSourceControlActionRunning, - useSourceControlPublishRepositoryAction, - useVcsInitAction, useVcsPullAction, } from "~/lib/sourceControlActions"; import { useThreadProjection, useThreadShell } from "~/state/entities"; import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { useEnvironmentQuery } from "~/state/query"; import { serverEnvironment } from "~/state/server"; -import { sourceControlEnvironment } from "~/state/sourceControl"; import { threadEnvironment } from "~/state/threads"; import { useAtomCommand } from "~/state/use-atom-command"; import { vcsActionManager, vcsEnvironment } from "~/state/vcs"; @@ -115,10 +104,8 @@ import { THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS, } from "./chat/threadDetailsPanelStyles"; -import { getSourceControlPresentation, sourceControlIcon } from "~/sourceControlPresentation"; -import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { getSourceControlPresentation } from "~/sourceControlPresentation"; import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; -import { useOpenLink } from "~/browser/useOpenLink"; interface GitActionsControlProps { presentation?: "toolbar" | "menu"; @@ -182,49 +169,6 @@ function requestVcsStatusRefresh( const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const; /** Every built-in host, in the order the built-in definitions list them. */ -const PUBLISH_PROVIDER_OPTIONS = sourceControlClients.definitions.map((definition) => ({ - value: definition.kind, - label: definition.pickerLabel, - description: definition.publishDescription, - pathPlaceholder: definition.repositoryPathHint, - definition, - Icon: sourceControlIcon(definition), -})); - -function publishProviderOption(provider: SourceControlProviderKind) { - return ( - PUBLISH_PROVIDER_OPTIONS.find((option) => option.value === provider) ?? - PUBLISH_PROVIDER_OPTIONS[0]! - ); -} - -function getPublishProviderReadiness(input: { - provider: SourceControlProviderKind; - sourceControlProviders: ReadonlyArray; -}): { readonly ready: boolean; readonly hint: string | null } { - const discovered = input.sourceControlProviders.find( - (provider) => provider.kind === input.provider, - ); - if (!discovered) { - return { - ready: false, - hint: "Provider status unavailable. Open Settings -> Source Control and rescan.", - }; - } - if (discovered.status !== "available") { - return { ready: false, hint: discovered.installHint }; - } - if (discovered.auth.status === "unauthenticated") { - return { - ready: false, - hint: - Option.getOrNull(discovered.auth.detail) ?? - `${discovered.label} is not authenticated. Open Settings -> Source Control for setup guidance.`, - }; - } - return { ready: true, hint: null }; -} - function getMenuActionDisabledReason({ item, gitStatus, @@ -312,7 +256,6 @@ function GitQuickActionIcon({ className?: string; SourceControlIcon: ReturnType["Icon"]; }) { - if (quickAction.kind === "open_publish") return ; if (quickAction.kind === "run_pull") return ; if (quickAction.kind === "run_action") { if (quickAction.action === "commit") return ; @@ -458,539 +401,6 @@ function GitActionSuccessButtonContent({ success }: { success: InlineGitActionSu ); } -interface PublishRepositoryDialogProps { - readonly open: boolean; - readonly onOpenChange: (open: boolean) => void; - readonly environmentId: ScopedThreadRef["environmentId"] | null; - /** Thread the dialog was opened from, so the new repository can open beside it. */ - readonly threadRef: ScopedThreadRef | null; - readonly gitCwd: string; -} - -function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { - const openLink = useOpenLink(props.threadRef); - const navigate = useNavigate(); - const sourceControlDiscovery = useEnvironmentQuery( - props.environmentId === null - ? null - : sourceControlEnvironment.discovery({ - environmentId: props.environmentId, - input: {}, - }), - ); - const [selectedPublishProvider, setSelectedPublishProvider] = - useState(null); - const [publishRepositoryOverride, setPublishRepositoryOverride] = useState(null); - const [publishVisibility, setPublishVisibility] = - useState("private"); - const [publishRemoteName, setPublishRemoteName] = useState("origin"); - const [publishProtocol, setPublishProtocol] = useState("ssh"); - const [publishWizardStep, setPublishWizardStep] = useState(0); - const [publishAdvancedOpen, setPublishAdvancedOpen] = useState(false); - const [publishError, setPublishError] = useState(null); - const [publishResult, setPublishResult] = useState( - null, - ); - const sourceControlScope = useMemo( - () => ({ - environmentId: props.environmentId, - cwd: props.gitCwd, - }), - [props.environmentId, props.gitCwd], - ); - const publishRepositoryAction = useSourceControlPublishRepositoryAction(sourceControlScope); - const publishAccountByProvider = useMemo(() => { - const accounts = new Map(); - for (const provider of sourceControlDiscovery.data?.sourceControlProviders ?? []) { - accounts.set(provider.kind, Option.getOrNull(provider.auth.account)); - } - return accounts; - }, [sourceControlDiscovery.data]); - const publishProviderReadiness = useMemo(() => { - const sourceControlProviders = sourceControlDiscovery.data?.sourceControlProviders ?? []; - const readiness = new Map( - PUBLISH_PROVIDER_OPTIONS.map((option) => [ - option.value, - getPublishProviderReadiness({ provider: option.value, sourceControlProviders }), - ]), - ); - return (provider: SourceControlProviderKind) => - readiness.get(provider) ?? { ready: false, hint: null }; - }, [sourceControlDiscovery.data]); - const hasReadyPublishProvider = useMemo( - () => PUBLISH_PROVIDER_OPTIONS.some((option) => publishProviderReadiness(option.value).ready), - [publishProviderReadiness], - ); - const sortedPublishProviderOptions = useMemo( - () => - PUBLISH_PROVIDER_OPTIONS.toSorted((left, right) => { - const leftReady = publishProviderReadiness(left.value).ready; - const rightReady = publishProviderReadiness(right.value).ready; - if (leftReady !== rightReady) { - return leftReady ? -1 : 1; - } - return left.label.localeCompare(right.label); - }), - [publishProviderReadiness], - ); - const firstReadyPublishProvider = sortedPublishProviderOptions.find( - (option) => publishProviderReadiness(option.value).ready, - )?.value; - const publishProvider = - selectedPublishProvider !== null && publishProviderReadiness(selectedPublishProvider).ready - ? selectedPublishProvider - : (firstReadyPublishProvider ?? - selectedPublishProvider ?? - sourceControlClients.get(undefined).kind); - const selectedPublishProviderReadiness = publishProviderReadiness(publishProvider); - const publishAccount = publishAccountByProvider.get(publishProvider) ?? null; - const publishRepositoryPrefill = publishAccount ? `${publishAccount}/` : ""; - const publishRepository = publishRepositoryOverride ?? publishRepositoryPrefill; - const currentPublishProvider = publishProviderOption(publishProvider); - const publishHost = currentPublishProvider.definition.publishHost( - Option.getOrNull( - sourceControlDiscovery.data?.sourceControlProviders.find( - (provider) => provider.kind === publishProvider, - )?.auth.host ?? Option.none(), - ), - ); - const publishPathPlaceholder = currentPublishProvider.pathPlaceholder; - const publishProviderLabel = currentPublishProvider.label; - const publishWizardSteps = ["Provider", "Repository", "Summary"] as const; - const publishWizardStepSummaries = [ - publishProviderLabel, - publishResult?.repository.nameWithOwner ?? null, - null, - ] as const; - - const canSubmitPublishRepository = useMemo(() => { - if (!publishRepositoryAction.isAllowed || !selectedPublishProviderReadiness.ready) return false; - if (publishRepositoryAction.isPending) return false; - const repositoryParts = publishRepository.trim().split("/"); - const owner = repositoryParts[0]?.trim() ?? ""; - const rest = repositoryParts.slice(1); - const name = rest.join("/").trim(); - return owner.length > 0 && name.length > 0; - }, [ - publishRepository, - publishRepositoryAction.isAllowed, - publishRepositoryAction.isPending, - selectedPublishProviderReadiness, - ]); - - const submitPublishRepository = useCallback(() => { - if (!canSubmitPublishRepository) { - return; - } - - setPublishError(null); - - void (async () => { - const result = await publishRepositoryAction.run({ - provider: publishProvider, - repository: publishRepository.trim(), - visibility: publishVisibility, - remoteName: publishRemoteName.trim() || "origin", - protocol: publishProtocol, - }); - - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setPublishError(error instanceof Error ? error.message : "An error occurred."); - } - return; - } - - flushSync(() => { - setPublishResult(result.value); - setPublishWizardStep(2); - }); - })(); - }, [ - canSubmitPublishRepository, - props.environmentId, - props.gitCwd, - publishProtocol, - publishProvider, - publishRemoteName, - publishRepository, - publishRepositoryAction, - publishVisibility, - ]); - - const resetState = useCallback(() => { - setPublishRemoteName("origin"); - setPublishRepositoryOverride(null); - setPublishWizardStep(0); - setPublishAdvancedOpen(false); - setPublishError(null); - setPublishResult(null); - }, []); - - const handleOpenChange = useCallback( - (open: boolean) => { - props.onOpenChange(open); - if (!open) { - resetState(); - } - }, - [props, resetState], - ); - - const openSourceControlSettings = useCallback(() => { - handleOpenChange(false); - void navigate({ to: "/settings/source-control" }); - }, [handleOpenChange, navigate]); - - return ( - - - - - publishWizardStep === 2 || - index >= publishWizardSteps.length - 1 || - index > publishWizardStep - } - onStepChange={setPublishWizardStep} - /> - - - -
- - Provider - - { - setSelectedPublishProvider(value as SourceControlProviderKind); - setPublishRepositoryOverride(null); - }} - aria-labelledby="publish-provider-cards-label" - className="grid grid-cols-2" - > - {sortedPublishProviderOptions.map((option) => { - const readiness = publishProviderReadiness(option.value); - const isSelected = publishProvider === option.value && readiness.ready; - if (!readiness.ready) { - return ( -
- - - {option.label} - - - { - event.preventDefault(); - event.stopPropagation(); - openSourceControlSettings(); - }} - > - Setup Required - - } - /> - - {readiness.hint ?? - "Open Settings -> Source Control to configure this provider."} - - -
- ); - } - - return ( - - - - {option.label} - - - ); - })} -
-
- -
-
- -
- - - {publishHost}/ - - { - setPublishRepositoryOverride(event.target.value); - }} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - submitPublishRepository(); - } - }} - placeholder={publishPathPlaceholder} - disabled={publishRepositoryAction.isPending} - className="w-full bg-transparent px-3 py-2 font-mono text-sm placeholder:text-muted-foreground/60 focus:outline-none" - /> -
-
- -
- - Visibility - - - setPublishVisibility(value as SourceControlRepositoryVisibility) - } - aria-labelledby="publish-visibility-cards-label" - disabled={publishRepositoryAction.isPending} - className="grid grid-cols-2" - > - {[ - { - value: "private" as const, - label: "Private", - description: "Only invited people", - Icon: LockIcon, - }, - { - value: "public" as const, - label: "Public", - description: "Anyone on the web", - Icon: GlobeIcon, - }, - ].map((option) => { - const isSelected = publishVisibility === option.value; - return ( - - - - - {option.label} - - - {option.description} - - - - ); - })} - -
- -
- - {publishAdvancedOpen ? ( -
- -
- - Protocol - - { - const protocol = next[0]; - if (protocol === "ssh" || protocol === "https") { - setPublishProtocol(protocol); - } - }} - aria-labelledby="publish-protocol-label" - disabled={publishRepositoryAction.isPending} - > - SSH - HTTPS - -
-
- ) : null} -
- - {publishRepositoryAction.isPending ? ( -
- - Publishing repository to {publishProviderLabel}... -
- ) : null} - {publishError && !publishRepositoryAction.isPending ? ( -
-

Publish failed

-

{publishError}

-
- ) : null} -
- -
- {publishResult ? ( - <> -
- - - -

- {publishResult.status === "pushed" - ? "Repository published" - : "Repository created"} -

-

- {publishResult.status === "pushed" - ? `${publishResult.branch} is now live on ${publishProviderLabel}.` - : `Remote "${publishResult.remoteName}" is set up. Make a commit and push it to share your code.`} -

-
-
- - - {publishResult.repository.nameWithOwner} - -
- - - ) : ( -
- Publish result unavailable. -
- )} -
-
- - - {publishWizardStep === 2 ? ( - - ) : ( - <> - - {publishWizardStep < 1 ? ( - - ) : ( - - )} - - )} - -
-
- ); -} - export default function GitActionsControl({ presentation = "toolbar", gitCwd, @@ -1044,7 +454,6 @@ export default function GitActionsControl({ const [dialogCommitMessage, setDialogCommitMessage] = useState(""); const [excludedFiles, setExcludedFiles] = useState>(new Set()); const [isEditingFiles, setIsEditingFiles] = useState(false); - const [isPublishDialogOpen, setIsPublishDialogOpen] = useState(false); const [inlineSuccess, setInlineSuccess] = useState(null); const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = useState(null); @@ -1153,7 +562,6 @@ export default function GitActionsControl({ const allSelected = excludedFiles.size === 0; const noneSelected = selectedFiles.length === 0; - const initAction = useVcsInitAction(sourceControlScope); const runImmediateGitAction = useGitStackedAction(sourceControlScope); const pullAction = useVcsPullAction(sourceControlScope); const isGitActionRunning = useSourceControlActionRunning( @@ -1470,10 +878,6 @@ export default function GitActionsControl({ }; const runQuickAction = () => { - if (quickAction.kind === "open_publish") { - setIsPublishDialogOpen(true); - return; - } if (quickAction.kind === "run_pull") { void (async () => { setInlineSuccess(null); @@ -1595,26 +999,6 @@ export default function GitActionsControl({ [gitCwd, openInPreferredEditor, threadToastData], ); - const canPublishRepository = - canWriteSourceControl && isRepo && gitStatusForActions !== null && !hasPrimaryRemote; - - const initializeGit = () => { - void (async () => { - const result = await initAction.run(); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { - return; - } - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Git initialization failed", - description: error instanceof Error ? error.message : "An error occurred.", - ...(threadToastData !== undefined ? { data: threadToastData } : {}), - }), - ); - })(); - }; const gitItems = ( <> {gitActionMenuItems.map((item) => { @@ -1673,18 +1057,6 @@ export default function GitActionsControl({ ); })} - {canPublishRepository ? ( - { - setIsPublishDialogOpen(true); - }} - > - - Publish repository... - - ) : null} {gitStatusForActions?.refName === null && (

Detached HEAD: create and check out a branch to enable push and pull request actions. @@ -1701,70 +1073,40 @@ export default function GitActionsControl({ ); - if (!gitCwd) return null; + if (!gitCwd || !isRepo) return null; return ( <> {presentation === "menu" ? ( - !isRepo ? ( + <> - - - {initAction.isPending ? "Initializing..." : "Initialize Git"} - + + {quickAction.label} - ) : ( - <> - - - {quickAction.label} - - {quickActionDisabledReason && ( -

- {quickActionDisabledReason} -

- )} - { - if (open) requestVcsStatusRefresh(refreshVcsStatus, activeEnvironmentId, gitCwd); - }} - > - - - Git actions - - {gitItems} - - - ) - ) : !isRepo ? ( - - - - {initAction.isPending ? "Initializing..." : "Initialize Git"} - - + {quickActionDisabledReason && ( +

{quickActionDisabledReason}

+ )} + { + if (open) requestVcsStatusRefresh(refreshVcsStatus, activeEnvironmentId, gitCwd); + }} + > + + + Git actions + + {gitItems} + + ) : compact && !gitActionProgress && !visibleInlineSuccess ? null : ( - - { diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index b0f9eb7e1acd29126e9a8842af827081929a9974..eeebf17dcc2dd14a7aa1e733d65765571d8827ce 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -1,3 +1,4 @@ +import { performDesktopUpdate } from "./desktopUpdate.actions"; import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; import { Spinner } from "~/components/ui/spinner"; @@ -138,14 +139,10 @@ import { formatRelativeTimeLabel } from "../timestampFormat"; import { Kbd } from "./ui/kbd"; import { getArm64IntelBuildWarningDescription, - getDesktopUpdateActionError, - getDesktopUpdateInstallConfirmationMessage, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, shouldShowArm64IntelBuildWarning, - shouldToastDesktopUpdateActionResult, } from "./desktopUpdate.logic"; -import { showDesktopUpdateDownloadedToast } from "./desktopUpdate.toast"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { Button } from "./ui/button"; import { @@ -3746,83 +3743,9 @@ export default function LegacySidebar() { setDesktopUpdateActionPending(true); - if (desktopUpdateButtonAction === "download") { - void bridge - .downloadUpdate() - .then((result) => { - if (result.completed) { - showDesktopUpdateDownloadedToast(bridge, result.state); - } - if (!shouldToastDesktopUpdateActionResult(result)) return; - const actionError = getDesktopUpdateActionError(result); - if (!actionError) return; - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not download update", - description: actionError, - }), - ); - }) - .catch((error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not start update download", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }), - ); - }) - .finally(() => setDesktopUpdateActionPending(false)); - return; - } - - if (desktopUpdateButtonAction === "install") { - let confirmed = false; - try { - confirmed = await ensureLocalApi().dialogs.confirm( - getDesktopUpdateInstallConfirmationMessage(desktopUpdateState), - ); - } catch (error) { - setDesktopUpdateActionPending(false); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not confirm update", - description: error instanceof Error ? error.message : "Update confirmation failed.", - }), - ); - return; - } - if (!confirmed) { - setDesktopUpdateActionPending(false); - return; - } - void bridge - .installUpdate() - .then((result) => { - if (!shouldToastDesktopUpdateActionResult(result)) return; - const actionError = getDesktopUpdateActionError(result); - if (!actionError) return; - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not install update", - description: actionError, - }), - ); - }) - .catch((error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not install update", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }), - ); - }) - .finally(() => setDesktopUpdateActionPending(false)); - } + void performDesktopUpdate(bridge, desktopUpdateState).finally(() => + setDesktopUpdateActionPending(false), + ); }, [ desktopUpdateActionPending, desktopUpdateButtonAction, diff --git a/apps/web/src/components/ProviderUpdatesAction.test.tsx b/apps/web/src/components/ProviderUpdatesAction.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..293aee6f9a2c6418dd0d10b86430e8b43172a2c0 --- /dev/null +++ b/apps/web/src/components/ProviderUpdatesAction.test.tsx @@ -0,0 +1,116 @@ +// @vitest-environment jsdom +import { + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import { AsyncResult, Atom } from "effect/reactivity"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +import { appAtomRegistry, AppAtomRegistryProvider } from "~/rpc/atomRegistry"; + +const mocks = vi.hoisted(() => ({ + environments: [] as Array<{ + environmentId: EnvironmentId; + label: string; + connection: { phase: "connected" }; + serverConfig: { providers: ServerProvider[] }; + }>, + permissionAtom: vi.fn(), + update: vi.fn(), +})); +vi.mock("~/state/environments", () => ({ + useEnvironments: () => ({ environments: mocks.environments }), +})); +vi.mock("~/state/server", () => ({ + serverEnvironment: { updateProvider: { permissionAtom: mocks.permissionAtom } }, +})); +vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => mocks.update })); +vi.mock("./ui/toast", () => ({ toastManager: { add: vi.fn() }, stackedThreadToast: vi.fn() })); + +import { useProviderUpdates } from "./ProviderUpdatesAction"; + +const home = EnvironmentId.make("home"); +const work = EnvironmentId.make("work"); +const homeAllowed = Atom.make(false).pipe(Atom.keepAlive); +const workAllowed = Atom.make(false).pipe(Atom.keepAlive); +let root: Root; +let container: HTMLDivElement; + +function Updates() { + const { machines, updateAll } = useProviderUpdates(); + return machines.length > 0 ? ( + + ) : null; +} + +beforeEach(() => { + const provider: ServerProvider = { + instanceId: ProviderInstanceId.make("codex-home"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-10-15T12:00:00Z", + models: [], + slashCommands: [], + skills: [], + versionAdvisory: { + status: "behind_latest", + currentVersion: "1.0.0", + latestVersion: "1.1.0", + updateCommand: "npm install -g provider", + canUpdate: true, + checkedAt: "2026-10-15T12:00:00Z", + message: "Update available.", + }, + }; + mocks.environments = [home, work].map((environmentId) => ({ + environmentId, + label: environmentId, + connection: { phase: "connected" }, + serverConfig: { providers: [provider] }, + })); + mocks.permissionAtom.mockImplementation((id: EnvironmentId) => + id === home ? homeAllowed : workAllowed, + ); + mocks.update.mockReset().mockResolvedValue(AsyncResult.success(undefined)); + appAtomRegistry.set(homeAllowed, false); + appAtomRegistry.set(workAllowed, false); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); +}); + +it("offers and updates only permitted machines, reacting to permission changes", async () => { + await act(async () => + root.render( + + + , + ), + ); + expect(container.querySelector("button")).toBeNull(); + + await act(async () => appAtomRegistry.set(workAllowed, true)); + expect(container.querySelector("button")).not.toBeNull(); + await act(async () => container.querySelector("button")?.click()); + expect(mocks.update).toHaveBeenCalledExactlyOnceWith({ + environmentId: work, + input: { provider: "codex", instanceId: "codex-home" }, + }); + + await act(async () => appAtomRegistry.set(workAllowed, false)); + expect(container.querySelector("button")).toBeNull(); +}); diff --git a/apps/web/src/components/ProviderUpdatesAction.tsx b/apps/web/src/components/ProviderUpdatesAction.tsx index 483ac520ad76d65bde5e0c0b685ccf578fa20b58..134b3157b525e9ccab354f3fdfc3a8c27a9292fe 100644 --- a/apps/web/src/components/ProviderUpdatesAction.tsx +++ b/apps/web/src/components/ProviderUpdatesAction.tsx @@ -1,3 +1,5 @@ +import { useAtomValue } from "@effect/atom-react"; +import { Atom } from "effect/reactivity"; import { PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts"; import { useMemo, useRef, useState } from "react"; @@ -18,8 +20,8 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; * Every outdated provider with a one-click update on every connected machine, * plus a function that updates them all and reports the results in one toast. * Each server queues updates that share an installer, so sending them all - * together is safe. The server still checks permissions: a session that - * cannot operate a machine gets a failure line for it. The returned promise + * together is safe. Only permitted machines are offered; the server checks + * permission again at execution. The returned promise * settles with every result when every update has finished. `quiet` skips the * toast, for a caller that reports the results itself. */ @@ -28,22 +30,28 @@ export function useProviderUpdates() { const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { reportFailure: false, }); - const machines = useMemo( + const machinesAtom = useMemo( () => - environments.flatMap((environment) => { - const providers = environment.serverConfig?.providers ?? []; - if (environment.connection.phase !== "connected") { - return []; - } - const candidates = collectProviderUpdateCandidates(providers).filter((candidate) => - canOneClickUpdateProviderCandidate(candidate, providers), - ); - return candidates.length > 0 - ? [{ environmentId: environment.environmentId, label: environment.label, candidates }] - : []; - }), + Atom.make((get) => + environments.flatMap((environment) => { + const providers = environment.serverConfig?.providers ?? []; + if ( + environment.connection.phase !== "connected" || + !get(serverEnvironment.updateProvider.permissionAtom(environment.environmentId)) + ) { + return []; + } + const candidates = collectProviderUpdateCandidates(providers).filter((candidate) => + canOneClickUpdateProviderCandidate(candidate, providers), + ); + return candidates.length > 0 + ? [{ environmentId: environment.environmentId, label: environment.label, candidates }] + : []; + }), + ), [environments], ); + const machines = useAtomValue(machinesAtom); const updateAll = async ({ quiet = false }: { readonly quiet?: boolean } = {}) => { const runs = await Promise.all( diff --git a/apps/web/src/components/SidebarStageBackdrop.tsx b/apps/web/src/components/SidebarStageBackdrop.tsx index c1e0209064923f620e344bdcd62ef68afc8b2b5e..fbba59badc1b096f5b594889fe5c6b3c3da5546b 100644 --- a/apps/web/src/components/SidebarStageBackdrop.tsx +++ b/apps/web/src/components/SidebarStageBackdrop.tsx @@ -46,18 +46,6 @@ export function useSidebarStageBackdropVariant(enabled = true): SidebarStageBack return resolveSidebarStageBackdropVariant(useEnvironmentStageLabel(), enabled); } -/** Stage-channel header art; palettes mirror the per-channel app icons in `assets/`. */ -export function SidebarStageBackdrop({ variant }: { variant: SidebarStageBackdropVariant }) { - return ( -
- -
- ); -} - export function StageBackdropArt({ variant }: { variant: SidebarStageBackdropVariant }) { return variant === "nightly" ? : ; } diff --git a/apps/web/src/components/desktopUpdate.actions.ts b/apps/web/src/components/desktopUpdate.actions.ts new file mode 100644 index 0000000000000000000000000000000000000000..f6cf68445a711a599f9d2dfc150e44ebfaece653 --- /dev/null +++ b/apps/web/src/components/desktopUpdate.actions.ts @@ -0,0 +1,102 @@ +import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts"; +import { ensureLocalApi } from "../localApi"; +import { stackedThreadToast, toastManager } from "./ui/toast"; +import { + getDesktopUpdateActionError, + getDesktopUpdateInstallConfirmationMessage, + resolveDesktopUpdateButtonAction, + shouldToastDesktopUpdateActionResult, +} from "./desktopUpdate.logic"; +import { showDesktopUpdateDownloadedToast } from "./desktopUpdate.toast"; +import type { ReportLocalUpdateError } from "./sidebar/useUpdateEverything"; + +export const toastUpdateError: ReportLocalUpdateError = (title, description) => { + toastManager.add(stackedThreadToast({ type: "error", title, description })); +}; + +/** Prepares a full-app update or applies a compatible UI without stopping tasks. */ +export async function downloadLocalUpdate( + bridge: DesktopBridge, + { + notifyDownloaded, + reportError = toastUpdateError, + }: { readonly notifyDownloaded: boolean; readonly reportError?: ReportLocalUpdateError }, +): Promise { + try { + const result = await bridge.downloadUpdate(); + if (result.completed && result.state.installKind === "ui") { + return await installLocalUpdate(bridge, reportError); + } + if (result.completed && notifyDownloaded) { + showDesktopUpdateDownloadedToast(bridge, result.state); + } + const actionError = shouldToastDesktopUpdateActionResult(result) + ? getDesktopUpdateActionError(result) + : null; + if (actionError) reportError("Could not download update", actionError); + return result.completed; + } catch (error) { + reportError( + "Could not start update download", + error instanceof Error ? error.message : "An unexpected error occurred.", + ); + return false; + } +} + +/** Applies a prepared UI in place or starts an app relaunch after the caller confirms. */ +export async function installLocalUpdate( + bridge: DesktopBridge, + reportError: ReportLocalUpdateError = toastUpdateError, +): Promise { + try { + const result = await bridge.installUpdate(); + if (!result.accepted) { + showDesktopUpdateDownloadedToast(bridge, result.state); + return false; + } + const actionError = shouldToastDesktopUpdateActionResult(result) + ? getDesktopUpdateActionError(result) + : null; + if (!actionError) { + if (result.completed && result.state.uiVersion) window.location.reload(); + return true; + } + reportError("Could not install update", actionError); + return false; + } catch (error) { + reportError( + "Could not install update", + error instanceof Error ? error.message : "An unexpected error occurred.", + ); + return false; + } +} + +export async function performDesktopUpdate( + bridge: DesktopBridge, + state: DesktopUpdateState, +): Promise { + const action = resolveDesktopUpdateButtonAction(state); + try { + if (action === "download") { + await downloadLocalUpdate(bridge, { notifyDownloaded: true }); + } else if (action === "install") { + if ( + state.installKind === "ui" || + (await ensureLocalApi().dialogs.confirm(getDesktopUpdateInstallConfirmationMessage(state))) + ) { + await installLocalUpdate(bridge); + } + } else { + const result = await bridge.checkForUpdate(); + if (!result.checked) + toastUpdateError( + "Could not check for updates", + result.state.message ?? "Automatic updates are not available in this build.", + ); + } + } catch (error) { + toastUpdateError("Could not update app", error instanceof Error ? error.message : "Try again."); + } +} diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index a5d10773a1fd4ea0c215824937d838e1678f2643..a461b1093f3c2e9c90a7b73ac9b74689a005ce99 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -87,7 +87,9 @@ export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string return `Downloading update${progress}`; } if (state.status === "downloaded") { - return `Update ${state.downloadedVersion ?? state.availableVersion ?? "ready"} downloaded. Click to restart and install.`; + return state.installKind === "ui" + ? `UI update ${state.downloadedVersion ?? state.availableVersion ?? "ready"} downloaded. Reload to apply without interrupting running tasks.` + : `Update ${state.downloadedVersion ?? state.availableVersion ?? "ready"} downloaded. Click to restart and install.`; } if (state.status === "error") { if (state.errorContext === "download" && state.availableVersion) { diff --git a/apps/web/src/components/desktopUpdate.toast.tsx b/apps/web/src/components/desktopUpdate.toast.tsx index 0c4991c1725087cc8dbc3eeeef8c2dd73b9ca742..8e413abf00a298cdff1d23da0fa16ae2e78e7cbc 100644 --- a/apps/web/src/components/desktopUpdate.toast.tsx +++ b/apps/web/src/components/desktopUpdate.toast.tsx @@ -56,7 +56,9 @@ export function showDesktopUpdateDownloadedToast( title: "Update downloaded", description: ( <> - Restart the app from the update button to install it. + {state.installKind === "ui" + ? "Reload the UI from the update button to apply it." + : "Restart the app from the update button to install it."} {releaseUrl ? : null} ), diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 4c0767ad5af4c9a362fcbc986d9a440c5553f9df..e598129000d0b31b70275a297cafbb5172651127 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -1101,7 +1101,7 @@ export default function FilePreviewPanel({ // it on the panel meant a thread switch dropped it and forced source back. const [renderMarkdownPreferred, setRenderMarkdownPreferred] = useLocalStorage( RENDER_MARKDOWN_STORAGE_KEY, - false, + true, Schema.Boolean, ); const [renderBrowserFilePreferred, setRenderBrowserFilePreferred] = useLocalStorage( diff --git a/apps/web/src/components/settings/ProjectFaviconPickerDialog.test.tsx b/apps/web/src/components/settings/ProjectFaviconPickerDialog.test.tsx index 1cb090d9dc5cfa648ef20751765934fb35e40f24..6994886af2384b0b387c564076147c575529da45 100644 --- a/apps/web/src/components/settings/ProjectFaviconPickerDialog.test.tsx +++ b/apps/web/src/components/settings/ProjectFaviconPickerDialog.test.tsx @@ -28,8 +28,8 @@ vi.mock("~/state/server", () => ({ primaryServerKeybindingsAtom: Symbol("keybindings"), })); -vi.mock("~/hooks/useTheme", () => ({ - useTheme: () => ({ resolvedTheme: "dark" }), +vi.mock("~/assets/assetUrls", () => ({ + useAssetUrlState: () => ({ _tag: "Loading" }), })); vi.mock("../files/projectFilesQueryState", () => ({ diff --git a/apps/web/src/components/settings/ProjectFaviconPickerDialog.tsx b/apps/web/src/components/settings/ProjectFaviconPickerDialog.tsx index 5ed077f31bcfea1eefe82f6c5b868d429e810429..c6c6fc85262354f03e14737fcc641ff42cb5d7d4 100644 --- a/apps/web/src/components/settings/ProjectFaviconPickerDialog.tsx +++ b/apps/web/src/components/settings/ProjectFaviconPickerDialog.tsx @@ -1,15 +1,15 @@ import { useAtomValue } from "@effect/atom-react"; import type { EnvironmentId } from "@t3tools/contracts"; import { isWindowsAbsolutePath } from "@t3tools/shared/path"; +import { ImageIcon } from "lucide-react"; import { useMemo, useState } from "react"; +import { useAssetUrlState } from "~/assets/assetUrls"; import { primaryServerKeybindingsAtom } from "~/state/server"; -import { useTheme } from "~/hooks/useTheme"; import { getLocalFileManagerName, isWindowsPlatform } from "~/lib/utils"; import { CommandPaletteContent } from "../CommandPaletteContent"; import type { CommandPaletteActionItem } from "../CommandPalette.logic"; import { CommandPaletteResults } from "../CommandPaletteResults"; -import { PierreEntryIcon } from "../chat/PierreEntryIcon"; import { getProjectFilePickerMatches, PROJECT_FILE_PICKER_RESULT_LIMIT, @@ -27,6 +27,51 @@ export function canPickExternalProjectFavicon(cwd: string, platform: string): bo return !isWindowsPlatform(platform) || isWindowsAbsolutePath(cwd); } +function ProjectIconFilePreview(props: { + readonly environmentId: EnvironmentId; + readonly cwd: string; + readonly path: string; +}) { + const asset = useAssetUrlState(props.environmentId, { + _tag: "draft-workspace-file", + cwd: props.cwd, + path: props.path, + }); + const [failedUrl, setFailedUrl] = useState(null); + const src = asset._tag === "Success" && asset.url !== failedUrl ? asset.url : null; + + return ( + + ); +} + export function ProjectFaviconPickerDialog(props: { readonly cwd: string; readonly environmentId: EnvironmentId; @@ -36,17 +81,17 @@ export function ProjectFaviconPickerDialog(props: { readonly open: boolean; readonly projectName: string; }) { + const { cwd, environmentId, onSelect } = props; const [query, setQuery] = useState(""); const [highlightedItemValue, setHighlightedItemValue] = useState(null); const [isPickingExternal, setIsPickingExternal] = useState(false); const result = useProjectFilePickerQuery( - props.environmentId, - props.cwd, + environmentId, + cwd, query, PROJECT_FILE_PICKER_RESULT_LIMIT, { imageOnly: true }, ); - const { resolvedTheme } = useTheme(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const pickExternal = props.onPickExternal; const fileManagerName = getLocalFileManagerName( @@ -60,10 +105,10 @@ export function ProjectFaviconPickerDialog(props: { searchTerms: [match.name, match.path], title: match.name, description: match.path, - icon: , - run: async () => props.onSelect(match.path), + icon: , + run: async () => onSelect(match.path), })), - [props.onSelect, resolvedTheme, result.entries, result.matchedQuery], + [environmentId, cwd, onSelect, result.entries, result.matchedQuery], ); return ( diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index af9b534e07c60ddb067fa826034c307421bc3b7d..b6ad7c6e6f7452a4ca3ee168dd5c1b773b178b72 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,3 +1,4 @@ +import { performDesktopUpdate } from "../desktopUpdate.actions"; import { SettingsGroup } from "./SettingsGroup"; import { useScopedSettingsWriteAllowed } from "./useScopedSettings"; import { Spinner } from "~/components/ui/spinner"; @@ -6,7 +7,7 @@ import { PRIVACY_POLICY_URL } from "../../legalLinks"; import { ArchiveIcon, ArchiveX, CheckIcon, ChevronRightIcon, SettingsIcon } from "lucide-react"; import { Link, useNavigate } from "@tanstack/react-router"; import type { CSSProperties, ReactNode } from "react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { type BackgroundActivityProfile, type DesktopUpdateChannel, @@ -59,7 +60,6 @@ import { CliCommandSettingsRow } from "./CliCommandSettingsRow"; import { canCheckForUpdate, getDesktopUpdateButtonTooltip, - getDesktopUpdateInstallConfirmationMessage, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, } from "../../components/desktopUpdate.logic"; @@ -270,10 +270,13 @@ function backgroundActivityProfileSettings(profile: BackgroundActivityProfile) { } function AboutVersionTitle() { + const updateState = useDesktopUpdateState(); + const version = updateState?.uiVersion ?? updateState?.currentVersion ?? APP_VERSION; + return ( Version - {APP_VERSION} + {version} ); } @@ -324,85 +327,13 @@ function AboutVersionSection() { const bridge = window.desktopBridge; if (!bridge) return; - const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none"; - - if (action === "download") { - void bridge.downloadUpdate().catch((error: unknown) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not download update", - description: error instanceof Error ? error.message : "Download failed.", - }), - ); - }); - return; + if (!updateState || isUpdateActionPending) return; + setIsUpdateActionPending(true); + try { + await performDesktopUpdate(bridge, updateState); + } finally { + setIsUpdateActionPending(false); } - - if (action === "install") { - if (isUpdateActionPending) return; - setIsUpdateActionPending(true); - let confirmed = false; - try { - confirmed = await ensureLocalApi().dialogs.confirm( - getDesktopUpdateInstallConfirmationMessage( - updateState ?? { availableVersion: null, downloadedVersion: null }, - ), - ); - } catch (error) { - setIsUpdateActionPending(false); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not confirm update", - description: error instanceof Error ? error.message : "Update confirmation failed.", - }), - ); - return; - } - if (!confirmed) { - setIsUpdateActionPending(false); - return; - } - void bridge - .installUpdate() - .catch((error: unknown) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not install update", - description: error instanceof Error ? error.message : "Install failed.", - }), - ); - }) - .finally(() => setIsUpdateActionPending(false)); - return; - } - - if (typeof bridge.checkForUpdate !== "function") return; - void bridge - .checkForUpdate() - .then((result) => { - if (!result.checked) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not check for updates", - description: - result.state.message ?? "Automatic updates are not available in this build.", - }), - ); - } - }) - .catch((error: unknown) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not check for updates", - description: error instanceof Error ? error.message : "Update check failed.", - }), - ); - }); }, [isUpdateActionPending, updateState]); const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none"; @@ -411,8 +342,10 @@ function AboutVersionSection() { action === "none" ? !canCheckForUpdate(updateState) : isDesktopUpdateButtonDisabled(updateState); - - const actionLabel: Record = { download: "Download", install: "Install" }; + const actionLabel: Record = { + download: "Download", + install: updateState?.installKind === "ui" ? "Reload" : "Restart", + }; const statusLabel: Record = { checking: "Checking…", downloading: "Downloading…", @@ -1525,23 +1458,32 @@ export function AppearanceSettingsPanel() { ); } +function subscribeToFontLoading(onChange: () => void) { + document.fonts.addEventListener("loadingdone", onChange); + document.fonts.addEventListener("loadingerror", onChange); + + return () => { + document.fonts.removeEventListener("loadingdone", onChange); + document.fonts.removeEventListener("loadingerror", onChange); + }; +} + function useFontDefaultFamilies() { const settings = useScopedSettings(); - // An unset preference shows the font it resolves to on this machine; the - // default stacks are the platform's own faces, so the name is probed, not - // hardcoded. - const defaults = useMemo( - () => ({ - sans: resolveDefaultFamilyLabel(DEFAULT_SANS_FONT_STACK) ?? "System default", - code: resolveDefaultFamilyLabel(DEFAULT_CODE_FONT_STACK) ?? "System monospace", - }), - [], + const sans = useSyncExternalStore( + subscribeToFontLoading, + () => resolveDefaultFamilyLabel(DEFAULT_SANS_FONT_STACK) ?? "System default", ); + const code = useSyncExternalStore( + subscribeToFontLoading, + () => resolveDefaultFamilyLabel(DEFAULT_CODE_FONT_STACK) ?? "System monospace", + ); + return { - sans: defaults.sans, - code: defaults.code, + sans, + code, // The composer inherits whatever the interface preference resolves to. - interfaceFamily: settings.fontFamilySans.trim() || defaults.sans, + interfaceFamily: settings.fontFamilySans.trim() || sans, }; } diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 035e42b53cc222138c84bb21ecf7d3e9e9f92e57..3f6ac3429d003ef49a86c9375df72cad0c21fd26 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -6,25 +6,9 @@ import { useMemo, useRef, useState, - type ComponentType, type KeyboardEvent, } from "react"; -import { - ArchiveIcon, - BlocksIcon, - BotIcon, - createLucideIcon, - CalendarClockIcon, - GitBranchIcon, - HardDriveIcon, - PanelsTopLeftIcon, - KeyboardIcon, - Link2Icon, - PaletteIcon, - SearchIcon, - Settings2Icon, - XIcon, -} from "lucide-react"; +import { SearchIcon, XIcon } from "lucide-react"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { Button } from "../ui/button"; @@ -43,25 +27,13 @@ import { SidebarUtilityMenu } from "../sidebar/SidebarChrome"; import { scrollToSettingsTarget } from "./settingsLayout"; import { searchSettings, - isSettingsOverviewVisible, SETTINGS_SECTION_LABELS, type SettingsPath, type SettingsSearchItem, } from "./settingsSearch"; import { useAvailableSettingsSearchItems } from "./useAvailableSettingsSearchItems"; import { validateSettingsScopeSearch } from "./settingsScope"; - -const SnapShotIcon = createLucideIcon("snap-shot", [ - [ - "path", - { - 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", - key: "capture-frame", - }, - ], - ["rect", { width: "10", height: "8", x: "7", y: "8", rx: "2", key: "window" }], - ["circle", { cx: "12", cy: "12", r: "1.5", key: "lens" }], -]); +import { getSettingsNavigationItems, SETTINGS_SECTION_ICONS } from "./settingsNavigation"; const T3ConnectSidebarSignIn = lazy(() => import("../clerk/T3ConnectSidebarSignIn").then((module) => ({ @@ -74,36 +46,6 @@ const T3ConnectSidebarAvatar = lazy(() => })), ); -const SETTINGS_SECTION_ICONS: Readonly< - Record> -> = { - "/settings/general": Settings2Icon, - "/settings/diagnostics": Settings2Icon, - "/settings/appearance": PaletteIcon, - "/settings/projects": PanelsTopLeftIcon, - "/settings/keybindings": KeyboardIcon, - "/settings/snap-shot": SnapShotIcon, - "/settings/providers": BotIcon, - "/settings/integrations": BlocksIcon, - "/settings/scheduled-tasks": CalendarClockIcon, - "/settings/source-control": GitBranchIcon, - "/settings/storage": HardDriveIcon, - "/settings/connections": Link2Icon, - "/settings/archived": ArchiveIcon, -}; - -const SETTINGS_NAV_ITEMS: ReadonlyArray<{ - label: string; - to: SettingsPath; - icon: ComponentType<{ className?: string }>; -}> = (Object.keys(SETTINGS_SECTION_LABELS) as SettingsPath[]) - .filter((to) => to !== "/settings/diagnostics") - .map((to) => ({ - to, - label: SETTINGS_SECTION_LABELS[to], - icon: SETTINGS_SECTION_ICONS[to], - })); - function SettingsSectionIcon({ to }: { to: SettingsPath }) { const Icon = SETTINGS_SECTION_ICONS[to]; return ; @@ -114,9 +56,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { const currentHash = useLocation({ select: (location) => location.hash }); const currentSearch = useLocation({ select: (location) => location.search }); const scopeSearch = useMemo(() => validateSettingsScopeSearch(currentSearch), [currentSearch]); - const navItems = SETTINGS_NAV_ITEMS.filter( - (item) => item.to !== "/settings/projects" || isSettingsOverviewVisible(scopeSearch), - ); + const navItems = getSettingsNavigationItems(scopeSearch); const { isMobile, setOpenMobile, open, setOpen } = useSidebar(); const searchInputRef = useRef(null); const [query, setQuery] = useState(""); diff --git a/apps/web/src/components/settings/settingsNavigation.tsx b/apps/web/src/components/settings/settingsNavigation.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3cf107458d08e03a06d5377977a8d9aaf952b934 --- /dev/null +++ b/apps/web/src/components/settings/settingsNavigation.tsx @@ -0,0 +1,70 @@ +import type { ComponentType } from "react"; +import { + ArchiveIcon, + BlocksIcon, + BotIcon, + CalendarClockIcon, + createLucideIcon, + GitBranchIcon, + HardDriveIcon, + KeyboardIcon, + Link2Icon, + PaletteIcon, + PanelsTopLeftIcon, + Settings2Icon, +} from "lucide-react"; + +import { + isSettingsOverviewVisible, + SETTINGS_SECTION_LABELS, + type SettingsPath, +} from "./settingsSearch"; +import type { SettingsScopeSearch } from "./settingsScope"; + +const SnapShotIcon = createLucideIcon("snap-shot", [ + [ + "path", + { + 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", + key: "capture-frame", + }, + ], + ["rect", { width: "10", height: "8", x: "7", y: "8", rx: "2", key: "window" }], + ["circle", { cx: "12", cy: "12", r: "1.5", key: "lens" }], +]); + +export const SETTINGS_SECTION_ICONS: Readonly< + Record> +> = { + "/settings/general": Settings2Icon, + "/settings/diagnostics": Settings2Icon, + "/settings/appearance": PaletteIcon, + "/settings/projects": PanelsTopLeftIcon, + "/settings/keybindings": KeyboardIcon, + "/settings/snap-shot": SnapShotIcon, + "/settings/providers": BotIcon, + "/settings/integrations": BlocksIcon, + "/settings/scheduled-tasks": CalendarClockIcon, + "/settings/source-control": GitBranchIcon, + "/settings/storage": HardDriveIcon, + "/settings/connections": Link2Icon, + "/settings/archived": ArchiveIcon, +}; + +const SETTINGS_NAV_ITEMS: ReadonlyArray<{ + label: string; + to: SettingsPath; + icon: ComponentType<{ className?: string }>; +}> = (Object.keys(SETTINGS_SECTION_LABELS) as SettingsPath[]) + .filter((to) => to !== "/settings/diagnostics") + .map((to) => ({ + to, + label: SETTINGS_SECTION_LABELS[to], + icon: SETTINGS_SECTION_ICONS[to], + })); + +export function getSettingsNavigationItems(scopeSearch: SettingsScopeSearch) { + return SETTINGS_NAV_ITEMS.filter( + (item) => item.to !== "/settings/projects" || isSettingsOverviewVisible(scopeSearch), + ); +} diff --git a/apps/web/src/components/sidebar/AppMenu.tsx b/apps/web/src/components/sidebar/AppMenu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8e36dcaf50ab804bd4a7612b6660b8dde12ed622 --- /dev/null +++ b/apps/web/src/components/sidebar/AppMenu.tsx @@ -0,0 +1,80 @@ +import { Link, useLocation } from "@tanstack/react-router"; +import { useId, useState } from "react"; + +import { APP_BASE_NAME, APP_VERSION } from "../../branding"; +import { useDesktopUpdateState } from "../../state/desktopUpdate"; +import { KnifeMark } from "../KnifeMark"; +import { useEnvironmentStageLabel } from "../SidebarStageBackdrop"; +import { getSettingsNavigationItems } from "../settings/settingsNavigation"; +import { validateSettingsScopeSearch } from "../settings/settingsScope"; +import { Button } from "../ui/button"; +import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover"; +import { Separator } from "../ui/separator"; +import { useSidebar } from "../ui/sidebar"; +import { AppMenuProviderUpdates } from "./AppMenuProviderUpdates"; +import { AppMenuUpdates } from "./SidebarUpdatePill"; + +export function AppMenu() { + const [open, setOpen] = useState(false); + const settingsHeadingId = useId(); + const { isMobile, setOpenMobile } = useSidebar(); + const { pathname, search } = useLocation(); + const scopeSearch = validateSettingsScopeSearch(pathname.startsWith("/settings") ? search : {}); + const stageLabel = useEnvironmentStageLabel(); + const updateState = useDesktopUpdateState(); + const version = updateState?.uiVersion ?? updateState?.currentVersion ?? APP_VERSION; + const closeForNavigation = () => { + setOpen(false); + if (isMobile) setOpenMobile(false); + }; + + return ( + + + + + } + /> + +
+ {APP_BASE_NAME} + + {version} {stageLabel} + +
+ + + +

+ Settings +

+ +
+
+ ); +} diff --git a/apps/web/src/components/sidebar/AppMenuProviderUpdates.test.tsx b/apps/web/src/components/sidebar/AppMenuProviderUpdates.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..74162f750c8410edef1af036e54e55fb824ee78a --- /dev/null +++ b/apps/web/src/components/sidebar/AppMenuProviderUpdates.test.tsx @@ -0,0 +1,284 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + RouterContextProvider, + type AnyRouter, +} from "@tanstack/react-router"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/reactivity"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + environments: [] as Array<{ + environmentId: string; + label: string; + connection: { phase: "connected" | "offline" }; + serverConfig: { providers: ServerProvider[] }; + }>, + permitted: new Set(), + updateProvider: vi.fn(), +})); + +vi.mock("~/state/environments", () => ({ useEnvironments: () => state })); +vi.mock("~/state/server", () => ({ + serverEnvironment: { updateProvider: { permissionAtom: (id: string) => id } }, +})); +vi.mock("@effect/atom-react", () => ({ + useAtomValue: (id: string) => state.permitted.has(id), +})); +vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => state.updateProvider })); +vi.mock("../chat/ProviderInstanceIcon", () => ({ ProviderInstanceIcon: () => null })); +vi.mock("../ui/spinner", () => ({ Spinner: () => null })); +vi.mock("../settings/providerDriverMeta", () => ({ providerClients: new Map() })); + +import { AppMenuProviderUpdates } from "./AppMenuProviderUpdates"; + +let root: Root; +let container: HTMLDivElement; +let router: AnyRouter; + +function provider(overrides: Partial = {}): ServerProvider { + return { + driver: ProviderDriverKind.make("codex"), + instanceId: ProviderInstanceId.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-10-15T12:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + versionAdvisory: { + status: "behind_latest", + currentVersion: "1.0.0", + latestVersion: "1.1.0", + updateCommand: "npm install -g provider", + canUpdate: true, + checkedAt: "2026-10-15T12:00:00.000Z", + message: "Update available.", + }, + ...overrides, + }; +} + +function connect(id: string, providers: ServerProvider[]) { + state.environments.push({ + environmentId: id, + label: id, + connection: { phase: "connected" }, + serverConfig: { providers }, + }); + state.permitted.add(id); +} + +function button(name: string) { + const node = container.querySelector(`button[aria-label="${name}"]`); + if (!node) throw new Error(`Missing button: ${name}`); + return node; +} + +async function render() { + await act(() => + root.render( + + + , + ), + ); +} + +beforeEach(async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + state.environments = []; + state.permitted.clear(); + state.updateProvider.mockReset(); + state.updateProvider.mockResolvedValue(AsyncResult.success({ providers: [] })); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + const rootRoute = createRootRoute(); + router = createRouter({ + routeTree: rootRoute.addChildren([ + createRoute({ getParentRoute: () => rootRoute, path: "/" }), + createRoute({ getParentRoute: () => rootRoute, path: "/settings/providers" }), + ]), + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + await router.load(); +}); + +afterEach(async () => { + await act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +it("updates one representative per installation and routes each machine independently", async () => { + connect("laptop", [ + provider({ instanceId: ProviderInstanceId.make("codex-personal") }), + provider(), + provider({ driver: ProviderDriverKind.make("claudeAgent"), enabled: false }), + provider({ driver: ProviderDriverKind.make("cursor"), installed: false }), + ]); + connect("desktop", [provider()]); + state.environments.push({ + environmentId: "offline", + label: "offline", + connection: { phase: "offline" }, + serverConfig: { providers: [provider()] }, + }); + await render(); + + await act(async () => button("Update Codex on laptop").click()); + await act(async () => button("Update Codex on desktop").click()); + + expect(state.updateProvider.mock.calls.map(([target]) => target)).toEqual([ + { environmentId: "laptop", input: { provider: "codex", instanceId: "codex" } }, + { environmentId: "desktop", input: { provider: "codex", instanceId: "codex" } }, + ]); + expect(container.querySelectorAll("button")).toHaveLength(2); +}); + +it("keeps an installation disabled until its direct update settles, including rapid clicks", async () => { + connect("laptop", [provider()]); + const run = Promise.withResolvers(); + state.updateProvider.mockReturnValue(run.promise); + await render(); + const update = button("Update Codex"); + await act(() => { + update.click(); + update.click(); + }); + expect(state.updateProvider).toHaveBeenCalledOnce(); + expect(button("Updating Codex").disabled).toBe(true); + + await act(() => run.resolve(AsyncResult.success({ providers: [] }))); + expect(button("Update Codex").disabled).toBe(false); +}); + +it("reports a transport failure inline, permits retry, and clears it once the installation is current", async () => { + const outdated = provider(); + connect("laptop", [outdated]); + state.updateProvider.mockResolvedValueOnce( + AsyncResult.failure(Cause.fail(new Error("Host disconnected. Reconnect and retry."))), + ); + await render(); + await act(async () => button("Update Codex").click()); + expect(container.querySelector('[role="alert"]')?.textContent).toBe( + "Host disconnected. Reconnect and retry.", + ); + + await act(async () => button("Retry Codex").click()); + expect(state.updateProvider).toHaveBeenCalledTimes(2); + expect(container.querySelector('[role="alert"]')).toBeNull(); + + state.environments[0]!.serverConfig.providers = [ + provider({ + version: "1.1.0", + versionAdvisory: { ...outdated.versionAdvisory!, status: "current", currentVersion: "1.1.0" }, + }), + ]; + await render(); + expect(container.querySelector('[role="img"][aria-label="Up to date"]')).not.toBeNull(); + expect(container.querySelector("button")).toBeNull(); +}); + +it("follows the guarded command permission and never dispatches through a disabled connection", async () => { + connect("laptop", [provider()]); + state.permitted.delete("laptop"); + await render(); + await act(async () => button("Update Codex").click()); + expect(state.updateProvider).not.toHaveBeenCalled(); + expect(container.textContent).toContain("This connection cannot update providers."); + + state.permitted.add("laptop"); + await render(); + await act(async () => button("Update Codex").click()); + expect(state.updateProvider).toHaveBeenCalledOnce(); +}); + +it("requires provider settings when profiles disagree on their installer", async () => { + const first = provider(); + connect("laptop", [ + first, + provider({ + instanceId: ProviderInstanceId.make("codex-personal"), + versionAdvisory: { ...first.versionAdvisory!, updateCommand: "brew upgrade provider" }, + }), + ]); + await render(); + expect(container.querySelector("button")).toBeNull(); + const details = container.querySelector("a"); + expect(details).not.toBeNull(); + await act(async () => details!.click()); + expect(router.state.location.pathname).toBe("/settings/providers"); + expect(router.state.location.search).toEqual({ machine: "laptop" }); + expect(state.updateProvider).not.toHaveBeenCalled(); +}); + +it("blocks incompatible latest versions and explains the reason", async () => { + connect("laptop", [ + provider({ + compatibilityAdvisory: { + status: "supported", + latestVersionStatus: "broken", + message: "The latest CLI cannot start sessions.", + recommendedVersion: "1.0.0", + recommendedRange: null, + }, + }), + ]); + await render(); + expect(container.textContent).toContain("Update blocked: The latest CLI cannot start sessions."); + expect(container.querySelector("button")).toBeNull(); + expect(container.querySelector("a")?.getAttribute("href")).toBe( + "/settings/providers?machine=laptop", + ); + expect(state.updateProvider).not.toHaveBeenCalled(); +}); + +it("honors a sibling profile's queued update and keeps failed server results retryable", async () => { + connect("laptop", [ + provider(), + provider({ + instanceId: ProviderInstanceId.make("codex-personal"), + updateState: { + status: "queued", + startedAt: "2026-10-15T12:01:00.000Z", + finishedAt: null, + message: null, + output: null, + }, + }), + ]); + await render(); + expect(button("Updating Codex").disabled).toBe(true); + expect(container.textContent).toContain("Queued"); + expect(state.updateProvider).not.toHaveBeenCalled(); + + state.environments[0]!.serverConfig.providers = [ + provider({ + updateState: { + status: "failed", + startedAt: "2026-10-15T12:01:00.000Z", + finishedAt: "2026-10-15T12:02:00.000Z", + message: "Installer unavailable. Try again.", + output: null, + }, + }), + ]; + await render(); + expect(container.querySelector('[role="alert"]')?.textContent).toBe( + "Installer unavailable. Try again.", + ); + await act(async () => button("Retry Codex").click()); + expect(state.updateProvider).toHaveBeenCalledOnce(); +}); diff --git a/apps/web/src/components/sidebar/AppMenuProviderUpdates.tsx b/apps/web/src/components/sidebar/AppMenuProviderUpdates.tsx new file mode 100644 index 0000000000000000000000000000000000000000..83389a57a81fdc31d81b5ec7dddf04322c545f6e --- /dev/null +++ b/apps/web/src/components/sidebar/AppMenuProviderUpdates.tsx @@ -0,0 +1,226 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + PROVIDER_DISPLAY_NAMES, + type EnvironmentId, + type ServerProvider, +} from "@t3tools/contracts"; +import { Link } from "@tanstack/react-router"; +import { CheckIcon } from "lucide-react"; +import { useRef, useState } from "react"; + +import { useEnvironments } from "~/state/environments"; +import { serverEnvironment } from "~/state/server"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { + canOneClickUpdateProviderCandidate, + collectProviderUpdateCandidates, + isProviderUpdateActive, +} from "../ProviderUpdateLaunchNotification.logic"; +import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; +import { providerClients } from "../settings/providerDriverMeta"; +import { Button } from "../ui/button"; +import { Spinner } from "../ui/spinner"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +function ProviderInstallationRow({ + environmentId, + environmentLabel, + providers, + onNavigate, +}: { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string | null; + readonly providers: ReadonlyArray; + readonly onNavigate: (() => void) | undefined; +}) { + const allowed = useAtomValue(serverEnvironment.updateProvider.permissionAtom(environmentId)); + const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { reportFailure: false }); + const inFlight = useRef(false); + const [pending, setPending] = useState(false); + const [requestError, setRequestError] = useState(null); + const candidate = collectProviderUpdateCandidates(providers)[0]; + const provider = + candidate ?? + providers.find((entry) => entry.versionAdvisory?.status === "behind_latest") ?? + providers.toSorted((a, b) => b.checkedAt.localeCompare(a.checkedAt))[0]; + if (!provider) return null; + + const name = + providerClients.get(provider.driver)?.label ?? + PROVIDER_DISPLAY_NAMES[provider.driver] ?? + provider.driver; + const active = providers.find(isProviderUpdateActive); + const updating = pending || active !== undefined; + const canUpdate = + candidate !== undefined && canOneClickUpdateProviderCandidate(candidate, providers); + const compatibility = provider.compatibilityAdvisory; + const latestBlocked = + compatibility?.latestVersionStatus === "broken" || + compatibility?.latestVersionStatus === "unsupported"; + const latestUpdate = providers + .flatMap((entry) => (entry.updateState ? [entry.updateState] : [])) + .toSorted((a, b) => + (b.finishedAt ?? b.startedAt ?? "").localeCompare(a.finishedAt ?? a.startedAt ?? ""), + )[0]; + const current = providers.every((entry) => entry.versionAdvisory?.status === "current"); + const error = + !current && !updating + ? (requestError ?? + (latestUpdate?.status === "failed" + ? (latestUpdate.message ?? "Provider update failed. Try again.") + : null)) + : null; + const versions = [ + ...new Set(providers.map((entry) => entry.version).filter((version) => version !== null)), + ]; + const installedVersion = versions.length > 0 ? versions.join(" / ") : "Version unknown"; + const latestVersion = provider.versionAdvisory?.latestVersion; + const versionLabel = + latestVersion && !versions.every((version) => version === latestVersion) + ? `${installedVersion} → ${latestVersion}` + : installedVersion; + + const handleUpdate = async () => { + if (!candidate || !allowed || !canUpdate || inFlight.current) return; + + inFlight.current = true; + setPending(true); + setRequestError(null); + try { + const result = await updateProvider({ + environmentId, + input: { provider: candidate.driver, instanceId: candidate.instanceId }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const failure = squashAtomCommandFailure(result); + setRequestError( + failure instanceof Error ? failure.message : "Provider update failed. Try again.", + ); + } + } catch (failure) { + setRequestError( + failure instanceof Error ? failure.message : "Provider update failed. Try again.", + ); + } finally { + inFlight.current = false; + setPending(false); + } + }; + + return ( +
+ +
+
+ {name} + {environmentLabel ? ( + {environmentLabel} + ) : null} + + + } + > + {versionLabel} + + {versionLabel} + +
+ {latestBlocked ? ( +

+ {compatibility?.message + ? `Update blocked: ${compatibility.message}` + : "Latest version blocked"} +

+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} + {candidate && canUpdate && !allowed ? ( +

+ This connection cannot update providers. +

+ ) : null} +
+
+ {updating ? ( + + ) : canUpdate ? ( + + ) : provider.versionAdvisory?.status === "behind_latest" ? ( + + ) : current ? ( + + + ) : null} +
+
+ ); +} + +export function AppMenuProviderUpdates({ onNavigate }: { readonly onNavigate?: () => void }) { + const { environments } = useEnvironments(); + const connected = environments.filter( + (environment) => environment.connection.phase === "connected", + ); + + return connected.flatMap((environment) => { + const providers = (environment.serverConfig?.providers ?? []).filter( + (provider) => provider.enabled && provider.installed, + ); + return [...Map.groupBy(providers, (provider) => provider.driver)].map(([driver, profiles]) => ( + 1 ? environment.label : null} + providers={profiles} + onNavigate={onNavigate} + /> + )); + }); +} diff --git a/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx b/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx index c995a23f3463000b27594692878878f9cd91b691..f1bc6b40ce7929cc121aaded4403024f453802fc 100644 --- a/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx +++ b/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx @@ -1,6 +1,5 @@ import { RefreshIcon } from "~/components/ui/refresh-icon"; -import { CheckIcon, CircleArrowUpIcon, DownloadIcon, RotateCwIcon } from "lucide-react"; -import type { AnimationEventHandler } from "react"; +import { CheckIcon, DownloadIcon, RotateCwIcon } from "lucide-react"; const DOWNLOAD_PROGRESS_RADIUS = 14; const DOWNLOAD_PROGRESS_CIRCUMFERENCE = 2 * Math.PI * DOWNLOAD_PROGRESS_RADIUS; @@ -10,38 +9,13 @@ export type DesktopUpdateStatusIconState = | "checking" | "available" | "downloading" - | "downloaded" - // Other machines or providers have updates. Matches the server update icon - // in Connections settings; the download arrow stays for this app's own file. - | "everything"; + | "downloaded"; function normalizeDesktopUpdateDownloadPercent(percent: number | null): number { if (percent === null || !Number.isFinite(percent)) return 0; return Math.min(100, Math.max(0, percent)); } -export function shouldShowDesktopUpdateCheckIcon({ - isAnimationLatched, - isChecking, - prefersReducedMotion, -}: { - readonly isAnimationLatched: boolean; - readonly isChecking: boolean; - readonly prefersReducedMotion: boolean; -}): boolean { - return isChecking || (isAnimationLatched && !prefersReducedMotion); -} - -export function shouldContinueDesktopUpdateCheckAnimation({ - isChecking, - prefersReducedMotion, -}: { - readonly isChecking: boolean; - readonly prefersReducedMotion: boolean; -}): boolean { - return isChecking && !prefersReducedMotion; -} - function DesktopUpdateAvailableIcon() { return ( @@ -105,12 +79,10 @@ function DesktopUpdateDownloadedIcon() { export function DesktopUpdateStatusIcon({ downloadPercent, isCheckAnimating, - onCheckAnimationIteration, status, }: { readonly downloadPercent?: number | null; readonly isCheckAnimating?: boolean; - readonly onCheckAnimationIteration?: AnimationEventHandler; readonly status: DesktopUpdateStatusIconState; }) { if (status === "available") return ; @@ -118,13 +90,11 @@ export function DesktopUpdateStatusIcon({ return ; } if (status === "downloaded") return ; - if (status === "everything") return ; return ( ); } diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index ae05529374c38f0c2c304e8ceedeec94828962d6..303233eee8c4ad645efb3dda48f13ba05498d0b4 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -1,16 +1,13 @@ -import { ArrowLeftIcon, ChartNoAxesColumnIcon, SettingsIcon } from "lucide-react"; +import { ArrowLeftIcon, ChartNoAxesColumnIcon } from "lucide-react"; import type { ReactNode } from "react"; import { memo, useCallback } from "react"; -import { Link, useLocation, useNavigate } from "@tanstack/react-router"; +import { useLocation, useNavigate } from "@tanstack/react-router"; import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; import { usePullRequestsSupported } from "../../state/environments"; -import { T3Wordmark } from "../T3Wordmark"; import { resolveEnvironmentIdentificationPillLabel, - resolveSidebarStageBackdropVariant, - SidebarStageBackdrop, useEnvironmentStageLabel, } from "../SidebarStageBackdrop"; import { Badge } from "../ui/badge"; @@ -26,10 +23,8 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { readPullRequestListPreferences } from "../pullRequest/pullRequestListPreferences"; import { isSidebarUtilityPage, useNavigateToMainApp } from "./mainAppLocation"; import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice"; -import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill"; -import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill"; +import { AppMenu } from "./AppMenu"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; -import { observeResize } from "~/lib/observeResize"; export const SidebarChromeHeader = memo(function SidebarChromeHeader({ isElectron, @@ -38,10 +33,6 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({ }) { const stageLabel = useEnvironmentStageLabel(); const environmentIdentificationMode = useEnvironmentIdentificationMode(); - const backdropVariant = resolveSidebarStageBackdropVariant( - stageLabel, - environmentIdentificationMode === "artwork", - ); const pillLabel = environmentIdentificationMode === "pill" ? resolveEnvironmentIdentificationPillLabel(stageLabel) @@ -55,94 +46,24 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({ isElectron && "drag-region", )} > - {backdropVariant ? : null} {/* Match the fixed control's inset and border compensation inside the sheet's safe area. */}
- -
- {/* One visible line: the pill wraps onto the clipped second line once it no longer fits. - The padding keeps the brand's focus ring inside the clip. */} -
- - {pillLabel ? ( -
- - {pillLabel} - -
- ) : null} +
+ +
+
+ {pillLabel ? ( +
+ + {pillLabel} + +
+ ) : null}
); }); -// Measures the brand at its titlebar inset, plus the header's right padding and the -// sidebar border, so the sidebar minimum follows font size, zoom and macOS window controls. -export function SidebarBrandWidthProbe({ - onWidthChange, -}: { - onWidthChange: (width: number) => void; -}) { - const observeWidth = useCallback( - (probe: HTMLDivElement) => { - return observeResize(probe, ([entry]) => { - if (entry) onWidthChange(entry.borderBoxSize[0]?.inlineSize ?? probe.offsetWidth); - }); - }, - [onWidthChange], - ); - - return ( - - ); -} - -function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { - return ( - - - - ); -} - -function SidebarBrandMark({ onBackdrop }: { onBackdrop: boolean }) { - return ( - // Center the visible capitals, without the font's ascender/descender space. - // Padding keeps ascenders and round-letter overshoot inside the truncation clip. - - - - Code - - - ); -} - function SidebarUtilityItem({ icon, label, @@ -188,11 +109,6 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { search: readPullRequestListPreferences(), }); }, [closeMobileSidebar, navigate]); - const handleSettingsClick = useCallback(() => { - closeMobileSidebar(); - void navigate({ to: "/settings" }); - }, [closeMobileSidebar, navigate]); - const handleUsageClick = useCallback(() => { if (isMobile) { setOpenMobile(false); @@ -216,11 +132,6 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { ) : ( <> - } - label="Settings" - onClick={handleSettingsClick} - /> {pullRequestsSupported ? ( } @@ -235,7 +146,6 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { /> )} - ); }); @@ -244,8 +154,6 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() { return ( - - ); diff --git a/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx deleted file mode 100644 index d584e04656f2849418bc5eb8bc63d4ab3dc4f212..0000000000000000000000000000000000000000 --- a/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx +++ /dev/null @@ -1,210 +0,0 @@ -import { Spinner } from "~/components/ui/spinner"; -import { useNavigate } from "@tanstack/react-router"; -import { useAtomValue } from "@effect/atom-react"; -import type { ServerProvider } from "@t3tools/contracts"; -import { CircleCheckIcon, DownloadIcon, TriangleAlertIcon, XIcon } from "lucide-react"; -import { useCallback, useEffect, useState, type CSSProperties } from "react"; - -import { primaryServerProvidersAtom } from "../../state/server"; -import { - getProviderUpdateSidebarPillView, - type ProviderUpdateSidebarPillView, -} from "../ProviderUpdateLaunchNotification.logic"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; - -const PROVIDER_UPDATE_PILL_STYLES = { - loading: - "bg-sidebar-control-surface text-sidebar-foreground group-has-[button[data-provider-update-main]:hover]/provider-update:bg-sidebar-row-hover", - success: - "bg-sidebar-control-surface text-sidebar-foreground group-has-[button[data-provider-update-main]:hover]/provider-update:bg-sidebar-row-hover", - warning: - "bg-warning/12 text-warning group-has-[button[data-provider-update-main]:hover]/provider-update:bg-warning/18", - error: - "bg-destructive/12 text-destructive group-has-[button[data-provider-update-main]:hover]/provider-update:bg-destructive/18", -} as const; - -const PROVIDER_UPDATE_PILL_PROGRESS_STYLES = { - success: "bg-foreground/8", - warning: "bg-warning/14", - error: "bg-destructive/14", -} as const; - -function latestProviderCheckedAt( - providers: ReadonlyArray>, -): string | undefined { - return providers.reduce( - (latest, provider) => - latest === undefined || provider.checkedAt > latest ? provider.checkedAt : latest, - undefined, - ); -} - -export function SidebarProviderUpdatePill() { - const navigate = useNavigate(); - const providers = useAtomValue(primaryServerProvidersAtom); - const [dismissedKeys, setDismissedKeys] = useState>(() => new Set()); - const [renderedView, setRenderedView] = useState(null); - const [pendingView, setPendingView] = useState(null); - const [exitingKey, setExitingKey] = useState(null); - const [dismissAfterExitKey, setDismissAfterExitKey] = useState(null); - const [visibleAfterIso, setVisibleAfterIso] = useState(); - const effectiveVisibleAfterIso = visibleAfterIso ?? latestProviderCheckedAt(providers); - const view = getProviderUpdateSidebarPillView(providers, { - ...(effectiveVisibleAfterIso !== undefined - ? { visibleAfterIso: effectiveVisibleAfterIso } - : {}), - dismissedKeys, - }); - - useEffect(() => { - if (visibleAfterIso === undefined && effectiveVisibleAfterIso !== undefined) { - setVisibleAfterIso(effectiveVisibleAfterIso); - } - }, [effectiveVisibleAfterIso, visibleAfterIso]); - - const openProviderSettings = useCallback(() => { - void navigate({ to: "/settings/providers" }); - }, [navigate]); - const displayedView = renderedView ?? view; - const dismissAfterVisibleMs = displayedView?.dismissAfterVisibleMs; - const viewKey = displayedView?.key ?? null; - const showDismissProgress = - dismissAfterVisibleMs !== undefined && - displayedView?.tone !== "loading" && - exitingKey !== viewKey; - - const startExit = useCallback( - (key: string, nextView: ProviderUpdateSidebarPillView | null, dismissKey?: string) => { - if (exitingKey === key) { - return; - } - setPendingView(nextView); - setExitingKey(key); - setDismissAfterExitKey(dismissKey ?? null); - }, - [exitingKey], - ); - - useEffect(() => { - if (exitingKey !== null) { - return; - } - if (!renderedView) { - if (view) { - setRenderedView(view); - } - return; - } - if (!view) { - startExit(renderedView.key, null); - return; - } - if (view.key !== renderedView.key) { - startExit(renderedView.key, view); - return; - } - }, [exitingKey, renderedView, startExit, view]); - - useEffect(() => { - if (!dismissAfterVisibleMs || !viewKey) { - return; - } - if (exitingKey === viewKey) { - return; - } - const timeoutId = window.setTimeout(() => { - startExit(viewKey, null, viewKey); - }, dismissAfterVisibleMs); - - return () => window.clearTimeout(timeoutId); - }, [dismissAfterVisibleMs, exitingKey, startExit, viewKey]); - - if (!displayedView) { - return null; - } - - return ( -
{ - if (event.target !== event.currentTarget) { - return; - } - if (!displayedView || exitingKey !== displayedView.key) { - return; - } - if (dismissAfterExitKey === displayedView.key) { - setDismissedKeys((previous) => new Set(previous).add(displayedView.key)); - } - setRenderedView(pendingView); - setPendingView(null); - setExitingKey(null); - setDismissAfterExitKey(null); - }} - > - {showDismissProgress ? ( -