@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,322 @@
1
+ import { Platform, StyleSheet, type TextStyle, type ViewStyle } from "react-native";
2
+ import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
3
+ import { MAX_MENU_HEIGHT } from "../workspace-creator/constants";
4
+ import { opaqueSurfaceColor } from "../workspace-creator/ui-utils";
5
+
6
+ export function createComposerStyles(theme: PluginSurfaceProps["theme"]) {
7
+ const colors = theme.colors;
8
+ const surface1 = colors.surface1;
9
+ return StyleSheet.create({
10
+ panel: {
11
+ flexShrink: 0,
12
+ overflow: "visible",
13
+ backgroundColor: colors.surface0,
14
+ paddingHorizontal: 0,
15
+ paddingTop: 0,
16
+ paddingBottom: 0,
17
+ } as ViewStyle,
18
+ content: {
19
+ width: "100%",
20
+ maxWidth: 760,
21
+ alignSelf: "center",
22
+ } as ViewStyle,
23
+ header: {
24
+ flexDirection: "row",
25
+ alignItems: "center",
26
+ justifyContent: "space-between",
27
+ marginBottom: 28,
28
+ } as ViewStyle,
29
+ headerText: {
30
+ flex: 1,
31
+ minWidth: 0,
32
+ paddingRight: 16,
33
+ } as ViewStyle,
34
+ title: {
35
+ color: colors.foreground,
36
+ fontSize: 24,
37
+ lineHeight: 32,
38
+ fontWeight: "400",
39
+ } as TextStyle,
40
+ formStackDesktop: {
41
+ position: "relative",
42
+ zIndex: 1,
43
+ flexDirection: "row",
44
+ alignItems: "center",
45
+ marginBottom: 16,
46
+ gap: 8,
47
+ } as ViewStyle,
48
+ controlAnchor: {
49
+ position: "relative",
50
+ flexShrink: 0,
51
+ overflow: "visible",
52
+ } as ViewStyle,
53
+ controlAnchorOpen: {
54
+ zIndex: 20,
55
+ } as ViewStyle,
56
+ hostBadge: {
57
+ flexDirection: "row",
58
+ alignItems: "center",
59
+ height: 28,
60
+ maxWidth: 240,
61
+ overflow: "hidden",
62
+ paddingHorizontal: 8,
63
+ borderRadius: 16,
64
+ gap: 8,
65
+ } as ViewStyle,
66
+ hostStatusDot: {
67
+ width: 8,
68
+ height: 8,
69
+ borderRadius: 4,
70
+ backgroundColor: "#3e704a",
71
+ flexShrink: 0,
72
+ } as ViewStyle,
73
+ hostText: {
74
+ minWidth: 0,
75
+ fontSize: 14,
76
+ lineHeight: 20,
77
+ color: colors.foregroundMuted,
78
+ flexShrink: 1,
79
+ } as TextStyle,
80
+ launchSpacer: {
81
+ flex: 1,
82
+ } as ViewStyle,
83
+ closeButton: {
84
+ width: 28,
85
+ height: 28,
86
+ borderRadius: 14,
87
+ alignItems: "center",
88
+ justifyContent: "center",
89
+ } as ViewStyle,
90
+ inputWrapper: {
91
+ position: "relative",
92
+ zIndex: 2,
93
+ flexDirection: "column",
94
+ gap: 12,
95
+ backgroundColor: surface1,
96
+ borderWidth: 1,
97
+ borderColor: colors.border,
98
+ borderRadius: 20,
99
+ paddingVertical: 16,
100
+ paddingHorizontal: 16,
101
+ } as ViewStyle,
102
+ menuRegionActive: {
103
+ zIndex: 10,
104
+ } as ViewStyle,
105
+ textInput: {
106
+ width: "100%",
107
+ minHeight: 46,
108
+ maxHeight: 160,
109
+ color: colors.foreground,
110
+ fontSize: 15,
111
+ lineHeight: 21,
112
+ fontWeight: "400",
113
+ borderWidth: 0,
114
+ backgroundColor: "transparent",
115
+ ...(Platform.OS === "web"
116
+ ? ({ outlineStyle: "none", outlineWidth: 0, outlineColor: "transparent" } as object)
117
+ : {}),
118
+ } as TextStyle,
119
+ buttonRow: {
120
+ flexDirection: "row",
121
+ alignItems: "flex-end",
122
+ justifyContent: "space-between",
123
+ marginHorizontal: -6,
124
+ } as ViewStyle,
125
+ leftControls: {
126
+ flex: 1,
127
+ minWidth: 0,
128
+ flexDirection: "row",
129
+ alignItems: "flex-end",
130
+ flexWrap: "wrap",
131
+ gap: 0,
132
+ } as ViewStyle,
133
+ rightControls: {
134
+ flexShrink: 0,
135
+ flexDirection: "row",
136
+ alignItems: "center",
137
+ gap: 4,
138
+ } as ViewStyle,
139
+ pendingText: {
140
+ color: colors.foregroundMuted,
141
+ fontSize: 12,
142
+ lineHeight: 16,
143
+ } as TextStyle,
144
+ sendButton: {
145
+ width: 28,
146
+ height: 28,
147
+ borderRadius: 14,
148
+ backgroundColor: colors.accent,
149
+ alignItems: "center",
150
+ justifyContent: "center",
151
+ } as ViewStyle,
152
+ disabled: {
153
+ opacity: 0.5,
154
+ } as ViewStyle,
155
+ errorText: {
156
+ color: colors.statusDanger,
157
+ fontSize: 13,
158
+ lineHeight: 18,
159
+ marginTop: 10,
160
+ } as TextStyle,
161
+ });
162
+ }
163
+
164
+ export function createMenuStyles(theme: PluginSurfaceProps["theme"]) {
165
+ const colors = theme.colors;
166
+ const menuBackground = opaqueSurfaceColor(colors.surface0, colors.foreground);
167
+ return StyleSheet.create({
168
+ menu: {
169
+ position: "absolute",
170
+ maxHeight: MAX_MENU_HEIGHT,
171
+ zIndex: 30,
172
+ backgroundColor: menuBackground,
173
+ borderWidth: 1,
174
+ borderColor: colors.foregroundMuted + "22",
175
+ borderRadius: 12,
176
+ paddingVertical: 6,
177
+ shadowColor: "#000",
178
+ shadowOpacity: 0.18,
179
+ shadowRadius: 16,
180
+ shadowOffset: { width: 0, height: 8 },
181
+ elevation: 12,
182
+ } as ViewStyle,
183
+ menuAbove: {
184
+ bottom: 52,
185
+ } as ViewStyle,
186
+ menuBelow: {
187
+ top: 36,
188
+ } as ViewStyle,
189
+ menuScroll: {
190
+ maxHeight: MAX_MENU_HEIGHT - 12,
191
+ backgroundColor: menuBackground,
192
+ } as ViewStyle,
193
+ menuSearchRow: {
194
+ minHeight: 42,
195
+ flexDirection: "row",
196
+ alignItems: "center",
197
+ gap: 8,
198
+ paddingHorizontal: 12,
199
+ backgroundColor: menuBackground,
200
+ } as ViewStyle,
201
+ menuSearchInput: {
202
+ flex: 1,
203
+ minWidth: 0,
204
+ height: 36,
205
+ padding: 0,
206
+ color: colors.foreground,
207
+ fontSize: 13,
208
+ lineHeight: 18,
209
+ borderWidth: 0,
210
+ backgroundColor: "transparent",
211
+ ...(Platform.OS === "web"
212
+ ? ({ outlineStyle: "none", outlineWidth: 0, outlineColor: "transparent" } as object)
213
+ : {}),
214
+ } as TextStyle,
215
+ menuEmptyText: {
216
+ color: colors.foregroundMuted,
217
+ fontSize: 13,
218
+ lineHeight: 18,
219
+ paddingHorizontal: 12,
220
+ paddingVertical: 14,
221
+ textAlign: "center",
222
+ } as TextStyle,
223
+ menuItem: {
224
+ flexDirection: "row",
225
+ alignItems: "center",
226
+ justifyContent: "space-between",
227
+ gap: 12,
228
+ minHeight: 40,
229
+ paddingHorizontal: 12,
230
+ paddingVertical: 7,
231
+ backgroundColor: menuBackground,
232
+ } as ViewStyle,
233
+ menuItemSelected: {
234
+ backgroundColor: colors.foregroundMuted + "0d",
235
+ } as ViewStyle,
236
+ menuIconBox: {
237
+ width: 18,
238
+ height: 18,
239
+ alignItems: "center",
240
+ justifyContent: "center",
241
+ flexShrink: 0,
242
+ } as ViewStyle,
243
+ projectIconFallback: {
244
+ borderRadius: 4,
245
+ backgroundColor: colors.foregroundMuted + "20",
246
+ } as ViewStyle,
247
+ projectIconFallbackText: {
248
+ color: colors.foreground,
249
+ fontSize: 10,
250
+ lineHeight: 14,
251
+ fontWeight: "600",
252
+ } as TextStyle,
253
+ menuTextGroup: {
254
+ flex: 1,
255
+ minWidth: 0,
256
+ } as ViewStyle,
257
+ menuLabel: {
258
+ color: colors.foreground,
259
+ fontSize: 13,
260
+ lineHeight: 18,
261
+ fontWeight: "500",
262
+ } as TextStyle,
263
+ menuDetail: {
264
+ color: colors.foregroundMuted,
265
+ fontSize: 11,
266
+ lineHeight: 15,
267
+ marginTop: 2,
268
+ } as TextStyle,
269
+ modelBrowserMenu: {
270
+ width: 360,
271
+ maxHeight: 400,
272
+ overflow: "hidden",
273
+ } as ViewStyle,
274
+ modelBrowserHeader: {
275
+ minHeight: 44,
276
+ flexDirection: "row",
277
+ alignItems: "center",
278
+ gap: 8,
279
+ paddingHorizontal: 12,
280
+ } as ViewStyle,
281
+ modelBrowserTitle: {
282
+ flex: 1,
283
+ minWidth: 0,
284
+ color: colors.foreground,
285
+ fontSize: 14,
286
+ lineHeight: 20,
287
+ fontWeight: "500",
288
+ } as TextStyle,
289
+ modelBrowserHeading: {
290
+ paddingHorizontal: 12,
291
+ paddingTop: 10,
292
+ paddingBottom: 6,
293
+ } as ViewStyle,
294
+ modelBrowserSectionLabel: {
295
+ color: colors.foregroundMuted,
296
+ fontSize: 11,
297
+ lineHeight: 15,
298
+ fontWeight: "500",
299
+ } as TextStyle,
300
+ modelBrowserSeparator: {
301
+ height: 1,
302
+ backgroundColor: colors.foregroundMuted + "18",
303
+ } as ViewStyle,
304
+ modelBrowserProviderRow: {
305
+ minHeight: 44,
306
+ } as ViewStyle,
307
+ modelBrowserProviderLabel: {
308
+ flex: 1,
309
+ minWidth: 0,
310
+ } as TextStyle,
311
+ modelBrowserCount: {
312
+ color: colors.foregroundMuted,
313
+ fontSize: 12,
314
+ lineHeight: 16,
315
+ } as TextStyle,
316
+ modelBrowserTrailing: {
317
+ width: 20,
318
+ alignItems: "center",
319
+ justifyContent: "center",
320
+ } as ViewStyle,
321
+ });
322
+ }
@@ -0,0 +1,178 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { AppState } from "react-native";
3
+ import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
4
+ import type { PaseoClient } from "../workspace-creator/types";
5
+ import type { AgentEntry } from "../../shared/overview-types";
6
+
7
+ export interface HeatmapAgent {
8
+ id: string;
9
+ title: string;
10
+ provider: string;
11
+ model: string | null;
12
+ status: string;
13
+ workspaceId?: string;
14
+ }
15
+
16
+ export interface HeatmapDay {
17
+ date: string; // YYYY-MM-DD
18
+ count: number;
19
+ agents: HeatmapAgent[];
20
+ }
21
+
22
+ const MONTH_LABELS = ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"];
23
+ export const WEEKDAY_LABELS = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
24
+
25
+ export function buildHeatmapGrid(days = 365) {
26
+ const endDate = new Date();
27
+ endDate.setHours(0, 0, 0, 0);
28
+ const startDate = new Date(endDate);
29
+ startDate.setDate(endDate.getDate() - days + 1);
30
+
31
+ // Use Monday as the first row. Convert JS Sunday=0…Saturday=6 to Monday=0…Sunday=6.
32
+ const leadingDays = (startDate.getDay() + 6) % 7;
33
+ const gridStart = new Date(startDate);
34
+ gridStart.setDate(gridStart.getDate() - leadingDays);
35
+
36
+ const totalWeeks = Math.ceil((leadingDays + days) / 7);
37
+
38
+ const monthLabels: { weekIndex: number; label: string }[] = [];
39
+ let lastMonth = -1;
40
+ for (let w = 0; w < totalWeeks; w++) {
41
+ const d = new Date(gridStart);
42
+ d.setDate(d.getDate() + w * 7);
43
+ const m = d.getMonth();
44
+ if (m !== lastMonth) {
45
+ monthLabels.push({ weekIndex: w, label: MONTH_LABELS[m] });
46
+ lastMonth = m;
47
+ }
48
+ }
49
+
50
+ return { gridStart, totalWeeks, monthLabels };
51
+ }
52
+
53
+ export function getColors(theme: PluginSurfaceProps["theme"]) {
54
+ const fallback = {
55
+ surface0: "#1e1e1e",
56
+ surface1: "#252526",
57
+ surface2: "#303033",
58
+ border: "#3f3f46",
59
+ foreground: "#ffffff",
60
+ foregroundMuted: "#888888",
61
+ accent: "#007acc",
62
+ };
63
+ if (!theme || !theme.colors) return fallback;
64
+ return { ...fallback, ...theme.colors };
65
+ }
66
+
67
+ export type ColorLevel = "none" | "low" | "medium" | "high" | "very-high";
68
+
69
+ export function getColorLevel(count: number, maxCount: number): ColorLevel {
70
+ if (count === 0 || maxCount === 0) return "none";
71
+ const ratio = count / maxCount;
72
+ if (ratio < 0.25) return "low";
73
+ if (ratio < 0.5) return "medium";
74
+ if (ratio < 0.75) return "high";
75
+ return "very-high";
76
+ }
77
+
78
+ export function getCellColor(level: ColorLevel, theme: PluginSurfaceProps["theme"]): string {
79
+ const c = getColors(theme);
80
+ switch (level) {
81
+ case "none": return c.surface2;
82
+ case "low": return c.accent + "33";
83
+ case "medium": return c.accent + "66";
84
+ case "high": return c.accent + "aa";
85
+ case "very-high": return c.accent;
86
+ }
87
+ }
88
+
89
+ function formatDate(d: Date): string {
90
+ const y = d.getFullYear();
91
+ const m = String(d.getMonth() + 1).padStart(2, "0");
92
+ const day = String(d.getDate()).padStart(2, "0");
93
+ return `${y}-${m}-${day}`;
94
+ }
95
+
96
+ /** Build daily activity counts from agent list metadata (no timeline fetch needed). */
97
+ function buildDaysFromAgents(agents: AgentEntry[]): { days: HeatmapDay[]; hasData: boolean } {
98
+ const now = Date.now();
99
+ const cutoffMs = now - 365 * 24 * 60 * 60 * 1000;
100
+
101
+ const cells: HeatmapDay[] = Array.from({ length: 365 }, (_, i) => {
102
+ const d = new Date(now - (365 - 1 - i) * 86400000);
103
+ return { date: formatDate(d), count: 0, agents: [] };
104
+ });
105
+ const cellMap = new Map(cells.map((c) => [c.date, c]));
106
+
107
+ let totalActivity = 0;
108
+ for (const agent of agents) {
109
+ // Only real user-message activity belongs on the heatmap. updatedAt can
110
+ // change for status/metadata updates, and archivedAt records archival time;
111
+ // using either would move an old conversation onto the wrong day.
112
+ const iso = agent.lastUserMessageAt;
113
+ if (!iso) continue;
114
+ const ts = new Date(iso).getTime();
115
+ if (Number.isNaN(ts) || ts < cutoffMs) continue;
116
+ const key = formatDate(new Date(ts));
117
+ const cell = cellMap.get(key);
118
+ if (cell) {
119
+ cell.count += 1;
120
+ cell.agents.push({
121
+ id: agent.id,
122
+ title: agent.title?.trim() || "未命名 Agent",
123
+ provider: agent.provider,
124
+ model: agent.model,
125
+ status: agent.status,
126
+ workspaceId: agent.workspaceId,
127
+ });
128
+ totalActivity++;
129
+ }
130
+ }
131
+
132
+
133
+ return { days: cells, hasData: totalActivity > 0 };
134
+ }
135
+
136
+ export function useAgentActivityHeatmap(
137
+ paseo: PaseoClient,
138
+ agents: AgentEntry[],
139
+ theme: PluginSurfaceProps["theme"],
140
+ ) {
141
+ const [days, setDays] = useState<HeatmapDay[]>([]);
142
+ const [loading, setLoading] = useState(true);
143
+ const [error, setError] = useState<string | null>(null);
144
+ const [hasData, setHasData] = useState(false);
145
+ const maxCountRef = useRef(0);
146
+
147
+ const fetchData = useCallback(() => {
148
+ const { days: cells, hasData: dataExists } = buildDaysFromAgents(agents);
149
+ const max = cells.reduce((m, c) => Math.max(m, c.count), 0);
150
+ maxCountRef.current = max;
151
+ setDays(cells);
152
+ setHasData(dataExists);
153
+ setLoading(false);
154
+ }, [agents]);
155
+
156
+ useEffect(() => {
157
+ fetchData();
158
+ }, [fetchData]);
159
+
160
+ // Refresh every 5 minutes, but only when app is active
161
+ const appStateRef = useRef(AppState.currentState);
162
+ useEffect(() => {
163
+ const timer = setInterval(() => { void fetchData(); }, 5 * 60 * 1000);
164
+ const subscription = AppState.addEventListener("change", (nextState) => {
165
+ appStateRef.current = nextState;
166
+ if (nextState === "active") {
167
+ clearInterval(timer);
168
+ void fetchData();
169
+ }
170
+ });
171
+ return () => {
172
+ clearInterval(timer);
173
+ subscription.remove();
174
+ };
175
+ }, [fetchData]);
176
+
177
+ return { days, loading, error, maxCount: maxCountRef.current, hasData };
178
+ }
@@ -0,0 +1,107 @@
1
+ import { useCallback, useMemo } from "react";
2
+ import { type WorkspaceEntry, type AgentEntry } from "../../shared/overview-types";
3
+
4
+ export const TIME_RANGES = [
5
+ { key: "24h", label: "近24小时", ms: 24 * 60 * 60 * 1000 },
6
+ { key: "3d", label: "近3天", ms: 3 * 24 * 60 * 60 * 1000 },
7
+ { key: "7d", label: "近7天", ms: 7 * 24 * 60 * 60 * 1000 },
8
+ { key: "30d", label: "近30天", ms: 30 * 24 * 60 * 60 * 1000 },
9
+ { key: "all", label: "全部", ms: Infinity },
10
+ ] as const;
11
+
12
+ export type TimeRange = (typeof TIME_RANGES)[number]["key"];
13
+
14
+ export function useFilter(
15
+ projects: Array<{
16
+ projectId: string;
17
+ projectDisplayName: string;
18
+ workspaces: WorkspaceEntry[];
19
+ }>,
20
+ agentsByWorkspace: Map<string, AgentEntry[]>,
21
+ timeRange: TimeRange,
22
+ showArchived: boolean,
23
+ ) {
24
+ const rangeMs = useMemo(
25
+ () => TIME_RANGES.find((r) => r.key === timeRange)!.ms,
26
+ [timeRange],
27
+ );
28
+
29
+ const isRecent = useCallback(
30
+ (iso: string | null | undefined) => {
31
+ if (!iso) return false;
32
+ const t = new Date(iso).getTime();
33
+ if (Number.isNaN(t)) return false;
34
+ return Date.now() - t < rangeMs;
35
+ },
36
+ [rangeMs],
37
+ );
38
+
39
+ const filteredProjects = useMemo(() => {
40
+ const result: Array<{
41
+ projectId: string;
42
+ projectDisplayName: string;
43
+ workspaces: WorkspaceEntry[];
44
+ }> = [];
45
+ for (const project of projects) {
46
+ const filteredWorkspaces = project.workspaces
47
+ .map((ws) => {
48
+ // Keep the workspace only if at least one agent has a user message
49
+ // in the selected window. Do NOT fall back to ws.activityAt or
50
+ // ws.statusEnteredAt — those are workspace creation/status-change
51
+ // metadata, not actual user activity.
52
+ let wsAgents = (agentsByWorkspace.get(ws.id) ?? []);
53
+
54
+ // Filter out archived agents unless showArchived is true
55
+ if (!showArchived) {
56
+ wsAgents = wsAgents.filter((a) => !a.archivedAt);
57
+ }
58
+
59
+ if (timeRange !== "all") {
60
+ wsAgents = wsAgents.filter((a) => isRecent(a.lastUserMessageAt));
61
+ }
62
+
63
+ return wsAgents.length > 0 ? ws : null;
64
+ })
65
+ .filter((ws): ws is WorkspaceEntry => ws !== null);
66
+ if (filteredWorkspaces.length > 0) {
67
+ result.push({ ...project, workspaces: filteredWorkspaces });
68
+ }
69
+ }
70
+ return result;
71
+ }, [projects, agentsByWorkspace, timeRange, isRecent, showArchived]);
72
+
73
+ const filteredAgentsByWorkspace = useMemo(() => {
74
+ const map = new Map<string, AgentEntry[]>();
75
+ for (const project of filteredProjects) {
76
+ for (const ws of project.workspaces) {
77
+ let agents = (agentsByWorkspace.get(ws.id) ?? []);
78
+
79
+ // Filter out archived agents unless showArchived is true
80
+ if (!showArchived) {
81
+ agents = agents.filter((a) => !a.archivedAt);
82
+ }
83
+
84
+ if (timeRange !== "all") {
85
+ agents = agents.filter((a) => isRecent(a.lastUserMessageAt));
86
+ }
87
+
88
+ if (agents.length > 0) map.set(ws.id, agents);
89
+ }
90
+ }
91
+ return map;
92
+ }, [timeRange, filteredProjects, agentsByWorkspace, isRecent, showArchived]);
93
+
94
+ const autoExpand = useMemo(() => {
95
+ if (timeRange === "all") return {};
96
+ const result: Record<string, boolean> = {};
97
+ for (const project of filteredProjects) {
98
+ result[project.projectId] = true;
99
+ for (const ws of project.workspaces) {
100
+ result[ws.id] = true;
101
+ }
102
+ }
103
+ return result;
104
+ }, [timeRange, filteredProjects]);
105
+
106
+ return { filteredProjects, filteredAgentsByWorkspace, autoExpand };
107
+ }
@@ -0,0 +1,37 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Keyboard, Platform, type KeyboardEvent } from "react-native";
3
+
4
+ function currentKeyboardHeight(): number {
5
+ if (Platform.OS === "web") return 0;
6
+ return Math.max(0, Keyboard.metrics()?.height ?? 0);
7
+ }
8
+
9
+ /**
10
+ * Returns the native keyboard height, including a keyboard that was already
11
+ * opened by an auto-focused input before the effect subscribed.
12
+ */
13
+ export function useKeyboardInset(enabled: boolean): number {
14
+ const [height, setHeight] = useState(() => enabled ? currentKeyboardHeight() : 0);
15
+
16
+ useEffect(() => {
17
+ if (!enabled || Platform.OS === "web") {
18
+ setHeight(0);
19
+ return;
20
+ }
21
+
22
+ const showEvent = Platform.OS === "ios" ? "keyboardWillChangeFrame" : "keyboardDidShow";
23
+ const hideEvent = Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide";
24
+ const update = (event: KeyboardEvent) => setHeight(Math.max(0, event.endCoordinates.height));
25
+ const showSubscription = Keyboard.addListener(showEvent, update);
26
+ const hideSubscription = Keyboard.addListener(hideEvent, () => setHeight(0));
27
+
28
+ // autoFocus can open the keyboard before useEffect has attached listeners.
29
+ setHeight(currentKeyboardHeight());
30
+ return () => {
31
+ showSubscription.remove();
32
+ hideSubscription.remove();
33
+ };
34
+ }, [enabled]);
35
+
36
+ return height;
37
+ }
@@ -0,0 +1,70 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import type { RefObject } from "react";
3
+ import type { View } from "react-native";
4
+ import { pointerX, trackHorizontalPointer } from "../web";
5
+
6
+ export interface ResizableWidthOptions {
7
+ initialWidth: number;
8
+ minWidth: number;
9
+ maxWidth: number;
10
+ /** Direction the panel grows. "left" = dragging left increases width (right-edge panel). */
11
+ direction?: "left" | "right";
12
+ }
13
+
14
+ export interface ResizableWidthResult {
15
+ width: number;
16
+ isDragging: boolean;
17
+ isHovered: boolean;
18
+ panelRef: RefObject<View | null>;
19
+ onPointerDown: (e: unknown) => void;
20
+ onPointerEnter: () => void;
21
+ onPointerLeave: () => void;
22
+ }
23
+
24
+ /**
25
+ * Pointer-based resizable width hook.
26
+ *
27
+ * Uses document-level pointer tracking on web and remains inert on native.
28
+ */
29
+ export function useResizableWidth({
30
+ initialWidth,
31
+ minWidth,
32
+ maxWidth,
33
+ direction = "left",
34
+ }: ResizableWidthOptions): ResizableWidthResult {
35
+ const [width, setWidth] = useState(initialWidth);
36
+ const [isDragging, setIsDragging] = useState(false);
37
+ const [isHovered, setIsHovered] = useState(false);
38
+ const panelRef = useRef<View | null>(null);
39
+ const cleanupRef = useRef<(() => void) | null>(null);
40
+
41
+ useEffect(() => () => cleanupRef.current?.(), []);
42
+
43
+ const onPointerDown = useCallback((event: unknown) => {
44
+ const startX = pointerX(event);
45
+ if (startX === null) return;
46
+ cleanupRef.current?.();
47
+ const startWidth = width;
48
+ setIsDragging(true);
49
+ cleanupRef.current = trackHorizontalPointer(
50
+ (clientX) => {
51
+ const delta = direction === "left" ? startX - clientX : clientX - startX;
52
+ setWidth(Math.max(minWidth, Math.min(maxWidth, startWidth + delta)));
53
+ },
54
+ () => {
55
+ cleanupRef.current = null;
56
+ setIsDragging(false);
57
+ },
58
+ );
59
+ }, [direction, maxWidth, minWidth, width]);
60
+
61
+ return {
62
+ width,
63
+ isDragging,
64
+ isHovered,
65
+ panelRef,
66
+ onPointerDown,
67
+ onPointerEnter: () => setIsHovered(true),
68
+ onPointerLeave: () => setIsHovered(false),
69
+ };
70
+ }