@yuangc/paseo-plugin-workspace-overview 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/client/agent-preview/PreviewActions.tsx +56 -0
  2. package/client/agent-preview/useAutoCommit.ts +91 -0
  3. package/client/components/AgentCommandMenu.tsx +51 -0
  4. package/client/components/AgentConversationPreview.tsx +620 -0
  5. package/client/components/AgentConversationTimeline.tsx +177 -0
  6. package/client/components/AgentCreatorPanel.tsx +152 -0
  7. package/client/components/AgentQuestionCard.tsx +244 -0
  8. package/client/components/AgentRow.tsx +108 -0
  9. package/client/components/BadgeWithTooltip.tsx +119 -0
  10. package/client/components/BranchRow.tsx +121 -0
  11. package/client/components/ControlGlyph.tsx +18 -0
  12. package/client/components/HeatmapCalendar.tsx +165 -0
  13. package/client/components/ModelBrowserMenu.tsx +135 -0
  14. package/client/components/PaseoAssistantMessage.tsx +216 -0
  15. package/client/components/PaseoShellDetail.tsx +128 -0
  16. package/client/components/PaseoStreamBadge.tsx +94 -0
  17. package/client/components/PaseoTimelineItem.tsx +147 -0
  18. package/client/components/PaseoToolCallGroup.tsx +52 -0
  19. package/client/components/PaseoToolDetail.tsx +55 -0
  20. package/client/components/ProjectCard.tsx +196 -0
  21. package/client/components/ProjectIcon.tsx +49 -0
  22. package/client/components/ProviderBrandIcon.tsx +52 -0
  23. package/client/components/ResizeHandle.tsx +91 -0
  24. package/client/components/SharedComposerInput.tsx +383 -0
  25. package/client/components/Tooltip.tsx +49 -0
  26. package/client/components/WorkspaceAgentRow.tsx +93 -0
  27. package/client/components/WorkspaceBadge.tsx +39 -0
  28. package/client/components/WorkspaceCreateComposer.tsx +170 -0
  29. package/client/components/WorkspaceCreatorPanel.tsx +256 -0
  30. package/client/components/WorkspaceLaunchMenu.tsx +76 -0
  31. package/client/components/WorkspaceMenu.tsx +116 -0
  32. package/client/components/WorkspaceSelectControl.tsx +52 -0
  33. package/client/components/agent-conversation-types.ts +7 -0
  34. package/client/components/workspace-creator-styles.ts +322 -0
  35. package/client/hooks/useAgentActivityHeatmap.ts +178 -0
  36. package/client/hooks/useFilter.ts +107 -0
  37. package/client/hooks/useKeyboardInset.ts +37 -0
  38. package/client/hooks/useResizableWidth.ts +70 -0
  39. package/client/hooks/useWorkspaces.ts +128 -0
  40. package/client/overview/useProjectOptions.ts +73 -0
  41. package/client/web.ts +160 -0
  42. package/client/workspace-agents.tsx +71 -0
  43. package/client/workspace-creator/badge-styles.ts +13 -0
  44. package/client/workspace-creator/constants.ts +14 -0
  45. package/client/workspace-creator/control-styles.ts +12 -0
  46. package/client/workspace-creator/provider-selection.ts +51 -0
  47. package/client/workspace-creator/submit-workspace.ts +67 -0
  48. package/client/workspace-creator/types.ts +40 -0
  49. package/client/workspace-creator/ui-utils.ts +15 -0
  50. package/client/workspace-creator/useProviderCatalog.ts +89 -0
  51. package/client/workspace-overview.tsx +533 -0
  52. package/index.client.tsx +36 -0
  53. package/index.server.ts +33 -0
  54. package/package.json +28 -4
  55. package/paseo-plugin.json +4 -0
  56. package/server/agent-config-impl.ts +57 -0
  57. package/server/auto-commit-impl.ts +221 -0
  58. package/server/git-branches-impl.ts +81 -0
  59. package/server/project-icon-impl.ts +102 -0
  60. package/server/terminal-launch-impl.ts +56 -0
  61. package/shared/agent-activity.ts +16 -0
  62. package/shared/agent-config.ts +30 -0
  63. package/shared/auto-commit.ts +38 -0
  64. package/shared/git-branches.ts +18 -0
  65. package/shared/overview-types.ts +87 -0
  66. package/shared/project-icon.ts +13 -0
  67. package/shared/terminal-launch.ts +20 -0
  68. package/README.md +0 -3
