diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts
index e32f41372528f4f4083283ddea4387e1e82084ea..9f4f16722ea8e84e30c8418fd7589a7ec4f8d7d4 100644
--- a/apps/server/src/assets/AssetAccess.test.ts
+++ b/apps/server/src/assets/AssetAccess.test.ts
@@ -54,6 +54,12 @@ screenshotPng.set([
]);
new DataView(screenshotPng.buffer).setUint32(16, 390);
new DataView(screenshotPng.buffer).setUint32(20, 844);
+const screenshotIcns = new Uint8Array(16 + screenshotPng.length);
+screenshotIcns.set(new TextEncoder().encode("icns"));
+new DataView(screenshotIcns.buffer).setUint32(4, screenshotIcns.length);
+screenshotIcns.set(new TextEncoder().encode("ic07"), 8);
+new DataView(screenshotIcns.buffer).setUint32(12, 8 + screenshotPng.length);
+screenshotIcns.set(screenshotPng, 16);
const screenshotItem = {
id: TurnItemId.make("tool-screenshot"),
type: "dynamic_tool" as const,
@@ -816,6 +822,84 @@ describe("AssetAccess", () => {
}).pipe(Effect.provide(layerTest)),
);
+ it.effect("previews ICNS candidates without selecting a project favicon", () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-icon-candidates-" });
+ const external = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-icon-external-" });
+ yield* fileSystem.writeFileString(path.join(root, "favicon.svg"), "");
+
+ for (const requestedPath of ["Candidate.icns", path.join(external, "Candidate.icns")]) {
+ const filePath = path.isAbsolute(requestedPath)
+ ? requestedPath
+ : path.join(root, requestedPath);
+ yield* fileSystem.writeFile(filePath, screenshotIcns);
+ const result = yield* issueAssetUrl({
+ resource: { _tag: "draft-workspace-file", cwd: root, path: requestedPath },
+ });
+ const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
+ const token = suffix.slice(0, suffix.indexOf("/"));
+ const canonicalPath = yield* fileSystem.realPath(filePath);
+ const asset = yield* resolveAsset(token, "Candidate.icns").pipe(
+ Effect.provideService(
+ FileSystem.FileSystem,
+ FileSystem.FileSystem.of({
+ ...fileSystem,
+ readFile: (requested) =>
+ path.isAbsolute(requestedPath) && requested === canonicalPath
+ ? Effect.succeed(new Uint8Array())
+ : fileSystem.readFile(requested),
+ }),
+ ),
+ );
+
+ expect(asset?.kind === "bytes" ? asset.mimeType : null).toBe("image/png");
+ expect(asset?.kind === "bytes" ? Uint8Array.from(asset.bytes) : null).toEqual(
+ screenshotPng,
+ );
+ expect(yield* resolveAsset(token, "favicon.svg")).toBeNull();
+ expect(yield* resolveAsset(token, "../Candidate.icns")).toBeNull();
+
+ if (path.isAbsolute(requestedPath)) {
+ const replacement = path.join(external, "replacement.icns");
+ yield* fileSystem.writeFile(replacement, screenshotIcns);
+ yield* fileSystem.rename(replacement, filePath);
+ expect(yield* resolveAsset(token, "Candidate.icns")).toBeNull();
+ }
+ }
+ const saved = yield* issueAssetUrl({
+ resource: { _tag: "project-favicon", cwd: root, path: "Candidate.icns" },
+ });
+ expect(saved.sourcePath).toBe("favicon.svg");
+ }).pipe(Effect.provide(layerTest)),
+ );
+
+ it.effect.skipIf(!symlinksSupported)("revalidates ICNS candidate workspace confinement", () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-icon-contained-" });
+ const outside = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-icon-private-" });
+ const filePath = path.join(root, "Candidate.icns");
+ const secret = path.join(outside, "Private.icns");
+ yield* fileSystem.writeFile(filePath, screenshotPng);
+ yield* fileSystem.writeFile(secret, screenshotPng);
+ const result = yield* issueAssetUrl({
+ resource: { _tag: "draft-workspace-file", cwd: root, path: "Candidate.icns" },
+ });
+ const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
+ const token = suffix.slice(0, suffix.indexOf("/"));
+ yield* fileSystem.remove(filePath);
+ yield* fileSystem.symlink(secret, filePath);
+ expect(yield* resolveAsset(token, "Candidate.icns")).toBeNull();
+ const error = yield* issueAssetUrl({
+ resource: { _tag: "draft-workspace-file", cwd: root, path: "Candidate.icns" },
+ }).pipe(Effect.flip);
+ expect(error._tag).toBe("AssetWorkspaceAssetNotFoundError");
+ }).pipe(Effect.provide(layerTest)),
+ );
+
it.effect("falls back to the resource cwd for relative draft paths", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
@@ -1335,14 +1419,8 @@ describe("AssetAccess", () => {
const root = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-asset-favicon-icns-",
});
- const icns = new Uint8Array(8 + 8 + screenshotPng.length);
- icns.set(new TextEncoder().encode("icns"));
- new DataView(icns.buffer).setUint32(4, icns.length);
- icns.set(new TextEncoder().encode("ic07"), 8);
- new DataView(icns.buffer).setUint32(12, 8 + screenshotPng.length);
- icns.set(screenshotPng, 16);
yield* fileSystem.makeDirectory(path.join(root, "Resources"));
- yield* fileSystem.writeFile(path.join(root, "Resources", "AppIcon.icns"), icns);
+ yield* fileSystem.writeFile(path.join(root, "Resources", "AppIcon.icns"), screenshotIcns);
const result = yield* issueAssetUrl({
resource: { _tag: "project-favicon", cwd: root },
diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts
index 6b736c68f74efbb1359145328b121f4bd6658bac..54ee6d605749ccd7b7e61869d0beb87cee7ce70b 100644
--- a/apps/server/src/assets/AssetAccess.ts
+++ b/apps/server/src/assets/AssetAccess.ts
@@ -262,15 +262,20 @@ const readToolOutputImage = Effect.fn("AssetAccess.readToolOutputImage")(functio
: { mimeType: image.mimeType, bytes: Buffer.from(image.data, "base64") };
});
-/** Serves an `.icns` favicon as its embedded PNG, since browsers cannot draw it. */
-const projectFaviconAsset = Effect.fn("AssetAccess.projectFaviconAsset")(function* (
+/** Browsers display the embedded PNG in an `.icns` image. */
+const imagePreviewAsset = Effect.fn("AssetAccess.imagePreviewAsset")(function* (
filePath: string,
+ file?: OpenMediaFile,
) {
if (!filePath.toLowerCase().endsWith(".icns")) {
return { kind: "file", path: filePath } satisfies ResolvedAsset;
}
const fileSystem = yield* FileSystem.FileSystem;
- const bytes = yield* fileSystem.readFile(filePath).pipe(
+ const bytes = yield* Effect.gen(function* () {
+ return file
+ ? yield* readMediaFileHeader(filePath, file, Number(file.info.size))
+ : yield* fileSystem.readFile(filePath);
+ }).pipe(
Effect.tapError((cause) =>
Effect.logError("Failed to read project icon image.", { filePath, cause }),
),
@@ -378,7 +383,10 @@ const finalizeAbsoluteMediaFileAsset = Effect.fn("AssetAccess.finalizeAbsoluteMe
if (!canonicalFile) {
return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource });
}
- if (hostPreviewMimeTypeFromExtension(path.extname(canonicalFile)) === null) {
+ if (
+ hostPreviewMimeTypeFromExtension(path.extname(canonicalFile)) === null &&
+ !canonicalFile.toLowerCase().endsWith(".icns")
+ ) {
return yield* new AssetPreviewTypeValidationError({ resource: input.resource });
}
const wantsDimensions = HEADER_IMAGE_EXTENSIONS.has(path.extname(canonicalFile).toLowerCase());
@@ -442,7 +450,10 @@ const finalizeWorkspaceFileAsset = Effect.fn("AssetAccess.finalizeWorkspaceFileA
}),
),
);
- if (!isWorkspacePreviewEntryPath(resolved.relativePath)) {
+ const isImage =
+ isWorkspaceImagePreviewPath(resolved.relativePath) ||
+ resolved.relativePath.toLowerCase().endsWith(".icns");
+ if (!isWorkspacePreviewEntryPath(resolved.relativePath) && !isImage) {
return yield* new AssetPreviewTypeValidationError({
resource: input.resource,
});
@@ -479,7 +490,7 @@ const finalizeWorkspaceFileAsset = Effect.fn("AssetAccess.finalizeWorkspaceFileA
? yield* readImageDimensionsFromHeader(canonicalFile)
: null;
return {
- claims: isWorkspaceImagePreviewPath(resolved.relativePath)
+ claims: isImage
? {
version: 1 as const,
kind: "workspace-file-exact" as const,
@@ -889,7 +900,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
workspaceRoot: claims.workspaceRoot,
relativePath: claims.relativePath,
});
- return faviconPath ? yield* projectFaviconAsset(faviconPath) : null;
+ return faviconPath ? yield* imagePreviewAsset(faviconPath) : null;
}
if (claims.kind === "project-favicon-external") {
@@ -902,7 +913,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
),
Effect.orElseSucceed(() => null),
);
- return faviconPath === claims.filePath ? yield* projectFaviconAsset(faviconPath) : null;
+ return faviconPath === claims.filePath ? yield* imagePreviewAsset(faviconPath) : null;
}
if (claims.kind === "github-media") {
@@ -949,7 +960,10 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
Effect.orElseSucceed(() => null),
);
if (canonicalFile !== claims.filePath) return null;
- const mimeType = hostPreviewMimeTypeFromExtension(path.extname(canonicalFile));
+ const isIcns = canonicalFile.toLowerCase().endsWith(".icns");
+ const mimeType = isIcns
+ ? "image/png"
+ : hostPreviewMimeTypeFromExtension(path.extname(canonicalFile));
if (!mimeType) return null;
const file = yield* openMediaFile(canonicalFile, claims).pipe(
Effect.tapError((cause) =>
@@ -957,9 +971,10 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
),
Effect.orElseSucceed(() => null),
);
- return file
- ? ({ kind: "file", path: canonicalFile, mimeType, file } satisfies ResolvedAsset)
- : null;
+ if (!file) return null;
+ return isIcns
+ ? yield* imagePreviewAsset(canonicalFile, file)
+ : ({ kind: "file", path: canonicalFile, mimeType, file } satisfies ResolvedAsset);
}
if (claims.kind === "workspace-file-exact") {
if (decodedPath !== path.basename(claims.relativePath)) return null;
@@ -967,9 +982,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
workspaceRoot: claims.workspaceRoot,
relativePath: claims.relativePath,
});
- return exactWorkspaceFile
- ? ({ kind: "file", path: exactWorkspaceFile } satisfies ResolvedAsset)
- : null;
+ return exactWorkspaceFile ? yield* imagePreviewAsset(exactWorkspaceFile) : null;
}
const segments = decodedPath.split(/[\\/]/);
if (
diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts
index 6053e5fb0dd4136f0896b591078d34ff7f5a05fe..b842ee5fd1cf512c33e827055110defea48d8564 100644
--- a/apps/web/src/appearanceFonts.ts
+++ b/apps/web/src/appearanceFonts.ts
@@ -18,12 +18,12 @@ import {
} from "@t3tools/contracts";
export const DEFAULT_SANS_FONT_STACK =
- '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif';
+ '"AT Name Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif';
// Concrete names first: some engines alias `ui-monospace` to the
// proportional system UI font, which would break every code surface.
export const DEFAULT_CODE_FONT_STACK =
- '"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace';
+ '"AT Name Mono", "SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace';
export const TYPOGRAPHY_ADVANCED_STORAGE_KEY = "t3code:typography-advanced";
diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx
index fd56ee8ca5ac9de20a7cf284361ff8465a048b0f..edcc4e7bf5fcd3978af51fb2f128020d26bff46d 100644
--- a/apps/web/src/components/AppSidebarLayout.tsx
+++ b/apps/web/src/components/AppSidebarLayout.tsx
@@ -25,7 +25,7 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina
import { resolveThreadRouteRef } from "../threadRoutes";
import { cn, isMacPlatform } from "../lib/utils";
import { primaryServerKeybindingsAtom } from "../state/server";
-import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings";
+import { useLegacySidebarEnabled } from "../hooks/useSettings";
import {
PanelAnimationSuppressionProvider,
usePanelAnimationSettings,
@@ -35,15 +35,15 @@ import LegacyThreadSidebar from "./LegacySidebar";
import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration";
import ThreadSidebar from "./Sidebar";
import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
-import { SidebarBrandWidthProbe, SidebarChromeHeader } from "./sidebar/SidebarChrome";
+import { AppMenu } from "./sidebar/AppMenu";
+import { SidebarChromeHeader } from "./sidebar/SidebarChrome";
import { MainAppLocationTracker } from "./sidebar/mainAppLocation";
-import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop";
import { useProjects } from "../state/entities";
import {
clampThreadSidebarWidth,
resolveInitialThreadSidebarWidth,
resolveThreadSidebarMaximumWidth,
- resolveThreadSidebarMinimumWidth,
+ THREAD_SIDEBAR_MIN_WIDTH,
THREAD_MAIN_CONTENT_MIN_WIDTH,
THREAD_SIDEBAR_WIDTH_STORAGE_KEY,
} from "./threadSidebarWidth";
@@ -85,10 +85,6 @@ function SidebarControl() {
const keybindings = useAtomValue(primaryServerKeybindingsAtom);
const { toggleSidebar, isMobile, mobileSheetMounted } = useSidebar();
const isSidebarVisible = useSidebarVisibility();
- const environmentIdentificationMode = useEnvironmentIdentificationMode();
- const stageBackdropVariant = useSidebarStageBackdropVariant(
- environmentIdentificationMode === "artwork",
- );
const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle", {
context: { usagePageOpen },
});
@@ -146,19 +142,13 @@ function SidebarControl() {
)}
data-sidebar-control=""
>
+
+
}
/>
@@ -246,9 +236,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
// and a clamped drag ends with an unchanged width, which skips the re-render
// that would otherwise refresh a render-time snapshot.
const viewportWidth = useSyncExternalStore(subscribeToViewportWidth, readViewportWidth);
- const [brandWidth, setBrandWidth] = useState(0);
- const sidebarMinimumWidth = resolveThreadSidebarMinimumWidth(brandWidth);
- const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(viewportWidth, sidebarMinimumWidth);
+ const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(viewportWidth);
const resetSidebarWidth = () => {
try {
removeLocalStorageItem(THREAD_SIDEBAR_WIDTH_STORAGE_KEY);
@@ -269,8 +257,10 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
: {}
) as CSSProperties;
const sidebarProviderStyle = {
- "--sidebar-width": `${clampThreadSidebarWidth(sidebarWidth, sidebarMinimumWidth, sidebarMaximumWidth)}px`,
+ "--sidebar-width": `${clampThreadSidebarWidth(sidebarWidth, sidebarMaximumWidth)}px`,
"--panel-animation-duration": `${panelAnimationDurationMs}ms`,
+ "--workspace-titlebar-content-left":
+ "calc(var(--workspace-controls-left) + 2 * var(--workspace-titlebar-control-size) + var(--workspace-titlebar-control-gap))",
...windowControlsInsetStyle,
} as CSSProperties;
@@ -319,7 +309,6 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
defaultOpen
style={sidebarProviderStyle}
>
-
nextWidth <= currentWidth ||
wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH,
diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx
index 6596e451e3fc60f92bb01523b613f1d34a4f978d..43416cff3a0e29c8ed844b49ed7aa13b0da86030 100644
--- a/apps/web/src/components/CommandPalette.tsx
+++ b/apps/web/src/components/CommandPalette.tsx
@@ -11,10 +11,6 @@ import {
getCloneDirectoryName,
getDefaultCloneUrl,
getNewProjectPathPreview,
- getNewProjectPublishTarget,
- getNewProjectPublishTargets,
- getNewProjectRepository,
- type NewProjectPublishTarget,
normalizePastedCloneUrl,
addProjectRemoteSourceLabel,
addProjectRemoteSourcePathHint,
@@ -49,13 +45,11 @@ import {
type FilesystemBrowseResult,
type ProjectId,
type SourceControlDiscoveryResult,
- SourceControlProviderKind,
type SourceControlRepositoryInfo,
PRIMARY_LOCAL_ENVIRONMENT_ID,
resolveEnvironmentMachineKind,
} from "@t3tools/contracts";
import { useLocation, useNavigate, useParams } from "@tanstack/react-router";
-import * as Option from "effect/Option";
import {
ArrowLeftIcon,
ChartNoAxesColumnIcon,
@@ -63,7 +57,6 @@ import {
ChevronRightIcon,
CornerLeftUpIcon,
FileSearchIcon,
- FolderGit2Icon,
FolderIcon,
FolderPlusIcon,
MessageSquareDashedIcon,
@@ -127,7 +120,6 @@ import { sourceControlEnvironment } from "../state/sourceControl";
import { useAtomCommand } from "../state/use-atom-command";
import { useAtomQueryRunner } from "../state/use-atom-query-runner";
import { useScratchProject } from "../hooks/useScratchProject";
-import { useNewProject } from "../hooks/useNewProject";
import { isScratchProject } from "@t3tools/client-runtime/state/projects";
import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities";
@@ -204,7 +196,6 @@ import {
} from "./CommandPaletteResults";
import { sourceControlIcon } from "~/sourceControlPresentation";
import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
-import { Checkbox } from "./ui/checkbox";
import { ProjectFavicon } from "./ProjectFavicon";
import { ProjectFilePicker } from "./files/ProjectFilePicker";
import { openLinkPullRequestDialog } from "./pullRequest/LinkPullRequestDialog";
@@ -231,7 +222,6 @@ import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindin
import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "./ui/command";
import { Button } from "./ui/button";
import { Kbd, KbdGroup } from "./ui/kbd";
-import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
import { stackedThreadToast, toastManager } from "./ui/toast";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext";
@@ -291,14 +281,6 @@ function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: stri
return ;
}
-function sourceControlHostIcon(
- definition: NewProjectPublishTarget["definition"],
- className: string,
-): ReactNode {
- const Icon = sourceControlIcon(definition);
- return ;
-}
-
function projectFaviconIcon(project: Project): ReactNode {
return ;
}
@@ -367,10 +349,6 @@ function notifyThemeSaveFailure(): void {
);
}
-function projectFavicon(project: Project) {
- return ;
-}
-
export function CommandPalette({ children }: { children: ReactNode }) {
const navigate = useNavigate();
const [state, dispatch] = useReducer(reduceCommandPaletteUiState, {
@@ -788,15 +766,9 @@ function OpenCommandPaletteDialog(props: {
/** Machine of the Add project sources view under this step; null from the palette root. */
readonly sourcesEnvironmentId: EnvironmentId | null;
} | null>(null);
- const [newProjectPublishes, setNewProjectPublishes] = useState(false);
- // The host picked beside the publish toggle; null takes the first ready host, GitHub when ready.
- const [newProjectPublishKind, setNewProjectPublishKind] =
- useState(null);
- const [newProjectHostPickerOpen, setNewProjectHostPickerOpen] = useState(false);
const [isCreatingNewProject, setIsCreatingNewProject] = useState(false);
// State lags a render behind, so a repeated Enter could start a second create.
const newProjectSubmittingRef = useRef(false);
- const createNewProject = useNewProject();
const cloneLookupGeneration = useRef(0);
const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false);
const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false);
@@ -1522,10 +1494,8 @@ function OpenCommandPaletteDialog(props: {
setAddProjectEnvironmentId(environmentId);
setAddProjectCloneFlow(null);
setNewProjectFlow({ environmentId, sourcesEnvironmentId });
- setNewProjectPublishes(false);
- setNewProjectPublishKind(null);
pushPaletteView({
- addonIcon: ,
+ addonIcon: ,
groups: [],
});
},
@@ -1561,10 +1531,10 @@ function OpenCommandPaletteDialog(props: {
sourceItems.unshift({
kind: "action",
value: `action:add-project:${environmentId}:new`,
- searchTerms: ["new project", "create", "empty", "repository", "git init"],
+ searchTerms: ["new project", "create", "empty", "folder"],
title: "New project",
- description: "Start a new Git repository from a name",
- icon: ,
+ description: "Create a project folder from a name",
+ icon: ,
keepOpen: true,
run: async () => {
startNewProject(environmentId, environmentId);
@@ -2037,9 +2007,9 @@ function OpenCommandPaletteDialog(props: {
actionItems.push({
kind: "action",
value: "action:new-project",
- searchTerms: ["new project", "create project", "empty", "repository", "repo", "git init"],
+ searchTerms: ["new project", "create project", "empty", "folder"],
title: "New project",
- icon: ,
+ icon: ,
keepOpen: true,
run: async () => {
openNewProjectFlow();
@@ -2508,12 +2478,6 @@ function OpenCommandPaletteDialog(props: {
],
);
- const newProjectPublishTargets =
- newProjectFlow === null ? [] : getNewProjectPublishTargets(sourceControlDiscovery.data ?? null);
- const newProjectPublishTarget = getNewProjectPublishTarget(
- newProjectPublishTargets,
- newProjectPublishKind,
- );
const newProjectName = query.trim();
const canSubmitNewProject =
newProjectFlow !== null &&
@@ -2528,12 +2492,14 @@ function OpenCommandPaletteDialog(props: {
newProjectSubmittingRef.current = true;
setIsCreatingNewProject(true);
try {
- const created = await createNewProject({
+ const root = newProjectsRootFor(newProjectFlow.environmentId);
+ if (root === null) return;
+ await handleAddProjectForEnvironment({
environmentId: newProjectFlow.environmentId,
- name: newProjectName,
- publishTo: newProjectPublishes ? newProjectPublishTarget : null,
+ rawCwd: getNewProjectPathPreview(root, newProjectName),
+ platform: browseEnvironmentPlatform,
+ currentProjectCwd: null,
});
- if (created) setOpen(false);
} finally {
newProjectSubmittingRef.current = false;
setIsCreatingNewProject(false);
@@ -2837,7 +2803,6 @@ function OpenCommandPaletteDialog(props: {
const newProjectsRoot = newProjectFlow ? newProjectsRootFor(newProjectFlow.environmentId) : null;
const newProjectPathPreview =
newProjectsRoot === null ? null : getNewProjectPathPreview(newProjectsRoot, newProjectName);
- const newProjectPublishToggleValue = "new-project:publish";
// The name step's way out to folders and clones, for the selected machine.
// It replaces the name step (and a sources view for another machine under
// it), so Back returns to wherever New project was opened from.
@@ -2853,8 +2818,6 @@ function OpenCommandPaletteDialog(props: {
popView();
startAddProjectSourceSelection(environmentId);
};
- // Switching machines keeps the typed name; the GitHub option follows the
- // machine because source control discovery reads addProjectEnvironmentId.
const switchNewProjectEnvironment = (environmentId: EnvironmentId) => {
setNewProjectFlow((flow) => (flow === null ? flow : { ...flow, environmentId }));
setAddProjectEnvironmentId(environmentId);
@@ -2923,102 +2886,11 @@ function OpenCommandPaletteDialog(props: {
},
],
};
- const newProjectOptionGroups: CommandPaletteView["groups"] =
- newProjectPublishTarget === null || newProjectPathPreview === null
- ? []
- : [
- {
- value: "new-project-options",
- label: "Options",
- items: [
- {
- kind: "action",
- value: newProjectPublishToggleValue,
- searchTerms: [],
- title:
- newProjectPublishTargets.length > 1
- ? "Create private repository"
- : `Create private repository on ${newProjectPublishTarget.definition.label}`,
- description:
- newProjectName.length > 0
- ? getNewProjectRepository(newProjectPublishTarget, newProjectPathPreview)
- : (newProjectPublishTarget.owner ??
- `Your ${newProjectPublishTarget.definition.label} account`),
- icon: sourceControlHostIcon(newProjectPublishTarget.definition, ITEM_ICON_CLASS),
- titleTrailingContent: (
-
- {newProjectPublishTargets.length > 1 ? (
- // The row toggles on click, so the picker keeps its clicks to itself. The
- // row also cancels pointerdown to keep focus in the input, which drops the
- // mousedown Select opens on, so the picker opens on click instead.
- {
- event.stopPropagation();
- // React bubbles the popup's clicks here through its portal; only the
- // trigger opens the picker.
- if (!event.currentTarget.contains(event.target as Node)) return;
- if (!isCreatingNewProject) setNewProjectHostPickerOpen(true);
- }}
- >
-
-
- ) : null}
-
-
-
-
- ),
- // The create in flight keeps the choice it started with.
- ...(isCreatingNewProject ? { disabled: true } : {}),
- keepOpen: true,
- run: async () => {
- setNewProjectPublishes((publishes) => !publishes);
- },
- },
- ],
- },
- ];
let displayedGroups: CommandPaletteView["groups"] = filteredGroups;
if (newProjectFlow !== null) {
displayedGroups = [
...(newProjectMachineGroup ? [newProjectMachineGroup] : []),
- ...newProjectOptionGroups,
...(newProjectExistingGroup ? [newProjectExistingGroup] : []),
];
} else if (addProjectCloneFlow?.step === "repository") {
@@ -3487,9 +3359,7 @@ function OpenCommandPaletteDialog(props: {
newProjectFlow !== null
? highlightedItemValue === null
? "Create"
- : highlightedItemValue === newProjectPublishToggleValue
- ? "Toggle"
- : "Select"
+ : "Select"
: addProjectCloneFlow?.step === "repository"
? (remoteProjectButtonLabel ?? "Continue")
: !canSubmitBrowsePath || hasHighlightedBrowseItem
@@ -3566,7 +3436,7 @@ function OpenCommandPaletteDialog(props: {
{newProjectPathPreview !== null ? (
-
+
{newProjectName.length > 0 ? newProjectName : "New project"}
diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts
index 12aee26c757ed118e811f6948b65160a43c4c6c6..adecc5d7e1e0b57a3bb0643def97ba8588406055 100644
--- a/apps/web/src/components/GitActionsControl.logic.test.ts
+++ b/apps/web/src/components/GitActionsControl.logic.test.ts
@@ -823,7 +823,7 @@ describe("when: ref has no upstream configured", () => {
});
});
- it("resolveQuickAction publishes when no origin remote exists", () => {
+ it("resolveQuickAction rests without offering repository creation when no remote exists", () => {
const quick = resolveQuickAction(
status({
hasUpstream: false,
@@ -835,9 +835,10 @@ describe("when: ref has no upstream configured", () => {
false,
);
assert.deepEqual(quick, {
- kind: "open_publish",
- label: "Publish repository",
- disabled: false,
+ kind: "show_hint",
+ label: "Commit",
+ disabled: true,
+ hint: "No changes to commit.",
});
});
diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts
index af6206625c4112aa909eb3a1343638deef9e41d3..444900573be6581042444e174337289ad843360d 100644
--- a/apps/web/src/components/GitActionsControl.logic.ts
+++ b/apps/web/src/components/GitActionsControl.logic.ts
@@ -23,7 +23,7 @@ export interface GitActionMenuItem {
export interface GitQuickAction {
label: string;
disabled: boolean;
- kind: "run_action" | "run_pull" | "open_publish" | "show_hint";
+ kind: "run_action" | "run_pull" | "show_hint";
action?: GitStackedAction;
hint?: string;
}
@@ -281,9 +281,10 @@ export function resolveQuickAction(
if (!gitStatus.hasUpstream) {
if (!hasPrimaryRemote) {
return {
- label: "Publish repository",
- disabled: false,
- kind: "open_publish",
+ label: "Commit",
+ disabled: true,
+ kind: "show_hint",
+ hint: "No changes to commit.",
};
}
if (!isAhead) {
diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx
index 8164537ee3a248cf204b53a038286741d4685e8e..cd3e701a76b58f4bd6203fb00ac78607013ec526 100644
--- a/apps/web/src/components/GitActionsControl.tsx
+++ b/apps/web/src/components/GitActionsControl.tsx
@@ -3,7 +3,6 @@ import { useAtomValue } from "@effect/atom-react";
import {
AuthOrchestrationOperateScope,
AuthSourceControlWriteScope,
- SourceControlProviderKind,
type ScopedThreadRef,
} from "@t3tools/contracts";
import {
@@ -13,32 +12,27 @@ import {
import type {
GitRunStackedActionResult,
GitStackedAction,
- SourceControlCloneProtocol,
- SourceControlProviderDiscoveryItem,
- SourceControlPublishRepositoryResult,
- SourceControlRepositoryVisibility,
VcsStatusResult,
} from "@t3tools/contracts";
-import { useNavigate } from "@tanstack/react-router";
-import * as Option from "effect/Option";
-import { type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { flushSync } from "react-dom";
+import {
+ type MouseEvent,
+ useCallback,
+ useEffect,
+ useEffectEvent,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
import {
CheckIcon,
ChevronDownIcon,
CloudDownloadIcon,
CloudUploadIcon,
FileDiffIcon,
- GitBranchPlusIcon,
GitCommitIcon,
InfoIcon,
- LockIcon,
- GlobeIcon,
} from "lucide-react";
-import { Radio as RadioPrimitive } from "@base-ui/react/radio";
-import { RadioGroup } from "~/components/ui/radio-group";
import { Spinner } from "~/components/ui/spinner";
-import { Toggle, ToggleGroup } from "~/components/ui/toggle-group";
import { cn } from "~/lib/utils";
import { useOpenPrLink } from "~/lib/openPullRequestLink";
import {
@@ -59,7 +53,6 @@ import {
resolveQuickAction,
resolveThreadBranchUpdate,
} from "./GitActionsControl.logic";
-import { WizardPopup, WizardHeader, WizardSteps, WizardPanel, WizardFooter } from "./ui/wizard";
import { StartTruncatedPath } from "./StartTruncatedPath";
import { Button } from "~/components/ui/button";
import { Checkbox } from "~/components/ui/checkbox";
@@ -73,7 +66,6 @@ import {
DialogTitle,
} from "~/components/ui/dialog";
import { Group, GroupSeparator } from "~/components/ui/group";
-import { Input } from "~/components/ui/input";
import {
Menu,
MenuItem,
@@ -93,15 +85,12 @@ import { useOpenInPreferredEditor } from "~/editorPreferences";
import {
useGitStackedAction,
useSourceControlActionRunning,
- useSourceControlPublishRepositoryAction,
- useVcsInitAction,
useVcsPullAction,
} from "~/lib/sourceControlActions";
import { useThreadProjection, useThreadShell } from "~/state/entities";
import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
import { useEnvironmentQuery } from "~/state/query";
import { serverEnvironment } from "~/state/server";
-import { sourceControlEnvironment } from "~/state/sourceControl";
import { threadEnvironment } from "~/state/threads";
import { useAtomCommand } from "~/state/use-atom-command";
import { vcsActionManager, vcsEnvironment } from "~/state/vcs";
@@ -115,10 +104,8 @@ import {
THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS,
THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS,
} from "./chat/threadDetailsPanelStyles";
-import { getSourceControlPresentation, sourceControlIcon } from "~/sourceControlPresentation";
-import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
+import { getSourceControlPresentation } from "~/sourceControlPresentation";
import { formatDiffCount } from "@t3tools/client-runtime/diff-count";
-import { useOpenLink } from "~/browser/useOpenLink";
interface GitActionsControlProps {
presentation?: "toolbar" | "menu";
@@ -182,49 +169,6 @@ function requestVcsStatusRefresh(
const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const;
/** Every built-in host, in the order the built-in definitions list them. */
-const PUBLISH_PROVIDER_OPTIONS = sourceControlClients.definitions.map((definition) => ({
- value: definition.kind,
- label: definition.pickerLabel,
- description: definition.publishDescription,
- pathPlaceholder: definition.repositoryPathHint,
- definition,
- Icon: sourceControlIcon(definition),
-}));
-
-function publishProviderOption(provider: SourceControlProviderKind) {
- return (
- PUBLISH_PROVIDER_OPTIONS.find((option) => option.value === provider) ??
- PUBLISH_PROVIDER_OPTIONS[0]!
- );
-}
-
-function getPublishProviderReadiness(input: {
- provider: SourceControlProviderKind;
- sourceControlProviders: ReadonlyArray;
-}): { readonly ready: boolean; readonly hint: string | null } {
- const discovered = input.sourceControlProviders.find(
- (provider) => provider.kind === input.provider,
- );
- if (!discovered) {
- return {
- ready: false,
- hint: "Provider status unavailable. Open Settings -> Source Control and rescan.",
- };
- }
- if (discovered.status !== "available") {
- return { ready: false, hint: discovered.installHint };
- }
- if (discovered.auth.status === "unauthenticated") {
- return {
- ready: false,
- hint:
- Option.getOrNull(discovered.auth.detail) ??
- `${discovered.label} is not authenticated. Open Settings -> Source Control for setup guidance.`,
- };
- }
- return { ready: true, hint: null };
-}
-
function getMenuActionDisabledReason({
item,
gitStatus,
@@ -312,7 +256,6 @@ function GitQuickActionIcon({
className?: string;
SourceControlIcon: ReturnType["Icon"];
}) {
- if (quickAction.kind === "open_publish") return ;
if (quickAction.kind === "run_pull") return ;
if (quickAction.kind === "run_action") {
if (quickAction.action === "commit") return ;
@@ -458,539 +401,6 @@ function GitActionSuccessButtonContent({ success }: { success: InlineGitActionSu
);
}
-interface PublishRepositoryDialogProps {
- readonly open: boolean;
- readonly onOpenChange: (open: boolean) => void;
- readonly environmentId: ScopedThreadRef["environmentId"] | null;
- /** Thread the dialog was opened from, so the new repository can open beside it. */
- readonly threadRef: ScopedThreadRef | null;
- readonly gitCwd: string;
-}
-
-function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
- const openLink = useOpenLink(props.threadRef);
- const navigate = useNavigate();
- const sourceControlDiscovery = useEnvironmentQuery(
- props.environmentId === null
- ? null
- : sourceControlEnvironment.discovery({
- environmentId: props.environmentId,
- input: {},
- }),
- );
- const [selectedPublishProvider, setSelectedPublishProvider] =
- useState(null);
- const [publishRepositoryOverride, setPublishRepositoryOverride] = useState(null);
- const [publishVisibility, setPublishVisibility] =
- useState("private");
- const [publishRemoteName, setPublishRemoteName] = useState("origin");
- const [publishProtocol, setPublishProtocol] = useState("ssh");
- const [publishWizardStep, setPublishWizardStep] = useState(0);
- const [publishAdvancedOpen, setPublishAdvancedOpen] = useState(false);
- const [publishError, setPublishError] = useState(null);
- const [publishResult, setPublishResult] = useState(
- null,
- );
- const sourceControlScope = useMemo(
- () => ({
- environmentId: props.environmentId,
- cwd: props.gitCwd,
- }),
- [props.environmentId, props.gitCwd],
- );
- const publishRepositoryAction = useSourceControlPublishRepositoryAction(sourceControlScope);
- const publishAccountByProvider = useMemo(() => {
- const accounts = new Map();
- for (const provider of sourceControlDiscovery.data?.sourceControlProviders ?? []) {
- accounts.set(provider.kind, Option.getOrNull(provider.auth.account));
- }
- return accounts;
- }, [sourceControlDiscovery.data]);
- const publishProviderReadiness = useMemo(() => {
- const sourceControlProviders = sourceControlDiscovery.data?.sourceControlProviders ?? [];
- const readiness = new Map(
- PUBLISH_PROVIDER_OPTIONS.map((option) => [
- option.value,
- getPublishProviderReadiness({ provider: option.value, sourceControlProviders }),
- ]),
- );
- return (provider: SourceControlProviderKind) =>
- readiness.get(provider) ?? { ready: false, hint: null };
- }, [sourceControlDiscovery.data]);
- const hasReadyPublishProvider = useMemo(
- () => PUBLISH_PROVIDER_OPTIONS.some((option) => publishProviderReadiness(option.value).ready),
- [publishProviderReadiness],
- );
- const sortedPublishProviderOptions = useMemo(
- () =>
- PUBLISH_PROVIDER_OPTIONS.toSorted((left, right) => {
- const leftReady = publishProviderReadiness(left.value).ready;
- const rightReady = publishProviderReadiness(right.value).ready;
- if (leftReady !== rightReady) {
- return leftReady ? -1 : 1;
- }
- return left.label.localeCompare(right.label);
- }),
- [publishProviderReadiness],
- );
- const firstReadyPublishProvider = sortedPublishProviderOptions.find(
- (option) => publishProviderReadiness(option.value).ready,
- )?.value;
- const publishProvider =
- selectedPublishProvider !== null && publishProviderReadiness(selectedPublishProvider).ready
- ? selectedPublishProvider
- : (firstReadyPublishProvider ??
- selectedPublishProvider ??
- sourceControlClients.get(undefined).kind);
- const selectedPublishProviderReadiness = publishProviderReadiness(publishProvider);
- const publishAccount = publishAccountByProvider.get(publishProvider) ?? null;
- const publishRepositoryPrefill = publishAccount ? `${publishAccount}/` : "";
- const publishRepository = publishRepositoryOverride ?? publishRepositoryPrefill;
- const currentPublishProvider = publishProviderOption(publishProvider);
- const publishHost = currentPublishProvider.definition.publishHost(
- Option.getOrNull(
- sourceControlDiscovery.data?.sourceControlProviders.find(
- (provider) => provider.kind === publishProvider,
- )?.auth.host ?? Option.none(),
- ),
- );
- const publishPathPlaceholder = currentPublishProvider.pathPlaceholder;
- const publishProviderLabel = currentPublishProvider.label;
- const publishWizardSteps = ["Provider", "Repository", "Summary"] as const;
- const publishWizardStepSummaries = [
- publishProviderLabel,
- publishResult?.repository.nameWithOwner ?? null,
- null,
- ] as const;
-
- const canSubmitPublishRepository = useMemo(() => {
- if (!publishRepositoryAction.isAllowed || !selectedPublishProviderReadiness.ready) return false;
- if (publishRepositoryAction.isPending) return false;
- const repositoryParts = publishRepository.trim().split("/");
- const owner = repositoryParts[0]?.trim() ?? "";
- const rest = repositoryParts.slice(1);
- const name = rest.join("/").trim();
- return owner.length > 0 && name.length > 0;
- }, [
- publishRepository,
- publishRepositoryAction.isAllowed,
- publishRepositoryAction.isPending,
- selectedPublishProviderReadiness,
- ]);
-
- const submitPublishRepository = useCallback(() => {
- if (!canSubmitPublishRepository) {
- return;
- }
-
- setPublishError(null);
-
- void (async () => {
- const result = await publishRepositoryAction.run({
- provider: publishProvider,
- repository: publishRepository.trim(),
- visibility: publishVisibility,
- remoteName: publishRemoteName.trim() || "origin",
- protocol: publishProtocol,
- });
-
- if (result._tag === "Failure") {
- if (!isAtomCommandInterrupted(result)) {
- const error = squashAtomCommandFailure(result);
- setPublishError(error instanceof Error ? error.message : "An error occurred.");
- }
- return;
- }
-
- flushSync(() => {
- setPublishResult(result.value);
- setPublishWizardStep(2);
- });
- })();
- }, [
- canSubmitPublishRepository,
- props.environmentId,
- props.gitCwd,
- publishProtocol,
- publishProvider,
- publishRemoteName,
- publishRepository,
- publishRepositoryAction,
- publishVisibility,
- ]);
-
- const resetState = useCallback(() => {
- setPublishRemoteName("origin");
- setPublishRepositoryOverride(null);
- setPublishWizardStep(0);
- setPublishAdvancedOpen(false);
- setPublishError(null);
- setPublishResult(null);
- }, []);
-
- const handleOpenChange = useCallback(
- (open: boolean) => {
- props.onOpenChange(open);
- if (!open) {
- resetState();
- }
- },
- [props, resetState],
- );
-
- const openSourceControlSettings = useCallback(() => {
- handleOpenChange(false);
- void navigate({ to: "/settings/source-control" });
- }, [handleOpenChange, navigate]);
-
- return (
-
- );
-}
-
export default function GitActionsControl({
presentation = "toolbar",
gitCwd,
@@ -1044,7 +454,6 @@ export default function GitActionsControl({
const [dialogCommitMessage, setDialogCommitMessage] = useState("");
const [excludedFiles, setExcludedFiles] = useState>(new Set());
const [isEditingFiles, setIsEditingFiles] = useState(false);
- const [isPublishDialogOpen, setIsPublishDialogOpen] = useState(false);
const [inlineSuccess, setInlineSuccess] = useState(null);
const [pendingDefaultBranchAction, setPendingDefaultBranchAction] =
useState(null);
@@ -1153,7 +562,6 @@ export default function GitActionsControl({
const allSelected = excludedFiles.size === 0;
const noneSelected = selectedFiles.length === 0;
- const initAction = useVcsInitAction(sourceControlScope);
const runImmediateGitAction = useGitStackedAction(sourceControlScope);
const pullAction = useVcsPullAction(sourceControlScope);
const isGitActionRunning = useSourceControlActionRunning(
@@ -1470,10 +878,6 @@ export default function GitActionsControl({
};
const runQuickAction = () => {
- if (quickAction.kind === "open_publish") {
- setIsPublishDialogOpen(true);
- return;
- }
if (quickAction.kind === "run_pull") {
void (async () => {
setInlineSuccess(null);
@@ -1595,26 +999,6 @@ export default function GitActionsControl({
[gitCwd, openInPreferredEditor, threadToastData],
);
- const canPublishRepository =
- canWriteSourceControl && isRepo && gitStatusForActions !== null && !hasPrimaryRemote;
-
- const initializeGit = () => {
- void (async () => {
- const result = await initAction.run();
- if (result._tag === "Success" || isAtomCommandInterrupted(result)) {
- return;
- }
- const error = squashAtomCommandFailure(result);
- toastManager.add(
- stackedThreadToast({
- type: "error",
- title: "Git initialization failed",
- description: error instanceof Error ? error.message : "An error occurred.",
- ...(threadToastData !== undefined ? { data: threadToastData } : {}),
- }),
- );
- })();
- };
const gitItems = (
<>
{gitActionMenuItems.map((item) => {
@@ -1673,18 +1057,6 @@ export default function GitActionsControl({
);
})}
- {canPublishRepository ? (
-
- ) : null}
{gitStatusForActions?.refName === null && (
Detached HEAD: create and check out a branch to enable push and pull request actions.
@@ -1701,70 +1073,40 @@ export default function GitActionsControl({
>
);
- if (!gitCwd) return null;
+ if (!gitCwd || !isRepo) return null;
return (
<>
{presentation === "menu" ? (
- !isRepo ? (
+ <>
- ) : (
- <>
-
- {quickActionDisabledReason && (
-
- {quickActionDisabledReason}
-
- )}
- {
- if (open) requestVcsStatusRefresh(refreshVcsStatus, activeEnvironmentId, gitCwd);
- }}
- >
-
-
- Git actions
-
- {gitItems}
-
- >
- )
- ) : !isRepo ? (
-
-
-
- {initAction.isPending ? "Initializing..." : "Initialize Git"}
-
-
+ {quickActionDisabledReason && (
+ {quickActionDisabledReason}
+ )}
+ {
+ if (open) requestVcsStatusRefresh(refreshVcsStatus, activeEnvironmentId, gitCwd);
+ }}
+ >
+
+
+ Git actions
+
+ {gitItems}
+
+ >
) : compact && !gitActionProgress && !visibleInlineSuccess ? null : (
-
-
);
});
-// Measures the brand at its titlebar inset, plus the header's right padding and the
-// sidebar border, so the sidebar minimum follows font size, zoom and macOS window controls.
-export function SidebarBrandWidthProbe({
- onWidthChange,
-}: {
- onWidthChange: (width: number) => void;
-}) {
- const observeWidth = useCallback(
- (probe: HTMLDivElement) => {
- return observeResize(probe, ([entry]) => {
- if (entry) onWidthChange(entry.borderBoxSize[0]?.inlineSize ?? probe.offsetWidth);
- });
- },
- [onWidthChange],
- );
-
- return (
-
- );
-}
-
-function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) {
- return (
-
-
-
- );
-}
-
-function SidebarBrandMark({ onBackdrop }: { onBackdrop: boolean }) {
- return (
- // Center the visible capitals, without the font's ascender/descender space.
- // Padding keeps ascenders and round-letter overshoot inside the truncation clip.
-
-
-
- Code
-
-
- );
-}
-
function SidebarUtilityItem({
icon,
label,
@@ -188,11 +109,6 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {
search: readPullRequestListPreferences(),
});
}, [closeMobileSidebar, navigate]);
- const handleSettingsClick = useCallback(() => {
- closeMobileSidebar();
- void navigate({ to: "/settings" });
- }, [closeMobileSidebar, navigate]);
-
const handleUsageClick = useCallback(() => {
if (isMobile) {
setOpenMobile(false);
@@ -216,11 +132,6 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {
) : (
<>
-
}
- label="Settings"
- onClick={handleSettingsClick}
- />
{pullRequestsSupported ? (
}
@@ -235,7 +146,6 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() {
/>
>
)}
-
);
});
@@ -244,8 +154,6 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() {
return (
-
-
);
diff --git a/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx
deleted file mode 100644
index d584e04656f2849418bc5eb8bc63d4ab3dc4f212..0000000000000000000000000000000000000000
--- a/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx
+++ /dev/null
@@ -1,210 +0,0 @@
-import { Spinner } from "~/components/ui/spinner";
-import { useNavigate } from "@tanstack/react-router";
-import { useAtomValue } from "@effect/atom-react";
-import type { ServerProvider } from "@t3tools/contracts";
-import { CircleCheckIcon, DownloadIcon, TriangleAlertIcon, XIcon } from "lucide-react";
-import { useCallback, useEffect, useState, type CSSProperties } from "react";
-
-import { primaryServerProvidersAtom } from "../../state/server";
-import {
- getProviderUpdateSidebarPillView,
- type ProviderUpdateSidebarPillView,
-} from "../ProviderUpdateLaunchNotification.logic";
-import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
-
-const PROVIDER_UPDATE_PILL_STYLES = {
- loading:
- "bg-sidebar-control-surface text-sidebar-foreground group-has-[button[data-provider-update-main]:hover]/provider-update:bg-sidebar-row-hover",
- success:
- "bg-sidebar-control-surface text-sidebar-foreground group-has-[button[data-provider-update-main]:hover]/provider-update:bg-sidebar-row-hover",
- warning:
- "bg-warning/12 text-warning group-has-[button[data-provider-update-main]:hover]/provider-update:bg-warning/18",
- error:
- "bg-destructive/12 text-destructive group-has-[button[data-provider-update-main]:hover]/provider-update:bg-destructive/18",
-} as const;
-
-const PROVIDER_UPDATE_PILL_PROGRESS_STYLES = {
- success: "bg-foreground/8",
- warning: "bg-warning/14",
- error: "bg-destructive/14",
-} as const;
-
-function latestProviderCheckedAt(
- providers: ReadonlyArray
>,
-): string | undefined {
- return providers.reduce(
- (latest, provider) =>
- latest === undefined || provider.checkedAt > latest ? provider.checkedAt : latest,
- undefined,
- );
-}
-
-export function SidebarProviderUpdatePill() {
- const navigate = useNavigate();
- const providers = useAtomValue(primaryServerProvidersAtom);
- const [dismissedKeys, setDismissedKeys] = useState>(() => new Set());
- const [renderedView, setRenderedView] = useState(null);
- const [pendingView, setPendingView] = useState(null);
- const [exitingKey, setExitingKey] = useState(null);
- const [dismissAfterExitKey, setDismissAfterExitKey] = useState(null);
- const [visibleAfterIso, setVisibleAfterIso] = useState();
- const effectiveVisibleAfterIso = visibleAfterIso ?? latestProviderCheckedAt(providers);
- const view = getProviderUpdateSidebarPillView(providers, {
- ...(effectiveVisibleAfterIso !== undefined
- ? { visibleAfterIso: effectiveVisibleAfterIso }
- : {}),
- dismissedKeys,
- });
-
- useEffect(() => {
- if (visibleAfterIso === undefined && effectiveVisibleAfterIso !== undefined) {
- setVisibleAfterIso(effectiveVisibleAfterIso);
- }
- }, [effectiveVisibleAfterIso, visibleAfterIso]);
-
- const openProviderSettings = useCallback(() => {
- void navigate({ to: "/settings/providers" });
- }, [navigate]);
- const displayedView = renderedView ?? view;
- const dismissAfterVisibleMs = displayedView?.dismissAfterVisibleMs;
- const viewKey = displayedView?.key ?? null;
- const showDismissProgress =
- dismissAfterVisibleMs !== undefined &&
- displayedView?.tone !== "loading" &&
- exitingKey !== viewKey;
-
- const startExit = useCallback(
- (key: string, nextView: ProviderUpdateSidebarPillView | null, dismissKey?: string) => {
- if (exitingKey === key) {
- return;
- }
- setPendingView(nextView);
- setExitingKey(key);
- setDismissAfterExitKey(dismissKey ?? null);
- },
- [exitingKey],
- );
-
- useEffect(() => {
- if (exitingKey !== null) {
- return;
- }
- if (!renderedView) {
- if (view) {
- setRenderedView(view);
- }
- return;
- }
- if (!view) {
- startExit(renderedView.key, null);
- return;
- }
- if (view.key !== renderedView.key) {
- startExit(renderedView.key, view);
- return;
- }
- }, [exitingKey, renderedView, startExit, view]);
-
- useEffect(() => {
- if (!dismissAfterVisibleMs || !viewKey) {
- return;
- }
- if (exitingKey === viewKey) {
- return;
- }
- const timeoutId = window.setTimeout(() => {
- startExit(viewKey, null, viewKey);
- }, dismissAfterVisibleMs);
-
- return () => window.clearTimeout(timeoutId);
- }, [dismissAfterVisibleMs, exitingKey, startExit, viewKey]);
-
- if (!displayedView) {
- return null;
- }
-
- return (
- {
- if (event.target !== event.currentTarget) {
- return;
- }
- if (!displayedView || exitingKey !== displayedView.key) {
- return;
- }
- if (dismissAfterExitKey === displayedView.key) {
- setDismissedKeys((previous) => new Set(previous).add(displayedView.key));
- }
- setRenderedView(pendingView);
- setPendingView(null);
- setExitingKey(null);
- setDismissAfterExitKey(null);
- }}
- >
- {showDismissProgress ? (
-
- ) : null}
-
-
-
- {displayedView.tone === "loading" ? (
-
- ) : displayedView.tone === "success" ? (
-
- ) : displayedView.tone === "error" ? (
-
- ) : (
-
- )}
- {displayedView.title}
-
- }
- />
- {displayedView.description}
-
- {displayedView.dismissible && (
-
- startExit(displayedView.key, null, displayedView.key)}
- >
-
-
- }
- />
- Dismiss until provider status changes
-
- )}
-
- );
-}
diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.test.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.test.tsx
index d3c6f368a54df3ed559427eba83f9d8e45541897..770300cb99520fefc9b39d9b9f933e523b93d827 100644
--- a/apps/web/src/components/sidebar/SidebarUpdatePill.test.tsx
+++ b/apps/web/src/components/sidebar/SidebarUpdatePill.test.tsx
@@ -1,23 +1,56 @@
+// @vitest-environment jsdom
import type { DesktopUpdateState } from "@t3tools/contracts";
-import { describe, expect, it, vi } from "vite-plus/test";
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
-import {
- handleSidebarUpdateReleaseNotesPopoverOpenChange,
- openSidebarUpdateReleaseNotesPopoverOnForwardTab,
- shouldUseSidebarUpdateReleaseNotesPopover,
-} from "./SidebarUpdatePill";
+const test = vi.hoisted(() => ({
+ state: null as DesktopUpdateState | null,
+ run: vi.fn().mockResolvedValue(undefined),
+ hasUpdates: true,
+ restarts: [] as Array<{ environmentId: string; continuesThreads: boolean }>,
+ toast: vi.fn(),
+}));
-const nightlyState: DesktopUpdateState = {
+vi.mock("../../env", () => ({ isElectron: true }));
+vi.mock("../../hooks/useMediaQuery", () => ({ useMediaQuery: () => true }));
+vi.mock("../../state/desktopUpdate", () => ({ useDesktopUpdateState: () => test.state }));
+vi.mock("../../state/server", () => ({ serverEnvironment: {} }));
+vi.mock("~/state/threads", () => ({ environmentThreadShells: {} }));
+vi.mock("@effect/atom-react", () => ({ useAtomValue: () => 0 }));
+vi.mock("./useUpdateEverything", () => ({
+ useUpdateEverything: () => ({
+ hasUpdates: test.hasUpdates,
+ servers: [],
+ summary: "Update providers",
+ lines: ["Home: Codex"],
+ skipped: [],
+ restarts: test.restarts,
+ run: test.run,
+ }),
+}));
+vi.mock("./updateEverythingRun", () => ({ useUpdateEverythingRun: () => null }));
+vi.mock("../ui/toast", () => ({
+ toastManager: { add: test.toast },
+ stackedThreadToast: (toast: unknown) => toast,
+}));
+vi.mock("../desktopUpdate.toast", () => ({ showDesktopUpdateDownloadedToast: vi.fn() }));
+vi.mock("../../localApi", () => ({ ensureLocalApi: vi.fn() }));
+
+import { ensureLocalApi } from "../../localApi";
+import { AppMenuUpdates } from "./SidebarUpdatePill";
+
+const baseState: DesktopUpdateState = {
enabled: true,
- status: "available",
- channel: "nightly",
- currentVersion: "0.0.35",
+ status: "idle",
+ channel: "latest",
+ currentVersion: "1.0.0",
hostArch: "arm64",
appArch: "arm64",
runningUnderArm64Translation: false,
- availableVersion: "0.0.36-nightly.3",
+ availableVersion: null,
downloadedVersion: null,
- releaseNotes: [{ version: "0.0.36-nightly.3", items: ["Newest change"], totalItems: 1 }],
+ releaseNotes: [],
omittedReleaseCount: 0,
downloadPercent: null,
checkedAt: null,
@@ -26,61 +59,172 @@ const nightlyState: DesktopUpdateState = {
canRetry: false,
};
-describe("sidebar update release notes popover", () => {
- it("uses the popover only for visible nightly release notes", () => {
- expect(shouldUseSidebarUpdateReleaseNotesPopover(true, nightlyState)).toBe(true);
- expect(shouldUseSidebarUpdateReleaseNotesPopover(false, nightlyState)).toBe(false);
- expect(
- shouldUseSidebarUpdateReleaseNotesPopover(true, {
- ...nightlyState,
- channel: "latest",
- }),
- ).toBe(false);
- expect(
- shouldUseSidebarUpdateReleaseNotesPopover(true, {
- ...nightlyState,
- releaseNotes: [],
- }),
- ).toBe(false);
+let root: Root;
+let container: HTMLDivElement;
+
+async function click(label: string) {
+ const button = Array.from(document.querySelectorAll("button")).find(
+ (element) => element.textContent?.trim() === label,
+ );
+ expect(button, `Button ${label}`).toBeDefined();
+ await act(async () => button?.click());
+}
+
+beforeEach(() => {
+ test.state = baseState;
+ test.hasUpdates = true;
+ test.restarts = [];
+ test.run.mockReset().mockResolvedValue(undefined);
+ test.toast.mockReset();
+ vi.mocked(ensureLocalApi).mockReset();
+ container = document.createElement("div");
+ document.body.append(container);
+ root = createRoot(container);
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+});
+
+afterEach(async () => {
+ await act(async () => root.unmount());
+ container.remove();
+ Reflect.deleteProperty(window, "desktopBridge");
+ vi.unstubAllGlobals();
+});
+
+describe("app menu update execution", () => {
+ it("updates providers directly when no app or server will restart", async () => {
+ await act(async () => root.render());
+ await click("Update all");
+
+ expect(test.run).toHaveBeenCalledExactlyOnceWith({});
+ expect(document.querySelector('[role="alertdialog"]')).toBeNull();
});
- it("cancels trigger presses without canceling other open reasons", () => {
- const cancelTriggerPress = vi.fn();
- const cancelHover = vi.fn();
+ it("waits for confirmation when a remote server will restart", async () => {
+ test.restarts = [{ environmentId: "home", continuesThreads: false }];
+ await act(async () => root.render());
+ await click("Update all");
- handleSidebarUpdateReleaseNotesPopoverOpenChange(true, {
- reason: "trigger-press",
- cancel: cancelTriggerPress,
- });
- handleSidebarUpdateReleaseNotesPopoverOpenChange(true, {
- reason: "trigger-hover",
- cancel: cancelHover,
- });
+ expect(test.run).not.toHaveBeenCalled();
+ expect(document.querySelector('[role="alertdialog"]')).not.toBeNull();
+ await click("Update");
+ expect(test.run).toHaveBeenCalledOnce();
+ });
+
+ it("keeps an app update behind restart confirmation even without connected servers", async () => {
+ test.state = { ...baseState, status: "available", availableVersion: "1.1.0" };
+ test.hasUpdates = false;
+ await act(async () => root.render());
+ await click("Update all");
- expect(cancelTriggerPress).toHaveBeenCalledOnce();
- expect(cancelHover).not.toHaveBeenCalled();
+ expect(test.run).not.toHaveBeenCalled();
+ expect(document.querySelector('[role="alertdialog"]')).not.toBeNull();
+ await click("Cancel");
+ expect(test.run).not.toHaveBeenCalled();
});
- it("promotes forward Tab without preventing native navigation", () => {
- const open = vi.fn();
- const preventDefault = vi.fn();
- const event = { key: "Tab", shiftKey: false, preventDefault };
+ it("reports a failed update run and allows retry", async () => {
+ test.run.mockRejectedValueOnce(new Error("Disconnected"));
+ await act(async () => root.render());
+ await click("Update all");
- openSidebarUpdateReleaseNotesPopoverOnForwardTab(event, { open }, "nightly-release-notes");
+ expect(test.toast).toHaveBeenCalledWith({
+ type: "error",
+ title: "Could not update everything",
+ description: "Disconnected",
+ });
+ await click("Update all");
+ expect(test.run).toHaveBeenCalledTimes(2);
+ });
+ it("retries a failed app download without restarting", async () => {
+ test.state = {
+ ...baseState,
+ status: "error",
+ errorContext: "download",
+ availableVersion: "1.1.0",
+ message: "Offline",
+ canRetry: true,
+ };
+ const downloadUpdate = vi.fn().mockResolvedValue({
+ accepted: true,
+ completed: true,
+ state: { ...test.state, status: "downloaded", downloadedVersion: "1.1.0" },
+ });
+ const installUpdate = vi.fn();
+ Object.defineProperty(window, "desktopBridge", {
+ configurable: true,
+ value: { downloadUpdate, installUpdate },
+ });
+ await act(async () => root.render());
+ await click("Retry download");
+
+ expect(downloadUpdate).toHaveBeenCalledOnce();
+ expect(installUpdate).not.toHaveBeenCalled();
+ });
- expect(open).toHaveBeenCalledWith("nightly-release-notes");
- expect(preventDefault).not.toHaveBeenCalled();
+ it("reloads a compatible UI update without asking to interrupt work", async () => {
+ test.state = {
+ ...baseState,
+ status: "downloaded",
+ downloadedVersion: "1.1.0",
+ installKind: "ui",
+ };
+ const installUpdate = vi
+ .fn()
+ .mockResolvedValue({ accepted: true, completed: true, state: { ...baseState } });
+ Object.defineProperty(window, "desktopBridge", {
+ configurable: true,
+ value: { installUpdate },
+ });
+ await act(async () => root.render());
+ await click("Reload");
+ expect(installUpdate).toHaveBeenCalledOnce();
+ expect(ensureLocalApi).not.toHaveBeenCalled();
});
- it("does not promote backward Tab", () => {
- const open = vi.fn();
+ it.each([false, true])(
+ "requires confirmation before a full-app restart (confirm=%s)",
+ async (accepted) => {
+ test.state = {
+ ...baseState,
+ status: "downloaded",
+ downloadedVersion: "1.1.0",
+ installKind: "app",
+ };
+ const confirm = vi.fn().mockResolvedValue(accepted);
+ const { createLocalApi } =
+ await vi.importActual("../../localApi");
+ const api = createLocalApi();
+ vi.mocked(ensureLocalApi).mockReturnValue({ ...api, dialogs: { ...api.dialogs, confirm } });
+ const installUpdate = vi
+ .fn()
+ .mockResolvedValue({ accepted: true, completed: false, state: test.state });
+ Object.defineProperty(window, "desktopBridge", {
+ configurable: true,
+ value: { installUpdate },
+ });
+ await act(async () => root.render());
+ await click("Restart");
+ expect(confirm).toHaveBeenCalledOnce();
+ expect(installUpdate).toHaveBeenCalledTimes(accepted ? 1 : 0);
+ },
+ );
- openSidebarUpdateReleaseNotesPopoverOnForwardTab(
- { key: "Tab", shiftKey: true },
- { open },
- "nightly-release-notes",
- );
+ it("reports an unavailable app update check", async () => {
+ const checkForUpdate = vi.fn().mockResolvedValue({
+ checked: false,
+ state: { ...baseState, message: "Updates disabled" },
+ });
+ Object.defineProperty(window, "desktopBridge", {
+ configurable: true,
+ value: { checkForUpdate },
+ });
+ await act(async () => root.render());
+ await click("Check");
- expect(open).not.toHaveBeenCalled();
+ expect(test.toast).toHaveBeenCalledWith({
+ type: "error",
+ title: "Could not check for updates",
+ description: "Updates disabled",
+ });
});
});
diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx
index 5f756069f98eb8694057cccfce5c3ba7160e62e1..fb628733931def8fb0ceb05891c7c58ae6dbf216 100644
--- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx
+++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx
@@ -1,47 +1,30 @@
import { useAtomValue } from "@effect/atom-react";
-import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts";
+import type { DesktopUpdateState } from "@t3tools/contracts";
import { Atom } from "effect/reactivity";
import { TriangleAlertIcon } from "lucide-react";
-import {
- type ComponentProps,
- useCallback,
- useEffect,
- useId,
- useMemo,
- useRef,
- useState,
-} from "react";
-import { flushSync } from "react-dom";
+import { useMemo, useRef, useState } from "react";
+
import { isElectron } from "../../env";
import { useMediaQuery } from "../../hooks/useMediaQuery";
-import { cn } from "../../lib/utils";
-import { ensureLocalApi } from "../../localApi";
import { useDesktopUpdateState } from "../../state/desktopUpdate";
import { serverEnvironment } from "../../state/server";
-import { stackedThreadToast, toastManager } from "../ui/toast";
+import { Button } from "../ui/button";
import {
canCheckForUpdate,
getArm64IntelBuildWarningDescription,
- getDesktopUpdateActionError,
getDesktopUpdateButtonTooltip,
getDesktopUpdateDownloadedVersion,
- getDesktopUpdateInstallConfirmationMessage,
isDesktopUpdateButtonDisabled,
resolveDesktopUpdateButtonAction,
shouldShowArm64IntelBuildWarning,
- shouldToastDesktopUpdateActionResult,
} from "../desktopUpdate.logic";
-import { showDesktopUpdateDownloadedToast } from "../desktopUpdate.toast";
-import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
-import { Popover, PopoverCreateHandle, PopoverPopup, PopoverTrigger } from "../ui/popover";
-import { SidebarMenuItem } from "../ui/sidebar";
-import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import {
- DesktopUpdateStatusIcon,
- type DesktopUpdateStatusIconState,
- shouldContinueDesktopUpdateCheckAnimation,
- shouldShowDesktopUpdateCheckIcon,
-} from "./DesktopUpdateStatusIcon";
+ downloadLocalUpdate,
+ installLocalUpdate,
+ performDesktopUpdate,
+ toastUpdateError,
+} from "../desktopUpdate.actions";
+import { DesktopUpdateStatusIcon } from "./DesktopUpdateStatusIcon";
import { SidebarUpdateReleaseNotes } from "./SidebarUpdateReleaseNotes";
import { UpdateEverythingDialog } from "./UpdateEverythingDialog";
import {
@@ -50,163 +33,11 @@ import {
type UpdateEverythingRun,
} from "./updateEverything.logic";
import { useUpdateEverythingRun } from "./updateEverythingRun";
-import { type ReportLocalUpdateError, useUpdateEverything } from "./useUpdateEverything";
+import { useUpdateEverything } from "./useUpdateEverything";
-type SidebarUpdatePopoverChangeDetails = Parameters<
- NonNullable["onOpenChange"]>
->[1];
-type SidebarUpdatePopoverHandle = ReturnType;
-
-export function shouldUseSidebarUpdateReleaseNotesPopover(
- showUpdateDetails: boolean,
- state: DesktopUpdateState | null,
-): boolean {
- return showUpdateDetails && state?.channel === "nightly" && state.releaseNotes.length > 0;
-}
-
-export function handleSidebarUpdateReleaseNotesPopoverOpenChange(
- _open: boolean,
- details: Pick,
-): void {
- // The trigger is the update action, so its presses must not also toggle the Popover.
- if (details.reason === "trigger-press") details.cancel();
-}
-
-export function openSidebarUpdateReleaseNotesPopoverOnForwardTab(
- event: { readonly key: string; readonly shiftKey: boolean },
- handle: Pick,
- triggerId: string,
-): void {
- if (event.key !== "Tab" || event.shiftKey) return;
- // Hover-open popovers do not manage focus. Promote this one before native Tab runs.
- flushSync(() => handle.open(triggerId));
-}
-
-function resolveSidebarUpdatePresentation({
- action,
- showsEverything,
- run,
- isDownloading,
- showCheckIcon,
-}: {
- readonly action: ReturnType;
- /** Remote work is waiting, and this app is not downloading. */
- readonly showsEverything: boolean;
- /** The running "update everything" pass. Its state sets the icon. */
- readonly run: UpdateEverythingRun | null;
- readonly isDownloading: boolean;
- readonly showCheckIcon: boolean;
-}) {
- const showUpdateDetails = action !== "none" || showsEverything || run !== null || isDownloading;
- const iconStatus: DesktopUpdateStatusIconState = showCheckIcon
- ? "checking"
- : isDownloading
- ? "downloading"
- : run !== null
- ? run.localVersion !== null && action === "install"
- ? "downloaded"
- : "everything"
- : showsEverything
- ? "everything"
- : action === "install"
- ? "downloaded"
- : action === "download"
- ? "available"
- : "idle";
-
- return {
- iconStatus,
- showUpdateDetails,
- showUpdateIconState: showUpdateDetails && !showCheckIcon,
- } as const;
-}
-
-export function SidebarUpdateArchitectureWarning() {
- return isElectron ? : null;
-}
-
-function SidebarUpdateArchitectureWarningContent() {
- const state = useDesktopUpdateState();
- const visible = shouldShowArm64IntelBuildWarning(state);
- const description = state && visible ? getArm64IntelBuildWarningDescription(state) : null;
-
- if (!visible || !description) return null;
-
- return (
-
-
- Intel build on Apple Silicon
- {description}
-
- );
-}
-
-const toastUpdateError: ReportLocalUpdateError = (title, description) => {
- toastManager.add(stackedThreadToast({ type: "error", title, description }));
-};
-
-/** Downloads this app's update. Resolves true when it is ready to install;
- failures go to `reportError` (a toast by default) and resolve false. */
-async function downloadLocalUpdate(
- bridge: DesktopBridge,
- {
- notifyDownloaded,
- reportError = toastUpdateError,
- }: { readonly notifyDownloaded: boolean; readonly reportError?: ReportLocalUpdateError },
-): Promise {
- try {
- const result = await bridge.downloadUpdate();
- if (result.completed && notifyDownloaded) {
- showDesktopUpdateDownloadedToast(bridge, result.state);
- }
- const actionError = shouldToastDesktopUpdateActionResult(result)
- ? getDesktopUpdateActionError(result)
- : null;
- if (actionError) reportError("Could not download update", actionError);
- return result.completed;
- } catch (error) {
- reportError(
- "Could not start update download",
- error instanceof Error ? error.message : "An unexpected error occurred.",
- );
- return false;
- }
-}
-
-/** Installs this app's downloaded update, which relaunches the app. Resolves
- true when the relaunch is under way. Failures go to `reportError` (a toast
- by default). A refused install (the download is not marked ready yet)
- leaves the restart to the user. */
-async function installLocalUpdate(
- bridge: DesktopBridge,
- reportError: ReportLocalUpdateError = toastUpdateError,
-): Promise {
- try {
- const result = await bridge.installUpdate();
- if (!result.accepted) {
- showDesktopUpdateDownloadedToast(bridge, result.state);
- return false;
- }
- const actionError = shouldToastDesktopUpdateActionResult(result)
- ? getDesktopUpdateActionError(result)
- : null;
- if (!actionError) return true;
- reportError("Could not install update", actionError);
- return false;
- } catch (error) {
- reportError(
- "Could not install update",
- error instanceof Error ? error.message : "An unexpected error occurred.",
- );
- return false;
- }
-}
-
-/** Tooltip lines for a running pass: this app first, then one per machine. */
-function useUpdateRunTooltip(
+function useUpdateRunStatus(
run: UpdateEverythingRun | null,
state: DesktopUpdateState | null,
- localReady: boolean,
): string | null {
const statusesAtom = useMemo(
() =>
@@ -233,178 +64,49 @@ function useUpdateRunTooltip(
? `downloading${state.downloadPercent === null ? "" : ` ${Math.floor(state.downloadPercent)}%`}`
: run.phase === "installing"
? "restarting"
- : localReady
+ : state && resolveDesktopUpdateButtonAction(state) === "install"
? "restarts last"
: "waiting";
return describeUpdateRun(statuses, local).join("\n");
}
-/**
- * The sidebar update button. With only a local app update it downloads, then
- * installs. When other machines' servers or any providers are behind, one
- * click updates everything: providers, then servers, then this app last. On
- * web there is no local app, so the button shows only for remote updates.
- */
-export function SidebarUpdatePill() {
- return ;
-}
-
-function SidebarUpdateControl() {
+export function AppMenuUpdates() {
const state = useDesktopUpdateState();
const [isActionPending, setIsActionPending] = useState(false);
const [isConfirmingEverything, setIsConfirmingEverything] = useState(false);
- const [checkAnimationKey, setCheckAnimationKey] = useState(0);
- const [isCheckAnimationLatched, setIsCheckAnimationLatched] = useState(false);
- const [releaseNotesPopoverHandle] = useState(() => PopoverCreateHandle());
- const suppressReleaseNotesFocusOpen = useRef(false);
- const releaseNotesPopupRef = useRef(null);
- const releaseNotesTriggerId = useId();
+ const pending = useRef(false);
const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
-
- useEffect(() => {
- if (prefersReducedMotion) {
- setIsCheckAnimationLatched(false);
- } else if (state?.status === "checking") {
- setIsCheckAnimationLatched(true);
- }
- }, [prefersReducedMotion, state?.status]);
-
const action = state ? resolveDesktopUpdateButtonAction(state) : "none";
- const isDownloading = state?.status === "downloading";
- // Remote servers follow this app to the version it is about to install.
const localUpdateVersion =
state && action !== "none" ? getDesktopUpdateDownloadedVersion(state) : null;
- const everything = useUpdateEverything(localUpdateVersion);
- const runsEverything = everything.hasUpdates && !isDownloading;
- // Providers leave the list once queued, so the run keeps its own state.
+ const restartsApp = state?.installKind !== "ui";
+ const everything = useUpdateEverything(restartsApp ? localUpdateVersion : null);
const run = useUpdateEverythingRun();
- const isRunningEverything = run !== null;
- const runTooltip = useUpdateRunTooltip(run, state, action === "install");
- const showCheckIcon = shouldShowDesktopUpdateCheckIcon({
- isAnimationLatched: isCheckAnimationLatched,
- isChecking: state?.status === "checking",
- prefersReducedMotion,
- });
- const { iconStatus, showUpdateDetails, showUpdateIconState } = resolveSidebarUpdatePresentation({
- action,
- showsEverything: runsEverything,
- run,
- isDownloading,
- showCheckIcon,
- });
- const tooltip =
- runTooltip !== null
- ? runTooltip
- : runsEverything
- ? everything.summary
- : showUpdateDetails
- ? state
- ? getDesktopUpdateButtonTooltip(state)
- : "Update available"
- : showCheckIcon
- ? "Checking for updates…"
- : "Check for updates";
- const disabled = showCheckIcon
- ? true
- : runsEverything
- ? false
- : showUpdateDetails
- ? isDesktopUpdateButtonDisabled(state)
- : !canCheckForUpdate(state);
- const isInteractionDisabled = disabled || isActionPending || isRunningEverything;
- // During a run the tooltip shows its progress instead of release notes.
- const showReleaseNotesPopover =
- !isRunningEverything && shouldUseSidebarUpdateReleaseNotesPopover(showUpdateDetails, state);
-
- useEffect(() => {
- if (!showReleaseNotesPopover) {
- releaseNotesPopoverHandle.close();
- return;
- }
-
- const trigger = document.getElementById(releaseNotesTriggerId);
- if (trigger?.matches(":focus-visible")) {
- releaseNotesPopoverHandle.open(releaseNotesTriggerId);
- }
- }, [releaseNotesPopoverHandle, releaseNotesTriggerId, showReleaseNotesPopover]);
-
- const handleAction = useCallback(async () => {
- const bridge = window.desktopBridge;
- if (isInteractionDisabled) return;
-
- if (runsEverything) {
- setIsConfirmingEverything(true);
- return;
- }
-
- if (!bridge || !state) return;
- setIsActionPending(true);
-
- if (action === "download") {
- void downloadLocalUpdate(bridge, { notifyDownloaded: true }).finally(() =>
- setIsActionPending(false),
- );
- return;
- }
-
- if (action === "install") {
- let confirmed = false;
- try {
- confirmed = await ensureLocalApi().dialogs.confirm(
- getDesktopUpdateInstallConfirmationMessage(state),
- );
- } catch (error) {
- setIsActionPending(false);
- toastManager.add(
- stackedThreadToast({
- type: "error",
- title: "Could not confirm update",
- description: error instanceof Error ? error.message : "Update confirmation failed.",
- }),
- );
- return;
- }
- if (!confirmed) {
- setIsActionPending(false);
- return;
- }
- void installLocalUpdate(bridge).finally(() => setIsActionPending(false));
- return;
- }
-
- if (!prefersReducedMotion) {
- setIsCheckAnimationLatched(true);
- setCheckAnimationKey((key) => key + 1);
- }
- void bridge
- .checkForUpdate()
- .then((result) => {
- if (result.checked) return;
- toastManager.add(
- stackedThreadToast({
- type: "error",
- title: "Could not check for updates",
- description:
- result.state.message ?? "Automatic updates are not available in this build.",
- }),
- );
- })
- .catch((error) => {
- toastManager.add(
- stackedThreadToast({
- type: "error",
- title: "Could not check for updates",
- description: error instanceof Error ? error.message : "Update check failed.",
- }),
- );
- })
- .finally(() => setIsActionPending(false));
- }, [action, isInteractionDisabled, prefersReducedMotion, runsEverything, state]);
-
- const runEverything = useCallback(async () => {
- const bridge = window.desktopBridge;
+ const runStatus = useUpdateRunStatus(run, state);
+ const isDownloading = state?.status === "downloading";
+ const isBusy = isActionPending || run !== null;
+ const hasUpdates = localUpdateVersion !== null || everything.hasUpdates;
+ const appActionLabel =
+ state?.status === "checking"
+ ? "Checking…"
+ : isDownloading
+ ? "Downloading…"
+ : action === "download"
+ ? state?.status === "error"
+ ? "Retry download"
+ : "Download"
+ : action === "install"
+ ? restartsApp
+ ? "Restart"
+ : "Reload"
+ : "Check";
+
+ const runEverything = async () => {
+ if (pending.current || run !== null) return;
+ pending.current = true;
setIsConfirmingEverything(false);
setIsActionPending(true);
+ const bridge = window.desktopBridge;
try {
await everything.run({
...(bridge && action === "download"
@@ -413,152 +115,152 @@ function SidebarUpdateControl() {
downloadLocalUpdate(bridge, { notifyDownloaded: false, reportError }),
}
: {}),
- ...(bridge && action !== "none"
- ? { installLocal: (reportError) => installLocalUpdate(bridge, reportError) }
+ ...(bridge && restartsApp && action !== "none"
+ ? {
+ installLocal: (reportError) => installLocalUpdate(bridge, reportError),
+ }
: {}),
});
+ if (bridge && !restartsApp && action === "install") await installLocalUpdate(bridge);
} catch (error) {
- toastManager.add(
- stackedThreadToast({
- type: "error",
- title: "Could not update everything",
- description: error instanceof Error ? error.message : "An unexpected error occurred.",
- }),
+ toastUpdateError(
+ "Could not update everything",
+ error instanceof Error ? error.message : "Try again.",
);
} finally {
+ pending.current = false;
setIsActionPending(false);
}
- }, [action, everything]);
+ };
- const handleCheckAnimationIteration = useCallback(() => {
- setIsCheckAnimationLatched(
- shouldContinueDesktopUpdateCheckAnimation({
- isChecking: state?.status === "checking",
- prefersReducedMotion,
- }),
- );
- }, [prefersReducedMotion, state?.status]);
-
- // Web has no local app to check, so it only shows the button for remote work.
- if (!isElectron && !runsEverything && !isRunningEverything) return null;
+ const handleAppAction = async () => {
+ const bridge = window.desktopBridge;
+ if (!bridge || !state || pending.current || isBusy) return;
+ pending.current = true;
+ setIsActionPending(true);
+ try {
+ await performDesktopUpdate(bridge, state);
+ } finally {
+ pending.current = false;
+ setIsActionPending(false);
+ }
+ };
- const updateButton = (
-
- );
+ const showArchitectureWarning = shouldShowArm64IntelBuildWarning(state);
+ const appStatus =
+ state?.status === "checking"
+ ? "Checking for updates…"
+ : state
+ ? getDesktopUpdateButtonTooltip(state)
+ : "Loading update status…";
+ const iconStatus =
+ state?.status === "checking"
+ ? "checking"
+ : isDownloading
+ ? "downloading"
+ : action === "install"
+ ? "downloaded"
+ : action === "download"
+ ? "available"
+ : "idle";
return (
-
- {
- if (open && !showReleaseNotesPopover) {
- details.cancel();
- return;
- }
- handleSidebarUpdateReleaseNotesPopoverOpenChange(open, details);
- }}
- >
-
-
- }
- />
- {!showReleaseNotesPopover ? (
-
- {tooltip}
-
- ) : null}
-
- {showReleaseNotesPopover && state ? (
- {
- if (
- event.key === "Escape" &&
- releaseNotesPopupRef.current?.contains(document.activeElement)
- ) {
- suppressReleaseNotesFocusOpen.current = true;
+
+
+
Updates
+ {hasUpdates || run !== null ? (
+
) : null}
-
+
+ {isElectron ? (
+
+ {/* Icon column width; the download ring overflows it evenly. */}
+
+
+
+
+
App
+
+ {appStatus}
+
+
+
+
+ ) : null}
+ {showArchitectureWarning && state ? (
+
+
+ {getArm64IntelBuildWarningDescription(state)}
+
+ ) : null}
+ {state?.channel === "nightly" && state.releaseNotes.length > 0 ? (
+
+
+ Release notes
+
+
+
+ ) : null}
+ {everything.servers.map((server) => (
+
+ {server.serverLabel}
+ Server {server.targetVersion}
+
+ ))}
+ {runStatus ? (
+
+ {runStatus}
+
+ ) : null}
+ {everything.skipped.length > 0 ? (
+
+
+ Unavailable machines ({everything.skipped.length})
+
+
+ {everything.skipped.map((machine) => (
+ -
+ {machine.label}: {machine.reason}
+
+ ))}
+
+
+ ) : null}
void runEverything()}
/>
-
+
);
}
diff --git a/apps/web/src/components/sidebar/updateEverythingRun.tsx b/apps/web/src/components/sidebar/updateEverythingRun.tsx
index 0179e78acee272994a805d82ee242119ae9bb6ed..050c9a21c0032cdd29888fbb1530ced1f0132ff1 100644
--- a/apps/web/src/components/sidebar/updateEverythingRun.tsx
+++ b/apps/web/src/components/sidebar/updateEverythingRun.tsx
@@ -15,7 +15,7 @@ import {
type UpdateEverythingRun,
} from "./updateEverything.logic";
-/** The running "update everything" pass, or null. Outlives the sidebar button. */
+/** The running "update everything" pass, or null. Outlives the app menu. */
const updateEverythingRunAtom = Atom.make(null).pipe(
Atom.keepAlive,
Atom.withLabel("update-everything-run"),
@@ -133,7 +133,7 @@ export function DesktopUpdateLaunchNotice() {
stackedThreadToast({
type: "error",
title: "Update did not apply",
- description: `The Knife is still on ${APP_VERSION}. Install ${notAppliedVersion} again from the update button.`,
+ description: `The Knife is still on ${APP_VERSION}. Install ${notAppliedVersion} again from the app menu.`,
}),
);
}, [notAppliedVersion]);
diff --git a/apps/web/src/components/sidebar/useUpdateEverything.ts b/apps/web/src/components/sidebar/useUpdateEverything.ts
index 4f1333ee28613f9e9d8ec01ba67add14f85d1043..e980209b669dfd431b12ba7fbe0bbc02e51f986f 100644
--- a/apps/web/src/components/sidebar/useUpdateEverything.ts
+++ b/apps/web/src/components/sidebar/useUpdateEverything.ts
@@ -34,7 +34,7 @@ import {
export type ReportLocalUpdateError = (title: string, description: string) => void;
/**
- * The remote half of the sidebar update button: outdated providers on every
+ * The remote half of the app menu updater: outdated providers on every
* connected machine, and servers behind the version this client runs or is
* about to install (`localUpdateVersion`). Servers that install from npm appear
* only once npm confirms that version. `run` performs the whole pass, including
@@ -104,6 +104,7 @@ export function useUpdateEverything(localUpdateVersion: string | null) {
});
return {
+ servers,
hasUpdates: servers.length > 0 || providers.machines.length > 0,
/** Short label for the tooltip and the confirmation title. */
summary,
diff --git a/apps/web/src/components/threadSidebarWidth.test.ts b/apps/web/src/components/threadSidebarWidth.test.ts
index 41f6b97c928346150a98973e5d46b7351a8c5cf0..15f8f69448714e254a7d04f0004cc4e12b04e95b 100644
--- a/apps/web/src/components/threadSidebarWidth.test.ts
+++ b/apps/web/src/components/threadSidebarWidth.test.ts
@@ -2,35 +2,23 @@ import { describe, expect, it } from "vite-plus/test";
import {
clampThreadSidebarWidth,
resolveThreadSidebarMaximumWidth,
- resolveThreadSidebarMinimumWidth,
THREAD_SIDEBAR_MIN_WIDTH,
} from "./threadSidebarWidth";
-describe("resolveThreadSidebarMinimumWidth", () => {
- it("keeps the default minimum when the brand fits", () => {
- expect(resolveThreadSidebarMinimumWidth(0)).toBe(THREAD_SIDEBAR_MIN_WIDTH);
- expect(resolveThreadSidebarMinimumWidth(194)).toBe(THREAD_SIDEBAR_MIN_WIDTH);
- });
-
- it("grows to a brand wider than the default, rounding up", () => {
- expect(resolveThreadSidebarMinimumWidth(237.2)).toBe(238);
- });
-});
-
describe("resolveThreadSidebarMaximumWidth", () => {
- it("never drops below a raised minimum on a narrow viewport", () => {
- expect(resolveThreadSidebarMaximumWidth(800, 238)).toBe(238);
- expect(resolveThreadSidebarMaximumWidth(1200, 238)).toBe(560);
+ it("keeps the sidebar usable on a narrow viewport", () => {
+ expect(resolveThreadSidebarMaximumWidth(800)).toBe(THREAD_SIDEBAR_MIN_WIDTH);
+ expect(resolveThreadSidebarMaximumWidth(1200)).toBe(560);
});
});
describe("clampThreadSidebarWidth", () => {
- it("widens a stored width below a raised minimum", () => {
- expect(clampThreadSidebarWidth(208, 238, 560)).toBe(238);
+ it("widens a stored width below the minimum", () => {
+ expect(clampThreadSidebarWidth(180, 560)).toBe(THREAD_SIDEBAR_MIN_WIDTH);
});
it("keeps widths inside the range and caps wide ones", () => {
- expect(clampThreadSidebarWidth(300, 238, 560)).toBe(300);
- expect(clampThreadSidebarWidth(900, 238, 560)).toBe(560);
+ expect(clampThreadSidebarWidth(300, 560)).toBe(300);
+ expect(clampThreadSidebarWidth(900, 560)).toBe(560);
});
});
diff --git a/apps/web/src/components/threadSidebarWidth.ts b/apps/web/src/components/threadSidebarWidth.ts
index ea9967c1f06de55a47b8f11be2edcb8bbb97f4e8..c05ef438616e2f6847030b516deb901403cc2d58 100644
--- a/apps/web/src/components/threadSidebarWidth.ts
+++ b/apps/web/src/components/threadSidebarWidth.ts
@@ -3,24 +3,15 @@ const THREAD_SIDEBAR_DEFAULT_WIDTH = 16 * 16;
export const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16;
export const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16;
-// The brand's measured width can raise the minimum so "T3 Code" never clips.
-export function resolveThreadSidebarMinimumWidth(brandWidth: number): number {
- return Math.max(THREAD_SIDEBAR_MIN_WIDTH, Math.ceil(brandWidth));
+export function resolveThreadSidebarMaximumWidth(viewportWidth: number): number {
+ return Math.max(
+ THREAD_SIDEBAR_MIN_WIDTH,
+ Math.floor(viewportWidth) - THREAD_MAIN_CONTENT_MIN_WIDTH,
+ );
}
-export function resolveThreadSidebarMaximumWidth(
- viewportWidth: number,
- minimumWidth = THREAD_SIDEBAR_MIN_WIDTH,
-): number {
- return Math.max(minimumWidth, Math.floor(viewportWidth) - THREAD_MAIN_CONTENT_MIN_WIDTH);
-}
-
-export function clampThreadSidebarWidth(
- width: number,
- minimumWidth: number,
- maximumWidth: number,
-): number {
- return Math.min(maximumWidth, Math.max(minimumWidth, width));
+export function clampThreadSidebarWidth(width: number, maximumWidth: number): number {
+ return Math.min(maximumWidth, Math.max(THREAD_SIDEBAR_MIN_WIDTH, width));
}
export function resolveInitialThreadSidebarWidth(
diff --git a/apps/web/src/components/ui/alert-dialog.focus.test.tsx b/apps/web/src/components/ui/alert-dialog.focus.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..33bb99ed67f03662ef6b3c7f2ec868944265ace6
--- /dev/null
+++ b/apps/web/src/components/ui/alert-dialog.focus.test.tsx
@@ -0,0 +1,88 @@
+// @vitest-environment jsdom
+
+import { act, createRef, useState } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
+
+import {
+ AlertDialog,
+ AlertDialogClose,
+ AlertDialogFooter,
+ AlertDialogPopup,
+ AlertDialogTitle,
+} from "./alert-dialog";
+import { Button } from "./button";
+
+let root: Root;
+let container: HTMLDivElement;
+const cancel = createRef();
+
+beforeEach(() => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ container = document.createElement("div");
+ document.body.append(container);
+ root = createRoot(container);
+});
+
+afterEach(async () => {
+ await act(async () => root.unmount());
+ container.remove();
+ vi.unstubAllGlobals();
+});
+
+function Confirmation({ disabled = false, explicitFocus = false }) {
+ const [open, setOpen] = useState(false);
+ return (
+ <>
+
+
+
+ Remove fixture?
+
+ }>
+ Cancel
+
+
+
+
+
+ >
+ );
+}
+
+async function open() {
+ const trigger = container.querySelector("button")!;
+ await act(async () => {
+ trigger.focus();
+ trigger.click();
+ });
+}
+
+it("focuses confirmation on every opening and lets Escape cancel", async () => {
+ await act(async () => root.render());
+ await open();
+ await vi.waitFor(() => expect(document.activeElement?.textContent).toBe("Remove"));
+ await act(async () => {
+ document.activeElement?.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
+ );
+ });
+ await vi.waitFor(() => expect(document.querySelector('[role="alertdialog"]')).toBeNull());
+ expect(document.activeElement?.textContent).toBe("Open danger dialog");
+ await open();
+ await vi.waitFor(() => expect(document.activeElement?.textContent).toBe("Remove"));
+});
+
+it("keeps disabled confirmation out of the default keyboard action", async () => {
+ await act(async () => root.render());
+ await open();
+ await vi.waitFor(() => expect(document.activeElement?.textContent).toBe("Cancel"));
+});
+
+it("preserves an explicit initial focus target", async () => {
+ await act(async () => root.render());
+ await open();
+ await vi.waitFor(() => expect(document.activeElement?.textContent).toBe("Cancel"));
+});
diff --git a/apps/web/src/components/ui/alert-dialog.tsx b/apps/web/src/components/ui/alert-dialog.tsx
index bef8c3223b9af7bd8147ff31aac05c210d2dbeaa..493a33abc3529f4c4db605195ff75a2ea300e2eb 100644
--- a/apps/web/src/components/ui/alert-dialog.tsx
+++ b/apps/web/src/components/ui/alert-dialog.tsx
@@ -1,6 +1,7 @@
"use client";
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog";
+import { useRef } from "react";
import { cn } from "~/lib/utils";
import {
@@ -41,11 +42,14 @@ function AlertDialogPopup({
className,
bottomStickOnMobile = true,
portalContainer,
+ initialFocus,
+ ref,
...props
}: AlertDialogPrimitive.Popup.Props & {
bottomStickOnMobile?: boolean;
portalContainer?: AlertDialogPrimitive.Portal.Props["container"];
}) {
+ const popup = useRef(null);
return (
@@ -53,6 +57,21 @@ function AlertDialogPopup({
className={cn(bottomStickOnMobile && "max-sm:grid-rows-[1fr_auto] max-sm:p-0 max-sm:pt-12")}
>
{
+ popup.current = element;
+ if (typeof ref === "function") return ref(element);
+ if (ref) ref.current = element;
+ }}
+ initialFocus={
+ initialFocus ??
+ (() => {
+ return (
+ popup.current?.querySelector(
+ '[data-slot="alert-dialog-footer"] button:not([data-slot="alert-dialog-close"]):not(:disabled):not([aria-disabled="true"])',
+ ) ?? true
+ );
+ })
+ }
className={cn(
DIALOG_POPUP_CLASS,
"row-start-2 max-h-full max-w-lg text-popover-foreground",
diff --git a/apps/web/src/hooks/useNewProject.ts b/apps/web/src/hooks/useNewProject.ts
deleted file mode 100644
index 6c148079cab3e3a9600aace87dfb6eb53e3e2c25..0000000000000000000000000000000000000000
--- a/apps/web/src/hooks/useNewProject.ts
+++ /dev/null
@@ -1,151 +0,0 @@
-import { scopeProjectRef } from "@t3tools/client-runtime/environment";
-import {
- getNewProjectRepository,
- type NewProjectPublishTarget,
-} from "@t3tools/client-runtime/operations/projects";
-import {
- isAtomCommandInterrupted,
- squashAtomCommandFailure,
-} from "@t3tools/client-runtime/state/runtime";
-import type { EnvironmentId } from "@t3tools/contracts";
-import { useCallback } from "react";
-
-import { stackedThreadToast, toastManager } from "~/components/ui/toast";
-import { waitForProject } from "~/state/entities";
-import { projectEnvironment } from "~/state/projects";
-import { sourceControlEnvironment } from "~/state/sourceControl";
-import { useAtomCommand } from "~/state/use-atom-command";
-import { useNewThreadHandler } from "./useHandleNewThread";
-
-function errorMessage(error: unknown): string {
- return error instanceof Error && error.message.trim().length > 0
- ? error.message
- : "An error occurred.";
-}
-
-/**
- * Starts a project from just a name. The server makes a folder under its
- * `newProjectsRoot` with a README, an icon, and a first commit; this then
- * opens a new thread draft in it. With `publishTo`, it also publishes the
- * repository as private on that host, without holding up the draft.
- *
- * Resolves to whether the project was created.
- */
-export function useNewProject() {
- const createNew = useAtomCommand(projectEnvironment.createNew, { reportFailure: false });
- const publishRepository = useAtomCommand(sourceControlEnvironment.publishRepository, {
- reportFailure: false,
- });
- const handleNewThread = useNewThreadHandler();
-
- const publishNewRepository = useCallback(
- async (input: {
- readonly environmentId: EnvironmentId;
- readonly workspaceRoot: string;
- readonly target: NewProjectPublishTarget;
- }) => {
- const { label } = input.target.definition;
- const result = await publishRepository({
- environmentId: input.environmentId,
- input: {
- cwd: input.workspaceRoot,
- provider: input.target.definition.kind,
- repository: getNewProjectRepository(input.target, input.workspaceRoot),
- visibility: "private",
- },
- });
- if (result._tag === "Failure") {
- if (!isAtomCommandInterrupted(result)) {
- toastManager.add(
- stackedThreadToast({
- type: "error",
- title: `Could not create the ${label} repository`,
- description: `${errorMessage(squashAtomCommandFailure(result))} Use Publish Repository in the Git menu to try again.`,
- }),
- );
- }
- return;
- }
- toastManager.add(
- stackedThreadToast({
- type: "success",
- title: `Published to ${label}`,
- description: result.value.repository.nameWithOwner,
- }),
- );
- },
- [publishRepository],
- );
-
- return useCallback(
- async (input: {
- readonly environmentId: EnvironmentId;
- readonly name: string;
- readonly publishTo: NewProjectPublishTarget | null;
- }): Promise => {
- const result = await createNew({
- environmentId: input.environmentId,
- input: { name: input.name },
- });
- if (result._tag === "Failure") {
- if (!isAtomCommandInterrupted(result)) {
- toastManager.add(
- stackedThreadToast({
- type: "error",
- title: "Could not create the project",
- description: errorMessage(squashAtomCommandFailure(result)),
- }),
- );
- }
- return false;
- }
-
- const { projectId, workspaceRoot, commitError } = result.value;
- // The folder sits in T3 Code's data directory, so always say where.
- toastManager.add(
- stackedThreadToast(
- commitError === undefined
- ? { type: "success", title: `Created ${input.name}`, description: workspaceRoot }
- : {
- type: "warning",
- title: `Created ${input.name} without a first commit`,
- description: `${commitError} The project is in ${workspaceRoot}.`,
- },
- ),
- );
- if (input.publishTo) {
- void publishNewRepository({
- environmentId: input.environmentId,
- workspaceRoot,
- target: input.publishTo,
- });
- }
-
- const projectRef = scopeProjectRef(input.environmentId, projectId);
- // Drafts key off the project's stored path, so wait for the create event
- // to reach the store before opening one.
- const project = await waitForProject(projectRef).catch((error: unknown) => {
- toastManager.add(
- stackedThreadToast({
- type: "error",
- title: "Failed to open project",
- description: `${errorMessage(error)} It will appear in the sidebar once this client catches up.`,
- }),
- );
- return null;
- });
- if (project === null) return true;
- await handleNewThread(projectRef).catch((error: unknown) => {
- toastManager.add(
- stackedThreadToast({
- type: "error",
- title: "Failed to open project",
- description: errorMessage(error),
- }),
- );
- });
- return true;
- },
- [createNew, handleNewThread, publishNewRepository],
- );
-}
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index 90f204d201fe8b65810bcb6c317f88e675218c17..9ac126d6b766239cdbd6cc8b4ff8c99607b6377f 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -1,5 +1,37 @@
@import "tailwindcss";
+@font-face {
+ font-family: "AT Name Sans";
+ src: url("/fonts/private/ATNameSans-Regular.woff2") format("woff2");
+ font-weight: 1 1000;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "AT Name Sans";
+ src: url("/fonts/private/ATNameSans-Italic.woff2") format("woff2");
+ font-weight: 1 1000;
+ font-style: italic;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "AT Name Mono";
+ src: url("/fonts/private/ATNameMono-Regular.woff2") format("woff2");
+ font-weight: 400 900;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "AT Name Mono";
+ src: url("/fonts/private/ATNameMono-Italic.woff2") format("woff2");
+ font-weight: 400 900;
+ font-style: italic;
+ font-display: swap;
+}
+
@custom-variant dark (&:is(.dark, .dark *));
@custom-variant light (&:not(.dark, .dark *));
/* 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
}
:root {
+ --clo-term-font: "AT Name Mono";
--app-scrollbar-width: 6px;
--appearance-contrast-base: 100%;
--appearance-contrast-boost: 0%;
@@ -164,9 +197,9 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
the variables and Settings -> Appearance can override them at runtime. The
default stacks are mirrored in `appearanceFonts.ts`. */
@theme {
- --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
+ --font-sans: "AT Name Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
--font-mono:
- ui-monospace, "SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace;
+ "AT Name Mono", "SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace;
}
@theme inline {
@@ -493,31 +526,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
var(--app-scrollbar-width) 100%;
}
-/* Stage-channel art needs a mask and pseudo-element gradient, so keep the
- behavior composable without tying it to the global components layer. */
-@utility sidebar-stage-backdrop {
- --stage-fade: var(--sidebar-stage-fade, var(--app-chrome-background));
- mask-image: linear-gradient(to bottom, black 0%, black 55%, transparent 92%);
- -webkit-mask-image: linear-gradient(to bottom, black 0%, black 55%, transparent 92%);
-
- &::after {
- content: "";
- position: absolute;
- inset: 0;
- background: linear-gradient(
- to bottom,
- transparent 0%,
- transparent 28%,
- color-mix(in srgb, var(--stage-fade) 10%, transparent) 40%,
- color-mix(in srgb, var(--stage-fade) 30%, transparent) 52%,
- color-mix(in srgb, var(--stage-fade) 58%, transparent) 64%,
- color-mix(in srgb, var(--stage-fade) 82%, transparent) 75%,
- color-mix(in srgb, var(--stage-fade) 96%, transparent) 85%,
- var(--stage-fade) 93%
- );
- }
-}
-
@keyframes live-tool-shine {
from {
background-position:
@@ -1146,7 +1154,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
--sidebar-row-active: var(--color-white);
--sidebar-row-selected: var(--color-white);
--sidebar-border: var(--border);
- --sidebar-stage-fade: var(--sidebar);
--code-background: color-mix(in srgb, var(--card) 90%, var(--background));
--code-foreground: var(--foreground);
--terminal-background: var(--background);
@@ -1189,7 +1196,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
--sidebar-row-hover: var(--accent);
--sidebar-row-active: var(--accent);
--sidebar-row-selected: var(--muted);
- --sidebar-stage-fade: var(--card);
--terminal-cursor: rgb(180 203 255);
--terminal-selection-background: rgb(180 203 255 / 25%);
}
@@ -1217,7 +1223,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
--sidebar-row-active: var(--color-white);
--sidebar-row-selected: var(--color-white);
--sidebar-border: var(--color-zinc-200);
- --sidebar-stage-fade: var(--sidebar);
@variant dark {
--background: #000;
@@ -1238,7 +1243,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
--sidebar-row-active: color-mix(in srgb, var(--contrast-foreground) 11%, transparent);
--sidebar-row-selected: color-mix(in srgb, var(--contrast-foreground) 7%, transparent);
--sidebar-border: var(--border);
- --sidebar-stage-fade: var(--card);
}
}
@@ -1307,7 +1311,6 @@ html[data-theme-id]:not([data-theme-id=""]) {
--sidebar-row-active: var(--app-theme-sidebar-row-active);
--sidebar-row-selected: var(--app-theme-sidebar-row-selected);
--sidebar-border: var(--app-theme-sidebar-border);
- --sidebar-stage-fade: var(--app-theme-sidebar);
--code-background: var(--app-theme-code-background);
--code-foreground: var(--app-theme-code-foreground);
--terminal-background: var(--app-theme-terminal-background);
@@ -1507,7 +1510,6 @@ html[data-theme-id] [data-app-sidebar] {
--sidebar-row-active: var(--app-theme-sidebar-row-active);
--sidebar-row-selected: var(--app-theme-sidebar-row-selected);
--sidebar-border: var(--app-theme-sidebar-border);
- --sidebar-stage-fade: var(--app-theme-sidebar);
border-color: color-mix(in srgb, var(--contrast-sidebar-foreground) 10%, transparent);
@variant dark {
diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts
index 961a4a178dac963de81002452c3820444a186956..13f9e1ca4ea6a696364027dd38ffd499291ec1af 100644
--- a/apps/web/src/rightPanelStore.test.ts
+++ b/apps/web/src/rightPanelStore.test.ts
@@ -565,7 +565,7 @@ describe("rightPanelStore", () => {
).toEqual({
byThreadKey: {},
threadPanelVisibilityByThreadKey: {
- "env-1:thread-A": { inlineOpen: false, popoverOpen: false },
+ "env-1:thread-B": { inlineOpen: true, popoverOpen: false },
},
});
});
@@ -574,13 +574,13 @@ describe("rightPanelStore", () => {
const store = useRightPanelStore.getState();
expect(selectThreadPanelOpen(store.threadPanelVisibilityByThreadKey, refA, "inline")).toBe(
- true,
+ false,
);
expect(selectThreadPanelOpen(store.threadPanelVisibilityByThreadKey, refA, "popover")).toBe(
false,
);
- store.setThreadPanelOpen(refA, "inline", false);
+ store.toggleThreadPanel(refA, "inline");
store.toggleThreadPanel(refA, "popover");
expect(
@@ -588,13 +588,13 @@ describe("rightPanelStore", () => {
useRightPanelStore.getState().threadPanelVisibilityByThreadKey,
refA,
),
- ).toEqual({ inlineOpen: false, popoverOpen: true });
+ ).toEqual({ inlineOpen: true, popoverOpen: true });
expect(
selectThreadPanelVisibility(
useRightPanelStore.getState().threadPanelVisibilityByThreadKey,
refB,
),
- ).toEqual({ inlineOpen: true, popoverOpen: false });
+ ).toEqual({ inlineOpen: false, popoverOpen: false });
});
it("closes the popover atomically when the real right panel opens", () => {
@@ -606,7 +606,7 @@ describe("rightPanelStore", () => {
useRightPanelStore.getState().threadPanelVisibilityByThreadKey,
refA,
),
- ).toEqual({ inlineOpen: true, popoverOpen: false });
+ ).toEqual({ inlineOpen: false, popoverOpen: false });
});
it("keeps an open popover visible by promoting it to inline when the real panel closes", () => {
diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts
index fa417786a019cf83a3b142b1a968df5adb83a47c..0ab7794d87386ed209ab29c95074613678f9a280 100644
--- a/apps/web/src/rightPanelStore.ts
+++ b/apps/web/src/rightPanelStore.ts
@@ -209,7 +209,7 @@ const EMPTY_THREAD_STATE: ThreadRightPanelState = {
};
const DEFAULT_THREAD_PANEL_VISIBILITY: ThreadPanelVisibility = {
- inlineOpen: true,
+ inlineOpen: false,
popoverOpen: false,
};
@@ -340,7 +340,7 @@ const updateThreadPanelVisibilityMap = (
): Record => {
const current = byThreadKey[threadKey] ?? DEFAULT_THREAD_PANEL_VISIBILITY;
const next = updater(current);
- if (next.inlineOpen && !next.popoverOpen) {
+ if (next.inlineOpen === DEFAULT_THREAD_PANEL_VISIBILITY.inlineOpen && !next.popoverOpen) {
if (!(threadKey in byThreadKey)) return byThreadKey;
const { [threadKey]: _removed, ...rest } = byThreadKey;
return rest;
@@ -589,8 +589,9 @@ export function migratePersistedRightPanelState(persistedState: unknown): {
persistedState.threadPanelVisibilityByThreadKey as Record,
).flatMap(([threadKey, value]) => {
if (!value || typeof value !== "object" || !("inlineOpen" in value)) return [];
- return value.inlineOpen === false
- ? [[threadKey, { inlineOpen: false, popoverOpen: false }]]
+ return typeof value.inlineOpen === "boolean" &&
+ value.inlineOpen !== DEFAULT_THREAD_PANEL_VISIBILITY.inlineOpen
+ ? [[threadKey, { inlineOpen: value.inlineOpen, popoverOpen: false }]]
: [];
}),
)
@@ -1068,7 +1069,9 @@ export const useRightPanelStore = create()(
threadPanelVisibilityByThreadKey: Object.fromEntries(
Object.entries(state.threadPanelVisibilityByThreadKey).flatMap(
([threadKey, visibility]) =>
- visibility.inlineOpen ? [] : [[threadKey, { inlineOpen: false, popoverOpen: false }]],
+ visibility.inlineOpen === DEFAULT_THREAD_PANEL_VISIBILITY.inlineOpen
+ ? []
+ : [[threadKey, { inlineOpen: visibility.inlineOpen, popoverOpen: false }]],
),
),
}),