@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,89 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ import { DEFAULT_TERMINAL_PROFILES, PROVIDER_READY_TIMEOUT_MS } from "./constants";
4
+ import type { ComposerSelection, PaseoClient, ProviderSnapshot, TerminalProfile } from "./types";
5
+ import { defaultSelection, readPaseoProviderModelPreference, readyProviders } from "./provider-selection";
6
+
7
+ export function useTerminalProfiles(paseo: PaseoClient): TerminalProfile[] {
8
+ const [profiles, setProfiles] = useState<TerminalProfile[]>(DEFAULT_TERMINAL_PROFILES);
9
+ useEffect(() => {
10
+ let cancelled = false;
11
+ void paseo.config.get().then(({ config }) => {
12
+ if (!cancelled && Array.isArray(config.terminalProfiles)) {
13
+ setProfiles(config.terminalProfiles.map((profile) => ({ ...profile, args: profile.args ?? [] })));
14
+ }
15
+ }).catch(() => {});
16
+ return () => { cancelled = true; };
17
+ }, [paseo]);
18
+ return profiles;
19
+ }
20
+
21
+ async function loadProviderCatalog(paseo: PaseoClient, cwd?: string): Promise<ProviderSnapshot> {
22
+ const initial = await paseo.providers.snapshot({ cwd }) as ProviderSnapshot;
23
+ const entries = await Promise.all(initial.entries.map(async (entry) => {
24
+ if (entry.enabled === false) return entry;
25
+ const provider = entry.provider as Parameters<typeof paseo.providers.listModels>[0];
26
+ const [modelsResult, modesResult] = await Promise.allSettled([
27
+ paseo.providers.listModels(provider, { cwd }),
28
+ paseo.providers.listModes(provider, { cwd }),
29
+ ]);
30
+ const models = modelsResult.status === "fulfilled" ? modelsResult.value.models : entry.models;
31
+ return {
32
+ ...entry,
33
+ status: models?.length ? "ready" : entry.status,
34
+ models,
35
+ modes: modesResult.status === "fulfilled" ? modesResult.value.modes : entry.modes,
36
+ };
37
+ }));
38
+ return { ...initial, entries };
39
+ }
40
+
41
+ export function useProviderCatalog(paseo: PaseoClient, cwd: string) {
42
+ const [snapshot, setSnapshot] = useState<ProviderSnapshot | null>(null);
43
+ const [selection, setSelection] = useState<ComposerSelection | null>(null);
44
+ const [loading, setLoading] = useState(false);
45
+ const [error, setError] = useState<string | null>(null);
46
+
47
+ useEffect(() => {
48
+ if (!cwd) return;
49
+ let cancelled = false;
50
+ setLoading(true); setError(null); setSnapshot(null); setSelection(null);
51
+ const requireReadyProviders = (next: ProviderSnapshot): ProviderSnapshot => {
52
+ if (!readyProviders(next).length) {
53
+ const details = next.entries.filter((entry) => entry.enabled !== false)
54
+ .map((entry) => `${entry.label ?? entry.provider}: ${entry.status}${entry.error ? ` (${entry.error})` : ""}`);
55
+ throw new Error(details.length ? `Provider 尚不可用:${details.join(";")}` : "主机未返回已启用的 Provider");
56
+ }
57
+ return next;
58
+ };
59
+ // An incomplete snapshot or an unsupported wait API must not win over
60
+ // the other discovery path before it has returned usable providers.
61
+ const localCatalog = Promise.any([
62
+ loadProviderCatalog(paseo, cwd).then(requireReadyProviders),
63
+ paseo.providers.waitForReady({ cwd, timeoutMs: PROVIDER_READY_TIMEOUT_MS }).then(requireReadyProviders),
64
+ ]);
65
+ void localCatalog.catch(async (localError: unknown) => {
66
+ // Global discovery can remain usable when directory-specific discovery fails.
67
+ try {
68
+ return requireReadyProviders(await loadProviderCatalog(paseo));
69
+ } catch (globalError) {
70
+ const failures = localError instanceof AggregateError ? localError.errors : [localError];
71
+ throw new AggregateError([...failures, globalError]);
72
+ }
73
+ }).then((nextSnapshot) => {
74
+ if (cancelled) return;
75
+ setSnapshot(nextSnapshot);
76
+ setSelection(defaultSelection(nextSnapshot, readPaseoProviderModelPreference()));
77
+ }).catch((loadError: unknown) => {
78
+ if (!cancelled) {
79
+ const failures = loadError instanceof AggregateError ? loadError.errors : [loadError];
80
+ setError([...new Set(failures.map((failure) => failure instanceof Error ? failure.message : String(failure)))].join("\n"));
81
+ }
82
+ }).finally(() => {
83
+ if (!cancelled) setLoading(false);
84
+ });
85
+ return () => { cancelled = true; };
86
+ }, [cwd, paseo]);
87
+
88
+ return { snapshot, selection, setSelection, loading, error };
89
+ }
@@ -0,0 +1,533 @@
1
+ import { type PluginSurfaceProps, usePaseo, useRpc } from "@getpaseo/plugin/client";
2
+ import React, { useCallback, useMemo, useRef, useState } from "react";
3
+ import { ScrollView, Text, TouchableOpacity, View, type LayoutChangeEvent, type ViewStyle, type TextStyle } from "react-native";
4
+
5
+ import { TooltipProvider, useTooltip } from "./components/Tooltip";
6
+ import { ProjectCard } from "./components/ProjectCard";
7
+ import { WorkspaceCreatorPanel } from "./components/WorkspaceCreatorPanel";
8
+ import { AgentCreatorPanel } from "./components/AgentCreatorPanel";
9
+ import { useWorkspaces } from "./hooks/useWorkspaces";
10
+ import { useFilter, TIME_RANGES, type TimeRange } from "./hooks/useFilter";
11
+ import { useAgentActivityHeatmap } from "./hooks/useAgentActivityHeatmap";
12
+ import { HeatmapCalendar } from "./components/HeatmapCalendar";
13
+ import { type AgentEntry, type TooltipState, type WorkspaceEntry } from "../shared/overview-types";
14
+ import { AgentConversationPreview } from "./components/AgentConversationPreview";
15
+ import { projectSourceDirectory, useProjectOptions } from "./overview/useProjectOptions";
16
+ import { pluginReloadRpc } from "../shared/agent-config";
17
+
18
+ const PLUGIN_ID = "workspace-overview";
19
+
20
+ function formatActivityDate(date: string) {
21
+ const [, month, day] = date.split("-");
22
+ return `${Number(month)}月${Number(day)}日`;
23
+ }
24
+
25
+ export function WorkspaceOverview({ theme, host, layout, navigation }: PluginSurfaceProps) {
26
+ const [containerWidth, setContainerWidth] = useState(0);
27
+ const [expanded, setExpanded] = useState<Record<string, boolean>>({});
28
+ const [cardExpanded, setCardExpanded] = useState<Record<string, boolean>>({});
29
+ const [tooltip, setTooltip] = useState<TooltipState | null>(null);
30
+ const [surfaceSize, setSurfaceSize] = useState({ width: 0, height: 0 });
31
+ const [timeRange, setTimeRange] = useState<TimeRange>("24h");
32
+ const [activeTimeFilter, setActiveTimeFilter] = useState<TimeRange | "date">("24h");
33
+ const [selectedActivityDate, setSelectedActivityDate] = useState<string | null>(null);
34
+ const [showArchived, setShowArchived] = useState(false);
35
+ const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
36
+ const [createDialog, setCreateDialog] = useState<{
37
+ projectId: string;
38
+ projectDisplayName: string;
39
+ projectDirectory: string;
40
+ } | null>(null);
41
+ const [agentCreateWorkspace, setAgentCreateWorkspace] = useState<WorkspaceEntry | null>(null);
42
+ const surfaceRef = useRef<View | null>(null);
43
+ const surfaceOrigin = useRef({ x: 0, y: 0 });
44
+
45
+ const paseo = usePaseo();
46
+ const { projects, agentsByWorkspace, isLoading, error, refetch } = useWorkspaces(host.id);
47
+
48
+ const reloadPlugin = useRpc(pluginReloadRpc);
49
+
50
+ const handlePluginReload = useCallback(async () => {
51
+ try {
52
+ await reloadPlugin({ pluginId: PLUGIN_ID });
53
+ } catch (e) {
54
+ console.warn("Failed to reload plugin:", e);
55
+ }
56
+ }, [reloadPlugin]);
57
+
58
+ const { filteredProjects, filteredAgentsByWorkspace, autoExpand } = useFilter(
59
+ projects,
60
+ agentsByWorkspace,
61
+ timeRange,
62
+ showArchived,
63
+ );
64
+ const selectedAgent = useMemo(
65
+ () => {
66
+ if (!selectedAgentId) return null;
67
+ for (const agents of agentsByWorkspace.values()) {
68
+ const agent = agents.find((candidate: AgentEntry) => candidate.id === selectedAgentId);
69
+ if (agent) return agent;
70
+ }
71
+ return null;
72
+ },
73
+ [agentsByWorkspace, selectedAgentId],
74
+ );
75
+ const selectedAgentWorkspaceDirectory = useMemo(() => {
76
+ if (!selectedAgent?.workspaceId) return undefined;
77
+ for (const project of projects) {
78
+ const workspace = project.workspaces.find((candidate) => candidate.id === selectedAgent.workspaceId);
79
+ if (workspace) return workspace.workspaceDirectory;
80
+ }
81
+ return undefined;
82
+ }, [projects, selectedAgent]);
83
+
84
+ const createProjectOptions = useProjectOptions(host.id, projects);
85
+
86
+ const handleOpenDirectory = useCallback((directory: string) => {
87
+ void paseo.workspaces.open(directory);
88
+ }, [paseo]);
89
+
90
+ const handleCreateWorktree = useCallback(
91
+ (projectId: string, projectDisplayName: string, projectDirectory: string) => {
92
+ setSelectedAgentId(null);
93
+ setAgentCreateWorkspace(null);
94
+ setCreateDialog({ projectId, projectDisplayName, projectDirectory });
95
+ },
96
+ [],
97
+ );
98
+
99
+ const handleContainerLayout = useCallback((e: LayoutChangeEvent) => {
100
+ const width = e.nativeEvent.layout.width - (layout.compact ? 12 : 20) * 2;
101
+ if (width > 0) setContainerWidth(width);
102
+ }, [layout.compact]);
103
+
104
+ const handleToggleBranch = useCallback((workspaceId: string, currentEffective: boolean) => {
105
+ setExpanded((prev) => ({ ...prev, [workspaceId]: !currentEffective }));
106
+ }, []);
107
+
108
+ const handleToggleCard = useCallback((projectId: string, currentEffective: boolean) => {
109
+ setCardExpanded((prev) => ({ ...prev, [projectId]: !currentEffective }));
110
+ }, []);
111
+
112
+ const { columns, cardWidth } = useMemo(() => {
113
+ // Render a full-width card before measurement so an empty grid cannot
114
+ // prevent its own initial layout event.
115
+ if (containerWidth <= 0) return { columns: 1, cardWidth: "100%" as const };
116
+ const CARD_GAP = 16;
117
+ const CARD_TARGET_WIDTH = 360;
118
+ const CARD_MIN_WIDTH = 280;
119
+ let cols = Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD_TARGET_WIDTH + CARD_GAP)));
120
+ let w = Math.floor((containerWidth - CARD_GAP * (cols - 1)) / cols);
121
+ while (cols > 1 && w < CARD_MIN_WIDTH) {
122
+ cols -= 1;
123
+ w = Math.floor((containerWidth - CARD_GAP * (cols - 1)) / cols);
124
+ }
125
+ return { columns: cols, cardWidth: w };
126
+ }, [containerWidth]);
127
+
128
+ const measureSurface = useCallback(() => {
129
+ surfaceRef.current?.measureInWindow((x, y, width, height) => {
130
+ surfaceOrigin.current = { x, y };
131
+ setSurfaceSize({ width, height });
132
+ });
133
+ }, []);
134
+
135
+ const tooltipCtxValue = useMemo(
136
+ () => ({
137
+ show: (state: TooltipState) => setTooltip(state),
138
+ hide: () => setTooltip(null),
139
+ }),
140
+ [],
141
+ );
142
+
143
+ const workspaceMeta = useMemo(() => {
144
+ const result = new Map<string, { projectId: string; projectDisplayName: string; projectOrder: number }>();
145
+ projects.forEach((project, projectOrder) => {
146
+ for (const workspace of project.workspaces) {
147
+ result.set(workspace.id, { projectId: project.projectId, projectDisplayName: project.projectDisplayName, projectOrder });
148
+ }
149
+ });
150
+ return result;
151
+ }, [projects]);
152
+ // Only include agents whose current project/workspace can be represented by the cards below.
153
+ const allAgents = useMemo(() => {
154
+ const result: AgentEntry[] = [];
155
+ for (const [workspaceId, agents] of agentsByWorkspace) {
156
+ if (!workspaceMeta.has(workspaceId)) continue;
157
+ result.push(...agents.map((agent) => agent.workspaceId ? agent : { ...agent, workspaceId }));
158
+ }
159
+ return result;
160
+ }, [agentsByWorkspace, workspaceMeta]);
161
+
162
+ const { days: heatmapDays, loading: heatmapLoading, maxCount: heatmapMaxCount, hasData: heatmapHasData } = useAgentActivityHeatmap(paseo, allAgents, theme);
163
+ const selectedActivityAgentIds = useMemo(() => {
164
+ if (!selectedActivityDate) return null;
165
+ const day = heatmapDays.find((candidate) => candidate.date === selectedActivityDate);
166
+ return new Set(day?.agents.map((agent) => agent.id) ?? []);
167
+ }, [heatmapDays, selectedActivityDate]);
168
+ const dateFilteredAgentsByWorkspace = useMemo(() => {
169
+ if (!selectedActivityAgentIds) return filteredAgentsByWorkspace;
170
+ const result = new Map<string, AgentEntry[]>();
171
+ for (const [workspaceId, agents] of agentsByWorkspace) {
172
+ const matching = agents.filter((agent) =>
173
+ selectedActivityAgentIds.has(agent.id) && (showArchived || !agent.archivedAt),
174
+ );
175
+ if (matching.length > 0) result.set(workspaceId, matching);
176
+ }
177
+ return result;
178
+ }, [agentsByWorkspace, filteredAgentsByWorkspace, selectedActivityAgentIds, showArchived]);
179
+ const dateFilteredProjects = useMemo(() => {
180
+ if (!selectedActivityAgentIds) return filteredProjects;
181
+ return projects.flatMap((project) => {
182
+ const workspaces = project.workspaces.filter((workspace) => dateFilteredAgentsByWorkspace.has(workspace.id));
183
+ return workspaces.length > 0 ? [{ ...project, workspaces }] : [];
184
+ });
185
+ }, [projects, filteredProjects, dateFilteredAgentsByWorkspace, selectedActivityAgentIds]);
186
+ const isDateFilterActive = activeTimeFilter === "date" && selectedActivityDate !== null;
187
+ const displayedProjects = isDateFilterActive ? dateFilteredProjects : filteredProjects;
188
+ const displayedAgentsByWorkspace = isDateFilterActive ? dateFilteredAgentsByWorkspace : filteredAgentsByWorkspace;
189
+ const displayedAutoExpand = useMemo(() => {
190
+ if (!isDateFilterActive) return autoExpand;
191
+ const result: Record<string, boolean> = {};
192
+ for (const project of dateFilteredProjects) {
193
+ result[project.projectId] = true;
194
+ for (const workspace of project.workspaces) result[workspace.id] = true;
195
+ }
196
+ return result;
197
+ }, [autoExpand, dateFilteredProjects, isDateFilterActive]);
198
+ const totalWorkspaces = displayedProjects.reduce((sum, project) => sum + project.workspaces.length, 0);
199
+ const totalAgents = displayedProjects.reduce((sum, project) =>
200
+ sum + project.workspaces.reduce((workspaceSum, workspace) => workspaceSum + (displayedAgentsByWorkspace.get(workspace.id)?.length ?? 0), 0),
201
+ 0);
202
+ const horizontalPadding = layout.compact ? 12 : 20;
203
+ const rightPanelInitialWidth = Math.max(360, Math.round(surfaceSize.width * 0.5));
204
+ const rightPanelMaxWidth = Math.max(800, surfaceSize.width - 280);
205
+
206
+ const TOOLTIP_WIDTH = 280;
207
+ const TOOLTIP_EST_HEIGHT = tooltip ? 24 + tooltip.lines.length * 18 : 0;
208
+
209
+ const tooltipPos = useMemo(() => {
210
+ if (!tooltip) return null;
211
+ const localX = tooltip.x - surfaceOrigin.current.x;
212
+ const localY = tooltip.y - surfaceOrigin.current.y;
213
+ let left = localX - TOOLTIP_WIDTH;
214
+ let top = localY + 6;
215
+ if (surfaceSize.width > 0) {
216
+ if (left + TOOLTIP_WIDTH > surfaceSize.width - 8) left = surfaceSize.width - TOOLTIP_WIDTH - 8;
217
+ if (left < 8) left = 8;
218
+ }
219
+ if (surfaceSize.height > 0 && top + TOOLTIP_EST_HEIGHT > surfaceSize.height - 8) {
220
+ top = Math.max(8, localY - TOOLTIP_EST_HEIGHT - 6);
221
+ }
222
+ return { left, top };
223
+ }, [tooltip, surfaceSize, TOOLTIP_EST_HEIGHT]);
224
+
225
+ const statusDanger = (theme.colors as { statusDanger?: string }).statusDanger ?? "#ef4444";
226
+ const tooltipStyles = useMemo(
227
+ () => ({
228
+ container: {
229
+ position: "absolute" as const,
230
+ width: TOOLTIP_WIDTH,
231
+ paddingHorizontal: 12,
232
+ paddingVertical: 10,
233
+ borderRadius: 8,
234
+ backgroundColor: theme.colors.foreground,
235
+ shadowColor: "#000",
236
+ shadowOpacity: 0.25,
237
+ shadowRadius: 12,
238
+ shadowOffset: { width: 0, height: 6 },
239
+ elevation: 12,
240
+ zIndex: 9999,
241
+ } as ViewStyle,
242
+ row: { flexDirection: "row" as const, marginBottom: 4 } as ViewStyle,
243
+ key: { color: theme.colors.surface0, opacity: 0.6, fontSize: 11, width: 72, flexShrink: 0 } as TextStyle,
244
+ value: { color: theme.colors.surface0, fontSize: 11, flex: 1, minWidth: 0, fontWeight: "500" as const } as TextStyle,
245
+ }),
246
+ [theme],
247
+ );
248
+
249
+ if (isLoading) {
250
+ return (
251
+ <View style={{ flex: 1, alignItems: "center" as const, justifyContent: "center" as const, backgroundColor: theme.colors.surface0 }}>
252
+ <Text style={{ color: theme.colors.foregroundMuted }}>Loading…</Text>
253
+ </View>
254
+ );
255
+ }
256
+
257
+ if (error) {
258
+ return (
259
+ <View style={{ flex: 1, alignItems: "center" as const, justifyContent: "center" as const, backgroundColor: theme.colors.surface0, padding: 24 }}>
260
+ <Text style={{ color: statusDanger }}>{(error as Error).message}</Text>
261
+ <TouchableOpacity onPress={() => void refetch()} style={{ marginTop: 12 }}>
262
+ <Text style={{ color: theme.colors.accent }}>Retry</Text>
263
+ </TouchableOpacity>
264
+ </View>
265
+ );
266
+ }
267
+
268
+ return (
269
+ <TooltipProvider value={tooltipCtxValue}>
270
+ <View
271
+ ref={surfaceRef}
272
+ style={{ flex: 1, backgroundColor: theme.colors.surface0 }}
273
+ onLayout={measureSurface}
274
+ >
275
+ <View style={{ flex: 1, minHeight: 0, position: "relative" }}>
276
+ <View style={{ flex: 1, minHeight: 0, flexDirection: "row" }}>
277
+ <View style={{ flex: 1, minWidth: 0, display: (createDialog || agentCreateWorkspace) && layout.compact ? "none" : "flex" }}>
278
+ <View style={{ paddingHorizontal: horizontalPadding, paddingTop: 12, paddingBottom: 8 }}>
279
+ <View style={{ flexDirection: "row" as const, alignItems: "center" as const, justifyContent: "space-between" }}>
280
+ <View style={{ flexDirection: "row" as const, alignItems: "center" as const, gap: 12 }}>
281
+ <Text style={{ color: theme.colors.foreground, fontSize: layout.compact ? 18 : 22, fontWeight: "700" as const }}>
282
+ 所有项目
283
+ </Text>
284
+ <TouchableOpacity
285
+ onPress={handlePluginReload}
286
+ activeOpacity={0.7}
287
+ style={{ padding: 4 }}
288
+ accessibilityLabel="重新加载插件"
289
+ >
290
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 22, fontWeight: "600" }}>⟳</Text>
291
+ </TouchableOpacity>
292
+ </View>
293
+ </View>
294
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12, marginTop: 2 }}>
295
+ {host.label} · {displayedProjects.length} 个项目 · {totalWorkspaces} 个分支 · {totalAgents} 个 agent
296
+ </Text>
297
+ <View style={{ flexDirection: "row" as const, alignItems: "center" as const, marginTop: 8, gap: 8 }}>
298
+ <View
299
+ style={{
300
+ flexDirection: "row" as const,
301
+ borderRadius: 6,
302
+ overflow: "hidden" as const,
303
+ borderWidth: 1,
304
+ borderColor: theme.colors.foregroundMuted + "22",
305
+ }}
306
+ >
307
+ {selectedActivityDate ? (
308
+ <TouchableOpacity
309
+ onPress={() => setActiveTimeFilter("date")}
310
+ style={{ paddingHorizontal: 7, paddingVertical: 3, backgroundColor: isDateFilterActive ? theme.colors.accent + "22" : "transparent" }}
311
+ >
312
+ <Text style={{ color: isDateFilterActive ? theme.colors.accent : theme.colors.foregroundMuted, fontSize: 11, fontWeight: isDateFilterActive ? "600" : "400" }}>
313
+ {formatActivityDate(selectedActivityDate)}
314
+ </Text>
315
+ </TouchableOpacity>
316
+ ) : null}
317
+ {TIME_RANGES.map((r) => {
318
+ const active = activeTimeFilter === r.key;
319
+ return (
320
+ <TouchableOpacity
321
+ key={r.key}
322
+ onPress={() => {
323
+ setTimeRange(r.key);
324
+ setActiveTimeFilter(r.key);
325
+ }}
326
+ style={{ paddingHorizontal: 7, paddingVertical: 3, backgroundColor: active ? theme.colors.accent + "22" : "transparent" }}
327
+ >
328
+ <Text style={{ color: active ? theme.colors.accent : theme.colors.foregroundMuted, fontSize: 11, fontWeight: active ? "600" as const : "400" as const }}>
329
+ {r.label}
330
+ </Text>
331
+ </TouchableOpacity>
332
+ );
333
+ })}
334
+ </View>
335
+ <View
336
+ style={{
337
+ flexDirection: "row" as const,
338
+ borderRadius: 6,
339
+ overflow: "hidden" as const,
340
+ borderWidth: 1,
341
+ borderColor: theme.colors.foregroundMuted + "22",
342
+ }}
343
+ >
344
+ <TouchableOpacity
345
+ onPress={() => setShowArchived(!showArchived)}
346
+ style={{
347
+ paddingHorizontal: 10,
348
+ paddingVertical: 3,
349
+ backgroundColor: showArchived ? theme.colors.accent + "22" : "transparent",
350
+ }}
351
+ >
352
+ <Text
353
+ style={{
354
+ color: showArchived ? theme.colors.accent : theme.colors.foregroundMuted,
355
+ fontSize: 11,
356
+ fontWeight: showArchived ? "600" as const : "400" as const,
357
+ }}
358
+ >
359
+ {showArchived ? "隐藏归档" : "显示归档"}
360
+ </Text>
361
+ </TouchableOpacity>
362
+ </View>
363
+ </View>
364
+ </View>
365
+
366
+ <ScrollView
367
+ onLayout={handleContainerLayout}
368
+ style={{ flex: 1 }}
369
+ contentContainerStyle={{ paddingHorizontal: horizontalPadding, paddingBottom: 24, paddingTop: 4 }}
370
+ >
371
+ {heatmapLoading ? (
372
+ <View style={{ paddingVertical: 16, alignItems: "center" }}>
373
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>加载热度图中…</Text>
374
+ </View>
375
+ ) : !heatmapHasData ? (
376
+ <View style={{ paddingVertical: 16, alignItems: "center" }}>
377
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>暂无活动数据,开始使用 Agent 后将在热力图中显示</Text>
378
+ </View>
379
+ ) : (
380
+ <HeatmapCalendar
381
+ days={heatmapDays}
382
+ maxCount={heatmapMaxCount}
383
+ theme={theme}
384
+ selectedDate={selectedActivityDate}
385
+ onSelectDate={(date) => {
386
+ if (selectedActivityDate === date) {
387
+ setSelectedActivityDate(null);
388
+ setActiveTimeFilter(timeRange);
389
+ return;
390
+ }
391
+ setSelectedActivityDate(date);
392
+ setActiveTimeFilter("date");
393
+ }}
394
+ workspaceMeta={workspaceMeta}
395
+ />
396
+ )}
397
+ {displayedProjects.length === 0 ? (
398
+ <View style={{ flex: 1, alignItems: "center" as const, justifyContent: "center" as const, padding: 24 }}>
399
+ <Text style={{ color: theme.colors.foregroundMuted, textAlign: "center" as const }}>
400
+ {isDateFilterActive && selectedActivityDate ? `${formatActivityDate(selectedActivityDate)}没有 Agent 活动。` : timeRange === "all" ? "No projects found." : "没有活跃会话。"}
401
+ </Text>
402
+ </View>
403
+ ) : (
404
+ <View
405
+ style={{
406
+ width: "100%",
407
+ flexDirection: "row" as const,
408
+ flexWrap: "wrap" as const,
409
+ justifyContent: "space-between" as const,
410
+ alignItems: "flex-start" as const,
411
+ }}
412
+ >
413
+ {displayedProjects.map((project) => {
414
+ const projectDirectory = projectSourceDirectory(project.workspaces);
415
+ return (
416
+ <ProjectCard
417
+ key={project.projectId}
418
+ projectId={project.projectId}
419
+ projectDisplayName={project.projectDisplayName}
420
+ workspaces={project.workspaces}
421
+ agentsByWorkspace={displayedAgentsByWorkspace}
422
+ expanded={{ ...displayedAutoExpand, ...expanded }}
423
+ cardExpanded={{ ...displayedAutoExpand, ...cardExpanded }}
424
+ onToggleCard={handleToggleCard}
425
+ onToggleBranch={handleToggleBranch}
426
+ onOpenDirectory={handleOpenDirectory}
427
+ theme={theme}
428
+ compact={layout.compact}
429
+ width={cardWidth}
430
+ hostLabel={host.label}
431
+ hostId={host.id}
432
+ onSelectAgent={(agent) => {
433
+ if (layout.compact && navigation) {
434
+ navigation.openAgent({ agentId: agent.id });
435
+ return;
436
+ }
437
+ setCreateDialog(null);
438
+ setAgentCreateWorkspace(null);
439
+ setSelectedAgentId(agent.id);
440
+ }}
441
+ onCreateAgent={(workspace) => {
442
+ setSelectedAgentId(null);
443
+ setCreateDialog(null);
444
+ setAgentCreateWorkspace(workspace);
445
+ }}
446
+ onCreateWorktree={
447
+ projectDirectory
448
+ ? () =>
449
+ handleCreateWorktree(
450
+ project.projectId,
451
+ project.projectDisplayName,
452
+ projectDirectory,
453
+ )
454
+ : undefined
455
+ }
456
+ />
457
+ );
458
+ })}
459
+ {columns > 1 &&
460
+ Array.from({ length: columns - 1 }).map((_, i) => (
461
+ <View key={`spacer-${i}`} style={{ width: cardWidth }} />
462
+ ))}
463
+ </View>
464
+ )}
465
+ </ScrollView>
466
+ </View>
467
+ {selectedAgent && (!layout.compact || !navigation) ? (
468
+ <AgentConversationPreview
469
+ key={selectedAgent.id}
470
+ agent={selectedAgent}
471
+ workspaceDirectory={selectedAgentWorkspaceDirectory}
472
+ paseo={paseo}
473
+ theme={theme}
474
+ onClose={() => setSelectedAgentId(null)}
475
+ onOpenFull={() => navigation?.openAgent({ agentId: selectedAgent.id })}
476
+ initialPanelWidth={rightPanelInitialWidth}
477
+ maxPanelWidth={rightPanelMaxWidth}
478
+ compact={layout.compact}
479
+ />
480
+ ) : null}
481
+ {createDialog ? (
482
+ <WorkspaceCreatorPanel
483
+ key={createDialog.projectId}
484
+ projectId={createDialog.projectId}
485
+ projectDisplayName={createDialog.projectDisplayName}
486
+ projectDirectory={createDialog.projectDirectory}
487
+ hostId={host.id}
488
+ navigation={navigation!}
489
+ projects={createProjectOptions}
490
+ paseo={paseo}
491
+ onClose={() => setCreateDialog(null)}
492
+ onCreate={() => void refetch()}
493
+ theme={theme}
494
+ layout={layout}
495
+ initialPanelWidth={rightPanelInitialWidth}
496
+ maxPanelWidth={rightPanelMaxWidth}
497
+ />
498
+ ) : null}
499
+ {agentCreateWorkspace ? (
500
+ <AgentCreatorPanel
501
+ key={agentCreateWorkspace.id}
502
+ workspace={agentCreateWorkspace}
503
+ paseo={paseo}
504
+ theme={theme}
505
+ compact={layout.compact}
506
+ initialPanelWidth={rightPanelInitialWidth}
507
+ maxPanelWidth={rightPanelMaxWidth}
508
+ onClose={() => setAgentCreateWorkspace(null)}
509
+ onCreated={(agent) => {
510
+ setAgentCreateWorkspace(null);
511
+ void refetch();
512
+ if (layout.compact && navigation) navigation.openAgent({ agentId: agent.id });
513
+ else setSelectedAgentId(agent.id);
514
+ }}
515
+ />
516
+ ) : null}
517
+ </View>
518
+
519
+ {tooltip && tooltipPos && (
520
+ <View style={[tooltipStyles.container, { left: tooltipPos.left, top: tooltipPos.top }]} pointerEvents="none">
521
+ {tooltip.lines.map((line, i) => (
522
+ <View key={i} style={[tooltipStyles.row, i === tooltip.lines.length - 1 && { marginBottom: 0 }]}>
523
+ <Text style={tooltipStyles.key}>{line.key}</Text>
524
+ <Text style={tooltipStyles.value}>{line.value}</Text>
525
+ </View>
526
+ ))}
527
+ </View>
528
+ )}
529
+ </View>
530
+ </View>
531
+ </TooltipProvider>
532
+ );
533
+ }
@@ -0,0 +1,36 @@
1
+ import type { PluginClientContext } from "@getpaseo/plugin/client";
2
+ import { WorkspaceOverview } from "./client/workspace-overview";
3
+ import { WorkspaceAgentsPanel } from "./client/workspace-agents";
4
+
5
+ export default function contribute(client: PluginClientContext) {
6
+ // Global sidebar surface: shows all workspaces with agent counts as cards
7
+ client.addSurface("overview", WorkspaceOverview);
8
+ client.addSidebarItem({
9
+ id: "overview",
10
+ title: "概述",
11
+ icon: "LayoutGrid",
12
+ surface: "overview",
13
+ });
14
+
15
+ // Workspace panel: shows agents within a specific workspace
16
+ client.addWorkspacePanel({
17
+ id: "agents",
18
+ title: "Workspace Agents",
19
+ icon: "Users",
20
+ context: "workspace",
21
+ Component: WorkspaceAgentsPanel,
22
+ });
23
+
24
+ // Command Center item to open the agents panel (⌘K → search "Workspace Agents")
25
+ client.addCommandCenterItem({
26
+ id: "open-agents",
27
+ title: "Workspace Agents",
28
+ icon: "Users",
29
+ context: "workspace",
30
+ onSelect({ openPanel }) {
31
+ openPanel("agents");
32
+ },
33
+ });
34
+
35
+ return () => {};
36
+ }