Merge origin/main into show-image-previews-in-optimistic-messages
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { shouldClearAgentAttentionOnView } from "./agent-attention";
|
||||
|
||||
describe("shouldClearAgentAttentionOnView", () => {
|
||||
it("returns true only when the viewed agent is focused, connected, and requires attention", () => {
|
||||
expect(
|
||||
shouldClearAgentAttentionOnView({
|
||||
agentId: "agent-1",
|
||||
focusedAgentId: "agent-1",
|
||||
isConnected: true,
|
||||
requiresAttention: true,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the app is disconnected", () => {
|
||||
expect(
|
||||
shouldClearAgentAttentionOnView({
|
||||
agentId: "agent-1",
|
||||
focusedAgentId: "agent-1",
|
||||
isConnected: false,
|
||||
requiresAttention: true,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when the agent is not focused", () => {
|
||||
expect(
|
||||
shouldClearAgentAttentionOnView({
|
||||
agentId: "agent-1",
|
||||
focusedAgentId: "agent-2",
|
||||
isConnected: true,
|
||||
requiresAttention: true,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when attention is already clear", () => {
|
||||
expect(
|
||||
shouldClearAgentAttentionOnView({
|
||||
agentId: "agent-1",
|
||||
focusedAgentId: "agent-1",
|
||||
isConnected: true,
|
||||
requiresAttention: false,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty agent ids", () => {
|
||||
expect(
|
||||
shouldClearAgentAttentionOnView({
|
||||
agentId: "",
|
||||
focusedAgentId: "agent-1",
|
||||
isConnected: true,
|
||||
requiresAttention: true,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
interface ShouldClearAgentAttentionOnViewInput {
|
||||
agentId: string | null | undefined;
|
||||
focusedAgentId: string | null | undefined;
|
||||
isConnected: boolean;
|
||||
requiresAttention: boolean | null | undefined;
|
||||
}
|
||||
|
||||
export function shouldClearAgentAttentionOnView(
|
||||
input: ShouldClearAgentAttentionOnViewInput
|
||||
): boolean {
|
||||
const agentId = input.agentId?.trim();
|
||||
if (!agentId) {
|
||||
return false;
|
||||
}
|
||||
if (!input.isConnected) {
|
||||
return false;
|
||||
}
|
||||
if (!input.requiresAttention) {
|
||||
return false;
|
||||
}
|
||||
return input.focusedAgentId === agentId;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { collectAgentWorkingDirectorySuggestions } from "@/utils/agent-working-directory-suggestions";
|
||||
|
||||
describe("collectAgentWorkingDirectorySuggestions", () => {
|
||||
it("deduplicates by cwd and sorts by most recent timestamp", () => {
|
||||
const results = collectAgentWorkingDirectorySuggestions([
|
||||
{
|
||||
cwd: "/Users/me/project-alpha",
|
||||
createdAt: new Date("2026-02-10T10:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
cwd: "/Users/me/project-beta",
|
||||
createdAt: new Date("2026-02-11T10:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
cwd: "/Users/me/project-alpha",
|
||||
lastActivityAt: new Date("2026-02-12T10:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(results).toEqual([
|
||||
"/Users/me/project-alpha",
|
||||
"/Users/me/project-beta",
|
||||
]);
|
||||
});
|
||||
|
||||
it("excludes Paseo-owned worktree paths", () => {
|
||||
const results = collectAgentWorkingDirectorySuggestions([
|
||||
{
|
||||
cwd: "/Users/me/repo/.paseo/worktrees/feature-a",
|
||||
createdAt: new Date("2026-02-12T10:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
cwd: "/Users/me/repo",
|
||||
createdAt: new Date("2026-02-10T10:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
cwd: "C:\\Users\\me\\repo\\.paseo\\worktrees\\feature-b",
|
||||
createdAt: new Date("2026-02-11T10:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(results).toEqual(["/Users/me/repo"]);
|
||||
});
|
||||
|
||||
it("ignores empty cwd values", () => {
|
||||
const results = collectAgentWorkingDirectorySuggestions([
|
||||
{ cwd: " ", createdAt: new Date("2026-02-10T10:00:00.000Z") },
|
||||
{ cwd: null, createdAt: new Date("2026-02-11T10:00:00.000Z") },
|
||||
{ cwd: undefined, lastActivityAt: new Date("2026-02-12T10:00:00.000Z") },
|
||||
{
|
||||
cwd: "/Users/me/project",
|
||||
createdAt: new Date("2026-02-09T10:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(results).toEqual(["/Users/me/project"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
export interface AgentWorkingDirectorySource {
|
||||
cwd?: string | null;
|
||||
createdAt?: Date | null;
|
||||
lastActivityAt?: Date | null;
|
||||
}
|
||||
|
||||
const PASEO_WORKTREE_PATH_PATTERN = /(^|\/)\.paseo\/worktrees(\/|$)/;
|
||||
|
||||
export function collectAgentWorkingDirectorySuggestions(
|
||||
sources: Iterable<AgentWorkingDirectorySource>
|
||||
): string[] {
|
||||
const lastSeenByPath = new Map<string, number>();
|
||||
|
||||
for (const source of sources) {
|
||||
const cwd = source.cwd?.trim();
|
||||
if (!cwd) {
|
||||
continue;
|
||||
}
|
||||
if (isPaseoOwnedWorktreePath(cwd)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const timestamp = toEpochMs(source.lastActivityAt ?? source.createdAt);
|
||||
const previous = lastSeenByPath.get(cwd);
|
||||
if (previous === undefined || timestamp > previous) {
|
||||
lastSeenByPath.set(cwd, timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(lastSeenByPath.entries())
|
||||
.sort((left, right) => {
|
||||
const timeDiff = right[1] - left[1];
|
||||
if (timeDiff !== 0) {
|
||||
return timeDiff;
|
||||
}
|
||||
return left[0].localeCompare(right[0]);
|
||||
})
|
||||
.map(([cwd]) => cwd);
|
||||
}
|
||||
|
||||
function isPaseoOwnedWorktreePath(cwd: string): boolean {
|
||||
return PASEO_WORKTREE_PATH_PATTERN.test(cwd.replace(/\\/g, "/"));
|
||||
}
|
||||
|
||||
function toEpochMs(date: Date | null | undefined): number {
|
||||
if (!(date instanceof Date)) {
|
||||
return 0;
|
||||
}
|
||||
const value = date.getTime();
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type MockPlatform = "web" | "ios" | "android";
|
||||
|
||||
type AlertButton = {
|
||||
onPress?: () => void;
|
||||
};
|
||||
|
||||
async function loadModuleForPlatform(platform: MockPlatform): Promise<{
|
||||
confirmDialog: typeof import("./confirm-dialog").confirmDialog;
|
||||
alertMock: ReturnType<typeof vi.fn>;
|
||||
}> {
|
||||
vi.resetModules();
|
||||
|
||||
const alertMock = vi.fn();
|
||||
vi.doMock("react-native", () => ({
|
||||
Alert: {
|
||||
alert: alertMock,
|
||||
},
|
||||
Platform: { OS: platform },
|
||||
}));
|
||||
|
||||
const module = await import("./confirm-dialog");
|
||||
return { confirmDialog: module.confirmDialog, alertMock };
|
||||
}
|
||||
|
||||
function clearDialogGlobals(): void {
|
||||
delete (globalThis as { __TAURI__?: unknown }).__TAURI__;
|
||||
delete (globalThis as { confirm?: unknown }).confirm;
|
||||
}
|
||||
|
||||
describe("confirmDialog", () => {
|
||||
afterEach(() => {
|
||||
vi.doUnmock("react-native");
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
clearDialogGlobals();
|
||||
});
|
||||
|
||||
it("uses Tauri dialog.ask on web when available", async () => {
|
||||
const askMock = vi.fn(async () => true);
|
||||
(globalThis as { __TAURI__?: unknown }).__TAURI__ = {
|
||||
dialog: { ask: askMock },
|
||||
};
|
||||
|
||||
const { confirmDialog, alertMock } = await loadModuleForPlatform("web");
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Restart host",
|
||||
message: "This will restart the daemon.",
|
||||
confirmLabel: "Restart",
|
||||
cancelLabel: "Cancel",
|
||||
destructive: true,
|
||||
});
|
||||
|
||||
expect(confirmed).toBe(true);
|
||||
expect(alertMock).not.toHaveBeenCalled();
|
||||
expect(askMock).toHaveBeenCalledWith("This will restart the daemon.", {
|
||||
title: "Restart host",
|
||||
okLabel: "Restart",
|
||||
cancelLabel: "Cancel",
|
||||
kind: "warning",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to browser confirm on web when Tauri APIs are unavailable", async () => {
|
||||
const browserConfirm = vi.fn(() => true);
|
||||
(globalThis as { confirm?: unknown }).confirm = browserConfirm;
|
||||
|
||||
const { confirmDialog } = await loadModuleForPlatform("web");
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Restart host",
|
||||
message: "This will restart the daemon.",
|
||||
});
|
||||
|
||||
expect(confirmed).toBe(true);
|
||||
expect(browserConfirm).toHaveBeenCalledWith(
|
||||
"Restart host\n\nThis will restart the daemon."
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on web when no confirm backend exists", async () => {
|
||||
const { confirmDialog } = await loadModuleForPlatform("web");
|
||||
|
||||
await expect(
|
||||
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?.();
|
||||
}
|
||||
);
|
||||
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Restart host",
|
||||
message: "This will restart the daemon.",
|
||||
confirmLabel: "Restart",
|
||||
cancelLabel: "Cancel",
|
||||
destructive: true,
|
||||
});
|
||||
|
||||
expect(confirmed).toBe(true);
|
||||
expect(alertMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Alert, Platform } from "react-native";
|
||||
|
||||
export interface ConfirmDialogInput {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
destructive?: boolean;
|
||||
}
|
||||
|
||||
interface TauriDialogAskOptions {
|
||||
title?: string;
|
||||
okLabel?: string;
|
||||
cancelLabel?: string;
|
||||
kind?: "info" | "warning" | "error";
|
||||
}
|
||||
|
||||
interface TauriDialogApi {
|
||||
ask?: (message: string, options?: TauriDialogAskOptions) => Promise<boolean>;
|
||||
}
|
||||
|
||||
interface TauriCoreApi {
|
||||
invoke?: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
}
|
||||
|
||||
interface TauriGlobalApi {
|
||||
dialog?: TauriDialogApi;
|
||||
core?: TauriCoreApi;
|
||||
}
|
||||
|
||||
interface ConfirmButtonConfig {
|
||||
confirmLabel: string;
|
||||
cancelLabel: string;
|
||||
}
|
||||
|
||||
function resolveButtonLabels(input: ConfirmDialogInput): ConfirmButtonConfig {
|
||||
return {
|
||||
confirmLabel: input.confirmLabel ?? "Confirm",
|
||||
cancelLabel: input.cancelLabel ?? "Cancel",
|
||||
};
|
||||
}
|
||||
|
||||
async function showNativeConfirmDialog(input: ConfirmDialogInput): Promise<boolean> {
|
||||
const labels = resolveButtonLabels(input);
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
Alert.alert(
|
||||
input.title,
|
||||
input.message,
|
||||
[
|
||||
{
|
||||
text: labels.cancelLabel,
|
||||
style: "cancel",
|
||||
onPress: () => resolve(false),
|
||||
},
|
||||
{
|
||||
text: labels.confirmLabel,
|
||||
style: input.destructive ? "destructive" : "default",
|
||||
onPress: () => resolve(true),
|
||||
},
|
||||
],
|
||||
{
|
||||
cancelable: true,
|
||||
onDismiss: () => resolve(false),
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function getTauriApi(): TauriGlobalApi | null {
|
||||
if (Platform.OS !== "web") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tauriApi = (globalThis as { __TAURI__?: TauriGlobalApi }).__TAURI__;
|
||||
return tauriApi ?? null;
|
||||
}
|
||||
|
||||
function buildTauriAskOptions(input: ConfirmDialogInput): TauriDialogAskOptions {
|
||||
const labels = resolveButtonLabels(input);
|
||||
|
||||
return {
|
||||
title: input.title,
|
||||
okLabel: labels.confirmLabel,
|
||||
cancelLabel: labels.cancelLabel,
|
||||
kind: input.destructive ? "warning" : "info",
|
||||
};
|
||||
}
|
||||
|
||||
async function showTauriConfirmDialog(input: ConfirmDialogInput): Promise<boolean | null> {
|
||||
const tauriApi = getTauriApi();
|
||||
if (!tauriApi) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const options = buildTauriAskOptions(input);
|
||||
const tauriAsk = tauriApi.dialog?.ask;
|
||||
|
||||
if (typeof tauriAsk === "function") {
|
||||
try {
|
||||
return Boolean(await tauriAsk(input.message, options));
|
||||
} catch (error) {
|
||||
console.warn("[ConfirmDialog] Tauri dialog.ask failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
const tauriInvoke = tauriApi.core?.invoke;
|
||||
if (typeof tauriInvoke === "function") {
|
||||
try {
|
||||
const result = await tauriInvoke("plugin:dialog|ask", {
|
||||
message: input.message,
|
||||
...options,
|
||||
});
|
||||
return result === true;
|
||||
} catch (error) {
|
||||
console.warn("[ConfirmDialog] Tauri plugin:dialog|ask failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function showWebConfirmDialog(input: ConfirmDialogInput): boolean {
|
||||
const browserConfirm = (globalThis as { confirm?: (message?: string) => boolean }).confirm;
|
||||
if (typeof browserConfirm !== "function") {
|
||||
throw new Error("[ConfirmDialog] No web confirmation backend is available.");
|
||||
}
|
||||
|
||||
const promptMessage = `${input.title}\n\n${input.message}`;
|
||||
return browserConfirm(promptMessage);
|
||||
}
|
||||
|
||||
export async function confirmDialog(input: ConfirmDialogInput): Promise<boolean> {
|
||||
if (Platform.OS !== "web") {
|
||||
return showNativeConfirmDialog(input);
|
||||
}
|
||||
|
||||
const tauriResult = await showTauriConfirmDialog(input);
|
||||
if (tauriResult !== null) {
|
||||
return tauriResult;
|
||||
}
|
||||
|
||||
return showWebConfirmDialog(input);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as Linking from "expo-linking";
|
||||
import { Platform } from "react-native";
|
||||
import { getIsTauri } from "@/constants/layout";
|
||||
|
||||
interface TauriWindowWithOpener {
|
||||
__TAURI__?: {
|
||||
opener?: {
|
||||
openUrl?: (url: string) => Promise<void>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export async function openExternalUrl(url: string): Promise<void> {
|
||||
if (Platform.OS === "web") {
|
||||
if (typeof window !== "undefined" && getIsTauri()) {
|
||||
const opener = (window as TauriWindowWithOpener).__TAURI__?.opener?.openUrl;
|
||||
if (typeof opener === "function") {
|
||||
await opener(url);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
|
||||
await Linking.openURL(url);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { parser as cssParser } from "@lezer/css";
|
||||
import { parser as htmlParser } from "@lezer/html";
|
||||
import { parser as pythonParser } from "@lezer/python";
|
||||
import { parser as markdownParser } from "@lezer/markdown";
|
||||
import { parser as elixirParser } from "lezer-elixir";
|
||||
import type { Parser } from "@lezer/common";
|
||||
|
||||
// Map file extensions to parsers
|
||||
@@ -26,6 +27,9 @@ const parsersByExtension: Record<string, Parser> = {
|
||||
htm: htmlParser,
|
||||
// Python
|
||||
py: pythonParser,
|
||||
// Elixir
|
||||
ex: elixirParser,
|
||||
exs: elixirParser,
|
||||
// Markdown
|
||||
md: markdownParser,
|
||||
mdx: markdownParser,
|
||||
|
||||
@@ -109,7 +109,21 @@ export function useTrafficLightPadding(): { left: number; top: number } {
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web" || !getIsTauriMac()) return;
|
||||
|
||||
let unlisten: (() => void) | undefined;
|
||||
let disposed = false;
|
||||
let cleanup: (() => void) | undefined;
|
||||
let didCleanup = false;
|
||||
|
||||
function runCleanup() {
|
||||
if (!cleanup || didCleanup) return;
|
||||
didCleanup = true;
|
||||
try {
|
||||
void Promise.resolve(cleanup()).catch((error) => {
|
||||
console.warn("[TauriWindow] Failed to remove resize listener", error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[TauriWindow] Failed to remove resize listener", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const win = await getTauriWindow();
|
||||
@@ -117,19 +131,28 @@ export function useTrafficLightPadding(): { left: number; top: number } {
|
||||
|
||||
// Check initial fullscreen state
|
||||
const fullscreen = await win.isFullscreen();
|
||||
if (disposed) return;
|
||||
setIsFullscreen(fullscreen);
|
||||
|
||||
// Listen for resize events which include fullscreen changes
|
||||
unlisten = await win.onResized(async () => {
|
||||
const unlisten = await win.onResized(async () => {
|
||||
if (disposed) return;
|
||||
const fs = await win.isFullscreen();
|
||||
if (disposed) return;
|
||||
setIsFullscreen(fs);
|
||||
});
|
||||
|
||||
cleanup = unlisten;
|
||||
if (disposed) {
|
||||
runCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
setup();
|
||||
void setup();
|
||||
|
||||
return () => {
|
||||
unlisten?.();
|
||||
disposed = true;
|
||||
runCleanup();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { upsertTerminalListEntry } from "./terminal-list";
|
||||
|
||||
describe("terminal-list", () => {
|
||||
it("adds a created terminal when the list is empty", () => {
|
||||
const result = upsertTerminalListEntry({
|
||||
terminals: [],
|
||||
terminal: {
|
||||
id: "term-1",
|
||||
name: "Terminal 1",
|
||||
cwd: "/tmp/project",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual([{ id: "term-1", name: "Terminal 1" }]);
|
||||
});
|
||||
|
||||
it("appends a created terminal when the id does not already exist", () => {
|
||||
const result = upsertTerminalListEntry({
|
||||
terminals: [{ id: "term-1", name: "Terminal 1" }],
|
||||
terminal: {
|
||||
id: "term-2",
|
||||
name: "Terminal 2",
|
||||
cwd: "/tmp/project",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: "term-1", name: "Terminal 1" },
|
||||
{ id: "term-2", name: "Terminal 2" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("replaces the existing terminal entry when ids match", () => {
|
||||
const result = upsertTerminalListEntry({
|
||||
terminals: [
|
||||
{ id: "term-1", name: "Terminal 1" },
|
||||
{ id: "term-2", name: "Old Name" },
|
||||
],
|
||||
terminal: {
|
||||
id: "term-2",
|
||||
name: "Renamed Terminal",
|
||||
cwd: "/tmp/project",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: "term-1", name: "Terminal 1" },
|
||||
{ id: "term-2", name: "Renamed Terminal" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type {
|
||||
CreateTerminalResponse,
|
||||
ListTerminalsResponse,
|
||||
} from "@server/shared/messages";
|
||||
|
||||
type TerminalListEntry = ListTerminalsResponse["payload"]["terminals"][number];
|
||||
type CreatedTerminal = NonNullable<CreateTerminalResponse["payload"]["terminal"]>;
|
||||
|
||||
function toTerminalListEntry(input: { terminal: CreatedTerminal }): TerminalListEntry {
|
||||
return {
|
||||
id: input.terminal.id,
|
||||
name: input.terminal.name,
|
||||
};
|
||||
}
|
||||
|
||||
export function upsertTerminalListEntry(input: {
|
||||
terminals: TerminalListEntry[];
|
||||
terminal: CreatedTerminal;
|
||||
}): TerminalListEntry[] {
|
||||
const createdTerminal = toTerminalListEntry({ terminal: input.terminal });
|
||||
const existingIndex = input.terminals.findIndex(
|
||||
(terminal) => terminal.id === createdTerminal.id
|
||||
);
|
||||
|
||||
if (existingIndex < 0) {
|
||||
return [...input.terminals, createdTerminal];
|
||||
}
|
||||
|
||||
const nextTerminals = [...input.terminals];
|
||||
nextTerminals[existingIndex] = createdTerminal;
|
||||
return nextTerminals;
|
||||
}
|
||||
@@ -76,6 +76,34 @@ describe("tool-call-display", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("builds display model from worktree setup detail", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "paseo_worktree_setup",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath: "/tmp/repo/.paseo/worktrees/repo/branch",
|
||||
branchName: "feature-branch",
|
||||
log: "==> [1/1] Running: npm install\n",
|
||||
commands: [
|
||||
{
|
||||
index: 1,
|
||||
command: "npm install",
|
||||
cwd: "/tmp/repo/.paseo/worktrees/repo/branch",
|
||||
status: "running",
|
||||
exitCode: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
displayName: "Worktree Setup",
|
||||
summary: "feature-branch",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not derive command summary from unknown raw detail", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "exec_command",
|
||||
|
||||
@@ -19,6 +19,7 @@ const TOOL_DETAIL_ICONS: Record<ToolCallDetail["type"], ToolCallIconComponent> =
|
||||
edit: Pencil,
|
||||
write: Pencil,
|
||||
search: Search,
|
||||
worktree_setup: SquareTerminal,
|
||||
unknown: Wrench,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildWorkingDirectorySuggestions } from "./working-directory-suggestions";
|
||||
|
||||
describe("buildWorkingDirectorySuggestions", () => {
|
||||
it("returns de-duplicated recommendations when query is empty", () => {
|
||||
const results = buildWorkingDirectorySuggestions({
|
||||
recommendedPaths: ["/Users/me/projects/paseo", "/Users/me/projects/paseo"],
|
||||
serverPaths: ["/Users/me/projects/playground"],
|
||||
query: "",
|
||||
});
|
||||
|
||||
expect(results).toEqual(["/Users/me/projects/paseo"]);
|
||||
});
|
||||
|
||||
it("prioritizes matching recommended directories before server matches", () => {
|
||||
const results = buildWorkingDirectorySuggestions({
|
||||
recommendedPaths: ["/Users/me/projects/paseo", "/Users/me/documents"],
|
||||
serverPaths: [
|
||||
"/Users/me/projects/playground",
|
||||
"/Users/me/projects/paseo",
|
||||
"/Users/me/projects/planbook",
|
||||
],
|
||||
query: "pla",
|
||||
});
|
||||
|
||||
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",
|
||||
],
|
||||
query: "pla",
|
||||
});
|
||||
|
||||
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",
|
||||
],
|
||||
query: "~",
|
||||
});
|
||||
|
||||
expect(results).toEqual([
|
||||
"/Users/me/projects/paseo",
|
||||
"/Users/me/documents",
|
||||
"/Users/me/projects",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
export interface BuildWorkingDirectorySuggestionsInput {
|
||||
recommendedPaths: string[];
|
||||
serverPaths: string[];
|
||||
query: string;
|
||||
}
|
||||
|
||||
export function buildWorkingDirectorySuggestions(
|
||||
input: BuildWorkingDirectorySuggestionsInput
|
||||
): string[] {
|
||||
const rawQuery = input.query.trim();
|
||||
const recommended = uniquePaths(input.recommendedPaths);
|
||||
if (!rawQuery) {
|
||||
return recommended;
|
||||
}
|
||||
|
||||
const normalizedQuery = normalizeQuery(rawQuery);
|
||||
const shouldFilterByQuery = normalizedQuery.length > 0;
|
||||
|
||||
const recommendedMatches = shouldFilterByQuery
|
||||
? recommended.filter((entry) => pathMatchesQuery(entry, normalizedQuery))
|
||||
: recommended;
|
||||
const seen = new Set(recommendedMatches);
|
||||
const ordered = [...recommendedMatches];
|
||||
|
||||
for (const entry of uniquePaths(input.serverPaths)) {
|
||||
if (shouldFilterByQuery && !pathMatchesQuery(entry, normalizedQuery)) {
|
||||
continue;
|
||||
}
|
||||
if (seen.has(entry)) {
|
||||
continue;
|
||||
}
|
||||
ordered.push(entry);
|
||||
seen.add(entry);
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function uniquePaths(paths: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const ordered: string[] = [];
|
||||
for (const pathEntry of paths) {
|
||||
const trimmed = pathEntry.trim();
|
||||
if (!trimmed || seen.has(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(trimmed);
|
||||
ordered.push(trimmed);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function normalizeQuery(query: string): string {
|
||||
let normalized = query.trim();
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
if (normalized.startsWith("~")) {
|
||||
normalized = normalized.slice(1);
|
||||
}
|
||||
normalized = normalized.replace(/^\/+/, "").toLowerCase();
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function pathMatchesQuery(candidatePath: string, query: string): boolean {
|
||||
const lowerPath = candidatePath.toLowerCase();
|
||||
if (lowerPath.includes(query)) {
|
||||
return true;
|
||||
}
|
||||
const segments = lowerPath.split("/");
|
||||
return (segments[segments.length - 1] ?? "").includes(query);
|
||||
}
|
||||
Reference in New Issue
Block a user