Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion apps/desktop/src/app/DesktopAppIdentity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,9 @@ describe("DesktopAppIdentity", () => {
assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "T3 Code (Alpha)");
assert.equal(calls.setAboutPanelOptions[0]?.applicationVersion, "1.2.3");
assert.equal(calls.setAboutPanelOptions[0]?.version, "0123456789ab");
assert.deepEqual(calls.setDockIcon, ["/icon.png"]);
// Packaged: the bundle's own icon stands, so a custom one the user
// attached survives.
assert.deepEqual(calls.setDockIcon, []);
}),
{
calls,
Expand All @@ -212,4 +214,28 @@ describe("DesktopAppIdentity", () => {
},
);
});

it.effect("sets the dock icon only when running unpackaged", () => {
const calls: ElectronAppCalls = {
setAboutPanelOptions: [],
setDockIcon: [],
setName: [],
};

return withIdentity(
Effect.gen(function* () {
const identity = yield* DesktopAppIdentity.DesktopAppIdentity;
yield* identity.configure;

// Electron shows a generic icon for an unpackaged run, which is the
// reason this call exists at all.
assert.deepEqual(calls.setDockIcon, ["/icon.png"]);
}),
{
calls,
environment: { isPackaged: false },
pngIconPath: Option.some("/icon.png"),
},
);
});
});
5 changes: 4 additions & 1 deletion apps/desktop/src/app/DesktopAppIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,10 @@ export const make = Effect.gen(function* () {
yield* electronApp.setDesktopName(environment.linuxDesktopEntryName);
}

if (environment.platform === "darwin") {
// Unpackaged runs only. A packaged bundle already carries its icon in
// Info.plist, so setting the dock tile again changes nothing except to
// overwrite a custom icon the user attached to the app themselves.
if (environment.platform === "darwin" && !environment.isPackaged) {
const iconPaths = yield* assets.iconPaths;
yield* Option.match(iconPaths.png, {
onNone: () => Effect.void,
Expand Down
30 changes: 23 additions & 7 deletions apps/desktop/src/window/QuitHold.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,17 +77,32 @@ describe("makeQuitHoldHandler", () => {
expect(harness.notifications).toEqual(["down", "up"]);
});

it("quits once the shortcut auto-repeats past the hold duration", async () => {
it("quits after a completed hold is released", async () => {
const harness = makeHarness();
await harness.send(makeInput({}));
await harness.holdFor(QUIT_HOLD_DURATION_MS - 200);
await harness.holdFor(QUIT_HOLD_DURATION_MS + 200);
expect(harness.quit).not.toHaveBeenCalled();
await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false }));
expect(harness.quit).not.toHaveBeenCalled();
await harness.holdFor(400);
vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS);
expect(harness.quit).toHaveBeenCalledTimes(1);
// Exactly one hint cycle for the whole hold.
expect(harness.notifications).toEqual(["down", "up"]);
});

it("waits for Q release when Cmd is released first", async () => {
const harness = makeHarness();
await harness.send(makeInput({}));
await harness.holdFor(QUIT_HOLD_DURATION_MS + 200);
await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false }));
harness.preventDefault.mockClear();
await harness.send(makeInput({ meta: false, isAutoRepeat: true }));
expect(harness.preventDefault).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(QUIT_HOLD_RELEASE_GRACE_MS * 2);
expect(harness.quit).not.toHaveBeenCalled();
await harness.send(makeInput({ type: "keyUp", meta: false }));
expect(harness.quit).toHaveBeenCalledTimes(1);
});

