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

feat: disable automatic Git setup and tune fork defaults

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

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

apps/server/src/assets/AssetAccess.test.ts+85-7
......@@ -54,6 +54,12 @@ screenshotPng.set([
5454]);
5555new DataView(screenshotPng.buffer).setUint32(16, 390);
5656new DataView(screenshotPng.buffer).setUint32(20, 844);
57const screenshotIcns = new Uint8Array(16 + screenshotPng.length);
58screenshotIcns.set(new TextEncoder().encode("icns"));
59new DataView(screenshotIcns.buffer).setUint32(4, screenshotIcns.length);
60screenshotIcns.set(new TextEncoder().encode("ic07"), 8);
61new DataView(screenshotIcns.buffer).setUint32(12, 8 + screenshotPng.length);
62screenshotIcns.set(screenshotPng, 16);
5763const screenshotItem = {
5864 id: TurnItemId.make("tool-screenshot"),
5965 type: "dynamic_tool" as const,
......@@ -816,6 +822,84 @@ describe("AssetAccess", () => {
816822 }).pipe(Effect.provide(layerTest)),
817823 );
818824
825 it.effect("previews ICNS candidates without selecting a project favicon", () =>
826 Effect.gen(function* () {
827 const fileSystem = yield* FileSystem.FileSystem;
828 const path = yield* Path.Path;
829 const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-icon-candidates-" });
830 const external = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-icon-external-" });
831 yield* fileSystem.writeFileString(path.join(root, "favicon.svg"), "<svg>saved</svg>");
832
833 for (const requestedPath of ["Candidate.icns", path.join(external, "Candidate.icns")]) {
834 const filePath = path.isAbsolute(requestedPath)
835 ? requestedPath
836 : path.join(root, requestedPath);
837 yield* fileSystem.writeFile(filePath, screenshotIcns);
838 const result = yield* issueAssetUrl({
839 resource: { _tag: "draft-workspace-file", cwd: root, path: requestedPath },
840 });
841 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
842 const token = suffix.slice(0, suffix.indexOf("/"));
843 const canonicalPath = yield* fileSystem.realPath(filePath);
844 const asset = yield* resolveAsset(token, "Candidate.icns").pipe(
845 Effect.provideService(
846 FileSystem.FileSystem,
847 FileSystem.FileSystem.of({
848 ...fileSystem,
849 readFile: (requested) =>
850 path.isAbsolute(requestedPath) && requested === canonicalPath
851 ? Effect.succeed(new Uint8Array())
852 : fileSystem.readFile(requested),
853 }),
854 ),
855 );
856
857 expect(asset?.kind === "bytes" ? asset.mimeType : null).toBe("image/png");
858 expect(asset?.kind === "bytes" ? Uint8Array.from(asset.bytes) : null).toEqual(
859 screenshotPng,
860 );
861 expect(yield* resolveAsset(token, "favicon.svg")).toBeNull();
862 expect(yield* resolveAsset(token, "../Candidate.icns")).toBeNull();
863
864 if (path.isAbsolute(requestedPath)) {
865 const replacement = path.join(external, "replacement.icns");
866 yield* fileSystem.writeFile(replacement, screenshotIcns);
867 yield* fileSystem.rename(replacement, filePath);
868 expect(yield* resolveAsset(token, "Candidate.icns")).toBeNull();
869 }
870 }
871 const saved = yield* issueAssetUrl({
872 resource: { _tag: "project-favicon", cwd: root, path: "Candidate.icns" },
873 });
874 expect(saved.sourcePath).toBe("favicon.svg");
875 }).pipe(Effect.provide(layerTest)),
876 );
877
878 it.effect.skipIf(!symlinksSupported)("revalidates ICNS candidate workspace confinement", () =>
879 Effect.gen(function* () {
880 const fileSystem = yield* FileSystem.FileSystem;
881 const path = yield* Path.Path;
882 const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-icon-contained-" });
883 const outside = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-icon-private-" });
884 const filePath = path.join(root, "Candidate.icns");
885 const secret = path.join(outside, "Private.icns");
886 yield* fileSystem.writeFile(filePath, screenshotPng);
887 yield* fileSystem.writeFile(secret, screenshotPng);
888 const result = yield* issueAssetUrl({
889 resource: { _tag: "draft-workspace-file", cwd: root, path: "Candidate.icns" },
890 });
891 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
892 const token = suffix.slice(0, suffix.indexOf("/"));
893 yield* fileSystem.remove(filePath);
894 yield* fileSystem.symlink(secret, filePath);
895 expect(yield* resolveAsset(token, "Candidate.icns")).toBeNull();
896 const error = yield* issueAssetUrl({
897 resource: { _tag: "draft-workspace-file", cwd: root, path: "Candidate.icns" },
898 }).pipe(Effect.flip);
899 expect(error._tag).toBe("AssetWorkspaceAssetNotFoundError");
900 }).pipe(Effect.provide(layerTest)),
901 );
902
819903 it.effect("falls back to the resource cwd for relative draft paths", () =>
820904 Effect.gen(function* () {
821905 const fileSystem = yield* FileSystem.FileSystem;
......@@ -1335,14 +1419,8 @@ describe("AssetAccess", () => {
13351419 const root = yield* fileSystem.makeTempDirectoryScoped({
13361420 prefix: "t3-asset-favicon-icns-",
13371421 });
1338 const icns = new Uint8Array(8 + 8 + screenshotPng.length);
1339 icns.set(new TextEncoder().encode("icns"));
1340 new DataView(icns.buffer).setUint32(4, icns.length);
1341 icns.set(new TextEncoder().encode("ic07"), 8);
1342 new DataView(icns.buffer).setUint32(12, 8 + screenshotPng.length);
1343 icns.set(screenshotPng, 16);
13441422 yield* fileSystem.makeDirectory(path.join(root, "Resources"));
1345 yield* fileSystem.writeFile(path.join(root, "Resources", "AppIcon.icns"), icns);
1423 yield* fileSystem.writeFile(path.join(root, "Resources", "AppIcon.icns"), screenshotIcns);
13461424
13471425 const result = yield* issueAssetUrl({
13481426 resource: { _tag: "project-favicon", cwd: root },
apps/server/src/assets/AssetAccess.ts+28-15
......@@ -262,15 +262,20 @@ const readToolOutputImage = Effect.fn("AssetAccess.readToolOutputImage")(functio
262262 : { mimeType: image.mimeType, bytes: Buffer.from(image.data, "base64") };
263263});
264264
265/** Serves an `.icns` favicon as its embedded PNG, since browsers cannot draw it. */
266const projectFaviconAsset = Effect.fn("AssetAccess.projectFaviconAsset")(function* (
265/** Browsers display the embedded PNG in an `.icns` image. */
266const imagePreviewAsset = Effect.fn("AssetAccess.imagePreviewAsset")(function* (
267267 filePath: string,
268 file?: OpenMediaFile,
268269) {
269270 if (!filePath.toLowerCase().endsWith(".icns")) {
270271 return { kind: "file", path: filePath } satisfies ResolvedAsset;
271272 }
272273 const fileSystem = yield* FileSystem.FileSystem;
273 const bytes = yield* fileSystem.readFile(filePath).pipe(
274 const bytes = yield* Effect.gen(function* () {
275 return file
276 ? yield* readMediaFileHeader(filePath, file, Number(file.info.size))
277 : yield* fileSystem.readFile(filePath);
278 }).pipe(
274279 Effect.tapError((cause) =>
275280 Effect.logError("Failed to read project icon image.", { filePath, cause }),
276281 ),
......@@ -378,7 +383,10 @@ const finalizeAbsoluteMediaFileAsset = Effect.fn("AssetAccess.finalizeAbsoluteMe
378383 if (!canonicalFile) {
379384 return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource });
380385 }
381 if (hostPreviewMimeTypeFromExtension(path.extname(canonicalFile)) === null) {
386 if (
387 hostPreviewMimeTypeFromExtension(path.extname(canonicalFile)) === null &&
388 !canonicalFile.toLowerCase().endsWith(".icns")
389 ) {
382390 return yield* new AssetPreviewTypeValidationError({ resource: input.resource });
383391 }
384392 const wantsDimensions = HEADER_IMAGE_EXTENSIONS.has(path.extname(canonicalFile).toLowerCase());
......@@ -442,7 +450,10 @@ const finalizeWorkspaceFileAsset = Effect.fn("AssetAccess.finalizeWorkspaceFileA
442450 }),
443451 ),
444452 );
445 if (!isWorkspacePreviewEntryPath(resolved.relativePath)) {
453 const isImage =
454 isWorkspaceImagePreviewPath(resolved.relativePath) ||
455 resolved.relativePath.toLowerCase().endsWith(".icns");
456 if (!isWorkspacePreviewEntryPath(resolved.relativePath) && !isImage) {
446457 return yield* new AssetPreviewTypeValidationError({
447458 resource: input.resource,
448459 });
......@@ -479,7 +490,7 @@ const finalizeWorkspaceFileAsset = Effect.fn("AssetAccess.finalizeWorkspaceFileA
479490 ? yield* readImageDimensionsFromHeader(canonicalFile)
480491 : null;
481492 return {
482 claims: isWorkspaceImagePreviewPath(resolved.relativePath)
493 claims: isImage
483494 ? {
484495 version: 1 as const,
485496 kind: "workspace-file-exact" as const,
......@@ -889,7 +900,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
889900 workspaceRoot: claims.workspaceRoot,
890901 relativePath: claims.relativePath,
891902 });
892 return faviconPath ? yield* projectFaviconAsset(faviconPath) : null;
903 return faviconPath ? yield* imagePreviewAsset(faviconPath) : null;
893904 }
894905
895906 if (claims.kind === "project-favicon-external") {
......@@ -902,7 +913,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
902913 ),
903914 Effect.orElseSucceed(() => null),
904915 );
905 return faviconPath === claims.filePath ? yield* projectFaviconAsset(faviconPath) : null;
916 return faviconPath === claims.filePath ? yield* imagePreviewAsset(faviconPath) : null;
906917 }
907918
908919 if (claims.kind === "github-media") {
......@@ -949,7 +960,10 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
949960 Effect.orElseSucceed(() => null),
950961 );
951962 if (canonicalFile !== claims.filePath) return null;
952 const mimeType = hostPreviewMimeTypeFromExtension(path.extname(canonicalFile));
963 const isIcns = canonicalFile.toLowerCase().endsWith(".icns");
964 const mimeType = isIcns
965 ? "image/png"
966 : hostPreviewMimeTypeFromExtension(path.extname(canonicalFile));
953967 if (!mimeType) return null;
954968 const file = yield* openMediaFile(canonicalFile, claims).pipe(
955969 Effect.tapError((cause) =>
......@@ -957,9 +971,10 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
957971 ),
958972 Effect.orElseSucceed(() => null),
959973 );
960 return file
961 ? ({ kind: "file", path: canonicalFile, mimeType, file } satisfies ResolvedAsset)
962 : null;
974 if (!file) return null;
975 return isIcns
976 ? yield* imagePreviewAsset(canonicalFile, file)
977 : ({ kind: "file", path: canonicalFile, mimeType, file } satisfies ResolvedAsset);
963978 }
964979 if (claims.kind === "workspace-file-exact") {
965980 if (decodedPath !== path.basename(claims.relativePath)) return null;
......@@ -967,9 +982,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
967982 workspaceRoot: claims.workspaceRoot,
968983 relativePath: claims.relativePath,
969984 });
970 return exactWorkspaceFile
971 ? ({ kind: "file", path: exactWorkspaceFile } satisfies ResolvedAsset)
972 : null;
985 return exactWorkspaceFile ? yield* imagePreviewAsset(exactWorkspaceFile) : null;
973986 }
974987 const segments = decodedPath.split(/[\\/]/);
975988 if (
apps/web/src/appearanceFonts.ts+2-2
......@@ -18,12 +18,12 @@ import {
1818} from "@t3tools/contracts";
1919
2020export const DEFAULT_SANS_FONT_STACK =
21 '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif';
21 '"AT Name Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif';
2222
2323// Concrete names first: some engines alias `ui-monospace` to the
2424// proportional system UI font, which would break every code surface.
2525export const DEFAULT_CODE_FONT_STACK =
26 '"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace';
26 '"AT Name Mono", "SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace';
2727
2828export const TYPOGRAPHY_ADVANCED_STORAGE_KEY = "t3code:typography-advanced";
2929
apps/web/src/components/AppSidebarLayout.tsx+13-24
......@@ -25,7 +25,7 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina
2525import { resolveThreadRouteRef } from "../threadRoutes";
2626import { cn, isMacPlatform } from "../lib/utils";
2727import { primaryServerKeybindingsAtom } from "../state/server";
28import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings";
28import { useLegacySidebarEnabled } from "../hooks/useSettings";
2929import {
3030 PanelAnimationSuppressionProvider,
3131 usePanelAnimationSettings,
......@@ -35,15 +35,15 @@ import LegacyThreadSidebar from "./LegacySidebar";
3535import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration";
3636import ThreadSidebar from "./Sidebar";
3737import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
38import { SidebarBrandWidthProbe, SidebarChromeHeader } from "./sidebar/SidebarChrome";
38import { AppMenu } from "./sidebar/AppMenu";
39import { SidebarChromeHeader } from "./sidebar/SidebarChrome";
3940import { MainAppLocationTracker } from "./sidebar/mainAppLocation";
40import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop";
4141import { useProjects } from "../state/entities";
4242import {
4343 clampThreadSidebarWidth,
4444 resolveInitialThreadSidebarWidth,
4545 resolveThreadSidebarMaximumWidth,
46 resolveThreadSidebarMinimumWidth,
46 THREAD_SIDEBAR_MIN_WIDTH,
4747 THREAD_MAIN_CONTENT_MIN_WIDTH,
4848 THREAD_SIDEBAR_WIDTH_STORAGE_KEY,
4949} from "./threadSidebarWidth";
......@@ -85,10 +85,6 @@ function SidebarControl() {
8585 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
8686 const { toggleSidebar, isMobile, mobileSheetMounted } = useSidebar();
8787 const isSidebarVisible = useSidebarVisibility();
88 const environmentIdentificationMode = useEnvironmentIdentificationMode();
89 const stageBackdropVariant = useSidebarStageBackdropVariant(
90 environmentIdentificationMode === "artwork",
91 );
9288 const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle", {
9389 context: { usagePageOpen },
9490 });
......@@ -146,19 +142,13 @@ function SidebarControl() {
146142 )}
147143 data-sidebar-control=""
148144 >
145 <div className="pointer-events-auto flex items-center [-webkit-app-region:no-drag]">
146 <AppMenu />
147 </div>
149148 <Tooltip>
150149 <TooltipTrigger
151150 render={
152 <SidebarTrigger
153 // Over the stage artwork the trigger is a control on imagery, like the media
154 // viewer's arrows; that variant positions itself, so the layout is reset here.
155 variant={isSidebarVisible && stageBackdropVariant ? "media-navigation" : "ghost"}
156 className={cn(
157 "pointer-events-auto",
158 isSidebarVisible && stageBackdropVariant && "relative top-auto translate-y-0",
159 )}
160 aria-label="Toggle main sidebar"
161 />
151 <SidebarTrigger className="pointer-events-auto" aria-label="Toggle main sidebar" />
162152 }
163153 />
164154 <TooltipPopup side="bottom">
......@@ -246,9 +236,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
246236 // and a clamped drag ends with an unchanged width, which skips the re-render
247237 // that would otherwise refresh a render-time snapshot.
248238 const viewportWidth = useSyncExternalStore(subscribeToViewportWidth, readViewportWidth);
249 const [brandWidth, setBrandWidth] = useState(0);
250 const sidebarMinimumWidth = resolveThreadSidebarMinimumWidth(brandWidth);
251 const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(viewportWidth, sidebarMinimumWidth);
239 const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(viewportWidth);
252240 const resetSidebarWidth = () => {
253241 try {
254242 removeLocalStorageItem(THREAD_SIDEBAR_WIDTH_STORAGE_KEY);
......@@ -269,8 +257,10 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
269257 : {}
270258 ) as CSSProperties;
271259 const sidebarProviderStyle = {
272 "--sidebar-width": `${clampThreadSidebarWidth(sidebarWidth, sidebarMinimumWidth, sidebarMaximumWidth)}px`,
260 "--sidebar-width": `${clampThreadSidebarWidth(sidebarWidth, sidebarMaximumWidth)}px`,
273261 "--panel-animation-duration": `${panelAnimationDurationMs}ms`,
262 "--workspace-titlebar-content-left":
263 "calc(var(--workspace-controls-left) + 2 * var(--workspace-titlebar-control-size) + var(--workspace-titlebar-control-gap))",
274264 ...windowControlsInsetStyle,
275265 } as CSSProperties;
276266
......@@ -319,7 +309,6 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
319309 defaultOpen
320310 style={sidebarProviderStyle}
321311 >
322 <SidebarBrandWidthProbe onWidthChange={setBrandWidth} />
323312 <ProjectProjectionRetention />
324313 <Sidebar
325314 side="left"
......@@ -329,7 +318,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
329318 aria-label={isOnSettings ? "Settings" : "Threads"}
330319 resizable={{
331320 maxWidth: sidebarMaximumWidth,
332 minWidth: sidebarMinimumWidth,
321 minWidth: THREAD_SIDEBAR_MIN_WIDTH,
333322 shouldAcceptWidth: ({ currentWidth, nextWidth, wrapper }) =>
334323 nextWidth <= currentWidth ||
335324 wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH,
apps/web/src/components/CommandPalette.tsx+14-144
......@@ -11,10 +11,6 @@ import {
1111 getCloneDirectoryName,
1212 getDefaultCloneUrl,
1313 getNewProjectPathPreview,
14 getNewProjectPublishTarget,
15 getNewProjectPublishTargets,
16 getNewProjectRepository,
17 type NewProjectPublishTarget,
1814 normalizePastedCloneUrl,
1915 addProjectRemoteSourceLabel,
2016 addProjectRemoteSourcePathHint,
......@@ -49,13 +45,11 @@ import {
4945 type FilesystemBrowseResult,
5046 type ProjectId,
5147 type SourceControlDiscoveryResult,
52 SourceControlProviderKind,
5348 type SourceControlRepositoryInfo,
5449 PRIMARY_LOCAL_ENVIRONMENT_ID,
5550 resolveEnvironmentMachineKind,
5651} from "@t3tools/contracts";
5752import { useLocation, useNavigate, useParams } from "@tanstack/react-router";
58import * as Option from "effect/Option";
5953import {
6054 ArrowLeftIcon,
6155 ChartNoAxesColumnIcon,
......@@ -63,7 +57,6 @@ import {
6357 ChevronRightIcon,
6458 CornerLeftUpIcon,
6559 FileSearchIcon,
66 FolderGit2Icon,
6760 FolderIcon,
6861 FolderPlusIcon,
6962 MessageSquareDashedIcon,
......@@ -127,7 +120,6 @@ import { sourceControlEnvironment } from "../state/sourceControl";
127120import { useAtomCommand } from "../state/use-atom-command";
128121import { useAtomQueryRunner } from "../state/use-atom-query-runner";
129122import { useScratchProject } from "../hooks/useScratchProject";
130import { useNewProject } from "../hooks/useNewProject";
131123import { isScratchProject } from "@t3tools/client-runtime/state/projects";
132124import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
133125import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities";
......@@ -204,7 +196,6 @@ import {
204196} from "./CommandPaletteResults";
205197import { sourceControlIcon } from "~/sourceControlPresentation";
206198import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
207import { Checkbox } from "./ui/checkbox";
208199import { ProjectFavicon } from "./ProjectFavicon";
209200import { ProjectFilePicker } from "./files/ProjectFilePicker";
210201import { openLinkPullRequestDialog } from "./pullRequest/LinkPullRequestDialog";
......@@ -231,7 +222,6 @@ import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindin
231222import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "./ui/command";
232223import { Button } from "./ui/button";
233224import { Kbd, KbdGroup } from "./ui/kbd";
234import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
235225import { stackedThreadToast, toastManager } from "./ui/toast";
236226import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
237227import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext";
......@@ -291,14 +281,6 @@ function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: stri
291281 return <Icon className={className} />;
292282}
293283
294function sourceControlHostIcon(
295 definition: NewProjectPublishTarget["definition"],
296 className: string,
297): ReactNode {
298 const Icon = sourceControlIcon(definition);
299 return <Icon className={className} />;
300}
301
302284function projectFaviconIcon(project: Project): ReactNode {
303285 return <ProjectFavicon project={project} className={ITEM_ICON_CLASS} />;
304286}
......@@ -367,10 +349,6 @@ function notifyThemeSaveFailure(): void {
367349 );
368350}
369351
370function projectFavicon(project: Project) {
371 return <ProjectFavicon project={project} className="size-4" />;
372}
373
374352export function CommandPalette({ children }: { children: ReactNode }) {
375353 const navigate = useNavigate();
376354 const [state, dispatch] = useReducer(reduceCommandPaletteUiState, {
......@@ -788,15 +766,9 @@ function OpenCommandPaletteDialog(props: {
788766 /** Machine of the Add project sources view under this step; null from the palette root. */
789767 readonly sourcesEnvironmentId: EnvironmentId | null;
790768 } | null>(null);
791 const [newProjectPublishes, setNewProjectPublishes] = useState(false);
792 // The host picked beside the publish toggle; null takes the first ready host, GitHub when ready.
793 const [newProjectPublishKind, setNewProjectPublishKind] =
794 useState<SourceControlProviderKind | null>(null);
795 const [newProjectHostPickerOpen, setNewProjectHostPickerOpen] = useState(false);
796769 const [isCreatingNewProject, setIsCreatingNewProject] = useState(false);
797770 // State lags a render behind, so a repeated Enter could start a second create.
798771 const newProjectSubmittingRef = useRef(false);
799 const createNewProject = useNewProject();
800772 const cloneLookupGeneration = useRef(0);
801773 const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false);
802774 const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false);
......@@ -1522,10 +1494,8 @@ function OpenCommandPaletteDialog(props: {
15221494 setAddProjectEnvironmentId(environmentId);
15231495 setAddProjectCloneFlow(null);
15241496 setNewProjectFlow({ environmentId, sourcesEnvironmentId });
1525 setNewProjectPublishes(false);
1526 setNewProjectPublishKind(null);
15271497 pushPaletteView({
1528 addonIcon: <FolderGit2Icon className={ADDON_ICON_CLASS} />,
1498 addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />,
15291499 groups: [],
15301500 });
15311501 },
......@@ -1561,10 +1531,10 @@ function OpenCommandPaletteDialog(props: {
15611531 sourceItems.unshift({
15621532 kind: "action",
15631533 value: `action:add-project:${environmentId}:new`,
1564 searchTerms: ["new project", "create", "empty", "repository", "git init"],
1534 searchTerms: ["new project", "create", "empty", "folder"],
15651535 title: "New project",
1566 description: "Start a new Git repository from a name",
1567 icon: <FolderGit2Icon className={ITEM_ICON_CLASS} />,
1536 description: "Create a project folder from a name",
1537 icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />,
15681538 keepOpen: true,
15691539 run: async () => {
15701540 startNewProject(environmentId, environmentId);
......@@ -2037,9 +2007,9 @@ function OpenCommandPaletteDialog(props: {
20372007 actionItems.push({
20382008 kind: "action",
20392009 value: "action:new-project",
2040 searchTerms: ["new project", "create project", "empty", "repository", "repo", "git init"],
2010 searchTerms: ["new project", "create project", "empty", "folder"],
20412011 title: "New project",
2042 icon: <FolderGit2Icon className={ITEM_ICON_CLASS} />,
2012 icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />,
20432013 keepOpen: true,
20442014 run: async () => {
20452015 openNewProjectFlow();
......@@ -2508,12 +2478,6 @@ function OpenCommandPaletteDialog(props: {
25082478 ],
25092479 );
25102480
2511 const newProjectPublishTargets =
2512 newProjectFlow === null ? [] : getNewProjectPublishTargets(sourceControlDiscovery.data ?? null);
2513 const newProjectPublishTarget = getNewProjectPublishTarget(
2514 newProjectPublishTargets,
2515 newProjectPublishKind,
2516 );
25172481 const newProjectName = query.trim();
25182482 const canSubmitNewProject =
25192483 newProjectFlow !== null &&
......@@ -2528,12 +2492,14 @@ function OpenCommandPaletteDialog(props: {
25282492 newProjectSubmittingRef.current = true;
25292493 setIsCreatingNewProject(true);
25302494 try {
2531 const created = await createNewProject({
2495 const root = newProjectsRootFor(newProjectFlow.environmentId);
2496 if (root === null) return;
2497 await handleAddProjectForEnvironment({
25322498 environmentId: newProjectFlow.environmentId,
2533 name: newProjectName,
2534 publishTo: newProjectPublishes ? newProjectPublishTarget : null,
2499 rawCwd: getNewProjectPathPreview(root, newProjectName),
2500 platform: browseEnvironmentPlatform,
2501 currentProjectCwd: null,
25352502 });
2536 if (created) setOpen(false);
25372503 } finally {
25382504 newProjectSubmittingRef.current = false;
25392505 setIsCreatingNewProject(false);
......@@ -2837,7 +2803,6 @@ function OpenCommandPaletteDialog(props: {
28372803 const newProjectsRoot = newProjectFlow ? newProjectsRootFor(newProjectFlow.environmentId) : null;
28382804 const newProjectPathPreview =
28392805 newProjectsRoot === null ? null : getNewProjectPathPreview(newProjectsRoot, newProjectName);
2840 const newProjectPublishToggleValue = "new-project:publish";
28412806 // The name step's way out to folders and clones, for the selected machine.
28422807 // It replaces the name step (and a sources view for another machine under
28432808 // it), so Back returns to wherever New project was opened from.
......@@ -2853,8 +2818,6 @@ function OpenCommandPaletteDialog(props: {
28532818 popView();
28542819 startAddProjectSourceSelection(environmentId);
28552820 };
2856 // Switching machines keeps the typed name; the GitHub option follows the
2857 // machine because source control discovery reads addProjectEnvironmentId.
28582821 const switchNewProjectEnvironment = (environmentId: EnvironmentId) => {
28592822 setNewProjectFlow((flow) => (flow === null ? flow : { ...flow, environmentId }));
28602823 setAddProjectEnvironmentId(environmentId);
......@@ -2923,102 +2886,11 @@ function OpenCommandPaletteDialog(props: {
29232886 },
29242887 ],
29252888 };
2926 const newProjectOptionGroups: CommandPaletteView["groups"] =
2927 newProjectPublishTarget === null || newProjectPathPreview === null
2928 ? []
2929 : [
2930 {
2931 value: "new-project-options",
2932 label: "Options",
2933 items: [
2934 {
2935 kind: "action",
2936 value: newProjectPublishToggleValue,
2937 searchTerms: [],
2938 title:
2939 newProjectPublishTargets.length > 1
2940 ? "Create private repository"
2941 : `Create private repository on ${newProjectPublishTarget.definition.label}`,
2942 description:
2943 newProjectName.length > 0
2944 ? getNewProjectRepository(newProjectPublishTarget, newProjectPathPreview)
2945 : (newProjectPublishTarget.owner ??
2946 `Your ${newProjectPublishTarget.definition.label} account`),
2947 icon: sourceControlHostIcon(newProjectPublishTarget.definition, ITEM_ICON_CLASS),
2948 titleTrailingContent: (
2949 <span className="ms-auto flex items-center gap-2">
2950 {newProjectPublishTargets.length > 1 ? (
2951 // The row toggles on click, so the picker keeps its clicks to itself. The
2952 // row also cancels pointerdown to keep focus in the input, which drops the
2953 // mousedown Select opens on, so the picker opens on click instead.
2954 <span
2955 className="flex"
2956 onClick={(event) => {
2957 event.stopPropagation();
2958 // React bubbles the popup's clicks here through its portal; only the
2959 // trigger opens the picker.
2960 if (!event.currentTarget.contains(event.target as Node)) return;
2961 if (!isCreatingNewProject) setNewProjectHostPickerOpen(true);
2962 }}
2963 >
2964 <Select
2965 disabled={isCreatingNewProject}
2966 open={newProjectHostPickerOpen}
2967 onOpenChange={setNewProjectHostPickerOpen}
2968 value={newProjectPublishTarget.definition.kind}
2969 onValueChange={(kind) => {
2970 if (kind !== null) setNewProjectPublishKind(kind);
2971 }}
2972 >
2973 <SelectTrigger
2974 size="xs"
2975 variant="ghost"
2976 aria-label="Host for the new repository"
2977 >
2978 <SelectValue>
2979 {(kind: SourceControlProviderKind) =>
2980 sourceControlClients.get(kind).label
2981 }
2982 </SelectValue>
2983 </SelectTrigger>
2984 <SelectPopup align="end" alignItemWithTrigger={false}>
2985 {newProjectPublishTargets.map((target) => (
2986 <SelectItem
2987 key={target.definition.kind}
2988 hideIndicator
2989 value={target.definition.kind}
2990 >
2991 <span className="inline-flex items-center gap-1.5">
2992 {sourceControlHostIcon(target.definition, "size-3.5")}
2993 {target.definition.label}
2994 </span>
2995 </SelectItem>
2996 ))}
2997 </SelectPopup>
2998 </Select>
2999 </span>
3000 ) : null}
3001 <span className="pointer-events-none flex">
3002 <Checkbox checked={newProjectPublishes} tabIndex={-1} aria-hidden />
3003 </span>
3004 </span>
3005 ),
3006 // The create in flight keeps the choice it started with.
3007 ...(isCreatingNewProject ? { disabled: true } : {}),
3008 keepOpen: true,
3009 run: async () => {
3010 setNewProjectPublishes((publishes) => !publishes);
3011 },
3012 },
3013 ],
3014 },
3015 ];
30162889
30172890 let displayedGroups: CommandPaletteView["groups"] = filteredGroups;
30182891 if (newProjectFlow !== null) {
30192892 displayedGroups = [
30202893 ...(newProjectMachineGroup ? [newProjectMachineGroup] : []),
3021 ...newProjectOptionGroups,
30222894 ...(newProjectExistingGroup ? [newProjectExistingGroup] : []),
30232895 ];
30242896 } else if (addProjectCloneFlow?.step === "repository") {
......@@ -3487,9 +3359,7 @@ function OpenCommandPaletteDialog(props: {
34873359 newProjectFlow !== null
34883360 ? highlightedItemValue === null
34893361 ? "Create"
3490 : highlightedItemValue === newProjectPublishToggleValue
3491 ? "Toggle"
3492 : "Select"
3362 : "Select"
34933363 : addProjectCloneFlow?.step === "repository"
34943364 ? (remoteProjectButtonLabel ?? "Continue")
34953365 : !canSubmitBrowsePath || hasHighlightedBrowseItem
......@@ -3566,7 +3436,7 @@ function OpenCommandPaletteDialog(props: {
35663436 {newProjectPathPreview !== null ? (
35673437 <div className="p-2 pb-0">
35683438 <div className="flex min-h-8 items-center gap-2 rounded-sm px-2 py-1.5">
3569 <FolderGit2Icon className={ITEM_ICON_CLASS} />
3439 <FolderPlusIcon className={ITEM_ICON_CLASS} />
35703440 <span className="flex min-w-0 flex-1 flex-col">
35713441 <span className="truncate text-foreground text-sm">
35723442 {newProjectName.length > 0 ? newProjectName : "New project"}
apps/web/src/components/GitActionsControl.logic.test.ts+5-4
......@@ -823,7 +823,7 @@ describe("when: ref has no upstream configured", () => {
823823 });
824824 });
825825
826 it("resolveQuickAction publishes when no origin remote exists", () => {
826 it("resolveQuickAction rests without offering repository creation when no remote exists", () => {
827827 const quick = resolveQuickAction(
828828 status({
829829 hasUpstream: false,
......@@ -835,9 +835,10 @@ describe("when: ref has no upstream configured", () => {
835835 false,
836836 );
837837 assert.deepEqual(quick, {
838 kind: "open_publish",
839 label: "Publish repository",
840 disabled: false,
838 kind: "show_hint",
839 label: "Commit",
840 disabled: true,
841 hint: "No changes to commit.",
841842 });
842843 });
843844
apps/web/src/components/GitActionsControl.logic.ts+5-4
......@@ -23,7 +23,7 @@ export interface GitActionMenuItem {
2323export interface GitQuickAction {
2424 label: string;
2525 disabled: boolean;
26 kind: "run_action" | "run_pull" | "open_publish" | "show_hint";
26 kind: "run_action" | "run_pull" | "show_hint";
2727 action?: GitStackedAction;
2828 hint?: string;
2929}
......@@ -281,9 +281,10 @@ export function resolveQuickAction(
281281 if (!gitStatus.hasUpstream) {
282282 if (!hasPrimaryRemote) {
283283 return {
284 label: "Publish repository",
285 disabled: false,
286 kind: "open_publish",
284 label: "Commit",
285 disabled: true,
286 kind: "show_hint",
287 hint: "No changes to commit.",
287288 };
288289 }
289290 if (!isAhead) {
apps/web/src/components/GitActionsControl.tsx+35-701
......@@ -3,7 +3,6 @@ import { useAtomValue } from "@effect/atom-react";
33import {
44 AuthOrchestrationOperateScope,
55 AuthSourceControlWriteScope,
6 SourceControlProviderKind,
76 type ScopedThreadRef,
87} from "@t3tools/contracts";
98import {
......@@ -13,32 +12,27 @@ import {
1312import type {
1413 GitRunStackedActionResult,
1514 GitStackedAction,
16 SourceControlCloneProtocol,
17 SourceControlProviderDiscoveryItem,
18 SourceControlPublishRepositoryResult,
19 SourceControlRepositoryVisibility,
2015 VcsStatusResult,
2116} from "@t3tools/contracts";
22import { useNavigate } from "@tanstack/react-router";
23import * as Option from "effect/Option";
24import { type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
25import { flushSync } from "react-dom";
17import {
18 type MouseEvent,
19 useCallback,
20 useEffect,
21 useEffectEvent,
22 useMemo,
23 useRef,
24 useState,
25} from "react";
2626import {
2727 CheckIcon,
2828 ChevronDownIcon,
2929 CloudDownloadIcon,
3030 CloudUploadIcon,
3131 FileDiffIcon,
32 GitBranchPlusIcon,
3332 GitCommitIcon,
3433 InfoIcon,
35 LockIcon,
36 GlobeIcon,
3734} from "lucide-react";
38import { Radio as RadioPrimitive } from "@base-ui/react/radio";
39import { RadioGroup } from "~/components/ui/radio-group";
4035import { Spinner } from "~/components/ui/spinner";
41import { Toggle, ToggleGroup } from "~/components/ui/toggle-group";
4236import { cn } from "~/lib/utils";
4337import { useOpenPrLink } from "~/lib/openPullRequestLink";
4438import {
......@@ -59,7 +53,6 @@ import {
5953 resolveQuickAction,
6054 resolveThreadBranchUpdate,
6155} from "./GitActionsControl.logic";
62import { WizardPopup, WizardHeader, WizardSteps, WizardPanel, WizardFooter } from "./ui/wizard";
6356import { StartTruncatedPath } from "./StartTruncatedPath";
6457import { Button } from "~/components/ui/button";
6558import { Checkbox } from "~/components/ui/checkbox";
......@@ -73,7 +66,6 @@ import {
7366 DialogTitle,
7467} from "~/components/ui/dialog";
7568import { Group, GroupSeparator } from "~/components/ui/group";
76import { Input } from "~/components/ui/input";
7769import {
7870 Menu,
7971 MenuItem,
......@@ -93,15 +85,12 @@ import { useOpenInPreferredEditor } from "~/editorPreferences";
9385import {
9486 useGitStackedAction,
9587 useSourceControlActionRunning,
96 useSourceControlPublishRepositoryAction,
97 useVcsInitAction,
9888 useVcsPullAction,
9989} from "~/lib/sourceControlActions";
10090import { useThreadProjection, useThreadShell } from "~/state/entities";
10191import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
10292import { useEnvironmentQuery } from "~/state/query";
10393import { serverEnvironment } from "~/state/server";
104import { sourceControlEnvironment } from "~/state/sourceControl";
10594import { threadEnvironment } from "~/state/threads";
10695import { useAtomCommand } from "~/state/use-atom-command";
10796import { vcsActionManager, vcsEnvironment } from "~/state/vcs";
......@@ -115,10 +104,8 @@ import {
115104 THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS,
116105 THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS,
117106} from "./chat/threadDetailsPanelStyles";
118import { getSourceControlPresentation, sourceControlIcon } from "~/sourceControlPresentation";
119import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
107import { getSourceControlPresentation } from "~/sourceControlPresentation";
120108import { formatDiffCount } from "@t3tools/client-runtime/diff-count";
121import { useOpenLink } from "~/browser/useOpenLink";
122109
123110interface GitActionsControlProps {
124111 presentation?: "toolbar" | "menu";
......@@ -182,49 +169,6 @@ function requestVcsStatusRefresh(
182169const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const;
183170
184171/** Every built-in host, in the order the built-in definitions list them. */
185const PUBLISH_PROVIDER_OPTIONS = sourceControlClients.definitions.map((definition) => ({
186 value: definition.kind,
187 label: definition.pickerLabel,
188 description: definition.publishDescription,
189 pathPlaceholder: definition.repositoryPathHint,
190 definition,
191 Icon: sourceControlIcon(definition),
192}));
193
194function publishProviderOption(provider: SourceControlProviderKind) {
195 return (
196 PUBLISH_PROVIDER_OPTIONS.find((option) => option.value === provider) ??
197 PUBLISH_PROVIDER_OPTIONS[0]!
198 );
199}
200
201function getPublishProviderReadiness(input: {
202 provider: SourceControlProviderKind;
203 sourceControlProviders: ReadonlyArray<SourceControlProviderDiscoveryItem>;
204}): { readonly ready: boolean; readonly hint: string | null } {
205 const discovered = input.sourceControlProviders.find(
206 (provider) => provider.kind === input.provider,
207 );
208 if (!discovered) {
209 return {
210 ready: false,
211 hint: "Provider status unavailable. Open Settings -> Source Control and rescan.",
212 };
213 }
214 if (discovered.status !== "available") {
215 return { ready: false, hint: discovered.installHint };
216 }
217 if (discovered.auth.status === "unauthenticated") {
218 return {
219 ready: false,
220 hint:
221 Option.getOrNull(discovered.auth.detail) ??
222 `${discovered.label} is not authenticated. Open Settings -> Source Control for setup guidance.`,
223 };
224 }
225 return { ready: true, hint: null };
226}
227
228172function getMenuActionDisabledReason({
229173 item,
230174 gitStatus,
......@@ -312,7 +256,6 @@ function GitQuickActionIcon({
312256 className?: string;
313257 SourceControlIcon: ReturnType<typeof getSourceControlPresentation>["Icon"];
314258}) {
315 if (quickAction.kind === "open_publish") return <CloudUploadIcon className={className} />;
316259 if (quickAction.kind === "run_pull") return <CloudDownloadIcon className={className} />;
317260 if (quickAction.kind === "run_action") {
318261 if (quickAction.action === "commit") return <GitCommitIcon className={className} />;
......@@ -458,539 +401,6 @@ function GitActionSuccessButtonContent({ success }: { success: InlineGitActionSu
458401 );
459402}
460403
461interface PublishRepositoryDialogProps {
462 readonly open: boolean;
463 readonly onOpenChange: (open: boolean) => void;
464 readonly environmentId: ScopedThreadRef["environmentId"] | null;
465 /** Thread the dialog was opened from, so the new repository can open beside it. */
466 readonly threadRef: ScopedThreadRef | null;
467 readonly gitCwd: string;
468}
469
470function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
471 const openLink = useOpenLink(props.threadRef);
472 const navigate = useNavigate();
473 const sourceControlDiscovery = useEnvironmentQuery(
474 props.environmentId === null
475 ? null
476 : sourceControlEnvironment.discovery({
477 environmentId: props.environmentId,
478 input: {},
479 }),
480 );
481 const [selectedPublishProvider, setSelectedPublishProvider] =
482 useState<SourceControlProviderKind | null>(null);
483 const [publishRepositoryOverride, setPublishRepositoryOverride] = useState<string | null>(null);
484 const [publishVisibility, setPublishVisibility] =
485 useState<SourceControlRepositoryVisibility>("private");
486 const [publishRemoteName, setPublishRemoteName] = useState("origin");
487 const [publishProtocol, setPublishProtocol] = useState<SourceControlCloneProtocol>("ssh");
488 const [publishWizardStep, setPublishWizardStep] = useState(0);
489 const [publishAdvancedOpen, setPublishAdvancedOpen] = useState(false);
490 const [publishError, setPublishError] = useState<string | null>(null);
491 const [publishResult, setPublishResult] = useState<SourceControlPublishRepositoryResult | null>(
492 null,
493 );
494 const sourceControlScope = useMemo(
495 () => ({
496 environmentId: props.environmentId,
497 cwd: props.gitCwd,
498 }),
499 [props.environmentId, props.gitCwd],
500 );
501 const publishRepositoryAction = useSourceControlPublishRepositoryAction(sourceControlScope);
502 const publishAccountByProvider = useMemo(() => {
503 const accounts = new Map<string, string | null>();
504 for (const provider of sourceControlDiscovery.data?.sourceControlProviders ?? []) {
505 accounts.set(provider.kind, Option.getOrNull(provider.auth.account));
506 }
507 return accounts;
508 }, [sourceControlDiscovery.data]);
509 const publishProviderReadiness = useMemo(() => {
510 const sourceControlProviders = sourceControlDiscovery.data?.sourceControlProviders ?? [];
511 const readiness = new Map(
512 PUBLISH_PROVIDER_OPTIONS.map((option) => [
513 option.value,
514 getPublishProviderReadiness({ provider: option.value, sourceControlProviders }),
515 ]),
516 );
517 return (provider: SourceControlProviderKind) =>
518 readiness.get(provider) ?? { ready: false, hint: null };
519 }, [sourceControlDiscovery.data]);
520 const hasReadyPublishProvider = useMemo(
521 () => PUBLISH_PROVIDER_OPTIONS.some((option) => publishProviderReadiness(option.value).ready),
522 [publishProviderReadiness],
523 );
524 const sortedPublishProviderOptions = useMemo(
525 () =>
526 PUBLISH_PROVIDER_OPTIONS.toSorted((left, right) => {
527 const leftReady = publishProviderReadiness(left.value).ready;
528 const rightReady = publishProviderReadiness(right.value).ready;
529 if (leftReady !== rightReady) {
530 return leftReady ? -1 : 1;
531 }
532 return left.label.localeCompare(right.label);
533 }),
534 [publishProviderReadiness],
535 );
536 const firstReadyPublishProvider = sortedPublishProviderOptions.find(
537 (option) => publishProviderReadiness(option.value).ready,
538 )?.value;
539 const publishProvider =
540 selectedPublishProvider !== null && publishProviderReadiness(selectedPublishProvider).ready
541 ? selectedPublishProvider
542 : (firstReadyPublishProvider ??
543 selectedPublishProvider ??
544 sourceControlClients.get(undefined).kind);
545 const selectedPublishProviderReadiness = publishProviderReadiness(publishProvider);
546 const publishAccount = publishAccountByProvider.get(publishProvider) ?? null;
547 const publishRepositoryPrefill = publishAccount ? `${publishAccount}/` : "";
548 const publishRepository = publishRepositoryOverride ?? publishRepositoryPrefill;
549 const currentPublishProvider = publishProviderOption(publishProvider);
550 const publishHost = currentPublishProvider.definition.publishHost(
551 Option.getOrNull(
552 sourceControlDiscovery.data?.sourceControlProviders.find(
553 (provider) => provider.kind === publishProvider,
554 )?.auth.host ?? Option.none(),
555 ),
556 );
557 const publishPathPlaceholder = currentPublishProvider.pathPlaceholder;
558 const publishProviderLabel = currentPublishProvider.label;
559 const publishWizardSteps = ["Provider", "Repository", "Summary"] as const;
560 const publishWizardStepSummaries = [
561 publishProviderLabel,
562 publishResult?.repository.nameWithOwner ?? null,
563 null,
564 ] as const;
565
566 const canSubmitPublishRepository = useMemo(() => {
567 if (!publishRepositoryAction.isAllowed || !selectedPublishProviderReadiness.ready) return false;
568 if (publishRepositoryAction.isPending) return false;
569 const repositoryParts = publishRepository.trim().split("/");
570 const owner = repositoryParts[0]?.trim() ?? "";
571 const rest = repositoryParts.slice(1);
572 const name = rest.join("/").trim();
573 return owner.length > 0 && name.length > 0;
574 }, [
575 publishRepository,
576 publishRepositoryAction.isAllowed,
577 publishRepositoryAction.isPending,
578 selectedPublishProviderReadiness,
579 ]);
580
581 const submitPublishRepository = useCallback(() => {
582 if (!canSubmitPublishRepository) {
583 return;
584 }
585
586 setPublishError(null);
587
588 void (async () => {
589 const result = await publishRepositoryAction.run({
590 provider: publishProvider,
591 repository: publishRepository.trim(),
592 visibility: publishVisibility,
593 remoteName: publishRemoteName.trim() || "origin",
594 protocol: publishProtocol,
595 });
596
597 if (result._tag === "Failure") {
598 if (!isAtomCommandInterrupted(result)) {
599 const error = squashAtomCommandFailure(result);
600 setPublishError(error instanceof Error ? error.message : "An error occurred.");
601 }
602 return;
603 }
604
605 flushSync(() => {
606 setPublishResult(result.value);
607 setPublishWizardStep(2);
608 });
609 })();
610 }, [
611 canSubmitPublishRepository,
612 props.environmentId,
613 props.gitCwd,
614 publishProtocol,
615 publishProvider,
616 publishRemoteName,
617 publishRepository,
618 publishRepositoryAction,
619 publishVisibility,
620 ]);
621
622 const resetState = useCallback(() => {
623 setPublishRemoteName("origin");
624 setPublishRepositoryOverride(null);
625 setPublishWizardStep(0);
626 setPublishAdvancedOpen(false);
627 setPublishError(null);
628 setPublishResult(null);
629 }, []);
630
631 const handleOpenChange = useCallback(
632 (open: boolean) => {
633 props.onOpenChange(open);
634 if (!open) {
635 resetState();
636 }
637 },
638 [props, resetState],
639 );
640
641 const openSourceControlSettings = useCallback(() => {
642 handleOpenChange(false);
643 void navigate({ to: "/settings/source-control" });
644 }, [handleOpenChange, navigate]);
645
646 return (
647 <Dialog open={props.open} onOpenChange={handleOpenChange}>
648 <WizardPopup>
649 <WizardHeader
650 title="Publish repository"
651 description="Pick where to host it, then point us at a repo to push to."
652 >
653 <WizardSteps
654 steps={publishWizardSteps}
655 currentStep={publishWizardStep}
656 summaries={publishWizardStepSummaries}
657 showSummaries
658 isStepDisabled={(index) =>
659 publishWizardStep === 2 ||
660 index >= publishWizardSteps.length - 1 ||
661 index > publishWizardStep
662 }
663 onStepChange={setPublishWizardStep}
664 />
665 </WizardHeader>
666
667 <WizardPanel>
668 <div className={cn("space-y-2", publishWizardStep !== 0 && "hidden")}>
669 <span id="publish-provider-cards-label" className="text-xs font-medium text-foreground">
670 Provider
671 </span>
672 <RadioGroup
673 value={publishProvider}
674 onValueChange={(value) => {
675 setSelectedPublishProvider(value as SourceControlProviderKind);
676 setPublishRepositoryOverride(null);
677 }}
678 aria-labelledby="publish-provider-cards-label"
679 className="grid grid-cols-2"
680 >
681 {sortedPublishProviderOptions.map((option) => {
682 const readiness = publishProviderReadiness(option.value);
683 const isSelected = publishProvider === option.value && readiness.ready;
684 if (!readiness.ready) {
685 return (
686 <div
687 key={option.value}
688 className="relative flex cursor-not-allowed items-center gap-3 rounded-lg border border-border bg-background px-3 py-3 text-left opacity-64 dark:border-transparent dark:bg-white/[0.035]"
689 >
690 <option.Icon className="size-5 shrink-0 text-muted-foreground" aria-hidden />
691 <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
692 {option.label}
693 </span>
694 <Tooltip>
695 <TooltipTrigger
696 render={
697 <Button
698 variant="warning-outline"
699 size="micro"
700 onClick={(event) => {
701 event.preventDefault();
702 event.stopPropagation();
703 openSourceControlSettings();
704 }}
705 >
706 Setup Required
707 </Button>
708 }
709 />
710 <TooltipPopup side="top" align="end">
711 {readiness.hint ??
712 "Open Settings -> Source Control to configure this provider."}
713 </TooltipPopup>
714 </Tooltip>
715 </div>
716 );
717 }
718
719 return (
720 <RadioPrimitive.Root
721 key={option.value}
722 value={option.value}
723 className={cn(
724 "relative flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-3 text-left outline-none transition-[background-color,border-color,box-shadow]",
725 "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
726 isSelected
727 ? "border-primary bg-background shadow-sm ring-2 ring-inset ring-primary/35 dark:border-transparent dark:bg-primary/10 dark:shadow-none dark:ring-1 dark:ring-primary/30"
728 : "border-border bg-background hover:border-foreground/20 hover:bg-muted/50 dark:border-transparent dark:bg-white/[0.035] dark:hover:bg-accent",
729 )}
730 >
731 <option.Icon className="size-5 shrink-0" aria-hidden />
732 <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
733 {option.label}
734 </span>
735 </RadioPrimitive.Root>
736 );
737 })}
738 </RadioGroup>
739 </div>
740
741 <div className={cn("space-y-5", publishWizardStep !== 1 && "hidden")}>
742 <div className="space-y-2">
743 <label
744 htmlFor="publish-repository-path"
745 className="text-xs font-medium text-foreground"
746 >
747 Repository
748 </label>
749 <div className="flex items-stretch overflow-hidden rounded-md border border-input bg-background focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-ring">
750 <span className="flex shrink-0 items-center gap-1.5 border-r border-input bg-muted/50 px-2.5 font-mono text-xs text-muted-foreground">
751 <currentPublishProvider.Icon className="size-3.5" />
752 {publishHost}/
753 </span>
754 <input
755 id="publish-repository-path"
756 name="publish-repository-path"
757 value={publishRepository}
758 onChange={(event) => {
759 setPublishRepositoryOverride(event.target.value);
760 }}
761 onKeyDown={(event) => {
762 if (event.key === "Enter") {
763 event.preventDefault();
764 submitPublishRepository();
765 }
766 }}
767 placeholder={publishPathPlaceholder}
768 disabled={publishRepositoryAction.isPending}
769 className="w-full bg-transparent px-3 py-2 font-mono text-sm placeholder:text-muted-foreground/60 focus:outline-none"
770 />
771 </div>
772 </div>
773
774 <div className="space-y-2">
775 <span
776 id="publish-visibility-cards-label"
777 className="text-xs font-medium text-foreground"
778 >
779 Visibility
780 </span>
781 <RadioGroup
782 value={publishVisibility}
783 onValueChange={(value) =>
784 setPublishVisibility(value as SourceControlRepositoryVisibility)
785 }
786 aria-labelledby="publish-visibility-cards-label"
787 disabled={publishRepositoryAction.isPending}
788 className="grid grid-cols-2"
789 >
790 {[
791 {
792 value: "private" as const,
793 label: "Private",
794 description: "Only invited people",
795 Icon: LockIcon,
796 },
797 {
798 value: "public" as const,
799 label: "Public",
800 description: "Anyone on the web",
801 Icon: GlobeIcon,
802 },
803 ].map((option) => {
804 const isSelected = publishVisibility === option.value;
805 return (
806 <RadioPrimitive.Root
807 key={option.value}
808 value={option.value}
809 className={cn(
810 "relative flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-2.5 text-left outline-none transition-[background-color,border-color,box-shadow]",
811 "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
812 isSelected
813 ? "border-primary bg-background shadow-sm ring-2 ring-inset ring-primary/35 dark:border-transparent dark:bg-primary/10 dark:shadow-none dark:ring-1 dark:ring-primary/30"
814 : "border-border bg-background hover:border-foreground/20 hover:bg-muted/50 dark:border-transparent dark:bg-white/[0.035] dark:hover:bg-accent",
815 )}
816 >
817 <option.Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
818 <span className="min-w-0 flex-1">
819 <span className="block text-sm font-medium text-foreground">
820 {option.label}
821 </span>
822 <span className="block text-xs text-muted-foreground">
823 {option.description}
824 </span>
825 </span>
826 </RadioPrimitive.Root>
827 );
828 })}
829 </RadioGroup>
830 </div>
831
832 <div>
833 <button
834 type="button"
835 onClick={() => setPublishAdvancedOpen((prev) => !prev)}
836 aria-expanded={publishAdvancedOpen}
837 className="flex items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
838 >
839 <ChevronDownIcon
840 className={cn(
841 "size-3.5 transition-transform",
842 publishAdvancedOpen ? "" : "-rotate-90",
843 )}
844 />
845 Advanced
846 </button>
847 {publishAdvancedOpen ? (
848 <div className="mt-3 grid gap-3 sm:grid-cols-2">
849 <label className="space-y-1.5" htmlFor="publish-remote-name">
850 <span className="text-xs font-medium text-foreground">Remote</span>
851 <Input
852 id="publish-remote-name"
853 value={publishRemoteName}
854 onChange={(event) => setPublishRemoteName(event.target.value)}
855 placeholder="origin"
856 disabled={publishRepositoryAction.isPending}
857 />
858 </label>
859 <div className="space-y-1.5">
860 <span
861 id="publish-protocol-label"
862 className="text-xs font-medium text-foreground"
863 >
864 Protocol
865 </span>
866 <ToggleGroup
867 value={[publishProtocol]}
868 onValueChange={(next) => {
869 const protocol = next[0];
870 if (protocol === "ssh" || protocol === "https") {
871 setPublishProtocol(protocol);
872 }
873 }}
874 aria-labelledby="publish-protocol-label"
875 disabled={publishRepositoryAction.isPending}
876 >
877 <Toggle value="ssh">SSH</Toggle>
878 <Toggle value="https">HTTPS</Toggle>
879 </ToggleGroup>
880 </div>
881 </div>
882 ) : null}
883 </div>
884
885 {publishRepositoryAction.isPending ? (
886 <div
887 role="status"
888 aria-live="polite"
889 className="flex items-center gap-2 rounded-md border border-input bg-muted/40 px-3 py-2 text-xs text-muted-foreground dark:border-transparent dark:bg-white/[0.035]"
890 >
891 <Spinner size="sm" aria-hidden />
892 Publishing repository to {publishProviderLabel}...
893 </div>
894 ) : null}
895 {publishError && !publishRepositoryAction.isPending ? (
896 <div
897 role="alert"
898 className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive"
899 >
900 <p className="font-medium">Publish failed</p>
901 <p className="mt-0.5 text-destructive/90">{publishError}</p>
902 </div>
903 ) : null}
904 </div>
905
906 <div className={cn("space-y-4", publishWizardStep !== 2 && "hidden")}>
907 {publishResult ? (
908 <>
909 <div className="flex flex-col items-center gap-2 py-1 text-center">
910 <span className="grid size-8 place-items-center rounded-full bg-success/15 text-success">
911 <CheckIcon className="size-4" aria-hidden />
912 </span>
913 <h3 className="text-sm font-semibold text-foreground">
914 {publishResult.status === "pushed"
915 ? "Repository published"
916 : "Repository created"}
917 </h3>
918 <p className="max-w-xs text-pretty text-xs text-muted-foreground">
919 {publishResult.status === "pushed"
920 ? `${publishResult.branch} is now live on ${publishProviderLabel}.`
921 : `Remote "${publishResult.remoteName}" is set up. Make a commit and push it to share your code.`}
922 </p>
923 </div>
924 <div className="flex items-center gap-2 rounded-lg border border-input bg-muted/40 px-3 py-2 dark:border-transparent dark:bg-white/[0.035]">
925 <currentPublishProvider.Icon className="size-3.5 shrink-0 text-muted-foreground" />
926 <span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">
927 {publishResult.repository.nameWithOwner}
928 </span>
929 </div>
930 <Button
931 variant="outline"
932 size="sm"
933 className="w-full"
934 onClick={() => {
935 void openLink(publishResult.repository.url).catch(() => undefined);
936 }}
937 >
938 Open on {publishProviderLabel}
939 </Button>
940 </>
941 ) : (
942 <div className="rounded-md border border-input bg-background px-3 py-2 text-xs text-muted-foreground dark:border-transparent dark:bg-white/[0.035]">
943 Publish result unavailable.
944 </div>
945 )}
946 </div>
947 </WizardPanel>
948
949 <WizardFooter>
950 {publishWizardStep === 2 ? (
951 <Button onClick={() => handleOpenChange(false)}>Done</Button>
952 ) : (
953 <>
954 <Button
955 variant="outline"
956 disabled={publishRepositoryAction.isPending}
957 onClick={() => {
958 if (publishWizardStep === 0) {
959 handleOpenChange(false);
960 return;
961 }
962 setPublishWizardStep((step) => Math.max(0, step - 1));
963 }}
964 >
965 {publishWizardStep === 0 ? "Cancel" : "Back"}
966 </Button>
967 {publishWizardStep < 1 ? (
968 <Button
969 disabled={!hasReadyPublishProvider || !selectedPublishProviderReadiness.ready}
970 onClick={() => setPublishWizardStep((step) => Math.min(1, step + 1))}
971 >
972 Next
973 </Button>
974 ) : (
975 <Button disabled={!canSubmitPublishRepository} onClick={submitPublishRepository}>
976 {publishRepositoryAction.isPending ? (
977 <>
978 <Spinner size="sm" aria-hidden />
979 Publishing...
980 </>
981 ) : (
982 "Publish"
983 )}
984 </Button>
985 )}
986 </>
987 )}
988 </WizardFooter>
989 </WizardPopup>
990 </Dialog>
991 );
992}
993
994404export default function GitActionsControl({
995405 presentation = "toolbar",
996406 gitCwd,
......@@ -1044,7 +454,6 @@ export default function GitActionsControl({
1044454 const [dialogCommitMessage, setDialogCommitMessage] = useState("");
1045455 const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set());
1046456 const [isEditingFiles, setIsEditingFiles] = useState(false);
1047 const [isPublishDialogOpen, setIsPublishDialogOpen] = useState(false);
1048457 const [inlineSuccess, setInlineSuccess] = useState<InlineGitActionSuccess | null>(null);
1049458 const [pendingDefaultBranchAction, setPendingDefaultBranchAction] =
1050459 useState<PendingDefaultBranchAction | null>(null);
......@@ -1153,7 +562,6 @@ export default function GitActionsControl({
1153562 const allSelected = excludedFiles.size === 0;
1154563 const noneSelected = selectedFiles.length === 0;
1155564
1156 const initAction = useVcsInitAction(sourceControlScope);
1157565 const runImmediateGitAction = useGitStackedAction(sourceControlScope);
1158566 const pullAction = useVcsPullAction(sourceControlScope);
1159567 const isGitActionRunning = useSourceControlActionRunning(
......@@ -1470,10 +878,6 @@ export default function GitActionsControl({
1470878 };
1471879
1472880 const runQuickAction = () => {
1473 if (quickAction.kind === "open_publish") {
1474 setIsPublishDialogOpen(true);
1475 return;
1476 }
1477881 if (quickAction.kind === "run_pull") {
1478882 void (async () => {
1479883 setInlineSuccess(null);
......@@ -1595,26 +999,6 @@ export default function GitActionsControl({
1595999 [gitCwd, openInPreferredEditor, threadToastData],
15961000 );
15971001
1598 const canPublishRepository =
1599 canWriteSourceControl && isRepo && gitStatusForActions !== null && !hasPrimaryRemote;
1600
1601 const initializeGit = () => {
1602 void (async () => {
1603 const result = await initAction.run();
1604 if (result._tag === "Success" || isAtomCommandInterrupted(result)) {
1605 return;
1606 }
1607 const error = squashAtomCommandFailure(result);
1608 toastManager.add(
1609 stackedThreadToast({
1610 type: "error",
1611 title: "Git initialization failed",
1612 description: error instanceof Error ? error.message : "An error occurred.",
1613 ...(threadToastData !== undefined ? { data: threadToastData } : {}),
1614 }),
1615 );
1616 })();
1617 };
16181002 const gitItems = (
16191003 <>
16201004 {gitActionMenuItems.map((item) => {
......@@ -1673,18 +1057,6 @@ export default function GitActionsControl({
16731057 </MenuItem>
16741058 );
16751059 })}
1676 {canPublishRepository ? (
1677 <MenuItem
1678 density={presentation === "menu" ? "touch" : "default"}
1679 disabled={isGitActionRunning}
1680 onClick={() => {
1681 setIsPublishDialogOpen(true);
1682 }}
1683 >
1684 <CloudUploadIcon />
1685 <MenuItemLabel>Publish repository...</MenuItemLabel>
1686 </MenuItem>
1687 ) : null}
16881060 {gitStatusForActions?.refName === null && (
16891061 <p className="px-2 py-1.5 text-xs text-warning">
16901062 Detached HEAD: create and check out a branch to enable push and pull request actions.
......@@ -1701,70 +1073,40 @@ export default function GitActionsControl({
17011073 </>
17021074 );
17031075
1704 if (!gitCwd) return null;
1076 if (!gitCwd || !isRepo) return null;
17051077
17061078 return (
17071079 <>
17081080 {presentation === "menu" ? (
1709 !isRepo ? (
1081 <>
17101082 <MenuItem
17111083 density={presentation === "menu" ? "touch" : "default"}
17121084
1713 disabled={initAction.isPending}
1714 onClick={initializeGit}
1085 disabled={isGitActionRunning || quickAction.disabled || !!quickActionDisabledReason}
1086 onClick={runQuickAction}
17151087 >
1716 <GitBranchPlusIcon className="size-4" />
1717 <MenuItemLabel>
1718 {initAction.isPending ? "Initializing..." : "Initialize Git"}
1719 </MenuItemLabel>
1088 <GitQuickActionIcon
1089 className="size-4"
1090 quickAction={quickAction}
1091 SourceControlIcon={SourceControlIcon}
1092 />
1093 <MenuItemLabel>{quickAction.label}</MenuItemLabel>
17201094 </MenuItem>
1721 ) : (
1722 <>
1723 <MenuItem
1724 density={presentation === "menu" ? "touch" : "default"}
1725
1726 disabled={isGitActionRunning || quickAction.disabled || !!quickActionDisabledReason}
1727 onClick={runQuickAction}
1728 >
1729 <GitQuickActionIcon
1730 className="size-4"
1731 quickAction={quickAction}
1732 SourceControlIcon={SourceControlIcon}
1733 />
1734 <MenuItemLabel>{quickAction.label}</MenuItemLabel>
1735 </MenuItem>
1736 {quickActionDisabledReason && (
1737 <p className="max-w-64 px-2 py-1.5 text-xs text-warning">
1738 {quickActionDisabledReason}
1739 </p>
1740 )}
1741 <MenuSub
1742 onOpenChange={(open) => {
1743 if (open) requestVcsStatusRefresh(refreshVcsStatus, activeEnvironmentId, gitCwd);
1744 }}
1745 >
1746 <MenuSubTrigger density="touch" disabled={isGitActionRunning}>
1747 <SourceControlIcon className="size-4" />
1748 <MenuItemLabel>Git actions</MenuItemLabel>
1749 </MenuSubTrigger>
1750 <MenuSubPopup>{gitItems}</MenuSubPopup>
1751 </MenuSub>
1752 </>
1753 )
1754 ) : !isRepo ? (
1755 <ThreadDetailsControl
1756 size="xs"
1757 variant={isPanel ? "ghost" : "outline"}
1758 part="row"
1759 panel={isPanel}
1760 disabled={!canWriteSourceControl || initAction.isPending}
1761 onClick={initializeGit}
1762 >
1763 <GitBranchPlusIcon className="size-3.5" aria-hidden />
1764 <span className={cn("ml-0.5", isPanel && THREAD_DETAILS_PANEL_LABEL_CLASS)}>
1765 {initAction.isPending ? "Initializing..." : "Initialize Git"}
1766 </span>
1767 </ThreadDetailsControl>
1095 {quickActionDisabledReason && (
1096 <p className="max-w-64 px-2 py-1.5 text-xs text-warning">{quickActionDisabledReason}</p>
1097 )}
1098 <MenuSub
1099 onOpenChange={(open) => {
1100 if (open) requestVcsStatusRefresh(refreshVcsStatus, activeEnvironmentId, gitCwd);
1101 }}
1102 >
1103 <MenuSubTrigger density="touch" disabled={isGitActionRunning}>
1104 <SourceControlIcon className="size-4" />
1105 <MenuItemLabel>Git actions</MenuItemLabel>
1106 </MenuSubTrigger>
1107 <MenuSubPopup>{gitItems}</MenuSubPopup>
1108 </MenuSub>
1109 </>
17681110 ) : compact && !gitActionProgress && !visibleInlineSuccess ? null : (
17691111 <ActionGroup
17701112 role="group"
......@@ -2102,14 +1444,6 @@ export default function GitActionsControl({
21021444 </DialogPopup>
21031445 </Dialog>
21041446
2105 <PublishRepositoryDialog
2106 open={isPublishDialogOpen}
2107 onOpenChange={setIsPublishDialogOpen}
2108 environmentId={activeEnvironmentId}
2109 threadRef={activeThreadRef}
2110 gitCwd={gitCwd}
2111 />
2112
21131447 <Dialog
21141448 open={pendingDefaultBranchAction !== null}
21151449 onOpenChange={(open) => {
apps/web/src/components/LegacySidebar.tsx+4-81
......@@ -1,3 +1,4 @@
1import { performDesktopUpdate } from "./desktopUpdate.actions";
12import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";
23import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests";
34import { Spinner } from "~/components/ui/spinner";
......@@ -138,14 +139,10 @@ import { formatRelativeTimeLabel } from "../timestampFormat";
138139import { Kbd } from "./ui/kbd";
139140import {
140141 getArm64IntelBuildWarningDescription,
141 getDesktopUpdateActionError,
142 getDesktopUpdateInstallConfirmationMessage,
143142 isDesktopUpdateButtonDisabled,
144143 resolveDesktopUpdateButtonAction,
145144 shouldShowArm64IntelBuildWarning,
146 shouldToastDesktopUpdateActionResult,
147145} from "./desktopUpdate.logic";
148import { showDesktopUpdateDownloadedToast } from "./desktopUpdate.toast";
149146import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
150147import { Button } from "./ui/button";
151148import {
......@@ -3746,83 +3743,9 @@ export default function LegacySidebar() {
37463743
37473744 setDesktopUpdateActionPending(true);
37483745
3749 if (desktopUpdateButtonAction === "download") {
3750 void bridge
3751 .downloadUpdate()
3752 .then((result) => {
3753 if (result.completed) {
3754 showDesktopUpdateDownloadedToast(bridge, result.state);
3755 }
3756 if (!shouldToastDesktopUpdateActionResult(result)) return;
3757 const actionError = getDesktopUpdateActionError(result);
3758 if (!actionError) return;
3759 toastManager.add(
3760 stackedThreadToast({
3761 type: "error",
3762 title: "Could not download update",
3763 description: actionError,
3764 }),
3765 );
3766 })
3767 .catch((error) => {
3768 toastManager.add(
3769 stackedThreadToast({
3770 type: "error",
3771 title: "Could not start update download",
3772 description: error instanceof Error ? error.message : "An unexpected error occurred.",
3773 }),
3774 );
3775 })
3776 .finally(() => setDesktopUpdateActionPending(false));
3777 return;
3778 }
3779
3780 if (desktopUpdateButtonAction === "install") {
3781 let confirmed = false;
3782 try {
3783 confirmed = await ensureLocalApi().dialogs.confirm(
3784 getDesktopUpdateInstallConfirmationMessage(desktopUpdateState),
3785 );
3786 } catch (error) {
3787 setDesktopUpdateActionPending(false);
3788 toastManager.add(
3789 stackedThreadToast({
3790 type: "error",
3791 title: "Could not confirm update",
3792 description: error instanceof Error ? error.message : "Update confirmation failed.",
3793 }),
3794 );
3795 return;
3796 }
3797 if (!confirmed) {
3798 setDesktopUpdateActionPending(false);
3799 return;
3800 }
3801 void bridge
3802 .installUpdate()
3803 .then((result) => {
3804 if (!shouldToastDesktopUpdateActionResult(result)) return;
3805 const actionError = getDesktopUpdateActionError(result);
3806 if (!actionError) return;
3807 toastManager.add(
3808 stackedThreadToast({
3809 type: "error",
3810 title: "Could not install update",
3811 description: actionError,
3812 }),
3813 );
3814 })
3815 .catch((error) => {
3816 toastManager.add(
3817 stackedThreadToast({
3818 type: "error",
3819 title: "Could not install update",
3820 description: error instanceof Error ? error.message : "An unexpected error occurred.",
3821 }),
3822 );
3823 })
3824 .finally(() => setDesktopUpdateActionPending(false));
3825 }
3746 void performDesktopUpdate(bridge, desktopUpdateState).finally(() =>
3747 setDesktopUpdateActionPending(false),
3748 );
38263749 }, [
38273750 desktopUpdateActionPending,
38283751 desktopUpdateButtonAction,
apps/web/src/components/ProviderUpdatesAction.test.tsx created+116
......@@ -0,0 +1,116 @@
1// @vitest-environment jsdom
2import {
3 EnvironmentId,
4 ProviderDriverKind,
5 ProviderInstanceId,
6 type ServerProvider,
7} from "@t3tools/contracts";
8import { AsyncResult, Atom } from "effect/reactivity";
9import { act } from "react";
10import { createRoot, type Root } from "react-dom/client";
11import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
12
13import { appAtomRegistry, AppAtomRegistryProvider } from "~/rpc/atomRegistry";
14
15const mocks = vi.hoisted(() => ({
16 environments: [] as Array<{
17 environmentId: EnvironmentId;
18 label: string;
19 connection: { phase: "connected" };
20 serverConfig: { providers: ServerProvider[] };
21 }>,
22 permissionAtom: vi.fn(),
23 update: vi.fn(),
24}));
25vi.mock("~/state/environments", () => ({
26 useEnvironments: () => ({ environments: mocks.environments }),
27}));
28vi.mock("~/state/server", () => ({
29 serverEnvironment: { updateProvider: { permissionAtom: mocks.permissionAtom } },
30}));
31vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => mocks.update }));
32vi.mock("./ui/toast", () => ({ toastManager: { add: vi.fn() }, stackedThreadToast: vi.fn() }));
33
34import { useProviderUpdates } from "./ProviderUpdatesAction";
35
36const home = EnvironmentId.make("home");
37const work = EnvironmentId.make("work");
38const homeAllowed = Atom.make(false).pipe(Atom.keepAlive);
39const workAllowed = Atom.make(false).pipe(Atom.keepAlive);
40let root: Root;
41let container: HTMLDivElement;
42
43function Updates() {
44 const { machines, updateAll } = useProviderUpdates();
45 return machines.length > 0 ? (
46 <button onClick={() => void updateAll({ quiet: true })}>Update all</button>
47 ) : null;
48}
49
50beforeEach(() => {
51 const provider: ServerProvider = {
52 instanceId: ProviderInstanceId.make("codex-home"),
53 driver: ProviderDriverKind.make("codex"),
54 enabled: true,
55 installed: true,
56 version: "1.0.0",
57 status: "ready",
58 auth: { status: "authenticated" },
59 checkedAt: "2026-10-15T12:00:00Z",
60 models: [],
61 slashCommands: [],
62 skills: [],
63 versionAdvisory: {
64 status: "behind_latest",
65 currentVersion: "1.0.0",
66 latestVersion: "1.1.0",
67 updateCommand: "npm install -g provider",
68 canUpdate: true,
69 checkedAt: "2026-10-15T12:00:00Z",
70 message: "Update available.",
71 },
72 };
73 mocks.environments = [home, work].map((environmentId) => ({
74 environmentId,
75 label: environmentId,
76 connection: { phase: "connected" },
77 serverConfig: { providers: [provider] },
78 }));
79 mocks.permissionAtom.mockImplementation((id: EnvironmentId) =>
80 id === home ? homeAllowed : workAllowed,
81 );
82 mocks.update.mockReset().mockResolvedValue(AsyncResult.success(undefined));
83 appAtomRegistry.set(homeAllowed, false);
84 appAtomRegistry.set(workAllowed, false);
85 container = document.createElement("div");
86 document.body.append(container);
87 root = createRoot(container);
88 Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
89});
90
91afterEach(async () => {
92 await act(async () => root.unmount());
93 container.remove();
94});
95
96it("offers and updates only permitted machines, reacting to permission changes", async () => {
97 await act(async () =>
98 root.render(
99 <AppAtomRegistryProvider>
100 <Updates />
101 </AppAtomRegistryProvider>,
102 ),
103 );
104 expect(container.querySelector("button")).toBeNull();
105
106 await act(async () => appAtomRegistry.set(workAllowed, true));
107 expect(container.querySelector("button")).not.toBeNull();
108 await act(async () => container.querySelector("button")?.click());
109 expect(mocks.update).toHaveBeenCalledExactlyOnceWith({
110 environmentId: work,
111 input: { provider: "codex", instanceId: "codex-home" },
112 });
113
114 await act(async () => appAtomRegistry.set(workAllowed, false));
115 expect(container.querySelector("button")).toBeNull();
116});
apps/web/src/components/ProviderUpdatesAction.tsx+23-15
......@@ -1,3 +1,5 @@
1import { useAtomValue } from "@effect/atom-react";
2import { Atom } from "effect/reactivity";
13import { PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts";
24import { useMemo, useRef, useState } from "react";
35
......@@ -18,8 +20,8 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
1820 * Every outdated provider with a one-click update on every connected machine,
1921 * plus a function that updates them all and reports the results in one toast.
2022 * Each server queues updates that share an installer, so sending them all
21 * together is safe. The server still checks permissions: a session that
22 * cannot operate a machine gets a failure line for it. The returned promise
23 * together is safe. Only permitted machines are offered; the server checks
24 * permission again at execution. The returned promise
2325 * settles with every result when every update has finished. `quiet` skips the
2426 * toast, for a caller that reports the results itself.
2527 */
......@@ -28,22 +30,28 @@ export function useProviderUpdates() {
2830 const updateProvider = useAtomCommand(serverEnvironment.updateProvider, {
2931 reportFailure: false,
3032 });
31 const machines = useMemo(
33 const machinesAtom = useMemo(
3234 () =>
33 environments.flatMap((environment) => {
34 const providers = environment.serverConfig?.providers ?? [];
35 if (environment.connection.phase !== "connected") {
36 return [];
37 }
38 const candidates = collectProviderUpdateCandidates(providers).filter((candidate) =>
39 canOneClickUpdateProviderCandidate(candidate, providers),
40 );
41 return candidates.length > 0
42 ? [{ environmentId: environment.environmentId, label: environment.label, candidates }]
43 : [];
44 }),
35 Atom.make((get) =>
36 environments.flatMap((environment) => {
37 const providers = environment.serverConfig?.providers ?? [];
38 if (
39 environment.connection.phase !== "connected" ||
40 !get(serverEnvironment.updateProvider.permissionAtom(environment.environmentId))
41 ) {
42 return [];
43 }
44 const candidates = collectProviderUpdateCandidates(providers).filter((candidate) =>
45 canOneClickUpdateProviderCandidate(candidate, providers),
46 );
47 return candidates.length > 0
48 ? [{ environmentId: environment.environmentId, label: environment.label, candidates }]
49 : [];
50 }),
51 ),
4552 [environments],
4653 );
54 const machines = useAtomValue(machinesAtom);
4755
4856 const updateAll = async ({ quiet = false }: { readonly quiet?: boolean } = {}) => {
4957 const runs = await Promise.all(
apps/web/src/components/SidebarStageBackdrop.tsx-12
......@@ -46,18 +46,6 @@ export function useSidebarStageBackdropVariant(enabled = true): SidebarStageBack
4646 return resolveSidebarStageBackdropVariant(useEnvironmentStageLabel(), enabled);
4747}
4848
49/** Stage-channel header art; palettes mirror the per-channel app icons in `assets/`. */
50export function SidebarStageBackdrop({ variant }: { variant: SidebarStageBackdropVariant }) {
51 return (
52 <div
53 aria-hidden
54 className="sidebar-stage-backdrop pointer-events-none absolute inset-x-0 top-0 z-0 h-20 select-none overflow-hidden"
55 >
56 <StageBackdropArt variant={variant} />
57 </div>
58 );
59}
60
6149export function StageBackdropArt({ variant }: { variant: SidebarStageBackdropVariant }) {
6250 return variant === "nightly" ? <NightlySkyArt /> : <DevBlueprintArt />;
6351}
apps/web/src/components/desktopUpdate.actions.ts created+102
......@@ -0,0 +1,102 @@
1import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts";
2import { ensureLocalApi } from "../localApi";
3import { stackedThreadToast, toastManager } from "./ui/toast";
4import {
5 getDesktopUpdateActionError,
6 getDesktopUpdateInstallConfirmationMessage,
7 resolveDesktopUpdateButtonAction,
8 shouldToastDesktopUpdateActionResult,
9} from "./desktopUpdate.logic";
10import { showDesktopUpdateDownloadedToast } from "./desktopUpdate.toast";
11import type { ReportLocalUpdateError } from "./sidebar/useUpdateEverything";
12
13export const toastUpdateError: ReportLocalUpdateError = (title, description) => {
14 toastManager.add(stackedThreadToast({ type: "error", title, description }));
15};
16
17/** Prepares a full-app update or applies a compatible UI without stopping tasks. */
18export async function downloadLocalUpdate(
19 bridge: DesktopBridge,
20 {
21 notifyDownloaded,
22 reportError = toastUpdateError,
23 }: { readonly notifyDownloaded: boolean; readonly reportError?: ReportLocalUpdateError },
24): Promise<boolean> {
25 try {
26 const result = await bridge.downloadUpdate();
27 if (result.completed && result.state.installKind === "ui") {
28 return await installLocalUpdate(bridge, reportError);
29 }
30 if (result.completed && notifyDownloaded) {
31 showDesktopUpdateDownloadedToast(bridge, result.state);
32 }
33 const actionError = shouldToastDesktopUpdateActionResult(result)
34 ? getDesktopUpdateActionError(result)
35 : null;
36 if (actionError) reportError("Could not download update", actionError);
37 return result.completed;
38 } catch (error) {
39 reportError(
40 "Could not start update download",
41 error instanceof Error ? error.message : "An unexpected error occurred.",
42 );
43 return false;
44 }
45}
46
47/** Applies a prepared UI in place or starts an app relaunch after the caller confirms. */
48export async function installLocalUpdate(
49 bridge: DesktopBridge,
50 reportError: ReportLocalUpdateError = toastUpdateError,
51): Promise<boolean> {
52 try {
53 const result = await bridge.installUpdate();
54 if (!result.accepted) {
55 showDesktopUpdateDownloadedToast(bridge, result.state);
56 return false;
57 }
58 const actionError = shouldToastDesktopUpdateActionResult(result)
59 ? getDesktopUpdateActionError(result)
60 : null;
61 if (!actionError) {
62 if (result.completed && result.state.uiVersion) window.location.reload();
63 return true;
64 }
65 reportError("Could not install update", actionError);
66 return false;
67 } catch (error) {
68 reportError(
69 "Could not install update",
70 error instanceof Error ? error.message : "An unexpected error occurred.",
71 );
72 return false;
73 }
74}
75
76export async function performDesktopUpdate(
77 bridge: DesktopBridge,
78 state: DesktopUpdateState,
79): Promise<void> {
80 const action = resolveDesktopUpdateButtonAction(state);
81 try {
82 if (action === "download") {
83 await downloadLocalUpdate(bridge, { notifyDownloaded: true });
84 } else if (action === "install") {
85 if (
86 state.installKind === "ui" ||
87 (await ensureLocalApi().dialogs.confirm(getDesktopUpdateInstallConfirmationMessage(state)))
88 ) {
89 await installLocalUpdate(bridge);
90 }
91 } else {
92 const result = await bridge.checkForUpdate();
93 if (!result.checked)
94 toastUpdateError(
95 "Could not check for updates",
96 result.state.message ?? "Automatic updates are not available in this build.",
97 );
98 }
99 } catch (error) {
100 toastUpdateError("Could not update app", error instanceof Error ? error.message : "Try again.");
101 }
102}
apps/web/src/components/desktopUpdate.logic.ts+3-1
......@@ -87,7 +87,9 @@ export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string
8787 return `Downloading update${progress}`;
8888 }
8989 if (state.status === "downloaded") {
90 return `Update ${state.downloadedVersion ?? state.availableVersion ?? "ready"} downloaded. Click to restart and install.`;
90 return state.installKind === "ui"
91 ? `UI update ${state.downloadedVersion ?? state.availableVersion ?? "ready"} downloaded. Reload to apply without interrupting running tasks.`
92 : `Update ${state.downloadedVersion ?? state.availableVersion ?? "ready"} downloaded. Click to restart and install.`;
9193 }
9294 if (state.status === "error") {
9395 if (state.errorContext === "download" && state.availableVersion) {
apps/web/src/components/desktopUpdate.toast.tsx+3-1
......@@ -56,7 +56,9 @@ export function showDesktopUpdateDownloadedToast(
5656 title: "Update downloaded",
5757 description: (
5858 <>
59 Restart the app from the update button to install it.
59 {state.installKind === "ui"
60 ? "Reload the UI from the update button to apply it."
61 : "Restart the app from the update button to install it."}
6062 {releaseUrl ? <ReleaseNotesLink releaseUrl={releaseUrl} shell={shell} /> : null}
6163 </>
6264 ),
apps/web/src/components/files/FilePreviewPanel.tsx+1-1
......@@ -1101,7 +1101,7 @@ export default function FilePreviewPanel({
11011101 // it on the panel meant a thread switch dropped it and forced source back.
11021102 const [renderMarkdownPreferred, setRenderMarkdownPreferred] = useLocalStorage(
11031103 RENDER_MARKDOWN_STORAGE_KEY,
1104 false,
1104 true,
11051105 Schema.Boolean,
11061106 );
11071107 const [renderBrowserFilePreferred, setRenderBrowserFilePreferred] = useLocalStorage(
apps/web/src/components/settings/ProjectFaviconPickerDialog.test.tsx+2-2
......@@ -28,8 +28,8 @@ vi.mock("~/state/server", () => ({
2828 primaryServerKeybindingsAtom: Symbol("keybindings"),
2929}));
3030
31vi.mock("~/hooks/useTheme", () => ({
32 useTheme: () => ({ resolvedTheme: "dark" }),
31vi.mock("~/assets/assetUrls", () => ({
32 useAssetUrlState: () => ({ _tag: "Loading" }),
3333}));
3434
3535vi.mock("../files/projectFilesQueryState", () => ({
apps/web/src/components/settings/ProjectFaviconPickerDialog.tsx+53-8
......@@ -1,15 +1,15 @@
11import { useAtomValue } from "@effect/atom-react";
22import type { EnvironmentId } from "@t3tools/contracts";
33import { isWindowsAbsolutePath } from "@t3tools/shared/path";
4import { ImageIcon } from "lucide-react";
45import { useMemo, useState } from "react";
56
7import { useAssetUrlState } from "~/assets/assetUrls";
68import { primaryServerKeybindingsAtom } from "~/state/server";
7import { useTheme } from "~/hooks/useTheme";
89import { getLocalFileManagerName, isWindowsPlatform } from "~/lib/utils";
910import { CommandPaletteContent } from "../CommandPaletteContent";
1011import type { CommandPaletteActionItem } from "../CommandPalette.logic";
1112import { CommandPaletteResults } from "../CommandPaletteResults";
12import { PierreEntryIcon } from "../chat/PierreEntryIcon";
1313import {
1414 getProjectFilePickerMatches,
1515 PROJECT_FILE_PICKER_RESULT_LIMIT,
......@@ -27,6 +27,51 @@ export function canPickExternalProjectFavicon(cwd: string, platform: string): bo
2727 return !isWindowsPlatform(platform) || isWindowsAbsolutePath(cwd);
2828}
2929
30function ProjectIconFilePreview(props: {
31 readonly environmentId: EnvironmentId;
32 readonly cwd: string;
33 readonly path: string;
34}) {
35 const asset = useAssetUrlState(props.environmentId, {
36 _tag: "draft-workspace-file",
37 cwd: props.cwd,
38 path: props.path,
39 });
40 const [failedUrl, setFailedUrl] = useState<string | null>(null);
41 const src = asset._tag === "Success" && asset.url !== failedUrl ? asset.url : null;
42
43 return (
44 <span
45 aria-hidden="true"
46 className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-sm bg-muted"
47 style={
48 src
49 ? {
50 backgroundImage:
51 "repeating-conic-gradient(color-mix(in srgb, var(--foreground) 40%, var(--background)) 0 25%, color-mix(in srgb, var(--foreground) 60%, var(--background)) 0 50%)",
52 backgroundSize: "8px 8px",
53 }
54 : undefined
55 }
56 >
57 {src ? (
58 <img
59 src={src}
60 alt=""
61 width={32}
62 height={32}
63 loading="lazy"
64 decoding="async"
65 className="size-full object-contain"
66 onError={() => setFailedUrl(src)}
67 />
68 ) : (
69 <ImageIcon className="size-4 text-muted-foreground" />
70 )}
71 </span>
72 );
73}
74
3075export function ProjectFaviconPickerDialog(props: {
3176 readonly cwd: string;
3277 readonly environmentId: EnvironmentId;
......@@ -36,17 +81,17 @@ export function ProjectFaviconPickerDialog(props: {
3681 readonly open: boolean;
3782 readonly projectName: string;
3883}) {
84 const { cwd, environmentId, onSelect } = props;
3985 const [query, setQuery] = useState("");
4086 const [highlightedItemValue, setHighlightedItemValue] = useState<string | null>(null);
4187 const [isPickingExternal, setIsPickingExternal] = useState(false);
4288 const result = useProjectFilePickerQuery(
43 props.environmentId,
44 props.cwd,
89 environmentId,
90 cwd,
4591 query,
4692 PROJECT_FILE_PICKER_RESULT_LIMIT,
4793 { imageOnly: true },
4894 );
49 const { resolvedTheme } = useTheme();
5095 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
5196 const pickExternal = props.onPickExternal;
5297 const fileManagerName = getLocalFileManagerName(
......@@ -60,10 +105,10 @@ export function ProjectFaviconPickerDialog(props: {
60105 searchTerms: [match.name, match.path],
61106 title: match.name,
62107 description: match.path,
63 icon: <PierreEntryIcon pathValue={match.path} kind="file" theme={resolvedTheme} />,
64 run: async () => props.onSelect(match.path),
108 icon: <ProjectIconFilePreview environmentId={environmentId} cwd={cwd} path={match.path} />,
109 run: async () => onSelect(match.path),
65110 })),
66 [props.onSelect, resolvedTheme, result.entries, result.matchedQuery],
111 [environmentId, cwd, onSelect, result.entries, result.matchedQuery],
67112 );
68113
69114 return (
apps/web/src/components/settings/SettingsPanels.tsx+37-95
......@@ -1,3 +1,4 @@
1import { performDesktopUpdate } from "../desktopUpdate.actions";
12import { SettingsGroup } from "./SettingsGroup";
23import { useScopedSettingsWriteAllowed } from "./useScopedSettings";
34import { Spinner } from "~/components/ui/spinner";
......@@ -6,7 +7,7 @@ import { PRIVACY_POLICY_URL } from "../../legalLinks";
67import { ArchiveIcon, ArchiveX, CheckIcon, ChevronRightIcon, SettingsIcon } from "lucide-react";
78import { Link, useNavigate } from "@tanstack/react-router";
89import type { CSSProperties, ReactNode } from "react";
9import { useCallback, useEffect, useMemo, useRef, useState } from "react";
10import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
1011import {
1112 type BackgroundActivityProfile,
1213 type DesktopUpdateChannel,
......@@ -59,7 +60,6 @@ import { CliCommandSettingsRow } from "./CliCommandSettingsRow";
5960import {
6061 canCheckForUpdate,
6162 getDesktopUpdateButtonTooltip,
62 getDesktopUpdateInstallConfirmationMessage,
6363 isDesktopUpdateButtonDisabled,
6464 resolveDesktopUpdateButtonAction,
6565} from "../../components/desktopUpdate.logic";
......@@ -270,10 +270,13 @@ function backgroundActivityProfileSettings(profile: BackgroundActivityProfile) {
270270}
271271
272272function AboutVersionTitle() {
273 const updateState = useDesktopUpdateState();
274 const version = updateState?.uiVersion ?? updateState?.currentVersion ?? APP_VERSION;
275
273276 return (
274277 <span className="inline-flex items-baseline gap-2">
275278 <span>Version</span>
276 <code className="text-2xs font-medium text-muted-foreground">{APP_VERSION}</code>
279 <code className="text-2xs font-medium text-muted-foreground">{version}</code>
277280 </span>
278281 );
279282}
......@@ -324,85 +327,13 @@ function AboutVersionSection() {
324327 const bridge = window.desktopBridge;
325328 if (!bridge) return;
326329
327 const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none";
328
329 if (action === "download") {
330 void bridge.downloadUpdate().catch((error: unknown) => {
331 toastManager.add(
332 stackedThreadToast({
333 type: "error",
334 title: "Could not download update",
335 description: error instanceof Error ? error.message : "Download failed.",
336 }),
337 );
338 });
339 return;
340 }
341
342 if (action === "install") {
343 if (isUpdateActionPending) return;
344 setIsUpdateActionPending(true);
345 let confirmed = false;
346 try {
347 confirmed = await ensureLocalApi().dialogs.confirm(
348 getDesktopUpdateInstallConfirmationMessage(
349 updateState ?? { availableVersion: null, downloadedVersion: null },
350 ),
351 );
352 } catch (error) {
353 setIsUpdateActionPending(false);
354 toastManager.add(
355 stackedThreadToast({
356 type: "error",
357 title: "Could not confirm update",
358 description: error instanceof Error ? error.message : "Update confirmation failed.",
359 }),
360 );
361 return;
362 }
363 if (!confirmed) {
364 setIsUpdateActionPending(false);
365 return;
366 }
367 void bridge
368 .installUpdate()
369 .catch((error: unknown) => {
370 toastManager.add(
371 stackedThreadToast({
372 type: "error",
373 title: "Could not install update",
374 description: error instanceof Error ? error.message : "Install failed.",
375 }),
376 );
377 })
378 .finally(() => setIsUpdateActionPending(false));
379 return;
330 if (!updateState || isUpdateActionPending) return;
331 setIsUpdateActionPending(true);
332 try {
333 await performDesktopUpdate(bridge, updateState);
334 } finally {
335 setIsUpdateActionPending(false);
380336 }
381
382 if (typeof bridge.checkForUpdate !== "function") return;
383 void bridge
384 .checkForUpdate()
385 .then((result) => {
386 if (!result.checked) {
387 toastManager.add(
388 stackedThreadToast({
389 type: "error",
390 title: "Could not check for updates",
391 description:
392 result.state.message ?? "Automatic updates are not available in this build.",
393 }),
394 );
395 }
396 })
397 .catch((error: unknown) => {
398 toastManager.add(
399 stackedThreadToast({
400 type: "error",
401 title: "Could not check for updates",
402 description: error instanceof Error ? error.message : "Update check failed.",
403 }),
404 );
405 });
406337 }, [isUpdateActionPending, updateState]);
407338
408339 const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none";
......@@ -411,8 +342,10 @@ function AboutVersionSection() {
411342 action === "none"
412343 ? !canCheckForUpdate(updateState)
413344 : isDesktopUpdateButtonDisabled(updateState);
414
415 const actionLabel: Record<string, string> = { download: "Download", install: "Install" };
345 const actionLabel: Record<string, string> = {
346 download: "Download",
347 install: updateState?.installKind === "ui" ? "Reload" : "Restart",
348 };
416349 const statusLabel: Record<string, string> = {
417350 checking: "Checking…",
418351 downloading: "Downloading…",
......@@ -1525,23 +1458,32 @@ export function AppearanceSettingsPanel() {
15251458 );
15261459}
15271460
1461function subscribeToFontLoading(onChange: () => void) {
1462 document.fonts.addEventListener("loadingdone", onChange);
1463 document.fonts.addEventListener("loadingerror", onChange);
1464
1465 return () => {
1466 document.fonts.removeEventListener("loadingdone", onChange);
1467 document.fonts.removeEventListener("loadingerror", onChange);
1468 };
1469}
1470
15281471function useFontDefaultFamilies() {
15291472 const settings = useScopedSettings();
1530 // An unset preference shows the font it resolves to on this machine; the
1531 // default stacks are the platform's own faces, so the name is probed, not
1532 // hardcoded.
1533 const defaults = useMemo(
1534 () => ({
1535 sans: resolveDefaultFamilyLabel(DEFAULT_SANS_FONT_STACK) ?? "System default",
1536 code: resolveDefaultFamilyLabel(DEFAULT_CODE_FONT_STACK) ?? "System monospace",
1537 }),
1538 [],
1473 const sans = useSyncExternalStore(
1474 subscribeToFontLoading,
1475 () => resolveDefaultFamilyLabel(DEFAULT_SANS_FONT_STACK) ?? "System default",
15391476 );
1477 const code = useSyncExternalStore(
1478 subscribeToFontLoading,
1479 () => resolveDefaultFamilyLabel(DEFAULT_CODE_FONT_STACK) ?? "System monospace",
1480 );
1481
15401482 return {
1541 sans: defaults.sans,
1542 code: defaults.code,
1483 sans,
1484 code,
15431485 // The composer inherits whatever the interface preference resolves to.
1544 interfaceFamily: settings.fontFamilySans.trim() || defaults.sans,
1486 interfaceFamily: settings.fontFamilySans.trim() || sans,
15451487 };
15461488}
15471489
apps/web/src/components/settings/SettingsSidebarNav.tsx+3-63
......@@ -6,25 +6,9 @@ import {
66 useMemo,
77 useRef,
88 useState,
9 type ComponentType,
109 type KeyboardEvent,
1110} from "react";
12import {
13 ArchiveIcon,
14 BlocksIcon,
15 BotIcon,
16 createLucideIcon,
17 CalendarClockIcon,
18 GitBranchIcon,
19 HardDriveIcon,
20 PanelsTopLeftIcon,
21 KeyboardIcon,
22 Link2Icon,
23 PaletteIcon,
24 SearchIcon,
25 Settings2Icon,
26 XIcon,
27} from "lucide-react";
11import { SearchIcon, XIcon } from "lucide-react";
2812import { useLocation, useNavigate } from "@tanstack/react-router";
2913
3014import { Button } from "../ui/button";
......@@ -43,25 +27,13 @@ import { SidebarUtilityMenu } from "../sidebar/SidebarChrome";
4327import { scrollToSettingsTarget } from "./settingsLayout";
4428import {
4529 searchSettings,
46 isSettingsOverviewVisible,
4730 SETTINGS_SECTION_LABELS,
4831 type SettingsPath,
4932 type SettingsSearchItem,
5033} from "./settingsSearch";
5134import { useAvailableSettingsSearchItems } from "./useAvailableSettingsSearchItems";
5235import { validateSettingsScopeSearch } from "./settingsScope";
53
54const SnapShotIcon = createLucideIcon("snap-shot", [
55 [
56 "path",
57 {
58 d: "M8 3H6a3 3 0 0 0-3 3v2M16 3h2a3 3 0 0 1 3 3v2M21 16v2a3 3 0 0 1-3 3h-2M8 21H6a3 3 0 0 1-3-3v-2",
59 key: "capture-frame",
60 },
61 ],
62 ["rect", { width: "10", height: "8", x: "7", y: "8", rx: "2", key: "window" }],
63 ["circle", { cx: "12", cy: "12", r: "1.5", key: "lens" }],
64]);
36import { getSettingsNavigationItems, SETTINGS_SECTION_ICONS } from "./settingsNavigation";
6537
6638const T3ConnectSidebarSignIn = lazy(() =>
6739 import("../clerk/T3ConnectSidebarSignIn").then((module) => ({
......@@ -74,36 +46,6 @@ const T3ConnectSidebarAvatar = lazy(() =>
7446 })),
7547);
7648
77const SETTINGS_SECTION_ICONS: Readonly<
78 Record<SettingsPath, ComponentType<{ className?: string }>>
79> = {
80 "/settings/general": Settings2Icon,
81 "/settings/diagnostics": Settings2Icon,
82 "/settings/appearance": PaletteIcon,
83 "/settings/projects": PanelsTopLeftIcon,
84 "/settings/keybindings": KeyboardIcon,
85 "/settings/snap-shot": SnapShotIcon,
86 "/settings/providers": BotIcon,
87 "/settings/integrations": BlocksIcon,
88 "/settings/scheduled-tasks": CalendarClockIcon,
89 "/settings/source-control": GitBranchIcon,
90 "/settings/storage": HardDriveIcon,
91 "/settings/connections": Link2Icon,
92 "/settings/archived": ArchiveIcon,
93};
94
95const SETTINGS_NAV_ITEMS: ReadonlyArray<{
96 label: string;
97 to: SettingsPath;
98 icon: ComponentType<{ className?: string }>;
99}> = (Object.keys(SETTINGS_SECTION_LABELS) as SettingsPath[])
100 .filter((to) => to !== "/settings/diagnostics")
101 .map((to) => ({
102 to,
103 label: SETTINGS_SECTION_LABELS[to],
104 icon: SETTINGS_SECTION_ICONS[to],
105 }));
106
10749function SettingsSectionIcon({ to }: { to: SettingsPath }) {
10850 const Icon = SETTINGS_SECTION_ICONS[to];
10951 return <Icon className="mt-0.5 size-3.5 shrink-0 text-sidebar-muted-foreground/60" />;
......@@ -114,9 +56,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
11456 const currentHash = useLocation({ select: (location) => location.hash });
11557 const currentSearch = useLocation({ select: (location) => location.search });
11658 const scopeSearch = useMemo(() => validateSettingsScopeSearch(currentSearch), [currentSearch]);
117 const navItems = SETTINGS_NAV_ITEMS.filter(
118 (item) => item.to !== "/settings/projects" || isSettingsOverviewVisible(scopeSearch),
119 );
59 const navItems = getSettingsNavigationItems(scopeSearch);
12060 const { isMobile, setOpenMobile, open, setOpen } = useSidebar();
12161 const searchInputRef = useRef<HTMLInputElement>(null);
12262 const [query, setQuery] = useState("");
apps/web/src/components/settings/settingsNavigation.tsx created+70
......@@ -0,0 +1,70 @@
1import type { ComponentType } from "react";
2import {
3 ArchiveIcon,
4 BlocksIcon,
5 BotIcon,
6 CalendarClockIcon,
7 createLucideIcon,
8 GitBranchIcon,
9 HardDriveIcon,
10 KeyboardIcon,
11 Link2Icon,
12 PaletteIcon,
13 PanelsTopLeftIcon,
14 Settings2Icon,
15} from "lucide-react";
16
17import {
18 isSettingsOverviewVisible,
19 SETTINGS_SECTION_LABELS,
20 type SettingsPath,
21} from "./settingsSearch";
22import type { SettingsScopeSearch } from "./settingsScope";
23
24const SnapShotIcon = createLucideIcon("snap-shot", [
25 [
26 "path",
27 {
28 d: "M8 3H6a3 3 0 0 0-3 3v2M16 3h2a3 3 0 0 1 3 3v2M21 16v2a3 3 0 0 1-3 3h-2M8 21H6a3 3 0 0 1-3-3v-2",
29 key: "capture-frame",
30 },
31 ],
32 ["rect", { width: "10", height: "8", x: "7", y: "8", rx: "2", key: "window" }],
33 ["circle", { cx: "12", cy: "12", r: "1.5", key: "lens" }],
34]);
35
36export const SETTINGS_SECTION_ICONS: Readonly<
37 Record<SettingsPath, ComponentType<{ className?: string }>>
38> = {
39 "/settings/general": Settings2Icon,
40 "/settings/diagnostics": Settings2Icon,
41 "/settings/appearance": PaletteIcon,
42 "/settings/projects": PanelsTopLeftIcon,
43 "/settings/keybindings": KeyboardIcon,
44 "/settings/snap-shot": SnapShotIcon,
45 "/settings/providers": BotIcon,
46 "/settings/integrations": BlocksIcon,
47 "/settings/scheduled-tasks": CalendarClockIcon,
48 "/settings/source-control": GitBranchIcon,
49 "/settings/storage": HardDriveIcon,
50 "/settings/connections": Link2Icon,
51 "/settings/archived": ArchiveIcon,
52};
53
54const SETTINGS_NAV_ITEMS: ReadonlyArray<{
55 label: string;
56 to: SettingsPath;
57 icon: ComponentType<{ className?: string }>;
58}> = (Object.keys(SETTINGS_SECTION_LABELS) as SettingsPath[])
59 .filter((to) => to !== "/settings/diagnostics")
60 .map((to) => ({
61 to,
62 label: SETTINGS_SECTION_LABELS[to],
63 icon: SETTINGS_SECTION_ICONS[to],
64 }));
65
66export function getSettingsNavigationItems(scopeSearch: SettingsScopeSearch) {
67 return SETTINGS_NAV_ITEMS.filter(
68 (item) => item.to !== "/settings/projects" || isSettingsOverviewVisible(scopeSearch),
69 );
70}
apps/web/src/components/sidebar/AppMenu.tsx created+80
......@@ -0,0 +1,80 @@
1import { Link, useLocation } from "@tanstack/react-router";
2import { useId, useState } from "react";
3
4import { APP_BASE_NAME, APP_VERSION } from "../../branding";
5import { useDesktopUpdateState } from "../../state/desktopUpdate";
6import { KnifeMark } from "../KnifeMark";
7import { useEnvironmentStageLabel } from "../SidebarStageBackdrop";
8import { getSettingsNavigationItems } from "../settings/settingsNavigation";
9import { validateSettingsScopeSearch } from "../settings/settingsScope";
10import { Button } from "../ui/button";
11import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover";
12import { Separator } from "../ui/separator";
13import { useSidebar } from "../ui/sidebar";
14import { AppMenuProviderUpdates } from "./AppMenuProviderUpdates";
15import { AppMenuUpdates } from "./SidebarUpdatePill";
16
17export function AppMenu() {
18 const [open, setOpen] = useState(false);
19 const settingsHeadingId = useId();
20 const { isMobile, setOpenMobile } = useSidebar();
21 const { pathname, search } = useLocation();
22 const scopeSearch = validateSettingsScopeSearch(pathname.startsWith("/settings") ? search : {});
23 const stageLabel = useEnvironmentStageLabel();
24 const updateState = useDesktopUpdateState();
25 const version = updateState?.uiVersion ?? updateState?.currentVersion ?? APP_VERSION;
26 const closeForNavigation = () => {
27 setOpen(false);
28 if (isMobile) setOpenMobile(false);
29 };
30
31 return (
32 <Popover open={open} onOpenChange={setOpen}>
33 <PopoverTrigger
34 render={
35 <Button variant="ghost" size="icon-sm" aria-label={`${APP_BASE_NAME} menu`}>
36 <KnifeMark className="size-4" />
37 </Button>
38 }
39 />
40 <PopoverPopup align="start" width="lg" padding="compact" keepMounted inert={!open}>
41 <div className="flex items-baseline justify-between gap-3 px-1.5 pt-1 pb-2">
42 <PopoverTitle>{APP_BASE_NAME}</PopoverTitle>
43 <span className="text-2xs text-muted-foreground">
44 {version} <span className="ml-1">{stageLabel}</span>
45 </span>
46 </div>
47 <AppMenuUpdates />
48 <AppMenuProviderUpdates onNavigate={closeForNavigation} />
49 <Separator className="my-2" />
50 <h3
51 id={settingsHeadingId}
52 className="flex min-h-7 items-center px-1.5 text-xs font-medium text-muted-foreground"
53 >
54 Settings
55 </h3>
56 <nav aria-labelledby={settingsHeadingId} className="grid grid-cols-2 gap-x-2">
57 {getSettingsNavigationItems(scopeSearch).map(({ to, label, icon: Icon }) => (
58 <Link
59 key={to}
60 to={to}
61 search={scopeSearch}
62 onClick={closeForNavigation}
63 aria-current={
64 pathname === to ||
65 pathname.startsWith(`${to}/`) ||
66 (to === "/settings/general" && pathname === "/settings/open-source-licenses")
67 ? "page"
68 : undefined
69 }
70 className="flex min-h-7 items-center gap-2 rounded-md px-1.5 py-1 text-xs text-foreground outline-none hover:bg-accent focus-visible:bg-accent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring aria-[current=page]:bg-accent"
71 >
72 <Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
73 {label}
74 </Link>
75 ))}
76 </nav>
77 </PopoverPopup>
78 </Popover>
79 );
80}
apps/web/src/components/sidebar/AppMenuProviderUpdates.test.tsx created+284
......@@ -0,0 +1,284 @@
1// @vitest-environment jsdom
2import { act } from "react";
3import { createRoot, type Root } from "react-dom/client";
4import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
5import {
6 createMemoryHistory,
7 createRootRoute,
8 createRoute,
9 createRouter,
10 RouterContextProvider,
11 type AnyRouter,
12} from "@tanstack/react-router";
13import * as Cause from "effect/Cause";
14import { AsyncResult } from "effect/reactivity";
15import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
16
17const state = vi.hoisted(() => ({
18 environments: [] as Array<{
19 environmentId: string;
20 label: string;
21 connection: { phase: "connected" | "offline" };
22 serverConfig: { providers: ServerProvider[] };
23 }>,
24 permitted: new Set<string>(),
25 updateProvider: vi.fn(),
26}));
27
28vi.mock("~/state/environments", () => ({ useEnvironments: () => state }));
29vi.mock("~/state/server", () => ({
30 serverEnvironment: { updateProvider: { permissionAtom: (id: string) => id } },
31}));
32vi.mock("@effect/atom-react", () => ({
33 useAtomValue: (id: string) => state.permitted.has(id),
34}));
35vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => state.updateProvider }));
36vi.mock("../chat/ProviderInstanceIcon", () => ({ ProviderInstanceIcon: () => null }));
37vi.mock("../ui/spinner", () => ({ Spinner: () => null }));
38vi.mock("../settings/providerDriverMeta", () => ({ providerClients: new Map() }));
39
40import { AppMenuProviderUpdates } from "./AppMenuProviderUpdates";
41
42let root: Root;
43let container: HTMLDivElement;
44let router: AnyRouter;
45
46function provider(overrides: Partial<ServerProvider> = {}): ServerProvider {
47 return {
48 driver: ProviderDriverKind.make("codex"),
49 instanceId: ProviderInstanceId.make("codex"),
50 enabled: true,
51 installed: true,
52 version: "1.0.0",
53 status: "ready",
54 auth: { status: "authenticated" },
55 checkedAt: "2026-10-15T12:00:00.000Z",
56 models: [],
57 slashCommands: [],
58 skills: [],
59 versionAdvisory: {
60 status: "behind_latest",
61 currentVersion: "1.0.0",
62 latestVersion: "1.1.0",
63 updateCommand: "npm install -g provider",
64 canUpdate: true,
65 checkedAt: "2026-10-15T12:00:00.000Z",
66 message: "Update available.",
67 },
68 ...overrides,
69 };
70}
71
72function connect(id: string, providers: ServerProvider[]) {
73 state.environments.push({
74 environmentId: id,
75 label: id,
76 connection: { phase: "connected" },
77 serverConfig: { providers },
78 });
79 state.permitted.add(id);
80}
81
82function button(name: string) {
83 const node = container.querySelector<HTMLButtonElement>(`button[aria-label="${name}"]`);
84 if (!node) throw new Error(`Missing button: ${name}`);
85 return node;
86}
87
88async function render() {
89 await act(() =>
90 root.render(
91 <RouterContextProvider router={router}>
92 <AppMenuProviderUpdates />
93 </RouterContextProvider>,
94 ),
95 );
96}
97
98beforeEach(async () => {
99 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
100 state.environments = [];
101 state.permitted.clear();
102 state.updateProvider.mockReset();
103 state.updateProvider.mockResolvedValue(AsyncResult.success({ providers: [] }));
104 container = document.createElement("div");
105 document.body.append(container);
106 root = createRoot(container);
107 const rootRoute = createRootRoute();
108 router = createRouter({
109 routeTree: rootRoute.addChildren([
110 createRoute({ getParentRoute: () => rootRoute, path: "/" }),
111 createRoute({ getParentRoute: () => rootRoute, path: "/settings/providers" }),
112 ]),
113 history: createMemoryHistory({ initialEntries: ["/"] }),
114 });
115 await router.load();
116});
117
118afterEach(async () => {
119 await act(() => root.unmount());
120 container.remove();
121 vi.unstubAllGlobals();
122});
123
124it("updates one representative per installation and routes each machine independently", async () => {
125 connect("laptop", [
126 provider({ instanceId: ProviderInstanceId.make("codex-personal") }),
127 provider(),
128 provider({ driver: ProviderDriverKind.make("claudeAgent"), enabled: false }),
129 provider({ driver: ProviderDriverKind.make("cursor"), installed: false }),
130 ]);
131 connect("desktop", [provider()]);
132 state.environments.push({
133 environmentId: "offline",
134 label: "offline",
135 connection: { phase: "offline" },
136 serverConfig: { providers: [provider()] },
137 });
138 await render();
139
140 await act(async () => button("Update Codex on laptop").click());
141 await act(async () => button("Update Codex on desktop").click());
142
143 expect(state.updateProvider.mock.calls.map(([target]) => target)).toEqual([
144 { environmentId: "laptop", input: { provider: "codex", instanceId: "codex" } },
145 { environmentId: "desktop", input: { provider: "codex", instanceId: "codex" } },
146 ]);
147 expect(container.querySelectorAll("button")).toHaveLength(2);
148});
149
150it("keeps an installation disabled until its direct update settles, including rapid clicks", async () => {
151 connect("laptop", [provider()]);
152 const run = Promise.withResolvers<unknown>();
153 state.updateProvider.mockReturnValue(run.promise);
154 await render();
155 const update = button("Update Codex");
156 await act(() => {
157 update.click();
158 update.click();
159 });
160 expect(state.updateProvider).toHaveBeenCalledOnce();
161 expect(button("Updating Codex").disabled).toBe(true);
162
163 await act(() => run.resolve(AsyncResult.success({ providers: [] })));
164 expect(button("Update Codex").disabled).toBe(false);
165});
166
167it("reports a transport failure inline, permits retry, and clears it once the installation is current", async () => {
168 const outdated = provider();
169 connect("laptop", [outdated]);
170 state.updateProvider.mockResolvedValueOnce(
171 AsyncResult.failure(Cause.fail(new Error("Host disconnected. Reconnect and retry."))),
172 );
173 await render();
174 await act(async () => button("Update Codex").click());
175 expect(container.querySelector('[role="alert"]')?.textContent).toBe(
176 "Host disconnected. Reconnect and retry.",
177 );
178
179 await act(async () => button("Retry Codex").click());
180 expect(state.updateProvider).toHaveBeenCalledTimes(2);
181 expect(container.querySelector('[role="alert"]')).toBeNull();
182
183 state.environments[0]!.serverConfig.providers = [
184 provider({
185 version: "1.1.0",
186 versionAdvisory: { ...outdated.versionAdvisory!, status: "current", currentVersion: "1.1.0" },
187 }),
188 ];
189 await render();
190 expect(container.querySelector('[role="img"][aria-label="Up to date"]')).not.toBeNull();
191 expect(container.querySelector("button")).toBeNull();
192});
193
194it("follows the guarded command permission and never dispatches through a disabled connection", async () => {
195 connect("laptop", [provider()]);
196 state.permitted.delete("laptop");
197 await render();
198 await act(async () => button("Update Codex").click());
199 expect(state.updateProvider).not.toHaveBeenCalled();
200 expect(container.textContent).toContain("This connection cannot update providers.");
201
202 state.permitted.add("laptop");
203 await render();
204 await act(async () => button("Update Codex").click());
205 expect(state.updateProvider).toHaveBeenCalledOnce();
206});
207
208it("requires provider settings when profiles disagree on their installer", async () => {
209 const first = provider();
210 connect("laptop", [
211 first,
212 provider({
213 instanceId: ProviderInstanceId.make("codex-personal"),
214 versionAdvisory: { ...first.versionAdvisory!, updateCommand: "brew upgrade provider" },
215 }),
216 ]);
217 await render();
218 expect(container.querySelector("button")).toBeNull();
219 const details = container.querySelector<HTMLAnchorElement>("a");
220 expect(details).not.toBeNull();
221 await act(async () => details!.click());
222 expect(router.state.location.pathname).toBe("/settings/providers");
223 expect(router.state.location.search).toEqual({ machine: "laptop" });
224 expect(state.updateProvider).not.toHaveBeenCalled();
225});
226
227it("blocks incompatible latest versions and explains the reason", async () => {
228 connect("laptop", [
229 provider({
230 compatibilityAdvisory: {
231 status: "supported",
232 latestVersionStatus: "broken",
233 message: "The latest CLI cannot start sessions.",
234 recommendedVersion: "1.0.0",
235 recommendedRange: null,
236 },
237 }),
238 ]);
239 await render();
240 expect(container.textContent).toContain("Update blocked: The latest CLI cannot start sessions.");
241 expect(container.querySelector("button")).toBeNull();
242 expect(container.querySelector("a")?.getAttribute("href")).toBe(
243 "/settings/providers?machine=laptop",
244 );
245 expect(state.updateProvider).not.toHaveBeenCalled();
246});
247
248it("honors a sibling profile's queued update and keeps failed server results retryable", async () => {
249 connect("laptop", [
250 provider(),
251 provider({
252 instanceId: ProviderInstanceId.make("codex-personal"),
253 updateState: {
254 status: "queued",
255 startedAt: "2026-10-15T12:01:00.000Z",
256 finishedAt: null,
257 message: null,
258 output: null,
259 },
260 }),
261 ]);
262 await render();
263 expect(button("Updating Codex").disabled).toBe(true);
264 expect(container.textContent).toContain("Queued");
265 expect(state.updateProvider).not.toHaveBeenCalled();
266
267 state.environments[0]!.serverConfig.providers = [
268 provider({
269 updateState: {
270 status: "failed",
271 startedAt: "2026-10-15T12:01:00.000Z",
272 finishedAt: "2026-10-15T12:02:00.000Z",
273 message: "Installer unavailable. Try again.",
274 output: null,
275 },
276 }),
277 ];
278 await render();
279 expect(container.querySelector('[role="alert"]')?.textContent).toBe(
280 "Installer unavailable. Try again.",
281 );
282 await act(async () => button("Retry Codex").click());
283 expect(state.updateProvider).toHaveBeenCalledOnce();
284});
apps/web/src/components/sidebar/AppMenuProviderUpdates.tsx created+226
......@@ -0,0 +1,226 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 isAtomCommandInterrupted,
4 squashAtomCommandFailure,
5} from "@t3tools/client-runtime/state/runtime";
6import {
7 PROVIDER_DISPLAY_NAMES,
8 type EnvironmentId,
9 type ServerProvider,
10} from "@t3tools/contracts";
11import { Link } from "@tanstack/react-router";
12import { CheckIcon } from "lucide-react";
13import { useRef, useState } from "react";
14
15import { useEnvironments } from "~/state/environments";
16import { serverEnvironment } from "~/state/server";
17import { useAtomCommand } from "~/state/use-atom-command";
18import {
19 canOneClickUpdateProviderCandidate,
20 collectProviderUpdateCandidates,
21 isProviderUpdateActive,
22} from "../ProviderUpdateLaunchNotification.logic";
23import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
24import { providerClients } from "../settings/providerDriverMeta";
25import { Button } from "../ui/button";
26import { Spinner } from "../ui/spinner";
27import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
28
29function ProviderInstallationRow({
30 environmentId,
31 environmentLabel,
32 providers,
33 onNavigate,
34}: {
35 readonly environmentId: EnvironmentId;
36 readonly environmentLabel: string | null;
37 readonly providers: ReadonlyArray<ServerProvider>;
38 readonly onNavigate: (() => void) | undefined;
39}) {
40 const allowed = useAtomValue(serverEnvironment.updateProvider.permissionAtom(environmentId));
41 const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { reportFailure: false });
42 const inFlight = useRef(false);
43 const [pending, setPending] = useState(false);
44 const [requestError, setRequestError] = useState<string | null>(null);
45 const candidate = collectProviderUpdateCandidates(providers)[0];
46 const provider =
47 candidate ??
48 providers.find((entry) => entry.versionAdvisory?.status === "behind_latest") ??
49 providers.toSorted((a, b) => b.checkedAt.localeCompare(a.checkedAt))[0];
50 if (!provider) return null;
51
52 const name =
53 providerClients.get(provider.driver)?.label ??
54 PROVIDER_DISPLAY_NAMES[provider.driver] ??
55 provider.driver;
56 const active = providers.find(isProviderUpdateActive);
57 const updating = pending || active !== undefined;
58 const canUpdate =
59 candidate !== undefined && canOneClickUpdateProviderCandidate(candidate, providers);
60 const compatibility = provider.compatibilityAdvisory;
61 const latestBlocked =
62 compatibility?.latestVersionStatus === "broken" ||
63 compatibility?.latestVersionStatus === "unsupported";
64 const latestUpdate = providers
65 .flatMap((entry) => (entry.updateState ? [entry.updateState] : []))
66 .toSorted((a, b) =>
67 (b.finishedAt ?? b.startedAt ?? "").localeCompare(a.finishedAt ?? a.startedAt ?? ""),
68 )[0];
69 const current = providers.every((entry) => entry.versionAdvisory?.status === "current");
70 const error =
71 !current && !updating
72 ? (requestError ??
73 (latestUpdate?.status === "failed"
74 ? (latestUpdate.message ?? "Provider update failed. Try again.")
75 : null))
76 : null;
77 const versions = [
78 ...new Set(providers.map((entry) => entry.version).filter((version) => version !== null)),
79 ];
80 const installedVersion = versions.length > 0 ? versions.join(" / ") : "Version unknown";
81 const latestVersion = provider.versionAdvisory?.latestVersion;
82 const versionLabel =
83 latestVersion && !versions.every((version) => version === latestVersion)
84 ? `${installedVersion} → ${latestVersion}`
85 : installedVersion;
86
87 const handleUpdate = async () => {
88 if (!candidate || !allowed || !canUpdate || inFlight.current) return;
89
90 inFlight.current = true;
91 setPending(true);
92 setRequestError(null);
93 try {
94 const result = await updateProvider({
95 environmentId,
96 input: { provider: candidate.driver, instanceId: candidate.instanceId },
97 });
98 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
99 const failure = squashAtomCommandFailure(result);
100 setRequestError(
101 failure instanceof Error ? failure.message : "Provider update failed. Try again.",
102 );
103 }
104 } catch (failure) {
105 setRequestError(
106 failure instanceof Error ? failure.message : "Provider update failed. Try again.",
107 );
108 } finally {
109 inFlight.current = false;
110 setPending(false);
111 }
112 };
113
114 return (
115 <div
116 className="flex items-start gap-2 px-1.5 py-0.5"
117 aria-label={environmentLabel ? `${name} on ${environmentLabel}` : name}
118 >
119 <div className="flex h-6 w-4 shrink-0 items-center justify-center" aria-hidden="true">
120 <ProviderInstanceIcon
121 driverKind={provider.driver}
122 displayName={name}
123 iconClassName="size-4"
124 />
125 </div>
126 <div className="min-w-0 flex-1">
127 <div className="flex min-h-6 items-center gap-2 text-xs">
128 <span className="truncate font-medium">{name}</span>
129 {environmentLabel ? (
130 <span className="truncate text-muted-foreground">{environmentLabel}</span>
131 ) : null}
132 <Tooltip>
133 <TooltipTrigger
134 render={
135 <span className="ml-auto truncate text-2xs text-muted-foreground tabular-nums" />
136 }
137 >
138 {versionLabel}
139 </TooltipTrigger>
140 <TooltipPopup>{versionLabel}</TooltipPopup>
141 </Tooltip>
142 </div>
143 {latestBlocked ? (
144 <p className="mt-0.5 break-words text-2xs text-warning">
145 {compatibility?.message
146 ? `Update blocked: ${compatibility.message}`
147 : "Latest version blocked"}
148 </p>
149 ) : null}
150 {error ? (
151 <p role="alert" className="mt-0.5 break-words text-xs text-destructive">
152 {error}
153 </p>
154 ) : null}
155 {candidate && canUpdate && !allowed ? (
156 <p className="mt-0.5 text-2xs text-muted-foreground">
157 This connection cannot update providers.
158 </p>
159 ) : null}
160 </div>
161 <div className="shrink-0" aria-live="polite">
162 {updating ? (
163 <Button size="xs" variant="ghost-muted" disabled aria-label={`Updating ${name}`}>
164 <Spinner size="xs" aria-hidden="true" />
165 {active?.updateState?.status === "queued" ? "Queued" : "Updating…"}
166 </Button>
167 ) : canUpdate ? (
168 <Button
169 size="xs"
170 variant="outline"
171 disabled={!allowed}
172 onClick={() => void handleUpdate()}
173 aria-label={`${error ? "Retry" : "Update"} ${name}${environmentLabel ? ` on ${environmentLabel}` : ""}`}
174 >
175 {error ? "Retry" : "Update"}
176 </Button>
177 ) : provider.versionAdvisory?.status === "behind_latest" ? (
178 <Button
179 size="xs"
180 variant="outline"
181 render={
182 <Link
183 to="/settings/providers"
184 search={{ machine: environmentId }}
185 onClick={onNavigate}
186 />
187 }
188 aria-label={`View ${name} update details`}
189 >
190 Details
191 </Button>
192 ) : current ? (
193 <span
194 className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground"
195 role="img"
196 aria-label="Up to date"
197 >
198 <CheckIcon className="size-3.5" aria-hidden="true" />
199 </span>
200 ) : null}
201 </div>
202 </div>
203 );
204}
205
206export function AppMenuProviderUpdates({ onNavigate }: { readonly onNavigate?: () => void }) {
207 const { environments } = useEnvironments();
208 const connected = environments.filter(
209 (environment) => environment.connection.phase === "connected",
210 );
211
212 return connected.flatMap((environment) => {
213 const providers = (environment.serverConfig?.providers ?? []).filter(
214 (provider) => provider.enabled && provider.installed,
215 );
216 return [...Map.groupBy(providers, (provider) => provider.driver)].map(([driver, profiles]) => (
217 <ProviderInstallationRow
218 key={`${environment.environmentId}:${driver}`}
219 environmentId={environment.environmentId}
220 environmentLabel={connected.length > 1 ? environment.label : null}
221 providers={profiles}
222 onNavigate={onNavigate}
223 />
224 ));
225 });
226}
apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx+2-32
......@@ -1,6 +1,5 @@
11import { RefreshIcon } from "~/components/ui/refresh-icon";
2import { CheckIcon, CircleArrowUpIcon, DownloadIcon, RotateCwIcon } from "lucide-react";
3import type { AnimationEventHandler } from "react";
2import { CheckIcon, DownloadIcon, RotateCwIcon } from "lucide-react";
43
54const DOWNLOAD_PROGRESS_RADIUS = 14;
65const DOWNLOAD_PROGRESS_CIRCUMFERENCE = 2 * Math.PI * DOWNLOAD_PROGRESS_RADIUS;
......@@ -10,38 +9,13 @@ export type DesktopUpdateStatusIconState =
109 | "checking"
1110 | "available"
1211 | "downloading"
13 | "downloaded"
14 // Other machines or providers have updates. Matches the server update icon
15 // in Connections settings; the download arrow stays for this app's own file.
16 | "everything";
12 | "downloaded";
1713
1814function normalizeDesktopUpdateDownloadPercent(percent: number | null): number {
1915 if (percent === null || !Number.isFinite(percent)) return 0;
2016 return Math.min(100, Math.max(0, percent));
2117}
2218
23export function shouldShowDesktopUpdateCheckIcon({
24 isAnimationLatched,
25 isChecking,
26 prefersReducedMotion,
27}: {
28 readonly isAnimationLatched: boolean;
29 readonly isChecking: boolean;
30 readonly prefersReducedMotion: boolean;
31}): boolean {
32 return isChecking || (isAnimationLatched && !prefersReducedMotion);
33}
34
35export function shouldContinueDesktopUpdateCheckAnimation({
36 isChecking,
37 prefersReducedMotion,
38}: {
39 readonly isChecking: boolean;
40 readonly prefersReducedMotion: boolean;
41}): boolean {
42 return isChecking && !prefersReducedMotion;
43}
44
4519function DesktopUpdateAvailableIcon() {
4620 return (
4721 <span className="relative grid size-4 place-items-center">
......@@ -105,12 +79,10 @@ function DesktopUpdateDownloadedIcon() {
10579export function DesktopUpdateStatusIcon({
10680 downloadPercent,
10781 isCheckAnimating,
108 onCheckAnimationIteration,
10982 status,
11083}: {
11184 readonly downloadPercent?: number | null;
11285 readonly isCheckAnimating?: boolean;
113 readonly onCheckAnimationIteration?: AnimationEventHandler<SVGSVGElement>;
11486 readonly status: DesktopUpdateStatusIconState;
11587}) {
11688 if (status === "available") return <DesktopUpdateAvailableIcon />;
......@@ -118,13 +90,11 @@ export function DesktopUpdateStatusIcon({
11890 return <DesktopUpdateDownloadingIcon percent={downloadPercent ?? null} />;
11991 }
12092 if (status === "downloaded") return <DesktopUpdateDownloadedIcon />;
121 if (status === "everything") return <CircleArrowUpIcon className="size-4" />;
12293
12394 return (
12495 <RefreshIcon
12596 className="size-4"
12697 refreshing={status === "checking" && isCheckAnimating === true}
127 onAnimationIteration={onCheckAnimationIteration}
12898 />
12999 );
130100}
apps/web/src/components/sidebar/SidebarChrome.tsx+14-106
......@@ -1,16 +1,13 @@
1import { ArrowLeftIcon, ChartNoAxesColumnIcon, SettingsIcon } from "lucide-react";
1import { ArrowLeftIcon, ChartNoAxesColumnIcon } from "lucide-react";
22import type { ReactNode } from "react";
33import { memo, useCallback } from "react";
4import { Link, useLocation, useNavigate } from "@tanstack/react-router";
4import { useLocation, useNavigate } from "@tanstack/react-router";
55
66import { useEnvironmentIdentificationMode } from "../../hooks/useSettings";
77import { cn } from "../../lib/utils";
88import { usePullRequestsSupported } from "../../state/environments";
9import { T3Wordmark } from "../T3Wordmark";
109import {
1110 resolveEnvironmentIdentificationPillLabel,
12 resolveSidebarStageBackdropVariant,
13 SidebarStageBackdrop,
1411 useEnvironmentStageLabel,
1512} from "../SidebarStageBackdrop";
1613import { Badge } from "../ui/badge";
......@@ -26,10 +23,8 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
2623import { readPullRequestListPreferences } from "../pullRequest/pullRequestListPreferences";
2724import { isSidebarUtilityPage, useNavigateToMainApp } from "./mainAppLocation";
2825import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice";
29import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill";
30import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill";
26import { AppMenu } from "./AppMenu";
3127import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
32import { observeResize } from "~/lib/observeResize";
3328
3429export const SidebarChromeHeader = memo(function SidebarChromeHeader({
3530 isElectron,
......@@ -38,10 +33,6 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({
3833}) {
3934 const stageLabel = useEnvironmentStageLabel();
4035 const environmentIdentificationMode = useEnvironmentIdentificationMode();
41 const backdropVariant = resolveSidebarStageBackdropVariant(
42 stageLabel,
43 environmentIdentificationMode === "artwork",
44 );
4536 const pillLabel =
4637 environmentIdentificationMode === "pill"
4738 ? resolveEnvironmentIdentificationPillLabel(stageLabel)
......@@ -55,94 +46,24 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({
5546 isElectron && "drag-region",
5647 )}
5748 >
58 {backdropVariant ? <SidebarStageBackdrop variant={backdropVariant} /> : null}
5949 {/* Match the fixed control's inset and border compensation inside the sheet's safe area. */}
6050 <div className="relative top-[calc(var(--workspace-controls-top)-env(safe-area-inset-top))] z-10 ml-[calc(var(--workspace-controls-left)+1px-env(safe-area-inset-left))] flex shrink-0 items-center md:hidden">
61 <SidebarTrigger
62 // Over the stage artwork: the media viewer's control-on-imagery treatment.
63 variant={backdropVariant ? "media-navigation" : "ghost"}
64 className="relative top-auto translate-y-0"
65 aria-label="Toggle main sidebar"
66 />
67 </div>
68 {/* One visible line: the pill wraps onto the clipped second line once it no longer fits.
69 The padding keeps the brand's focus ring inside the clip. */}
70 <div className="relative z-10 flex h-8 min-w-0 flex-1 flex-wrap content-start items-center gap-x-2 overflow-hidden py-0.5">
71 <SidebarBrand onBackdrop={backdropVariant !== null} />
72 {pillLabel ? (
73 <div className="ml-1 flex h-7 items-center">
74 <Badge data-environment-identification="pill" size="sm" variant="secondary">
75 {pillLabel}
76 </Badge>
77 </div>
78 ) : null}
51 <div className="flex items-center [-webkit-app-region:no-drag]">
52 <AppMenu />
53 </div>
54 <SidebarTrigger aria-label="Toggle main sidebar" />
7955 </div>
56 {pillLabel ? (
57 <div className="relative z-10 ml-[var(--workspace-titlebar-content-left)] flex h-7 min-w-0 items-center">
58 <Badge data-environment-identification="pill" size="sm" variant="secondary">
59 {pillLabel}
60 </Badge>
61 </div>
62 ) : null}
8063 </div>
8164 );
8265});
8366
84// Measures the brand at its titlebar inset, plus the header's right padding and the
85// sidebar border, so the sidebar minimum follows font size, zoom and macOS window controls.
86export function SidebarBrandWidthProbe({
87 onWidthChange,
88}: {
89 onWidthChange: (width: number) => void;
90}) {
91 const observeWidth = useCallback(
92 (probe: HTMLDivElement) => {
93 return observeResize(probe, ([entry]) => {
94 if (entry) onWidthChange(entry.borderBoxSize[0]?.inlineSize ?? probe.offsetWidth);
95 });
96 },
97 [onWidthChange],
98 );
99
100 return (
101 <div
102 aria-hidden="true"
103 className="pointer-events-none invisible fixed top-0 left-0 flex w-max border-r border-transparent pr-3"
104 ref={observeWidth}
105 >
106 <div className="ml-[var(--workspace-titlebar-content-left)] flex">
107 <SidebarBrandMark onBackdrop={false} />
108 </div>
109 </div>
110 );
111}
112
113function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) {
114 return (
115 <Link
116 aria-label="Go to threads"
117 className={cn(
118 "relative z-10 ml-[var(--workspace-titlebar-content-left)] hidden h-7 w-fit min-w-0 shrink-0 items-center overflow-hidden rounded-md outline-hidden ring-ring focus-visible:ring-2 focus-visible:ring-inset md:flex",
119 onBackdrop ? "text-white" : "text-foreground",
120 )}
121 to="/"
122 >
123 <SidebarBrandMark onBackdrop={onBackdrop} />
124 </Link>
125 );
126}
127
128function SidebarBrandMark({ onBackdrop }: { onBackdrop: boolean }) {
129 return (
130 // Center the visible capitals, without the font's ascender/descender space.
131 // Padding keeps ascenders and round-letter overshoot inside the truncation clip.
132 <span className="inline-flex min-w-0 items-baseline gap-1 text-sm font-medium tracking-tight">
133 <T3Wordmark aria-label="T3" className="h-[1cap] w-auto shrink-0" />
134 <span
135 className={cn(
136 "truncate [text-box:trim-both_cap_alphabetic] supports-[text-box:trim-both_cap_alphabetic]:py-1",
137 onBackdrop ? "text-white/70" : "text-muted-foreground",
138 )}
139 >
140 Code
141 </span>
142 </span>
143 );
144}
145
14667function SidebarUtilityItem({
14768 icon,
14869 label,
......@@ -188,11 +109,6 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {
188109 search: readPullRequestListPreferences(),
189110 });
190111 }, [closeMobileSidebar, navigate]);
191 const handleSettingsClick = useCallback(() => {
192 closeMobileSidebar();
193 void navigate({ to: "/settings" });
194 }, [closeMobileSidebar, navigate]);
195
196112 const handleUsageClick = useCallback(() => {
197113 if (isMobile) {
198114 setOpenMobile(false);
......@@ -216,11 +132,6 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {
216132 </SidebarMenuItem>
217133 ) : (
218134 <>
219 <SidebarUtilityItem
220 icon={<SettingsIcon />}
221 label="Settings"
222 onClick={handleSettingsClick}
223 />
224135 {pullRequestsSupported ? (
225136 <SidebarUtilityItem
226137 icon={<PullRequestGlyph.pullRequest />}
......@@ -235,7 +146,6 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {
235146 />
236147 </>
237148 )}
238 <SidebarUpdatePill />
239149 </SidebarMenu>
240150 );
241151});
......@@ -244,8 +154,6 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() {
244154 return (
245155 <SidebarFooter>
246156 <SidebarThreadUndoNotice />
247 <SidebarProviderUpdatePill />
248 <SidebarUpdateArchitectureWarning />
249157 <SidebarUtilityMenu />
250158 </SidebarFooter>
251159 );
apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx deleted-210
......@@ -1,210 +0,0 @@
1import { Spinner } from "~/components/ui/spinner";
2import { useNavigate } from "@tanstack/react-router";
3import { useAtomValue } from "@effect/atom-react";
4import type { ServerProvider } from "@t3tools/contracts";
5import { CircleCheckIcon, DownloadIcon, TriangleAlertIcon, XIcon } from "lucide-react";
6import { useCallback, useEffect, useState, type CSSProperties } from "react";
7
8import { primaryServerProvidersAtom } from "../../state/server";
9import {
10 getProviderUpdateSidebarPillView,
11 type ProviderUpdateSidebarPillView,
12} from "../ProviderUpdateLaunchNotification.logic";
13import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
14
15const PROVIDER_UPDATE_PILL_STYLES = {
16 loading:
17 "bg-sidebar-control-surface text-sidebar-foreground group-has-[button[data-provider-update-main]:hover]/provider-update:bg-sidebar-row-hover",
18 success:
19 "bg-sidebar-control-surface text-sidebar-foreground group-has-[button[data-provider-update-main]:hover]/provider-update:bg-sidebar-row-hover",
20 warning:
21 "bg-warning/12 text-warning group-has-[button[data-provider-update-main]:hover]/provider-update:bg-warning/18",
22 error:
23 "bg-destructive/12 text-destructive group-has-[button[data-provider-update-main]:hover]/provider-update:bg-destructive/18",
24} as const;
25
26const PROVIDER_UPDATE_PILL_PROGRESS_STYLES = {
27 success: "bg-foreground/8",
28 warning: "bg-warning/14",
29 error: "bg-destructive/14",
30} as const;
31
32function latestProviderCheckedAt(
33 providers: ReadonlyArray<Pick<ServerProvider, "checkedAt">>,
34): string | undefined {
35 return providers.reduce<string | undefined>(
36 (latest, provider) =>
37 latest === undefined || provider.checkedAt > latest ? provider.checkedAt : latest,
38 undefined,
39 );
40}
41
42export function SidebarProviderUpdatePill() {
43 const navigate = useNavigate();
44 const providers = useAtomValue(primaryServerProvidersAtom);
45 const [dismissedKeys, setDismissedKeys] = useState<ReadonlySet<string>>(() => new Set());
46 const [renderedView, setRenderedView] = useState<ProviderUpdateSidebarPillView | null>(null);
47 const [pendingView, setPendingView] = useState<ProviderUpdateSidebarPillView | null>(null);
48 const [exitingKey, setExitingKey] = useState<string | null>(null);
49 const [dismissAfterExitKey, setDismissAfterExitKey] = useState<string | null>(null);
50 const [visibleAfterIso, setVisibleAfterIso] = useState<string | undefined>();
51 const effectiveVisibleAfterIso = visibleAfterIso ?? latestProviderCheckedAt(providers);
52 const view = getProviderUpdateSidebarPillView(providers, {
53 ...(effectiveVisibleAfterIso !== undefined
54 ? { visibleAfterIso: effectiveVisibleAfterIso }
55 : {}),
56 dismissedKeys,
57 });
58
59 useEffect(() => {
60 if (visibleAfterIso === undefined && effectiveVisibleAfterIso !== undefined) {
61 setVisibleAfterIso(effectiveVisibleAfterIso);
62 }
63 }, [effectiveVisibleAfterIso, visibleAfterIso]);
64
65 const openProviderSettings = useCallback(() => {
66 void navigate({ to: "/settings/providers" });
67 }, [navigate]);
68 const displayedView = renderedView ?? view;
69 const dismissAfterVisibleMs = displayedView?.dismissAfterVisibleMs;
70 const viewKey = displayedView?.key ?? null;
71 const showDismissProgress =
72 dismissAfterVisibleMs !== undefined &&
73 displayedView?.tone !== "loading" &&
74 exitingKey !== viewKey;
75
76 const startExit = useCallback(
77 (key: string, nextView: ProviderUpdateSidebarPillView | null, dismissKey?: string) => {
78 if (exitingKey === key) {
79 return;
80 }
81 setPendingView(nextView);
82 setExitingKey(key);
83 setDismissAfterExitKey(dismissKey ?? null);
84 },
85 [exitingKey],
86 );
87
88 useEffect(() => {
89 if (exitingKey !== null) {
90 return;
91 }
92 if (!renderedView) {
93 if (view) {
94 setRenderedView(view);
95 }
96 return;
97 }
98 if (!view) {
99 startExit(renderedView.key, null);
100 return;
101 }
102 if (view.key !== renderedView.key) {
103 startExit(renderedView.key, view);
104 return;
105 }
106 }, [exitingKey, renderedView, startExit, view]);
107
108 useEffect(() => {
109 if (!dismissAfterVisibleMs || !viewKey) {
110 return;
111 }
112 if (exitingKey === viewKey) {
113 return;
114 }
115 const timeoutId = window.setTimeout(() => {
116 startExit(viewKey, null, viewKey);
117 }, dismissAfterVisibleMs);
118
119 return () => window.clearTimeout(timeoutId);
120 }, [dismissAfterVisibleMs, exitingKey, startExit, viewKey]);
121
122 if (!displayedView) {
123 return null;
124 }
125
126 return (
127 <div
128 className={`group/provider-update relative flex min-h-7 w-full shrink-0 items-center overflow-hidden rounded-lg text-2xs leading-4 font-medium transform-gpu transition-all duration-180 ease-drawer will-change-transform ${
129 PROVIDER_UPDATE_PILL_STYLES[displayedView.tone]
130 } ${
131 exitingKey === displayedView.key
132 ? "pointer-events-none translate-y-1.5 opacity-0"
133 : "translate-y-0 opacity-100"
134 }`}
135 onTransitionEnd={(event) => {
136 if (event.target !== event.currentTarget) {
137 return;
138 }
139 if (!displayedView || exitingKey !== displayedView.key) {
140 return;
141 }
142 if (dismissAfterExitKey === displayedView.key) {
143 setDismissedKeys((previous) => new Set(previous).add(displayedView.key));
144 }
145 setRenderedView(pendingView);
146 setPendingView(null);
147 setExitingKey(null);
148 setDismissAfterExitKey(null);
149 }}
150 >
151 {showDismissProgress ? (
152 <div
153 key={displayedView.key}
154 aria-hidden="true"
155 className={`pointer-events-none absolute inset-y-0 left-0 w-full origin-left scale-x-0 transition-transform duration-(--provider-update-pill-dismiss-ms) ease-linear starting:scale-x-100 border-r border-current/15 inset-shadow-2xs inset-shadow-white/8 ${
156 PROVIDER_UPDATE_PILL_PROGRESS_STYLES[displayedView.tone]
157 }`}
158 style={
159 {
160 "--provider-update-pill-dismiss-ms": `${dismissAfterVisibleMs}ms`,
161 } as CSSProperties
162 }
163 />
164 ) : null}
165 <div className="pointer-events-none absolute inset-0 rounded-lg transition-colors" />
166 <Tooltip>
167 <TooltipTrigger
168 render={
169 <button
170 type="button"
171 aria-label={displayedView.description}
172 data-provider-update-main
173 className="relative z-[1] flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left"
174 onClick={openProviderSettings}
175 >
176 {displayedView.tone === "loading" ? (
177 <Spinner size="sm" className="shrink-0" />
178 ) : displayedView.tone === "success" ? (
179 <CircleCheckIcon className="size-3.5 shrink-0" />
180 ) : displayedView.tone === "error" ? (
181 <TriangleAlertIcon className="size-3.5 shrink-0" />
182 ) : (
183 <DownloadIcon className="size-3.5 shrink-0" />
184 )}
185 <span className="min-w-0 wrap-break-word">{displayedView.title}</span>
186 </button>
187 }
188 />
189 <TooltipPopup side="top">{displayedView.description}</TooltipPopup>
190 </Tooltip>
191 {displayedView.dismissible && (
192 <Tooltip>
193 <TooltipTrigger
194 render={
195 <button
196 type="button"
197 aria-label="Dismiss provider update notice"
198 className="relative z-[1] mr-1 flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-md opacity-70 outline-none hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
199 onClick={() => startExit(displayedView.key, null, displayedView.key)}
200 >
201 <XIcon className="size-3.5 shrink-0" />
202 </button>
203 }
204 />
205 <TooltipPopup side="top">Dismiss until provider status changes</TooltipPopup>
206 </Tooltip>
207 )}
208 </div>
209 );
210}
apps/web/src/components/sidebar/SidebarUpdatePill.test.tsx+200-56
......@@ -1,23 +1,56 @@
1// @vitest-environment jsdom
12import type { DesktopUpdateState } from "@t3tools/contracts";
2import { describe, expect, it, vi } from "vite-plus/test";
3import { act } from "react";
4import { createRoot, type Root } from "react-dom/client";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
36
4import {
5 handleSidebarUpdateReleaseNotesPopoverOpenChange,
6 openSidebarUpdateReleaseNotesPopoverOnForwardTab,
7 shouldUseSidebarUpdateReleaseNotesPopover,
8} from "./SidebarUpdatePill";
7const test = vi.hoisted(() => ({
8 state: null as DesktopUpdateState | null,
9 run: vi.fn().mockResolvedValue(undefined),
10 hasUpdates: true,
11 restarts: [] as Array<{ environmentId: string; continuesThreads: boolean }>,
12 toast: vi.fn(),
13}));
914
10const nightlyState: DesktopUpdateState = {
15vi.mock("../../env", () => ({ isElectron: true }));
16vi.mock("../../hooks/useMediaQuery", () => ({ useMediaQuery: () => true }));
17vi.mock("../../state/desktopUpdate", () => ({ useDesktopUpdateState: () => test.state }));
18vi.mock("../../state/server", () => ({ serverEnvironment: {} }));
19vi.mock("~/state/threads", () => ({ environmentThreadShells: {} }));
20vi.mock("@effect/atom-react", () => ({ useAtomValue: () => 0 }));
21vi.mock("./useUpdateEverything", () => ({
22 useUpdateEverything: () => ({
23 hasUpdates: test.hasUpdates,
24 servers: [],
25 summary: "Update providers",
26 lines: ["Home: Codex"],
27 skipped: [],
28 restarts: test.restarts,
29 run: test.run,
30 }),
31}));
32vi.mock("./updateEverythingRun", () => ({ useUpdateEverythingRun: () => null }));
33vi.mock("../ui/toast", () => ({
34 toastManager: { add: test.toast },
35 stackedThreadToast: (toast: unknown) => toast,
36}));
37vi.mock("../desktopUpdate.toast", () => ({ showDesktopUpdateDownloadedToast: vi.fn() }));
38vi.mock("../../localApi", () => ({ ensureLocalApi: vi.fn() }));
39
40import { ensureLocalApi } from "../../localApi";
41import { AppMenuUpdates } from "./SidebarUpdatePill";
42
43const baseState: DesktopUpdateState = {
1144 enabled: true,
12 status: "available",
13 channel: "nightly",
14 currentVersion: "0.0.35",
45 status: "idle",
46 channel: "latest",
47 currentVersion: "1.0.0",
1548 hostArch: "arm64",
1649 appArch: "arm64",
1750 runningUnderArm64Translation: false,
18 availableVersion: "0.0.36-nightly.3",
51 availableVersion: null,
1952 downloadedVersion: null,
20 releaseNotes: [{ version: "0.0.36-nightly.3", items: ["Newest change"], totalItems: 1 }],
53 releaseNotes: [],
2154 omittedReleaseCount: 0,
2255 downloadPercent: null,
2356 checkedAt: null,
......@@ -26,61 +59,172 @@ const nightlyState: DesktopUpdateState = {
2659 canRetry: false,
2760};
2861
29describe("sidebar update release notes popover", () => {
30 it("uses the popover only for visible nightly release notes", () => {
31 expect(shouldUseSidebarUpdateReleaseNotesPopover(true, nightlyState)).toBe(true);
32 expect(shouldUseSidebarUpdateReleaseNotesPopover(false, nightlyState)).toBe(false);
33 expect(
34 shouldUseSidebarUpdateReleaseNotesPopover(true, {
35 ...nightlyState,
36 channel: "latest",
37 }),
38 ).toBe(false);
39 expect(
40 shouldUseSidebarUpdateReleaseNotesPopover(true, {
41 ...nightlyState,
42 releaseNotes: [],
43 }),
44 ).toBe(false);
62let root: Root;
63let container: HTMLDivElement;
64
65async function click(label: string) {
66 const button = Array.from(document.querySelectorAll("button")).find(
67 (element) => element.textContent?.trim() === label,
68 );
69 expect(button, `Button ${label}`).toBeDefined();
70 await act(async () => button?.click());
71}
72
73beforeEach(() => {
74 test.state = baseState;
75 test.hasUpdates = true;
76 test.restarts = [];
77 test.run.mockReset().mockResolvedValue(undefined);
78 test.toast.mockReset();
79 vi.mocked(ensureLocalApi).mockReset();
80 container = document.createElement("div");
81 document.body.append(container);
82 root = createRoot(container);
83 Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
84});
85
86afterEach(async () => {
87 await act(async () => root.unmount());
88 container.remove();
89 Reflect.deleteProperty(window, "desktopBridge");
90 vi.unstubAllGlobals();
91});
92
93describe("app menu update execution", () => {
94 it("updates providers directly when no app or server will restart", async () => {
95 await act(async () => root.render(<AppMenuUpdates />));
96 await click("Update all");
97
98 expect(test.run).toHaveBeenCalledExactlyOnceWith({});
99 expect(document.querySelector('[role="alertdialog"]')).toBeNull();
45100 });
46101
47 it("cancels trigger presses without canceling other open reasons", () => {
48 const cancelTriggerPress = vi.fn();
49 const cancelHover = vi.fn();
102 it("waits for confirmation when a remote server will restart", async () => {
103 test.restarts = [{ environmentId: "home", continuesThreads: false }];
104 await act(async () => root.render(<AppMenuUpdates />));
105 await click("Update all");
50106
51 handleSidebarUpdateReleaseNotesPopoverOpenChange(true, {
52 reason: "trigger-press",
53 cancel: cancelTriggerPress,
54 });
55 handleSidebarUpdateReleaseNotesPopoverOpenChange(true, {
56 reason: "trigger-hover",
57 cancel: cancelHover,
58 });
107 expect(test.run).not.toHaveBeenCalled();
108 expect(document.querySelector('[role="alertdialog"]')).not.toBeNull();
109 await click("Update");
110 expect(test.run).toHaveBeenCalledOnce();
111 });
59112
60 expect(cancelTriggerPress).toHaveBeenCalledOnce();
61 expect(cancelHover).not.toHaveBeenCalled();
113 it("keeps an app update behind restart confirmation even without connected servers", async () => {
114 test.state = { ...baseState, status: "available", availableVersion: "1.1.0" };
115 test.hasUpdates = false;
116 await act(async () => root.render(<AppMenuUpdates />));
117 await click("Update all");
118
119 expect(test.run).not.toHaveBeenCalled();
120 expect(document.querySelector('[role="alertdialog"]')).not.toBeNull();
121 await click("Cancel");
122 expect(test.run).not.toHaveBeenCalled();
62123 });
63124
64 it("promotes forward Tab without preventing native navigation", () => {
65 const open = vi.fn();
66 const preventDefault = vi.fn();
67 const event = { key: "Tab", shiftKey: false, preventDefault };
125 it("reports a failed update run and allows retry", async () => {
126 test.run.mockRejectedValueOnce(new Error("Disconnected"));
127 await act(async () => root.render(<AppMenuUpdates />));
128 await click("Update all");
68129
69 openSidebarUpdateReleaseNotesPopoverOnForwardTab(event, { open }, "nightly-release-notes");
130 expect(test.toast).toHaveBeenCalledWith({
131 type: "error",
132 title: "Could not update everything",
133 description: "Disconnected",
134 });
135 await click("Update all");
136 expect(test.run).toHaveBeenCalledTimes(2);
137 });
138 it("retries a failed app download without restarting", async () => {
139 test.state = {
140 ...baseState,
141 status: "error",
142 errorContext: "download",
143 availableVersion: "1.1.0",
144 message: "Offline",
145 canRetry: true,
146 };
147 const downloadUpdate = vi.fn().mockResolvedValue({
148 accepted: true,
149 completed: true,
150 state: { ...test.state, status: "downloaded", downloadedVersion: "1.1.0" },
151 });
152 const installUpdate = vi.fn();
153 Object.defineProperty(window, "desktopBridge", {
154 configurable: true,
155 value: { downloadUpdate, installUpdate },
156 });
157 await act(async () => root.render(<AppMenuUpdates />));
158 await click("Retry download");
70159
71 expect(open).toHaveBeenCalledWith("nightly-release-notes");
72 expect(preventDefault).not.toHaveBeenCalled();
160 expect(downloadUpdate).toHaveBeenCalledOnce();
161 expect(installUpdate).not.toHaveBeenCalled();
73162 });
74163
75 it("does not promote backward Tab", () => {
76 const open = vi.fn();
164 it("reloads a compatible UI update without asking to interrupt work", async () => {
165 test.state = {
166 ...baseState,
167 status: "downloaded",
168 downloadedVersion: "1.1.0",
169 installKind: "ui",
170 };
171 const installUpdate = vi
172 .fn()
173 .mockResolvedValue({ accepted: true, completed: true, state: { ...baseState } });
174 Object.defineProperty(window, "desktopBridge", {
175 configurable: true,
176 value: { installUpdate },
177 });
178 await act(async () => root.render(<AppMenuUpdates />));
179 await click("Reload");
180 expect(installUpdate).toHaveBeenCalledOnce();
181 expect(ensureLocalApi).not.toHaveBeenCalled();
182 });
77183
78 openSidebarUpdateReleaseNotesPopoverOnForwardTab(
79 { key: "Tab", shiftKey: true },
80 { open },
81 "nightly-release-notes",
82 );
184 it.each([false, true])(
185 "requires confirmation before a full-app restart (confirm=%s)",
186 async (accepted) => {
187 test.state = {
188 ...baseState,
189 status: "downloaded",
190 downloadedVersion: "1.1.0",
191 installKind: "app",
192 };
193 const confirm = vi.fn().mockResolvedValue(accepted);
194 const { createLocalApi } =
195 await vi.importActual<typeof import("../../localApi")>("../../localApi");
196 const api = createLocalApi();
197 vi.mocked(ensureLocalApi).mockReturnValue({ ...api, dialogs: { ...api.dialogs, confirm } });
198 const installUpdate = vi
199 .fn()
200 .mockResolvedValue({ accepted: true, completed: false, state: test.state });
201 Object.defineProperty(window, "desktopBridge", {
202 configurable: true,
203 value: { installUpdate },
204 });
205 await act(async () => root.render(<AppMenuUpdates />));
206 await click("Restart");
207 expect(confirm).toHaveBeenCalledOnce();
208 expect(installUpdate).toHaveBeenCalledTimes(accepted ? 1 : 0);
209 },
210 );
83211
84 expect(open).not.toHaveBeenCalled();
212 it("reports an unavailable app update check", async () => {
213 const checkForUpdate = vi.fn().mockResolvedValue({
214 checked: false,
215 state: { ...baseState, message: "Updates disabled" },
216 });
217 Object.defineProperty(window, "desktopBridge", {
218 configurable: true,
219 value: { checkForUpdate },
220 });
221 await act(async () => root.render(<AppMenuUpdates />));
222 await click("Check");
223
224 expect(test.toast).toHaveBeenCalledWith({
225 type: "error",
226 title: "Could not check for updates",
227 description: "Updates disabled",
228 });
85229 });
86230});
apps/web/src/components/sidebar/SidebarUpdatePill.tsx+171-469
......@@ -1,47 +1,30 @@
11import { useAtomValue } from "@effect/atom-react";
2import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts";
2import type { DesktopUpdateState } from "@t3tools/contracts";
33import { Atom } from "effect/reactivity";
44import { TriangleAlertIcon } from "lucide-react";
5import {
6 type ComponentProps,
7 useCallback,
8 useEffect,
9 useId,
10 useMemo,
11 useRef,
12 useState,
13} from "react";
14import { flushSync } from "react-dom";
5import { useMemo, useRef, useState } from "react";
6
157import { isElectron } from "../../env";
168import { useMediaQuery } from "../../hooks/useMediaQuery";
17import { cn } from "../../lib/utils";
18import { ensureLocalApi } from "../../localApi";
199import { useDesktopUpdateState } from "../../state/desktopUpdate";
2010import { serverEnvironment } from "../../state/server";
21import { stackedThreadToast, toastManager } from "../ui/toast";
11import { Button } from "../ui/button";
2212import {
2313 canCheckForUpdate,
2414 getArm64IntelBuildWarningDescription,
25 getDesktopUpdateActionError,
2615 getDesktopUpdateButtonTooltip,
2716 getDesktopUpdateDownloadedVersion,
28 getDesktopUpdateInstallConfirmationMessage,
2917 isDesktopUpdateButtonDisabled,
3018 resolveDesktopUpdateButtonAction,
3119 shouldShowArm64IntelBuildWarning,
32 shouldToastDesktopUpdateActionResult,
3320} from "../desktopUpdate.logic";
34import { showDesktopUpdateDownloadedToast } from "../desktopUpdate.toast";
35import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
36import { Popover, PopoverCreateHandle, PopoverPopup, PopoverTrigger } from "../ui/popover";
37import { SidebarMenuItem } from "../ui/sidebar";
38import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
3921import {
40 DesktopUpdateStatusIcon,
41 type DesktopUpdateStatusIconState,
42 shouldContinueDesktopUpdateCheckAnimation,
43 shouldShowDesktopUpdateCheckIcon,
44} from "./DesktopUpdateStatusIcon";
22 downloadLocalUpdate,
23 installLocalUpdate,
24 performDesktopUpdate,
25 toastUpdateError,
26} from "../desktopUpdate.actions";
27import { DesktopUpdateStatusIcon } from "./DesktopUpdateStatusIcon";
4528import { SidebarUpdateReleaseNotes } from "./SidebarUpdateReleaseNotes";
4629import { UpdateEverythingDialog } from "./UpdateEverythingDialog";
4730import {
......@@ -50,163 +33,11 @@ import {
5033 type UpdateEverythingRun,
5134} from "./updateEverything.logic";
5235import { useUpdateEverythingRun } from "./updateEverythingRun";
53import { type ReportLocalUpdateError, useUpdateEverything } from "./useUpdateEverything";
54
55type SidebarUpdatePopoverChangeDetails = Parameters<
56 NonNullable<ComponentProps<typeof Popover>["onOpenChange"]>
57>[1];
58type SidebarUpdatePopoverHandle = ReturnType<typeof PopoverCreateHandle>;
59
60export function shouldUseSidebarUpdateReleaseNotesPopover(
61 showUpdateDetails: boolean,
62 state: DesktopUpdateState | null,
63): boolean {
64 return showUpdateDetails && state?.channel === "nightly" && state.releaseNotes.length > 0;
65}
66
67export function handleSidebarUpdateReleaseNotesPopoverOpenChange(
68 _open: boolean,
69 details: Pick<SidebarUpdatePopoverChangeDetails, "reason" | "cancel">,
70): void {
71 // The trigger is the update action, so its presses must not also toggle the Popover.
72 if (details.reason === "trigger-press") details.cancel();
73}
74
75export function openSidebarUpdateReleaseNotesPopoverOnForwardTab(
76 event: { readonly key: string; readonly shiftKey: boolean },
77 handle: Pick<SidebarUpdatePopoverHandle, "open">,
78 triggerId: string,
79): void {
80 if (event.key !== "Tab" || event.shiftKey) return;
81 // Hover-open popovers do not manage focus. Promote this one before native Tab runs.
82 flushSync(() => handle.open(triggerId));
83}
84
85function resolveSidebarUpdatePresentation({
86 action,
87 showsEverything,
88 run,
89 isDownloading,
90 showCheckIcon,
91}: {
92 readonly action: ReturnType<typeof resolveDesktopUpdateButtonAction>;
93 /** Remote work is waiting, and this app is not downloading. */
94 readonly showsEverything: boolean;
95 /** The running "update everything" pass. Its state sets the icon. */
96 readonly run: UpdateEverythingRun | null;
97 readonly isDownloading: boolean;
98 readonly showCheckIcon: boolean;
99}) {
100 const showUpdateDetails = action !== "none" || showsEverything || run !== null || isDownloading;
101 const iconStatus: DesktopUpdateStatusIconState = showCheckIcon
102 ? "checking"
103 : isDownloading
104 ? "downloading"
105 : run !== null
106 ? run.localVersion !== null && action === "install"
107 ? "downloaded"
108 : "everything"
109 : showsEverything
110 ? "everything"
111 : action === "install"
112 ? "downloaded"
113 : action === "download"
114 ? "available"
115 : "idle";
116
117 return {
118 iconStatus,
119 showUpdateDetails,
120 showUpdateIconState: showUpdateDetails && !showCheckIcon,
121 } as const;
122}
123
124export function SidebarUpdateArchitectureWarning() {
125 return isElectron ? <SidebarUpdateArchitectureWarningContent /> : null;
126}
127
128function SidebarUpdateArchitectureWarningContent() {
129 const state = useDesktopUpdateState();
130 const visible = shouldShowArm64IntelBuildWarning(state);
131 const description = state && visible ? getArm64IntelBuildWarningDescription(state) : null;
132
133 if (!visible || !description) return null;
36import { useUpdateEverything } from "./useUpdateEverything";
13437
135 return (
136 <Alert variant="warning">
137 <TriangleAlertIcon />
138 <AlertTitle>Intel build on Apple Silicon</AlertTitle>
139 <AlertDescription>{description}</AlertDescription>
140 </Alert>
141 );
142}
143
144const toastUpdateError: ReportLocalUpdateError = (title, description) => {
145 toastManager.add(stackedThreadToast({ type: "error", title, description }));
146};
147
148/** Downloads this app's update. Resolves true when it is ready to install;
149 failures go to `reportError` (a toast by default) and resolve false. */
150async function downloadLocalUpdate(
151 bridge: DesktopBridge,
152 {
153 notifyDownloaded,
154 reportError = toastUpdateError,
155 }: { readonly notifyDownloaded: boolean; readonly reportError?: ReportLocalUpdateError },
156): Promise<boolean> {
157 try {
158 const result = await bridge.downloadUpdate();
159 if (result.completed && notifyDownloaded) {
160 showDesktopUpdateDownloadedToast(bridge, result.state);
161 }
162 const actionError = shouldToastDesktopUpdateActionResult(result)
163 ? getDesktopUpdateActionError(result)
164 : null;
165 if (actionError) reportError("Could not download update", actionError);
166 return result.completed;
167 } catch (error) {
168 reportError(
169 "Could not start update download",
170 error instanceof Error ? error.message : "An unexpected error occurred.",
171 );
172 return false;
173 }
174}
175
176/** Installs this app's downloaded update, which relaunches the app. Resolves
177 true when the relaunch is under way. Failures go to `reportError` (a toast
178 by default). A refused install (the download is not marked ready yet)
179 leaves the restart to the user. */
180async function installLocalUpdate(
181 bridge: DesktopBridge,
182 reportError: ReportLocalUpdateError = toastUpdateError,
183): Promise<boolean> {
184 try {
185 const result = await bridge.installUpdate();
186 if (!result.accepted) {
187 showDesktopUpdateDownloadedToast(bridge, result.state);
188 return false;
189 }
190 const actionError = shouldToastDesktopUpdateActionResult(result)
191 ? getDesktopUpdateActionError(result)
192 : null;
193 if (!actionError) return true;
194 reportError("Could not install update", actionError);
195 return false;
196 } catch (error) {
197 reportError(
198 "Could not install update",
199 error instanceof Error ? error.message : "An unexpected error occurred.",
200 );
201 return false;
202 }
203}
204
205/** Tooltip lines for a running pass: this app first, then one per machine. */
206function useUpdateRunTooltip(
38function useUpdateRunStatus(
20739 run: UpdateEverythingRun | null,
20840 state: DesktopUpdateState | null,
209 localReady: boolean,
21041): string | null {
21142 const statusesAtom = useMemo(
21243 () =>
......@@ -233,178 +64,49 @@ function useUpdateRunTooltip(
23364 ? `downloading${state.downloadPercent === null ? "" : ` ${Math.floor(state.downloadPercent)}%`}`
23465 : run.phase === "installing"
23566 ? "restarting"
236 : localReady
67 : state && resolveDesktopUpdateButtonAction(state) === "install"
23768 ? "restarts last"
23869 : "waiting";
23970 return describeUpdateRun(statuses, local).join("\n");
24071}
24172
242/**
243 * The sidebar update button. With only a local app update it downloads, then
244 * installs. When other machines' servers or any providers are behind, one
245 * click updates everything: providers, then servers, then this app last. On
246 * web there is no local app, so the button shows only for remote updates.
247 */
248export function SidebarUpdatePill() {
249 return <SidebarUpdateControl />;
250}
251
252function SidebarUpdateControl() {
73export function AppMenuUpdates() {
25374 const state = useDesktopUpdateState();
25475 const [isActionPending, setIsActionPending] = useState(false);
25576 const [isConfirmingEverything, setIsConfirmingEverything] = useState(false);
256 const [checkAnimationKey, setCheckAnimationKey] = useState(0);
257 const [isCheckAnimationLatched, setIsCheckAnimationLatched] = useState(false);
258 const [releaseNotesPopoverHandle] = useState(() => PopoverCreateHandle());
259 const suppressReleaseNotesFocusOpen = useRef(false);
260 const releaseNotesPopupRef = useRef<HTMLDivElement>(null);
261 const releaseNotesTriggerId = useId();
77 const pending = useRef(false);
26278 const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
263
264 useEffect(() => {
265 if (prefersReducedMotion) {
266 setIsCheckAnimationLatched(false);
267 } else if (state?.status === "checking") {
268 setIsCheckAnimationLatched(true);
269 }
270 }, [prefersReducedMotion, state?.status]);
271
27279 const action = state ? resolveDesktopUpdateButtonAction(state) : "none";
273 const isDownloading = state?.status === "downloading";
274 // Remote servers follow this app to the version it is about to install.
27580 const localUpdateVersion =
27681 state && action !== "none" ? getDesktopUpdateDownloadedVersion(state) : null;
277 const everything = useUpdateEverything(localUpdateVersion);
278 const runsEverything = everything.hasUpdates && !isDownloading;
279 // Providers leave the list once queued, so the run keeps its own state.
82 const restartsApp = state?.installKind !== "ui";
83 const everything = useUpdateEverything(restartsApp ? localUpdateVersion : null);
28084 const run = useUpdateEverythingRun();
281 const isRunningEverything = run !== null;
282 const runTooltip = useUpdateRunTooltip(run, state, action === "install");
283 const showCheckIcon = shouldShowDesktopUpdateCheckIcon({
284 isAnimationLatched: isCheckAnimationLatched,
285 isChecking: state?.status === "checking",
286 prefersReducedMotion,
287 });
288 const { iconStatus, showUpdateDetails, showUpdateIconState } = resolveSidebarUpdatePresentation({
289 action,
290 showsEverything: runsEverything,
291 run,
292 isDownloading,
293 showCheckIcon,
294 });
295 const tooltip =
296 runTooltip !== null
297 ? runTooltip
298 : runsEverything
299 ? everything.summary
300 : showUpdateDetails
301 ? state
302 ? getDesktopUpdateButtonTooltip(state)
303 : "Update available"
304 : showCheckIcon
305 ? "Checking for updates…"
306 : "Check for updates";
307 const disabled = showCheckIcon
308 ? true
309 : runsEverything
310 ? false
311 : showUpdateDetails
312 ? isDesktopUpdateButtonDisabled(state)
313 : !canCheckForUpdate(state);
314 const isInteractionDisabled = disabled || isActionPending || isRunningEverything;
315 // During a run the tooltip shows its progress instead of release notes.
316 const showReleaseNotesPopover =
317 !isRunningEverything && shouldUseSidebarUpdateReleaseNotesPopover(showUpdateDetails, state);
318
319 useEffect(() => {
320 if (!showReleaseNotesPopover) {
321 releaseNotesPopoverHandle.close();
322 return;
323 }
324
325 const trigger = document.getElementById(releaseNotesTriggerId);
326 if (trigger?.matches(":focus-visible")) {
327 releaseNotesPopoverHandle.open(releaseNotesTriggerId);
328 }
329 }, [releaseNotesPopoverHandle, releaseNotesTriggerId, showReleaseNotesPopover]);
330
331 const handleAction = useCallback(async () => {
332 const bridge = window.desktopBridge;
333 if (isInteractionDisabled) return;
334
335 if (runsEverything) {
336 setIsConfirmingEverything(true);
337 return;
338 }
339
340 if (!bridge || !state) return;
341 setIsActionPending(true);
342
343 if (action === "download") {
344 void downloadLocalUpdate(bridge, { notifyDownloaded: true }).finally(() =>
345 setIsActionPending(false),
346 );
347 return;
348 }
349
350 if (action === "install") {
351 let confirmed = false;
352 try {
353 confirmed = await ensureLocalApi().dialogs.confirm(
354 getDesktopUpdateInstallConfirmationMessage(state),
355 );
356 } catch (error) {
357 setIsActionPending(false);
358 toastManager.add(
359 stackedThreadToast({
360 type: "error",
361 title: "Could not confirm update",
362 description: error instanceof Error ? error.message : "Update confirmation failed.",
363 }),
364 );
365 return;
366 }
367 if (!confirmed) {
368 setIsActionPending(false);
369 return;
370 }
371 void installLocalUpdate(bridge).finally(() => setIsActionPending(false));
372 return;
373 }
374
375 if (!prefersReducedMotion) {
376 setIsCheckAnimationLatched(true);
377 setCheckAnimationKey((key) => key + 1);
378 }
379 void bridge
380 .checkForUpdate()
381 .then((result) => {
382 if (result.checked) return;
383 toastManager.add(
384 stackedThreadToast({
385 type: "error",
386 title: "Could not check for updates",
387 description:
388 result.state.message ?? "Automatic updates are not available in this build.",
389 }),
390 );
391 })
392 .catch((error) => {
393 toastManager.add(
394 stackedThreadToast({
395 type: "error",
396 title: "Could not check for updates",
397 description: error instanceof Error ? error.message : "Update check failed.",
398 }),
399 );
400 })
401 .finally(() => setIsActionPending(false));
402 }, [action, isInteractionDisabled, prefersReducedMotion, runsEverything, state]);
85 const runStatus = useUpdateRunStatus(run, state);
86 const isDownloading = state?.status === "downloading";
87 const isBusy = isActionPending || run !== null;
88 const hasUpdates = localUpdateVersion !== null || everything.hasUpdates;
89 const appActionLabel =
90 state?.status === "checking"
91 ? "Checking…"
92 : isDownloading
93 ? "Downloading…"
94 : action === "download"
95 ? state?.status === "error"
96 ? "Retry download"
97 : "Download"
98 : action === "install"
99 ? restartsApp
100 ? "Restart"
101 : "Reload"
102 : "Check";
403103
404 const runEverything = useCallback(async () => {
405 const bridge = window.desktopBridge;
104 const runEverything = async () => {
105 if (pending.current || run !== null) return;
106 pending.current = true;
406107 setIsConfirmingEverything(false);
407108 setIsActionPending(true);
109 const bridge = window.desktopBridge;
408110 try {
409111 await everything.run({
410112 ...(bridge && action === "download"
......@@ -413,152 +115,152 @@ function SidebarUpdateControl() {
413115 downloadLocalUpdate(bridge, { notifyDownloaded: false, reportError }),
414116 }
415117 : {}),
416 ...(bridge && action !== "none"
417 ? { installLocal: (reportError) => installLocalUpdate(bridge, reportError) }
118 ...(bridge && restartsApp && action !== "none"
119 ? {
120 installLocal: (reportError) => installLocalUpdate(bridge, reportError),
121 }
418122 : {}),
419123 });
124 if (bridge && !restartsApp && action === "install") await installLocalUpdate(bridge);
420125 } catch (error) {
421 toastManager.add(
422 stackedThreadToast({
423 type: "error",
424 title: "Could not update everything",
425 description: error instanceof Error ? error.message : "An unexpected error occurred.",
426 }),
126 toastUpdateError(
127 "Could not update everything",
128 error instanceof Error ? error.message : "Try again.",
427129 );
428130 } finally {
131 pending.current = false;
429132 setIsActionPending(false);
430133 }
431 }, [action, everything]);
432
433 const handleCheckAnimationIteration = useCallback(() => {
434 setIsCheckAnimationLatched(
435 shouldContinueDesktopUpdateCheckAnimation({
436 isChecking: state?.status === "checking",
437 prefersReducedMotion,
438 }),
439 );
440 }, [prefersReducedMotion, state?.status]);
134 };
441135
442 // Web has no local app to check, so it only shows the button for remote work.
443 if (!isElectron && !runsEverything && !isRunningEverything) return null;
136 const handleAppAction = async () => {
137 const bridge = window.desktopBridge;
138 if (!bridge || !state || pending.current || isBusy) return;
139 pending.current = true;
140 setIsActionPending(true);
141 try {
142 await performDesktopUpdate(bridge, state);
143 } finally {
144 pending.current = false;
145 setIsActionPending(false);
146 }
147 };
444148
445 const updateButton = (
446 <button
447 type="button"
448 aria-label={tooltip}
449 aria-disabled={isInteractionDisabled || undefined}
450 className={cn(
451 "inline-flex size-8 items-center justify-center rounded-full outline-hidden ring-ring transition-colors focus-visible:ring-2 focus-visible:ring-inset",
452 isInteractionDisabled ? "cursor-not-allowed" : "cursor-pointer",
453 showUpdateIconState
454 ? cn(
455 "bg-sidebar-control-surface text-sidebar-foreground",
456 !isInteractionDisabled && "hover:bg-sidebar-row-hover",
457 )
458 : cn(
459 "text-(--sidebar-icon-color)",
460 !isInteractionDisabled && "hover:bg-sidebar-row-hover hover:text-sidebar-foreground",
461 ),
462 disabled && !showUpdateIconState && "opacity-60",
463 )}
464 onClick={handleAction}
465 onBlur={() => {
466 suppressReleaseNotesFocusOpen.current = false;
467 }}
468 onFocus={(event) => {
469 if (!showReleaseNotesPopover || !event.currentTarget.matches(":focus-visible")) return;
470 if (suppressReleaseNotesFocusOpen.current) {
471 suppressReleaseNotesFocusOpen.current = false;
472 return;
473 }
474 flushSync(() => releaseNotesPopoverHandle.open(releaseNotesTriggerId));
475 }}
476 onKeyDown={(event) => {
477 if (!showReleaseNotesPopover) return;
478 openSidebarUpdateReleaseNotesPopoverOnForwardTab(
479 event,
480 releaseNotesPopoverHandle,
481 releaseNotesTriggerId,
482 );
483 }}
484 >
485 <DesktopUpdateStatusIcon
486 key={showCheckIcon ? checkAnimationKey : iconStatus}
487 downloadPercent={state?.downloadPercent ?? null}
488 isCheckAnimating={showCheckIcon && !prefersReducedMotion}
489 onCheckAnimationIteration={handleCheckAnimationIteration}
490 status={iconStatus}
491 />
492 </button>
493 );
149 const showArchitectureWarning = shouldShowArm64IntelBuildWarning(state);
150 const appStatus =
151 state?.status === "checking"
152 ? "Checking for updates…"
153 : state
154 ? getDesktopUpdateButtonTooltip(state)
155 : "Loading update status…";
156 const iconStatus =
157 state?.status === "checking"
158 ? "checking"
159 : isDownloading
160 ? "downloading"
161 : action === "install"
162 ? "downloaded"
163 : action === "download"
164 ? "available"
165 : "idle";
494166
495167 return (
496 <SidebarMenuItem className="ml-auto shrink-0">
497 <Popover
498 handle={releaseNotesPopoverHandle}
499 onOpenChange={(open, details) => {
500 if (open && !showReleaseNotesPopover) {
501 details.cancel();
502 return;
503 }
504 handleSidebarUpdateReleaseNotesPopoverOpenChange(open, details);
505 }}
506 >
507 <Tooltip disabled={showReleaseNotesPopover}>
508 <TooltipTrigger
509 id={releaseNotesTriggerId}
510 render={
511 <PopoverTrigger
512 {...(!showReleaseNotesPopover
513 ? {
514 "aria-controls": undefined,
515 "aria-expanded": undefined,
516 "aria-haspopup": undefined,
517 }
518 : {})}
519 closeDelay={150}
520 handle={releaseNotesPopoverHandle}
521 id={releaseNotesTriggerId}
522 openOnHover={showReleaseNotesPopover}
523 render={updateButton}
524 />
525 }
526 />
527 {!showReleaseNotesPopover ? (
528 <TooltipPopup
529 align="center"
530 side="top"
531 variant={showUpdateDetails ? "glass" : "default"}
532 >
533 <span className="whitespace-pre-line">{tooltip}</span>
534 </TooltipPopup>
535 ) : null}
536 </Tooltip>
537 {showReleaseNotesPopover && state ? (
538 <PopoverPopup
539 align="center"
540 aria-label="Nightly update release notes"
541 initialFocus={false}
542 onKeyDownCapture={(event) => {
543 if (
544 event.key === "Escape" &&
545 releaseNotesPopupRef.current?.contains(document.activeElement)
546 ) {
547 suppressReleaseNotesFocusOpen.current = true;
168 <section aria-label="Updates" className="space-y-1">
169 <div className="flex min-h-7 items-center justify-between gap-2 px-1.5">
170 <h3 className="text-xs font-medium text-muted-foreground">Updates</h3>
171 {hasUpdates || run !== null ? (
172 <Button
173 size="xs"
174 variant="outline"
175 disabled={isBusy || isDownloading}
176 onClick={() => {
177 if (everything.restarts.length > 0 || (restartsApp && localUpdateVersion !== null)) {
178 setIsConfirmingEverything(true);
179 } else {
180 void runEverything();
548181 }
549182 }}
550 ref={releaseNotesPopupRef}
551 side="top"
552 tooltipStyle
553183 >
554 <SidebarUpdateReleaseNotes
555 shell={window.desktopBridge}
556 state={state}
557 tooltip={tooltip}
558 />
559 </PopoverPopup>
184 {run !== null ? "Updating…" : "Update all"}
185 </Button>
560186 ) : null}
561 </Popover>
187 </div>
188 {isElectron ? (
189 <div className="flex min-h-9 items-center gap-2 px-1.5">
190 {/* Icon column width; the download ring overflows it evenly. */}
191 <span aria-hidden="true" className="flex size-4 shrink-0 items-center justify-center">
192 <DesktopUpdateStatusIcon
193 status={iconStatus}
194 downloadPercent={state?.downloadPercent ?? null}
195 isCheckAnimating={!prefersReducedMotion}
196 />
197 </span>
198 <div className="min-w-0 flex-1">
199 <div className="text-xs font-medium">App</div>
200 <p role="status" className="text-2xs leading-4 text-muted-foreground">
201 {appStatus}
202 </p>
203 </div>
204 <Button
205 size="xs"
206 variant="outline"
207 disabled={
208 isBusy ||
209 (action === "none" ? !canCheckForUpdate(state) : isDesktopUpdateButtonDisabled(state))
210 }
211 aria-label={`${appActionLabel} app update`}
212 onClick={() => void handleAppAction()}
213 >
214 {appActionLabel}
215 </Button>
216 </div>
217 ) : null}
218 {showArchitectureWarning && state ? (
219 <p className="flex gap-2 px-1.5 text-xs text-warning-foreground">
220 <TriangleAlertIcon aria-hidden="true" className="size-4 shrink-0" />
221 {getArm64IntelBuildWarningDescription(state)}
222 </p>
223 ) : null}
224 {state?.channel === "nightly" && state.releaseNotes.length > 0 ? (
225 <details className="px-1.5 text-xs">
226 <summary className="cursor-pointer rounded-sm py-1 text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring">
227 Release notes
228 </summary>
229 <SidebarUpdateReleaseNotes
230 shell={window.desktopBridge}
231 state={state}
232 tooltip={appStatus}
233 />
234 </details>
235 ) : null}
236 {everything.servers.map((server) => (
237 <div
238 key={server.environmentId}
239 className="flex items-center justify-between gap-2 px-1.5 py-1 text-xs"
240 >
241 <span>{server.serverLabel}</span>
242 <span className="text-2xs text-muted-foreground">Server {server.targetVersion}</span>
243 </div>
244 ))}
245 {runStatus ? (
246 <p role="status" className="whitespace-pre-line px-1.5 text-xs text-muted-foreground">
247 {runStatus}
248 </p>
249 ) : null}
250 {everything.skipped.length > 0 ? (
251 <details className="px-1.5 text-xs text-muted-foreground">
252 <summary className="cursor-pointer rounded-sm py-1 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring">
253 Unavailable machines ({everything.skipped.length})
254 </summary>
255 <ul className="space-y-1 py-1">
256 {everything.skipped.map((machine) => (
257 <li key={machine.environmentId}>
258 {machine.label}: {machine.reason}
259 </li>
260 ))}
261 </ul>
262 </details>
263 ) : null}
562264 <UpdateEverythingDialog
563265 open={isConfirmingEverything}
564266 onOpenChange={setIsConfirmingEverything}
......@@ -568,6 +270,6 @@ function SidebarUpdateControl() {
568270 skipped={everything.skipped}
569271 onConfirm={() => void runEverything()}
570272 />
571 </SidebarMenuItem>
273 </section>
572274 );
573275}
apps/web/src/components/sidebar/updateEverythingRun.tsx+2-2
......@@ -15,7 +15,7 @@ import {
1515 type UpdateEverythingRun,
1616} from "./updateEverything.logic";
1717
18/** The running "update everything" pass, or null. Outlives the sidebar button. */
18/** The running "update everything" pass, or null. Outlives the app menu. */
1919const updateEverythingRunAtom = Atom.make<UpdateEverythingRun | null>(null).pipe(
2020 Atom.keepAlive,
2121 Atom.withLabel("update-everything-run"),
......@@ -133,7 +133,7 @@ export function DesktopUpdateLaunchNotice() {
133133 stackedThreadToast({
134134 type: "error",
135135 title: "Update did not apply",
136 description: `The Knife is still on ${APP_VERSION}. Install ${notAppliedVersion} again from the update button.`,
136 description: `The Knife is still on ${APP_VERSION}. Install ${notAppliedVersion} again from the app menu.`,
137137 }),
138138 );
139139 }, [notAppliedVersion]);
apps/web/src/components/sidebar/useUpdateEverything.ts+2-1
......@@ -34,7 +34,7 @@ import {
3434export type ReportLocalUpdateError = (title: string, description: string) => void;
3535
3636/**
37 * The remote half of the sidebar update button: outdated providers on every
37 * The remote half of the app menu updater: outdated providers on every
3838 * connected machine, and servers behind the version this client runs or is
3939 * about to install (`localUpdateVersion`). Servers that install from npm appear
4040 * only once npm confirms that version. `run` performs the whole pass, including
......@@ -104,6 +104,7 @@ export function useUpdateEverything(localUpdateVersion: string | null) {
104104 });
105105
106106 return {
107 servers,
107108 hasUpdates: servers.length > 0 || providers.machines.length > 0,
108109 /** Short label for the tooltip and the confirmation title. */
109110 summary,
apps/web/src/components/threadSidebarWidth.test.ts+7-19
......@@ -2,35 +2,23 @@ import { describe, expect, it } from "vite-plus/test";
22import {
33 clampThreadSidebarWidth,
44 resolveThreadSidebarMaximumWidth,
5 resolveThreadSidebarMinimumWidth,
65 THREAD_SIDEBAR_MIN_WIDTH,
76} from "./threadSidebarWidth";
87
9describe("resolveThreadSidebarMinimumWidth", () => {
10 it("keeps the default minimum when the brand fits", () => {
11 expect(resolveThreadSidebarMinimumWidth(0)).toBe(THREAD_SIDEBAR_MIN_WIDTH);
12 expect(resolveThreadSidebarMinimumWidth(194)).toBe(THREAD_SIDEBAR_MIN_WIDTH);
13 });
14
15 it("grows to a brand wider than the default, rounding up", () => {
16 expect(resolveThreadSidebarMinimumWidth(237.2)).toBe(238);
17 });
18});
19
208describe("resolveThreadSidebarMaximumWidth", () => {
21 it("never drops below a raised minimum on a narrow viewport", () => {
22 expect(resolveThreadSidebarMaximumWidth(800, 238)).toBe(238);
23 expect(resolveThreadSidebarMaximumWidth(1200, 238)).toBe(560);
9 it("keeps the sidebar usable on a narrow viewport", () => {
10 expect(resolveThreadSidebarMaximumWidth(800)).toBe(THREAD_SIDEBAR_MIN_WIDTH);
11 expect(resolveThreadSidebarMaximumWidth(1200)).toBe(560);
2412 });
2513});
2614
2715describe("clampThreadSidebarWidth", () => {
28 it("widens a stored width below a raised minimum", () => {
29 expect(clampThreadSidebarWidth(208, 238, 560)).toBe(238);
16 it("widens a stored width below the minimum", () => {
17 expect(clampThreadSidebarWidth(180, 560)).toBe(THREAD_SIDEBAR_MIN_WIDTH);
3018 });
3119
3220 it("keeps widths inside the range and caps wide ones", () => {
33 expect(clampThreadSidebarWidth(300, 238, 560)).toBe(300);
34 expect(clampThreadSidebarWidth(900, 238, 560)).toBe(560);
21 expect(clampThreadSidebarWidth(300, 560)).toBe(300);
22 expect(clampThreadSidebarWidth(900, 560)).toBe(560);
3523 });
3624});
apps/web/src/components/threadSidebarWidth.ts+7-16
......@@ -3,24 +3,15 @@ const THREAD_SIDEBAR_DEFAULT_WIDTH = 16 * 16;
33export const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16;
44export const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16;
55
6// The brand's measured width can raise the minimum so "T3 Code" never clips.
7export function resolveThreadSidebarMinimumWidth(brandWidth: number): number {
8 return Math.max(THREAD_SIDEBAR_MIN_WIDTH, Math.ceil(brandWidth));
6export function resolveThreadSidebarMaximumWidth(viewportWidth: number): number {
7 return Math.max(
8 THREAD_SIDEBAR_MIN_WIDTH,
9 Math.floor(viewportWidth) - THREAD_MAIN_CONTENT_MIN_WIDTH,
10 );
911}
1012
11export function resolveThreadSidebarMaximumWidth(
12 viewportWidth: number,
13 minimumWidth = THREAD_SIDEBAR_MIN_WIDTH,
14): number {
15 return Math.max(minimumWidth, Math.floor(viewportWidth) - THREAD_MAIN_CONTENT_MIN_WIDTH);
16}
17
18export function clampThreadSidebarWidth(
19 width: number,
20 minimumWidth: number,
21 maximumWidth: number,
22): number {
23 return Math.min(maximumWidth, Math.max(minimumWidth, width));
13export function clampThreadSidebarWidth(width: number, maximumWidth: number): number {
14 return Math.min(maximumWidth, Math.max(THREAD_SIDEBAR_MIN_WIDTH, width));
2415}
2516
2617export function resolveInitialThreadSidebarWidth(
apps/web/src/components/ui/alert-dialog.focus.test.tsx created+88
......@@ -0,0 +1,88 @@
1// @vitest-environment jsdom
2
3import { act, createRef, useState } from "react";
4import { createRoot, type Root } from "react-dom/client";
5import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
6
7import {
8 AlertDialog,
9 AlertDialogClose,
10 AlertDialogFooter,
11 AlertDialogPopup,
12 AlertDialogTitle,
13} from "./alert-dialog";
14import { Button } from "./button";
15
16let root: Root;
17let container: HTMLDivElement;
18const cancel = createRef<HTMLButtonElement>();
19
20beforeEach(() => {
21 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
22 container = document.createElement("div");
23 document.body.append(container);
24 root = createRoot(container);
25});
26
27afterEach(async () => {
28 await act(async () => root.unmount());
29 container.remove();
30 vi.unstubAllGlobals();
31});
32
33function Confirmation({ disabled = false, explicitFocus = false }) {
34 const [open, setOpen] = useState(false);
35 return (
36 <>
37 <button onClick={() => setOpen(true)}>Open danger dialog</button>
38 <AlertDialog open={open} onOpenChange={setOpen}>
39 <AlertDialogPopup {...(explicitFocus ? { initialFocus: cancel } : {})}>
40 <AlertDialogTitle>Remove fixture?</AlertDialogTitle>
41 <AlertDialogFooter>
42 <AlertDialogClose ref={cancel} render={<Button variant="outline" />}>
43 Cancel
44 </AlertDialogClose>
45 <Button variant="destructive" disabled={disabled} onClick={() => setOpen(false)}>
46 Remove
47 </Button>
48 </AlertDialogFooter>
49 </AlertDialogPopup>
50 </AlertDialog>
51 </>
52 );
53}
54
55async function open() {
56 const trigger = container.querySelector("button")!;
57 await act(async () => {
58 trigger.focus();
59 trigger.click();
60 });
61}
62
63it("focuses confirmation on every opening and lets Escape cancel", async () => {
64 await act(async () => root.render(<Confirmation />));
65 await open();
66 await vi.waitFor(() => expect(document.activeElement?.textContent).toBe("Remove"));
67 await act(async () => {
68 document.activeElement?.dispatchEvent(
69 new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
70 );
71 });
72 await vi.waitFor(() => expect(document.querySelector('[role="alertdialog"]')).toBeNull());
73 expect(document.activeElement?.textContent).toBe("Open danger dialog");
74 await open();
75 await vi.waitFor(() => expect(document.activeElement?.textContent).toBe("Remove"));
76});
77
78it("keeps disabled confirmation out of the default keyboard action", async () => {
79 await act(async () => root.render(<Confirmation disabled />));
80 await open();
81 await vi.waitFor(() => expect(document.activeElement?.textContent).toBe("Cancel"));
82});
83
84it("preserves an explicit initial focus target", async () => {
85 await act(async () => root.render(<Confirmation explicitFocus />));
86 await open();
87 await vi.waitFor(() => expect(document.activeElement?.textContent).toBe("Cancel"));
88});
apps/web/src/components/ui/alert-dialog.tsx+19
......@@ -1,6 +1,7 @@
11"use client";
22
33import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog";
4import { useRef } from "react";
45
56import { cn } from "~/lib/utils";
67import {
......@@ -41,11 +42,14 @@ function AlertDialogPopup({
4142 className,
4243 bottomStickOnMobile = true,
4344 portalContainer,
45 initialFocus,
46 ref,
4447 ...props
4548}: AlertDialogPrimitive.Popup.Props & {
4649 bottomStickOnMobile?: boolean;
4750 portalContainer?: AlertDialogPrimitive.Portal.Props["container"];
4851}) {
52 const popup = useRef<HTMLDivElement | null>(null);
4953 return (
5054 <AlertDialogPortal container={portalContainer}>
5155 <AlertDialogBackdrop />
......@@ -53,6 +57,21 @@ function AlertDialogPopup({
5357 className={cn(bottomStickOnMobile && "max-sm:grid-rows-[1fr_auto] max-sm:p-0 max-sm:pt-12")}
5458 >
5559 <AlertDialogPrimitive.Popup
60 ref={(element) => {
61 popup.current = element;
62 if (typeof ref === "function") return ref(element);
63 if (ref) ref.current = element;
64 }}
65 initialFocus={
66 initialFocus ??
67 (() => {
68 return (
69 popup.current?.querySelector<HTMLButtonElement>(
70 '[data-slot="alert-dialog-footer"] button:not([data-slot="alert-dialog-close"]):not(:disabled):not([aria-disabled="true"])',
71 ) ?? true
72 );
73 })
74 }
5675 className={cn(
5776 DIALOG_POPUP_CLASS,
5877 "row-start-2 max-h-full max-w-lg text-popover-foreground",
apps/web/src/hooks/useNewProject.ts deleted-151
......@@ -1,151 +0,0 @@
1import { scopeProjectRef } from "@t3tools/client-runtime/environment";
2import {
3 getNewProjectRepository,
4 type NewProjectPublishTarget,
5} from "@t3tools/client-runtime/operations/projects";
6import {
7 isAtomCommandInterrupted,
8 squashAtomCommandFailure,
9} from "@t3tools/client-runtime/state/runtime";
10import type { EnvironmentId } from "@t3tools/contracts";
11import { useCallback } from "react";
12
13import { stackedThreadToast, toastManager } from "~/components/ui/toast";
14import { waitForProject } from "~/state/entities";
15import { projectEnvironment } from "~/state/projects";
16import { sourceControlEnvironment } from "~/state/sourceControl";
17import { useAtomCommand } from "~/state/use-atom-command";
18import { useNewThreadHandler } from "./useHandleNewThread";
19
20function errorMessage(error: unknown): string {
21 return error instanceof Error && error.message.trim().length > 0
22 ? error.message
23 : "An error occurred.";
24}
25
26/**
27 * Starts a project from just a name. The server makes a folder under its
28 * `newProjectsRoot` with a README, an icon, and a first commit; this then
29 * opens a new thread draft in it. With `publishTo`, it also publishes the
30 * repository as private on that host, without holding up the draft.
31 *
32 * Resolves to whether the project was created.
33 */
34export function useNewProject() {
35 const createNew = useAtomCommand(projectEnvironment.createNew, { reportFailure: false });
36 const publishRepository = useAtomCommand(sourceControlEnvironment.publishRepository, {
37 reportFailure: false,
38 });
39 const handleNewThread = useNewThreadHandler();
40
41 const publishNewRepository = useCallback(
42 async (input: {
43 readonly environmentId: EnvironmentId;
44 readonly workspaceRoot: string;
45 readonly target: NewProjectPublishTarget;
46 }) => {
47 const { label } = input.target.definition;
48 const result = await publishRepository({
49 environmentId: input.environmentId,
50 input: {
51 cwd: input.workspaceRoot,
52 provider: input.target.definition.kind,
53 repository: getNewProjectRepository(input.target, input.workspaceRoot),
54 visibility: "private",
55 },
56 });
57 if (result._tag === "Failure") {
58 if (!isAtomCommandInterrupted(result)) {
59 toastManager.add(
60 stackedThreadToast({
61 type: "error",
62 title: `Could not create the ${label} repository`,
63 description: `${errorMessage(squashAtomCommandFailure(result))} Use Publish Repository in the Git menu to try again.`,
64 }),
65 );
66 }
67 return;
68 }
69 toastManager.add(
70 stackedThreadToast({
71 type: "success",
72 title: `Published to ${label}`,
73 description: result.value.repository.nameWithOwner,
74 }),
75 );
76 },
77 [publishRepository],
78 );
79
80 return useCallback(
81 async (input: {
82 readonly environmentId: EnvironmentId;
83 readonly name: string;
84 readonly publishTo: NewProjectPublishTarget | null;
85 }): Promise<boolean> => {
86 const result = await createNew({
87 environmentId: input.environmentId,
88 input: { name: input.name },
89 });
90 if (result._tag === "Failure") {
91 if (!isAtomCommandInterrupted(result)) {
92 toastManager.add(
93 stackedThreadToast({
94 type: "error",
95 title: "Could not create the project",
96 description: errorMessage(squashAtomCommandFailure(result)),
97 }),
98 );
99 }
100 return false;
101 }
102
103 const { projectId, workspaceRoot, commitError } = result.value;
104 // The folder sits in T3 Code's data directory, so always say where.
105 toastManager.add(
106 stackedThreadToast(
107 commitError === undefined
108 ? { type: "success", title: `Created ${input.name}`, description: workspaceRoot }
109 : {
110 type: "warning",
111 title: `Created ${input.name} without a first commit`,
112 description: `${commitError} The project is in ${workspaceRoot}.`,
113 },
114 ),
115 );
116 if (input.publishTo) {
117 void publishNewRepository({
118 environmentId: input.environmentId,
119 workspaceRoot,
120 target: input.publishTo,
121 });
122 }
123
124 const projectRef = scopeProjectRef(input.environmentId, projectId);
125 // Drafts key off the project's stored path, so wait for the create event
126 // to reach the store before opening one.
127 const project = await waitForProject(projectRef).catch((error: unknown) => {
128 toastManager.add(
129 stackedThreadToast({
130 type: "error",
131 title: "Failed to open project",
132 description: `${errorMessage(error)} It will appear in the sidebar once this client catches up.`,
133 }),
134 );
135 return null;
136 });
137 if (project === null) return true;
138 await handleNewThread(projectRef).catch((error: unknown) => {
139 toastManager.add(
140 stackedThreadToast({
141 type: "error",
142 title: "Failed to open project",
143 description: errorMessage(error),
144 }),
145 );
146 });
147 return true;
148 },
149 [createNew, handleNewThread, publishNewRepository],
150 );
151}
apps/web/src/index.css+35-33
......@@ -1,5 +1,37 @@
11@import "tailwindcss";
22
3@font-face {
4 font-family: "AT Name Sans";
5 src: url("/fonts/private/ATNameSans-Regular.woff2") format("woff2");
6 font-weight: 1 1000;
7 font-style: normal;
8 font-display: swap;
9}
10
11@font-face {
12 font-family: "AT Name Sans";
13 src: url("/fonts/private/ATNameSans-Italic.woff2") format("woff2");
14 font-weight: 1 1000;
15 font-style: italic;
16 font-display: swap;
17}
18
19@font-face {
20 font-family: "AT Name Mono";
21 src: url("/fonts/private/ATNameMono-Regular.woff2") format("woff2");
22 font-weight: 400 900;
23 font-style: normal;
24 font-display: swap;
25}
26
27@font-face {
28 font-family: "AT Name Mono";
29 src: url("/fonts/private/ATNameMono-Italic.woff2") format("woff2");
30 font-weight: 400 900;
31 font-style: italic;
32 font-display: swap;
33}
34
335@custom-variant dark (&:is(.dark, .dark *));
436@custom-variant light (&:not(.dark, .dark *));
537/* Window Controls Overlay: active when Electron exposes native titlebar control geometry. */
......@@ -87,6 +119,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
87119}
88120
89121:root {
122 --clo-term-font: "AT Name Mono";
90123 --app-scrollbar-width: 6px;
91124 --appearance-contrast-base: 100%;
92125 --appearance-contrast-boost: 0%;
......@@ -164,9 +197,9 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
164197 the variables and Settings -> Appearance can override them at runtime. The
165198 default stacks are mirrored in `appearanceFonts.ts`. */
166199@theme {
167 --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
200 --font-sans: "AT Name Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
168201 --font-mono:
169 ui-monospace, "SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace;
202 "AT Name Mono", "SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace;
170203}
171204
172205@theme inline {
......@@ -493,31 +526,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
493526 var(--app-scrollbar-width) 100%;
494527}
495528
496/* Stage-channel art needs a mask and pseudo-element gradient, so keep the
497 behavior composable without tying it to the global components layer. */
498@utility sidebar-stage-backdrop {
499 --stage-fade: var(--sidebar-stage-fade, var(--app-chrome-background));
500 mask-image: linear-gradient(to bottom, black 0%, black 55%, transparent 92%);
501 -webkit-mask-image: linear-gradient(to bottom, black 0%, black 55%, transparent 92%);
502
503 &::after {
504 content: "";
505 position: absolute;
506 inset: 0;
507 background: linear-gradient(
508 to bottom,
509 transparent 0%,
510 transparent 28%,
511 color-mix(in srgb, var(--stage-fade) 10%, transparent) 40%,
512 color-mix(in srgb, var(--stage-fade) 30%, transparent) 52%,
513 color-mix(in srgb, var(--stage-fade) 58%, transparent) 64%,
514 color-mix(in srgb, var(--stage-fade) 82%, transparent) 75%,
515 color-mix(in srgb, var(--stage-fade) 96%, transparent) 85%,
516 var(--stage-fade) 93%
517 );
518 }
519}
520
521529@keyframes live-tool-shine {
522530 from {
523531 background-position:
......@@ -1146,7 +1154,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
11461154 --sidebar-row-active: var(--color-white);
11471155 --sidebar-row-selected: var(--color-white);
11481156 --sidebar-border: var(--border);
1149 --sidebar-stage-fade: var(--sidebar);
11501157 --code-background: color-mix(in srgb, var(--card) 90%, var(--background));
11511158 --code-foreground: var(--foreground);
11521159 --terminal-background: var(--background);
......@@ -1189,7 +1196,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
11891196 --sidebar-row-hover: var(--accent);
11901197 --sidebar-row-active: var(--accent);
11911198 --sidebar-row-selected: var(--muted);
1192 --sidebar-stage-fade: var(--card);
11931199 --terminal-cursor: rgb(180 203 255);
11941200 --terminal-selection-background: rgb(180 203 255 / 25%);
11951201 }
......@@ -1217,7 +1223,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
12171223 --sidebar-row-active: var(--color-white);
12181224 --sidebar-row-selected: var(--color-white);
12191225 --sidebar-border: var(--color-zinc-200);
1220 --sidebar-stage-fade: var(--sidebar);
12211226
12221227 @variant dark {
12231228 --background: #000;
......@@ -1238,7 +1243,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
12381243 --sidebar-row-active: color-mix(in srgb, var(--contrast-foreground) 11%, transparent);
12391244 --sidebar-row-selected: color-mix(in srgb, var(--contrast-foreground) 7%, transparent);
12401245 --sidebar-border: var(--border);
1241 --sidebar-stage-fade: var(--card);
12421246 }
12431247}
12441248
......@@ -1307,7 +1311,6 @@ html[data-theme-id]:not([data-theme-id=""]) {
13071311 --sidebar-row-active: var(--app-theme-sidebar-row-active);
13081312 --sidebar-row-selected: var(--app-theme-sidebar-row-selected);
13091313 --sidebar-border: var(--app-theme-sidebar-border);
1310 --sidebar-stage-fade: var(--app-theme-sidebar);
13111314 --code-background: var(--app-theme-code-background);
13121315 --code-foreground: var(--app-theme-code-foreground);
13131316 --terminal-background: var(--app-theme-terminal-background);
......@@ -1507,7 +1510,6 @@ html[data-theme-id] [data-app-sidebar] {
15071510 --sidebar-row-active: var(--app-theme-sidebar-row-active);
15081511 --sidebar-row-selected: var(--app-theme-sidebar-row-selected);
15091512 --sidebar-border: var(--app-theme-sidebar-border);
1510 --sidebar-stage-fade: var(--app-theme-sidebar);
15111513 border-color: color-mix(in srgb, var(--contrast-sidebar-foreground) 10%, transparent);
15121514
15131515 @variant dark {
apps/web/src/rightPanelStore.test.ts+6-6
......@@ -565,7 +565,7 @@ describe("rightPanelStore", () => {
565565 ).toEqual({
566566 byThreadKey: {},
567567 threadPanelVisibilityByThreadKey: {
568 "env-1:thread-A": { inlineOpen: false, popoverOpen: false },
568 "env-1:thread-B": { inlineOpen: true, popoverOpen: false },
569569 },
570570 });
571571 });
......@@ -574,13 +574,13 @@ describe("rightPanelStore", () => {
574574 const store = useRightPanelStore.getState();
575575
576576 expect(selectThreadPanelOpen(store.threadPanelVisibilityByThreadKey, refA, "inline")).toBe(
577 true,
577 false,
578578 );
579579 expect(selectThreadPanelOpen(store.threadPanelVisibilityByThreadKey, refA, "popover")).toBe(
580580 false,
581581 );
582582
583 store.setThreadPanelOpen(refA, "inline", false);
583 store.toggleThreadPanel(refA, "inline");
584584 store.toggleThreadPanel(refA, "popover");
585585
586586 expect(
......@@ -588,13 +588,13 @@ describe("rightPanelStore", () => {
588588 useRightPanelStore.getState().threadPanelVisibilityByThreadKey,
589589 refA,
590590 ),
591 ).toEqual({ inlineOpen: false, popoverOpen: true });
591 ).toEqual({ inlineOpen: true, popoverOpen: true });
592592 expect(
593593 selectThreadPanelVisibility(
594594 useRightPanelStore.getState().threadPanelVisibilityByThreadKey,
595595 refB,
596596 ),
597 ).toEqual({ inlineOpen: true, popoverOpen: false });
597 ).toEqual({ inlineOpen: false, popoverOpen: false });
598598 });
599599
600600 it("closes the popover atomically when the real right panel opens", () => {
......@@ -606,7 +606,7 @@ describe("rightPanelStore", () => {
606606 useRightPanelStore.getState().threadPanelVisibilityByThreadKey,
607607 refA,
608608 ),
609 ).toEqual({ inlineOpen: true, popoverOpen: false });
609 ).toEqual({ inlineOpen: false, popoverOpen: false });
610610 });
611611
612612 it("keeps an open popover visible by promoting it to inline when the real panel closes", () => {
apps/web/src/rightPanelStore.ts+8-5
......@@ -209,7 +209,7 @@ const EMPTY_THREAD_STATE: ThreadRightPanelState = {
209209};
210210
211211const DEFAULT_THREAD_PANEL_VISIBILITY: ThreadPanelVisibility = {
212 inlineOpen: true,
212 inlineOpen: false,
213213 popoverOpen: false,
214214};
215215
......@@ -340,7 +340,7 @@ const updateThreadPanelVisibilityMap = (
340340): Record<string, ThreadPanelVisibility> => {
341341 const current = byThreadKey[threadKey] ?? DEFAULT_THREAD_PANEL_VISIBILITY;
342342 const next = updater(current);
343 if (next.inlineOpen && !next.popoverOpen) {
343 if (next.inlineOpen === DEFAULT_THREAD_PANEL_VISIBILITY.inlineOpen && !next.popoverOpen) {
344344 if (!(threadKey in byThreadKey)) return byThreadKey;
345345 const { [threadKey]: _removed, ...rest } = byThreadKey;
346346 return rest;
......@@ -589,8 +589,9 @@ export function migratePersistedRightPanelState(persistedState: unknown): {
589589 persistedState.threadPanelVisibilityByThreadKey as Record<string, unknown>,
590590 ).flatMap(([threadKey, value]) => {
591591 if (!value || typeof value !== "object" || !("inlineOpen" in value)) return [];
592 return value.inlineOpen === false
593 ? [[threadKey, { inlineOpen: false, popoverOpen: false }]]
592 return typeof value.inlineOpen === "boolean" &&
593 value.inlineOpen !== DEFAULT_THREAD_PANEL_VISIBILITY.inlineOpen
594 ? [[threadKey, { inlineOpen: value.inlineOpen, popoverOpen: false }]]
594595 : [];
595596 }),
596597 )
......@@ -1068,7 +1069,9 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
10681069 threadPanelVisibilityByThreadKey: Object.fromEntries(
10691070 Object.entries(state.threadPanelVisibilityByThreadKey).flatMap(
10701071 ([threadKey, visibility]) =>
1071 visibility.inlineOpen ? [] : [[threadKey, { inlineOpen: false, popoverOpen: false }]],
1072 visibility.inlineOpen === DEFAULT_THREAD_PANEL_VISIBILITY.inlineOpen
1073 ? []
1074 : [[threadKey, { inlineOpen: visibility.inlineOpen, popoverOpen: false }]],
10721075 ),
10731076 ),
10741077 }),