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 ? ( -