@@ -0,0 +1,128 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import { usePaseo } from "@getpaseo/plugin/client";
3
+ import { useCallback, useMemo } from "react";
4
+ import { type WorkspaceEntry, type AgentEntry } from "../../shared/overview-types";
5
+ import { agentActivityMs } from "../../shared/agent-activity";
6
+
7
+ /** Type guard: verify an object is a valid WorkspaceEntry at runtime. */
8
+ function isWorkspaceEntry(obj: unknown): obj is WorkspaceEntry {
9
+ return (
10
+ typeof obj === "object" &&
11
+ obj !== null &&
12
+ "id" in obj &&
13
+ "projectId" in obj &&
14
+ "projectDisplayName" in obj &&
15
+ "name" in obj &&
16
+ "status" in obj &&
17
+ "workspaceKind" in obj &&
18
+ "workspaceDirectory" in obj
19
+ );
20
+ }
21
+
22
+ export interface UseWorkspacesResult {
23
+ projects: Array<{
24
+ projectId: string;
25
+ projectDisplayName: string;
26
+ workspaces: WorkspaceEntry[];
27
+ }>;
28
+ agentsByWorkspace: Map<string, AgentEntry[]>;
29
+ isLoading: boolean;
30
+ error: unknown;
31
+ refetch: () => void;
32
+ }
33
+
34
+ export function useWorkspaces(hostId: string): UseWorkspacesResult {
35
+ const paseo = usePaseo();
36
+
37
+ const { data: wsResult, isLoading: wsLoading, error: wsError, refetch: refetchWorkspaces } = useQuery({
38
+ queryKey: ["ws-list", hostId],
39
+ queryFn: () => paseo.workspaces.list({ subscribe: {} }),
40
+ refetchInterval: 5000,
41
+ staleTime: 3000,
42
+ });
43
+
44
+ const { data: agResult, isLoading: agLoading, error: agError, refetch: refetchAgents } = useQuery({
45
+ queryKey: ["ag-list", hostId],
46
+ queryFn: () => paseo.agents.list({ filter: { includeArchived: true } }),
47
+ refetchInterval: 5000,
48
+ staleTime: 3000,
49
+ });
50
+
51
+ const refetch = useCallback(() => {
52
+ void Promise.all([refetchWorkspaces(), refetchAgents()]);
53
+ }, [refetchWorkspaces, refetchAgents]);
54
+
55
+ const { projects, agentsByWorkspace } = useMemo(() => {
56
+ const workspaces = (wsResult?.entries ?? []).filter(isWorkspaceEntry);
57
+ const agents = (agResult?.entries ?? []) as Array<{ agent: AgentEntry }>;
58
+
59
+ const agentsByWs = new Map<string, AgentEntry[]>();
60
+ for (const entry of agents) {
61
+ const wsId = entry.agent.workspaceId;
62
+ if (!wsId) continue;
63
+ const list = agentsByWs.get(wsId) ?? [];
64
+ list.push(entry.agent);
65
+ agentsByWs.set(wsId, list);
66
+ }
67
+ // Sort agents inside each workspace by last activity, newest first.
68
+ for (const list of agentsByWs.values()) {
69
+ list.sort((a, b) => agentActivityMs(b) - agentActivityMs(a));
70
+ }
71
+
72
+ const byProject = new Map<string, { projectId: string; projectDisplayName: string; workspaces: WorkspaceEntry[] }>();
73
+ for (const ws of workspaces) {
74
+ const pid = ws.projectId || ws.id;
75
+ const existing = byProject.get(pid);
76
+ if (existing) {
77
+ existing.workspaces.push(ws);
78
+ } else {
79
+ byProject.set(pid, {
80
+ projectId: pid,
81
+ projectDisplayName: ws.projectDisplayName || pid,
82
+ workspaces: [ws],
83
+ });
84
+ }
85
+ }
86
+
87
+ // Compute each workspace's "last activity" timestamp (ms since epoch).
88
+ // Prefer the latest agent activity; fall back to workspace activityAt,
89
+ // then statusEnteredAt. Returns 0 when nothing is known (sorts last).
90
+ const workspaceActivity = (ws: WorkspaceEntry): number => {
91
+ const agents = agentsByWs.get(ws.id) ?? [];
92
+ let latest = 0;
93
+ for (const a of agents) {
94
+ const t = agentActivityMs(a);
95
+ if (t > latest) latest = t;
96
+ }
97
+ if (latest > 0) return latest;
98
+ const fallbackIso = ws.activityAt ?? ws.statusEnteredAt;
99
+ if (fallbackIso) {
100
+ const t = new Date(fallbackIso).getTime();
101
+ if (!Number.isNaN(t)) return t;
102
+ }
103
+ return 0;
104
+ };
105
+
106
+ // Sort workspaces inside each project by last activity, newest first.
107
+ for (const project of byProject.values()) {
108
+ project.workspaces.sort((a, b) => workspaceActivity(b) - workspaceActivity(a));
109
+ }
110
+
111
+ // Sort projects by their own last activity (max over their workspaces), newest first.
112
+ const projects = Array.from(byProject.values()).sort((a, b) => {
113
+ const aMax = a.workspaces.reduce((m, ws) => Math.max(m, workspaceActivity(ws)), 0);
114
+ const bMax = b.workspaces.reduce((m, ws) => Math.max(m, workspaceActivity(ws)), 0);
115
+ return bMax - aMax;
116
+ });
117
+
118
+ return { projects, agentsByWorkspace: agentsByWs };
119
+ }, [wsResult, agResult]);
120
+
121
+ return {
122
+ projects,
123
+ agentsByWorkspace,
124
+ isLoading: wsLoading || agLoading,
125
+ error: wsError ?? agError,
126
+ refetch,
127
+ };
128
+ }
@@ -0,0 +1,73 @@
1
+ import { useRpc } from "@getpaseo/plugin/client";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { useMemo } from "react";
4
+
5
+ import type { WorkspaceEntry } from "../../shared/overview-types";
6
+ import { gitBranchesRpc } from "../../shared/git-branches";
7
+ import { projectIconRpc } from "../../shared/project-icon";
8
+
9
+ function projectSourceDirectory(workspaces: WorkspaceEntry[]): string | undefined {
10
+ return workspaces.find((workspace) => workspace.workspaceKind !== "worktree")?.workspaceDirectory
11
+ ?? workspaces[0]?.workspaceDirectory;
12
+ }
13
+
14
+ function currentWorkspaceBranches(workspaces: WorkspaceEntry[]) {
15
+ const branches = new Set<string>();
16
+ for (const workspace of workspaces) {
17
+ const branch = workspace.gitRuntime?.currentBranch?.trim();
18
+ if (branch) branches.add(branch);
19
+ }
20
+ return Array.from(branches, (branch) => ({ id: branch, label: branch, detail: "当前 Workspace 分支" }));
21
+ }
22
+
23
+ export function useProjectOptions(
24
+ hostId: string,
25
+ projects: Array<{ projectId: string; projectDisplayName: string; workspaces: WorkspaceEntry[] }>,
26
+ ) {
27
+ const baseOptions = useMemo(() => projects.flatMap((project) => {
28
+ const projectDirectory = projectSourceDirectory(project.workspaces);
29
+ if (!projectDirectory) return [];
30
+ const branches = currentWorkspaceBranches(project.workspaces);
31
+ return [{
32
+ projectId: project.projectId,
33
+ projectDisplayName: project.projectDisplayName,
34
+ projectDirectory,
35
+ branches,
36
+ defaultBranch: branches[0]?.id ?? null,
37
+ }];
38
+ }), [projects]);
39
+
40
+ const getProjectIcon = useRpc(projectIconRpc);
41
+ const getGitBranches = useRpc(gitBranchesRpc);
42
+ const projectIds = baseOptions.map((item) => item.projectId);
43
+ const projectDirectories = baseOptions.map((item) => item.projectDirectory);
44
+
45
+ const { data: icons = new Map<string, string | null>() } = useQuery({
46
+ queryKey: ["workspace-overview-project-icons", hostId, projectIds],
47
+ queryFn: async () => new Map(await Promise.all(baseOptions.map(async (project) => [
48
+ project.projectId,
49
+ (await getProjectIcon({ projectId: project.projectId, projectDirectory: project.projectDirectory })).dataUri,
50
+ ] as const))),
51
+ enabled: baseOptions.length > 0,
52
+ staleTime: 60_000,
53
+ });
54
+
55
+ const { data: branches = new Map<string, Awaited<ReturnType<typeof getGitBranches>>>() } = useQuery({
56
+ queryKey: ["workspace-overview-git-branches", hostId, projectDirectories],
57
+ queryFn: async () => new Map(await Promise.all(baseOptions.map(async (project) => [
58
+ project.projectId,
59
+ await getGitBranches({ projectDirectory: project.projectDirectory }),
60
+ ] as const))),
61
+ enabled: baseOptions.length > 0,
62
+ staleTime: 10_000,
63
+ });
64
+
65
+ return useMemo(() => baseOptions.map((project) => ({
66
+ ...project,
67
+ branches: branches.get(project.projectId)?.branches ?? project.branches,
68
+ defaultBranch: branches.get(project.projectId)?.defaultBranch ?? project.defaultBranch,
69
+ projectIconDataUri: icons.get(project.projectId) ?? null,
70
+ })), [baseOptions, branches, icons]);
71
+ }
72
+
73
+ export { projectSourceDirectory };
package/client/web.ts ADDED
@@ -0,0 +1,160 @@
1
+ import { Platform } from "react-native";
2
+
3
+ type PointerLike = { clientX?: number; pageX?: number };
4
+ type ClipboardFileLike = {
5
+ name?: string;
6
+ type?: string;
7
+ };
8
+ type ClipboardItemLike = {
9
+ kind?: string;
10
+ type?: string;
11
+ getAsFile?: () => ClipboardFileLike | null;
12
+ };
13
+ type ClipboardEventLike = {
14
+ clipboardData?: { items?: ArrayLike<ClipboardItemLike> | null } | null;
15
+ preventDefault(): void;
16
+ };
17
+ type KeyboardEventLike = {
18
+ key: string;
19
+ isComposing: boolean;
20
+ keyCode: number;
21
+ stopPropagation(): void;
22
+ };
23
+ type ElementListener =
24
+ | ((event: ClipboardEventLike) => void)
25
+ | ((event: KeyboardEventLike) => void)
26
+ | (() => void);
27
+ type ElementLike = {
28
+ addEventListener(type: string, listener: ElementListener, capture?: boolean): void;
29
+ removeEventListener(type: string, listener: ElementListener, capture?: boolean): void;
30
+ };
31
+ type DocumentLike = {
32
+ addEventListener(type: string, listener: (event: PointerLike) => void): void;
33
+ removeEventListener(type: string, listener: (event: PointerLike) => void): void;
34
+ getElementById(id: string): ElementLike | null;
35
+ };
36
+
37
+ declare const document: DocumentLike;
38
+ declare class FileReader {
39
+ result: string | ArrayBuffer | null;
40
+ error: Error | null;
41
+ onload: (() => void) | null;
42
+ onerror: (() => void) | null;
43
+ readAsDataURL(file: ClipboardFileLike): void;
44
+ }
45
+
46
+ export interface PastedImage {
47
+ data: string;
48
+ dataUrl: string;
49
+ mimeType: string;
50
+ fileName: string;
51
+ }
52
+
53
+ const RASTER_IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
54
+
55
+ function readImage(file: ClipboardFileLike, mimeType: string, index: number): Promise<PastedImage> {
56
+ return new Promise((resolve, reject) => {
57
+ const reader = new FileReader();
58
+ reader.onload = () => {
59
+ if (typeof reader.result !== "string") {
60
+ reject(new Error("无法读取粘贴的图片"));
61
+ return;
62
+ }
63
+ const comma = reader.result.indexOf(",");
64
+ if (comma < 0) {
65
+ reject(new Error("粘贴的图片格式无效"));
66
+ return;
67
+ }
68
+ resolve({
69
+ data: reader.result.slice(comma + 1),
70
+ dataUrl: reader.result,
71
+ mimeType,
72
+ fileName: file.name?.trim() || `pasted-image-${index + 1}.${mimeType.split("/")[1] || "png"}`,
73
+ });
74
+ };
75
+ reader.onerror = () => reject(reader.error ?? new Error("无法读取粘贴的图片"));
76
+ reader.readAsDataURL(file);
77
+ });
78
+ }
79
+
80
+ export function subscribeToImagePaste(
81
+ inputNativeID: string,
82
+ onImages: (images: PastedImage[]) => void,
83
+ onError: (message: string) => void,
84
+ ): () => void {
85
+ if (Platform.OS !== "web" || typeof document === "undefined") return () => {};
86
+ const element = document.getElementById(inputNativeID);
87
+ if (!element) return () => {};
88
+
89
+ const handlePaste = (event: ClipboardEventLike) => {
90
+ const files: Array<{ file: ClipboardFileLike; mimeType: string }> = [];
91
+ for (const item of Array.from(event.clipboardData?.items ?? [])) {
92
+ const mimeType = item.type?.toLowerCase() ?? "";
93
+ if (item.kind !== "file" || !RASTER_IMAGE_TYPES.has(mimeType)) continue;
94
+ const file = item.getAsFile?.();
95
+ if (file) files.push({ file, mimeType });
96
+ }
97
+ if (files.length === 0) return;
98
+ event.preventDefault();
99
+ void Promise.all(files.map(({ file, mimeType }, index) => readImage(file, mimeType, index)))
100
+ .then(onImages)
101
+ .catch((error: unknown) => onError(error instanceof Error ? error.message : String(error)));
102
+ };
103
+
104
+ element.addEventListener("paste", handlePaste);
105
+ return () => element.removeEventListener("paste", handlePaste);
106
+ }
107
+
108
+ export function subscribeToImeEnter(inputNativeID: string): () => void {
109
+ if (Platform.OS !== "web" || typeof document === "undefined") return () => {};
110
+ const input = document.getElementById(inputNativeID);
111
+ if (!input) return () => {};
112
+ let composing = false;
113
+ const start = () => { composing = true; };
114
+ const end = () => { composing = false; };
115
+ const keydown = (event: KeyboardEventLike) => {
116
+ if (event.key === "Enter" && (composing || event.isComposing || event.keyCode === 229)) {
117
+ // Keep the IME candidate selection; don't let React submit it.
118
+ event.stopPropagation();
119
+ }
120
+ };
121
+ input.addEventListener("compositionstart", start);
122
+ input.addEventListener("compositionend", end);
123
+ input.addEventListener("keydown", keydown, true);
124
+ return () => {
125
+ input.removeEventListener("compositionstart", start);
126
+ input.removeEventListener("compositionend", end);
127
+ input.removeEventListener("keydown", keydown, true);
128
+ };
129
+ }
130
+
131
+ export function pointerX(event: unknown): number | null {
132
+ if (Platform.OS !== "web" || !event || typeof event !== "object") return null;
133
+ const nativeEvent = (event as { nativeEvent?: PointerLike }).nativeEvent;
134
+ const value = nativeEvent?.clientX ?? nativeEvent?.pageX;
135
+ return typeof value === "number" ? value : null;
136
+ }
137
+
138
+ export function trackHorizontalPointer(
139
+ onMove: (clientX: number) => void,
140
+ onEnd: () => void,
141
+ ): () => void {
142
+ if (Platform.OS !== "web" || typeof document === "undefined") return () => {};
143
+ const move = (event: PointerLike) => {
144
+ const value = event.clientX ?? event.pageX;
145
+ if (typeof value === "number") onMove(value);
146
+ };
147
+ const end = () => {
148
+ cleanup();
149
+ onEnd();
150
+ };
151
+ const cleanup = () => {
152
+ document.removeEventListener("pointermove", move);
153
+ document.removeEventListener("pointerup", end);
154
+ document.removeEventListener("pointercancel", end);
155
+ };
156
+ document.addEventListener("pointermove", move);
157
+ document.addEventListener("pointerup", end);
158
+ document.addEventListener("pointercancel", end);
159
+ return cleanup;
160
+ }
@@ -0,0 +1,71 @@
1
+ import { type PluginWorkspacePanelProps, usePaseo } from "@getpaseo/plugin/client";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import React, { useMemo } from "react";
4
+ import { FlatList, Text, TouchableOpacity, View, type ViewStyle, type TextStyle } from "react-native";
5
+ import { WorkspaceAgentRow } from "./components/WorkspaceAgentRow";
6
+
7
+ export function WorkspaceAgentsPanel({ theme, layout, workspaceId, navigation }: PluginWorkspacePanelProps) {
8
+ const paseo = usePaseo();
9
+
10
+ const { data, isLoading, error } = useQuery({
11
+ queryKey: ["workspace-agents", workspaceId],
12
+ queryFn: async () => {
13
+ const result = await paseo.agents.list({
14
+ filter: { includeArchived: true },
15
+ });
16
+ return result.entries.filter(
17
+ (entry: { agent: { workspaceId?: string } }) => entry.agent.workspaceId === workspaceId,
18
+ );
19
+ },
20
+ });
21
+
22
+ const styles = useMemo(
23
+ () =>
24
+ ({
25
+ screen: { flex: 1, backgroundColor: theme.colors.surface0, padding: layout.compact ? 12 : 16 } as ViewStyle,
26
+ header: { marginBottom: 12 } as ViewStyle,
27
+ title: { color: theme.colors.foreground, fontSize: layout.compact ? 16 : 18, fontWeight: "700" as const } as TextStyle,
28
+ empty: { color: theme.colors.foregroundMuted, textAlign: "center" as const, marginTop: 32 } as TextStyle,
29
+ error: { color: theme.colors.statusDanger, textAlign: "center" as const, marginTop: 16 } as TextStyle,
30
+ }),
31
+ [theme, layout.compact],
32
+ );
33
+
34
+ if (isLoading) {
35
+ return (
36
+ <View style={styles.screen}>
37
+ <Text style={{ color: theme.colors.foregroundMuted }}>Loading agents…</Text>
38
+ </View>
39
+ );
40
+ }
41
+
42
+ if (error) {
43
+ return (
44
+ <View style={styles.screen}>
45
+ <Text style={styles.error}>{error.message}</Text>
46
+ </View>
47
+ );
48
+ }
49
+
50
+ const agents = data ?? [];
51
+
52
+ return (
53
+ <View style={styles.screen}>
54
+ <View style={styles.header}>
55
+ <Text style={styles.title}>Workspace Agents</Text>
56
+ </View>
57
+ {agents.length === 0 ? (
58
+ <Text style={styles.empty}>No agents in this workspace.</Text>
59
+ ) : (
60
+ <FlatList
61
+ data={agents.map((entry: { agent: { id: string; title: string | null; provider: string; model: string | null; status: string; requiresAttention?: boolean; archivedAt?: string | null } }) => entry.agent)}
62
+ keyExtractor={(item) => item.id}
63
+ renderItem={({ item }) => (
64
+ <WorkspaceAgentRow agent={item} theme={theme} compact={layout.compact} navigation={navigation!} />
65
+ )}
66
+ contentContainerStyle={{ paddingBottom: 16 }}
67
+ />
68
+ )}
69
+ </View>
70
+ );
71
+ }
@@ -0,0 +1,13 @@
1
+ import { StyleSheet, type TextStyle, type ViewStyle } from "react-native";
2
+ import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
3
+
4
+ export function createBadgeStyles(theme: PluginSurfaceProps["theme"]) {
5
+ const colors = theme.colors;
6
+ return StyleSheet.create({
7
+ badge: { flexDirection: "row", alignItems: "center", height: 28, maxWidth: 240, overflow: "hidden", borderRadius: 16, gap: 4 } as ViewStyle,
8
+ badgeSelectable: {} as ViewStyle,
9
+ badgeDisabled: { opacity: 0.5 } as ViewStyle,
10
+ badgeIcon: { width: 16, height: 16, alignItems: "center", justifyContent: "center", flexShrink: 0 } as ViewStyle,
11
+ badgeText: { minWidth: 0, fontSize: 14, lineHeight: 20, color: colors.foregroundMuted, flexShrink: 1 } as TextStyle,
12
+ });
13
+ }
@@ -0,0 +1,14 @@
1
+ import type { OpenMenu, TerminalProfile } from "./types";
2
+
3
+ export const PROVIDER_READY_TIMEOUT_MS = 10_000;
4
+ export const MAX_MENU_HEIGHT = 260;
5
+ export const MENU_WIDTH_BY_KIND: Record<Exclude<OpenMenu, null>, number> = {
6
+ project: 360, host: 240, isolation: 220, base: 240, launch: 260,
7
+ provider: 260, model: 340, mode: 220, thinking: 240,
8
+ };
9
+ export const DEFAULT_TERMINAL_PROFILES: TerminalProfile[] = [
10
+ { id: "claude", name: "Claude Code", command: "claude", args: ["{{{prompt}}}"], icon: "claude" },
11
+ { id: "codex", name: "Codex", command: "codex", args: ["{{{prompt}}}"], icon: "codex" },
12
+ { id: "opencode", name: "OpenCode", command: "opencode", args: ["--prompt={{{prompt}}}"], icon: "opencode" },
13
+ { id: "pi", name: "Pi", command: "pi", args: ["{{{prompt}}}"], icon: "pi" },
14
+ ];
@@ -0,0 +1,12 @@
1
+ import { StyleSheet, type TextStyle, type ViewStyle } from "react-native";
2
+ import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
3
+
4
+ export function createControlStyles(theme: PluginSurfaceProps["theme"]) {
5
+ const colors = theme.colors;
6
+ return StyleSheet.create({
7
+ control: { minHeight: 28, maxWidth: 220, flexDirection: "row", alignItems: "center", gap: 4, paddingHorizontal: 8, borderRadius: 14 } as ViewStyle,
8
+ controlOpen: { backgroundColor: colors.foregroundMuted + "12" } as ViewStyle,
9
+ controlDisabled: { opacity: 0.55 } as ViewStyle,
10
+ controlValue: { color: colors.foregroundMuted, fontSize: 14, lineHeight: 18, fontWeight: "400", minWidth: 0, maxWidth: 150 } as TextStyle,
11
+ });
12
+ }
@@ -0,0 +1,51 @@
1
+ import type { ComposerSelection, PaseoProviderModelPreference, ProviderEntry, ProviderModel, ProviderSnapshot, ThinkingOption } from "./types";
2
+
3
+ export function readPaseoProviderModelPreference(): PaseoProviderModelPreference | null { return null; }
4
+ export function readyProviders(snapshot: ProviderSnapshot): ProviderEntry[] {
5
+ return snapshot.entries.filter((entry) => entry.enabled !== false && entry.status === "ready");
6
+ }
7
+ export function selectableModels(entry: ProviderEntry | null): ProviderModel[] {
8
+ return (entry?.models ?? []).filter((model) => model.isSelectable !== false);
9
+ }
10
+ export function defaultModel(entry: ProviderEntry | null): ProviderModel | null {
11
+ const models = selectableModels(entry);
12
+ return models.find((model) => model.isDefault) ?? models[0] ?? null;
13
+ }
14
+ function defaultMode(entry: ProviderEntry | null): { id: string; label?: string } | null {
15
+ if (!entry) return null;
16
+ return entry.modes?.find((mode) => mode.id === entry.defaultModeId) ?? entry.modes?.[0]
17
+ ?? (entry.defaultModeId ? { id: entry.defaultModeId } : null);
18
+ }
19
+ function defaultThinking(model: ProviderModel | null): ThinkingOption | null {
20
+ if (!model) return null;
21
+ return model.thinkingOptions?.find((option) => option.id === model.defaultThinkingOptionId)
22
+ ?? model.thinkingOptions?.find((option) => option.isDefault) ?? model.thinkingOptions?.[0] ?? null;
23
+ }
24
+ export function buildSelection(entry: ProviderEntry, model = defaultModel(entry)): ComposerSelection {
25
+ const mode = defaultMode(entry); const thinking = defaultThinking(model);
26
+ return {
27
+ providerId: entry.provider, providerLabel: entry.label ?? entry.provider,
28
+ modelId: model?.id ?? null, modelLabel: model?.label ?? null,
29
+ modeId: mode?.id ?? null, modeLabel: mode?.label ?? mode?.id ?? null,
30
+ thinkingOptionId: thinking?.id ?? null, thinkingLabel: thinking?.label ?? thinking?.id ?? null,
31
+ };
32
+ }
33
+ export function defaultSelection(snapshot: ProviderSnapshot, preferred: PaseoProviderModelPreference | null = readPaseoProviderModelPreference()): ComposerSelection | null {
34
+ const entries = readyProviders(snapshot);
35
+ const preferredProviderId = preferred?.providerId ?? null;
36
+ const preferredModelId = preferred?.modelId ?? null;
37
+ const preferredEntry = preferredProviderId ? entries.find((item) => item.provider === preferredProviderId) ?? null : null;
38
+ if (preferredEntry) {
39
+ const preferredModel = preferredModelId ? selectableModels(preferredEntry).find((model) => model.id === preferredModelId) ?? null : null;
40
+ return buildSelection(preferredEntry, preferredModel ?? defaultModel(preferredEntry));
41
+ }
42
+ const entry = entries.find((item) => item.provider === "codex")
43
+ ?? entries.find((item) => item.provider.toLowerCase().includes("codex")) ?? entries[0] ?? null;
44
+ return entry ? buildSelection(entry) : null;
45
+ }
46
+ export function providerModelId(selection: ComposerSelection): string {
47
+ return selection.modelId ? `${selection.providerId}/${selection.modelId}` : selection.providerId;
48
+ }
49
+ export function providerById(snapshot: ProviderSnapshot | null, providerId: string): ProviderEntry | null {
50
+ return readyProviders(snapshot ?? { entries: [] }).find((entry) => entry.provider === providerId) ?? null;
51
+ }
@@ -0,0 +1,67 @@
1
+ import { PROVIDER_READY_TIMEOUT_MS } from "./constants";
2
+ import { defaultSelection, providerModelId } from "./provider-selection";
3
+ import type { ComposerSelection, Isolation, LaunchTarget, PaseoClient, ProviderSnapshot, TerminalProfile, WorkspaceProjectOption } from "./types";
4
+ import type { PastedImage } from "../web";
5
+
6
+ function createWorktreeSlug(): string { return `workspace-${Date.now().toString(36)}`; }
7
+
8
+ export async function ensureSelection(input: { paseo: PaseoClient; projectDirectory: string; snapshot: ProviderSnapshot | null; selection: ComposerSelection | null }): Promise<ComposerSelection> {
9
+ if (input.selection) return input.selection;
10
+ const snapshot = input.snapshot ?? await input.paseo.providers.waitForReady({ cwd: input.projectDirectory, timeoutMs: PROVIDER_READY_TIMEOUT_MS });
11
+ const selection = defaultSelection(snapshot);
12
+ if (!selection) throw new Error("没有可用的 provider");
13
+ return selection;
14
+ }
15
+
16
+ function workspaceSource(project: WorkspaceProjectOption, isolation: Isolation, baseBranch: string) {
17
+ return isolation === "worktree"
18
+ ? { kind: "worktree" as const, cwd: project.projectDirectory, projectId: project.projectId, worktreeSlug: createWorktreeSlug(), action: "branch-off" as const, baseBranch }
19
+ : { kind: "directory" as const, path: project.projectDirectory, projectId: project.projectId };
20
+ }
21
+
22
+ interface SubmitWorkspaceInput {
23
+ project: WorkspaceProjectOption | null; isolation: Isolation; baseBranch: string; paseo: PaseoClient;
24
+ snapshot: ProviderSnapshot | null; selection: ComposerSelection | null; launchTarget: LaunchTarget;
25
+ terminalProfiles: TerminalProfile[];
26
+ launchTerminal: (input: { workspaceDirectory: string; workspaceId: string; serverId: string; prompt: string; profile: { name: string; command: string; args: string[] } | null }) => Promise<{ terminalId: string }>;
27
+ openAgent: (agentId: string) => void | Promise<unknown>; serverId: string;
28
+ setSelection: (selection: ComposerSelection) => void; setPending: (pending: boolean) => void;
29
+ setError: (error: string | null) => void; onDone: () => void;
30
+ images: PastedImage[];
31
+ }
32
+
33
+ export async function submitWorkspacePrompt(input: SubmitWorkspaceInput, rawPrompt: string): Promise<boolean> {
34
+ const prompt = rawPrompt.trim();
35
+ if ((!prompt && input.images.length === 0) || !input.project) return false;
36
+ input.setPending(true); input.setError(null);
37
+ try {
38
+ const source = workspaceSource(input.project, input.isolation, input.baseBranch);
39
+ if (input.launchTarget.kind === "terminal") {
40
+ if (!prompt) return false;
41
+ const profileId = input.launchTarget.profileId;
42
+ const workspace = await input.paseo.workspaces.create({ source });
43
+ const current = workspace.current() ?? await workspace.refresh();
44
+ const workspaceDirectory = current?.workspaceDirectory ?? workspace.directory;
45
+ if (!workspaceDirectory) throw new Error("创建的 Workspace 没有可用目录");
46
+ const profile = profileId === "blank" ? null : input.terminalProfiles.find((item) => item.id === profileId) ?? null;
47
+ await input.launchTerminal({ workspaceDirectory, workspaceId: workspace.id, serverId: input.serverId, prompt, profile: profile ? { name: profile.name, command: profile.command, args: profile.args } : null });
48
+ } else {
49
+ const selection = await ensureSelection({ paseo: input.paseo, projectDirectory: input.project.projectDirectory, snapshot: input.snapshot, selection: input.selection });
50
+ input.setSelection(selection);
51
+ const workspace = await input.paseo.workspaces.create({ source, ...(prompt ? { firstAgentContext: { prompt } } : {}) });
52
+ const agent = await workspace.agents.create({
53
+ config: { provider: providerModelId(selection), ...(selection.modeId ? { modeId: selection.modeId } : {}), ...(selection.thinkingOptionId ? { thinkingOptionId: selection.thinkingOptionId } : {}) },
54
+ prompt,
55
+ images: input.images.map(({ data, mimeType }) => ({ data, mimeType })),
56
+ });
57
+ await input.openAgent(agent.id);
58
+ }
59
+ input.onDone();
60
+ return true;
61
+ } catch (error) {
62
+ input.setError(error instanceof Error ? error.message : String(error));
63
+ return false;
64
+ } finally {
65
+ input.setPending(false);
66
+ }
67
+ }
@@ -0,0 +1,40 @@
1
+ import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
2
+
3
+ export type PaseoClient = ReturnType<typeof import("@getpaseo/plugin/client").usePaseo>;
4
+ export type OpenMenu = "project" | "host" | "isolation" | "base" | "launch" | "provider" | "model" | "mode" | "thinking" | null;
5
+ export type Isolation = "local" | "worktree";
6
+ export type LaunchTarget = { kind: "chat" } | { kind: "terminal"; profileId: string };
7
+
8
+ export interface TerminalProfile { id: string; name: string; command: string; args: string[]; icon?: string }
9
+ export interface GitBranchOption { id: string; label: string; detail: string }
10
+ export interface WorkspaceProjectOption {
11
+ projectId: string; projectDisplayName: string; projectDirectory: string;
12
+ branches: GitBranchOption[]; defaultBranch: string | null; projectIconDataUri?: string | null;
13
+ }
14
+ export interface ThinkingOption { id: string; label?: string; isDefault?: boolean }
15
+ export interface ProviderModel {
16
+ id: string; label: string; isSelectable?: boolean; isDefault?: boolean;
17
+ defaultThinkingOptionId?: string; thinkingOptions?: ThinkingOption[];
18
+ }
19
+ export interface ProviderEntry {
20
+ provider: string; status: "ready" | "loading" | "error" | "unavailable" | string;
21
+ enabled?: boolean; label?: string; error?: string; defaultModeId?: string | null;
22
+ modes?: Array<{ id: string; label?: string; description?: string; icon?: string }>;
23
+ models?: ProviderModel[];
24
+ }
25
+ export interface ProviderSnapshot { entries: ProviderEntry[] }
26
+ export interface ComposerSelection {
27
+ providerId: string; providerLabel: string; modelId: string | null; modelLabel: string | null;
28
+ modeId: string | null; modeLabel: string | null; thinkingOptionId: string | null; thinkingLabel: string | null;
29
+ }
30
+ export interface PaseoProviderModelPreference { providerId: string; modelId: string | null }
31
+ export interface WorkspaceCreatorPanelProps {
32
+ projectId: string; projectDisplayName: string; projectDirectory: string | undefined;
33
+ hostId: string; navigation: NonNullable<PluginSurfaceProps["navigation"]>;
34
+ projects: WorkspaceProjectOption[]; paseo: PaseoClient; onClose: () => void; onCreate?: () => void;
35
+ initialPanelWidth?: number; maxPanelWidth?: number;
36
+ theme: PluginSurfaceProps["theme"]; layout: PluginSurfaceProps["layout"];
37
+ }
38
+ export interface MenuOption {
39
+ id: string; label: string; detail?: string; iconName?: string; projectIconDataUri?: string | null;
40
+ }
@@ -0,0 +1,15 @@
1
+ export function formatControlValue(value: string | null, fallback: string): string {
2
+ return value && value.trim() ? value : fallback;
3
+ }
4
+ export function opaqueSurfaceColor(surface: string, foreground: string): string {
5
+ const rgba = surface.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
6
+ if (rgba) return `rgb(${rgba[1]}, ${rgba[2]}, ${rgba[3]})`;
7
+ if (/^#[0-9a-f]{8}$/i.test(surface)) return surface.slice(0, 7);
8
+ if (/^#[0-9a-f]{6}$/i.test(surface)) return surface;
9
+ const foregroundHex = foreground.match(/^#([0-9a-f]{6})$/i)?.[1];
10
+ const foregroundRgb = foreground.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
11
+ const red = foregroundHex ? Number.parseInt(foregroundHex.slice(0, 2), 16) : Number(foregroundRgb?.[1] ?? 0);
12
+ const green = foregroundHex ? Number.parseInt(foregroundHex.slice(2, 4), 16) : Number(foregroundRgb?.[2] ?? 0);
13
+ const blue = foregroundHex ? Number.parseInt(foregroundHex.slice(4, 6), 16) : Number(foregroundRgb?.[3] ?? 0);
14
+ return (red * 299 + green * 587 + blue * 114) / 1000 > 128 ? "#171717" : "#ffffff";
15
+ }