@opengeni/react 0.1.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.
- package/README.md +141 -0
- package/dist/index.d.ts +897 -0
- package/dist/index.js +3013 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -0
- package/src/approvals.ts +86 -0
- package/src/client.ts +52 -0
- package/src/commands/index.ts +17 -0
- package/src/commands/registry.ts +236 -0
- package/src/commands/types.ts +88 -0
- package/src/components/chat-composer.tsx +619 -0
- package/src/components/command-palette.tsx +94 -0
- package/src/components/fleet-tile.tsx +72 -0
- package/src/components/message-timeline.tsx +416 -0
- package/src/components/session-status.tsx +92 -0
- package/src/hooks/internal.ts +236 -0
- package/src/hooks/use-billing-usage.ts +51 -0
- package/src/hooks/use-composer.ts +213 -0
- package/src/hooks/use-environments.ts +118 -0
- package/src/hooks/use-file-attachments.ts +135 -0
- package/src/hooks/use-goal.ts +154 -0
- package/src/hooks/use-packs.ts +101 -0
- package/src/hooks/use-scheduled-tasks.ts +29 -0
- package/src/hooks/use-session-control.ts +85 -0
- package/src/hooks/use-session-events.ts +130 -0
- package/src/hooks/use-session.ts +33 -0
- package/src/hooks/use-slash-commands.ts +366 -0
- package/src/hooks/use-turn-queue.ts +229 -0
- package/src/hooks/use-workspace-sessions.ts +30 -0
- package/src/hooks/use-workspaces.ts +66 -0
- package/src/index.ts +115 -0
- package/src/lib/cn.ts +7 -0
- package/src/lib/format.ts +84 -0
- package/src/provider.tsx +57 -0
- package/src/timeline.ts +632 -0
- package/styles/index.css +157 -0
- package/styles/tokens.css +111 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { AnimatePresence, motion } from "motion/react";
|
|
2
|
+
import { argHint } from "../commands/registry";
|
|
3
|
+
import type { SlashCommand } from "../commands/types";
|
|
4
|
+
import { cn } from "../lib/cn";
|
|
5
|
+
|
|
6
|
+
export type CommandPaletteProps = {
|
|
7
|
+
open: boolean;
|
|
8
|
+
items: SlashCommand[];
|
|
9
|
+
highlight: number;
|
|
10
|
+
/** Hover/click selects a row. */
|
|
11
|
+
onHighlight: (index: number) => void;
|
|
12
|
+
/** Click runs the row (same path as Enter). */
|
|
13
|
+
onRun: (index: number) => void;
|
|
14
|
+
/** Footer arg hint shown in arg-hint mode (e.g. "<pause|resume>"). */
|
|
15
|
+
argHintText: string;
|
|
16
|
+
/** id used for aria-activedescendant wiring from the textarea. */
|
|
17
|
+
listboxId: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The slash-command palette: a popover anchored above the textarea, rendered
|
|
22
|
+
* entirely from the filtered registry. Dark-first, Linear/Vercel-calm, using
|
|
23
|
+
* the opengeni#46 design tokens. Full keyboard nav lives in useSlashCommands;
|
|
24
|
+
* this component is presentational + aria.
|
|
25
|
+
*/
|
|
26
|
+
export function CommandPalette({ open, items, highlight, onHighlight, onRun, argHintText, listboxId }: CommandPaletteProps) {
|
|
27
|
+
return (
|
|
28
|
+
<AnimatePresence>
|
|
29
|
+
{open ? (
|
|
30
|
+
<motion.div
|
|
31
|
+
initial={{ opacity: 0, y: 6, scale: 0.99 }}
|
|
32
|
+
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
33
|
+
exit={{ opacity: 0, y: 6, scale: 0.99 }}
|
|
34
|
+
transition={{ duration: 0.13, ease: "easeOut" }}
|
|
35
|
+
className={cn(
|
|
36
|
+
"absolute bottom-full left-0 right-0 z-20 mb-2 overflow-hidden",
|
|
37
|
+
"rounded-og-lg border border-og-border bg-og-surface-2 shadow-og-sm",
|
|
38
|
+
)}
|
|
39
|
+
>
|
|
40
|
+
<ul
|
|
41
|
+
id={listboxId}
|
|
42
|
+
role="listbox"
|
|
43
|
+
aria-label="Slash commands"
|
|
44
|
+
className="max-h-72 overflow-y-auto py-1"
|
|
45
|
+
>
|
|
46
|
+
{items.map((command, index) => {
|
|
47
|
+
const selected = index === highlight;
|
|
48
|
+
const hint = argHint(command.args);
|
|
49
|
+
return (
|
|
50
|
+
<li
|
|
51
|
+
key={command.name}
|
|
52
|
+
id={`${listboxId}-option-${index}`}
|
|
53
|
+
role="option"
|
|
54
|
+
aria-selected={selected}
|
|
55
|
+
onMouseEnter={() => onHighlight(index)}
|
|
56
|
+
onMouseDown={(event) => {
|
|
57
|
+
// Keep textarea focus; run on click.
|
|
58
|
+
event.preventDefault();
|
|
59
|
+
onRun(index);
|
|
60
|
+
}}
|
|
61
|
+
className={cn(
|
|
62
|
+
"mx-1 flex cursor-pointer items-center gap-2 rounded-og-md px-2.5 py-1.5",
|
|
63
|
+
"transition-colors duration-100",
|
|
64
|
+
selected ? "bg-og-accent/15 text-og-fg" : "text-og-fg-muted hover:bg-og-surface-3",
|
|
65
|
+
)}
|
|
66
|
+
>
|
|
67
|
+
<span className="flex min-w-0 items-baseline gap-1.5">
|
|
68
|
+
<span className={cn("font-mono text-[13px]", selected ? "text-og-accent" : "text-og-fg")}>
|
|
69
|
+
/{command.name}
|
|
70
|
+
</span>
|
|
71
|
+
{hint ? <span className="truncate font-mono text-[11px] text-og-fg-subtle">{hint}</span> : null}
|
|
72
|
+
</span>
|
|
73
|
+
<span className="ml-auto flex items-center gap-1.5">
|
|
74
|
+
{command.danger ? (
|
|
75
|
+
<span className="rounded-og-xs bg-og-status-failed/15 px-1 text-[10px] uppercase tracking-wide text-og-status-failed">
|
|
76
|
+
danger
|
|
77
|
+
</span>
|
|
78
|
+
) : null}
|
|
79
|
+
<span className="truncate text-[12px] text-og-fg-subtle max-sm:hidden">{command.description}</span>
|
|
80
|
+
</span>
|
|
81
|
+
</li>
|
|
82
|
+
);
|
|
83
|
+
})}
|
|
84
|
+
</ul>
|
|
85
|
+
{argHintText ? (
|
|
86
|
+
<div className="border-t border-og-border px-3 py-1.5 font-mono text-[11px] text-og-fg-subtle">
|
|
87
|
+
{argHintText}
|
|
88
|
+
</div>
|
|
89
|
+
) : null}
|
|
90
|
+
</motion.div>
|
|
91
|
+
) : null}
|
|
92
|
+
</AnimatePresence>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Session } from "@opengeni/sdk";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
import { formatRelativeTime, truncate } from "../lib/format";
|
|
4
|
+
import { SessionStatus } from "./session-status";
|
|
5
|
+
|
|
6
|
+
export type FleetTileProps = {
|
|
7
|
+
session: Session;
|
|
8
|
+
/** Overrides the derived title (metadata.title/name, else the initial message). */
|
|
9
|
+
title?: string | undefined;
|
|
10
|
+
/** Extra line under the title — e.g. "drift check" or the worker's task. */
|
|
11
|
+
subtitle?: string | undefined;
|
|
12
|
+
onOpen?: ((session: Session) => void) | undefined;
|
|
13
|
+
className?: string | undefined;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Best-effort display title for a session. */
|
|
17
|
+
export function sessionDisplayTitle(session: Session): string {
|
|
18
|
+
for (const key of ["title", "name"] as const) {
|
|
19
|
+
const value = session.metadata[key];
|
|
20
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return truncate(session.initialMessage, 100) || session.id.slice(0, 8);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* One session in the fleet/manager view: title, live status, model, and
|
|
29
|
+
* recency — dense but calm. Running sessions carry a breathing status dot
|
|
30
|
+
* and a faint accent edge so the live ones read at a glance.
|
|
31
|
+
*/
|
|
32
|
+
export function FleetTile({ session, title, subtitle, onOpen, className }: FleetTileProps) {
|
|
33
|
+
const running = session.status === "running" || session.status === "queued";
|
|
34
|
+
const needsYou = session.status === "requires_action";
|
|
35
|
+
return (
|
|
36
|
+
<button
|
|
37
|
+
type="button"
|
|
38
|
+
data-status={session.status}
|
|
39
|
+
onClick={onOpen ? () => onOpen(session) : undefined}
|
|
40
|
+
disabled={!onOpen}
|
|
41
|
+
className={cn(
|
|
42
|
+
"og-root group relative flex w-full flex-col gap-2.5 overflow-hidden rounded-og-lg border border-og-border",
|
|
43
|
+
"bg-og-surface-1 p-4 text-left shadow-og-sm",
|
|
44
|
+
"transition-[border-color,background-color,box-shadow,transform] duration-200 ease-og-out",
|
|
45
|
+
onOpen && "hover:-translate-y-px hover:border-og-border-strong hover:bg-og-surface-2 hover:shadow-og-md",
|
|
46
|
+
"disabled:cursor-default",
|
|
47
|
+
className,
|
|
48
|
+
)}
|
|
49
|
+
>
|
|
50
|
+
<span
|
|
51
|
+
aria-hidden
|
|
52
|
+
className={cn(
|
|
53
|
+
"absolute inset-y-0 left-0 w-0.5 transition-opacity duration-300",
|
|
54
|
+
running ? "bg-og-accent opacity-100" : needsYou ? "bg-og-status-waiting opacity-100" : "opacity-0",
|
|
55
|
+
)}
|
|
56
|
+
/>
|
|
57
|
+
<span className="flex w-full items-start justify-between gap-3">
|
|
58
|
+
<span className="line-clamp-2 min-w-0 text-sm font-medium leading-snug text-og-fg">{title ?? sessionDisplayTitle(session)}</span>
|
|
59
|
+
<SessionStatus status={session.status} size="sm" className="mt-px" />
|
|
60
|
+
</span>
|
|
61
|
+
{subtitle ? <span className="line-clamp-1 text-xs text-og-fg-muted">{subtitle}</span> : null}
|
|
62
|
+
<span className="mt-auto flex w-full items-center gap-2 text-[11px] text-og-fg-subtle">
|
|
63
|
+
<span className="font-og-mono">{session.id.slice(0, 8)}</span>
|
|
64
|
+
<span aria-hidden>·</span>
|
|
65
|
+
<span className="truncate">{session.model}</span>
|
|
66
|
+
<span className="ml-auto shrink-0" title={session.updatedAt}>
|
|
67
|
+
{formatRelativeTime(session.updatedAt)}
|
|
68
|
+
</span>
|
|
69
|
+
</span>
|
|
70
|
+
</button>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import type { SessionEvent, SessionStatus } from "@opengeni/sdk";
|
|
2
|
+
import {
|
|
3
|
+
ArrowDownIcon,
|
|
4
|
+
BotIcon,
|
|
5
|
+
BrainIcon,
|
|
6
|
+
ChevronRightIcon,
|
|
7
|
+
SquareTerminalIcon,
|
|
8
|
+
TargetIcon,
|
|
9
|
+
TriangleAlertIcon,
|
|
10
|
+
WrenchIcon,
|
|
11
|
+
} from "lucide-react";
|
|
12
|
+
import { AnimatePresence, motion } from "motion/react";
|
|
13
|
+
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
14
|
+
import { Collapsible } from "radix-ui";
|
|
15
|
+
import { cn } from "../lib/cn";
|
|
16
|
+
import { formatRelativeTime, stringifyPayload, truncate } from "../lib/format";
|
|
17
|
+
import {
|
|
18
|
+
buildTimeline,
|
|
19
|
+
compactPayloadPreview,
|
|
20
|
+
groupTimeline,
|
|
21
|
+
toolDisplayName,
|
|
22
|
+
type AgentMessageItem,
|
|
23
|
+
type GoalItem,
|
|
24
|
+
type NoticeItem,
|
|
25
|
+
type ReasoningItem,
|
|
26
|
+
type SandboxItem,
|
|
27
|
+
type TimelineItem,
|
|
28
|
+
type ToolCallItem,
|
|
29
|
+
type UserMessageItem,
|
|
30
|
+
type WorkerItem,
|
|
31
|
+
} from "../timeline";
|
|
32
|
+
import { SESSION_STATUS_META, StatusDot } from "./session-status";
|
|
33
|
+
|
|
34
|
+
export type MessageTimelineProps = {
|
|
35
|
+
/** Raw session events (projected internally) … */
|
|
36
|
+
events?: SessionEvent[] | undefined;
|
|
37
|
+
/** … or pre-projected items (e.g. from `useSessionEvents().timeline`). */
|
|
38
|
+
items?: TimelineItem[] | undefined;
|
|
39
|
+
/** Current session status; drives the live "working" indicator. */
|
|
40
|
+
status?: SessionStatus | null | undefined;
|
|
41
|
+
/** Plug a markdown renderer for message bodies (e.g. streamdown). */
|
|
42
|
+
renderMessageText?: ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
|
|
43
|
+
/** Drill into a spawned worker session. */
|
|
44
|
+
onOpenSession?: ((sessionId: string) => void) | undefined;
|
|
45
|
+
/** Follow new events when pinned to the bottom. Defaults to true. */
|
|
46
|
+
autoFollow?: boolean | undefined;
|
|
47
|
+
emptyState?: ReactNode | undefined;
|
|
48
|
+
className?: string | undefined;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The session timeline: chat messages with streaming deltas, collapsed
|
|
53
|
+
* activity clusters (reasoning, tool calls, sandbox work), spawned-worker
|
|
54
|
+
* cards, goal markers, and status transitions. Owns stick-to-bottom scrolling
|
|
55
|
+
* with a "jump to latest" affordance when the reader scrolls back.
|
|
56
|
+
*/
|
|
57
|
+
export function MessageTimeline({
|
|
58
|
+
events,
|
|
59
|
+
items,
|
|
60
|
+
status,
|
|
61
|
+
renderMessageText,
|
|
62
|
+
onOpenSession,
|
|
63
|
+
autoFollow = true,
|
|
64
|
+
emptyState,
|
|
65
|
+
className,
|
|
66
|
+
}: MessageTimelineProps) {
|
|
67
|
+
const resolvedItems = useMemo(() => items ?? buildTimeline(events ?? []), [items, events]);
|
|
68
|
+
const groups = useMemo(() => groupTimeline(resolvedItems), [resolvedItems]);
|
|
69
|
+
|
|
70
|
+
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
71
|
+
const [pinned, setPinned] = useState(true);
|
|
72
|
+
const lastItem = resolvedItems[resolvedItems.length - 1];
|
|
73
|
+
const streaming = lastItem !== undefined && (lastItem.kind === "agent-message" || lastItem.kind === "reasoning") && lastItem.streaming;
|
|
74
|
+
const working = status === "running" && !streaming;
|
|
75
|
+
|
|
76
|
+
// Follow the stream while pinned to the bottom; never fight the reader.
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
const node = scrollRef.current;
|
|
79
|
+
if (node && autoFollow && pinned) {
|
|
80
|
+
node.scrollTop = node.scrollHeight;
|
|
81
|
+
}
|
|
82
|
+
}, [resolvedItems, working, autoFollow, pinned]);
|
|
83
|
+
|
|
84
|
+
const onScroll = () => {
|
|
85
|
+
const node = scrollRef.current;
|
|
86
|
+
if (!node) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
setPinned(node.scrollHeight - node.scrollTop - node.clientHeight < 48);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div className={cn("og-root relative min-h-0", className)}>
|
|
94
|
+
<div ref={scrollRef} onScroll={onScroll} className="h-full overflow-y-auto overscroll-contain px-4 py-6 sm:px-6">
|
|
95
|
+
<div className="mx-auto flex w-full max-w-3xl flex-col gap-5">
|
|
96
|
+
{groups.length === 0 && !working
|
|
97
|
+
? (emptyState ?? <p className="py-10 text-center text-sm text-og-fg-subtle">No activity yet.</p>)
|
|
98
|
+
: null}
|
|
99
|
+
{groups.map((group) =>
|
|
100
|
+
group.kind === "activity" ? (
|
|
101
|
+
<ActivityCluster key={group.id} items={group.items} onOpenSession={onOpenSession} />
|
|
102
|
+
) : (
|
|
103
|
+
<TimelineRow key={group.item.id} item={group.item} renderMessageText={renderMessageText} />
|
|
104
|
+
),
|
|
105
|
+
)}
|
|
106
|
+
{working ? (
|
|
107
|
+
<div className="animate-og-enter flex items-center gap-2 text-sm">
|
|
108
|
+
<span className="og-shimmer-text font-medium">Working…</span>
|
|
109
|
+
</div>
|
|
110
|
+
) : null}
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
<AnimatePresence>
|
|
114
|
+
{!pinned && autoFollow ? (
|
|
115
|
+
<motion.button
|
|
116
|
+
type="button"
|
|
117
|
+
initial={{ opacity: 0, y: 8 }}
|
|
118
|
+
animate={{ opacity: 1, y: 0 }}
|
|
119
|
+
exit={{ opacity: 0, y: 8 }}
|
|
120
|
+
transition={{ duration: 0.15, ease: "easeOut" }}
|
|
121
|
+
onClick={() => {
|
|
122
|
+
const node = scrollRef.current;
|
|
123
|
+
if (node) {
|
|
124
|
+
node.scrollTo({ top: node.scrollHeight, behavior: "smooth" });
|
|
125
|
+
}
|
|
126
|
+
setPinned(true);
|
|
127
|
+
}}
|
|
128
|
+
className={cn(
|
|
129
|
+
"absolute bottom-4 left-1/2 -translate-x-1/2",
|
|
130
|
+
"inline-flex items-center gap-1.5 rounded-full border border-og-border bg-og-surface-3/90 px-3 py-1.5",
|
|
131
|
+
"text-xs font-medium text-og-fg shadow-og-md backdrop-blur",
|
|
132
|
+
"hover:border-og-border-strong",
|
|
133
|
+
)}
|
|
134
|
+
>
|
|
135
|
+
<ArrowDownIcon className="size-3.5" />
|
|
136
|
+
Jump to latest
|
|
137
|
+
</motion.button>
|
|
138
|
+
) : null}
|
|
139
|
+
</AnimatePresence>
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/* --- single rows ------------------------------------------------------------ */
|
|
145
|
+
|
|
146
|
+
function TimelineRow({
|
|
147
|
+
item,
|
|
148
|
+
renderMessageText,
|
|
149
|
+
}: {
|
|
150
|
+
item: TimelineItem;
|
|
151
|
+
renderMessageText?: ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
|
|
152
|
+
}) {
|
|
153
|
+
switch (item.kind) {
|
|
154
|
+
case "user-message":
|
|
155
|
+
return <UserMessageRow item={item} renderMessageText={renderMessageText} />;
|
|
156
|
+
case "agent-message":
|
|
157
|
+
return <AgentMessageRow item={item} renderMessageText={renderMessageText} />;
|
|
158
|
+
case "session-status":
|
|
159
|
+
return <SessionStatusRow item={item} />;
|
|
160
|
+
case "goal":
|
|
161
|
+
return <GoalRow item={item} />;
|
|
162
|
+
case "notice":
|
|
163
|
+
return <NoticeRow item={item} />;
|
|
164
|
+
default:
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function UserMessageRow({
|
|
170
|
+
item,
|
|
171
|
+
renderMessageText,
|
|
172
|
+
}: {
|
|
173
|
+
item: UserMessageItem;
|
|
174
|
+
renderMessageText?: ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
|
|
175
|
+
}) {
|
|
176
|
+
return (
|
|
177
|
+
<div className="animate-og-enter flex justify-end">
|
|
178
|
+
<div className="max-w-[85%] rounded-og-lg rounded-br-og-xs border border-og-border bg-og-surface-2 px-4 py-2.5 text-[15px] leading-6 text-og-fg">
|
|
179
|
+
{renderMessageText ? renderMessageText(item.text, item) : <span className="whitespace-pre-wrap">{item.text}</span>}
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function AgentMessageRow({
|
|
186
|
+
item,
|
|
187
|
+
renderMessageText,
|
|
188
|
+
}: {
|
|
189
|
+
item: AgentMessageItem;
|
|
190
|
+
renderMessageText?: ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
|
|
191
|
+
}) {
|
|
192
|
+
return (
|
|
193
|
+
<div className="animate-og-enter text-[15px] leading-7 text-og-fg">
|
|
194
|
+
{renderMessageText ? renderMessageText(item.text, item) : <span className="whitespace-pre-wrap">{item.text}</span>}
|
|
195
|
+
{item.streaming ? <span className="ml-0.5 inline-block h-[1.1em] w-[2px] translate-y-[3px] animate-og-blink rounded-full bg-og-accent" aria-hidden /> : null}
|
|
196
|
+
</div>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function SessionStatusRow({ item }: { item: { status: SessionStatus; occurredAt: string } }) {
|
|
201
|
+
const meta = SESSION_STATUS_META[item.status];
|
|
202
|
+
return (
|
|
203
|
+
<div className="animate-og-enter flex items-center gap-3 text-[11px] text-og-fg-subtle" role="status">
|
|
204
|
+
<span className="h-px flex-1 bg-og-border" />
|
|
205
|
+
<span className="inline-flex items-center gap-1.5">
|
|
206
|
+
<StatusDot status={item.status} className="size-1" />
|
|
207
|
+
{meta.label.toLowerCase()} · {formatRelativeTime(item.occurredAt)}
|
|
208
|
+
</span>
|
|
209
|
+
<span className="h-px flex-1 bg-og-border" />
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function GoalRow({ item }: { item: GoalItem }) {
|
|
215
|
+
const label =
|
|
216
|
+
item.action === "set"
|
|
217
|
+
? "Goal set"
|
|
218
|
+
: item.action === "updated"
|
|
219
|
+
? "Goal updated"
|
|
220
|
+
: item.action === "completed"
|
|
221
|
+
? "Goal completed"
|
|
222
|
+
: item.action === "paused"
|
|
223
|
+
? "Goal paused"
|
|
224
|
+
: item.action === "resumed"
|
|
225
|
+
? "Goal resumed"
|
|
226
|
+
: "Continuing toward the goal";
|
|
227
|
+
return (
|
|
228
|
+
<div className="animate-og-enter flex justify-center">
|
|
229
|
+
<span className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-og-border bg-og-surface-1 px-3 py-1 text-xs text-og-fg-muted">
|
|
230
|
+
<TargetIcon className="size-3.5 shrink-0 text-og-accent" />
|
|
231
|
+
<span className="truncate">
|
|
232
|
+
{label}
|
|
233
|
+
{item.text ? `: ${truncate(item.text, 90)}` : ""}
|
|
234
|
+
</span>
|
|
235
|
+
</span>
|
|
236
|
+
</div>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function NoticeRow({ item }: { item: NoticeItem }) {
|
|
241
|
+
const tone =
|
|
242
|
+
item.tone === "failed"
|
|
243
|
+
? "border-og-status-failed/35 bg-og-status-failed/10 text-og-status-failed"
|
|
244
|
+
: item.tone === "waiting"
|
|
245
|
+
? "border-og-status-waiting/35 bg-og-status-waiting/10 text-og-status-waiting"
|
|
246
|
+
: "border-og-border bg-og-surface-1 text-og-fg-muted";
|
|
247
|
+
return (
|
|
248
|
+
<div className={cn("animate-og-enter flex items-start gap-2.5 rounded-og-md border px-3.5 py-2.5 text-sm", tone)} role="status">
|
|
249
|
+
<TriangleAlertIcon className={cn("mt-0.5 size-4 shrink-0", item.tone === "cancelled" && "opacity-60")} />
|
|
250
|
+
<span className="min-w-0 whitespace-pre-wrap break-words">{item.text}</span>
|
|
251
|
+
</div>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/* --- activity cluster -------------------------------------------------------- */
|
|
256
|
+
|
|
257
|
+
type ActivityItem = ReasoningItem | ToolCallItem | WorkerItem | SandboxItem;
|
|
258
|
+
|
|
259
|
+
function ActivityCluster({
|
|
260
|
+
items,
|
|
261
|
+
onOpenSession,
|
|
262
|
+
}: {
|
|
263
|
+
items: ActivityItem[];
|
|
264
|
+
onOpenSession?: ((sessionId: string) => void) | undefined;
|
|
265
|
+
}) {
|
|
266
|
+
return (
|
|
267
|
+
<div className="animate-og-enter flex flex-col gap-1.5 border-l-2 border-og-border pl-3 sm:pl-4">
|
|
268
|
+
{items.map((item) => {
|
|
269
|
+
switch (item.kind) {
|
|
270
|
+
case "reasoning":
|
|
271
|
+
return <ReasoningRow key={item.id} item={item} />;
|
|
272
|
+
case "tool-call":
|
|
273
|
+
return <ToolCallRow key={item.id} item={item} />;
|
|
274
|
+
case "worker":
|
|
275
|
+
return <WorkerRow key={item.id} item={item} onOpenSession={onOpenSession} />;
|
|
276
|
+
case "sandbox":
|
|
277
|
+
return <SandboxRow key={item.id} item={item} />;
|
|
278
|
+
}
|
|
279
|
+
})}
|
|
280
|
+
</div>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function ActivityDisclosure({
|
|
285
|
+
icon,
|
|
286
|
+
title,
|
|
287
|
+
running,
|
|
288
|
+
failed,
|
|
289
|
+
preview,
|
|
290
|
+
children,
|
|
291
|
+
}: {
|
|
292
|
+
icon: ReactNode;
|
|
293
|
+
title: string;
|
|
294
|
+
running: boolean;
|
|
295
|
+
failed?: boolean | undefined;
|
|
296
|
+
preview?: string | undefined;
|
|
297
|
+
children?: ReactNode;
|
|
298
|
+
}) {
|
|
299
|
+
const [open, setOpen] = useState(false);
|
|
300
|
+
return (
|
|
301
|
+
<Collapsible.Root open={open} onOpenChange={setOpen}>
|
|
302
|
+
<Collapsible.Trigger
|
|
303
|
+
className={cn(
|
|
304
|
+
"group flex w-full min-w-0 items-center gap-2 rounded-og-sm px-1.5 py-1 text-left text-[13px]",
|
|
305
|
+
"text-og-fg-muted transition-colors duration-150 hover:bg-og-surface-1 hover:text-og-fg",
|
|
306
|
+
)}
|
|
307
|
+
>
|
|
308
|
+
<ChevronRightIcon className="size-3.5 shrink-0 text-og-fg-subtle transition-transform duration-150 group-data-[state=open]:rotate-90" />
|
|
309
|
+
<span className={cn("shrink-0", failed ? "text-og-status-failed" : running ? "text-og-status-running" : "text-og-fg-subtle")}>{icon}</span>
|
|
310
|
+
<span className={cn("shrink-0 font-medium", running && "og-shimmer-text", failed && "text-og-status-failed")}>{title}</span>
|
|
311
|
+
{preview ? <span className="min-w-0 flex-1 truncate font-og-mono text-xs text-og-fg-subtle">{preview}</span> : null}
|
|
312
|
+
{running ? <span className="ml-auto size-1.5 shrink-0 animate-og-pulse rounded-full bg-og-status-running" /> : null}
|
|
313
|
+
</Collapsible.Trigger>
|
|
314
|
+
<Collapsible.Content className="overflow-hidden">
|
|
315
|
+
<div className="mt-1 mb-1.5 ml-7 flex flex-col gap-2">{children}</div>
|
|
316
|
+
</Collapsible.Content>
|
|
317
|
+
</Collapsible.Root>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function PayloadBlock({ label, value }: { label: string; value: unknown }) {
|
|
322
|
+
const text = stringifyPayload(value);
|
|
323
|
+
if (!text) {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
return (
|
|
327
|
+
<div className="min-w-0">
|
|
328
|
+
<p className="mb-1 text-[10px] font-medium uppercase tracking-[0.08em] text-og-fg-subtle">{label}</p>
|
|
329
|
+
<pre className="max-h-64 overflow-auto rounded-og-sm border border-og-border bg-og-bg/60 p-2.5 font-og-mono text-xs leading-5 text-og-fg-muted">
|
|
330
|
+
{text}
|
|
331
|
+
</pre>
|
|
332
|
+
</div>
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function ReasoningRow({ item }: { item: ReasoningItem }) {
|
|
337
|
+
return (
|
|
338
|
+
<ActivityDisclosure
|
|
339
|
+
icon={<BrainIcon className="size-3.5" />}
|
|
340
|
+
title={item.streaming ? "Thinking" : "Thought"}
|
|
341
|
+
running={item.streaming}
|
|
342
|
+
preview={truncate(item.text, 110)}
|
|
343
|
+
>
|
|
344
|
+
<p className="whitespace-pre-wrap text-[13px] leading-6 text-og-fg-muted">{item.text}</p>
|
|
345
|
+
</ActivityDisclosure>
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function ToolCallRow({ item }: { item: ToolCallItem }) {
|
|
350
|
+
return (
|
|
351
|
+
<ActivityDisclosure
|
|
352
|
+
icon={<WrenchIcon className="size-3.5" />}
|
|
353
|
+
title={toolDisplayName(item.name)}
|
|
354
|
+
running={item.status === "running"}
|
|
355
|
+
preview={compactPayloadPreview(item.arguments)}
|
|
356
|
+
>
|
|
357
|
+
<PayloadBlock label="Arguments" value={item.arguments} />
|
|
358
|
+
{item.status === "complete" ? <PayloadBlock label="Output" value={item.output} /> : null}
|
|
359
|
+
</ActivityDisclosure>
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function SandboxRow({ item }: { item: SandboxItem }) {
|
|
364
|
+
return (
|
|
365
|
+
<ActivityDisclosure
|
|
366
|
+
icon={<SquareTerminalIcon className="size-3.5" />}
|
|
367
|
+
title={toolDisplayName(item.name)}
|
|
368
|
+
running={item.status === "running"}
|
|
369
|
+
failed={item.status === "failed"}
|
|
370
|
+
preview={item.command ?? undefined}
|
|
371
|
+
>
|
|
372
|
+
{item.command ? <PayloadBlock label="Command" value={item.command} /> : null}
|
|
373
|
+
{item.output ? <PayloadBlock label="Output" value={item.output} /> : null}
|
|
374
|
+
</ActivityDisclosure>
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Spawned/messaged worker sessions get a first-class card, not a tool row. */
|
|
379
|
+
function WorkerRow({ item, onOpenSession }: { item: WorkerItem; onOpenSession?: ((sessionId: string) => void) | undefined }) {
|
|
380
|
+
const running = item.status === "running";
|
|
381
|
+
const title = item.action === "spawn" ? (running ? "Spawning worker" : "Worker spawned") : running ? "Messaging worker" : "Worker messaged";
|
|
382
|
+
return (
|
|
383
|
+
<div className="my-0.5 flex items-start gap-3 rounded-og-md border border-og-border bg-og-surface-1 p-3 shadow-og-sm">
|
|
384
|
+
<span
|
|
385
|
+
className={cn(
|
|
386
|
+
"mt-0.5 inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
|
|
387
|
+
"bg-og-accent-soft text-og-accent",
|
|
388
|
+
)}
|
|
389
|
+
>
|
|
390
|
+
<BotIcon className="size-4" />
|
|
391
|
+
</span>
|
|
392
|
+
<div className="min-w-0 flex-1">
|
|
393
|
+
<div className="flex items-center gap-2">
|
|
394
|
+
<span className={cn("text-[13px] font-medium", running ? "og-shimmer-text" : "text-og-fg")}>{title}</span>
|
|
395
|
+
{running ? <span className="size-1.5 animate-og-pulse rounded-full bg-og-status-running" /> : null}
|
|
396
|
+
</div>
|
|
397
|
+
{item.prompt ? <p className="mt-0.5 truncate text-xs text-og-fg-muted">{truncate(item.prompt, 140)}</p> : null}
|
|
398
|
+
{item.workerSessionId ? (
|
|
399
|
+
<p className="mt-1 font-og-mono text-[11px] text-og-fg-subtle">{item.workerSessionId.slice(0, 8)}</p>
|
|
400
|
+
) : null}
|
|
401
|
+
</div>
|
|
402
|
+
{item.workerSessionId && onOpenSession ? (
|
|
403
|
+
<button
|
|
404
|
+
type="button"
|
|
405
|
+
onClick={() => item.workerSessionId && onOpenSession(item.workerSessionId)}
|
|
406
|
+
className={cn(
|
|
407
|
+
"shrink-0 self-center rounded-og-sm border border-og-border px-2.5 py-1 text-xs font-medium text-og-fg-muted",
|
|
408
|
+
"transition-colors duration-150 hover:border-og-border-strong hover:text-og-fg",
|
|
409
|
+
)}
|
|
410
|
+
>
|
|
411
|
+
Open session
|
|
412
|
+
</button>
|
|
413
|
+
) : null}
|
|
414
|
+
</div>
|
|
415
|
+
);
|
|
416
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { SessionStatus as SessionStatusValue } from "@opengeni/sdk";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
|
|
4
|
+
export type SessionStatusMeta = {
|
|
5
|
+
label: string;
|
|
6
|
+
/** Token-backed color classes for the dot and tinted badge. */
|
|
7
|
+
dotClassName: string;
|
|
8
|
+
badgeClassName: string;
|
|
9
|
+
/** Live states breathe; terminal states hold still. */
|
|
10
|
+
pulse: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const SESSION_STATUS_META: Record<SessionStatusValue, SessionStatusMeta> = {
|
|
14
|
+
queued: {
|
|
15
|
+
label: "Queued",
|
|
16
|
+
dotClassName: "bg-og-status-queued",
|
|
17
|
+
badgeClassName: "text-og-fg-muted border-og-border bg-og-status-queued/10",
|
|
18
|
+
pulse: true,
|
|
19
|
+
},
|
|
20
|
+
running: {
|
|
21
|
+
label: "Running",
|
|
22
|
+
dotClassName: "bg-og-status-running",
|
|
23
|
+
badgeClassName: "text-og-status-running border-og-status-running/30 bg-og-status-running/10",
|
|
24
|
+
pulse: true,
|
|
25
|
+
},
|
|
26
|
+
idle: {
|
|
27
|
+
label: "Idle",
|
|
28
|
+
dotClassName: "bg-og-status-idle",
|
|
29
|
+
badgeClassName: "text-og-status-idle border-og-status-idle/30 bg-og-status-idle/10",
|
|
30
|
+
pulse: false,
|
|
31
|
+
},
|
|
32
|
+
requires_action: {
|
|
33
|
+
label: "Needs you",
|
|
34
|
+
dotClassName: "bg-og-status-waiting",
|
|
35
|
+
badgeClassName: "text-og-status-waiting border-og-status-waiting/35 bg-og-status-waiting/10",
|
|
36
|
+
pulse: true,
|
|
37
|
+
},
|
|
38
|
+
failed: {
|
|
39
|
+
label: "Failed",
|
|
40
|
+
dotClassName: "bg-og-status-failed",
|
|
41
|
+
badgeClassName: "text-og-status-failed border-og-status-failed/35 bg-og-status-failed/10",
|
|
42
|
+
pulse: false,
|
|
43
|
+
},
|
|
44
|
+
cancelled: {
|
|
45
|
+
label: "Cancelled",
|
|
46
|
+
dotClassName: "bg-og-status-cancelled",
|
|
47
|
+
badgeClassName: "text-og-fg-subtle border-og-border bg-og-status-cancelled/10",
|
|
48
|
+
pulse: false,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type SessionStatusProps = {
|
|
53
|
+
status: SessionStatusValue;
|
|
54
|
+
/** Override the label ("Running" -> "Deploying", ...). */
|
|
55
|
+
label?: string | undefined;
|
|
56
|
+
size?: "sm" | "md" | undefined;
|
|
57
|
+
className?: string | undefined;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Status badge with a breathing dot for live states. */
|
|
61
|
+
export function SessionStatus({ status, label, size = "md", className }: SessionStatusProps) {
|
|
62
|
+
const meta = SESSION_STATUS_META[status];
|
|
63
|
+
return (
|
|
64
|
+
<span
|
|
65
|
+
data-status={status}
|
|
66
|
+
className={cn(
|
|
67
|
+
"og-root inline-flex shrink-0 items-center rounded-full border font-medium",
|
|
68
|
+
size === "sm" ? "gap-1 px-1.5 py-px text-[10px]" : "gap-1.5 px-2 py-0.5 text-xs",
|
|
69
|
+
meta.badgeClassName,
|
|
70
|
+
className,
|
|
71
|
+
)}
|
|
72
|
+
>
|
|
73
|
+
<StatusDot status={status} className={size === "sm" ? "size-1" : "size-1.5"} />
|
|
74
|
+
{label ?? meta.label}
|
|
75
|
+
</span>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type StatusDotProps = {
|
|
80
|
+
status: SessionStatusValue;
|
|
81
|
+
className?: string | undefined;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Just the dot — for dense rows and tiles. */
|
|
85
|
+
export function StatusDot({ status, className }: StatusDotProps) {
|
|
86
|
+
const meta = SESSION_STATUS_META[status];
|
|
87
|
+
return (
|
|
88
|
+
<span className={cn("relative inline-flex size-1.5 shrink-0 rounded-full", meta.dotClassName, className)}>
|
|
89
|
+
{meta.pulse ? <span className={cn("absolute inset-0 animate-og-pulse rounded-full", meta.dotClassName)} /> : null}
|
|
90
|
+
</span>
|
|
91
|
+
);
|
|
92
|
+
}
|