feat: improve agent workspace flows and directory suggestions
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,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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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