@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
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@opengeni/react",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "React hooks and styled components for OpenGeni: live session streaming, chat composer, message timeline, session status, and fleet views — token-themed (CSS variables), dark-first, built on Tailwind v4 + Radix + Motion.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"module": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./styles.css": "./styles/index.css",
|
|
17
|
+
"./tokens.css": "./styles/tokens.css"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"src",
|
|
22
|
+
"styles"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public",
|
|
29
|
+
"provenance": true
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"typecheck": "tsc --noEmit",
|
|
33
|
+
"build": "tsup",
|
|
34
|
+
"demo": "vite dev demo --port 3100",
|
|
35
|
+
"demo:build": "vite build demo"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@opengeni/sdk": "^0.1.0",
|
|
39
|
+
"clsx": "^2.1.1",
|
|
40
|
+
"lucide-react": "^1.8.0",
|
|
41
|
+
"motion": "^12.0.0",
|
|
42
|
+
"radix-ui": "^1.4.3",
|
|
43
|
+
"tailwind-merge": "^3.5.0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
47
|
+
"react-dom": "^18.0.0 || ^19.0.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@fontsource-variable/inter": "^5.2.8",
|
|
51
|
+
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
|
52
|
+
"@happy-dom/global-registrator": "^20.10.2",
|
|
53
|
+
"@tailwindcss/vite": "^4.2.4",
|
|
54
|
+
"@types/react": "^19.2.14",
|
|
55
|
+
"@types/react-dom": "^19.2.3",
|
|
56
|
+
"@vitejs/plugin-react": "^6.0.1",
|
|
57
|
+
"react": "^19.2.5",
|
|
58
|
+
"react-dom": "^19.2.5",
|
|
59
|
+
"tailwindcss": "^4.2.4",
|
|
60
|
+
"tsup": "^8.5.0",
|
|
61
|
+
"typescript": "^6.0.3",
|
|
62
|
+
"vite": "^8.0.9"
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/approvals.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { SessionEvent } from "@opengeni/sdk";
|
|
2
|
+
|
|
3
|
+
/* ----------------------------------------------------------------------------
|
|
4
|
+
Pending-approvals projection
|
|
5
|
+
|
|
6
|
+
`session.requiresAction` events live forever in the durable log, so a
|
|
7
|
+
console that replays the log from sequence 0 must not render every
|
|
8
|
+
historical approval as actionable. `projectPendingApprovals` folds the
|
|
9
|
+
ordered event log into the approvals that are still undecided *now*:
|
|
10
|
+
|
|
11
|
+
- `session.requiresAction` replaces the pending set with the payload's
|
|
12
|
+
approvals (the producer always emits the full currently-pending set, so
|
|
13
|
+
re-emission after a worker re-dispatch cannot duplicate cards);
|
|
14
|
+
- `user.approvalDecision` subtracts the decided approval;
|
|
15
|
+
- the owning turn finishing (`turn.completed`/`turn.failed`/
|
|
16
|
+
`turn.cancelled`) clears the set — whatever was pending died with the
|
|
17
|
+
turn. A `turn.cancelled` for a *different* turn (deleting a queued turn
|
|
18
|
+
while the session waits on an approval) leaves the set alone.
|
|
19
|
+
|
|
20
|
+
Pure function — same events in, same approvals out — so approve-then-reload
|
|
21
|
+
projects to an empty set instead of a zombie Approve button.
|
|
22
|
+
-------------------------------------------------------------------------- */
|
|
23
|
+
|
|
24
|
+
export type PendingApproval = {
|
|
25
|
+
/** The id to send back via `user.approvalDecision` (`approvalId`). */
|
|
26
|
+
id: string;
|
|
27
|
+
/** Tool/function name awaiting the decision. */
|
|
28
|
+
name: string;
|
|
29
|
+
arguments?: unknown;
|
|
30
|
+
/** The raw approval entry from the `session.requiresAction` payload. */
|
|
31
|
+
raw?: unknown;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** The approvals carried by one `session.requiresAction` payload. */
|
|
35
|
+
export function approvalsFromRequiresAction(payload: unknown): PendingApproval[] {
|
|
36
|
+
const approvals = payload && typeof payload === "object" ? (payload as { approvals?: unknown }).approvals : undefined;
|
|
37
|
+
if (!Array.isArray(approvals)) {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
return approvals.map((approval, index) => {
|
|
41
|
+
const raw = (approval && typeof approval === "object" ? approval : {}) as Record<string, unknown>;
|
|
42
|
+
const rawItem = raw.rawItem && typeof raw.rawItem === "object" ? raw.rawItem as Record<string, unknown> : {};
|
|
43
|
+
return {
|
|
44
|
+
id: String(raw.id ?? raw.callId ?? rawItem.callId ?? index),
|
|
45
|
+
name: String(raw.name ?? "approval"),
|
|
46
|
+
arguments: raw.arguments,
|
|
47
|
+
raw: approval,
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The approvals still awaiting a decision after replaying `events` in order. */
|
|
53
|
+
export function projectPendingApprovals(events: SessionEvent[]): PendingApproval[] {
|
|
54
|
+
let pending: PendingApproval[] = [];
|
|
55
|
+
let owningTurnId: string | null = null;
|
|
56
|
+
for (const event of events) {
|
|
57
|
+
switch (event.type) {
|
|
58
|
+
case "session.requiresAction": {
|
|
59
|
+
pending = approvalsFromRequiresAction(event.payload);
|
|
60
|
+
owningTurnId = event.turnId ?? null;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
case "user.approvalDecision": {
|
|
64
|
+
const payload = event.payload && typeof event.payload === "object" ? event.payload as { approvalId?: unknown } : {};
|
|
65
|
+
if (typeof payload.approvalId === "string") {
|
|
66
|
+
pending = pending.filter((approval) => approval.id !== payload.approvalId);
|
|
67
|
+
}
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
case "turn.completed":
|
|
71
|
+
case "turn.failed":
|
|
72
|
+
case "turn.cancelled": {
|
|
73
|
+
// Scope clearing to the turn that raised the approvals when both
|
|
74
|
+
// sides carry a turn id; clear conservatively when either is unknown.
|
|
75
|
+
if (owningTurnId === null || event.turnId == null || event.turnId === owningTurnId) {
|
|
76
|
+
pending = [];
|
|
77
|
+
owningTurnId = null;
|
|
78
|
+
}
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
default:
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return pending;
|
|
86
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { OpenGeniClient } from "@opengeni/sdk";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The slice of `OpenGeniClient` the hooks depend on. Structural, so apps can
|
|
5
|
+
* pass the real SDK client, a proxy-backed client that routes through their
|
|
6
|
+
* own API, or a scripted client in tests/demos.
|
|
7
|
+
*/
|
|
8
|
+
export type SessionClientLike = Pick<
|
|
9
|
+
OpenGeniClient,
|
|
10
|
+
// Sessions, events, composer
|
|
11
|
+
| "getSession"
|
|
12
|
+
| "listSessions"
|
|
13
|
+
| "sendMessage"
|
|
14
|
+
| "steerMessage"
|
|
15
|
+
| "interrupt"
|
|
16
|
+
| "sendApprovalDecision"
|
|
17
|
+
| "streamEvents"
|
|
18
|
+
// Turn queue
|
|
19
|
+
| "listTurns"
|
|
20
|
+
| "updateQueuedTurn"
|
|
21
|
+
| "reorderQueuedTurns"
|
|
22
|
+
| "deleteQueuedTurn"
|
|
23
|
+
// Goal
|
|
24
|
+
| "getGoal"
|
|
25
|
+
| "updateGoal"
|
|
26
|
+
// Operator context controls (/clear, /compact)
|
|
27
|
+
| "clearSessionContext"
|
|
28
|
+
| "compactSessionContext"
|
|
29
|
+
// Scheduled tasks
|
|
30
|
+
| "listScheduledTasks"
|
|
31
|
+
// Files (upload + download-url minting for attachments)
|
|
32
|
+
| "uploadFile"
|
|
33
|
+
| "getFile"
|
|
34
|
+
| "createFileDownloadUrl"
|
|
35
|
+
// Environments
|
|
36
|
+
| "listEnvironments"
|
|
37
|
+
| "createEnvironment"
|
|
38
|
+
| "updateEnvironment"
|
|
39
|
+
| "deleteEnvironment"
|
|
40
|
+
| "setEnvironmentVariable"
|
|
41
|
+
| "deleteEnvironmentVariable"
|
|
42
|
+
// Packs
|
|
43
|
+
| "listPacks"
|
|
44
|
+
| "registerPack"
|
|
45
|
+
| "enablePack"
|
|
46
|
+
| "deletePack"
|
|
47
|
+
// Workspaces + billing
|
|
48
|
+
| "listWorkspaces"
|
|
49
|
+
| "createWorkspace"
|
|
50
|
+
| "updateWorkspace"
|
|
51
|
+
| "getBillingUsage"
|
|
52
|
+
>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
CommandContext,
|
|
3
|
+
CommandResult,
|
|
4
|
+
Notice,
|
|
5
|
+
SlashArg,
|
|
6
|
+
SlashCommand,
|
|
7
|
+
} from "./types";
|
|
8
|
+
export {
|
|
9
|
+
argHint,
|
|
10
|
+
defaultCommands,
|
|
11
|
+
filterCommands,
|
|
12
|
+
firstMissingRequiredArg,
|
|
13
|
+
hasPermission,
|
|
14
|
+
matchCommand,
|
|
15
|
+
parseCommandLine,
|
|
16
|
+
} from "./registry";
|
|
17
|
+
export type { ParsedCommandLine } from "./registry";
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import type { Permission } from "@opengeni/sdk";
|
|
2
|
+
import type { CommandContext, SlashArg, SlashCommand } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parse a composer value into a command name + the rest. A command is
|
|
6
|
+
* recognized ONLY when the value's first character is "/" (the start token);
|
|
7
|
+
* anything else is plain chat and returns null.
|
|
8
|
+
*
|
|
9
|
+
* "/cl" -> { name: "cl", rest: "", hasTrailingSpace: false }
|
|
10
|
+
* "/goal " -> { name: "goal", rest: "", hasTrailingSpace: true }
|
|
11
|
+
* "/goal pause"-> { name: "goal", rest: "pause", hasTrailingSpace: false }
|
|
12
|
+
*/
|
|
13
|
+
export type ParsedCommandLine = {
|
|
14
|
+
name: string;
|
|
15
|
+
rest: string;
|
|
16
|
+
/** True when the name token is closed by a space — arg-hint mode. */
|
|
17
|
+
hasTrailingSpace: boolean;
|
|
18
|
+
args: string[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function parseCommandLine(value: string): ParsedCommandLine | null {
|
|
22
|
+
if (value[0] !== "/") {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const body = value.slice(1);
|
|
26
|
+
const firstSpace = body.indexOf(" ");
|
|
27
|
+
if (firstSpace === -1) {
|
|
28
|
+
return { name: body, rest: "", hasTrailingSpace: false, args: [] };
|
|
29
|
+
}
|
|
30
|
+
const name = body.slice(0, firstSpace);
|
|
31
|
+
const rest = body.slice(firstSpace + 1);
|
|
32
|
+
return {
|
|
33
|
+
name,
|
|
34
|
+
rest,
|
|
35
|
+
hasTrailingSpace: true,
|
|
36
|
+
args: rest.split(/\s+/).filter((token) => token.length > 0),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const PERMISSION_SUPERUSER: Permission = "workspace:admin";
|
|
41
|
+
|
|
42
|
+
/** Whether the operator's permission set satisfies a command's gate. */
|
|
43
|
+
export function hasPermission(required: Permission | undefined, permissions: Permission[]): boolean {
|
|
44
|
+
if (!required) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
return permissions.includes(required) || permissions.includes(PERMISSION_SUPERUSER);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Match a command (by name or alias) against the value. */
|
|
51
|
+
export function matchCommand(commands: readonly SlashCommand[], value: string): SlashCommand | null {
|
|
52
|
+
const parsed = parseCommandLine(value);
|
|
53
|
+
if (!parsed) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const token = parsed.name.toLowerCase();
|
|
57
|
+
return commands.find((command) => command.name === token || command.aliases?.includes(token)) ?? null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type FilterCtx = Pick<CommandContext, "sessionId" | "status" | "permissions">;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The commands visible for the current token + context. Permission-absent and
|
|
64
|
+
* `available()===false` commands are dropped entirely (a gated command is never
|
|
65
|
+
* shown, not shown-disabled). Filtering is a prefix match on name/alias.
|
|
66
|
+
*/
|
|
67
|
+
export function filterCommands(commands: readonly SlashCommand[], token: string, ctx: FilterCtx): SlashCommand[] {
|
|
68
|
+
const needle = token.toLowerCase();
|
|
69
|
+
return commands.filter((command) => {
|
|
70
|
+
if (!hasPermission(command.permission, ctx.permissions)) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
if (command.available && !command.available(ctx)) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
if (needle.length === 0) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
return command.name.startsWith(needle) || (command.aliases?.some((alias) => alias.startsWith(needle)) ?? false);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Render a command's arg hint for the palette footer / help, e.g. "<pause|resume>". */
|
|
84
|
+
export function argHint(args: readonly SlashArg[] | undefined): string {
|
|
85
|
+
if (!args || args.length === 0) {
|
|
86
|
+
return "";
|
|
87
|
+
}
|
|
88
|
+
return args
|
|
89
|
+
.map((arg) => {
|
|
90
|
+
const label = arg.oneOf ? arg.oneOf.join("|") : arg.name;
|
|
91
|
+
return arg.required ? `<${label}>` : `[${label}]`;
|
|
92
|
+
})
|
|
93
|
+
.join(" ");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The first required arg that has not yet been supplied, if any. */
|
|
97
|
+
export function firstMissingRequiredArg(command: SlashCommand, args: string[]): SlashArg | null {
|
|
98
|
+
const required = (command.args ?? []).filter((arg) => arg.required);
|
|
99
|
+
for (let i = 0; i < required.length; i += 1) {
|
|
100
|
+
const value = args[i];
|
|
101
|
+
if (value === undefined || value.length === 0) {
|
|
102
|
+
return required[i] ?? null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function requireSession(ctx: CommandContext): string {
|
|
109
|
+
if (!ctx.sessionId) {
|
|
110
|
+
throw new Error("No active session yet — start a session first.");
|
|
111
|
+
}
|
|
112
|
+
return ctx.sessionId;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const hasSession = (ctx: FilterCtx): boolean => ctx.sessionId !== null;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The default command set. Adding a command is one object literal here; the
|
|
119
|
+
* palette list, filter, arg-hint footer, and /help all render from this array.
|
|
120
|
+
* Apps concat their own commands via the ChatComposer `commands` prop.
|
|
121
|
+
*/
|
|
122
|
+
export const defaultCommands: readonly SlashCommand[] = [
|
|
123
|
+
{
|
|
124
|
+
name: "help",
|
|
125
|
+
aliases: ["?"],
|
|
126
|
+
description: "Show available commands.",
|
|
127
|
+
run: (_args, ctx) => {
|
|
128
|
+
ctx.openHelp();
|
|
129
|
+
return { status: "ok" };
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
name: "clear-view",
|
|
134
|
+
description: "Clear the local timeline view (this device only; no server change).",
|
|
135
|
+
run: (_args, ctx) => {
|
|
136
|
+
// clearView() reports whether the host actually wired a view-reset. If it
|
|
137
|
+
// didn't (the console surface has no resettable local timeline), reporting
|
|
138
|
+
// "Local view cleared." would be a false success — return an honest error
|
|
139
|
+
// instead so the operator isn't told something happened when nothing did.
|
|
140
|
+
const cleared = ctx.clearView();
|
|
141
|
+
if (!cleared) {
|
|
142
|
+
return { status: "error", message: "This view can't be cleared here (no local timeline to reset)." };
|
|
143
|
+
}
|
|
144
|
+
return { status: "ok", message: "Local view cleared." };
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: "goal",
|
|
149
|
+
description: "Pause or resume the session's goal loop.",
|
|
150
|
+
permission: "sessions:control",
|
|
151
|
+
available: hasSession,
|
|
152
|
+
args: [{ name: "action", required: true, oneOf: ["pause", "resume"], description: "pause | resume" }],
|
|
153
|
+
run: async (args, ctx) => {
|
|
154
|
+
const sessionId = requireSession(ctx);
|
|
155
|
+
const action = args[0];
|
|
156
|
+
if (action !== "pause" && action !== "resume") {
|
|
157
|
+
return { status: "error", message: "Usage: /goal pause | /goal resume" };
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
await ctx.client.updateGoal(ctx.workspaceId, sessionId, { status: action === "pause" ? "paused" : "active" });
|
|
161
|
+
return { status: "ok", message: action === "pause" ? "Goal paused." : "Goal resumed." };
|
|
162
|
+
} catch (cause) {
|
|
163
|
+
return { status: "error", message: goalErrorMessage(cause, action) };
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
name: "compact",
|
|
169
|
+
description: "Compact the conversation context now.",
|
|
170
|
+
permission: "sessions:control",
|
|
171
|
+
available: hasSession,
|
|
172
|
+
run: async (_args, ctx) => {
|
|
173
|
+
const sessionId = requireSession(ctx);
|
|
174
|
+
try {
|
|
175
|
+
const result = await ctx.client.compactSessionContext(ctx.workspaceId, sessionId);
|
|
176
|
+
return { status: "ok", message: result.message };
|
|
177
|
+
} catch (cause) {
|
|
178
|
+
return { status: "error", message: errorMessage(cause) ?? "Could not compact context." };
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
name: "clear",
|
|
184
|
+
description: "Clear the conversation context (destructive; audit-preserved).",
|
|
185
|
+
permission: "sessions:control",
|
|
186
|
+
danger: true,
|
|
187
|
+
available: hasSession,
|
|
188
|
+
run: async (_args, ctx) => {
|
|
189
|
+
const sessionId = requireSession(ctx);
|
|
190
|
+
const confirmed = await ctx.confirm();
|
|
191
|
+
if (!confirmed) {
|
|
192
|
+
// Canceled: no error, but keep the "/clear" draft so the operator who
|
|
193
|
+
// backed out doesn't silently lose what they typed.
|
|
194
|
+
return { status: "ok", keepDraft: true };
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
await ctx.client.clearSessionContext(ctx.workspaceId, sessionId);
|
|
198
|
+
return { status: "ok", message: "Context cleared." };
|
|
199
|
+
} catch (cause) {
|
|
200
|
+
return { status: "error", message: clearErrorMessage(cause) };
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
];
|
|
205
|
+
|
|
206
|
+
function errorMessage(cause: unknown): string | undefined {
|
|
207
|
+
if (cause && typeof cause === "object" && "message" in cause && typeof (cause as { message?: unknown }).message === "string") {
|
|
208
|
+
return (cause as { message: string }).message;
|
|
209
|
+
}
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function statusCode(cause: unknown): number | undefined {
|
|
214
|
+
if (cause && typeof cause === "object" && "status" in cause && typeof (cause as { status?: unknown }).status === "number") {
|
|
215
|
+
return (cause as { status: number }).status;
|
|
216
|
+
}
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function goalErrorMessage(cause: unknown, action: "pause" | "resume"): string {
|
|
221
|
+
const code = statusCode(cause);
|
|
222
|
+
if (code === 404) {
|
|
223
|
+
return "This session has no goal to control.";
|
|
224
|
+
}
|
|
225
|
+
if (code === 409) {
|
|
226
|
+
return action === "resume" ? "Only a paused goal can be resumed." : "Goal is already in a terminal state.";
|
|
227
|
+
}
|
|
228
|
+
return errorMessage(cause) ?? `Could not ${action} the goal.`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function clearErrorMessage(cause: unknown): string {
|
|
232
|
+
if (statusCode(cause) === 409) {
|
|
233
|
+
return "Can't clear context mid-turn — stop the current turn first.";
|
|
234
|
+
}
|
|
235
|
+
return errorMessage(cause) ?? "Could not clear context.";
|
|
236
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { Permission, SessionStatus } from "@opengeni/sdk";
|
|
2
|
+
import type { SessionClientLike } from "../client";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The slash-command registry. A command is a SESSION / OPERATOR control — an
|
|
6
|
+
* action on the session or the UI (clear, compact, pause the goal, show help) —
|
|
7
|
+
* NOT a structured way to talk to the agent. The human↔agent channel stays
|
|
8
|
+
* plain chat; the palette only recognizes a leading "/" and never sends a
|
|
9
|
+
* command to the model.
|
|
10
|
+
*
|
|
11
|
+
* Two kinds, modeled by where the handler does its work:
|
|
12
|
+
* - CLIENT commands touch only the local UI (e.g. /help, /clear-view).
|
|
13
|
+
* - SERVER commands call the API through the SDK (e.g. /clear, /compact,
|
|
14
|
+
* /goal).
|
|
15
|
+
* Both are just `run(args, ctx)`; `ctx` exposes the client for server commands
|
|
16
|
+
* and the UI affordances (notice, openHelp, clearView, confirm) for both.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** A positional argument a command accepts after its name. */
|
|
20
|
+
export type SlashArg = {
|
|
21
|
+
name: string;
|
|
22
|
+
/** Enter runs only once every required arg is present; otherwise autocompletes. */
|
|
23
|
+
required?: boolean;
|
|
24
|
+
/** Closed value set (rendered as a hint; validated by the command itself). */
|
|
25
|
+
oneOf?: readonly string[];
|
|
26
|
+
description?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Transient feedback surfaced in the composer (generalized error line). */
|
|
30
|
+
export type Notice = { tone: "ok" | "error"; message: string };
|
|
31
|
+
|
|
32
|
+
/** Everything a command handler can reach. Assembled by the composer. */
|
|
33
|
+
export type CommandContext = {
|
|
34
|
+
/** SDK-shaped client for server commands. */
|
|
35
|
+
client: SessionClientLike;
|
|
36
|
+
workspaceId: string;
|
|
37
|
+
/** Null before a session exists (server commands should guard on this). */
|
|
38
|
+
sessionId: string | null;
|
|
39
|
+
status: SessionStatus | null;
|
|
40
|
+
/** The operator's permissions on this workspace (gates command visibility). */
|
|
41
|
+
permissions: Permission[];
|
|
42
|
+
/** Surface a transient ok/error notice in the composer. */
|
|
43
|
+
notice: (notice: Notice) => void;
|
|
44
|
+
/** Open the in-composer /help panel (rendered from the registry). */
|
|
45
|
+
openHelp: () => void;
|
|
46
|
+
/**
|
|
47
|
+
* Reset only the LOCAL timeline view — no server call. Returns whether a
|
|
48
|
+
* view-reset affordance was actually wired (and thus had an effect): the host
|
|
49
|
+
* surface supplies one via the composer's `onClearView` prop, and consoles
|
|
50
|
+
* that don't (no resettable local timeline) get `false`. The /clear-view
|
|
51
|
+
* command uses this to avoid reporting a false "cleared" success on a no-op.
|
|
52
|
+
*/
|
|
53
|
+
clearView: () => boolean;
|
|
54
|
+
/** Show the danger confirm bar; resolves true once the operator confirms. */
|
|
55
|
+
confirm: () => Promise<boolean>;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type CommandResult = {
|
|
59
|
+
status: "ok" | "error";
|
|
60
|
+
message?: string;
|
|
61
|
+
/**
|
|
62
|
+
* Keep the composer draft instead of clearing it on an ok result. Used when a
|
|
63
|
+
* command resolves to a no-op the operator may want to retry — e.g. canceling
|
|
64
|
+
* the /clear confirm bar returns ok (no error) but must NOT wipe the typed
|
|
65
|
+
* "/clear" draft. Default false: a successful command clears the draft.
|
|
66
|
+
*/
|
|
67
|
+
keepDraft?: boolean;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export type SlashCommand = {
|
|
71
|
+
/** Primary token after the slash (no leading "/"). */
|
|
72
|
+
name: string;
|
|
73
|
+
/** Alternate tokens that resolve to this command. */
|
|
74
|
+
aliases?: readonly string[];
|
|
75
|
+
description: string;
|
|
76
|
+
args?: readonly SlashArg[];
|
|
77
|
+
/** Required permission; the command is hidden from the palette without it. */
|
|
78
|
+
permission?: Permission;
|
|
79
|
+
/** Destructive — the palette shows a confirm bar before running. */
|
|
80
|
+
danger?: boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Dynamic availability beyond the permission gate (e.g. hide a server command
|
|
83
|
+
* until a session exists). Returning false hides the command.
|
|
84
|
+
*/
|
|
85
|
+
available?: (ctx: Pick<CommandContext, "sessionId" | "status" | "permissions">) => boolean;
|
|
86
|
+
/** Execute the command. Throwing is caught and surfaced as an error notice. */
|
|
87
|
+
run: (args: string[], ctx: CommandContext) => Promise<CommandResult> | CommandResult;
|
|
88
|
+
};
|