it("does not quit when the hold stops before the duration", async () => {
const harness = makeHarness();
await harness.send(makeInput({}));
Expand All @@ -107,12 +122,11 @@ describe("makeQuitHoldHandler", () => {
expect(harness.quit).not.toHaveBeenCalled();
});

it("quits immediately on a single press when disabled", async () => {
it("quits without showing a hint when hold-to-quit is disabled", async () => {
const harness = makeHarness({ enabled: false });
await harness.send(makeInput({}));
expect(harness.quit).toHaveBeenCalledTimes(1);
// The hint is dismissed in case the quit gets cancelled downstream.
expect(harness.notifications).toEqual(["down", "up"]);
expect(harness.notifications).toEqual([]);
});

it("discards a stale isEnabled resolution from a superseded press", async () => {
Expand All @@ -138,6 +152,7 @@ describe("makeQuitHoldHandler", () => {
// Press #2 resolves enabled and completes a full hold.
resolvers[1]?.(true);
await harness.holdFor(QUIT_HOLD_DURATION_MS + 200);
await harness.send(makeInput({ type: "keyUp" }));
expect(harness.quit).toHaveBeenCalledTimes(1);
});

Expand Down Expand Up @@ -196,6 +211,7 @@ describe("makeQuitHoldHandler", () => {
await harness.send(makeInput({ meta: false, control: true }));
expect(harness.preventDefault).toHaveBeenCalledTimes(1);
await harness.holdFor(QUIT_HOLD_DURATION_MS + 200, { meta: false, control: true });
await harness.send(makeInput({ type: "keyUp", meta: false, control: true }));
expect(harness.quit).toHaveBeenCalledTimes(1);
});
});
46 changes: 34 additions & 12 deletions apps/desktop/src/window/QuitHold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,20 @@

// Chrome-style hold-to-quit. The quit accelerator is intercepted in
// before-input-event (which runs before the native menu accelerator), and the
// app only quits once the shortcut has been held for QUIT_HOLD_DURATION_MS.
// app only quits after the shortcut has been held for QUIT_HOLD_DURATION_MS
// and released.
// A quick tap just shows the renderer's "Hold to Quit" hint, and a second tap
// within QUIT_DOUBLE_TAP_MS quits immediately. Quitting from the application
// menu itself is untouched and quits immediately.
export const QUIT_HOLD_DURATION_MS = 1200;
// A second quick tap of the shortcut is the user insisting: quit immediately.
export const QUIT_DOUBLE_TAP_MS = 500;
// "Still held" is proven by auto-repeat keydowns, not by the absence of a
// release: macOS suppresses a letter's keyUp while the command key is down, so
// a tap's release can go completely unseen and a release-based timer would
// quit anyway. The press is treated as released once no key event has arrived
// for QUIT_HOLD_RELEASE_GRACE_MS past the hold duration. Keyboards with
// auto-repeat disabled cannot hold-to-quit and fall back to the menu's Quit.
// release: macOS suppresses a letter keyUp while the command key is down, so a
// tap release can go completely unseen and a release-based timer would quit
// anyway. Once held, quitting waits for Q keyUp or a quiet grace period after
// modifier keyUp so repeats cannot reach the next app. Keyboards with
// auto-repeat disabled fall back to the application menu Quit action.
export const QUIT_HOLD_RELEASE_GRACE_MS = 600;

export type QuitHoldState = "down" | "up";
Expand Down Expand Up @@ -42,8 +43,9 @@ export function makeQuitHoldHandler(
const modifierKey = options.platform === "darwin" ? "meta" : "control";
let watchdog: NodeJS.Timeout | undefined;
let holding = false;
// Set once isEnabled resolves true; auto-repeats may only quit when armed.
// Set once isEnabled resolves true; auto-repeats may only complete the hold when armed.
let armed = false;
let quitOnRelease = false;
let heldSince = 0;
let lastPressAt = 0;
// Incremented on every new press and every release/quit so a pending
Expand All @@ -60,14 +62,16 @@ export function makeQuitHoldHandler(

const release = () => {
if (!holding) return;
const shouldNotify = armed || quitOnRelease;
generation += 1;
holding = false;
armed = false;
quitOnRelease = false;
clearWatchdog();
options.notify("up");
if (shouldNotify) options.notify("up");
};

// Dismisses the overlay first: if the quit is cancelled downstream the
// Dismisses any overlay first: if the quit is cancelled downstream the
// renderer must not be left with a stuck "Hold to Quit" hint.
const quitNow = () => {
release();
Expand All @@ -77,11 +81,27 @@ export function makeQuitHoldHandler(
return (event, input) => {
const key = input.key.toLowerCase();
if (input.type === "keyUp") {
if (key === "q" || key === modifierKey) release();
if (key === "q") {
const shouldQuit = quitOnRelease;
release();
if (shouldQuit) options.quit();
} else if (key === modifierKey) {
if (!quitOnRelease) {
release();
} else {
watchdog = setTimeout(quitNow, QUIT_HOLD_RELEASE_GRACE_MS);
}
}
return;
}
if (input.type !== "keyDown") return;

if (quitOnRelease && input.isAutoRepeat && key === "q") {
event.preventDefault();
clearWatchdog();
return;
}

const modifierDown = options.platform === "darwin" ? input.meta : input.control;
if (!modifierDown || input.alt || input.shift || key !== "q") {
// Any other key (or an extra modifier) pressed mid-hold breaks the
Expand All @@ -101,7 +121,9 @@ export function makeQuitHoldHandler(

if (input.isAutoRepeat) {
if (armed && Date.now() - heldSince >= QUIT_HOLD_DURATION_MS) {
quitNow();
armed = false;
quitOnRelease = true;
clearWatchdog();
}
return;
}
Expand All @@ -121,7 +143,6 @@ export function makeQuitHoldHandler(
const pressGeneration = generation;
holding = true;
heldSince = now;
options.notify("down");
void options.isEnabled().then(
(enabled) => {
if (generation !== pressGeneration) return;
Expand All @@ -131,6 +152,7 @@ export function makeQuitHoldHandler(
return;
}
armed = true;
options.notify("down");
// No auto-repeat by then means the key was released (possibly with a
// suppressed keyUp) or repeat is disabled; either way, don't quit.
watchdog = setTimeout(() => {
Expand Down
3 changes: 1 addition & 2 deletions apps/server/src/pullRequest/GitHubPullRequestCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1451,8 +1451,7 @@ export const make = Effect.gen(function* () {
// the page. Narrowed to a command that ran and was refused: a missing `gh` or a
// signed-out one fails the same way for every request.
Effect.catchTags({
GitHubCliCommandError: (error) =>
filesPage(1).pipe(Effect.catch(() => Effect.fail(error))),
GitHubCliCommandError: (error) => filesPage(1).pipe(Effect.mapError(() => error)),
}),
);
},
Expand Down
14 changes: 13 additions & 1 deletion apps/web/src/browser/browserTargetResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,19 @@ describe("browser target resolver", () => {
kind: "environment-port",
port: 5173,
}).resolvedUrl,
).toBe("http://[::1]:5173/");
).toBe("http://localhost:5173/");
});

it("maps local IPv4 environment ports onto localhost for dual-stack guests", async () => {
readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" });
const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver");
expect(
resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), {
kind: "environment-port",
port: 5173,
path: "/app",
}).resolvedUrl,
).toBe("http://localhost:5173/app");
});

it("leaves malformed input for the normal navigation error path", async () => {
Expand Down
10 changes: 7 additions & 3 deletions apps/web/src/browser/browserTargetResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,9 +178,13 @@ const resolveEnvironmentPortTarget = (
const protocol = target.protocol ?? "http";
const path = target.path?.startsWith("/") ? target.path : `/${target.path ?? ""}`;
const normalizedEnvironmentHost = environmentUrl.hostname.replace(/^\[|\]$/g, "");
const resolvedHost = normalizedEnvironmentHost.includes(":")
? `[${normalizedEnvironmentHost}]`
: normalizedEnvironmentHost;
// Local loopback environments should advertise `localhost` so Chromium
// dual-stack lookup can reach a Vite server bound only to ::1 or 127.0.0.1.
const resolvedHost = isLocalLoopbackHost(normalizedEnvironmentHost)
? "localhost"
: normalizedEnvironmentHost.includes(":")
? `[${normalizedEnvironmentHost}]`
: normalizedEnvironmentHost;
const resolved = sourceUrl
? new URL(sourceUrl)
: new URL(path, `${protocol}://${resolvedHost}:${target.port}`);
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/CommandPalette.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,14 +150,15 @@ export function buildProjectActionItems(input: {
icon: (project: Project) => ReactNode;
runProject: (project: Project) => Promise<void>;
searchTerms?: (project: Project) => ReadonlyArray<string>;
renderDescription?: (project: Project) => ReactNode;
shortcutCommand?: KeybindingCommand;
}): CommandPaletteActionItem[] {
return input.projects.map((project) => ({
kind: "action",
value: `${input.valuePrefix}:${project.environmentId}:${project.id}`,
searchTerms: [project.title, project.workspaceRoot, ...(input.searchTerms?.(project) ?? [])],
title: project.title,
description: project.workspaceRoot,
description: input.renderDescription?.(project) ?? project.workspaceRoot,
icon: input.icon(project),
...(input.shortcutCommand !== undefined ? { shortcutCommand: input.shortcutCommand } : {}),
run: async () => {
Expand Down
59 changes: 56 additions & 3 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
LinkIcon,
MessageSquareIcon,
PaletteIcon,
ServerIcon,
SettingsIcon,
SquarePenIcon,
TextSearchIcon,
Expand Down Expand Up @@ -131,7 +132,11 @@ import { ProjectFavicon } from "./ProjectFavicon";
import { ProjectFilePicker } from "./files/ProjectFilePicker";
import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog";
import { toggleThemeEditorForTheme } from "./settings/themeEditorStore";
import { ThreadCommandSubtitle } from "./ThreadCommandSubtitle";
import {
COMMAND_PALETTE_META_ICON_CLASS,
CommandPaletteMetaDot,
ThreadCommandSubtitle,
} from "./ThreadCommandSubtitle";
import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators";
import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server";
import {
Expand Down Expand Up @@ -657,6 +662,27 @@ function OpenCommandPaletteDialog(props: {
),
[environments],
);
const projectEnvironmentLocationById = useMemo(
() =>
new Map(
environments.map((environment) => {
const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget";
const isLocal = isPrimary || isDesktopLocalConnectionTarget(environment.entry.target);
return [
environment.environmentId,
{
kind: isLocal ? "local" : "remote",
label: isPrimary
? "Local"
: isLocal
? `${environment.label} (Local)`
: environment.label,
},
] as const;
}),
),
[environments],
);
const orderedProjects = useMemo(
() =>
orderItemsByPreferredIds({
Expand Down Expand Up @@ -1011,8 +1037,29 @@ function OpenCommandPaletteDialog(props: {
valuePrefix: "new-thread-in",
searchTerms: (project) => {
const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`);
const location = projectEnvironmentLocationById.get(project.environmentId);
return [
...(group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ??
[]),
...(location ? [location.label] : []),
];
},
renderDescription: (project) => {
const location = projectEnvironmentLocationById.get(project.environmentId) ?? {
kind: "remote",
label: "Remote",
};
return (
group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? []
<span className="flex min-w-0 items-center gap-1">
<span className="inline-flex min-w-0 items-center gap-1">
{location.kind === "remote" ? (
<ServerIcon aria-hidden className={COMMAND_PALETTE_META_ICON_CLASS} />
) : null}
<span className="truncate">{location.label}</span>
</span>
<CommandPaletteMetaDot />
<span className="truncate">{project.workspaceRoot}</span>
</span>
);
},
icon: projectFavicon,
Expand All @@ -1033,7 +1080,13 @@ function OpenCommandPaletteDialog(props: {
},
}),
),
[contextualProjectRef, handleNewThread, pickerProjects, projectGroupByTargetKey],
[
contextualProjectRef,
handleNewThread,
pickerProjects,
projectEnvironmentLocationById,
projectGroupByTargetKey,
],
);

const allThreadItems = useMemo(
Expand Down
Loading
Loading