Switch to double quotes and reformat codebase with Biome

This commit is contained in:
Mohamed Boudra
2026-03-21 20:22:15 +07:00
parent ab40786882
commit f6579dbeb4
805 changed files with 38132 additions and 40175 deletions
@@ -10,7 +10,7 @@ describe("shouldClearAgentAttention", () => {
isConnected: true,
requiresAttention: true,
attentionReason: "finished",
})
}),
).toBe(true);
});
@@ -21,7 +21,7 @@ describe("shouldClearAgentAttention", () => {
isConnected: false,
requiresAttention: true,
attentionReason: "finished",
})
}),
).toBe(false);
});
@@ -32,7 +32,7 @@ describe("shouldClearAgentAttention", () => {
isConnected: true,
requiresAttention: false,
attentionReason: null,
})
}),
).toBe(false);
});
@@ -43,7 +43,7 @@ describe("shouldClearAgentAttention", () => {
isConnected: true,
requiresAttention: true,
attentionReason: "finished",
})
}),
).toBe(false);
});
@@ -54,7 +54,7 @@ describe("shouldClearAgentAttention", () => {
isConnected: true,
requiresAttention: true,
attentionReason: "permission",
})
}),
).toBe(false);
});
@@ -67,7 +67,7 @@ describe("shouldClearAgentAttention", () => {
attentionReason: "finished",
trigger: "focus-entry",
hasDeferredFocusEntryClear: true,
})
}),
).toBe(false);
});
@@ -80,7 +80,7 @@ describe("shouldClearAgentAttention", () => {
attentionReason: "finished",
trigger: "input-focus",
hasDeferredFocusEntryClear: true,
})
}),
).toBe(true);
expect(
@@ -91,7 +91,7 @@ describe("shouldClearAgentAttention", () => {
attentionReason: "finished",
trigger: "prompt-send",
hasDeferredFocusEntryClear: true,
})
}),
).toBe(true);
expect(
@@ -102,7 +102,7 @@ describe("shouldClearAgentAttention", () => {
attentionReason: "finished",
trigger: "agent-blur",
hasDeferredFocusEntryClear: true,
})
}),
).toBe(true);
});
});
+2 -7
View File
@@ -13,9 +13,7 @@ export type AgentAttentionClearTrigger =
| "prompt-send"
| "agent-blur";
export function shouldClearAgentAttention(
input: ShouldClearAgentAttentionInput
): boolean {
export function shouldClearAgentAttention(input: ShouldClearAgentAttentionInput): boolean {
const agentId = input.agentId?.trim();
if (!agentId) {
return false;
@@ -29,10 +27,7 @@ export function shouldClearAgentAttention(
if (input.attentionReason === "permission") {
return false;
}
if (
input.trigger === "focus-entry" &&
input.hasDeferredFocusEntryClear === true
) {
if (input.trigger === "focus-entry" && input.hasDeferredFocusEntryClear === true) {
return false;
}
return true;
@@ -1,9 +1,6 @@
import type { FetchAgentsEntry } from "@server/client/daemon-client";
import { useSessionStore, type Agent } from "@/stores/session-store";
import {
derivePendingPermissionKey,
normalizeAgentSnapshot,
} from "@/utils/agent-snapshots";
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { resolveProjectPlacement } from "@/utils/project-placement";
type PendingPermissionEntry = {
+2 -7
View File
@@ -4,9 +4,7 @@ import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
* Derives the branch label to display for an agent.
* Returns null if there's no branch to show (not a git repo, or on the base branch).
*/
export function deriveBranchLabel(
checkout: CheckoutStatusPayload | null
): string | null {
export function deriveBranchLabel(checkout: CheckoutStatusPayload | null): string | null {
if (!checkout || !checkout.isGit) {
return null;
}
@@ -29,10 +27,7 @@ export function deriveBranchLabel(
* If inside a Paseo worktree, shows just the worktree-relative path.
* Otherwise uses the repo root or cwd.
*/
export function deriveProjectPath(
cwd: string,
checkout: CheckoutStatusPayload | null
): string {
export function deriveProjectPath(cwd: string, checkout: CheckoutStatusPayload | null): string {
const basePath = checkout?.isGit ? (checkout.repoRoot ?? cwd) : cwd;
const worktreeMarker = ".paseo/worktrees/";
const idx = basePath.indexOf(worktreeMarker);
+6 -15
View File
@@ -1,9 +1,5 @@
import { describe, expect, it } from "vitest";
import {
deriveProjectDisplayName,
deriveRemoteProjectKey,
groupAgents,
} from "./agent-grouping";
import { deriveProjectDisplayName, deriveRemoteProjectKey, groupAgents } from "./agent-grouping";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
function makeAgent(overrides: Partial<AggregatedAgent> = {}): AggregatedAgent {
@@ -33,9 +29,7 @@ describe("deriveRemoteProjectKey", () => {
it("includes host for non-GitHub remotes", () => {
const gitlab = "git@gitlab.example.com:group/repo.git";
expect(deriveRemoteProjectKey(gitlab)).toBe(
"remote:gitlab.example.com/group/repo"
);
expect(deriveRemoteProjectKey(gitlab)).toBe("remote:gitlab.example.com/group/repo");
});
});
@@ -45,7 +39,7 @@ describe("deriveProjectDisplayName", () => {
deriveProjectDisplayName({
projectKey: "remote:github.com/getpaseo/paseo",
projectName: "paseo",
})
}),
).toBe("getpaseo/paseo");
});
@@ -54,7 +48,7 @@ describe("deriveProjectDisplayName", () => {
deriveProjectDisplayName({
projectKey: "remote:gitlab.example.com/group/repo",
projectName: "repo",
})
}),
).toBe("group/repo");
});
@@ -63,7 +57,7 @@ describe("deriveProjectDisplayName", () => {
deriveProjectDisplayName({
projectKey: "/Users/me/dev/paseo",
projectName: "paseo",
})
}),
).toBe("paseo");
});
});
@@ -80,10 +74,7 @@ describe("groupAgents", () => {
});
expect(activeGroups).toHaveLength(1);
expect(activeGroups[0]?.agents.map((a) => a.id).sort()).toEqual([
"a1",
"a2",
]);
expect(activeGroups[0]?.agents.map((a) => a.id).sort()).toEqual(["a1", "a2"]);
});
it("falls back to cwd grouping when remote URL is unavailable", () => {
+10 -27
View File
@@ -87,9 +87,7 @@ export function deriveRemoteProjectKey(remoteUrl: string | null): string | null
* https://github.com/anthropics/claude-code.git -> anthropics/claude-code
* https://github.com/anthropics/claude-code -> anthropics/claude-code
*/
export function parseRepoNameFromRemoteUrl(
remoteUrl: string | null
): string | null {
export function parseRepoNameFromRemoteUrl(remoteUrl: string | null): string | null {
if (!remoteUrl) {
return null;
}
@@ -133,9 +131,7 @@ export function parseRepoNameFromRemoteUrl(
* Examples:
* git@github.com:anthropics/claude-code.git -> claude-code
*/
export function parseRepoShortNameFromRemoteUrl(
remoteUrl: string | null
): string | null {
export function parseRepoShortNameFromRemoteUrl(remoteUrl: string | null): string | null {
const fullName = parseRepoNameFromRemoteUrl(remoteUrl);
if (!fullName) {
return null;
@@ -204,7 +200,7 @@ export function deriveDateGroup(lastActivityAt: Date): string {
const activityDate = new Date(
lastActivityAt.getFullYear(),
lastActivityAt.getMonth(),
lastActivityAt.getDate()
lastActivityAt.getDate(),
);
if (activityDate.getTime() >= today.getTime()) {
@@ -268,7 +264,7 @@ const MAX_INACTIVE_PER_PROJECT = 5;
*/
export function groupAgents(
agents: AggregatedAgent[],
options?: GroupAgentsOptions
options?: GroupAgentsOptions,
): GroupedAgents {
const activeAgents: AggregatedAgent[] = [];
const inactiveAgents: AggregatedAgent[] = [];
@@ -302,9 +298,7 @@ export function groupAgents(
{ trulyActive: AggregatedAgent[]; recentlyActive: AggregatedAgent[] }
>();
for (const agent of activeAgents) {
const remoteKey = deriveRemoteProjectKey(
options?.getRemoteUrl?.(agent) ?? null
);
const remoteKey = deriveRemoteProjectKey(options?.getRemoteUrl?.(agent) ?? null);
const projectKey = remoteKey ?? deriveProjectKey(agent.cwd);
const existing = projectMap.get(projectKey) || {
trulyActive: [],
@@ -328,24 +322,15 @@ export function groupAgents(
const activeGroups: ProjectGroup[] = [];
for (const [projectKey, { trulyActive, recentlyActive }] of projectMap) {
// Sort both arrays by lastActivityAt (newest first)
trulyActive.sort(
(a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime()
);
recentlyActive.sort(
(a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime()
);
trulyActive.sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime());
recentlyActive.sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime());
// All truly active agents shown, limit recently active to MAX_INACTIVE_PER_PROJECT
const limitedRecentlyActive = recentlyActive.slice(
0,
MAX_INACTIVE_PER_PROJECT
);
const limitedRecentlyActive = recentlyActive.slice(0, MAX_INACTIVE_PER_PROJECT);
const combinedAgents = [...trulyActive, ...limitedRecentlyActive];
// Re-sort combined list by lastActivityAt
combinedAgents.sort(
(a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime()
);
combinedAgents.sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime());
activeGroups.push({
projectKey,
@@ -378,9 +363,7 @@ export function groupAgents(
for (const label of dateOrder) {
const dateAgents = dateMap.get(label);
if (dateAgents && dateAgents.length > 0) {
dateAgents.sort(
(a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime()
);
dateAgents.sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime());
inactiveGroups.push({ label, agents: dateAgents });
}
}
@@ -16,10 +16,7 @@ export function getInitDeferred(key: string): DeferredInit | undefined {
return initPromises.get(key);
}
export function createInitDeferred(
key: string,
requestDirection: "tail" | "after"
): DeferredInit {
export function createInitDeferred(key: string, requestDirection: "tail" | "after"): DeferredInit {
let resolve!: () => void;
let reject!: (error: Error) => void;
+4 -3
View File
@@ -4,7 +4,7 @@ import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-type
export function derivePendingPermissionKey(
agentId: string,
request: AgentPermissionRequest
request: AgentPermissionRequest,
): string {
const fallbackId =
request.id ||
@@ -19,7 +19,9 @@ export function derivePendingPermissionKey(
export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: string) {
const createdAt = new Date(snapshot.createdAt);
const updatedAt = new Date(snapshot.updatedAt);
const lastUserMessageAt = snapshot.lastUserMessageAt ? new Date(snapshot.lastUserMessageAt) : null;
const lastUserMessageAt = snapshot.lastUserMessageAt
? new Date(snapshot.lastUserMessageAt)
: null;
const attentionTimestamp = snapshot.attentionTimestamp
? new Date(snapshot.attentionTimestamp)
: null;
@@ -53,4 +55,3 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
labels: snapshot.labels,
};
}
@@ -18,10 +18,7 @@ describe("collectAgentWorkingDirectorySuggestions", () => {
},
]);
expect(results).toEqual([
"/Users/me/project-alpha",
"/Users/me/project-beta",
]);
expect(results).toEqual(["/Users/me/project-alpha", "/Users/me/project-beta"]);
});
it("excludes Paseo-owned worktree paths", () => {
@@ -7,7 +7,7 @@ export interface AgentWorkingDirectorySource {
const PASEO_WORKTREE_PATH_PATTERN = /(^|\/)\.paseo\/worktrees(\/|$)/;
export function collectAgentWorkingDirectorySuggestions(
sources: Iterable<AgentWorkingDirectorySource>
sources: Iterable<AgentWorkingDirectorySource>,
): string[] {
const lastSeenByPath = new Map<string, number>();
+1 -5
View File
@@ -1,8 +1,4 @@
type OfflineAction =
| "create"
| "resume"
| "dictation"
| "import_list";
type OfflineAction = "create" | "resume" | "dictation" | "import_list";
type AnalyticsEvent =
| {
+2 -5
View File
@@ -1,8 +1,6 @@
import { AppState, Platform } from "react-native";
export function getIsAppActivelyVisible(
appState: string = AppState.currentState
): boolean {
export function getIsAppActivelyVisible(appState: string = AppState.currentState): boolean {
if (appState !== "active") {
return false;
}
@@ -11,8 +9,7 @@ export function getIsAppActivelyVisible(
return true;
}
const documentVisible =
typeof document === "undefined" || document.visibilityState === "visible";
const documentVisible = typeof document === "undefined" || document.visibilityState === "visible";
const windowFocused =
typeof document === "undefined" ||
typeof document.hasFocus !== "function" ||
-1
View File
@@ -33,4 +33,3 @@ export class AttemptGuard {
}
}
}
+4 -2
View File
@@ -29,7 +29,9 @@ describe("client-id", () => {
it("creates and persists a client id when missing", async () => {
asyncStorageMock.getItem.mockResolvedValue(null);
asyncStorageMock.setItem.mockResolvedValue();
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue("12345678-1234-1234-1234-1234567890ab");
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"12345678-1234-1234-1234-1234567890ab",
);
const mod = await import("./client-id");
const key = await mod.getOrCreateClientId();
@@ -37,7 +39,7 @@ describe("client-id", () => {
expect(key).toBe("cid_123456781234123412341234567890ab");
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
"@paseo:client-id-v1",
"cid_123456781234123412341234567890ab"
"cid_123456781234123412341234567890ab",
);
});
});
@@ -13,4 +13,3 @@ export function takeCommandCenterFocusRestoreElement(): HTMLElement | null {
export function clearCommandCenterFocusRestoreElement(): void {
focusRestoreElement = null;
}
+6 -14
View File
@@ -94,9 +94,7 @@ describe("confirmDialog", () => {
expect(confirmed).toBe(true);
expect(blurMock).toHaveBeenCalledTimes(1);
expect(browserConfirm).toHaveBeenCalledWith(
"Restart host\n\nThis will restart the daemon."
);
expect(browserConfirm).toHaveBeenCalledWith("Restart host\n\nThis will restart the daemon.");
});
it("throws on web when no confirm backend exists", async () => {
@@ -106,22 +104,16 @@ describe("confirmDialog", () => {
confirmDialog({
title: "Restart host",
message: "This will restart the daemon.",
})
}),
).rejects.toThrow("[ConfirmDialog] No web confirmation backend is available.");
});
it("uses native Alert on iOS/Android", async () => {
const { confirmDialog, alertMock } = await loadModuleForPlatform("ios");
alertMock.mockImplementation(
(
_title: string,
_message: string,
buttons?: AlertButton[]
) => {
const confirmButton = buttons?.[1];
confirmButton?.onPress?.();
}
);
alertMock.mockImplementation((_title: string, _message: string, buttons?: AlertButton[]) => {
const confirmButton = buttons?.[1];
confirmButton?.onPress?.();
});
const confirmed = await confirmDialog({
title: "Restart host",
+1 -1
View File
@@ -43,7 +43,7 @@ async function showNativeConfirmDialog(input: ConfirmDialogInput): Promise<boole
{
cancelable: true,
onDismiss: () => resolve(false),
}
},
);
});
}
@@ -10,17 +10,11 @@ function makeDirect(id: string, endpoint: string): HostConnection {
return { id, type: "directTcp", endpoint };
}
function makeRelay(
id: string,
relayEndpoint: string,
daemonPublicKeyB64 = "abc"
): HostConnection {
function makeRelay(id: string, relayEndpoint: string, daemonPublicKeyB64 = "abc"): HostConnection {
return { id, type: "relay", relayEndpoint, daemonPublicKeyB64 };
}
function probes(
input: Record<string, ConnectionProbeState>
): Map<string, ConnectionProbeState> {
function probes(input: Record<string, ConnectionProbeState>): Map<string, ConnectionProbeState> {
return new Map(Object.entries(input));
}
@@ -23,9 +23,7 @@ function getAvailableLatency(input: {
return probe?.status === "available" ? probe.latencyMs : null;
}
export function selectBestConnection(
input: SelectBestConnectionInput
): string | null {
export function selectBestConnection(input: SelectBestConnectionInput): string | null {
const { candidates, probeByConnectionId } = input;
if (candidates.length === 0) {
return null;
+1 -4
View File
@@ -30,9 +30,6 @@ export function decodeOfferFragmentPayload(encoded: string): unknown {
return JSON.parse(json) as unknown;
}
export function buildRelayWebSocketUrl(params: {
endpoint: string;
serverId: string;
}): string {
export function buildRelayWebSocketUrl(params: { endpoint: string; serverId: string }): string {
return buildSharedRelayWebSocketUrl({ ...params, role: "client" });
}
+1 -3
View File
@@ -20,9 +20,7 @@ export function formatConnectionStatus(status: HostRuntimeConnectionStatus): str
export type ConnectionStatusTone = "success" | "warning" | "error" | "muted";
export function getConnectionStatusTone(
status: HostRuntimeConnectionStatus
): ConnectionStatusTone {
export function getConnectionStatusTone(status: HostRuntimeConnectionStatus): ConnectionStatusTone {
switch (status) {
case "online":
return "success";
@@ -1,28 +1,28 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from "vitest";
import { readFiniteScreenPoint } from './desktop-window-drag-coordinates'
import { readFiniteScreenPoint } from "./desktop-window-drag-coordinates";
describe('readFiniteScreenPoint', () => {
it('returns finite screen coordinates', () => {
describe("readFiniteScreenPoint", () => {
it("returns finite screen coordinates", () => {
expect(readFiniteScreenPoint({ screenX: 1280, screenY: 720 })).toEqual({
screenX: 1280,
screenY: 720,
})
})
});
});
it('rejects NaN screen coordinates', () => {
expect(readFiniteScreenPoint({ screenX: Number.NaN, screenY: 720 })).toBeNull()
expect(readFiniteScreenPoint({ screenX: 1280, screenY: Number.NaN })).toBeNull()
})
it("rejects NaN screen coordinates", () => {
expect(readFiniteScreenPoint({ screenX: Number.NaN, screenY: 720 })).toBeNull();
expect(readFiniteScreenPoint({ screenX: 1280, screenY: Number.NaN })).toBeNull();
});
it('rejects infinite screen coordinates', () => {
expect(readFiniteScreenPoint({ screenX: Number.POSITIVE_INFINITY, screenY: 720 })).toBeNull()
expect(readFiniteScreenPoint({ screenX: 1280, screenY: Number.NEGATIVE_INFINITY })).toBeNull()
})
it("rejects infinite screen coordinates", () => {
expect(readFiniteScreenPoint({ screenX: Number.POSITIVE_INFINITY, screenY: 720 })).toBeNull();
expect(readFiniteScreenPoint({ screenX: 1280, screenY: Number.NEGATIVE_INFINITY })).toBeNull();
});
it('rejects missing screen coordinates', () => {
expect(readFiniteScreenPoint(undefined)).toBeNull()
expect(readFiniteScreenPoint({ screenX: 1280 })).toBeNull()
expect(readFiniteScreenPoint({ screenY: 720 })).toBeNull()
})
})
it("rejects missing screen coordinates", () => {
expect(readFiniteScreenPoint(undefined)).toBeNull();
expect(readFiniteScreenPoint({ screenX: 1280 })).toBeNull();
expect(readFiniteScreenPoint({ screenY: 720 })).toBeNull();
});
});
@@ -1,27 +1,27 @@
export type DesktopWindowScreenPoint = {
screenX: number
screenY: number
}
screenX: number;
screenY: number;
};
type ScreenPointInput =
| {
screenX?: unknown
screenY?: unknown
screenX?: unknown;
screenY?: unknown;
}
| null
| undefined
| undefined;
function isFiniteCoordinate(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value)
return typeof value === "number" && Number.isFinite(value);
}
export function readFiniteScreenPoint(input: ScreenPointInput): DesktopWindowScreenPoint | null {
if (!isFiniteCoordinate(input?.screenX) || !isFiniteCoordinate(input?.screenY)) {
return null
return null;
}
return {
screenX: input.screenX,
screenY: input.screenY,
}
};
}
+22 -22
View File
@@ -1,40 +1,40 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from "vitest";
import { isInteractiveDesktopDragTarget } from './desktop-window'
import { isInteractiveDesktopDragTarget } from "./desktop-window";
function createTarget(input: { matchesSelector: (selector: string) => boolean }): EventTarget {
return {
closest: (selector: string) => (input.matchesSelector(selector) ? ({} as Element) : null),
} as unknown as EventTarget
} as unknown as EventTarget;
}
describe('isInteractiveDesktopDragTarget', () => {
it('treats focusable pressables as interactive drag exemptions', () => {
describe("isInteractiveDesktopDragTarget", () => {
it("treats focusable pressables as interactive drag exemptions", () => {
const target = createTarget({
matchesSelector: (selector) => selector.includes('[tabindex]'),
})
matchesSelector: (selector) => selector.includes("[tabindex]"),
});
expect(isInteractiveDesktopDragTarget(target)).toBe(true)
})
expect(isInteractiveDesktopDragTarget(target)).toBe(true);
});
it('treats semantic button targets as interactive drag exemptions', () => {
it("treats semantic button targets as interactive drag exemptions", () => {
const target = createTarget({
matchesSelector: (selector) => selector.includes("[role='button']"),
})
});
expect(isInteractiveDesktopDragTarget(target)).toBe(true)
})
expect(isInteractiveDesktopDragTarget(target)).toBe(true);
});
it('returns false for non-interactive targets', () => {
it("returns false for non-interactive targets", () => {
const target = createTarget({
matchesSelector: () => false,
})
});
expect(isInteractiveDesktopDragTarget(target)).toBe(false)
})
expect(isInteractiveDesktopDragTarget(target)).toBe(false);
});
it('returns false when the target does not support closest()', () => {
expect(isInteractiveDesktopDragTarget(null)).toBe(false)
expect(isInteractiveDesktopDragTarget({} as EventTarget)).toBe(false)
})
})
it("returns false when the target does not support closest()", () => {
expect(isInteractiveDesktopDragTarget(null)).toBe(false);
expect(isInteractiveDesktopDragTarget({} as EventTarget)).toBe(false);
});
});
+90 -90
View File
@@ -1,21 +1,21 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Platform, type PointerEvent as RNPointerEvent, type ViewProps } from 'react-native'
import { useEffect, useMemo, useRef, useState } from "react";
import { Platform, type PointerEvent as RNPointerEvent, type ViewProps } from "react-native";
import {
getIsDesktopMac,
DESKTOP_TRAFFIC_LIGHT_WIDTH,
DESKTOP_TRAFFIC_LIGHT_HEIGHT,
} from '@/constants/layout'
import { getDesktopWindow } from '@/desktop/electron/window'
import { isDesktop } from '@/desktop/host'
import { readFiniteScreenPoint } from './desktop-window-drag-coordinates'
} from "@/constants/layout";
import { getDesktopWindow } from "@/desktop/electron/window";
import { isDesktop } from "@/desktop/host";
import { readFiniteScreenPoint } from "./desktop-window-drag-coordinates";
export async function toggleMaximize() {
const win = getDesktopWindow()
if (win && typeof win.toggleMaximize === 'function') {
const win = getDesktopWindow();
if (win && typeof win.toggleMaximize === "function") {
try {
await win.toggleMaximize()
await win.toggleMaximize();
} catch (error) {
console.warn('[DesktopWindow] toggleMaximize failed', error)
console.warn("[DesktopWindow] toggleMaximize failed", error);
}
}
}
@@ -28,159 +28,159 @@ export async function toggleMaximize() {
// ---------------------------------------------------------------------------
const INTERACTIVE_SELECTOR =
'button, a, input, textarea, select, ' +
"button, a, input, textarea, select, " +
"[role='button'], [role='link'], [role='textbox'], [role='combobox'], " +
"[role='tab'], [role='switch'], [role='checkbox'], [role='slider'], " +
"[role='menuitem'], [tabindex], [contenteditable='true']"
"[role='menuitem'], [tabindex], [contenteditable='true']";
const DOUBLE_CLICK_MS = 300
const DOUBLE_CLICK_MS = 300;
type DesktopDragViewProps = Pick<
ViewProps,
'onPointerDown' | 'onPointerMove' | 'onPointerUp' | 'onPointerCancel'
>
"onPointerDown" | "onPointerMove" | "onPointerUp" | "onPointerCancel"
>;
export function isInteractiveDesktopDragTarget(target: unknown): boolean {
const candidate = target as unknown as { closest?: (selector: string) => Element | null } | null
if (!candidate || typeof candidate.closest !== 'function') {
return false
const candidate = target as unknown as { closest?: (selector: string) => Element | null } | null;
if (!candidate || typeof candidate.closest !== "function") {
return false;
}
return Boolean(candidate.closest(INTERACTIVE_SELECTOR))
return Boolean(candidate.closest(INTERACTIVE_SELECTOR));
}
export function useDesktopDragHandlers(): DesktopDragViewProps {
const isDragging = useRef(false)
const lastPointerDownAt = useRef(0)
const isActive = Platform.OS === 'web' && isDesktop()
const isDragging = useRef(false);
const lastPointerDownAt = useRef(0);
const isActive = Platform.OS === "web" && isDesktop();
useEffect(() => {
if (!isActive) return
if (!isActive) return;
function handleBlur() {
if (!isDragging.current) return
isDragging.current = false
getDesktopWindow()?.endMove?.()
if (!isDragging.current) return;
isDragging.current = false;
getDesktopWindow()?.endMove?.();
}
window.addEventListener('blur', handleBlur)
return () => window.removeEventListener('blur', handleBlur)
}, [isActive])
window.addEventListener("blur", handleBlur);
return () => window.removeEventListener("blur", handleBlur);
}, [isActive]);
return useMemo((): DesktopDragViewProps => {
if (!isActive) return {}
if (!isActive) return {};
function stopDrag(e: RNPointerEvent) {
if (!isDragging.current) return
isDragging.current = false
if (!isDragging.current) return;
isDragging.current = false;
// On web, currentTarget is a DOM Element (typed as HostInstance in RN)
const el = e.currentTarget as unknown as Element | null
if (el && 'releasePointerCapture' in el) {
el.releasePointerCapture(e.nativeEvent.pointerId)
const el = e.currentTarget as unknown as Element | null;
if (el && "releasePointerCapture" in el) {
el.releasePointerCapture(e.nativeEvent.pointerId);
}
getDesktopWindow()?.endMove?.()
getDesktopWindow()?.endMove?.();
}
return {
onPointerDown: (e: RNPointerEvent) => {
if (e.nativeEvent.button !== 0) return
if (e.nativeEvent.button !== 0) return;
if (isInteractiveDesktopDragTarget(e.target)) return
if (isInteractiveDesktopDragTarget(e.target)) return;
e.preventDefault()
e.preventDefault();
const now = Date.now()
const now = Date.now();
if (now - lastPointerDownAt.current < DOUBLE_CLICK_MS) {
lastPointerDownAt.current = 0
void toggleMaximize()
return
lastPointerDownAt.current = 0;
void toggleMaximize();
return;
}
lastPointerDownAt.current = now
lastPointerDownAt.current = now;
const win = getDesktopWindow()
if (!win?.startMove) return
const screenPoint = readFiniteScreenPoint(e.nativeEvent)
if (!screenPoint) return
const win = getDesktopWindow();
if (!win?.startMove) return;
const screenPoint = readFiniteScreenPoint(e.nativeEvent);
if (!screenPoint) return;
isDragging.current = true
const el = e.currentTarget as unknown as Element
el.setPointerCapture(e.nativeEvent.pointerId)
win.startMove(screenPoint.screenX, screenPoint.screenY)
isDragging.current = true;
const el = e.currentTarget as unknown as Element;
el.setPointerCapture(e.nativeEvent.pointerId);
win.startMove(screenPoint.screenX, screenPoint.screenY);
},
onPointerMove: (e: RNPointerEvent) => {
if (!isDragging.current) return
const screenPoint = readFiniteScreenPoint(e.nativeEvent)
if (!isDragging.current) return;
const screenPoint = readFiniteScreenPoint(e.nativeEvent);
if (!screenPoint) {
stopDrag(e)
return
stopDrag(e);
return;
}
getDesktopWindow()?.moving?.(screenPoint.screenX, screenPoint.screenY)
getDesktopWindow()?.moving?.(screenPoint.screenX, screenPoint.screenY);
},
onPointerUp: stopDrag,
onPointerCancel: stopDrag,
}
}, [isActive])
};
}, [isActive]);
}
export function useTrafficLightPadding(): { left: number; top: number } {
const [isFullscreen, setIsFullscreen] = useState(false)
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
if (Platform.OS !== 'web' || !getIsDesktopMac()) return
if (Platform.OS !== "web" || !getIsDesktopMac()) return;
let disposed = false
let cleanup: (() => void) | undefined
let didCleanup = false
let disposed = false;
let cleanup: (() => void) | undefined;
let didCleanup = false;
function runCleanup() {
if (!cleanup || didCleanup) return
didCleanup = true
if (!cleanup || didCleanup) return;
didCleanup = true;
try {
void Promise.resolve(cleanup()).catch((error) => {
console.warn('[DesktopWindow] Failed to remove resize listener', error)
})
console.warn("[DesktopWindow] Failed to remove resize listener", error);
});
} catch (error) {
console.warn('[DesktopWindow] Failed to remove resize listener', error)
console.warn("[DesktopWindow] Failed to remove resize listener", error);
}
}
async function setup() {
const win = getDesktopWindow()
if (!win) return
const win = getDesktopWindow();
if (!win) return;
const fullscreen = typeof win.isFullscreen === 'function' ? await win.isFullscreen() : false
if (disposed) return
setIsFullscreen(fullscreen)
const fullscreen = typeof win.isFullscreen === "function" ? await win.isFullscreen() : false;
if (disposed) return;
setIsFullscreen(fullscreen);
if (typeof win.onResized !== 'function') {
return
if (typeof win.onResized !== "function") {
return;
}
const unlisten = await win.onResized(async () => {
if (disposed) return
const fs = typeof win.isFullscreen === 'function' ? await win.isFullscreen() : false
if (disposed) return
setIsFullscreen(fs)
})
if (disposed) return;
const fs = typeof win.isFullscreen === "function" ? await win.isFullscreen() : false;
if (disposed) return;
setIsFullscreen(fs);
});
cleanup = unlisten
cleanup = unlisten;
if (disposed) {
runCleanup()
runCleanup();
}
}
void setup()
void setup();
return () => {
disposed = true
runCleanup()
}
}, [])
disposed = true;
runCleanup();
};
}, []);
if (!getIsDesktopMac() || isFullscreen) {
return { left: 0, top: 0 }
return { left: 0, top: 0 };
}
return {
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
}
};
}
+17 -35
View File
@@ -306,17 +306,13 @@ describe("highlightDiffFile", () => {
const hunk = highlighted.hunks[0];
// Removed line: "const bar = 2;"
const removedLine = hunk.lines.find(
(l) => l.type === "remove" && l.content.includes("bar")
);
const removedLine = hunk.lines.find((l) => l.type === "remove" && l.content.includes("bar"));
expect(removedLine?.tokens).toBeDefined();
const removedNumber = removedLine!.tokens!.find((t) => t.text === "2");
expect(removedNumber?.style).toBe("number");
// Added line: "const bar = 3;"
const addedLine = hunk.lines.find(
(l) => l.type === "add" && l.content.includes("bar")
);
const addedLine = hunk.lines.find((l) => l.type === "add" && l.content.includes("bar"));
expect(addedLine?.tokens).toBeDefined();
const addedNumber = addedLine!.tokens!.find((t) => t.text === "3");
expect(addedNumber?.style).toBe("number");
@@ -361,12 +357,10 @@ describe("highlightDiffFile", () => {
expect(fnLine.tokens!.some((t) => t.text === "fn" && t.style === "keyword")).toBe(true);
const addedLine = hunk.lines.find(
(line) => line.type === "add" && line.content.includes("version")
(line) => line.type === "add" && line.content.includes("version"),
);
expect(addedLine?.tokens).toBeDefined();
expect(
addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")
).toBe(true);
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
});
it("adds syntax highlighting tokens to C code", () => {
@@ -379,12 +373,10 @@ describe("highlightDiffFile", () => {
expect(mainLine.tokens!.length).toBeGreaterThan(0);
const addedLine = hunk.lines.find(
(line) => line.type === "add" && line.content.includes("version")
(line) => line.type === "add" && line.content.includes("version"),
);
expect(addedLine?.tokens).toBeDefined();
expect(
addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")
).toBe(true);
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
});
it("adds syntax highlighting tokens to Java code", () => {
@@ -394,17 +386,13 @@ describe("highlightDiffFile", () => {
const classLine = hunk.lines[1];
expect(classLine.tokens).toBeDefined();
expect(
classLine.tokens!.some((t) => t.text === "public" && t.style === "keyword")
).toBe(true);
expect(classLine.tokens!.some((t) => t.text === "public" && t.style === "keyword")).toBe(true);
const addedLine = hunk.lines.find(
(line) => line.type === "add" && line.content.includes("version")
(line) => line.type === "add" && line.content.includes("version"),
);
expect(addedLine?.tokens).toBeDefined();
expect(
addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")
).toBe(true);
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
});
it("adds syntax highlighting tokens to Objective-C file extensions", () => {
@@ -413,45 +401,39 @@ describe("highlightDiffFile", () => {
const hunk = highlighted.hunks[0];
const addedLine = hunk.lines.find(
(line) => line.type === "add" && line.content.includes("version")
(line) => line.type === "add" && line.content.includes("version"),
);
expect(addedLine?.tokens).toBeDefined();
expect(
addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")
).toBe(true);
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
});
it("adds syntax highlighting tokens to Go code", () => {
const files = parseDiff(GO_DIFF);
const highlighted = highlightDiffFile(files[0]);
const addedLine = highlighted.hunks[0].lines.find(
(line) => line.type === "add" && line.content.includes("version")
(line) => line.type === "add" && line.content.includes("version"),
);
expect(addedLine?.tokens).toBeDefined();
expect(
addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")
).toBe(true);
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
});
it("adds syntax highlighting tokens to PHP code", () => {
const files = parseDiff(PHP_DIFF);
const highlighted = highlightDiffFile(files[0]);
const addedLine = highlighted.hunks[0].lines.find(
(line) => line.type === "add" && line.content.includes("$version")
(line) => line.type === "add" && line.content.includes("$version"),
);
expect(addedLine?.tokens).toBeDefined();
expect(
addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")
).toBe(true);
expect(addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")).toBe(true);
});
it("adds syntax highlighting tokens to YAML code", () => {
const files = parseDiff(YAML_DIFF);
const highlighted = highlightDiffFile(files[0]);
const addedLine = highlighted.hunks[0].lines.find(
(line) => line.type === "add" && line.content.includes("count")
(line) => line.type === "add" && line.content.includes("count"),
);
expect(addedLine?.tokens).toBeDefined();
@@ -462,7 +444,7 @@ describe("highlightDiffFile", () => {
const files = parseDiff(XML_DIFF);
const highlighted = highlightDiffFile(files[0]);
const addedLine = highlighted.hunks[0].lines.find(
(line) => line.type === "add" && line.content.includes("<count>")
(line) => line.type === "add" && line.content.includes("<count>"),
);
expect(addedLine?.tokens).toBeDefined();
+119 -119
View File
@@ -1,27 +1,27 @@
import { highlightCode, isLanguageSupported, type HighlightToken } from '@getpaseo/highlight'
import { highlightCode, isLanguageSupported, type HighlightToken } from "@getpaseo/highlight";
export interface DiffLine {
type: 'add' | 'remove' | 'context' | 'header'
content: string
lineNumber?: number // Line number in the original/new file
tokens?: HighlightToken[]
type: "add" | "remove" | "context" | "header";
content: string;
lineNumber?: number; // Line number in the original/new file
tokens?: HighlightToken[];
}
export interface DiffHunk {
oldStart: number
oldCount: number
newStart: number
newCount: number
lines: DiffLine[]
oldStart: number;
oldCount: number;
newStart: number;
newCount: number;
lines: DiffLine[];
}
export interface ParsedDiffFile {
path: string
isNew: boolean
isDeleted: boolean
additions: number
deletions: number
hunks: DiffHunk[]
path: string;
isNew: boolean;
isDeleted: boolean;
additions: number;
deletions: number;
hunks: DiffHunk[];
}
/**
@@ -29,87 +29,87 @@ export interface ParsedDiffFile {
*/
export function parseDiff(diffText: string): ParsedDiffFile[] {
if (!diffText || diffText.trim().length === 0) {
return []
return [];
}
const files: ParsedDiffFile[] = []
const fileSections = diffText.split(/^diff --git /m).filter(Boolean)
const files: ParsedDiffFile[] = [];
const fileSections = diffText.split(/^diff --git /m).filter(Boolean);
for (const section of fileSections) {
const lines = section.split('\n')
const firstLine = lines[0]
const lines = section.split("\n");
const firstLine = lines[0];
// Detect new/deleted file
const isNew = section.includes('new file mode') || section.includes('--- /dev/null')
const isDeleted = section.includes('deleted file mode') || section.includes('+++ /dev/null')
const isNew = section.includes("new file mode") || section.includes("--- /dev/null");
const isDeleted = section.includes("deleted file mode") || section.includes("+++ /dev/null");
// Extract path
let path = 'unknown'
const pathMatch = firstLine.match(/a\/(.*?) b\//)
let path = "unknown";
const pathMatch = firstLine.match(/a\/(.*?) b\//);
if (pathMatch) {
path = pathMatch[1]
path = pathMatch[1];
} else {
const newFileMatch = firstLine.match(/b\/(.+)$/)
const newFileMatch = firstLine.match(/b\/(.+)$/);
if (newFileMatch) {
path = newFileMatch[1]
path = newFileMatch[1];
}
}
const hunks: DiffHunk[] = []
let currentHunk: DiffHunk | null = null
let additions = 0
let deletions = 0
const hunks: DiffHunk[] = [];
let currentHunk: DiffHunk | null = null;
let additions = 0;
let deletions = 0;
for (let i = 1; i < lines.length; i++) {
const line = lines[i]
const line = lines[i];
// Skip metadata lines
if (line.startsWith('index ')) continue
if (line.startsWith('--- ')) continue
if (line.startsWith('+++ ')) continue
if (line.startsWith('new file mode')) continue
if (line.startsWith('deleted file mode')) continue
if (line.startsWith("index ")) continue;
if (line.startsWith("--- ")) continue;
if (line.startsWith("+++ ")) continue;
if (line.startsWith("new file mode")) continue;
if (line.startsWith("deleted file mode")) continue;
// Parse hunk header: @@ -oldStart,oldCount +newStart,newCount @@
const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/)
const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
if (hunkMatch) {
if (currentHunk) {
hunks.push(currentHunk)
hunks.push(currentHunk);
}
currentHunk = {
oldStart: parseInt(hunkMatch[1], 10),
oldCount: parseInt(hunkMatch[2] ?? '1', 10),
oldCount: parseInt(hunkMatch[2] ?? "1", 10),
newStart: parseInt(hunkMatch[3], 10),
newCount: parseInt(hunkMatch[4] ?? '1', 10),
lines: [{ type: 'header', content: line.match(/^(@@ .+? @@)/)?.[1] ?? line }],
}
continue
newCount: parseInt(hunkMatch[4] ?? "1", 10),
lines: [{ type: "header", content: line.match(/^(@@ .+? @@)/)?.[1] ?? line }],
};
continue;
}
if (!currentHunk) continue
if (!currentHunk) continue;
if (line.startsWith('+')) {
currentHunk.lines.push({ type: 'add', content: line.slice(1) })
additions++
} else if (line.startsWith('-')) {
currentHunk.lines.push({ type: 'remove', content: line.slice(1) })
deletions++
} else if (line.startsWith(' ')) {
currentHunk.lines.push({ type: 'context', content: line.slice(1) })
} else if (line.length > 0 && !line.startsWith('\\')) {
if (line.startsWith("+")) {
currentHunk.lines.push({ type: "add", content: line.slice(1) });
additions++;
} else if (line.startsWith("-")) {
currentHunk.lines.push({ type: "remove", content: line.slice(1) });
deletions++;
} else if (line.startsWith(" ")) {
currentHunk.lines.push({ type: "context", content: line.slice(1) });
} else if (line.length > 0 && !line.startsWith("\\")) {
// Non-empty line that's not a "\ No newline" marker
currentHunk.lines.push({ type: 'context', content: line })
currentHunk.lines.push({ type: "context", content: line });
}
}
if (currentHunk) {
hunks.push(currentHunk)
hunks.push(currentHunk);
}
files.push({ path, isNew, isDeleted, additions, deletions, hunks })
files.push({ path, isNew, isDeleted, additions, deletions, hunks });
}
return files
return files;
}
/**
@@ -117,23 +117,23 @@ export function parseDiff(diffText: string): ParsedDiffFile[] {
* Returns a map of new line numbers to their content.
*/
export function reconstructNewFile(hunks: DiffHunk[]): Map<number, string> {
const lines = new Map<number, string>()
const lines = new Map<number, string>();
for (const hunk of hunks) {
let newLineNum = hunk.newStart
let newLineNum = hunk.newStart;
for (const line of hunk.lines) {
if (line.type === 'header') continue
if (line.type === "header") continue;
if (line.type === 'add' || line.type === 'context') {
lines.set(newLineNum, line.content)
newLineNum++
if (line.type === "add" || line.type === "context") {
lines.set(newLineNum, line.content);
newLineNum++;
}
// Remove lines don't appear in the new file
}
}
return lines
return lines;
}
/**
@@ -141,23 +141,23 @@ export function reconstructNewFile(hunks: DiffHunk[]): Map<number, string> {
* Returns a map of old line numbers to their content.
*/
export function reconstructOldFile(hunks: DiffHunk[]): Map<number, string> {
const lines = new Map<number, string>()
const lines = new Map<number, string>();
for (const hunk of hunks) {
let oldLineNum = hunk.oldStart
let oldLineNum = hunk.oldStart;
for (const line of hunk.lines) {
if (line.type === 'header') continue
if (line.type === "header") continue;
if (line.type === 'remove' || line.type === 'context') {
lines.set(oldLineNum, line.content)
oldLineNum++
if (line.type === "remove" || line.type === "context") {
lines.set(oldLineNum, line.content);
oldLineNum++;
}
// Add lines don't appear in the old file
}
}
return lines
return lines;
}
/**
@@ -170,100 +170,100 @@ export function reconstructOldFile(hunks: DiffHunk[]): Map<number, string> {
*/
export function highlightDiffFile(file: ParsedDiffFile): ParsedDiffFile {
if (!isLanguageSupported(file.path)) {
return file
return file;
}
// Reconstruct both versions
const newFileLines = reconstructNewFile(file.hunks)
const oldFileLines = reconstructOldFile(file.hunks)
const newFileLines = reconstructNewFile(file.hunks);
const oldFileLines = reconstructOldFile(file.hunks);
// Build complete file content strings for highlighting
const newFileContent = buildFileContent(newFileLines)
const oldFileContent = buildFileContent(oldFileLines)
const newFileContent = buildFileContent(newFileLines);
const oldFileContent = buildFileContent(oldFileLines);
// Highlight both versions
const newHighlighted = highlightCode(newFileContent, file.path)
const oldHighlighted = highlightCode(oldFileContent, file.path)
const newHighlighted = highlightCode(newFileContent, file.path);
const oldHighlighted = highlightCode(oldFileContent, file.path);
// Build lookup maps: line number -> tokens
const newTokensByLine = buildTokenLookup(newFileLines, newHighlighted)
const oldTokensByLine = buildTokenLookup(oldFileLines, oldHighlighted)
const newTokensByLine = buildTokenLookup(newFileLines, newHighlighted);
const oldTokensByLine = buildTokenLookup(oldFileLines, oldHighlighted);
// Apply tokens to hunks
const highlightedHunks = file.hunks.map((hunk) => {
let oldLineNum = hunk.oldStart
let newLineNum = hunk.newStart
let oldLineNum = hunk.oldStart;
let newLineNum = hunk.newStart;
const highlightedLines = hunk.lines.map((line): DiffLine => {
if (line.type === 'header') {
return line
if (line.type === "header") {
return line;
}
let tokens: HighlightToken[] | undefined
let tokens: HighlightToken[] | undefined;
if (line.type === 'add') {
tokens = newTokensByLine.get(newLineNum)
newLineNum++
} else if (line.type === 'remove') {
tokens = oldTokensByLine.get(oldLineNum)
oldLineNum++
} else if (line.type === 'context') {
if (line.type === "add") {
tokens = newTokensByLine.get(newLineNum);
newLineNum++;
} else if (line.type === "remove") {
tokens = oldTokensByLine.get(oldLineNum);
oldLineNum++;
} else if (line.type === "context") {
// Context lines exist in both - use new file version
tokens = newTokensByLine.get(newLineNum)
oldLineNum++
newLineNum++
tokens = newTokensByLine.get(newLineNum);
oldLineNum++;
newLineNum++;
}
return tokens ? { ...line, tokens } : line
})
return tokens ? { ...line, tokens } : line;
});
return { ...hunk, lines: highlightedLines }
})
return { ...hunk, lines: highlightedLines };
});
return { ...file, hunks: highlightedHunks }
return { ...file, hunks: highlightedHunks };
}
function buildFileContent(lineMap: Map<number, string>): string {
if (lineMap.size === 0) return ''
if (lineMap.size === 0) return "";
const lineNumbers = Array.from(lineMap.keys()).sort((a, b) => a - b)
const minLine = lineNumbers[0]
const maxLine = lineNumbers[lineNumbers.length - 1]
const lineNumbers = Array.from(lineMap.keys()).sort((a, b) => a - b);
const minLine = lineNumbers[0];
const maxLine = lineNumbers[lineNumbers.length - 1];
const lines: string[] = []
const lines: string[] = [];
for (let i = minLine; i <= maxLine; i++) {
lines.push(lineMap.get(i) ?? '')
lines.push(lineMap.get(i) ?? "");
}
return lines.join('\n')
return lines.join("\n");
}
function buildTokenLookup(
lineMap: Map<number, string>,
highlighted: HighlightToken[][]
highlighted: HighlightToken[][],
): Map<number, HighlightToken[]> {
const lookup = new Map<number, HighlightToken[]>()
const lookup = new Map<number, HighlightToken[]>();
if (lineMap.size === 0) return lookup
if (lineMap.size === 0) return lookup;
const lineNumbers = Array.from(lineMap.keys()).sort((a, b) => a - b)
const minLine = lineNumbers[0]
const lineNumbers = Array.from(lineMap.keys()).sort((a, b) => a - b);
const minLine = lineNumbers[0];
// highlighted array is 0-indexed, line numbers are 1-indexed
for (let i = 0; i < highlighted.length; i++) {
const lineNum = minLine + i
const lineNum = minLine + i;
if (lineMap.has(lineNum)) {
lookup.set(lineNum, highlighted[i])
lookup.set(lineNum, highlighted[i]);
}
}
return lookup
return lookup;
}
/**
* Parse and highlight a complete diff
*/
export function parseAndHighlightDiff(diffText: string): ParsedDiffFile[] {
const files = parseDiff(diffText)
return files.map(highlightDiffFile)
const files = parseDiff(diffText);
return files.map(highlightDiffFile);
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { encodeAttachmentsForSend } from "@/attachments/service";
type ImageInput = AttachmentMetadata;
export async function encodeImages(
images?: ImageInput[]
images?: ImageInput[],
): Promise<Array<{ data: string; mimeType: string }> | undefined> {
return await encodeAttachmentsForSend(images);
}
@@ -7,7 +7,7 @@ describe("buildAbsoluteExplorerPath", () => {
buildAbsoluteExplorerPath({
workspaceRoot: "/Users/moboudra/dev/paseo",
entryPath: "packages/app/src/components/file-explorer-pane.tsx",
})
}),
).toBe("/Users/moboudra/dev/paseo/packages/app/src/components/file-explorer-pane.tsx");
});
@@ -16,7 +16,7 @@ describe("buildAbsoluteExplorerPath", () => {
buildAbsoluteExplorerPath({
workspaceRoot: "/Users/moboudra/dev/paseo",
entryPath: ".",
})
}),
).toBe("/Users/moboudra/dev/paseo");
});
@@ -25,7 +25,7 @@ describe("buildAbsoluteExplorerPath", () => {
buildAbsoluteExplorerPath({
workspaceRoot: "/Users/moboudra/dev/paseo/",
entryPath: "README.md",
})
}),
).toBe("/Users/moboudra/dev/paseo/README.md");
});
@@ -34,7 +34,7 @@ describe("buildAbsoluteExplorerPath", () => {
buildAbsoluteExplorerPath({
workspaceRoot: "C:\\repo\\paseo",
entryPath: "packages/app/src/components/file-explorer-pane.tsx",
})
}),
).toBe("C:\\repo\\paseo\\packages\\app\\src\\components\\file-explorer-pane.tsx");
});
@@ -43,7 +43,7 @@ describe("buildAbsoluteExplorerPath", () => {
buildAbsoluteExplorerPath({
workspaceRoot: "/Users/moboudra/dev/paseo",
entryPath: "/tmp/another/location.txt",
})
}),
).toBe("/tmp/another/location.txt");
});
});
+1 -3
View File
@@ -5,9 +5,7 @@ interface BuildAbsoluteExplorerPathInput {
function isAbsolutePath(pathValue: string): boolean {
return (
pathValue.startsWith("/") ||
pathValue.startsWith("\\\\") ||
/^[A-Za-z]:[\\/]/.test(pathValue)
pathValue.startsWith("/") || pathValue.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(pathValue)
);
}
@@ -1,61 +1,61 @@
import { describe, expect, it } from 'vitest'
import { applyFileMentionReplacement, findActiveFileMention } from './file-mention-autocomplete'
import { describe, expect, it } from "vitest";
import { applyFileMentionReplacement, findActiveFileMention } from "./file-mention-autocomplete";
describe('findActiveFileMention', () => {
it('detects mentions at the start of input', () => {
describe("findActiveFileMention", () => {
it("detects mentions at the start of input", () => {
const mention = findActiveFileMention({
text: '@src/components',
cursorIndex: '@src/components'.length,
})
text: "@src/components",
cursorIndex: "@src/components".length,
});
expect(mention).toEqual({
start: 0,
end: '@src/components'.length,
query: 'src/components',
})
})
end: "@src/components".length,
query: "src/components",
});
});
it('detects mentions in the middle of input using cursor position', () => {
const text = 'read "@src/com" before merging'
const cursorIndex = text.indexOf('"') + 9
it("detects mentions in the middle of input using cursor position", () => {
const text = 'read "@src/com" before merging';
const cursorIndex = text.indexOf('"') + 9;
const mention = findActiveFileMention({
text,
cursorIndex,
})
});
expect(mention).toEqual({
start: text.indexOf('@'),
start: text.indexOf("@"),
end: cursorIndex,
query: 'src/com',
})
})
query: "src/com",
});
});
it('returns null when cursor is outside the mention token', () => {
const text = 'please review @src/components now'
it("returns null when cursor is outside the mention token", () => {
const text = "please review @src/components now";
const mention = findActiveFileMention({
text,
cursorIndex: text.length,
})
expect(mention).toBeNull()
})
})
});
expect(mention).toBeNull();
});
});
describe('applyFileMentionReplacement', () => {
it('replaces only the active @query segment with a quoted relative path', () => {
const text = 'open @src/com next'
describe("applyFileMentionReplacement", () => {
it("replaces only the active @query segment with a quoted relative path", () => {
const text = "open @src/com next";
const next = applyFileMentionReplacement({
text,
mention: { start: 5, end: 13, query: 'src/com' },
relativePath: 'src/components/chat.tsx',
})
expect(next).toBe('open "src/components/chat.tsx" next')
})
mention: { start: 5, end: 13, query: "src/com" },
relativePath: "src/components/chat.tsx",
});
expect(next).toBe('open "src/components/chat.tsx" next');
});
it('escapes double quotes in replacement path', () => {
const text = '@foo'
it("escapes double quotes in replacement path", () => {
const text = "@foo";
const next = applyFileMentionReplacement({
text,
mention: { start: 0, end: 4, query: 'foo' },
mention: { start: 0, end: 4, query: "foo" },
relativePath: 'src/"quoted".ts',
})
expect(next).toBe('"src/\\"quoted\\".ts"')
})
})
});
expect(next).toBe('"src/\\"quoted\\".ts"');
});
});
@@ -1,48 +1,48 @@
export interface FileMentionRange {
start: number
end: number
query: string
start: number;
end: number;
query: string;
}
interface FindActiveFileMentionInput {
text: string
cursorIndex: number
text: string;
cursorIndex: number;
}
interface ApplyFileMentionReplacementInput {
text: string
mention: FileMentionRange
relativePath: string
text: string;
mention: FileMentionRange;
relativePath: string;
}
const INVALID_MENTION_QUERY_CHARS = /[\s\n\r\t"']/
const INVALID_MENTION_QUERY_CHARS = /[\s\n\r\t"']/;
export function findActiveFileMention(input: FindActiveFileMentionInput): FileMentionRange | null {
const clampedCursor = Math.max(0, Math.min(input.cursorIndex, input.text.length))
const beforeCursor = input.text.slice(0, clampedCursor)
const clampedCursor = Math.max(0, Math.min(input.cursorIndex, input.text.length));
const beforeCursor = input.text.slice(0, clampedCursor);
for (
let atIndex = beforeCursor.lastIndexOf('@');
let atIndex = beforeCursor.lastIndexOf("@");
atIndex >= 0;
atIndex = beforeCursor.lastIndexOf('@', atIndex - 1)
atIndex = beforeCursor.lastIndexOf("@", atIndex - 1)
) {
const query = beforeCursor.slice(atIndex + 1)
const query = beforeCursor.slice(atIndex + 1);
if (INVALID_MENTION_QUERY_CHARS.test(query)) {
continue
continue;
}
return {
start: atIndex,
end: clampedCursor,
query,
}
};
}
return null
return null;
}
export function applyFileMentionReplacement(input: ApplyFileMentionReplacementInput): string {
const safePath = input.relativePath.replace(/"/g, '\\"')
const before = input.text.slice(0, input.mention.start)
const after = input.text.slice(input.mention.end)
return `${before}"${safePath}"${after}`
const safePath = input.relativePath.replace(/"/g, '\\"');
const before = input.text.slice(0, input.mention.start);
const after = input.text.slice(input.mention.end);
return `${before}"${safePath}"${after}`;
}
@@ -13,4 +13,3 @@ describe("formatShortcut", () => {
expect(formatShortcut(["mod", "E"], "non-mac")).toBe("Ctrl+E");
});
});
+2 -11
View File
@@ -1,10 +1,4 @@
export type ShortcutKey =
| "mod"
| "shift"
| "alt"
| "ctrl"
| "meta"
| string;
export type ShortcutKey = "mod" | "shift" | "alt" | "ctrl" | "meta" | string;
export type ShortcutOs = "mac" | "non-mac";
@@ -28,9 +22,7 @@ export function formatShortcut(keys: ShortcutKey[], os: ShortcutOs): string {
};
const modifierSet = new Set(normalized);
const mods = order
.filter((k) => modifierSet.has(k))
.map((k) => symbols[k] ?? "");
const mods = order.filter((k) => modifierSet.has(k)).map((k) => symbols[k] ?? "");
const main = normalized
.filter((k) => !order.includes(k))
.map(normalizeKey)
@@ -50,4 +42,3 @@ export function formatShortcut(keys: ShortcutKey[], os: ShortcutOs): string {
.filter(Boolean)
.join("+");
}
@@ -9,7 +9,7 @@ describe("shouldAnchorHeaderBeforeCollapse", () => {
headerHeight: 44,
viewportOffset: 80,
viewportHeight: 400,
})
}),
).toBe(false);
});
@@ -20,7 +20,7 @@ describe("shouldAnchorHeaderBeforeCollapse", () => {
headerHeight: 44,
viewportOffset: 80,
viewportHeight: 300,
})
}),
).toBe(false);
});
@@ -31,7 +31,7 @@ describe("shouldAnchorHeaderBeforeCollapse", () => {
headerHeight: 44,
viewportOffset: 500,
viewportHeight: 300,
})
}),
).toBe(true);
});
@@ -42,7 +42,7 @@ describe("shouldAnchorHeaderBeforeCollapse", () => {
headerHeight: 44,
viewportOffset: 100,
viewportHeight: 300,
})
}),
).toBe(true);
});
@@ -53,7 +53,7 @@ describe("shouldAnchorHeaderBeforeCollapse", () => {
headerHeight: 44,
viewportOffset: 0,
viewportHeight: 0,
})
}),
).toBe(true);
});
});
+7 -7
View File
@@ -29,9 +29,9 @@ describe("workspace route parsing", () => {
});
it("decodes non-canonical base64url workspace IDs used by older links", () => {
expect(
decodeWorkspaceIdFromPathSegment("L1VzZXJzL21vYm91ZHJhL2Rldi9wYXNlby")
).toBe("/Users/moboudra/dev/paseo");
expect(decodeWorkspaceIdFromPathSegment("L1VzZXJzL21vYm91ZHJhL2Rldi9wYXNlby")).toBe(
"/Users/moboudra/dev/paseo",
);
});
it("encodes file paths as base64url (no padding)", () => {
@@ -49,7 +49,7 @@ describe("workspace route parsing", () => {
it("does not treat /tab routes as valid workspace routes", () => {
expect(
parseHostWorkspaceRouteFromPathname("/h/local/workspace/L3RtcC9yZXBv/tab/draft_abc123")
parseHostWorkspaceRouteFromPathname("/h/local/workspace/L3RtcC9yZXBv/tab/draft_abc123"),
).toBeNull();
});
@@ -64,8 +64,8 @@ describe("workspace route parsing", () => {
it("parses workspace open intent from pathname query", () => {
expect(
parseHostWorkspaceOpenIntentFromPathname(
"/h/local/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1"
)
"/h/local/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1",
),
).toEqual({
kind: "agent",
agentId: "agent-1",
@@ -86,7 +86,7 @@ describe("workspace route parsing", () => {
it("uses the plain workspace route when workspace context is provided", () => {
expect(buildHostAgentDetailRoute("local", "agent-1", "/tmp/repo")).toBe(
"/h/local/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1"
"/h/local/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1",
);
});
});
+10 -25
View File
@@ -88,11 +88,7 @@ function tryDecodeBase64UrlNoPadUtf8(input: string): string | null {
}
function isPathLikeWorkspaceIdentity(value: string): boolean {
return (
value.includes("/") ||
value.includes("\\") ||
/^[A-Za-z]:[\\/]/.test(value)
);
return value.includes("/") || value.includes("\\") || /^[A-Za-z]:[\\/]/.test(value);
}
function normalizeWorkspaceId(value: string): string {
@@ -106,7 +102,7 @@ export type WorkspaceOpenIntent =
| { kind: "draft"; draftId: string };
export function parseWorkspaceOpenIntent(
value: string | null | undefined
value: string | null | undefined,
): WorkspaceOpenIntent | null {
const normalized = trimNonEmpty(value);
if (!normalized) {
@@ -145,7 +141,7 @@ export function parseWorkspaceOpenIntent(
}
export function parseHostWorkspaceOpenIntentFromPathname(
pathname: string
pathname: string,
): WorkspaceOpenIntent | null {
const search = extractSearch(pathname);
if (!search) {
@@ -228,7 +224,7 @@ export function parseServerIdFromPathname(pathname: string): string | null {
}
export function parseHostAgentRouteFromPathname(
pathname: string
pathname: string,
): { serverId: string; agentId: string } | null {
const pathOnly = stripSearchAndHash(pathname);
const match = pathOnly.match(/^\/h\/([^/]+)\/agent\/([^/]+)(?:\/|$)/);
@@ -251,7 +247,7 @@ export function parseHostAgentRouteFromPathname(
}
export function parseHostWorkspaceRouteFromPathname(
pathname: string
pathname: string,
): { serverId: string; workspaceId: string } | null {
const pathOnly = stripSearchAndHash(pathname);
const match = pathOnly.match(/^\/h\/([^/]+)\/workspace\/([^/]+)\/?$/);
@@ -272,10 +268,7 @@ export function parseHostWorkspaceRouteFromPathname(
return { serverId, workspaceId };
}
export function buildHostWorkspaceRoute(
serverId: string,
workspaceId: string
): string {
export function buildHostWorkspaceRoute(serverId: string, workspaceId: string): string {
const normalizedServerId = trimNonEmpty(serverId);
const normalizedWorkspaceId = trimNonEmpty(workspaceId);
if (!normalizedServerId || !normalizedWorkspaceId) {
@@ -291,7 +284,7 @@ export function buildHostWorkspaceRoute(
export function buildHostAgentDetailRoute(
serverId: string,
agentId: string,
workspaceId?: string
workspaceId?: string,
): string {
const normalizedWorkspaceId = trimNonEmpty(workspaceId);
if (normalizedWorkspaceId) {
@@ -310,9 +303,7 @@ export function buildHostAgentDetailRoute(
if (!normalizedServerId || !normalizedAgentId) {
return "/";
}
return `${buildHostRootRoute(normalizedServerId)}/agent/${encodeSegment(
normalizedAgentId
)}`;
return `${buildHostRootRoute(normalizedServerId)}/agent/${encodeSegment(normalizedAgentId)}`;
}
export function buildHostRootRoute(serverId: string): string {
@@ -347,10 +338,7 @@ export function buildHostSettingsRoute(serverId: string): string {
return `${base}/settings`;
}
export function mapPathnameToServer(
pathname: string,
nextServerId: string
): string {
export function mapPathnameToServer(pathname: string, nextServerId: string): string {
const normalized = trimNonEmpty(nextServerId);
if (!normalized) {
return "/";
@@ -369,10 +357,7 @@ export function mapPathnameToServer(
}
const workspaceRoute = parseHostWorkspaceRouteFromPathname(pathname);
if (workspaceRoute) {
return buildHostWorkspaceRoute(
normalized,
workspaceRoute.workspaceId
);
return buildHostWorkspaceRoute(normalized, workspaceRoute.workspaceId);
}
if (suffix.startsWith("agent/")) {
return `${base}/${suffix}`;
@@ -6,11 +6,7 @@ import {
import { __setAttachmentStoreForTests } from "@/attachments/store";
import type { AttachmentStore } from "@/attachments/types";
function createClipboardItem(params: {
kind: string;
type: string;
file?: File | null;
}) {
function createClipboardItem(params: { kind: string; type: string; file?: File | null }) {
return {
kind: params.kind,
type: params.type,
@@ -14,7 +14,7 @@ export interface ClipboardDataLike {
export type ImageAttachmentFromFile = AttachmentMetadata;
export function collectImageFilesFromClipboardData(
clipboardData?: ClipboardDataLike | null
clipboardData?: ClipboardDataLike | null,
): File[] {
if (!clipboardData?.items) {
return [];
@@ -39,7 +39,7 @@ export function collectImageFilesFromClipboardData(
}
export async function filesToImageAttachments(
files: readonly File[]
files: readonly File[],
): Promise<ImageAttachmentFromFile[]> {
const attachments = await Promise.all(
files.map(async (file) => {
@@ -56,10 +56,8 @@ export async function filesToImageAttachments(
});
return null;
}
})
}),
);
return attachments.filter(
(entry): entry is ImageAttachmentFromFile => entry !== null
);
return attachments.filter((entry): entry is ImageAttachmentFromFile => entry !== null);
}
+16 -32
View File
@@ -38,9 +38,7 @@ describe("parseInlinePathToken", () => {
describe("parseFileProtocolUrl", () => {
it("parses file URLs with line fragments", () => {
expect(
parseFileProtocolUrl("file:///Users/test/project/src/app.tsx#L81")
).toEqual({
expect(parseFileProtocolUrl("file:///Users/test/project/src/app.tsx#L81")).toEqual({
raw: "file:///Users/test/project/src/app.tsx#L81",
path: "/Users/test/project/src/app.tsx",
lineStart: 81,
@@ -49,9 +47,7 @@ describe("parseFileProtocolUrl", () => {
});
it("parses file URLs without line fragments", () => {
expect(
parseFileProtocolUrl("file:///Users/test/project/src/app.tsx")
).toEqual({
expect(parseFileProtocolUrl("file:///Users/test/project/src/app.tsx")).toEqual({
raw: "file:///Users/test/project/src/app.tsx",
path: "/Users/test/project/src/app.tsx",
lineStart: undefined,
@@ -60,9 +56,7 @@ describe("parseFileProtocolUrl", () => {
});
it("parses windows file URLs and line ranges", () => {
expect(
parseFileProtocolUrl("file:///C:/Users/test/project/src/app.tsx#L12-L20")
).toEqual({
expect(parseFileProtocolUrl("file:///C:/Users/test/project/src/app.tsx#L12-L20")).toEqual({
raw: "file:///C:/Users/test/project/src/app.tsx#L12-L20",
path: "C:/Users/test/project/src/app.tsx",
lineStart: 12,
@@ -72,9 +66,7 @@ describe("parseFileProtocolUrl", () => {
it("rejects non-file URLs and invalid ranges", () => {
expect(parseFileProtocolUrl("https://example.com/test.ts#L10")).toBeNull();
expect(
parseFileProtocolUrl("file:///Users/test/project/src/app.tsx#L20-L12")
).toBeNull();
expect(parseFileProtocolUrl("file:///Users/test/project/src/app.tsx#L20-L12")).toBeNull();
});
});
@@ -83,7 +75,7 @@ describe("parseAssistantFileLink", () => {
expect(
parseAssistantFileLink("/Users/test/project/src/app.tsx#L33", {
workspaceRoot: "/Users/test/project",
})
}),
).toEqual({
raw: "/Users/test/project/src/app.tsx#L33",
path: "/Users/test/project/src/app.tsx",
@@ -96,7 +88,7 @@ describe("parseAssistantFileLink", () => {
expect(
parseAssistantFileLink("C:/repo/src/app.tsx#L12-L20", {
workspaceRoot: "C:/repo",
})
}),
).toEqual({
raw: "C:/repo/src/app.tsx#L12-L20",
path: "C:/repo/src/app.tsx",
@@ -109,7 +101,7 @@ describe("parseAssistantFileLink", () => {
expect(
parseAssistantFileLink("file:///tmp/outside.txt", {
workspaceRoot: "/Users/test/project",
})
}),
).toEqual({
raw: "file:///tmp/outside.txt",
path: "/tmp/outside.txt",
@@ -122,30 +114,26 @@ describe("parseAssistantFileLink", () => {
expect(
parseAssistantFileLink("/tmp/outside.txt", {
workspaceRoot: "/Users/test/project",
})
}),
).toBeNull();
});
it("rejects external URLs", () => {
expect(
parseAssistantFileLink("https://example.com/Users/test/project/src/app.tsx")
).toBeNull();
expect(parseAssistantFileLink("https://example.com/Users/test/project/src/app.tsx")).toBeNull();
});
it("rejects invalid line fragments", () => {
expect(
parseAssistantFileLink("/Users/test/project/src/app.tsx#L20-L12", {
workspaceRoot: "/Users/test/project",
})
}),
).toBeNull();
});
});
describe("normalizeInlinePathTarget", () => {
it("keeps relative file paths as file targets", () => {
expect(
normalizeInlinePathTarget("packages/app/src/components/message.tsx")
).toEqual({
expect(normalizeInlinePathTarget("packages/app/src/components/message.tsx")).toEqual({
directory: "packages/app/src/components",
file: "packages/app/src/components/message.tsx",
});
@@ -155,8 +143,8 @@ describe("normalizeInlinePathTarget", () => {
expect(
normalizeInlinePathTarget(
"/Users/test/project/packages/app/src/components/message.tsx",
"/Users/test/project"
)
"/Users/test/project",
),
).toEqual({
directory: "packages/app/src/components",
file: "packages/app/src/components/message.tsx",
@@ -164,25 +152,21 @@ describe("normalizeInlinePathTarget", () => {
});
it("keeps absolute paths outside cwd as absolute file targets", () => {
expect(
normalizeInlinePathTarget("/tmp/message.tsx", "/Users/test/project")
).toEqual({
expect(normalizeInlinePathTarget("/tmp/message.tsx", "/Users/test/project")).toEqual({
directory: "/tmp",
file: "/tmp/message.tsx",
});
});
it("treats cwd itself as the workspace root directory", () => {
expect(
normalizeInlinePathTarget("/Users/test/project", "/Users/test/project")
).toEqual({
expect(normalizeInlinePathTarget("/Users/test/project", "/Users/test/project")).toEqual({
directory: ".",
});
});
it("keeps trailing-slash paths as directories", () => {
expect(
normalizeInlinePathTarget("/Users/test/project/packages/app/", "/Users/test/project")
normalizeInlinePathTarget("/Users/test/project/packages/app/", "/Users/test/project"),
).toEqual({
directory: "packages/app",
});
+6 -14
View File
@@ -148,7 +148,7 @@ export function parseFileProtocolUrl(value: string): InlinePathTarget | null {
export function parseAssistantFileLink(
value: string,
options: AssistantHrefParseOptions = {}
options: AssistantHrefParseOptions = {},
): InlinePathTarget | null {
const fileUrlTarget = parseFileProtocolUrl(value);
if (fileUrlTarget) {
@@ -217,7 +217,7 @@ export function parseAssistantFileLink(
export function normalizeInlinePathTarget(
rawPath: string,
cwd?: string
cwd?: string,
): NormalizedInlinePathTarget | null {
if (!rawPath) {
return null;
@@ -268,15 +268,11 @@ function isAllowedAbsolutePath(pathValue: string, workspaceRoot?: string): boole
const comparePath = normalizePathForCompare(pathValue);
const compareWorkspaceRoot = normalizePathForCompare(
normalizedWorkspaceRoot.replace(/\/+$/, "") || "/"
normalizedWorkspaceRoot.replace(/\/+$/, "") || "/",
);
const comparePrefix =
compareWorkspaceRoot === "/" ? "/" : `${compareWorkspaceRoot}/`;
const comparePrefix = compareWorkspaceRoot === "/" ? "/" : `${compareWorkspaceRoot}/`;
return (
comparePath === compareWorkspaceRoot ||
comparePath.startsWith(comparePrefix)
);
return comparePath === compareWorkspaceRoot || comparePath.startsWith(comparePrefix);
}
function normalizeFileUrlPath(pathname: string): string | null {
@@ -314,11 +310,7 @@ function normalizePathInput(value: string | undefined): string | null {
function resolvePathAgainstCwd(pathValue: string, cwd?: string): string | null {
const normalizedCwd = normalizePathInput(cwd);
if (
!normalizedCwd ||
!isAbsolutePath(pathValue) ||
!isAbsolutePath(normalizedCwd)
) {
if (!normalizedCwd || !isAbsolutePath(pathValue) || !isAbsolutePath(normalizedCwd)) {
return null;
}
+9 -20
View File
@@ -3,24 +3,14 @@ import { getMarkdownListMarker } from "./markdown-list";
describe("getMarkdownListMarker", () => {
it("returns a bullet marker for unordered list items", () => {
expect(
getMarkdownListMarker(
{ index: 0 },
[{ type: "bullet_list" }]
)
).toEqual({
expect(getMarkdownListMarker({ index: 0 }, [{ type: "bullet_list" }])).toEqual({
isOrdered: false,
marker: "•",
});
});
it("returns numbered markers for ordered list items", () => {
expect(
getMarkdownListMarker(
{ index: 1, markup: "." },
[{ type: "ordered_list" }]
)
).toEqual({
expect(getMarkdownListMarker({ index: 1, markup: "." }, [{ type: "ordered_list" }])).toEqual({
isOrdered: true,
marker: "2.",
});
@@ -28,10 +18,9 @@ describe("getMarkdownListMarker", () => {
it("respects ordered list start attribute", () => {
expect(
getMarkdownListMarker(
{ index: 2, markup: ")" },
[{ type: "ordered_list", attributes: { start: "5" } }]
)
getMarkdownListMarker({ index: 2, markup: ")" }, [
{ type: "ordered_list", attributes: { start: "5" } },
]),
).toEqual({
isOrdered: true,
marker: "7)",
@@ -40,10 +29,10 @@ describe("getMarkdownListMarker", () => {
it("prefers the nearest list ancestor in nested lists", () => {
expect(
getMarkdownListMarker(
{ index: 0, markup: "." },
[{ type: "ordered_list" }, { type: "bullet_list" }]
)
getMarkdownListMarker({ index: 0, markup: "." }, [
{ type: "ordered_list" },
{ type: "bullet_list" },
]),
).toEqual({
isOrdered: true,
marker: "1.",
+5 -3
View File
@@ -25,8 +25,7 @@ function toParentNodes(parent: unknown): MarkdownNode[] {
function getNearestListParent(parent: unknown): MarkdownNode | undefined {
return toParentNodes(parent).find(
(ancestor) =>
ancestor?.type === "ordered_list" || ancestor?.type === "bullet_list"
(ancestor) => ancestor?.type === "ordered_list" || ancestor?.type === "bullet_list",
);
}
@@ -61,7 +60,10 @@ function parseOrderedListStart(node: MarkdownNode): number {
return 1;
}
export function getMarkdownListMarker(node: MarkdownNode, parent: unknown): {
export function getMarkdownListMarker(
node: MarkdownNode,
parent: unknown,
): {
isOrdered: boolean;
marker: string;
} {
@@ -10,15 +10,13 @@ import {
describe("buildNewAgentRoute", () => {
it("falls back to server workspace route with dot workspace when no working directory is provided", () => {
expect(buildNewAgentRoute("srv-1", undefined)).toBe(
"/h/srv-1/workspace/Lg"
);
expect(buildNewAgentRoute("srv-1", undefined)).toBe("/h/srv-1/workspace/Lg");
expect(buildNewAgentRoute("srv-1", " ")).toBe("/h/srv-1/workspace/Lg");
});
it("encodes the working directory as a workspace path segment", () => {
expect(buildNewAgentRoute("srv-1", "/Users/me/dev/paseo")).toBe(
"/h/srv-1/workspace/L1VzZXJzL21lL2Rldi9wYXNlbw"
"/h/srv-1/workspace/L1VzZXJzL21lL2Rldi9wYXNlbw",
);
});
});
@@ -29,15 +27,13 @@ describe("resolveNewAgentWorkingDir", () => {
});
it("falls back to repo root when checkout metadata is unavailable", () => {
expect(resolveNewAgentWorkingDir("/repo/.paseo/worktrees/feature", null)).toBe(
"/repo"
);
expect(resolveNewAgentWorkingDir("/repo/.paseo/worktrees/feature", null)).toBe("/repo");
});
it("supports windows-style paseo worktree paths without checkout metadata", () => {
expect(
resolveNewAgentWorkingDir("C:\\Users\\me\\repo\\.paseo\\worktrees\\feature", null)
).toBe("C:\\Users\\me\\repo");
expect(resolveNewAgentWorkingDir("C:\\Users\\me\\repo\\.paseo\\worktrees\\feature", null)).toBe(
"C:\\Users\\me\\repo",
);
});
it("returns the main repo root for paseo-owned worktrees", () => {
@@ -47,7 +43,7 @@ describe("resolveNewAgentWorkingDir", () => {
} as CheckoutStatusPayload;
expect(resolveNewAgentWorkingDir("/repo/.paseo/worktrees/feature", checkout)).toBe(
"/repo/main"
"/repo/main",
);
});
});
@@ -81,7 +77,7 @@ describe("resolveSelectedAgentForNewAgent", () => {
resolveSelectedAgentForNewAgent({
pathname: "/h/srv-1/workspace/L3JlcG8?open=agent%3Aagent-2",
selectedAgentId: "srv-9:agent-9",
})
}),
).toEqual({
serverId: "srv-1",
agentId: "agent-2",
@@ -93,7 +89,7 @@ describe("resolveSelectedAgentForNewAgent", () => {
resolveSelectedAgentForNewAgent({
pathname: "/h/srv-1/settings",
selectedAgentId: "srv-1:agent-7",
})
}),
).toEqual({
serverId: "srv-1",
agentId: "agent-7",
@@ -104,7 +100,7 @@ describe("resolveSelectedAgentForNewAgent", () => {
expect(
resolveSelectedAgentForNewAgent({
pathname: "/h/srv-1/settings",
})
}),
).toBeNull();
});
});
+4 -10
View File
@@ -7,7 +7,7 @@ import {
} from "@/utils/host-routes";
export function parseAgentKey(
key: string | null | undefined
key: string | null | undefined,
): { serverId: string; agentId: string } | null {
if (!key) {
return null;
@@ -36,10 +36,7 @@ export function resolveSelectedAgentForNewAgent(input: {
return { serverId: workspaceRoute.serverId, agentId };
}
}
return (
parseHostAgentRouteFromPathname(input.pathname) ??
parseAgentKey(input.selectedAgentId)
);
return parseHostAgentRouteFromPathname(input.pathname) ?? parseAgentKey(input.selectedAgentId);
}
function inferMainRepoRootFromPaseoWorktreePath(cwd: string): string | null {
@@ -60,7 +57,7 @@ function inferMainRepoRootFromPaseoWorktreePath(cwd: string): string | null {
export function resolveNewAgentWorkingDir(
cwd: string,
checkout: CheckoutStatusPayload | null
checkout: CheckoutStatusPayload | null,
): string {
const explicitMainRepoRoot = checkout?.isPaseoOwnedWorktree
? checkout.mainRepoRoot?.trim() || null
@@ -72,10 +69,7 @@ export function resolveNewAgentWorkingDir(
return inferMainRepoRootFromPaseoWorktreePath(cwd) ?? cwd;
}
export function buildNewAgentRoute(
serverId: string,
workingDir?: string | null
): string {
export function buildNewAgentRoute(serverId: string, workingDir?: string | null): string {
const trimmedWorkingDir = workingDir?.trim();
return buildHostWorkspaceRoute(serverId, trimmedWorkingDir || ".");
}
@@ -1,9 +1,6 @@
import { describe, expect, it } from "vitest";
import {
buildNotificationRoute,
resolveNotificationTarget,
} from "./notification-routing";
import { buildNotificationRoute, resolveNotificationTarget } from "./notification-routing";
describe("resolveNotificationTarget", () => {
it("extracts non-empty server and agent ids", () => {
@@ -11,7 +8,7 @@ describe("resolveNotificationTarget", () => {
resolveNotificationTarget({
serverId: " server-123 ",
agentId: " agent-456 ",
})
}),
).toEqual({
serverId: "server-123",
agentId: "agent-456",
@@ -40,13 +37,13 @@ describe("buildNotificationRoute", () => {
serverId: "srv-1",
agentId: "agent-1",
workspaceId: "/tmp/repo",
})
}),
).toBe("/h/srv-1/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1");
});
it("routes directly to server-scoped agent path when both ids are present", () => {
expect(buildNotificationRoute({ serverId: "srv-1", agentId: "agent-1" })).toBe(
"/h/srv-1/agent/agent-1"
"/h/srv-1/agent/agent-1",
);
});
@@ -64,7 +61,7 @@ describe("buildNotificationRoute", () => {
buildNotificationRoute({
serverId: "srv/with/slash",
agentId: "agent with space",
})
}),
).toBe("/h/srv%2Fwith%2Fslash/agent/agent%20with%20space");
});
});
@@ -6,10 +6,7 @@ import {
type NotificationData = Record<string, unknown> | null | undefined;
function readNonEmptyString(
data: NotificationData,
key: string
): string | null {
function readNonEmptyString(data: NotificationData, key: string): string | null {
const value = data?.[key];
if (typeof value !== "string") {
return null;
@@ -26,9 +23,7 @@ export function resolveNotificationTarget(data: NotificationData): {
return {
serverId: readNonEmptyString(data, "serverId"),
agentId: readNonEmptyString(data, "agentId"),
workspaceId:
readNonEmptyString(data, "workspaceId") ??
readNonEmptyString(data, "cwd"),
workspaceId: readNonEmptyString(data, "workspaceId") ?? readNonEmptyString(data, "cwd"),
};
}
@@ -41,7 +41,7 @@ async function loadModuleForPlatform(
}) => Promise<boolean>;
};
} | null;
}
},
) {
vi.resetModules();
vi.doMock("react-native", () => ({ Platform: { OS: platform } }));
@@ -107,7 +107,10 @@ describe("sendOsNotification", () => {
onclick: ((event: Event) => void) | null = null;
close = vi.fn();
constructor(public title: string, public options?: MockNotificationOptions) {
constructor(
public title: string,
public options?: MockNotificationOptions,
) {
created.push(this);
}
}
@@ -122,8 +125,7 @@ describe("sendOsNotification", () => {
(globalThis as { dispatchEvent?: unknown }).dispatchEvent = dispatchEvent;
(globalThis as { location?: unknown }).location = { assign };
const { sendOsNotification, WEB_NOTIFICATION_CLICK_EVENT } =
await loadModuleForPlatform("web");
const { sendOsNotification, WEB_NOTIFICATION_CLICK_EVENT } = await loadModuleForPlatform("web");
const sent = await sendOsNotification({
title: "Agent finished",
@@ -159,7 +161,10 @@ describe("sendOsNotification", () => {
onclick: ((event: Event) => void) | null = null;
close = vi.fn();
constructor(public title: string, public options?: MockNotificationOptions) {
constructor(
public title: string,
public options?: MockNotificationOptions,
) {
created.push(this);
}
}
@@ -209,7 +214,10 @@ describe("sendOsNotification", () => {
onclick: ((event: Event) => void) | null = null;
close = vi.fn();
constructor(public title: string, public options?: MockNotificationOptions) {
constructor(
public title: string,
public options?: MockNotificationOptions,
) {
created.push(this);
}
}
+10 -22
View File
@@ -1,10 +1,7 @@
import { Asset } from "expo-asset";
import { Platform } from "react-native";
import { getDesktopHost } from "@/desktop/host";
import {
buildNotificationRoute,
resolveNotificationTarget,
} from "./notification-routing";
import { buildNotificationRoute, resolveNotificationTarget } from "./notification-routing";
type OsNotificationPayload = {
title: string;
@@ -51,7 +48,7 @@ function getWebNotificationConstructor(): {
body?: string;
data?: Record<string, unknown>;
icon?: string;
}
},
): unknown;
} | null {
const NotificationConstructor = (globalThis as { Notification?: any }).Notification;
@@ -75,7 +72,7 @@ async function ensureNotificationPermission(): Promise<boolean> {
permissionRequest = Promise.resolve(
NotificationConstructor.requestPermission
? NotificationConstructor.requestPermission()
: "denied"
: "denied",
).then((permission) => permission === "granted");
const result = await permissionRequest;
permissionRequest = null;
@@ -89,9 +86,7 @@ export async function ensureOsNotificationPermission(): Promise<boolean> {
return await ensureNotificationPermission();
}
function hasNotificationClickTarget(
data: Record<string, unknown> | undefined
): boolean {
function hasNotificationClickTarget(data: Record<string, unknown> | undefined): boolean {
const target = resolveNotificationTarget(data);
return target.serverId !== null || target.agentId !== null || target.workspaceId !== null;
}
@@ -102,9 +97,7 @@ function getWebNotificationIconUrl(): string | undefined {
}
try {
const asset = Asset.fromModule(
require("../../assets/images/notification-icon.png")
);
const asset = Asset.fromModule(require("../../assets/images/notification-icon.png"));
notificationIconUrl = asset.uri ?? null;
} catch {
notificationIconUrl = null;
@@ -115,8 +108,7 @@ function getWebNotificationIconUrl(): string | undefined {
function dispatchWebNotificationClick(detail: WebNotificationClickDetail): boolean {
const dispatch = (globalThis as { dispatchEvent?: (event: Event) => boolean }).dispatchEvent;
const CustomEventConstructor = (globalThis as { CustomEvent?: typeof CustomEvent })
.CustomEvent;
const CustomEventConstructor = (globalThis as { CustomEvent?: typeof CustomEvent }).CustomEvent;
if (typeof dispatch !== "function" || !CustomEventConstructor) {
return false;
@@ -127,14 +119,12 @@ function dispatchWebNotificationClick(detail: WebNotificationClickDetail): boole
{
detail,
cancelable: true,
}
},
);
return dispatch(event) === false;
}
function fallbackNavigateToNotificationTarget(
data: Record<string, unknown> | undefined
): void {
function fallbackNavigateToNotificationTarget(data: Record<string, unknown> | undefined): void {
const route = buildNotificationRoute(data);
const location = (globalThis as { location?: { assign?: (url: string) => void; href?: string } })
.location;
@@ -152,7 +142,7 @@ function fallbackNavigateToNotificationTarget(
function attachWebClickHandler(
notification: WebNotificationInstance,
data: Record<string, unknown> | undefined
data: Record<string, unknown> | undefined,
): void {
notification.onclick = () => {
const handledByApp = dispatchWebNotificationClick({ data });
@@ -162,9 +152,7 @@ function attachWebClickHandler(
};
}
export async function sendOsNotification(
payload: OsNotificationPayload
): Promise<boolean> {
export async function sendOsNotification(payload: OsNotificationPayload): Promise<boolean> {
// Mobile/native notifications should be remote push only.
if (Platform.OS !== "web") {
return false;
@@ -5,7 +5,13 @@ import { mergePendingCreateImages } from "./pending-create-images";
function userMessage(params: {
id: string;
text: string;
images?: Array<{ id: string; storageType: "native-file"; storageKey: string; mimeType: string; createdAt: number }>;
images?: Array<{
id: string;
storageType: "native-file";
storageKey: string;
mimeType: string;
createdAt: number;
}>;
}): StreamItem {
return {
kind: "user_message",
@@ -71,9 +77,7 @@ describe("mergePendingCreateImages", () => {
it("does not overwrite existing user message images", () => {
const existingImages = buildImage("existing");
const streamItems = [
userMessage({ id: "msg-1", text: "hello", images: existingImages }),
];
const streamItems = [userMessage({ id: "msg-1", text: "hello", images: existingImages })];
const result = mergePendingCreateImages({
streamItems,
clientMessageId: "msg-1",
@@ -16,7 +16,7 @@ export function mergePendingCreateImages({
}
const targetIndex = streamItems.findIndex(
(item) => item.kind === "user_message" && item.id === clientMessageId
(item) => item.kind === "user_message" && item.id === clientMessageId,
);
if (targetIndex < 0) {
return streamItems;
@@ -7,7 +7,7 @@ import {
describe("projectDisplayNameFromProjectId", () => {
it("shows owner and repo for GitHub remote ids", () => {
expect(projectDisplayNameFromProjectId("remote:github.com/getpaseo/paseo")).toBe(
"getpaseo/paseo"
"getpaseo/paseo",
);
});
@@ -8,9 +8,7 @@ export function projectDisplayNameFromProjectId(projectId: string): string {
return segments[segments.length - 1] || projectId;
}
export function projectIconPlaceholderLabelFromDisplayName(
displayName: string
): string {
export function projectIconPlaceholderLabelFromDisplayName(displayName: string): string {
const trimmedDisplayName = displayName.trim();
if (!trimmedDisplayName) {
return "";
@@ -1,8 +1,5 @@
import { describe, expect, it } from "vitest";
import {
deriveProjectPlacementFromCwd,
resolveProjectPlacement,
} from "./project-placement";
import { deriveProjectPlacementFromCwd, resolveProjectPlacement } from "./project-placement";
describe("project-placement", () => {
it("derives fallback placement from cwd", () => {
@@ -15,15 +12,11 @@ describe("project-placement", () => {
});
it("normalizes paseo worktree paths into the parent repo key", () => {
const placement = deriveProjectPlacementFromCwd(
"/Users/test/repo/.paseo/worktrees/feature-x"
);
const placement = deriveProjectPlacementFromCwd("/Users/test/repo/.paseo/worktrees/feature-x");
expect(placement.projectKey).toBe("/Users/test/repo");
expect(placement.projectName).toBe("repo");
expect(placement.checkout.cwd).toBe(
"/Users/test/repo/.paseo/worktrees/feature-x"
);
expect(placement.checkout.cwd).toBe("/Users/test/repo/.paseo/worktrees/feature-x");
});
it("prefers an existing placement when present", () => {
@@ -19,10 +19,7 @@ export const PROVIDER_COMMAND_TEMPLATES: Record<
},
};
function renderTemplate(
template: string,
vars: Record<string, string>
): string {
function renderTemplate(template: string, vars: Record<string, string>): string {
return template.replace(/\{(\w+)\}/g, (_match, key: string) => vars[key] ?? "");
}
@@ -37,4 +34,3 @@ export function buildProviderCommand(input: {
}
return renderTemplate(template, { sessionId: input.sessionId });
}
@@ -50,7 +50,7 @@ type ScrollInvestigationStore = {
| "wheelAttach"
| "wheelDetach"
| "inputChange"
| "keyPress"
| "keyPress",
) => void;
snapshot: () => {
listeners: {
@@ -92,11 +92,7 @@ const SOURCE_LABEL = "[ScrollJankInvestigation]";
function shouldInstall(): boolean {
const runtime = globalThis as ScrollInvestigationGlobal;
const isDev = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
return (
Platform.OS === "web" &&
isDev &&
!runtime.__PASEO_SCROLL_JANK_INVESTIGATION_DISABLED__
);
return Platform.OS === "web" && isDev && !runtime.__PASEO_SCROLL_JANK_INVESTIGATION_DISABLED__;
}
function normalizeCapture(options?: AddEventListenerOptions | boolean): boolean {
@@ -137,8 +133,7 @@ function describeEventTarget(target: EventTarget): string {
.join("");
}
const ctorName = (target as { constructor?: { name?: string } }).constructor
?.name;
const ctorName = (target as { constructor?: { name?: string } }).constructor?.name;
return ctorName || "unknown-target";
}
@@ -164,10 +159,7 @@ function inferCallsite(): string {
return "unknown";
}
function ensureListenerStats(
map: Map<string, ListenerStats>,
type: string
): ListenerStats {
function ensureListenerStats(map: Map<string, ListenerStats>, type: string): ListenerStats {
const existing = map.get(type);
if (existing) {
return existing;
@@ -179,7 +171,7 @@ function ensureListenerStats(
function ensureComponentStats(
map: Map<string, ComponentStats>,
componentId: string
componentId: string,
): ComponentStats {
const existing = map.get(componentId);
if (existing) {
@@ -216,10 +208,7 @@ export function installScrollJankInvestigation(): void {
const targetIds = new WeakMap<object, number>();
const listenerIds = new WeakMap<object, number>();
const activeListenerKeys = new Set<string>();
const activeListenerMeta = new Map<
string,
{ type: string; target: string }
>();
const activeListenerMeta = new Map<string, { type: string; target: string }>();
const listenerStatsByType = new Map<string, ListenerStats>();
const listenerCallsiteCount = new Map<string, number>();
const componentStatsById = new Map<string, ComponentStats>();
@@ -276,7 +265,7 @@ export function installScrollJankInvestigation(): void {
target: EventTarget,
type: string,
listener: EventListenerOrEventListenerObject,
options?: AddEventListenerOptions | boolean
options?: AddEventListenerOptions | boolean,
): string {
return [
getTargetId(target),
@@ -290,7 +279,7 @@ export function installScrollJankInvestigation(): void {
this: EventTarget,
type: string,
listener: EventListenerOrEventListenerObject | null,
options?: AddEventListenerOptions | boolean
options?: AddEventListenerOptions | boolean,
): void {
nativeAddEventListener.call(this, type, listener as any, options as any);
if (!listener) {
@@ -312,10 +301,7 @@ export function installScrollJankInvestigation(): void {
if (TRACKED_EVENT_TYPES.has(type)) {
const callsite = inferCallsite();
const metricKey = `${type} :: ${callsite}`;
listenerCallsiteCount.set(
metricKey,
(listenerCallsiteCount.get(metricKey) ?? 0) + 1
);
listenerCallsiteCount.set(metricKey, (listenerCallsiteCount.get(metricKey) ?? 0) + 1);
}
};
@@ -323,7 +309,7 @@ export function installScrollJankInvestigation(): void {
this: EventTarget,
type: string,
listener: EventListenerOrEventListenerObject | null,
options?: EventListenerOptions | boolean
options?: EventListenerOptions | boolean,
): void {
nativeRemoveEventListener.call(this, type, listener as any, options as any);
if (!listener) {
@@ -343,10 +329,8 @@ export function installScrollJankInvestigation(): void {
const nativeClearTimeout = globalThis.clearTimeout.bind(globalThis);
const nativeSetInterval = globalThis.setInterval.bind(globalThis);
const nativeClearInterval = globalThis.clearInterval.bind(globalThis);
const nativeRequestAnimationFrame =
globalThis.requestAnimationFrame.bind(globalThis);
const nativeCancelAnimationFrame =
globalThis.cancelAnimationFrame.bind(globalThis);
const nativeRequestAnimationFrame = globalThis.requestAnimationFrame.bind(globalThis);
const nativeCancelAnimationFrame = globalThis.cancelAnimationFrame.bind(globalThis);
globalThis.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => {
timerStats.timeout.created += 1;
@@ -545,7 +529,7 @@ export function installScrollJankInvestigation(): void {
runtime.__PASEO_SCROLL_JANK_INVESTIGATION__ = store;
console.log(
`${SOURCE_LABEL} installed`,
"Use window.__PASEO_SCROLL_JANK_INVESTIGATION__.snapshot()"
"Use window.__PASEO_SCROLL_JANK_INVESTIGATION__.snapshot()",
);
}
@@ -573,7 +557,7 @@ export function markScrollInvestigationEvent(
| "wheelAttach"
| "wheelDetach"
| "inputChange"
| "keyPress"
| "keyPress",
): void {
if (!shouldInstall()) {
return;
+1 -1
View File
@@ -12,5 +12,5 @@ export function markScrollInvestigationRender(_componentId: string): void {}
export function markScrollInvestigationEvent(
_componentId: string,
_event: ScrollInvestigationEvent
_event: ScrollInvestigationEvent,
): void {}
@@ -41,13 +41,13 @@ describe("server-info-capabilities", () => {
getVoiceReadinessState({
serverInfo,
mode: "dictation",
})
}),
).toEqual(capabilities.voice?.dictation);
expect(
getVoiceReadinessState({
serverInfo,
mode: "voice",
})
}),
).toEqual(capabilities.voice?.voice);
});
@@ -69,7 +69,7 @@ describe("server-info-capabilities", () => {
resolveVoiceUnavailableMessage({
serverInfo,
mode: "dictation",
})
}),
).toBeNull();
});
@@ -91,7 +91,7 @@ describe("server-info-capabilities", () => {
resolveVoiceUnavailableMessage({
serverInfo,
mode: "dictation",
})
}),
).toBe("Dictation models are still downloading.");
});
@@ -113,7 +113,7 @@ describe("server-info-capabilities", () => {
resolveVoiceUnavailableMessage({
serverInfo,
mode: "dictation",
})
}),
).toBeNull();
});
});
+1 -2
View File
@@ -11,7 +11,6 @@ export function getShortcutOs(): ShortcutOs {
const ua = navigator.userAgent ?? "";
const platform = (navigator as any).platform ?? "";
const isApple =
/Macintosh|Mac OS|iPhone|iPad|iPod/i.test(ua) ||
/Mac|iPhone|iPad|iPod/i.test(platform);
/Macintosh|Mac OS|iPhone|iPad|iPod/i.test(ua) || /Mac|iPhone|iPad|iPod/i.test(platform);
return isApple ? "mac" : "non-mac";
}
@@ -9,7 +9,7 @@ describe("deriveSidebarStateBucket", () => {
pendingPermissionCount: 1,
requiresAttention: false,
attentionReason: null,
})
}),
).toBe("needs_input");
});
@@ -20,7 +20,7 @@ describe("deriveSidebarStateBucket", () => {
pendingPermissionCount: 0,
requiresAttention: true,
attentionReason: "permission",
})
}),
).toBe("needs_input");
});
@@ -31,7 +31,7 @@ describe("deriveSidebarStateBucket", () => {
pendingPermissionCount: 0,
requiresAttention: true,
attentionReason: "finished",
})
}),
).toBe("attention");
});
@@ -42,7 +42,7 @@ describe("deriveSidebarStateBucket", () => {
pendingPermissionCount: 0,
requiresAttention: false,
attentionReason: null,
})
}),
).toBe("running");
});
});
+2 -12
View File
@@ -1,18 +1,8 @@
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
export type SidebarAttentionReason =
| "finished"
| "error"
| "permission"
| null
| undefined;
export type SidebarAttentionReason = "finished" | "error" | "permission" | null | undefined;
export type SidebarStateBucket =
| "needs_input"
| "failed"
| "running"
| "attention"
| "done";
export type SidebarStateBucket = "needs_input" | "failed" | "running" | "attention" | "done";
export function deriveSidebarStateBucket(input: {
status: AgentLifecycleStatus;
@@ -13,7 +13,7 @@ describe("decideLongPressMove", () => {
didStartDrag: false,
startPoint: { x: 0, y: 0 },
currentPoint: { x: 3, y: 2 },
})
}),
).toBe("none");
});
@@ -24,7 +24,7 @@ describe("decideLongPressMove", () => {
didStartDrag: false,
startPoint: { x: 0, y: 0 },
currentPoint: { x: 8, y: 8 },
})
}),
).toBe("cancel_long_press");
});
@@ -35,7 +35,7 @@ describe("decideLongPressMove", () => {
didStartDrag: false,
startPoint: { x: 0, y: 0 },
currentPoint: { x: 2, y: 7 },
})
}),
).toBe("vertical_scroll");
});
@@ -46,7 +46,7 @@ describe("decideLongPressMove", () => {
didStartDrag: false,
startPoint: { x: 0, y: 0 },
currentPoint: { x: 5, y: 7 },
})
}),
).toBe("none");
});
@@ -57,7 +57,7 @@ describe("decideLongPressMove", () => {
didStartDrag: false,
startPoint: { x: 0, y: 0 },
currentPoint: { x: -10, y: 2 },
})
}),
).toBe("horizontal_swipe");
});
@@ -68,7 +68,7 @@ describe("decideLongPressMove", () => {
didStartDrag: false,
startPoint: { x: 0, y: 0 },
currentPoint: { x: 0, y: 9 },
})
}),
).toBe("start_drag");
});
@@ -79,7 +79,7 @@ describe("decideLongPressMove", () => {
didStartDrag: true,
startPoint: { x: 0, y: 0 },
currentPoint: { x: 20, y: 20 },
})
}),
).toBe("none");
});
});
@@ -90,21 +90,21 @@ describe("shouldOpenContextMenuOnPressOut", () => {
shouldOpenContextMenuOnPressOut({
longPressArmed: true,
didStartDrag: false,
})
}),
).toBe(true);
expect(
shouldOpenContextMenuOnPressOut({
longPressArmed: false,
didStartDrag: false,
})
}),
).toBe(false);
expect(
shouldOpenContextMenuOnPressOut({
longPressArmed: true,
didStartDrag: true,
})
}),
).toBe(false);
});
});
@@ -1,150 +1,155 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from "vitest";
import {
buildSidebarProjectRowModel,
isSidebarProjectFlattened,
} from './sidebar-project-row-model'
} from "./sidebar-project-row-model";
import type {
SidebarProjectEntry,
SidebarWorkspaceEntry,
} from '@/hooks/use-sidebar-workspaces-list'
} from "@/hooks/use-sidebar-workspaces-list";
function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorkspaceEntry {
return {
workspaceKey: 'srv:/repo',
serverId: 'srv',
workspaceId: '/repo',
workspaceKind: 'directory',
name: 'paseo',
workspaceKey: "srv:/repo",
serverId: "srv",
workspaceId: "/repo",
workspaceKind: "directory",
name: "paseo",
activityAt: null,
statusBucket: 'done',
statusBucket: "done",
diffStat: null,
...overrides,
}
};
}
function project(overrides: Partial<SidebarProjectEntry> = {}): SidebarProjectEntry {
return {
projectKey: 'project-1',
projectName: 'paseo',
projectKind: 'git',
iconWorkingDir: '/repo',
statusBucket: 'done',
projectKey: "project-1",
projectName: "paseo",
projectKind: "git",
iconWorkingDir: "/repo",
statusBucket: "done",
activeCount: 0,
totalWorkspaces: 1,
latestActivityAt: null,
workspaces: [workspace()],
...overrides,
}
};
}
describe('buildSidebarProjectRowModel', () => {
it('flattens non-git projects with one workspace into a direct workspace row model', () => {
describe("buildSidebarProjectRowModel", () => {
it("flattens non-git projects with one workspace into a direct workspace row model", () => {
const flattenedWorkspace = workspace({
workspaceId: '/repo/non-git',
workspaceKind: 'directory',
statusBucket: 'running',
})
workspaceId: "/repo/non-git",
workspaceKind: "directory",
statusBucket: "running",
});
const result = buildSidebarProjectRowModel({
project: project({
projectKind: 'non_git',
projectKind: "non_git",
workspaces: [flattenedWorkspace],
}),
collapsed: false,
})
});
expect(result).toEqual({
kind: 'workspace_link',
kind: "workspace_link",
workspace: flattenedWorkspace,
selected: false,
chevron: null,
trailingAction: 'none',
})
})
trailingAction: "none",
});
});
it('marks flattened non-git project rows as selected when their workspace is active', () => {
it("marks flattened non-git project rows as selected when their workspace is active", () => {
const flattenedWorkspace = workspace({
serverId: 'srv-2',
workspaceId: '/repo/non-git',
})
serverId: "srv-2",
workspaceId: "/repo/non-git",
});
const result = buildSidebarProjectRowModel({
project: project({
projectKind: 'non_git',
projectKind: "non_git",
workspaces: [flattenedWorkspace],
}),
collapsed: false,
serverId: 'srv-2',
serverId: "srv-2",
activeWorkspaceSelection: {
serverId: 'srv-2',
workspaceId: '/repo/non-git',
serverId: "srv-2",
workspaceId: "/repo/non-git",
},
})
});
expect(result).toMatchObject({
kind: 'workspace_link',
kind: "workspace_link",
selected: true,
})
})
});
});
it('flattens git projects with a single workspace and keeps the new worktree action', () => {
it("flattens git projects with a single workspace and keeps the new worktree action", () => {
const flattenedWorkspace = workspace({
workspaceId: '/repo/main',
workspaceKind: 'local_checkout',
})
workspaceId: "/repo/main",
workspaceKind: "local_checkout",
});
const result = buildSidebarProjectRowModel({
project: project({
projectKind: 'git',
projectKind: "git",
workspaces: [flattenedWorkspace],
}),
collapsed: true,
})
});
expect(result).toEqual({
kind: 'workspace_link',
kind: "workspace_link",
workspace: flattenedWorkspace,
selected: false,
chevron: null,
trailingAction: 'new_worktree',
})
})
trailingAction: "new_worktree",
});
});
it('keeps multi-workspace git projects as expandable sections with a new worktree action', () => {
it("keeps multi-workspace git projects as expandable sections with a new worktree action", () => {
const result = buildSidebarProjectRowModel({
project: project({
projectKind: 'git',
projectKind: "git",
workspaces: [
workspace({ workspaceId: '/repo/main', workspaceKind: 'local_checkout' }),
workspace({ workspaceId: '/repo/feature', workspaceKind: 'worktree' }),
workspace({ workspaceId: "/repo/main", workspaceKind: "local_checkout" }),
workspace({ workspaceId: "/repo/feature", workspaceKind: "worktree" }),
],
}),
collapsed: true,
})
});
expect(result).toEqual({
kind: 'project_section',
chevron: 'expand',
trailingAction: 'new_worktree',
})
})
})
kind: "project_section",
chevron: "expand",
trailingAction: "new_worktree",
});
});
});
describe('isSidebarProjectFlattened', () => {
it('returns true for single-workspace projects regardless of kind', () => {
expect(isSidebarProjectFlattened(project({ projectKind: 'git', workspaces: [workspace()] }))).toBe(true)
describe("isSidebarProjectFlattened", () => {
it("returns true for single-workspace projects regardless of kind", () => {
expect(
isSidebarProjectFlattened(project({ projectKind: 'non_git', workspaces: [workspace()] }))
).toBe(true)
})
isSidebarProjectFlattened(project({ projectKind: "git", workspaces: [workspace()] })),
).toBe(true);
expect(
isSidebarProjectFlattened(project({ projectKind: "non_git", workspaces: [workspace()] })),
).toBe(true);
});
it('returns false for multi-workspace projects', () => {
it("returns false for multi-workspace projects", () => {
expect(
isSidebarProjectFlattened(
project({
workspaces: [workspace({ workspaceId: '/repo/main' }), workspace({ workspaceId: '/repo/feat' })],
})
)
).toBe(false)
})
})
workspaces: [
workspace({ workspaceId: "/repo/main" }),
workspace({ workspaceId: "/repo/feat" }),
],
}),
),
).toBe(false);
});
});
@@ -1,58 +1,58 @@
import type {
SidebarProjectEntry,
SidebarWorkspaceEntry,
} from '@/hooks/use-sidebar-workspaces-list'
} from "@/hooks/use-sidebar-workspaces-list";
export interface SidebarProjectWorkspaceLinkRowModel {
kind: 'workspace_link'
workspace: SidebarWorkspaceEntry
selected: boolean
chevron: null
trailingAction: 'new_worktree' | 'none'
kind: "workspace_link";
workspace: SidebarWorkspaceEntry;
selected: boolean;
chevron: null;
trailingAction: "new_worktree" | "none";
}
export interface SidebarProjectSectionRowModel {
kind: 'project_section'
chevron: 'expand' | 'collapse'
trailingAction: 'new_worktree' | 'none'
kind: "project_section";
chevron: "expand" | "collapse";
trailingAction: "new_worktree" | "none";
}
export type SidebarProjectRowModel =
| SidebarProjectWorkspaceLinkRowModel
| SidebarProjectSectionRowModel
| SidebarProjectSectionRowModel;
export function isSidebarProjectFlattened(project: SidebarProjectEntry): boolean {
return project.workspaces.length === 1
return project.workspaces.length === 1;
}
export function buildSidebarProjectRowModel(input: {
project: SidebarProjectEntry
collapsed: boolean
serverId?: string | null
activeWorkspaceSelection?: { serverId: string; workspaceId: string } | null
project: SidebarProjectEntry;
collapsed: boolean;
serverId?: string | null;
activeWorkspaceSelection?: { serverId: string; workspaceId: string } | null;
}): SidebarProjectRowModel {
const flattenedWorkspace = isSidebarProjectFlattened(input.project)
? (input.project.workspaces[0] ?? null)
: null
: null;
const selected =
flattenedWorkspace !== null &&
Boolean(input.serverId) &&
input.activeWorkspaceSelection?.serverId === input.serverId &&
input.activeWorkspaceSelection?.workspaceId === flattenedWorkspace.workspaceId
input.activeWorkspaceSelection?.workspaceId === flattenedWorkspace.workspaceId;
if (flattenedWorkspace) {
return {
kind: 'workspace_link',
kind: "workspace_link",
workspace: flattenedWorkspace,
selected,
chevron: null,
trailingAction: input.project.projectKind === 'git' ? 'new_worktree' : 'none',
}
trailingAction: input.project.projectKind === "git" ? "new_worktree" : "none",
};
}
return {
kind: 'project_section',
chevron: input.collapsed ? 'expand' : 'collapse',
trailingAction: input.project.projectKind === 'git' ? 'new_worktree' : 'none',
}
kind: "project_section",
chevron: input.collapsed ? "expand" : "collapse",
trailingAction: input.project.projectKind === "git" ? "new_worktree" : "none",
};
}
@@ -8,7 +8,7 @@ describe("hasVisibleOrderChanged", () => {
hasVisibleOrderChanged({
currentOrder: ["a", "b", "c", "d"],
reorderedVisibleKeys: ["a", "b", "c"],
})
}),
).toBe(false);
});
@@ -17,7 +17,7 @@ describe("hasVisibleOrderChanged", () => {
hasVisibleOrderChanged({
currentOrder: ["a", "b", "c", "d"],
reorderedVisibleKeys: ["b", "a", "c"],
})
}),
).toBe(true);
});
@@ -26,7 +26,7 @@ describe("hasVisibleOrderChanged", () => {
hasVisibleOrderChanged({
currentOrder: ["a", "b"],
reorderedVisibleKeys: ["a", "c"],
})
}),
).toBe(true);
});
});
@@ -37,7 +37,7 @@ describe("mergeWithRemainder", () => {
mergeWithRemainder({
currentOrder: ["a", "x", "b", "y"],
reorderedVisibleKeys: ["b", "a"],
})
}),
).toEqual(["b", "a", "x", "y"]);
});
@@ -46,8 +46,7 @@ describe("mergeWithRemainder", () => {
mergeWithRemainder({
currentOrder: ["stale", "hidden"],
reorderedVisibleKeys: [],
})
}),
).toEqual(["stale", "hidden"]);
});
});
+11 -12
View File
@@ -1,21 +1,20 @@
export function mergeWithRemainder(input: {
currentOrder: string[]
reorderedVisibleKeys: string[]
currentOrder: string[];
reorderedVisibleKeys: string[];
}): string[] {
const reorderedSet = new Set(input.reorderedVisibleKeys)
const remainder = input.currentOrder.filter((key) => !reorderedSet.has(key))
return [...input.reorderedVisibleKeys, ...remainder]
const reorderedSet = new Set(input.reorderedVisibleKeys);
const remainder = input.currentOrder.filter((key) => !reorderedSet.has(key));
return [...input.reorderedVisibleKeys, ...remainder];
}
export function hasVisibleOrderChanged(input: {
currentOrder: string[]
reorderedVisibleKeys: string[]
currentOrder: string[];
reorderedVisibleKeys: string[];
}): boolean {
const visibleSet = new Set(input.reorderedVisibleKeys)
const currentVisible = input.currentOrder.filter((key) => visibleSet.has(key))
const visibleSet = new Set(input.reorderedVisibleKeys);
const currentVisible = input.currentOrder.filter((key) => visibleSet.has(key));
if (currentVisible.length !== input.reorderedVisibleKeys.length) {
return true
return true;
}
return input.reorderedVisibleKeys.some((key, index) => currentVisible[index] !== key)
return input.reorderedVisibleKeys.some((key, index) => currentVisible[index] !== key);
}
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import type { SidebarProjectEntry, SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import type {
SidebarProjectEntry,
SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { buildSidebarShortcutModel } from "./sidebar-shortcuts";
@@ -56,7 +59,9 @@ describe("buildSidebarShortcutModel", () => {
});
it("limits shortcuts to 9", () => {
const workspaces = Array.from({ length: 20 }, (_, index) => workspace("s", `/repo/w${index + 1}`));
const workspaces = Array.from({ length: 20 }, (_, index) =>
workspace("s", `/repo/w${index + 1}`),
);
const projects = [project("p", workspaces)];
const model = buildSidebarShortcutModel({
+23 -25
View File
@@ -1,56 +1,54 @@
import type {
SidebarProjectEntry,
SidebarWorkspaceEntry,
} from '@/hooks/use-sidebar-workspaces-list'
import { isSidebarProjectFlattened } from './sidebar-project-row-model'
} from "@/hooks/use-sidebar-workspaces-list";
import { isSidebarProjectFlattened } from "./sidebar-project-row-model";
export interface SidebarShortcutWorkspaceTarget {
serverId: string
workspaceId: string
serverId: string;
workspaceId: string;
}
export interface SidebarShortcutModel {
visibleTargets: SidebarShortcutWorkspaceTarget[]
shortcutTargets: SidebarShortcutWorkspaceTarget[]
shortcutIndexByWorkspaceKey: Map<string, number>
visibleTargets: SidebarShortcutWorkspaceTarget[];
shortcutTargets: SidebarShortcutWorkspaceTarget[];
shortcutIndexByWorkspaceKey: Map<string, number>;
}
function createShortcutTarget(
workspace: SidebarWorkspaceEntry,
): SidebarShortcutWorkspaceTarget {
function createShortcutTarget(workspace: SidebarWorkspaceEntry): SidebarShortcutWorkspaceTarget {
return {
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
}
};
}
export function buildSidebarShortcutModel(input: {
projects: SidebarProjectEntry[]
collapsedProjectKeys: ReadonlySet<string>
shortcutLimit?: number
projects: SidebarProjectEntry[];
collapsedProjectKeys: ReadonlySet<string>;
shortcutLimit?: number;
}): SidebarShortcutModel {
const maxShortcuts = Math.max(0, Math.floor(input.shortcutLimit ?? 9))
const visibleTargets: SidebarShortcutWorkspaceTarget[] = []
const shortcutTargets: SidebarShortcutWorkspaceTarget[] = []
const shortcutIndexByWorkspaceKey = new Map<string, number>()
const maxShortcuts = Math.max(0, Math.floor(input.shortcutLimit ?? 9));
const visibleTargets: SidebarShortcutWorkspaceTarget[] = [];
const shortcutTargets: SidebarShortcutWorkspaceTarget[] = [];
const shortcutIndexByWorkspaceKey = new Map<string, number>();
for (const project of input.projects) {
if (!isSidebarProjectFlattened(project) && input.collapsedProjectKeys.has(project.projectKey)) {
continue
continue;
}
for (const workspace of project.workspaces) {
visibleTargets.push(createShortcutTarget(workspace))
visibleTargets.push(createShortcutTarget(workspace));
if (shortcutTargets.length >= maxShortcuts) {
continue
continue;
}
const shortcutNumber = shortcutTargets.length + 1
shortcutTargets.push(createShortcutTarget(workspace))
shortcutIndexByWorkspaceKey.set(workspace.workspaceKey, shortcutNumber)
const shortcutNumber = shortcutTargets.length + 1;
shortcutTargets.push(createShortcutTarget(workspace));
shortcutIndexByWorkspaceKey.set(workspace.workspaceKey, shortcutNumber);
}
}
return { visibleTargets, shortcutTargets, shortcutIndexByWorkspaceKey }
return { visibleTargets, shortcutTargets, shortcutIndexByWorkspaceKey };
}
+4 -4
View File
@@ -25,7 +25,7 @@ interface PaneCandidate {
export function findAdjacentPane(
root: SplitNode,
focusedPaneId: string,
direction: "left" | "right" | "up" | "down"
direction: "left" | "right" | "up" | "down",
): string | null {
const panes = collectPaneBounds(root, {
left: ROOT_MIN,
@@ -168,7 +168,7 @@ function buildCandidate(input: {
function collectPaneBounds(
node: SplitNode,
bounds: { left: number; top: number; right: number; bottom: number }
bounds: { left: number; top: number; right: number; bottom: number },
): PaneBounds[] {
if (node.kind === "pane") {
return [
@@ -203,7 +203,7 @@ function collectPaneBounds(
top: bounds.top,
right: childRight,
bottom: bounds.bottom,
})
}),
);
continue;
}
@@ -217,7 +217,7 @@ function collectPaneBounds(
top: childTop,
right: bounds.right,
bottom: childBottom,
})
}),
);
}
+1 -6
View File
@@ -1,9 +1,4 @@
export type StatusLoaderBucket =
| "needs_input"
| "failed"
| "running"
| "attention"
| "done";
export type StatusLoaderBucket = "needs_input" | "failed" | "running" | "attention" | "done";
export function shouldRenderSyncedStatusLoader(input: {
bucket: StatusLoaderBucket | null | undefined;
+13 -17
View File
@@ -18,18 +18,14 @@ describe("terminal-attach", () => {
});
it("matches retryable attach errors", () => {
expect(
isTerminalAttachRetryableError({ message: "Terminal not found while attaching" })
).toBe(true);
expect(
isTerminalAttachRetryableError({ message: "Network disconnected during attach" })
).toBe(true);
expect(
isTerminalAttachRetryableError({ message: "stream ended before ack" })
).toBe(true);
expect(
isTerminalAttachRetryableError({ message: "permission denied" })
).toBe(false);
expect(isTerminalAttachRetryableError({ message: "Terminal not found while attaching" })).toBe(
true,
);
expect(isTerminalAttachRetryableError({ message: "Network disconnected during attach" })).toBe(
true,
);
expect(isTerminalAttachRetryableError({ message: "stream ended before ack" })).toBe(true);
expect(isTerminalAttachRetryableError({ message: "permission denied" })).toBe(false);
});
it("reads and updates resume offsets monotonically", () => {
@@ -46,7 +42,7 @@ describe("terminal-attach", () => {
getTerminalResumeOffset({
terminalId,
resumeOffsetStore,
})
}),
).toBeUndefined();
updateTerminalResumeOffset({
@@ -58,7 +54,7 @@ describe("terminal-attach", () => {
getTerminalResumeOffset({
terminalId,
resumeOffsetStore,
})
}),
).toBe(8);
// Stale offsets must not move resume backwards.
@@ -71,7 +67,7 @@ describe("terminal-attach", () => {
getTerminalResumeOffset({
terminalId,
resumeOffsetStore,
})
}),
).toBe(8);
});
@@ -81,7 +77,7 @@ describe("terminal-attach", () => {
promise: Promise.resolve("ok"),
timeoutMs: 50,
timeoutMessage: "timed out",
})
}),
).resolves.toBe("ok");
});
@@ -91,7 +87,7 @@ describe("terminal-attach", () => {
promise: new Promise<string>(() => {}),
timeoutMs: 10,
timeoutMessage: "timed out",
})
}),
).rejects.toThrow("timed out");
});
});
+8 -7
View File
@@ -53,15 +53,13 @@ export function updateTerminalResumeOffset(input: {
export function getTerminalAttachRetryDelayMs(input: { attempt: number }): number {
const clampedAttempt = Math.max(0, input.attempt);
const exponentialDelay = 250 * (2 ** clampedAttempt);
const exponentialDelay = 250 * 2 ** clampedAttempt;
return Math.min(2_000, exponentialDelay);
}
export function isTerminalAttachRetryableError(input: { message: string }): boolean {
const normalized = input.message.toLowerCase();
return TERMINAL_ATTACH_RETRYABLE_ERROR_PATTERNS.some((pattern) =>
normalized.includes(pattern)
);
return TERMINAL_ATTACH_RETRYABLE_ERROR_PATTERNS.some((pattern) => normalized.includes(pattern));
}
export async function waitForDuration(input: { durationMs: number }): Promise<void> {
@@ -77,9 +75,12 @@ export async function withPromiseTimeout<T>(input: {
}): Promise<T> {
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => {
reject(new Error(input.timeoutMessage));
}, Math.max(0, input.timeoutMs));
timeoutHandle = setTimeout(
() => {
reject(new Error(input.timeoutMessage));
},
Math.max(0, input.timeoutMs),
);
});
try {
return await Promise.race([input.promise, timeoutPromise]);
+9 -13
View File
@@ -43,7 +43,7 @@ describe("terminal key helpers", () => {
shiftKey: true,
altKey: false,
metaKey: false,
})
}),
).toEqual({
ctrl: true,
shift: true,
@@ -59,7 +59,7 @@ describe("terminal key helpers", () => {
ctrlKey: false,
altKey: false,
pendingModifiers: { ctrl: false, shift: false, alt: false },
})
}),
).toBe(true);
expect(
shouldInterceptDomTerminalKey({
@@ -67,7 +67,7 @@ describe("terminal key helpers", () => {
ctrlKey: true,
altKey: false,
pendingModifiers: { ctrl: false, shift: false, alt: false },
})
}),
).toBe(true);
expect(
shouldInterceptDomTerminalKey({
@@ -75,17 +75,13 @@ describe("terminal key helpers", () => {
ctrlKey: false,
altKey: false,
pendingModifiers: { ctrl: false, shift: false, alt: false },
})
}),
).toBe(false);
});
it("detects pending modifier state", () => {
expect(hasPendingTerminalModifiers({ ctrl: false, shift: false, alt: false })).toBe(
false
);
expect(hasPendingTerminalModifiers({ ctrl: true, shift: false, alt: false })).toBe(
true
);
expect(hasPendingTerminalModifiers({ ctrl: false, shift: false, alt: false })).toBe(false);
expect(hasPendingTerminalModifiers({ ctrl: true, shift: false, alt: false })).toBe(true);
});
it("maps onData bytes to terminal keys for modifier fallback", () => {
@@ -103,7 +99,7 @@ describe("terminal key helpers", () => {
resolvePendingModifierDataInput({
data: "hello",
pendingModifiers: { ctrl: true, shift: false, alt: false },
})
}),
).toEqual({
mode: "raw",
clearPendingModifiers: true,
@@ -115,7 +111,7 @@ describe("terminal key helpers", () => {
resolvePendingModifierDataInput({
data: "c",
pendingModifiers: { ctrl: true, shift: false, alt: false },
})
}),
).toEqual({
mode: "key",
key: "c",
@@ -128,7 +124,7 @@ describe("terminal key helpers", () => {
resolvePendingModifierDataInput({
data: "c",
pendingModifiers: { ctrl: false, shift: false, alt: false },
})
}),
).toEqual({
mode: "raw",
clearPendingModifiers: false,
+3 -17
View File
@@ -4,14 +4,7 @@ export type PendingTerminalModifiers = {
alt: boolean;
};
const MODIFIER_DOM_KEYS = new Set([
"Control",
"Shift",
"Alt",
"Meta",
"AltGraph",
"OS",
]);
const MODIFIER_DOM_KEYS = new Set(["Control", "Shift", "Alt", "Meta", "AltGraph", "OS"]);
const DOM_KEY_ALIASES: Record<string, string> = {
Esc: "Escape",
@@ -63,12 +56,7 @@ export function normalizeDomTerminalKey(rawKey: string): string | null {
}
const key = DOM_KEY_ALIASES[rawKey] ?? rawKey;
if (
key === "Unidentified" ||
key === "Dead" ||
key === "Compose" ||
key === "Process"
) {
if (key === "Unidentified" || key === "Dead" || key === "Compose" || key === "Process") {
return null;
}
@@ -90,9 +78,7 @@ export function normalizeTerminalTransportKey(key: string): string {
return key;
}
export function hasPendingTerminalModifiers(
modifiers: PendingTerminalModifiers
): boolean {
export function hasPendingTerminalModifiers(modifiers: PendingTerminalModifiers): boolean {
return modifiers.ctrl || modifiers.shift || modifiers.alt;
}
+2 -7
View File
@@ -1,7 +1,4 @@
import type {
CreateTerminalResponse,
ListTerminalsResponse,
} from "@server/shared/messages";
import type { CreateTerminalResponse, ListTerminalsResponse } from "@server/shared/messages";
type TerminalListEntry = ListTerminalsResponse["payload"]["terminals"][number];
type CreatedTerminal = NonNullable<CreateTerminalResponse["payload"]["terminal"]>;
@@ -18,9 +15,7 @@ export function upsertTerminalListEntry(input: {
terminal: CreatedTerminal;
}): TerminalListEntry[] {
const createdTerminal = toTerminalListEntry({ terminal: input.terminal });
const existingIndex = input.terminals.findIndex(
(terminal) => terminal.id === createdTerminal.id
);
const existingIndex = input.terminals.findIndex((terminal) => terminal.id === createdTerminal.id);
if (existingIndex < 0) {
return [...input.terminals, createdTerminal];
@@ -79,9 +79,7 @@ export function appendTerminalOutputBuffer(input: {
});
}
export function readTerminalOutputBuffer(input: {
buffer: TerminalOutputBuffer;
}): string {
export function readTerminalOutputBuffer(input: { buffer: TerminalOutputBuffer }): string {
const { buffer } = input;
if (buffer.totalChars <= 0) {
return "";
@@ -99,7 +99,7 @@ describe("test-daemon-connection connectToDaemon", () => {
await result.client.close();
expect(daemonClientMock.createdConfigs[0]?.url).toBe(
"paseo+local://socket?path=%2Ftmp%2Fpaseo.sock"
"paseo+local://socket?path=%2Ftmp%2Fpaseo.sock",
);
});
});
@@ -46,7 +46,7 @@ export class DaemonConnectionTestError extends Error {
export async function buildClientConfig(
connection: HostConnection,
serverId?: string
serverId?: string,
): Promise<DaemonClientConfig> {
const clientId = await getOrCreateClientId();
const localTransportFactory = createDesktopLocalDaemonTransportFactory();
@@ -99,44 +99,51 @@ export function connectAndProbe(
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }> {
const client = new DaemonClient(config);
return new Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>((resolve, reject) => {
const timer = setTimeout(() => {
void client.close().catch(() => undefined);
reject(
new DaemonConnectionTestError("Connection timed out", {
reason: "Connection timed out",
lastError: client.lastError ?? null,
})
);
}, timeoutMs);
void client.connect().then(() => {
clearTimeout(timer);
const serverInfo = client.getLastServerInfoMessage();
if (!serverInfo) {
return new Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>(
(resolve, reject) => {
const timer = setTimeout(() => {
void client.close().catch(() => undefined);
reject(
new DaemonConnectionTestError("Missing server info message", {
reason: "Missing server info message",
new DaemonConnectionTestError("Connection timed out", {
reason: "Connection timed out",
lastError: client.lastError ?? null,
})
}),
);
return;
}
resolve({
client,
serverId: serverInfo.serverId,
hostname: serverInfo.hostname,
});
}).catch((error) => {
clearTimeout(timer);
const reason = normalizeNonEmptyString(error instanceof Error ? error.message : String(error));
const lastError = normalizeNonEmptyString(client.lastError);
const message = pickBestReason(reason, lastError);
void client.close().catch(() => undefined);
reject(new DaemonConnectionTestError(message, { reason, lastError }));
});
});
}, timeoutMs);
void client
.connect()
.then(() => {
clearTimeout(timer);
const serverInfo = client.getLastServerInfoMessage();
if (!serverInfo) {
void client.close().catch(() => undefined);
reject(
new DaemonConnectionTestError("Missing server info message", {
reason: "Missing server info message",
lastError: client.lastError ?? null,
}),
);
return;
}
resolve({
client,
serverId: serverInfo.serverId,
hostname: serverInfo.hostname,
});
})
.catch((error) => {
clearTimeout(timer);
const reason = normalizeNonEmptyString(
error instanceof Error ? error.message : String(error),
);
const lastError = normalizeNonEmptyString(client.lastError);
const message = pickBestReason(reason, lastError);
void client.close().catch(() => undefined);
reject(new DaemonConnectionTestError(message, { reason, lastError }));
});
},
);
}
interface ProbeOptions {
File diff suppressed because one or more lines are too long
-1
View File
@@ -32,4 +32,3 @@ export function toXtermTheme(terminal: TerminalPalette): ITheme {
brightWhite: terminal.brightWhite,
};
}
@@ -2,10 +2,7 @@ import assert from "node:assert/strict";
import { describe, it } from "vitest";
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
import {
hasMeaningfulToolCallDetail,
isPendingToolCallDetail,
} from "./tool-call-detail-state";
import { hasMeaningfulToolCallDetail, isPendingToolCallDetail } from "./tool-call-detail-state";
describe("tool-call detail state", () => {
it("treats empty unknown payloads as not meaningful", () => {
@@ -39,7 +36,7 @@ describe("tool-call detail state", () => {
status: "running",
error: null,
}),
true
true,
);
});
@@ -54,7 +51,7 @@ describe("tool-call detail state", () => {
status: "completed",
error: null,
}),
false
false,
);
});
@@ -19,9 +19,7 @@ function hasMeaningfulUnknownValue(value: unknown): boolean {
return true;
}
export function hasMeaningfulToolCallDetail(
detail: ToolCallDetail | undefined
): boolean {
export function hasMeaningfulToolCallDetail(detail: ToolCallDetail | undefined): boolean {
if (!detail) {
return false;
}
@@ -32,9 +30,7 @@ export function hasMeaningfulToolCallDetail(
case "read":
return Boolean(detail.filePath || detail.content);
case "edit":
return Boolean(
detail.filePath || detail.unifiedDiff || detail.oldString || detail.newString
);
return Boolean(detail.filePath || detail.unifiedDiff || detail.oldString || detail.newString);
case "write":
return Boolean(detail.filePath || detail.content);
case "search":
@@ -43,7 +39,7 @@ export function hasMeaningfulToolCallDetail(
detail.content ||
(detail.filePaths && detail.filePaths.length > 0) ||
(detail.webResults && detail.webResults.length > 0) ||
(detail.annotations && detail.annotations.length > 0)
(detail.annotations && detail.annotations.length > 0),
);
case "fetch":
return Boolean(detail.url || detail.result || detail.codeText);
@@ -51,18 +47,12 @@ export function hasMeaningfulToolCallDetail(
return Boolean(detail.branchName || detail.worktreePath || detail.log);
case "sub_agent":
return Boolean(
detail.subAgentType ||
detail.description ||
detail.log ||
detail.actions.length > 0
detail.subAgentType || detail.description || detail.log || detail.actions.length > 0,
);
case "plain_text":
return Boolean(detail.label || detail.text);
case "unknown":
return (
hasMeaningfulUnknownValue(detail.input) ||
hasMeaningfulUnknownValue(detail.output)
);
return hasMeaningfulUnknownValue(detail.input) || hasMeaningfulUnknownValue(detail.output);
}
}
@@ -71,7 +61,6 @@ export function isPendingToolCallDetail(params: {
status: "executing" | "running" | "completed" | "failed" | "canceled";
error: unknown | null | undefined;
}): boolean {
const isRunning =
params.status === "running" || params.status === "executing";
const isRunning = params.status === "running" || params.status === "executing";
return isRunning && params.error == null && !hasMeaningfulToolCallDetail(params.detail);
}
+4 -1
View File
@@ -39,7 +39,10 @@ const TOOL_ICON_BY_NAME: Record<ToolCallIconName, ToolCallIconComponent> = {
mic_vocal: MicVocal,
};
export function resolveToolCallIcon(toolName: string, detail?: ToolCallDetail): ToolCallIconComponent {
export function resolveToolCallIcon(
toolName: string,
detail?: ToolCallDetail,
): ToolCallIconComponent {
const lowerName = toolName.trim().toLowerCase();
if (detail?.type === "plain_text" && detail.icon) {
+12 -8
View File
@@ -48,7 +48,10 @@ function splitIntoWords(text: string): string[] {
return result;
}
function computeWordLevelDiff(oldLine: string, newLine: string): { oldSegments: DiffSegment[]; newSegments: DiffSegment[] } {
function computeWordLevelDiff(
oldLine: string,
newLine: string,
): { oldSegments: DiffSegment[]; newSegments: DiffSegment[] } {
const oldWords = splitIntoWords(oldLine);
const newWords = splitIntoWords(newLine);
@@ -138,9 +141,7 @@ export function buildLineDiff(originalText: string, updatedText: string): DiffLi
const m = originalLines.length;
const n = updatedLines.length;
const dp: number[][] = Array.from({ length: m + 1 }, () =>
Array(n + 1).fill(0)
);
const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
for (let i = m - 1; i >= 0; i -= 1) {
for (let j = n - 1; j >= 0; j -= 1) {
@@ -270,7 +271,7 @@ const ClaudeTodoWriteSchema = z.object({
content: z.string(),
status: TaskStatusSchema,
activeForm: z.string().optional(),
})
}),
),
});
@@ -279,17 +280,20 @@ const UpdatePlanSchema = z.object({
z.object({
step: z.string(),
status: TaskStatusSchema.catch("pending"),
})
}),
),
});
function normalizeToolName(toolName: string): string {
return toolName.trim().replace(/[.\s-]+/g, "_").toLowerCase();
return toolName
.trim()
.replace(/[.\s-]+/g, "_")
.toLowerCase();
}
export function extractTaskEntriesFromToolCall(
toolName: string,
input: unknown
input: unknown,
): TaskEntry[] | null {
const normalized = normalizeToolName(toolName);
-1
View File
@@ -48,4 +48,3 @@ export function focusWithRetries({
cancelled = true;
};
}
@@ -23,35 +23,23 @@ describe("buildWorkingDirectorySuggestions", () => {
query: "pla",
});
expect(results).toEqual([
"/Users/me/projects/playground",
"/Users/me/projects/planbook",
]);
expect(results).toEqual(["/Users/me/projects/playground", "/Users/me/projects/planbook"]);
});
it("puts matching recommended items first when they also match query", () => {
const results = buildWorkingDirectorySuggestions({
recommendedPaths: ["/Users/me/projects/playground", "/Users/me/projects/paseo"],
serverPaths: [
"/Users/me/projects/planbook",
"/Users/me/projects/playground",
],
serverPaths: ["/Users/me/projects/planbook", "/Users/me/projects/playground"],
query: "pla",
});
expect(results).toEqual([
"/Users/me/projects/playground",
"/Users/me/projects/planbook",
]);
expect(results).toEqual(["/Users/me/projects/playground", "/Users/me/projects/planbook"]);
});
it("treats '~' as an active query and includes server suggestions", () => {
const results = buildWorkingDirectorySuggestions({
recommendedPaths: ["/Users/me/projects/paseo"],
serverPaths: [
"/Users/me/documents",
"/Users/me/projects",
],
serverPaths: ["/Users/me/documents", "/Users/me/projects"],
query: "~",
});
@@ -5,7 +5,7 @@ export interface BuildWorkingDirectorySuggestionsInput {
}
export function buildWorkingDirectorySuggestions(
input: BuildWorkingDirectorySuggestionsInput
input: BuildWorkingDirectorySuggestionsInput,
): string[] {
const rawQuery = input.query.trim();
const recommended = uniquePaths(input.recommendedPaths);
@@ -1,8 +1,5 @@
import { describe, expect, it } from "vitest";
import {
getWorkingIndicatorDotStrength,
WORKING_INDICATOR_OFFSETS,
} from "./working-indicator";
import { getWorkingIndicatorDotStrength, WORKING_INDICATOR_OFFSETS } from "./working-indicator";
describe("getWorkingIndicatorDotStrength", () => {
it("returns a stable triangular pulse over one cycle", () => {
@@ -16,7 +13,7 @@ describe("getWorkingIndicatorDotStrength", () => {
it("keeps the dots phase-shifted instead of identical", () => {
const strengths = WORKING_INDICATOR_OFFSETS.map((offset) =>
getWorkingIndicatorDotStrength(0, offset)
getWorkingIndicatorDotStrength(0, offset),
);
expect(strengths).toEqual([0, 0.26666666666666666, 0.5333333333333333]);
@@ -24,10 +21,10 @@ describe("getWorkingIndicatorDotStrength", () => {
it("wraps progress cleanly across loop boundaries", () => {
expect(getWorkingIndicatorDotStrength(1.1, 0)).toBeCloseTo(
getWorkingIndicatorDotStrength(0.1, 0)
getWorkingIndicatorDotStrength(0.1, 0),
);
expect(getWorkingIndicatorDotStrength(-0.1, 0)).toBeCloseTo(
getWorkingIndicatorDotStrength(0.9, 0)
getWorkingIndicatorDotStrength(0.9, 0),
);
});
});
+1 -4
View File
@@ -11,10 +11,7 @@ function normalizePhase(value: number): number {
return wrapped < 0 ? wrapped + 1 : wrapped;
}
export function getWorkingIndicatorDotStrength(
progress: number,
offset: number
): number {
export function getWorkingIndicatorDotStrength(progress: number, offset: number): number {
"worklet";
const phase = normalizePhase(progress + offset);
if (phase <= 0.5) {
@@ -6,7 +6,7 @@ import {
import type { WorkspaceDescriptor } from "@/stores/session-store";
function workspace(
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
): WorkspaceDescriptor {
return {
id: input.id,
@@ -33,7 +33,7 @@ describe("resolveWorkspaceArchiveRedirectWorkspaceId", () => {
resolveWorkspaceArchiveRedirectWorkspaceId({
archivedWorkspaceId: "/repo/.paseo/worktrees/feature",
workspaces,
})
}),
).toBe("/repo");
});
@@ -50,7 +50,7 @@ describe("resolveWorkspaceArchiveRedirectWorkspaceId", () => {
resolveWorkspaceArchiveRedirectWorkspaceId({
archivedWorkspaceId: "/repo/.paseo/worktrees/feature",
workspaces,
})
}),
).toBe("/repo");
});
@@ -70,7 +70,7 @@ describe("resolveWorkspaceArchiveRedirectWorkspaceId", () => {
serverId: "server-1",
archivedWorkspaceId: "/notes",
workspaces,
})
}),
).toBe("/h/server-1");
});
});
@@ -27,13 +27,12 @@ export function resolveWorkspaceArchiveRedirectWorkspaceId(input: {
}
const sameProjectWorkspaces = workspaces.filter(
(workspace) => workspace.projectId === archivedWorkspace.projectId
(workspace) => workspace.projectId === archivedWorkspace.projectId,
);
const rootCheckoutWorkspace =
sameProjectWorkspaces.find(
(workspace) =>
workspace.workspaceKind === "local_checkout" &&
workspace.id !== archivedWorkspace.id
workspace.workspaceKind === "local_checkout" && workspace.id !== archivedWorkspace.id,
) ?? null;
if (rootCheckoutWorkspace) {
return rootCheckoutWorkspace.id;
@@ -1,7 +1,7 @@
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
export function normalizeWorkspaceTabTarget(
value: WorkspaceTabTarget | null | undefined
value: WorkspaceTabTarget | null | undefined,
): WorkspaceTabTarget | null {
if (!value || typeof value !== "object" || typeof value.kind !== "string") {
return null;
@@ -27,7 +27,7 @@ export function normalizeWorkspaceTabTarget(
export function workspaceTabTargetsEqual(
left: WorkspaceTabTarget,
right: WorkspaceTabTarget
right: WorkspaceTabTarget,
): boolean {
if (left.kind !== right.kind) {
return false;