@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,108 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { Animated, Easing, Text, TouchableOpacity, View, type ViewStyle, type TextStyle } from "react-native";
3
+ import { type PluginSurfaceProps } from "@getpaseo/plugin/client";
4
+ import { copyText } from "@getpaseo/plugin/client/react-native";
5
+ import { type AgentEntry } from "../../shared/overview-types";
6
+ import { BadgeWithTooltip } from "./Tooltip";
7
+ import { statusColor, statusRowBackground } from "./Tooltip";
8
+
9
+ /** Format an ISO timestamp as a compact relative time like "3m ago" / "2h ago" / "5d ago". */
10
+ function formatRelativeTime(iso: string | null | undefined): string {
11
+ if (!iso) return "";
12
+ const then = new Date(iso).getTime();
13
+ if (Number.isNaN(then)) return "";
14
+ const diffMs = Date.now() - then;
15
+ if (diffMs < 0) return "just now";
16
+ const sec = Math.floor(diffMs / 1000);
17
+ if (sec < 60) return "just now";
18
+ const min = Math.floor(sec / 60);
19
+ if (min < 60) return `${min}m ago`;
20
+ const hour = Math.floor(min / 60);
21
+ if (hour < 24) return `${hour}h ago`;
22
+ const day = Math.floor(hour / 24);
23
+ if (day < 30) return `${day}d ago`;
24
+ const month = Math.floor(day / 30);
25
+ if (month < 12) return `${month}mo ago`;
26
+ const year = Math.floor(month / 12);
27
+ return `${year}y ago`;
28
+ }
29
+
30
+ export function AgentRow({ agent, theme, compact, onSelect }: { agent: AgentEntry; theme: PluginSurfaceProps["theme"]; compact: boolean; onSelect: () => void }) {
31
+ const activityAt = agent.lastUserMessageAt ?? agent.updatedAt;
32
+ const activityLabel = formatRelativeTime(activityAt);
33
+ const activityTooltip = activityAt ? new Date(activityAt).toLocaleString() : "";
34
+
35
+ const isArchived = !!agent.archivedAt;
36
+
37
+ const rowBg = statusRowBackground(agent.status, agent.requiresAttention);
38
+ const [copied, setCopied] = useState(false);
39
+ const copyFade = useRef(new Animated.Value(0)).current;
40
+
41
+ useEffect(() => {
42
+ if (!copied) return;
43
+ Animated.timing(copyFade, { toValue: 1, duration: 150, easing: Easing.in(Easing.ease), useNativeDriver: true }).start();
44
+ const timer = setTimeout(() => {
45
+ Animated.timing(copyFade, { toValue: 0, duration: 300, easing: Easing.out(Easing.ease), useNativeDriver: true }).start();
46
+ setTimeout(() => setCopied(false), 300);
47
+ }, 1200);
48
+ return () => clearTimeout(timer);
49
+ }, [copied, copyFade]);
50
+
51
+ const styles = useMemo(
52
+ () =>
53
+ ({
54
+ row: {
55
+ flexDirection: "row" as const,
56
+ alignItems: "center" as const,
57
+ paddingHorizontal: 12,
58
+ paddingLeft: 32,
59
+ paddingVertical: 6,
60
+ backgroundColor: rowBg || undefined,
61
+ opacity: isArchived ? 0.5 : 1,
62
+ } as ViewStyle,
63
+ title: {
64
+ color: theme.colors.foreground,
65
+ fontSize: 12,
66
+ flex: 1,
67
+ textDecorationLine: isArchived ? "line-through" as const : "none" as const,
68
+ } as TextStyle,
69
+ meta: {
70
+ color: theme.colors.foregroundMuted,
71
+ fontSize: 11,
72
+ marginLeft: 8,
73
+ } as TextStyle,
74
+ }),
75
+ [theme, compact, rowBg, isArchived],
76
+ );
77
+
78
+ const handleLongPress = useCallback(() => {
79
+ void copyText(agent.id).then(() => setCopied(true)).catch(() => {});
80
+ }, [agent.id]);
81
+
82
+ return (
83
+ <TouchableOpacity onPress={onSelect} onLongPress={handleLongPress} activeOpacity={0.7}>
84
+ <View style={styles.row}>
85
+ <Text style={styles.title} numberOfLines={1}>
86
+ {agent.title ?? agent.id.slice(0, 8)}
87
+ {isArchived ? " (archived)" : ""}
88
+ </Text>
89
+ {activityLabel ? (
90
+ <BadgeWithTooltip
91
+ label={activityLabel}
92
+ tooltipLines={[{ key: "Last active", value: activityTooltip }]}
93
+ theme={theme}
94
+ color={statusColor(agent.status, theme, agent.requiresAttention)}
95
+ pulsing={
96
+ agent.status === "running" ||
97
+ agent.status === "needs_input" ||
98
+ agent.status === "initializing"
99
+ }
100
+ />
101
+ ) : null}
102
+ <Animated.View style={[{ position: "absolute", right: 8, top: 0, paddingHorizontal: 4, paddingVertical: 1, borderRadius: 3, backgroundColor: theme.colors.accent + "22" }, { opacity: copyFade }]}>
103
+ <Text style={{ color: theme.colors.accentForeground, fontSize: 10, fontWeight: "600" as const }}>Copied!</Text>
104
+ </Animated.View>
105
+ </View>
106
+ </TouchableOpacity>
107
+ );
108
+ }
@@ -0,0 +1,119 @@
1
+ import React, { useEffect, useMemo, useRef } from "react";
2
+ import { Animated, Easing, Text, TouchableOpacity, View, type TextStyle, type ViewStyle } from "react-native";
3
+ import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
4
+ import type { TooltipLine } from "../../shared/overview-types";
5
+ import { useTooltip } from "./Tooltip";
6
+
7
+ /**
8
+ * A badge that, on hover, asks the surface root to render a tooltip near the
9
+ * badge. Rendering at the root avoids being clipped by the card's
10
+ * `overflow: hidden`.
11
+ *
12
+ * Uses RN Web's `onMouseEnter`/`onMouseLeave` (not in the RN type defs) and
13
+ * `measureInWindow` to compute window coordinates.
14
+ */
15
+ export function BadgeWithTooltip({
16
+ label,
17
+ tooltipLines,
18
+ theme,
19
+ color,
20
+ pulsing,
21
+ onPress,
22
+ }: {
23
+ label: string;
24
+ tooltipLines: TooltipLine[];
25
+ theme: PluginSurfaceProps["theme"];
26
+ /** Optional semantic color; when provided, the badge uses it as a tinted
27
+ * background with matching foreground. Falls back to neutral gray. */
28
+ color?: string;
29
+ /** Pulse the badge opacity — used for active (running/initializing) statuses. */
30
+ pulsing?: boolean;
31
+ /** Called when the badge is clicked/tapped. */
32
+ onPress?: () => void;
33
+ }) {
34
+ const tooltipCtx = useTooltip();
35
+ const wrapperRef = useRef<View | null>(null);
36
+ const pulse = useRef(new Animated.Value(1)).current;
37
+
38
+ useEffect(() => {
39
+ if (!pulsing) {
40
+ pulse.setValue(1);
41
+ return;
42
+ }
43
+ const loop = Animated.loop(
44
+ Animated.sequence([
45
+ Animated.timing(pulse, {
46
+ toValue: 0.45,
47
+ duration: 800,
48
+ easing: Easing.inOut(Easing.ease),
49
+ useNativeDriver: true,
50
+ }),
51
+ Animated.timing(pulse, {
52
+ toValue: 1,
53
+ duration: 800,
54
+ easing: Easing.inOut(Easing.ease),
55
+ useNativeDriver: true,
56
+ }),
57
+ ]),
58
+ );
59
+ loop.start();
60
+ return () => loop.stop();
61
+ }, [pulsing, pulse]);
62
+
63
+ const styles = useMemo(
64
+ () =>
65
+ ({
66
+ wrapper: {
67
+ marginLeft: 8,
68
+ flexShrink: 0,
69
+ } as ViewStyle,
70
+ badge: {
71
+ paddingHorizontal: 6,
72
+ paddingVertical: 2,
73
+ borderRadius: 4,
74
+ backgroundColor: color ? color + "22" : theme.colors.foregroundMuted + "14",
75
+ } as ViewStyle,
76
+ badgeText: {
77
+ color: color ?? theme.colors.foregroundMuted,
78
+ fontSize: 10,
79
+ fontWeight: color ? ("600" as const) : ("400" as const),
80
+ } as TextStyle,
81
+ }),
82
+ [theme, color],
83
+ );
84
+
85
+ const handleEnter = React.useCallback(() => {
86
+ if (!tooltipCtx || !wrapperRef.current) return;
87
+ wrapperRef.current.measureInWindow((x, y, width, height) => {
88
+ tooltipCtx.show({
89
+ x: x + width,
90
+ y: y + height,
91
+ lines: tooltipLines,
92
+ });
93
+ });
94
+ }, [tooltipCtx, tooltipLines]);
95
+
96
+ const handleLeave = React.useCallback(() => {
97
+ tooltipCtx?.hide();
98
+ }, [tooltipCtx]);
99
+
100
+ const handlePress = React.useCallback(() => {
101
+ onPress?.();
102
+ }, [onPress]);
103
+
104
+ return (
105
+ <TouchableOpacity
106
+ ref={wrapperRef}
107
+ style={styles.wrapper}
108
+ onPress={handlePress}
109
+ {...({
110
+ onMouseEnter: handleEnter,
111
+ onMouseLeave: handleLeave,
112
+ } as Record<string, unknown>)}
113
+ >
114
+ <Animated.View style={[styles.badge, pulsing ? { opacity: pulse } : null]}>
115
+ <Text style={styles.badgeText}>{label}</Text>
116
+ </Animated.View>
117
+ </TouchableOpacity>
118
+ );
119
+ }
@@ -0,0 +1,121 @@
1
+ import React, { useMemo } from "react";
2
+ import { Text, TouchableOpacity, View, type ViewStyle, type TextStyle } from "react-native";
3
+ import { type PluginSurfaceProps } from "@getpaseo/plugin/client";
4
+ import { Icon } from "@getpaseo/plugin/client/react-native";
5
+ import { type WorkspaceEntry, type AgentEntry, KIND_LABEL } from "../../shared/overview-types";
6
+ import { BadgeWithTooltip, statusColor } from "./Tooltip";
7
+
8
+ export function BranchRow({
9
+ workspace,
10
+ agents,
11
+ expanded,
12
+ onToggle,
13
+ onOpenDirectory,
14
+ onCreateAgent,
15
+ theme,
16
+ compact,
17
+ hostLabel,
18
+ }: {
19
+ workspace: WorkspaceEntry;
20
+ agents: AgentEntry[];
21
+ expanded: boolean;
22
+ onToggle: () => void;
23
+ onOpenDirectory?: () => void;
24
+ onCreateAgent: () => void;
25
+ theme: PluginSurfaceProps["theme"];
26
+ compact: boolean;
27
+ hostLabel: string;
28
+ }) {
29
+ const branchLabel = useMemo(() => {
30
+ const b = workspace.gitRuntime?.currentBranch;
31
+ if (b) return b.replace(/^heads\//, "");
32
+ return workspace.name;
33
+ }, [workspace.gitRuntime?.currentBranch, workspace.name]);
34
+
35
+ const styles = useMemo(
36
+ () =>
37
+ ({
38
+ row: {
39
+ flexDirection: "row" as const,
40
+ alignItems: "center" as const,
41
+ paddingHorizontal: 12,
42
+ paddingVertical: 8,
43
+ } as ViewStyle,
44
+ chevron: {
45
+ width: 16,
46
+ marginRight: 4,
47
+ alignItems: "center" as const,
48
+ justifyContent: "center" as const,
49
+ } as ViewStyle,
50
+ name: {
51
+ color: theme.colors.foreground,
52
+ fontSize: 13,
53
+ fontWeight: "500" as const,
54
+ flex: 1,
55
+ } as TextStyle,
56
+ createButton: {
57
+ width: 26,
58
+ height: 26,
59
+ marginLeft: 8,
60
+ alignItems: "center" as const,
61
+ justifyContent: "center" as const,
62
+ } as ViewStyle,
63
+ }),
64
+ [theme],
65
+ );
66
+
67
+ // Compose tooltip content as key/value rows.
68
+ const tooltipLines = useMemo(() => {
69
+ const lines: Array<{ key: string; value: string }> = [
70
+ { key: "Kind", value: KIND_LABEL[workspace.workspaceKind] ?? workspace.workspaceKind },
71
+ { key: "Branch", value: branchLabel },
72
+ { key: "Directory", value: workspace.workspaceDirectory },
73
+ { key: "Host", value: hostLabel },
74
+ { key: "Status", value: workspace.status },
75
+ ];
76
+ if (workspace.statusEnteredAt) {
77
+ lines.push({ key: "Since", value: new Date(workspace.statusEnteredAt).toLocaleString() });
78
+ }
79
+ if (agents.length > 0) {
80
+ lines.push({ key: "Agents", value: String(agents.length) });
81
+ }
82
+ return lines;
83
+ }, [workspace, branchLabel, hostLabel, agents.length]);
84
+
85
+ return (
86
+ <TouchableOpacity onPress={onToggle} activeOpacity={0.7}>
87
+ <View style={styles.row}>
88
+ <View style={styles.chevron}>
89
+ <Icon name={expanded ? "ChevronDown" : "ChevronRight"} color={theme.colors.foregroundMuted} size={10} />
90
+ </View>
91
+ <Text style={styles.name} numberOfLines={1}>
92
+ {branchLabel}
93
+ </Text>
94
+ <BadgeWithTooltip
95
+ label={KIND_LABEL[workspace.workspaceKind] ?? workspace.workspaceKind}
96
+ tooltipLines={tooltipLines}
97
+ theme={theme}
98
+ color={statusColor(workspace.status, theme)}
99
+ pulsing={
100
+ workspace.status === "running" ||
101
+ workspace.status === "needs_input" ||
102
+ workspace.status === "initializing"
103
+ }
104
+ onPress={onOpenDirectory}
105
+ />
106
+ <TouchableOpacity
107
+ accessibilityRole="button"
108
+ accessibilityLabel={`在 ${branchLabel} 新建 Agent`}
109
+ style={styles.createButton}
110
+ onPress={(event) => {
111
+ event.stopPropagation();
112
+ onCreateAgent();
113
+ }}
114
+ hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
115
+ >
116
+ <Icon name="Plus" color={theme.colors.foregroundMuted} size={18} />
117
+ </TouchableOpacity>
118
+ </View>
119
+ </TouchableOpacity>
120
+ );
121
+ }
@@ -0,0 +1,18 @@
1
+ import React from "react";
2
+ import { Icon } from "@getpaseo/plugin/client/react-native";
3
+ import type { OpenMenu } from "../workspace-creator/types";
4
+
5
+ export function ControlGlyph({ kind, color }: { kind: Exclude<OpenMenu, null>; color: string }) {
6
+ const names: Record<Exclude<OpenMenu, null>, string> = {
7
+ project: "Folder",
8
+ host: "Circle",
9
+ isolation: "GitBranch",
10
+ base: "GitBranch",
11
+ launch: "MessageCircle",
12
+ provider: "Boxes",
13
+ model: "Atom",
14
+ mode: "ListFilter",
15
+ thinking: "Brain",
16
+ };
17
+ return <Icon name={names[kind]} color={color} size={16} />;
18
+ }
@@ -0,0 +1,165 @@
1
+ import React, { useMemo, useRef, useState } from "react";
2
+ import { Pressable, ScrollView, Text, View } from "react-native";
3
+ import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
4
+ import type { ColorLevel, HeatmapDay } from "../hooks/useAgentActivityHeatmap";
5
+ import { buildHeatmapGrid, getColorLevel, getColors, getCellColor, WEEKDAY_LABELS } from "../hooks/useAgentActivityHeatmap";
6
+
7
+ export interface HeatmapWorkspaceMeta {
8
+ projectId: string;
9
+ projectDisplayName: string;
10
+ projectOrder: number;
11
+ }
12
+
13
+ interface HeatmapCalendarProps {
14
+ days: HeatmapDay[];
15
+ maxCount: number;
16
+ theme: PluginSurfaceProps["theme"];
17
+ selectedDate: string | null;
18
+ onSelectDate: (date: string) => void;
19
+ workspaceMeta: Map<string, HeatmapWorkspaceMeta>;
20
+ }
21
+
22
+ function formatDisplayDate(date: string) {
23
+ const [year, month, day] = date.split("-");
24
+ return `${year}年${Number(month)}月${Number(day)}日`;
25
+ }
26
+
27
+ export const HeatmapCalendar = React.memo(function HeatmapCalendar({ days, maxCount, theme, selectedDate, onSelectDate, workspaceMeta }: HeatmapCalendarProps) {
28
+ const { gridStart, totalWeeks, monthLabels } = useMemo(() => buildHeatmapGrid(365), []);
29
+ const colors = getColors(theme);
30
+ const CELL = 12;
31
+ const GAP = 3;
32
+ const COLUMN_W = CELL + GAP;
33
+ const ROW_H = CELL + GAP;
34
+ const GRID_WIDTH = totalWeeks * COLUMN_W;
35
+ const TOOLTIP_WIDTH = 230;
36
+ // Left offset of the grid inside the outer container (weekday label column width + its padding)
37
+ const GRID_LEFT_OFFSET = 34;
38
+ // Height occupied by the month-label row above the heatmap grid (approx.)
39
+ const MONTH_LABEL_ROW_H = 24;
40
+ // Tooltip left base: GRID_LEFT_OFFSET + CELL (skip label col + one cell width)
41
+ const TOOLTIP_LEFT_BASE = GRID_LEFT_OFFSET + CELL;
42
+ // Tooltip vertical bounds
43
+ const TOOLTIP_TOP_MIN = 38;
44
+ const TOOLTIP_TOP_MAX_BASE = 60;
45
+ // Weekday label column width (reused for bottom-calculation as label-row height proxy)
46
+ const WEEKDAY_COL_W = 34;
47
+ // Height per project line in tooltip
48
+ const PROJECT_LINE_H = 19;
49
+ // Outer container padding
50
+ const OUTER_PAD = 12;
51
+ const [hoveredCell, setHoveredCell] = useState<{ day: HeatmapDay; week: number; weekday: number } | null>(null);
52
+ const [scrollX, setScrollX] = useState(0);
53
+ const [calendarSize, setCalendarSize] = useState({ width: 0, height: 0 });
54
+ const scrollRef = useRef<ScrollView | null>(null);
55
+ const didAutoScroll = useRef(false);
56
+ const hoveredDay = hoveredCell?.day ?? null;
57
+ const gridCenterOffset = Math.max(0, (calendarSize.width - MONTH_LABEL_ROW_H - GRID_LEFT_OFFSET - GRID_WIDTH) / 2);
58
+
59
+ const hoveredProjects = useMemo(() => {
60
+ if (!hoveredDay) return [];
61
+ const counts = new Map<string, { id: string; name: string; count: number; order: number }>();
62
+ for (const agent of hoveredDay.agents) {
63
+ const meta = agent.workspaceId ? workspaceMeta.get(agent.workspaceId) : undefined;
64
+ const key = meta?.projectId ?? "unknown";
65
+ const current = counts.get(key);
66
+ counts.set(key, { id: key, name: meta?.projectDisplayName ?? "其他项目", count: (current?.count ?? 0) + 1, order: meta?.projectOrder ?? Number.MAX_SAFE_INTEGER });
67
+ }
68
+ return [...counts.values()].sort((a, b) => a.order - b.order);
69
+ }, [hoveredDay, workspaceMeta]);
70
+
71
+ const rows = useMemo(() => {
72
+ const values = new Map(days.map((day) => [day.date, day]));
73
+ const firstDate = days[0]?.date;
74
+ const lastDate = days[days.length - 1]?.date;
75
+ return Array.from({ length: 7 }, (_, weekday) =>
76
+ Array.from({ length: totalWeeks }, (_, week) => {
77
+ const date = new Date(gridStart);
78
+ date.setDate(gridStart.getDate() + week * 7 + weekday);
79
+ const dateKey = [date.getFullYear(), String(date.getMonth() + 1).padStart(2, "0"), String(date.getDate()).padStart(2, "0")].join("-");
80
+ const isPadding = !firstDate || !lastDate || dateKey < firstDate || dateKey > lastDate;
81
+ const value = values.get(dateKey);
82
+ return { date: dateKey, count: isPadding ? 0 : (value?.count ?? 0), agents: isPadding ? [] : (value?.agents ?? []), isPadding };
83
+ }),
84
+ );
85
+ }, [days, gridStart, totalWeeks]);
86
+
87
+ return (
88
+ <View onLayout={(event) => setCalendarSize({ width: event.nativeEvent.layout.width, height: event.nativeEvent.layout.height })} style={{ marginBottom: OUTER_PAD, padding: OUTER_PAD, borderRadius: 10, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface1, position: "relative" }}>
89
+ <View style={{ flexDirection: "row", alignItems: "baseline", justifyContent: "space-between", marginBottom: 12 }}>
90
+ <Text style={{ color: colors.foregroundMuted, fontSize: 11 }}>近一年每日 Agent 活动</Text>
91
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
92
+ <Text style={{ color: colors.foregroundMuted, fontSize: 9 }}>少</Text>
93
+ {(["none", "low", "medium", "high", "very-high"] as ColorLevel[]).map((level) => (
94
+ <View key={level} style={{ width: CELL, height: CELL, borderRadius: 3, backgroundColor: getCellColor(level, theme) }} />
95
+ ))}
96
+ <Text style={{ color: colors.foregroundMuted, fontSize: 9 }}>多</Text>
97
+ </View>
98
+ </View>
99
+
100
+ {hoveredCell ? (
101
+ <View pointerEvents="none" style={{ position: "absolute", left: (() => { const cellLeft = TOOLTIP_LEFT_BASE + gridCenterOffset + hoveredCell.week * COLUMN_W - scrollX; return cellLeft + CELL + 10 + TOOLTIP_WIDTH <= calendarSize.width - OUTER_PAD ? cellLeft + CELL + 10 : Math.max(OUTER_PAD, cellLeft - TOOLTIP_WIDTH - 10); })(), top: Math.max(TOOLTIP_TOP_MIN, Math.min(TOOLTIP_TOP_MAX_BASE + hoveredCell.weekday * ROW_H - 8, calendarSize.height - (WEEKDAY_COL_W + Math.max(1, hoveredProjects.length) * PROJECT_LINE_H) - OUTER_PAD)), zIndex: 20, width: TOOLTIP_WIDTH, paddingHorizontal: OUTER_PAD, paddingVertical: 10, borderRadius: 8, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface0, shadowColor: "#000", shadowOpacity: 0.22, shadowRadius: 10, shadowOffset: { width: 0, height: 5 }, elevation: 10 }}>
102
+ <Text style={{ color: colors.foreground, fontSize: 11, fontWeight: "600" }}>{formatDisplayDate(hoveredCell.day.date)}</Text>
103
+ {hoveredProjects.length > 0 ? hoveredProjects.map((project) => (
104
+ <View key={project.id} style={{ flexDirection: "row", justifyContent: "space-between", gap: 16, marginTop: 5 }}>
105
+ <Text numberOfLines={1} style={{ color: colors.foregroundMuted, fontSize: 10, flex: 1 }}>{project.name}</Text>
106
+ <Text style={{ color: colors.foreground, fontSize: 10 }}>{project.count} 个 Agent</Text>
107
+ </View>
108
+ )) : <Text style={{ color: colors.foregroundMuted, fontSize: 10, marginTop: 5 }}>当天没有项目活动</Text>}
109
+ </View>
110
+ ) : null}
111
+
112
+ <View style={{ flexDirection: "row", justifyContent: gridCenterOffset > 0 ? "center" : "flex-start" }}>
113
+ <View style={{ width: WEEKDAY_COL_W, paddingTop: 20 }}>
114
+ {WEEKDAY_LABELS.map((label) => (
115
+ <View key={label} style={{ height: ROW_H, justifyContent: "center", alignItems: "flex-end", paddingRight: 6 }}>
116
+ <Text style={{ color: colors.foregroundMuted, fontSize: 9, lineHeight: ROW_H }}>{label}</Text>
117
+ </View>
118
+ ))}
119
+ </View>
120
+ <ScrollView
121
+ ref={scrollRef}
122
+ horizontal
123
+ showsHorizontalScrollIndicator={false}
124
+ scrollEventThrottle={16}
125
+ onLayout={(event) => {
126
+ const viewportWidth = event.nativeEvent.layout.width;
127
+ if (GRID_WIDTH <= viewportWidth) {
128
+ didAutoScroll.current = false;
129
+ return;
130
+ }
131
+ if (!didAutoScroll.current) {
132
+ didAutoScroll.current = true;
133
+ setTimeout(() => scrollRef.current?.scrollToEnd({ animated: false }), 0);
134
+ }
135
+ }}
136
+ onScroll={(event) => setScrollX(event.nativeEvent.contentOffset.x)}
137
+ style={{ flexGrow: 0 }}
138
+ >
139
+ <View style={{ width: GRID_WIDTH }}>
140
+ <View style={{ height: 20, position: "relative" }}>
141
+ {monthLabels.map(({ weekIndex, label }) => (
142
+ <Text key={weekIndex} style={{ position: "absolute", left: weekIndex * COLUMN_W, color: colors.foregroundMuted, fontSize: 9 }}>{label}</Text>
143
+ ))}
144
+ </View>
145
+ {rows.map((row, weekday) => (
146
+ <View key={weekday} style={{ flexDirection: "row", height: ROW_H, gap: GAP }}>
147
+ {row.map((cell, week) => (
148
+ <Pressable key={cell.date} disabled={cell.isPadding} accessibilityRole="button" accessibilityLabel={`${cell.date}: ${cell.count} 个 Agent`} onHoverIn={() => setHoveredCell({ day: cell, week, weekday })} onHoverOut={() => setHoveredCell((current) => current?.day.date === cell.date ? null : current)} onFocus={() => setHoveredCell({ day: cell, week, weekday })} onBlur={() => setHoveredCell((current) => current?.day.date === cell.date ? null : current)} onPress={() => onSelectDate(cell.date)}>
149
+ {selectedDate === cell.date ? (
150
+ <View style={{ width: CELL + 2, height: CELL + 2, marginLeft: -1, marginTop: -1, borderRadius: 4, padding: 2, backgroundColor: colors.accent, shadowColor: colors.accent, shadowOpacity: 0.45, shadowRadius: 3, shadowOffset: { width: 0, height: 0 }, elevation: 4 }}>
151
+ <View style={{ flex: 1, borderRadius: 2, backgroundColor: getCellColor(getColorLevel(cell.count, maxCount), theme) }} />
152
+ </View>
153
+ ) : (
154
+ <View style={{ width: CELL, height: CELL, borderRadius: 3, backgroundColor: cell.isPadding ? colors.surface1 : getCellColor(getColorLevel(cell.count, maxCount), theme) }} />
155
+ )}
156
+ </Pressable>
157
+ ))}
158
+ </View>
159
+ ))}
160
+ </View>
161
+ </ScrollView>
162
+ </View>
163
+ </View>
164
+ );
165
+ });
@@ -0,0 +1,135 @@
1
+ import React, { useMemo, useState } from "react";
2
+ import { Pressable, Text, 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 { ProviderBrandIcon } from "./ProviderBrandIcon";
6
+ import { selectableModels } from "../workspace-creator/provider-selection";
7
+ import { opaqueSurfaceColor } from "../workspace-creator/ui-utils";
8
+ import type { ComposerSelection, ProviderEntry } from "../workspace-creator/types";
9
+ import { createMenuStyles } from "./workspace-creator-styles";
10
+
11
+ export function ModelBrowserMenu({
12
+ providers,
13
+ selection,
14
+ onSelect,
15
+ theme,
16
+ }: {
17
+ providers: ProviderEntry[];
18
+ selection: ComposerSelection | null;
19
+ onSelect: (id: string) => void;
20
+ theme: PluginSurfaceProps["theme"];
21
+ }) {
22
+ const [providerId, setProviderId] = useState<string | null>(null);
23
+ const [searchQuery, setSearchQuery] = useState("");
24
+ const styles = useMemo(() => createMenuStyles(theme), [theme]);
25
+ const backgroundColor = opaqueSurfaceColor(theme.colors.surface0, theme.colors.foreground);
26
+ const provider = providerId ? providers.find((entry) => entry.provider === providerId) ?? null : null;
27
+ const models = selectableModels(provider);
28
+ const normalizedQuery = searchQuery.trim().toLocaleLowerCase();
29
+ const filteredModels = models.filter((model) =>
30
+ [model.label, model.id].some((value) => value.toLocaleLowerCase().includes(normalizedQuery)),
31
+ );
32
+ const filteredProviders = providers.filter((entry) => {
33
+ if (!normalizedQuery) return true;
34
+ if ([entry.label, entry.provider].some((value) => value?.toLocaleLowerCase().includes(normalizedQuery))) {
35
+ return true;
36
+ }
37
+ return selectableModels(entry).some((model) =>
38
+ [model.label, model.id].some((value) => value.toLocaleLowerCase().includes(normalizedQuery)),
39
+ );
40
+ });
41
+
42
+ const search = (
43
+ <>
44
+ <View style={[styles.menuSearchRow, { backgroundColor }]}>
45
+ <Icon name="Search" color={theme.colors.foregroundMuted} size={15} />
46
+ <TextInput
47
+ nativeID="workspace-create-model-search"
48
+ autoFocus
49
+ value={searchQuery}
50
+ onChangeText={setSearchQuery}
51
+ placeholder={provider ? "搜索 Model" : "搜索 Provider 或 Model"}
52
+ placeholderTextColor={theme.colors.foregroundMuted}
53
+ selectionColor={theme.colors.accent}
54
+ style={styles.menuSearchInput}
55
+ />
56
+ {searchQuery ? (
57
+ <Pressable accessibilityLabel="清除搜索" onPress={() => setSearchQuery("")}>
58
+ <Icon name="X" color={theme.colors.foregroundMuted} size={14} />
59
+ </Pressable>
60
+ ) : null}
61
+ </View>
62
+ <View style={styles.modelBrowserSeparator} />
63
+ </>
64
+ );
65
+
66
+ return (
67
+ <View style={[styles.menu, styles.menuAbove, styles.modelBrowserMenu, { left: 0 }]}>
68
+ {provider ? (
69
+ <>
70
+ <Pressable onPress={() => setProviderId(null)} style={[styles.modelBrowserHeader, { backgroundColor }]}>
71
+ <Icon name="ChevronLeft" color={theme.colors.foregroundMuted} size={16} />
72
+ <ProviderBrandIcon providerId={provider.provider} color={theme.colors.foregroundMuted} size={16} />
73
+ <Text style={styles.modelBrowserTitle} numberOfLines={1}>{provider.label ?? provider.provider}</Text>
74
+ </Pressable>
75
+ <View style={styles.modelBrowserSeparator} />
76
+ {search}
77
+ <ScrollView style={[styles.menuScroll, { backgroundColor }]} contentContainerStyle={{ backgroundColor }}>
78
+ {filteredModels.map((model) => {
79
+ const selected = selection?.providerId === provider.provider && selection.modelId === model.id;
80
+ return (
81
+ <Pressable
82
+ key={model.id}
83
+ onPress={() => onSelect(`${provider.provider}::${model.id}`)}
84
+ style={[styles.menuItem, { backgroundColor }]}
85
+ >
86
+ <View style={styles.menuTextGroup}>
87
+ <Text style={styles.menuLabel} numberOfLines={1}>{model.label}</Text>
88
+ {model.id !== model.label ? <Text style={styles.menuDetail} numberOfLines={1}>{model.id}</Text> : null}
89
+ </View>
90
+ <View style={styles.modelBrowserTrailing}>
91
+ {selected ? <Icon name="Check" color={theme.colors.foregroundMuted} size={16} /> : null}
92
+ </View>
93
+ </Pressable>
94
+ );
95
+ })}
96
+ {filteredModels.length === 0 ? <Text style={styles.menuEmptyText}>没有匹配结果</Text> : null}
97
+ </ScrollView>
98
+ </>
99
+ ) : (
100
+ <>
101
+ <View style={[styles.modelBrowserHeading, { backgroundColor }]}>
102
+ <Text style={styles.modelBrowserSectionLabel}>Providers</Text>
103
+ </View>
104
+ {search}
105
+ <ScrollView style={[styles.menuScroll, { backgroundColor }]} contentContainerStyle={{ backgroundColor }}>
106
+ {filteredProviders.map((entry, index) => {
107
+ const count = selectableModels(entry).length;
108
+ return (
109
+ <View key={entry.provider}>
110
+ {index > 0 ? <View style={styles.modelBrowserSeparator} /> : null}
111
+ <Pressable
112
+ onPress={() => {
113
+ const providerMatches = [entry.label, entry.provider].some((value) =>
114
+ value?.toLocaleLowerCase().includes(normalizedQuery),
115
+ );
116
+ if (providerMatches) setSearchQuery("");
117
+ setProviderId(entry.provider);
118
+ }}
119
+ style={[styles.menuItem, styles.modelBrowserProviderRow, { backgroundColor }]}
120
+ >
121
+ <ProviderBrandIcon providerId={entry.provider} color={theme.colors.foregroundMuted} size={16} />
122
+ <Text style={[styles.menuLabel, styles.modelBrowserProviderLabel]} numberOfLines={1}>{entry.label ?? entry.provider}</Text>
123
+ <Text style={styles.modelBrowserCount}>{count} {count === 1 ? "model" : "models"}</Text>
124
+ <Icon name="ChevronRight" color={theme.colors.foregroundMuted} size={16} />
125
+ </Pressable>
126
+ </View>
127
+ );
128
+ })}
129
+ {filteredProviders.length === 0 ? <Text style={styles.menuEmptyText}>没有匹配结果</Text> : null}
130
+ </ScrollView>
131
+ </>
132
+ )}
133
+ </View>
134
+ );
135
+ }