@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,170 @@
1
+ import React, { useMemo } from "react";
2
+ import { Text, View } from "react-native";
3
+ import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
4
+ import { Icon } from "@getpaseo/plugin/client/react-native";
5
+ import { Badge } from "./WorkspaceBadge";
6
+ import { Menu } from "./WorkspaceMenu";
7
+ import type { ComposerSelection, Isolation, LaunchTarget, MenuOption, OpenMenu, ProviderSnapshot, TerminalProfile, WorkspaceProjectOption } from "../workspace-creator/types";
8
+ import { createComposerStyles } from "./workspace-creator-styles";
9
+ import { ProviderBrandIcon } from "./ProviderBrandIcon";
10
+ import { WorkspaceLaunchMenu } from "./WorkspaceLaunchMenu";
11
+ import { SharedComposerInput } from "./SharedComposerInput";
12
+ import type { PastedImage } from "../web";
13
+
14
+ export function WorkspaceCreateComposer({
15
+ project,
16
+ isolation,
17
+ baseBranch,
18
+ pending,
19
+ error,
20
+ providerLoading,
21
+ snapshot,
22
+ selection,
23
+ launchTarget,
24
+ terminalProfiles,
25
+ openMenu,
26
+ onToggleMenu,
27
+ onSelectIsolation,
28
+ onSelectBase,
29
+ onSelectModel,
30
+ onSelectMode,
31
+ onSelectThinking,
32
+ onSelectLaunchTarget,
33
+ theme,
34
+ prompt,
35
+ onPromptChange,
36
+ onSubmit,
37
+ compact,
38
+ images,
39
+ onImagesChange,
40
+ }: {
41
+ project: WorkspaceProjectOption | null;
42
+ isolation: Isolation;
43
+ baseBranch: string;
44
+ pending: boolean;
45
+ error: string | null;
46
+ providerLoading: boolean;
47
+ snapshot: ProviderSnapshot | null;
48
+ selection: ComposerSelection | null;
49
+ launchTarget: LaunchTarget;
50
+ terminalProfiles: TerminalProfile[];
51
+ openMenu: OpenMenu;
52
+ onToggleMenu: (menu: OpenMenu) => void;
53
+ onSelectIsolation: (isolation: Isolation) => void;
54
+ onSelectBase: (branch: string) => void;
55
+ onSelectModel: (modelId: string) => void;
56
+ onSelectMode: (modeId: string) => void;
57
+ onSelectThinking: (thinkingId: string) => void;
58
+ onSelectLaunchTarget: (target: LaunchTarget) => void;
59
+ theme: PluginSurfaceProps["theme"];
60
+ prompt: string;
61
+ onPromptChange: (value: string) => void;
62
+ onSubmit: () => void | Promise<void>;
63
+ compact: boolean;
64
+ images: PastedImage[];
65
+ onImagesChange: (images: PastedImage[]) => void;
66
+ }) {
67
+ const styles = useMemo(() => createComposerStyles(theme), [theme]);
68
+
69
+ const selectedTerminalProfile = launchTarget.kind === "terminal"
70
+ ? terminalProfiles.find((profile) => profile.id === launchTarget.profileId) ?? null
71
+ : null;
72
+ const launchLabel = launchTarget.kind === "chat"
73
+ ? "Chat"
74
+ : selectedTerminalProfile ? `Terminal ${selectedTerminalProfile.name}` : "Terminal";
75
+
76
+ const isolationOptions: MenuOption[] = [
77
+ { id: "worktree", label: "新建 worktree", detail: "创建隔离分支 workspace", iconName: "GitBranch" },
78
+ { id: "local", label: "Local", detail: "在项目目录中创建 workspace", iconName: "Folder" },
79
+ ];
80
+ const baseOptions = (project?.branches.length
81
+ ? project.branches
82
+ : [{ id: "main", label: "main", detail: "本地分支" }]
83
+ ).map((branch) => ({
84
+ id: branch.id,
85
+ label: branch.label,
86
+ detail: branch.detail,
87
+ iconName: "GitBranch",
88
+ }));
89
+ const topMenuOpen = openMenu === "isolation" || openMenu === "base" || openMenu === "launch";
90
+ if (!project) return null;
91
+
92
+ return (
93
+ <View style={[styles.panel, compact && { paddingHorizontal: 0, paddingVertical: 4, borderTopWidth: 0 }]}>
94
+ <View style={styles.content}>
95
+ <View style={[styles.formStackDesktop, compact && { flexWrap: "wrap", marginBottom: 16 }, topMenuOpen && styles.menuRegionActive]}>
96
+ <View nativeID="workspace-create-dropdown-isolation" style={[styles.controlAnchor, openMenu === "isolation" && styles.controlAnchorOpen]}>
97
+ <Badge
98
+ icon={<Icon name="GitBranch" color={theme.colors.foregroundMuted} size={16} />}
99
+ label={isolation === "worktree" ? "新建 worktree" : "Local"}
100
+ selectable
101
+ disabled={pending}
102
+ onPress={() => onToggleMenu(openMenu === "isolation" ? null : "isolation")}
103
+ theme={theme}
104
+ />
105
+ {openMenu === "isolation" ? (
106
+ <Menu kind="isolation" placement="above" options={isolationOptions} selectedId={isolation} onSelect={(id) => onSelectIsolation(id === "local" ? "local" : "worktree")} theme={theme} />
107
+ ) : null}
108
+ </View>
109
+ <View nativeID="workspace-create-dropdown-base" style={[styles.controlAnchor, openMenu === "base" && styles.controlAnchorOpen]}>
110
+ <Badge
111
+ icon={<Icon name="GitBranch" color={theme.colors.foregroundMuted} size={16} />}
112
+ label={isolation === "worktree" ? baseBranch : project.projectDisplayName}
113
+ selectable={isolation === "worktree" && baseOptions.length > 1}
114
+ disabled={pending || isolation !== "worktree"}
115
+ onPress={() => onToggleMenu(openMenu === "base" ? null : "base")}
116
+ theme={theme}
117
+ />
118
+ {openMenu === "base" ? (
119
+ <Menu kind="base" placement="above" options={baseOptions} selectedId={baseBranch} onSelect={onSelectBase} theme={theme} />
120
+ ) : null}
121
+ </View>
122
+ <View style={styles.launchSpacer} />
123
+ <View nativeID="workspace-create-dropdown-launch" style={[styles.controlAnchor, openMenu === "launch" && styles.controlAnchorOpen]}>
124
+ <Badge
125
+ icon={launchTarget.kind === "chat"
126
+ ? <Icon name="MessageCircle" color={theme.colors.foregroundMuted} size={16} />
127
+ : selectedTerminalProfile
128
+ ? <ProviderBrandIcon providerId={selectedTerminalProfile.icon ?? selectedTerminalProfile.id} color={theme.colors.foregroundMuted} size={16} />
129
+ : <Icon name="SquareTerminal" color={theme.colors.foregroundMuted} size={16} />}
130
+ label={launchLabel}
131
+ selectable
132
+ disabled={pending}
133
+ onPress={() => onToggleMenu(openMenu === "launch" ? null : "launch")}
134
+ theme={theme}
135
+ />
136
+ {openMenu === "launch" ? (
137
+ <WorkspaceLaunchMenu target={launchTarget} profiles={terminalProfiles} onSelect={onSelectLaunchTarget} theme={theme} />
138
+ ) : null}
139
+ </View>
140
+ </View>
141
+
142
+ <SharedComposerInput
143
+ selection={selection}
144
+ snapshot={snapshot}
145
+ providerLoading={providerLoading}
146
+ openMenu={openMenu}
147
+ onToggleMenu={onToggleMenu}
148
+ onSelectModel={onSelectModel}
149
+ onSelectMode={onSelectMode}
150
+ onSelectThinking={onSelectThinking}
151
+ isAgentRunning={false}
152
+ isCancelling={false}
153
+ onCancel={() => {}}
154
+ placeholder={launchTarget.kind === "chat" ? "给 Agent 发消息,标记 @files,或使用 /commands 和 /skills" : selectedTerminalProfile ? "输入启动提示词" : "输入终端命令"}
155
+ theme={theme}
156
+ disabled={pending}
157
+ showAgentControls={launchTarget.kind === "chat"}
158
+ autoFocus
159
+ pendingLabel={pending ? "Sending" : null}
160
+ value={prompt}
161
+ onChangeText={onPromptChange}
162
+ onSubmit={onSubmit}
163
+ images={images}
164
+ onImagesChange={onImagesChange}
165
+ />
166
+ {error ? <Text style={styles.errorText}>{error}</Text> : null}
167
+ </View>
168
+ </View>
169
+ );
170
+ }
@@ -0,0 +1,256 @@
1
+ import React, { useCallback, useMemo, useState } from "react";
2
+ import { useRpc } from "@getpaseo/plugin/client";
3
+ import { Icon } from "@getpaseo/plugin/client/react-native";
4
+ import { Pressable, Text, View, type TextStyle, type ViewStyle } from "react-native";
5
+ import { WorkspaceCreateComposer } from "./WorkspaceCreateComposer";
6
+ import { ResizeHandle } from "./ResizeHandle";
7
+ import { buildSelection, providerById, selectableModels } from "../workspace-creator/provider-selection";
8
+ import { submitWorkspacePrompt } from "../workspace-creator/submit-workspace";
9
+ import type { Isolation, LaunchTarget, OpenMenu, WorkspaceCreatorPanelProps } from "../workspace-creator/types";
10
+ import { terminalLaunchRpc } from "../../shared/terminal-launch";
11
+ import { useProviderCatalog, useTerminalProfiles } from "../workspace-creator/useProviderCatalog";
12
+ import { useKeyboardInset } from "../hooks/useKeyboardInset";
13
+ import type { PastedImage } from "../web";
14
+
15
+ export function WorkspaceCreatorPanel({
16
+ projectId,
17
+ projectDisplayName,
18
+ projectDirectory,
19
+ hostId,
20
+ navigation,
21
+ projects,
22
+ paseo,
23
+ onClose,
24
+ onCreate,
25
+ initialPanelWidth = 480,
26
+ maxPanelWidth = 800,
27
+ theme,
28
+ layout,
29
+ }: WorkspaceCreatorPanelProps) {
30
+ const [pending, setPending] = useState(false);
31
+ const [error, setError] = useState<string | null>(null);
32
+ const [openMenu, setOpenMenu] = useState<OpenMenu>(null);
33
+ const [prompt, setPrompt] = useState("");
34
+ const [images, setImages] = useState<PastedImage[]>([]);
35
+ const [isolation, setIsolation] = useState<Isolation>("worktree");
36
+ const [baseBranch, setBaseBranch] = useState(() => {
37
+ const initialProject = projects.find((project) => project.projectId === projectId);
38
+ return initialProject?.defaultBranch ?? initialProject?.branches[0]?.id ?? "main";
39
+ });
40
+ const [launchTarget, setLaunchTarget] = useState<LaunchTarget>({ kind: "chat" });
41
+ const launchTerminal = useRpc(terminalLaunchRpc);
42
+ const keyboardInset = useKeyboardInset(layout.compact);
43
+
44
+ const selectedProject = useMemo(
45
+ () =>
46
+ projects.find((project) => project.projectId === projectId) ?? {
47
+ projectId,
48
+ projectDisplayName,
49
+ projectDirectory: projectDirectory ?? "",
50
+ branches: [{ id: "main", label: "main", detail: "本地分支" }],
51
+ defaultBranch: "main",
52
+ },
53
+ [projectDirectory, projectDisplayName, projectId, projects],
54
+ );
55
+ const terminalProfiles = useTerminalProfiles(paseo);
56
+ const providerCatalog = useProviderCatalog(paseo, selectedProject.projectDirectory);
57
+ const { snapshot, selection, setSelection, loading: providerLoading } = providerCatalog;
58
+ const visibleError = error ?? providerCatalog.error;
59
+
60
+ const selectBase = useCallback((branch: string) => {
61
+ setBaseBranch(branch);
62
+ setOpenMenu(null);
63
+ }, []);
64
+
65
+ const selectIsolation = useCallback((nextIsolation: Isolation) => {
66
+ setIsolation(nextIsolation);
67
+ setOpenMenu(null);
68
+ }, []);
69
+
70
+ const selectModel = useCallback(
71
+ (combinedId: string) => {
72
+ const separator = combinedId.indexOf("::");
73
+ const providerId = separator >= 0 ? combinedId.slice(0, separator) : selection?.providerId ?? "";
74
+ const modelId = separator >= 0 ? combinedId.slice(separator + 2) : combinedId;
75
+ const entry = providerById(snapshot, providerId);
76
+ const model = selectableModels(entry).find((item) => item.id === modelId) ?? null;
77
+ if (entry && model) {
78
+ setSelection(buildSelection(entry, model));
79
+ }
80
+ setOpenMenu(null);
81
+ },
82
+ [selection?.providerId, snapshot],
83
+ );
84
+
85
+ const selectMode = useCallback(
86
+ (modeId: string) => {
87
+ const entry = providerById(snapshot, selection?.providerId ?? "");
88
+ const mode = entry?.modes?.find((item) => item.id === modeId);
89
+ setSelection((current) =>
90
+ current
91
+ ? {
92
+ ...current,
93
+ modeId,
94
+ modeLabel: mode?.label ?? modeId,
95
+ }
96
+ : current,
97
+ );
98
+ setOpenMenu(null);
99
+ },
100
+ [selection?.providerId, snapshot],
101
+ );
102
+
103
+ const selectThinking = useCallback(
104
+ (thinkingOptionId: string) => {
105
+ const entry = providerById(snapshot, selection?.providerId ?? "");
106
+ const model = selectableModels(entry).find((item) => item.id === selection?.modelId);
107
+ const thinking = model?.thinkingOptions?.find((item) => item.id === thinkingOptionId);
108
+ setSelection((current) =>
109
+ current
110
+ ? {
111
+ ...current,
112
+ thinkingOptionId,
113
+ thinkingLabel: thinking?.label ?? thinkingOptionId,
114
+ }
115
+ : current,
116
+ );
117
+ setOpenMenu(null);
118
+ },
119
+ [selection?.modelId, selection?.providerId, snapshot],
120
+ );
121
+
122
+ const submit = useCallback(async () => {
123
+ const submitted = await submitWorkspacePrompt({
124
+ project: selectedProject.projectDirectory ? selectedProject : null,
125
+ isolation,
126
+ baseBranch,
127
+ paseo,
128
+ snapshot,
129
+ selection,
130
+ launchTarget,
131
+ terminalProfiles,
132
+ launchTerminal,
133
+ openAgent: (agentId) => navigation.openAgent({ agentId }),
134
+ serverId: hostId,
135
+ setSelection,
136
+ setPending,
137
+ setError,
138
+ onDone: () => {
139
+ onCreate?.();
140
+ onClose();
141
+ },
142
+ images,
143
+ }, prompt);
144
+ if (submitted) {
145
+ setPrompt("");
146
+ setImages([]);
147
+ }
148
+ },
149
+ [baseBranch, hostId, images, isolation, launchTarget, launchTerminal, navigation, onClose, onCreate, paseo, prompt, selectedProject, selection, snapshot, terminalProfiles],
150
+ );
151
+
152
+ const close = useCallback(() => {
153
+ if (pending) return;
154
+ setOpenMenu(null);
155
+ setError(null);
156
+ onClose();
157
+ }, [onClose, pending]);
158
+
159
+ const panelStyles = useMemo(() => ({
160
+ panel: {
161
+ height: "100%",
162
+ borderLeftWidth: 1,
163
+ borderLeftColor: theme.colors.foregroundMuted + "22",
164
+ backgroundColor: theme.colors.surface0,
165
+ } as ViewStyle,
166
+ header: {
167
+ height: 54,
168
+ paddingHorizontal: 14,
169
+ flexDirection: "row",
170
+ alignItems: "center",
171
+ gap: 8,
172
+ borderBottomWidth: 1,
173
+ borderBottomColor: theme.colors.foregroundMuted + "22",
174
+ } as ViewStyle,
175
+ title: {
176
+ flex: 1,
177
+ color: theme.colors.foreground,
178
+ fontSize: 14,
179
+ fontWeight: "600",
180
+ } as TextStyle,
181
+ closeButton: {
182
+ width: 30,
183
+ height: 30,
184
+ alignItems: "center",
185
+ justifyContent: "center",
186
+ } as ViewStyle,
187
+ body: {
188
+ flex: 1,
189
+ justifyContent: "flex-end",
190
+ paddingHorizontal: layout.compact ? 12 : 20,
191
+ paddingBottom: layout.compact ? 12 + keyboardInset : 20,
192
+ } as ViewStyle,
193
+ }), [keyboardInset, layout.compact, theme]);
194
+
195
+ const content = (
196
+ <>
197
+ <View style={panelStyles.header}>
198
+ <Icon name="Plus" size={17} color={theme.colors.foregroundMuted} />
199
+ <Text numberOfLines={1} style={panelStyles.title}>新建 Workspace</Text>
200
+ <Pressable accessibilityLabel="关闭新建 Workspace" disabled={pending} onPress={close} style={panelStyles.closeButton}>
201
+ <Icon name="X" size={16} color={theme.colors.foregroundMuted} />
202
+ </Pressable>
203
+ </View>
204
+ <View style={panelStyles.body}>
205
+ <WorkspaceCreateComposer
206
+ project={selectedProject.projectDirectory ? selectedProject : null}
207
+ isolation={isolation}
208
+ baseBranch={baseBranch}
209
+ pending={pending}
210
+ error={visibleError}
211
+ providerLoading={providerLoading}
212
+ snapshot={snapshot}
213
+ selection={selection}
214
+ launchTarget={launchTarget}
215
+ terminalProfiles={terminalProfiles}
216
+ openMenu={openMenu}
217
+ onToggleMenu={setOpenMenu}
218
+ onSelectIsolation={selectIsolation}
219
+ onSelectBase={selectBase}
220
+ onSelectModel={selectModel}
221
+ onSelectMode={selectMode}
222
+ onSelectThinking={selectThinking}
223
+ onSelectLaunchTarget={(target) => {
224
+ setLaunchTarget(target);
225
+ setOpenMenu(null);
226
+ }}
227
+ theme={theme}
228
+ prompt={prompt}
229
+ onPromptChange={setPrompt}
230
+ onSubmit={submit}
231
+ compact={layout.compact}
232
+ images={launchTarget.kind === "chat" ? images : []}
233
+ onImagesChange={launchTarget.kind === "chat" ? setImages : () => {}}
234
+ />
235
+ </View>
236
+ </>
237
+ );
238
+
239
+ if (layout.compact) {
240
+ return <View style={[panelStyles.panel, { flex: 1, width: "100%" }]}>{content}</View>;
241
+ }
242
+
243
+ return (
244
+ <ResizeHandle
245
+ theme={theme}
246
+ side="left"
247
+ variant="grip"
248
+ initialWidth={initialPanelWidth}
249
+ minWidth={360}
250
+ maxWidth={maxPanelWidth}
251
+ style={panelStyles.panel}
252
+ >
253
+ {content}
254
+ </ResizeHandle>
255
+ );
256
+ }
@@ -0,0 +1,76 @@
1
+ import React, { useMemo } from "react";
2
+ import { Pressable, Text, View } from "react-native";
3
+ import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
4
+ import { Icon, ScrollView } from "@getpaseo/plugin/client/react-native";
5
+ import { opaqueSurfaceColor } from "../workspace-creator/ui-utils";
6
+ import { createMenuStyles } from "./workspace-creator-styles";
7
+ import { ProviderBrandIcon } from "./ProviderBrandIcon";
8
+ import type { LaunchTarget, TerminalProfile } from "../workspace-creator/types";
9
+
10
+ export function WorkspaceLaunchMenu({
11
+ target,
12
+ profiles,
13
+ onSelect,
14
+ theme,
15
+ }: {
16
+ target: LaunchTarget;
17
+ profiles: TerminalProfile[];
18
+ onSelect: (target: LaunchTarget) => void;
19
+ theme: PluginSurfaceProps["theme"];
20
+ }) {
21
+ const styles = useMemo(() => createMenuStyles(theme), [theme]);
22
+ const backgroundColor = opaqueSurfaceColor(theme.colors.surface0, theme.colors.foreground);
23
+
24
+ return (
25
+ <View style={[styles.menu, styles.menuAbove, { left: 0, width: 260, maxHeight: 360, backgroundColor }]}>
26
+ <ScrollView style={{ maxHeight: 350 }} contentContainerStyle={{ backgroundColor }}>
27
+ <Pressable
28
+ accessibilityRole="menuitem"
29
+ accessibilityState={{ selected: target.kind === "chat" }}
30
+ onPress={() => onSelect({ kind: "chat" })}
31
+ style={[styles.menuItem, target.kind === "chat" && styles.menuItemSelected, { backgroundColor }]}
32
+ >
33
+ <View style={styles.menuIconBox}>
34
+ <Icon name="MessageCircle" color={theme.colors.foreground} size={16} />
35
+ </View>
36
+ <View style={styles.menuTextGroup}>
37
+ <Text style={styles.menuLabel}>Chat</Text>
38
+ </View>
39
+ {target.kind === "chat" ? <Icon name="Check" color={theme.colors.accent} size={13} /> : null}
40
+ </Pressable>
41
+ <View style={styles.modelBrowserHeading}><Text style={styles.modelBrowserSectionLabel}>Terminal</Text></View>
42
+ <Pressable
43
+ accessibilityRole="menuitem"
44
+ accessibilityState={{ selected: target.kind === "terminal" && target.profileId === "blank" }}
45
+ onPress={() => onSelect({ kind: "terminal", profileId: "blank" })}
46
+ style={[styles.menuItem, target.kind === "terminal" && target.profileId === "blank" && styles.menuItemSelected, { backgroundColor }]}
47
+ >
48
+ <View style={styles.menuIconBox}><Icon name="SquareTerminal" color={theme.colors.foreground} size={16} /></View>
49
+ <View style={styles.menuTextGroup}><Text style={styles.menuLabel}>Terminal</Text></View>
50
+ {target.kind === "terminal" && target.profileId === "blank" ? <Icon name="Check" color={theme.colors.accent} size={13} /> : null}
51
+ </Pressable>
52
+ {profiles.map((profile) => {
53
+ const selected = target.kind === "terminal" && target.profileId === profile.id;
54
+ return (
55
+ <Pressable
56
+ key={profile.id}
57
+ accessibilityRole="menuitem"
58
+ accessibilityState={{ selected }}
59
+ onPress={() => onSelect({ kind: "terminal", profileId: profile.id })}
60
+ style={[styles.menuItem, selected && styles.menuItemSelected, { backgroundColor }]}
61
+ >
62
+ <View style={styles.menuIconBox}>
63
+ <ProviderBrandIcon providerId={profile.icon ?? profile.id} color={theme.colors.foreground} size={16} />
64
+ </View>
65
+ <View style={styles.menuTextGroup}>
66
+ <Text style={styles.menuLabel} numberOfLines={1}>{profile.name}</Text>
67
+ <Text style={styles.menuDetail} numberOfLines={1}>{[profile.command, ...profile.args].join(" ")}</Text>
68
+ </View>
69
+ {selected ? <Icon name="Check" color={theme.colors.accent} size={13} /> : null}
70
+ </Pressable>
71
+ );
72
+ })}
73
+ </ScrollView>
74
+ </View>
75
+ );
76
+ }
@@ -0,0 +1,116 @@
1
+ import React, { useMemo, useState } from "react";
2
+ import { Pressable, Text, useWindowDimensions, View } from "react-native";
3
+ import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
4
+ import { Icon, ScrollView, TextInput } from "@getpaseo/plugin/client/react-native";
5
+ import { MAX_MENU_HEIGHT, MENU_WIDTH_BY_KIND } from "../workspace-creator/constants";
6
+ import { opaqueSurfaceColor } from "../workspace-creator/ui-utils";
7
+ import type { MenuOption, OpenMenu } from "../workspace-creator/types";
8
+ import { createMenuStyles } from "./workspace-creator-styles";
9
+ import { ProjectIcon } from "./ProjectIcon";
10
+
11
+ export function Menu({
12
+ kind,
13
+ placement = "above",
14
+ options,
15
+ selectedId,
16
+ onSelect,
17
+ theme,
18
+ }: {
19
+ kind: Exclude<OpenMenu, null>;
20
+ placement?: "above" | "below";
21
+ options: MenuOption[];
22
+ selectedId: string | null;
23
+ onSelect: (id: string) => void;
24
+ theme: PluginSurfaceProps["theme"];
25
+ }) {
26
+ const styles = useMemo(() => createMenuStyles(theme), [theme]);
27
+ const [searchQuery, setSearchQuery] = useState("");
28
+ const window = useWindowDimensions();
29
+ const menuBackground = opaqueSurfaceColor(theme.colors.surface0, theme.colors.foreground);
30
+ const normalizedQuery = searchQuery.trim().toLocaleLowerCase();
31
+ const filteredOptions = useMemo(
32
+ () =>
33
+ normalizedQuery
34
+ ? options.filter((option) =>
35
+ [option.label, option.detail, option.id].some((value) =>
36
+ value?.toLocaleLowerCase().includes(normalizedQuery),
37
+ ),
38
+ )
39
+ : options,
40
+ [normalizedQuery, options],
41
+ );
42
+ const menuWidth = Math.min(MENU_WIDTH_BY_KIND[kind], Math.max(180, window.width - 80));
43
+ const menuHeight = Math.min(
44
+ placement === "below" ? 160 : MAX_MENU_HEIGHT,
45
+ Math.max(120, window.height * 0.45),
46
+ );
47
+ return (
48
+ <View
49
+ style={[
50
+ styles.menu,
51
+ placement === "below" ? styles.menuBelow : styles.menuAbove,
52
+ { left: 0 },
53
+ { width: menuWidth, maxHeight: menuHeight },
54
+ ]}
55
+ >
56
+ <View style={styles.menuSearchRow}>
57
+ <Icon name="Search" color={theme.colors.foregroundMuted} size={15} />
58
+ <TextInput
59
+ nativeID={`workspace-create-menu-search-${kind}`}
60
+ autoFocus
61
+ value={searchQuery}
62
+ onChangeText={setSearchQuery}
63
+ placeholder="搜索"
64
+ placeholderTextColor={theme.colors.foregroundMuted}
65
+ selectionColor={theme.colors.accent}
66
+ style={styles.menuSearchInput}
67
+ />
68
+ {searchQuery ? (
69
+ <Pressable accessibilityLabel="清除搜索" onPress={() => setSearchQuery("")}>
70
+ <Icon name="X" color={theme.colors.foregroundMuted} size={14} />
71
+ </Pressable>
72
+ ) : null}
73
+ </View>
74
+ <View style={styles.modelBrowserSeparator} />
75
+ <ScrollView
76
+ style={[styles.menuScroll, { maxHeight: menuHeight - 12 }]}
77
+ contentContainerStyle={{ backgroundColor: menuBackground }}
78
+ >
79
+ {filteredOptions.map((option) => {
80
+ const selected = option.id === selectedId;
81
+ return (
82
+ <Pressable
83
+ key={option.id}
84
+ accessibilityRole="menuitem"
85
+ accessibilityState={{ selected }}
86
+ onPress={() => onSelect(option.id)}
87
+ style={[styles.menuItem, selected && styles.menuItemSelected]}
88
+ >
89
+ {option.projectIconDataUri !== undefined ? (
90
+ <View style={styles.menuIconBox}>
91
+ <ProjectIcon dataUri={option.projectIconDataUri} label={option.label} theme={theme} />
92
+ </View>
93
+ ) : option.iconName ? (
94
+ <View style={styles.menuIconBox}>
95
+ <Icon name={option.iconName} color={theme.colors.foreground} size={16} />
96
+ </View>
97
+ ) : null}
98
+ <View style={styles.menuTextGroup}>
99
+ <Text style={styles.menuLabel} numberOfLines={1}>
100
+ {option.label}
101
+ </Text>
102
+ {option.detail ? (
103
+ <Text style={styles.menuDetail} numberOfLines={1}>
104
+ {option.detail}
105
+ </Text>
106
+ ) : null}
107
+ </View>
108
+ {selected ? <Icon name="Check" color={theme.colors.accent} size={13} /> : null}
109
+ </Pressable>
110
+ );
111
+ })}
112
+ {filteredOptions.length === 0 ? <Text style={styles.menuEmptyText}>没有匹配结果</Text> : null}
113
+ </ScrollView>
114
+ </View>
115
+ );
116
+ }
@@ -0,0 +1,52 @@
1
+ import React, { useMemo } from "react";
2
+ import { Pressable, Text } from "react-native";
3
+ import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
4
+ import { Icon } from "@getpaseo/plugin/client/react-native";
5
+ import { ControlGlyph } from "./ControlGlyph";
6
+ import { ProviderBrandIcon } from "./ProviderBrandIcon";
7
+ import type { OpenMenu } from "../workspace-creator/types";
8
+ import { createControlStyles } from "../workspace-creator/control-styles";
9
+
10
+ export function SelectControl({
11
+ kind,
12
+ value,
13
+ disabled,
14
+ open,
15
+ onPress,
16
+ theme,
17
+ iconName,
18
+ providerId,
19
+ }: {
20
+ kind: Exclude<OpenMenu, null>;
21
+ value: string;
22
+ disabled?: boolean;
23
+ open: boolean;
24
+ onPress: () => void;
25
+ theme: PluginSurfaceProps["theme"];
26
+ iconName?: string;
27
+ providerId?: string | null;
28
+ }) {
29
+ const styles = useMemo(() => createControlStyles(theme), [theme]);
30
+ const iconColor = theme.colors.foregroundMuted;
31
+ return (
32
+ <Pressable
33
+ accessibilityRole="button"
34
+ accessibilityLabel={`切换 ${value}`}
35
+ disabled={disabled}
36
+ onPress={onPress}
37
+ style={[styles.control, open && styles.controlOpen, disabled && styles.controlDisabled]}
38
+ >
39
+ {kind === "model" && providerId ? (
40
+ <ProviderBrandIcon providerId={providerId} color={iconColor} size={16} />
41
+ ) : kind === "provider" ? null : iconName ? (
42
+ <Icon name={iconName} color={iconColor} size={16} />
43
+ ) : (
44
+ <ControlGlyph kind={kind} color={iconColor} />
45
+ )}
46
+ <Text style={styles.controlValue} numberOfLines={1}>
47
+ {value}
48
+ </Text>
49
+ <Icon name="ChevronDown" color={iconColor} size={12} />
50
+ </Pressable>
51
+ );
52
+ }
@@ -0,0 +1,7 @@
1
+ export interface AgentTimelineEntry {
2
+ item: { type: string; [key: string]: any };
3
+ timestamp: string;
4
+ seqStart: number;
5
+ turnId?: string;
6
+ }
7
+