feat: workspace-level sidebar shortcuts with shortcut badges and desktop tabs extraction
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { 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 {
|
||||
@@ -35,6 +39,35 @@ describe("deriveRemoteProjectKey", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveProjectDisplayName", () => {
|
||||
it("shows owner/repo for GitHub remote keys", () => {
|
||||
expect(
|
||||
deriveProjectDisplayName({
|
||||
projectKey: "remote:github.com/getpaseo/paseo",
|
||||
projectName: "paseo",
|
||||
})
|
||||
).toBe("getpaseo/paseo");
|
||||
});
|
||||
|
||||
it("shows remote path for non-GitHub remote keys", () => {
|
||||
expect(
|
||||
deriveProjectDisplayName({
|
||||
projectKey: "remote:gitlab.example.com/group/repo",
|
||||
projectName: "repo",
|
||||
})
|
||||
).toBe("group/repo");
|
||||
});
|
||||
|
||||
it("falls back to projectName for local keys", () => {
|
||||
expect(
|
||||
deriveProjectDisplayName({
|
||||
projectKey: "/Users/me/dev/paseo",
|
||||
projectName: "paseo",
|
||||
})
|
||||
).toBe("paseo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupAgents", () => {
|
||||
it("groups active agents by remote URL when available", () => {
|
||||
const agents = [
|
||||
|
||||
@@ -156,6 +156,44 @@ export function deriveProjectName(projectKey: string): string {
|
||||
return segments[segments.length - 1] || projectKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a project name for display in the UI.
|
||||
*
|
||||
* - GitHub remotes show owner/repo
|
||||
* - Other remotes show the remote path when possible
|
||||
* - Local projects prefer the provided projectName, then fallback to cwd tail
|
||||
*/
|
||||
export function deriveProjectDisplayName(input: {
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
}): string {
|
||||
const githubPrefix = "remote:github.com/";
|
||||
if (input.projectKey.startsWith(githubPrefix)) {
|
||||
return input.projectKey.slice(githubPrefix.length);
|
||||
}
|
||||
|
||||
if (input.projectKey.startsWith("remote:")) {
|
||||
const withoutPrefix = input.projectKey.slice("remote:".length);
|
||||
const slashIdx = withoutPrefix.indexOf("/");
|
||||
if (slashIdx >= 0) {
|
||||
const remotePath = withoutPrefix.slice(slashIdx + 1).trim();
|
||||
if (remotePath.length > 0) {
|
||||
return remotePath;
|
||||
}
|
||||
}
|
||||
return withoutPrefix;
|
||||
}
|
||||
|
||||
const trimmedProjectName = input.projectName.trim();
|
||||
if (trimmedProjectName.length > 0) {
|
||||
return trimmedProjectName;
|
||||
}
|
||||
|
||||
const normalized = input.projectKey.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const segments = normalized.split("/").filter(Boolean);
|
||||
return segments[segments.length - 1] ?? input.projectKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the date group label for an agent based on lastActivityAt.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAbsoluteExplorerPath } from "./explorer-paths";
|
||||
|
||||
describe("buildAbsoluteExplorerPath", () => {
|
||||
it("builds a POSIX absolute path from a relative explorer path", () => {
|
||||
expect(
|
||||
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");
|
||||
});
|
||||
|
||||
it("returns workspace root when entry path points to explorer root", () => {
|
||||
expect(
|
||||
buildAbsoluteExplorerPath({
|
||||
workspaceRoot: "/Users/moboudra/dev/paseo",
|
||||
entryPath: ".",
|
||||
})
|
||||
).toBe("/Users/moboudra/dev/paseo");
|
||||
});
|
||||
|
||||
it("trims trailing separators from workspace root before joining", () => {
|
||||
expect(
|
||||
buildAbsoluteExplorerPath({
|
||||
workspaceRoot: "/Users/moboudra/dev/paseo/",
|
||||
entryPath: "README.md",
|
||||
})
|
||||
).toBe("/Users/moboudra/dev/paseo/README.md");
|
||||
});
|
||||
|
||||
it("builds a Windows absolute path with backslash separators", () => {
|
||||
expect(
|
||||
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");
|
||||
});
|
||||
|
||||
it("passes through an already-absolute entry path", () => {
|
||||
expect(
|
||||
buildAbsoluteExplorerPath({
|
||||
workspaceRoot: "/Users/moboudra/dev/paseo",
|
||||
entryPath: "/tmp/another/location.txt",
|
||||
})
|
||||
).toBe("/tmp/another/location.txt");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
interface BuildAbsoluteExplorerPathInput {
|
||||
workspaceRoot: string;
|
||||
entryPath: string;
|
||||
}
|
||||
|
||||
function isAbsolutePath(pathValue: string): boolean {
|
||||
return (
|
||||
pathValue.startsWith("/") ||
|
||||
pathValue.startsWith("\\\\") ||
|
||||
/^[A-Za-z]:[\\/]/.test(pathValue)
|
||||
);
|
||||
}
|
||||
|
||||
export function buildAbsoluteExplorerPath({
|
||||
workspaceRoot,
|
||||
entryPath,
|
||||
}: BuildAbsoluteExplorerPathInput): string {
|
||||
const normalizedWorkspaceRoot = workspaceRoot.trim().replace(/[\\/]+$/, "");
|
||||
const normalizedEntryPath = entryPath.trim();
|
||||
|
||||
if (!normalizedWorkspaceRoot) {
|
||||
return normalizedEntryPath;
|
||||
}
|
||||
|
||||
if (!normalizedEntryPath || normalizedEntryPath === ".") {
|
||||
return normalizedWorkspaceRoot;
|
||||
}
|
||||
|
||||
if (isAbsolutePath(normalizedEntryPath)) {
|
||||
return normalizedEntryPath;
|
||||
}
|
||||
|
||||
const separator = normalizedWorkspaceRoot.includes("\\") ? "\\" : "/";
|
||||
const segments = normalizedEntryPath.split(/[\\/]+/).filter(Boolean);
|
||||
if (segments.length === 0) {
|
||||
return normalizedWorkspaceRoot;
|
||||
}
|
||||
|
||||
return `${normalizedWorkspaceRoot}${separator}${segments.join(separator)}`;
|
||||
}
|
||||
@@ -1,56 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SidebarProjectEntry, SidebarWorkspaceEntry } from "@/hooks/use-sidebar-agents-list";
|
||||
|
||||
import { deriveSidebarShortcutAgentKeys, parseSidebarAgentKey } from "./sidebar-shortcuts";
|
||||
import { buildSidebarWorkspaceViewModel } from "./sidebar-shortcuts";
|
||||
|
||||
describe("parseSidebarAgentKey", () => {
|
||||
it("parses serverId and agentId", () => {
|
||||
expect(parseSidebarAgentKey("server:agent")).toEqual({ serverId: "server", agentId: "agent" });
|
||||
});
|
||||
function workspace(serverId: string, cwd: string): SidebarWorkspaceEntry {
|
||||
return {
|
||||
workspaceKey: `${serverId}:${cwd}`,
|
||||
serverId,
|
||||
cwd,
|
||||
branchName: null,
|
||||
createdAt: null,
|
||||
isMainCheckout: false,
|
||||
isPaseoOwnedWorktree: false,
|
||||
statusBucket: "done",
|
||||
};
|
||||
}
|
||||
|
||||
it("returns null for invalid keys", () => {
|
||||
expect(parseSidebarAgentKey("")).toBeNull();
|
||||
expect(parseSidebarAgentKey("no-separator")).toBeNull();
|
||||
expect(parseSidebarAgentKey(":agent")).toBeNull();
|
||||
expect(parseSidebarAgentKey("server:")).toBeNull();
|
||||
});
|
||||
});
|
||||
function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): SidebarProjectEntry {
|
||||
return {
|
||||
projectKey,
|
||||
projectName: projectKey,
|
||||
iconWorkingDir: workspaces[0]?.cwd ?? "",
|
||||
statusBucket: "done",
|
||||
activeCount: 0,
|
||||
totalCount: workspaces.length,
|
||||
latestCreatedAt: null,
|
||||
workspaces,
|
||||
};
|
||||
}
|
||||
|
||||
describe("deriveSidebarShortcutAgentKeys", () => {
|
||||
it("skips collapsed projects and preserves visual order", () => {
|
||||
const sections = [
|
||||
{
|
||||
projectKey: "p1",
|
||||
agents: [
|
||||
{ serverId: "s", id: "a1" },
|
||||
{ serverId: "s", id: "a2" },
|
||||
],
|
||||
},
|
||||
{
|
||||
projectKey: "p2",
|
||||
agents: [
|
||||
{ serverId: "s", id: "b1" },
|
||||
{ serverId: "s", id: "b2" },
|
||||
],
|
||||
},
|
||||
describe("buildSidebarWorkspaceViewModel", () => {
|
||||
it("builds visible rows and shortcut targets in visual order", () => {
|
||||
const projects = [
|
||||
project("p1", [workspace("s1", "/repo/main"), workspace("s1", "/repo/feat-a")]),
|
||||
project("p2", [workspace("s1", "/repo2/main")]),
|
||||
];
|
||||
|
||||
expect(deriveSidebarShortcutAgentKeys(sections, new Set(["p2"]), 9)).toEqual([
|
||||
"s:a1",
|
||||
"s:a2",
|
||||
const model = buildSidebarWorkspaceViewModel({
|
||||
projects,
|
||||
collapsedProjectKeys: new Set<string>(["p2"]),
|
||||
getProjectDisplayName: (entry) => entry.projectName,
|
||||
});
|
||||
|
||||
expect(model.rows.map((row) => row.kind)).toEqual(["project", "workspace", "workspace", "project"]);
|
||||
expect(model.shortcutTargets).toEqual([
|
||||
{ serverId: "s1", workspaceId: "/repo/main" },
|
||||
{ serverId: "s1", workspaceId: "/repo/feat-a" },
|
||||
]);
|
||||
expect(model.shortcutIndexByWorkspaceKey.get("s1:/repo/main")).toBe(1);
|
||||
expect(model.shortcutIndexByWorkspaceKey.get("s1:/repo/feat-a")).toBe(2);
|
||||
expect(model.shortcutIndexByWorkspaceKey.get("s1:/repo2/main")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("limits to 9", () => {
|
||||
const sections = [
|
||||
{
|
||||
projectKey: "p1",
|
||||
agents: Array.from({ length: 20 }, (_, i) => ({ serverId: "s", id: `a${i + 1}` })),
|
||||
},
|
||||
];
|
||||
it("limits shortcuts to 9", () => {
|
||||
const workspaces = Array.from({ length: 20 }, (_, index) => workspace("s", `/repo/w${index + 1}`));
|
||||
const projects = [project("p", workspaces)];
|
||||
|
||||
expect(deriveSidebarShortcutAgentKeys(sections, new Set(), 9)).toHaveLength(9);
|
||||
expect(deriveSidebarShortcutAgentKeys(sections, new Set(), 9)[0]).toBe("s:a1");
|
||||
expect(deriveSidebarShortcutAgentKeys(sections, new Set(), 9)[8]).toBe("s:a9");
|
||||
const model = buildSidebarWorkspaceViewModel({
|
||||
projects,
|
||||
collapsedProjectKeys: new Set<string>(),
|
||||
getProjectDisplayName: (entry) => entry.projectName,
|
||||
});
|
||||
|
||||
expect(model.shortcutTargets).toHaveLength(9);
|
||||
expect(model.shortcutTargets[0]).toEqual({ serverId: "s", workspaceId: "/repo/w1" });
|
||||
expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "/repo/w9" });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,46 +1,77 @@
|
||||
export interface SidebarShortcutSection {
|
||||
projectKey: string;
|
||||
agents: Array<{ serverId: string; id: string }>;
|
||||
import type {
|
||||
SidebarProjectEntry,
|
||||
SidebarWorkspaceEntry,
|
||||
} from '@/hooks/use-sidebar-agents-list'
|
||||
|
||||
export interface SidebarShortcutWorkspaceTarget {
|
||||
serverId: string
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
export function parseSidebarAgentKey(
|
||||
key: string
|
||||
): { serverId: string; agentId: string } | null {
|
||||
const sep = key.indexOf(":");
|
||||
if (sep === -1) {
|
||||
return null;
|
||||
}
|
||||
const serverId = key.slice(0, sep);
|
||||
const agentId = key.slice(sep + 1);
|
||||
if (!serverId || !agentId) {
|
||||
return null;
|
||||
}
|
||||
return { serverId, agentId };
|
||||
}
|
||||
|
||||
export function deriveSidebarShortcutAgentKeys(
|
||||
sections: SidebarShortcutSection[],
|
||||
collapsedProjectKeys: ReadonlySet<string>,
|
||||
limit = 9
|
||||
): string[] {
|
||||
const keys: string[] = [];
|
||||
const max = Math.max(0, Math.floor(limit));
|
||||
if (max === 0) {
|
||||
return keys;
|
||||
}
|
||||
|
||||
for (const section of sections) {
|
||||
if (collapsedProjectKeys.has(section.projectKey)) {
|
||||
continue;
|
||||
export type SidebarWorkspaceTreeRow =
|
||||
| {
|
||||
kind: 'project'
|
||||
rowKey: string
|
||||
project: SidebarProjectEntry
|
||||
displayName: string
|
||||
}
|
||||
for (const agent of section.agents) {
|
||||
keys.push(`${agent.serverId}:${agent.id}`);
|
||||
if (keys.length >= max) {
|
||||
return keys;
|
||||
| {
|
||||
kind: 'workspace'
|
||||
rowKey: string
|
||||
projectKey: string
|
||||
workspace: SidebarWorkspaceEntry
|
||||
shortcutNumber: number | null
|
||||
}
|
||||
|
||||
export interface SidebarWorkspaceViewModel {
|
||||
rows: SidebarWorkspaceTreeRow[]
|
||||
shortcutTargets: SidebarShortcutWorkspaceTarget[]
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>
|
||||
}
|
||||
|
||||
export function buildSidebarWorkspaceViewModel(input: {
|
||||
projects: SidebarProjectEntry[]
|
||||
collapsedProjectKeys: ReadonlySet<string>
|
||||
getProjectDisplayName: (project: SidebarProjectEntry) => string
|
||||
shortcutLimit?: number
|
||||
}): SidebarWorkspaceViewModel {
|
||||
const maxShortcuts = Math.max(0, Math.floor(input.shortcutLimit ?? 9))
|
||||
const rows: SidebarWorkspaceTreeRow[] = []
|
||||
const shortcutTargets: SidebarShortcutWorkspaceTarget[] = []
|
||||
const shortcutIndexByWorkspaceKey = new Map<string, number>()
|
||||
|
||||
for (const project of input.projects) {
|
||||
rows.push({
|
||||
kind: 'project',
|
||||
rowKey: `project:${project.projectKey}`,
|
||||
project,
|
||||
displayName: input.getProjectDisplayName(project),
|
||||
})
|
||||
|
||||
if (input.collapsedProjectKeys.has(project.projectKey)) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const workspace of project.workspaces) {
|
||||
const shortcutNumber =
|
||||
shortcutTargets.length < maxShortcuts ? shortcutTargets.length + 1 : null
|
||||
if (shortcutNumber !== null) {
|
||||
shortcutTargets.push({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.cwd,
|
||||
})
|
||||
shortcutIndexByWorkspaceKey.set(workspace.workspaceKey, shortcutNumber)
|
||||
}
|
||||
|
||||
rows.push({
|
||||
kind: 'workspace',
|
||||
rowKey: `workspace:${project.projectKey}:${workspace.workspaceKey}`,
|
||||
projectKey: project.projectKey,
|
||||
workspace,
|
||||
shortcutNumber,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
return { rows, shortcutTargets, shortcutIndexByWorkspaceKey }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user