@schlessera/brain-ui-react 0.21.0 → 0.23.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/dist/components/chat/renderers/claude-tools.d.ts.map +1 -1
- package/dist/components/chat/renderers/claude-tools.js +21 -1
- package/dist/components/chat/renderers/claude-tools.js.map +1 -1
- package/dist/components/chat/renderers/index.d.ts.map +1 -1
- package/dist/components/chat/renderers/index.js +2 -0
- package/dist/components/chat/renderers/index.js.map +1 -1
- package/dist/components/chat/renderers/pi-tools.d.ts +3 -0
- package/dist/components/chat/renderers/pi-tools.d.ts.map +1 -0
- package/dist/components/chat/renderers/pi-tools.js +112 -0
- package/dist/components/chat/renderers/pi-tools.js.map +1 -0
- package/dist/components/chat/risk-hints.d.ts +14 -2
- package/dist/components/chat/risk-hints.d.ts.map +1 -1
- package/dist/components/chat/risk-hints.js +54 -19
- package/dist/components/chat/risk-hints.js.map +1 -1
- package/dist/components/chat/tool-call-timeline.d.ts +1 -1
- package/dist/components/chat/tool-call-timeline.d.ts.map +1 -1
- package/dist/components/chat/tool-call-timeline.js +25 -14
- package/dist/components/chat/tool-call-timeline.js.map +1 -1
- package/dist/components/settings/models-tab.d.ts.map +1 -1
- package/dist/components/settings/models-tab.js +2 -1
- package/dist/components/settings/models-tab.js.map +1 -1
- package/dist/components/settings/pi-accounts.d.ts +13 -0
- package/dist/components/settings/pi-accounts.d.ts.map +1 -0
- package/dist/components/settings/pi-accounts.js +146 -0
- package/dist/components/settings/pi-accounts.js.map +1 -0
- package/dist/hooks/use-websocket.d.ts.map +1 -1
- package/dist/hooks/use-websocket.js +26 -2
- package/dist/hooks/use-websocket.js.map +1 -1
- package/dist/lib/api-client.d.ts +42 -0
- package/dist/lib/api-client.d.ts.map +1 -1
- package/dist/lib/api-client.js +18 -0
- package/dist/lib/api-client.js.map +1 -1
- package/dist/lib/tool-names.d.ts.map +1 -1
- package/dist/lib/tool-names.js +5 -1
- package/dist/lib/tool-names.js.map +1 -1
- package/dist/stores/chat-store.d.ts +8 -0
- package/dist/stores/chat-store.d.ts.map +1 -1
- package/dist/stores/chat-store.js +4 -0
- package/dist/stores/chat-store.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +2 -2
- package/src/components/chat/renderers/claude-tools.tsx +32 -1
- package/src/components/chat/renderers/index.ts +2 -0
- package/src/components/chat/renderers/pi-tools.tsx +144 -0
- package/src/components/chat/risk-hints.ts +73 -25
- package/src/components/chat/tool-call-timeline.tsx +57 -16
- package/src/components/settings/models-tab.tsx +3 -0
- package/src/components/settings/pi-accounts.tsx +258 -0
- package/src/hooks/use-websocket.ts +29 -2
- package/src/lib/api-client.ts +52 -0
- package/src/lib/tool-names.ts +6 -1
- package/src/stores/chat-store.ts +16 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ToolSemantics } from "@schlessera/brain-ui-sdk/client";
|
|
1
2
|
import type { ToolCall } from "../../stores/chat-store.js";
|
|
2
3
|
|
|
3
4
|
// ============================================================
|
|
@@ -6,7 +7,9 @@ import type { ToolCall } from "../../stores/chat-store.js";
|
|
|
6
7
|
//
|
|
7
8
|
// Non-blocking advisories surfaced in the pending-approval detail so a human
|
|
8
9
|
// notices known-risky shapes before hitting Allow. Detection only — the human
|
|
9
|
-
// still decides.
|
|
10
|
+
// still decides. Rules test backend-neutral SEMANTICS (the command line run,
|
|
11
|
+
// the path written), supplied by the tool's renderer, so a rule written once
|
|
12
|
+
// covers Claude's `Bash`, pi's `bash`, and any future backend's equivalent.
|
|
10
13
|
|
|
11
14
|
/**
|
|
12
15
|
* Does this path resolve inside the brain repo?
|
|
@@ -31,8 +34,58 @@ function asString(v: unknown): string {
|
|
|
31
34
|
return typeof v === "string" ? v : "";
|
|
32
35
|
}
|
|
33
36
|
|
|
37
|
+
/** The extracted, plain-value meaning risk rules test against. */
|
|
38
|
+
export type ResolvedSemantics = {
|
|
39
|
+
/** Shell command line the call executes, or "". */
|
|
40
|
+
command: string;
|
|
41
|
+
/** Filesystem path the call writes, or "". */
|
|
42
|
+
writePath: string;
|
|
43
|
+
/** Call explicitly opts out of its backend's sandbox. */
|
|
44
|
+
unsandboxed: boolean;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Shape-sniffing fallback for renderers that declare no semantics, so an
|
|
49
|
+
* unknown tool with a recognizable input still gets its advisories.
|
|
50
|
+
*/
|
|
51
|
+
function sniffSemantics(tool: ToolCall): ResolvedSemantics {
|
|
52
|
+
const input = tool.input ?? {};
|
|
53
|
+
const writeSignal =
|
|
54
|
+
"content" in input ||
|
|
55
|
+
"old_string" in input ||
|
|
56
|
+
"new_string" in input ||
|
|
57
|
+
"new_source" in input;
|
|
58
|
+
return {
|
|
59
|
+
command: asString(input.command) || asString(input.cmd),
|
|
60
|
+
writePath: writeSignal
|
|
61
|
+
? asString(input.file_path) ||
|
|
62
|
+
asString(input.notebook_path) ||
|
|
63
|
+
asString(input.path)
|
|
64
|
+
: "",
|
|
65
|
+
unsandboxed: Boolean(input.dangerouslyDisableSandbox),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Evaluate a renderer's semantics accessors (each optional) for one call. */
|
|
70
|
+
export function resolveSemantics(
|
|
71
|
+
tool: ToolCall,
|
|
72
|
+
semantics?: ToolSemantics
|
|
73
|
+
): ResolvedSemantics {
|
|
74
|
+
const sniffed = sniffSemantics(tool);
|
|
75
|
+
if (!semantics) return sniffed;
|
|
76
|
+
return {
|
|
77
|
+
command: semantics.command ? semantics.command(tool) ?? "" : sniffed.command,
|
|
78
|
+
writePath: semantics.writePath
|
|
79
|
+
? semantics.writePath(tool) ?? ""
|
|
80
|
+
: sniffed.writePath,
|
|
81
|
+
unsandboxed: semantics.unsandboxed
|
|
82
|
+
? semantics.unsandboxed(tool)
|
|
83
|
+
: sniffed.unsandboxed,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
34
87
|
export type RiskRule = {
|
|
35
|
-
test: (
|
|
88
|
+
test: (sem: ResolvedSemantics) => boolean;
|
|
36
89
|
message: string;
|
|
37
90
|
};
|
|
38
91
|
|
|
@@ -41,54 +94,49 @@ export const RISK_RULES: RiskRule[] = [
|
|
|
41
94
|
// Note: the Bash input view already badges this red, so this line is
|
|
42
95
|
// secondary. Kept for consistency so the advisory row reflects every known
|
|
43
96
|
// risk in one place rather than the human having to cross-reference badges.
|
|
44
|
-
test: (
|
|
97
|
+
test: (sem) => sem.unsandboxed,
|
|
45
98
|
message: "runs without the sandbox",
|
|
46
99
|
},
|
|
47
100
|
{
|
|
48
101
|
// Recursive + force in either flag order (-rf, -fr) or as separate flags
|
|
49
102
|
// (rm -r -f). Lookaheads match a `-…r` flag and a `-…f` flag in any order.
|
|
50
|
-
test: (
|
|
51
|
-
|
|
52
|
-
/\brm\b(?=[^\n]*\s-[a-zA-Z]*r)(?=[^\n]*\s-[a-zA-Z]*f)/.test(
|
|
53
|
-
asString(t.input?.command)
|
|
54
|
-
),
|
|
103
|
+
test: (sem) =>
|
|
104
|
+
/\brm\b(?=[^\n]*\s-[a-zA-Z]*r)(?=[^\n]*\s-[a-zA-Z]*f)/.test(sem.command),
|
|
55
105
|
message: "removes files recursively (rm -rf)",
|
|
56
106
|
},
|
|
57
107
|
{
|
|
58
|
-
test: (
|
|
59
|
-
t.name === "Bash" &&
|
|
108
|
+
test: (sem) =>
|
|
60
109
|
/git\s+push\b[^\n]*(?:--force\b|--force-with-lease\b|\s-f\b)/.test(
|
|
61
|
-
|
|
110
|
+
sem.command
|
|
62
111
|
),
|
|
63
112
|
message: "force-pushes a git branch",
|
|
64
113
|
},
|
|
65
114
|
{
|
|
66
|
-
test: (
|
|
67
|
-
|
|
68
|
-
/\b(?:curl|wget)\b[^|]*\|\s*(?:ba)?sh\b/.test(asString(t.input?.command)),
|
|
115
|
+
test: (sem) =>
|
|
116
|
+
/\b(?:curl|wget)\b[^|]*\|\s*(?:ba)?sh\b/.test(sem.command),
|
|
69
117
|
message: "pipes a remote script into a shell",
|
|
70
118
|
},
|
|
71
119
|
{
|
|
72
|
-
test: (
|
|
73
|
-
if (t.name !== "Write" && t.name !== "Edit" && t.name !== "NotebookEdit")
|
|
74
|
-
return false;
|
|
75
|
-
const path =
|
|
76
|
-
asString(t.input?.file_path) || asString(t.input?.notebook_path);
|
|
77
|
-
if (!path) return false;
|
|
78
|
-
return !isInsideBrainRepo(path);
|
|
79
|
-
},
|
|
120
|
+
test: (sem) => Boolean(sem.writePath) && !isInsideBrainRepo(sem.writePath),
|
|
80
121
|
message: "writes outside the brain repo",
|
|
81
122
|
},
|
|
82
123
|
];
|
|
83
124
|
|
|
84
125
|
/** Advisory messages for every risk rule that fires. Never throws. */
|
|
85
|
-
export function riskHints(tool: ToolCall): string[] {
|
|
126
|
+
export function riskHints(tool: ToolCall, semantics?: ToolSemantics): string[] {
|
|
86
127
|
const hints: string[] = [];
|
|
128
|
+
let sem: ResolvedSemantics;
|
|
129
|
+
try {
|
|
130
|
+
sem = resolveSemantics(tool, semantics);
|
|
131
|
+
} catch {
|
|
132
|
+
// Defensive: never let a malformed input break the approval UI.
|
|
133
|
+
return hints;
|
|
134
|
+
}
|
|
87
135
|
for (const rule of RISK_RULES) {
|
|
88
136
|
try {
|
|
89
|
-
if (rule.test(
|
|
137
|
+
if (rule.test(sem)) hints.push(rule.message);
|
|
90
138
|
} catch {
|
|
91
|
-
//
|
|
139
|
+
// Same defensiveness per rule.
|
|
92
140
|
}
|
|
93
141
|
}
|
|
94
142
|
return hints;
|
|
@@ -9,8 +9,8 @@ import {
|
|
|
9
9
|
AlertTriangle,
|
|
10
10
|
FileText,
|
|
11
11
|
} from "lucide-react";
|
|
12
|
-
import { resolveToolRenderer } from "@schlessera/brain-ui-sdk/client";
|
|
13
|
-
import type
|
|
12
|
+
import { resolveToolRenderer, type ToolSemantics } from "@schlessera/brain-ui-sdk/client";
|
|
13
|
+
import { useChatStore, type ToolCall } from "../../stores/chat-store.js";
|
|
14
14
|
import { cn } from "../../lib/utils.js";
|
|
15
15
|
import { motion, AnimatePresence } from "framer-motion";
|
|
16
16
|
import { getToolLabel, getTouchedFile, formatDuration, formatTokenCount } from "./tool-views.js";
|
|
@@ -26,10 +26,18 @@ import { SpanStatusDot } from "../activity/span-bits.js";
|
|
|
26
26
|
// runs on module load.
|
|
27
27
|
registerBuiltinRenderers();
|
|
28
28
|
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
// Sessions that predate backend stamping (old servers, cleared stores) scope
|
|
30
|
+
// to the shipped default backend.
|
|
31
|
+
const DEFAULT_BACKEND_ID = "claude";
|
|
32
|
+
|
|
33
|
+
/** Backend owning the session in view, scoping renderer resolution. */
|
|
34
|
+
function useBackendId(): string {
|
|
35
|
+
return useChatStore(
|
|
36
|
+
(s) =>
|
|
37
|
+
(s.activeSessionId ? s.backendIds[s.activeSessionId] : undefined) ??
|
|
38
|
+
DEFAULT_BACKEND_ID
|
|
39
|
+
);
|
|
40
|
+
}
|
|
33
41
|
|
|
34
42
|
export function ToolCallTimeline({
|
|
35
43
|
toolCalls,
|
|
@@ -44,6 +52,7 @@ export function ToolCallTimeline({
|
|
|
44
52
|
// Collapse the whole run to a summary row once the turn is over. History
|
|
45
53
|
// messages mount collapsed; a live timeline collapses when streaming ends.
|
|
46
54
|
const [collapsed, setCollapsed] = useState(!live);
|
|
55
|
+
const backendId = useBackendId();
|
|
47
56
|
const prevLive = useRef(live);
|
|
48
57
|
useEffect(() => {
|
|
49
58
|
if (prevLive.current && !live) setCollapsed(true);
|
|
@@ -57,7 +66,11 @@ export function ToolCallTimeline({
|
|
|
57
66
|
|
|
58
67
|
if (effectiveCollapsed) {
|
|
59
68
|
return (
|
|
60
|
-
<TimelineSummaryRow
|
|
69
|
+
<TimelineSummaryRow
|
|
70
|
+
toolCalls={toolCalls}
|
|
71
|
+
backendId={backendId}
|
|
72
|
+
onExpand={() => setCollapsed(false)}
|
|
73
|
+
/>
|
|
61
74
|
);
|
|
62
75
|
}
|
|
63
76
|
|
|
@@ -75,7 +88,12 @@ export function ToolCallTimeline({
|
|
|
75
88
|
)}
|
|
76
89
|
<div className="relative ml-1 border-l-2 border-border/40 pl-4 space-y-1.5">
|
|
77
90
|
{toolCalls.map((tool) => (
|
|
78
|
-
<ToolCallEntry
|
|
91
|
+
<ToolCallEntry
|
|
92
|
+
key={tool.id}
|
|
93
|
+
toolCall={tool}
|
|
94
|
+
backendId={backendId}
|
|
95
|
+
onApproval={onApproval}
|
|
96
|
+
/>
|
|
79
97
|
))}
|
|
80
98
|
</div>
|
|
81
99
|
</div>
|
|
@@ -84,13 +102,22 @@ export function ToolCallTimeline({
|
|
|
84
102
|
|
|
85
103
|
function TimelineSummaryRow({
|
|
86
104
|
toolCalls,
|
|
105
|
+
backendId,
|
|
87
106
|
onExpand,
|
|
88
107
|
}: {
|
|
89
108
|
toolCalls: ToolCall[];
|
|
109
|
+
backendId: string;
|
|
90
110
|
onExpand: () => void;
|
|
91
111
|
}) {
|
|
92
112
|
const steps = toolCalls.length;
|
|
93
|
-
const files = new Set(
|
|
113
|
+
const files = new Set(
|
|
114
|
+
toolCalls
|
|
115
|
+
.map(
|
|
116
|
+
(t) =>
|
|
117
|
+
resolveToolRenderer(t, backendId)?.touchedFile?.(t) ?? getTouchedFile(t)
|
|
118
|
+
)
|
|
119
|
+
.filter(Boolean)
|
|
120
|
+
).size;
|
|
94
121
|
const errors = toolCalls.filter((t) => t.isError).length;
|
|
95
122
|
const started = Math.min(...toolCalls.map((t) => t.startedAt ?? Infinity));
|
|
96
123
|
const ended = Math.max(...toolCalls.map((t) => t.endedAt ?? -Infinity));
|
|
@@ -123,9 +150,11 @@ function TimelineSummaryRow({
|
|
|
123
150
|
|
|
124
151
|
function ToolCallEntry({
|
|
125
152
|
toolCall,
|
|
153
|
+
backendId,
|
|
126
154
|
onApproval,
|
|
127
155
|
}: {
|
|
128
156
|
toolCall: ToolCall;
|
|
157
|
+
backendId: string;
|
|
129
158
|
onApproval: (toolUseId: string, approved: boolean) => void;
|
|
130
159
|
}) {
|
|
131
160
|
const [expanded, setExpanded] = useState(
|
|
@@ -147,8 +176,12 @@ function ToolCallEntry({
|
|
|
147
176
|
: toolCall;
|
|
148
177
|
// Resolve the renderer for this tool (backend-scoped exact -> global exact ->
|
|
149
178
|
// shape-sniffing predicate). Falls back to the generic renderer.
|
|
150
|
-
const renderer = resolveToolRenderer(toolCall,
|
|
179
|
+
const renderer = resolveToolRenderer(toolCall, backendId) ?? GENERIC_RENDERER;
|
|
151
180
|
const Icon = renderer.icon ?? FileText;
|
|
181
|
+
const label =
|
|
182
|
+
typeof renderer.label === "function"
|
|
183
|
+
? renderer.label(toolCall)
|
|
184
|
+
: renderer.label ?? getToolLabel(toolCall.name);
|
|
152
185
|
const isPending = toolCall.status === "pending_approval";
|
|
153
186
|
const summary = renderer.summary?.(timed) ?? null;
|
|
154
187
|
const meta = renderer.meta?.(timed) ?? null;
|
|
@@ -204,7 +237,7 @@ function ToolCallEntry({
|
|
|
204
237
|
>
|
|
205
238
|
<Icon className="h-3.5 w-3.5 shrink-0" />
|
|
206
239
|
<span className="font-[family-name:var(--font-mono)] font-medium">
|
|
207
|
-
{
|
|
240
|
+
{label}
|
|
208
241
|
</span>
|
|
209
242
|
{summary && (
|
|
210
243
|
<span
|
|
@@ -226,8 +259,8 @@ function ToolCallEntry({
|
|
|
226
259
|
</span>
|
|
227
260
|
</button>
|
|
228
261
|
|
|
229
|
-
{/* Live subagent state for
|
|
230
|
-
{
|
|
262
|
+
{/* Live subagent state for tool fan-outs, fed by the activity stream. */}
|
|
263
|
+
{renderer.subagentRows && <SubagentEntryRows agentToolUseId={toolCall.id} />}
|
|
231
264
|
|
|
232
265
|
{/* Expandable detail */}
|
|
233
266
|
<AnimatePresence>
|
|
@@ -251,7 +284,9 @@ function ToolCallEntry({
|
|
|
251
284
|
{Input && <Input tool={toolCall} />}
|
|
252
285
|
|
|
253
286
|
{/* Risk hints — advisory only, never blocks approval */}
|
|
254
|
-
{isPending &&
|
|
287
|
+
{isPending && (
|
|
288
|
+
<RiskHints toolCall={toolCall} semantics={renderer.semantics} />
|
|
289
|
+
)}
|
|
255
290
|
|
|
256
291
|
{/* Approval buttons */}
|
|
257
292
|
{isPending && (
|
|
@@ -333,8 +368,14 @@ function SubagentEntryRows({ agentToolUseId }: { agentToolUseId: string }) {
|
|
|
333
368
|
);
|
|
334
369
|
}
|
|
335
370
|
|
|
336
|
-
function RiskHints({
|
|
337
|
-
|
|
371
|
+
function RiskHints({
|
|
372
|
+
toolCall,
|
|
373
|
+
semantics,
|
|
374
|
+
}: {
|
|
375
|
+
toolCall: ToolCall;
|
|
376
|
+
semantics?: ToolSemantics;
|
|
377
|
+
}) {
|
|
378
|
+
const hints = riskHints(toolCall, semantics);
|
|
338
379
|
if (hints.length === 0) return null;
|
|
339
380
|
return (
|
|
340
381
|
<div className="space-y-0.5 text-[11px] text-amber-400">
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
import { api } from "../../lib/api-client.js";
|
|
9
9
|
import { useProviderStore } from "../../stores/provider-store.js";
|
|
10
10
|
import { cn } from "../../lib/utils.js";
|
|
11
|
+
import { PiAccountsSection } from "./pi-accounts.js";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* The model picker's contents, and which of them to show.
|
|
@@ -200,6 +201,8 @@ export function ModelsTab({ active }: { active: boolean }) {
|
|
|
200
201
|
profiles are listed.
|
|
201
202
|
</p>
|
|
202
203
|
)}
|
|
204
|
+
|
|
205
|
+
<PiAccountsSection active={active} />
|
|
203
206
|
</div>
|
|
204
207
|
|
|
205
208
|
<div className="flex items-center justify-between gap-3 border-t border-border p-4">
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Check, ExternalLink, Loader2, LogOut, X } from "lucide-react";
|
|
3
|
+
import { api, type PiAuthProviderStatus, type PiLoginFlow } from "../../lib/api-client.js";
|
|
4
|
+
import { cn } from "../../lib/utils.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Provider sign-in for the pi backend's OAuth vendors — most importantly
|
|
8
|
+
* OpenAI (ChatGPT Plus/Pro), whose device-code flow needs no browser callback
|
|
9
|
+
* on the server: the user gets a short code here, enters it at the provider's
|
|
10
|
+
* verification page on ANY device, and the server stores the credential.
|
|
11
|
+
*
|
|
12
|
+
* Renders nothing when the server reports no pi providers (pi not
|
|
13
|
+
* configured), so the Models tab is unchanged for Claude-only deployments.
|
|
14
|
+
*/
|
|
15
|
+
export function PiAccountsSection({ active }: { active: boolean }) {
|
|
16
|
+
const [providers, setProviders] = useState<PiAuthProviderStatus[]>([]);
|
|
17
|
+
const [error, setError] = useState<string | null>(null);
|
|
18
|
+
const [busy, setBusy] = useState<string | null>(null);
|
|
19
|
+
const [flow, setFlow] = useState<PiLoginFlow | null>(null);
|
|
20
|
+
const pollTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
21
|
+
|
|
22
|
+
function stopPolling() {
|
|
23
|
+
if (pollTimer.current) {
|
|
24
|
+
clearTimeout(pollTimer.current);
|
|
25
|
+
pollTimer.current = null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function reload() {
|
|
30
|
+
try {
|
|
31
|
+
const { providers } = await api.piAuthProviders();
|
|
32
|
+
setProviders(providers);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
setError(err instanceof Error ? err.message : "Could not load accounts");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!active) return;
|
|
40
|
+
let cancelled = false;
|
|
41
|
+
api
|
|
42
|
+
.piAuthProviders()
|
|
43
|
+
.then(({ providers }) => {
|
|
44
|
+
if (!cancelled) setProviders(providers);
|
|
45
|
+
})
|
|
46
|
+
.catch(() => {
|
|
47
|
+
// A server without the endpoint (older release) just hides the card.
|
|
48
|
+
if (!cancelled) setProviders([]);
|
|
49
|
+
});
|
|
50
|
+
return () => {
|
|
51
|
+
cancelled = true;
|
|
52
|
+
};
|
|
53
|
+
}, [active]);
|
|
54
|
+
|
|
55
|
+
// Poll the pending flow until it settles. Chained timeouts rather than an
|
|
56
|
+
// interval, so a slow response never stacks requests.
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (!flow || flow.status !== "pending") return;
|
|
59
|
+
let disposed = false;
|
|
60
|
+
const delayMs = (flow.intervalSeconds ?? 5) * 1000;
|
|
61
|
+
const tick = async () => {
|
|
62
|
+
try {
|
|
63
|
+
const { flow: next } = await api.piAuthFlow(flow.id);
|
|
64
|
+
if (disposed) return;
|
|
65
|
+
setFlow(next);
|
|
66
|
+
if (next.status === "success") void reload();
|
|
67
|
+
} catch {
|
|
68
|
+
if (disposed) return;
|
|
69
|
+
// Transient poll failure: keep trying until the flow expires.
|
|
70
|
+
pollTimer.current = setTimeout(tick, delayMs);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
pollTimer.current = setTimeout(tick, delayMs);
|
|
75
|
+
return () => {
|
|
76
|
+
disposed = true;
|
|
77
|
+
stopPolling();
|
|
78
|
+
};
|
|
79
|
+
}, [flow]);
|
|
80
|
+
|
|
81
|
+
async function connect(providerId: string) {
|
|
82
|
+
setBusy(providerId);
|
|
83
|
+
setError(null);
|
|
84
|
+
try {
|
|
85
|
+
const { flow } = await api.piAuthStart(providerId);
|
|
86
|
+
setFlow(flow);
|
|
87
|
+
if (flow.status === "error") setError(flow.error ?? "Login failed");
|
|
88
|
+
} catch (err) {
|
|
89
|
+
setError(err instanceof Error ? err.message : "Could not start login");
|
|
90
|
+
} finally {
|
|
91
|
+
setBusy(null);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function cancel() {
|
|
96
|
+
if (!flow) return;
|
|
97
|
+
stopPolling();
|
|
98
|
+
try {
|
|
99
|
+
await api.piAuthCancel(flow.id);
|
|
100
|
+
} catch {
|
|
101
|
+
// The flow record may already be gone; clearing locally is enough.
|
|
102
|
+
}
|
|
103
|
+
setFlow(null);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function disconnect(providerId: string) {
|
|
107
|
+
setBusy(providerId);
|
|
108
|
+
setError(null);
|
|
109
|
+
try {
|
|
110
|
+
await api.piAuthLogout(providerId);
|
|
111
|
+
await reload();
|
|
112
|
+
} catch (err) {
|
|
113
|
+
setError(err instanceof Error ? err.message : "Could not disconnect");
|
|
114
|
+
} finally {
|
|
115
|
+
setBusy(null);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (providers.length === 0) return null;
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<div className="mt-6">
|
|
123
|
+
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
124
|
+
Accounts
|
|
125
|
+
</h3>
|
|
126
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
127
|
+
Model providers that sign in with an account instead of an API key.
|
|
128
|
+
</p>
|
|
129
|
+
<ul className="mt-4 flex flex-col gap-2">
|
|
130
|
+
{providers.map((provider) => (
|
|
131
|
+
<li
|
|
132
|
+
key={provider.providerId}
|
|
133
|
+
className="rounded-lg border border-border-subtle bg-surface p-3"
|
|
134
|
+
>
|
|
135
|
+
<div className="flex items-center gap-3">
|
|
136
|
+
<div className="min-w-0 flex-1">
|
|
137
|
+
<p className="truncate text-sm text-foreground">{provider.name}</p>
|
|
138
|
+
<p className="truncate text-[11px] text-muted-foreground">
|
|
139
|
+
{provider.configured
|
|
140
|
+
? `Connected${provider.source ? ` · ${provider.source}` : ""}`
|
|
141
|
+
: "Not connected"}
|
|
142
|
+
</p>
|
|
143
|
+
</div>
|
|
144
|
+
{provider.configured && (
|
|
145
|
+
<Check className="h-4 w-4 shrink-0 text-accent" />
|
|
146
|
+
)}
|
|
147
|
+
{provider.oauth && !provider.configured && (
|
|
148
|
+
<button
|
|
149
|
+
onClick={() => connect(provider.providerId)}
|
|
150
|
+
disabled={busy !== null || flow?.status === "pending"}
|
|
151
|
+
className="shrink-0 rounded-lg border border-border-subtle bg-surface px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:border-primary hover:text-primary disabled:opacity-50"
|
|
152
|
+
>
|
|
153
|
+
{busy === provider.providerId ? (
|
|
154
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
155
|
+
) : (
|
|
156
|
+
"Connect"
|
|
157
|
+
)}
|
|
158
|
+
</button>
|
|
159
|
+
)}
|
|
160
|
+
{provider.configured && provider.source === "stored" && (
|
|
161
|
+
<button
|
|
162
|
+
onClick={() => disconnect(provider.providerId)}
|
|
163
|
+
disabled={busy !== null}
|
|
164
|
+
title="Disconnect"
|
|
165
|
+
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-surface-raised hover:text-destructive"
|
|
166
|
+
>
|
|
167
|
+
<LogOut className="h-4 w-4" />
|
|
168
|
+
</button>
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
{flow && flow.providerId === provider.providerId && (
|
|
173
|
+
<LoginFlowCard flow={flow} onCancel={cancel} onDismiss={() => setFlow(null)} />
|
|
174
|
+
)}
|
|
175
|
+
</li>
|
|
176
|
+
))}
|
|
177
|
+
</ul>
|
|
178
|
+
{error && (
|
|
179
|
+
<p role="alert" className="mt-3 text-xs text-destructive">
|
|
180
|
+
{error}
|
|
181
|
+
</p>
|
|
182
|
+
)}
|
|
183
|
+
</div>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function LoginFlowCard({
|
|
188
|
+
flow,
|
|
189
|
+
onCancel,
|
|
190
|
+
onDismiss,
|
|
191
|
+
}: {
|
|
192
|
+
flow: PiLoginFlow;
|
|
193
|
+
onCancel: () => void;
|
|
194
|
+
onDismiss: () => void;
|
|
195
|
+
}) {
|
|
196
|
+
if (flow.status === "pending") {
|
|
197
|
+
return (
|
|
198
|
+
<div className="mt-3 rounded-lg border border-primary/40 bg-primary/5 p-3">
|
|
199
|
+
<p className="text-xs text-muted-foreground">
|
|
200
|
+
Enter this code at the provider's device page — on this or any other
|
|
201
|
+
device:
|
|
202
|
+
</p>
|
|
203
|
+
<p className="mt-2 select-all text-center font-[family-name:var(--font-mono)] text-xl font-semibold tracking-widest text-foreground">
|
|
204
|
+
{flow.userCode ?? "…"}
|
|
205
|
+
</p>
|
|
206
|
+
<div className="mt-3 flex items-center justify-center gap-2">
|
|
207
|
+
{flow.verificationUri && (
|
|
208
|
+
<a
|
|
209
|
+
href={flow.verificationUri}
|
|
210
|
+
target="_blank"
|
|
211
|
+
rel="noreferrer"
|
|
212
|
+
className="flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:brightness-110"
|
|
213
|
+
>
|
|
214
|
+
<ExternalLink className="h-3 w-3" />
|
|
215
|
+
Open verification page
|
|
216
|
+
</a>
|
|
217
|
+
)}
|
|
218
|
+
<button
|
|
219
|
+
onClick={onCancel}
|
|
220
|
+
className="flex items-center gap-1.5 rounded-lg border border-border-subtle px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
|
|
221
|
+
>
|
|
222
|
+
<X className="h-3 w-3" />
|
|
223
|
+
Cancel
|
|
224
|
+
</button>
|
|
225
|
+
</div>
|
|
226
|
+
<p className="mt-2 flex items-center justify-center gap-1.5 text-center text-[11px] text-muted-foreground">
|
|
227
|
+
<Loader2 className="h-3 w-3 animate-spin" />
|
|
228
|
+
Waiting for approval…
|
|
229
|
+
</p>
|
|
230
|
+
</div>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const message =
|
|
235
|
+
flow.status === "success"
|
|
236
|
+
? "Connected."
|
|
237
|
+
: flow.status === "cancelled"
|
|
238
|
+
? "Login cancelled."
|
|
239
|
+
: flow.error ?? "Login failed.";
|
|
240
|
+
return (
|
|
241
|
+
<div
|
|
242
|
+
className={cn(
|
|
243
|
+
"mt-3 flex items-center justify-between rounded-lg border p-3 text-xs",
|
|
244
|
+
flow.status === "success"
|
|
245
|
+
? "border-border-subtle text-muted-foreground"
|
|
246
|
+
: "border-destructive/30 text-destructive"
|
|
247
|
+
)}
|
|
248
|
+
>
|
|
249
|
+
<span>{message}</span>
|
|
250
|
+
<button
|
|
251
|
+
onClick={onDismiss}
|
|
252
|
+
className="text-muted-foreground transition-colors hover:text-foreground"
|
|
253
|
+
>
|
|
254
|
+
<X className="h-3.5 w-3.5" />
|
|
255
|
+
</button>
|
|
256
|
+
</div>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
@@ -60,6 +60,15 @@ function convertHistoryMessage(msg: SessionHistoryMessage): ChatMessage {
|
|
|
60
60
|
* payload, so an answered question survives session resume (rendered in its
|
|
61
61
|
* chronological slot, collapsed) instead of vanishing.
|
|
62
62
|
*/
|
|
63
|
+
function isBareAnswersMap(v: unknown): v is Record<string, string> {
|
|
64
|
+
return (
|
|
65
|
+
!!v &&
|
|
66
|
+
typeof v === "object" &&
|
|
67
|
+
!Array.isArray(v) &&
|
|
68
|
+
Object.values(v).every((x) => typeof x === "string")
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
63
72
|
function reconstructAskUserExchanges(
|
|
64
73
|
toolCalls: SessionHistoryMessage["toolCalls"]
|
|
65
74
|
): AskUserExchange[] | undefined {
|
|
@@ -74,8 +83,14 @@ function reconstructAskUserExchanges(
|
|
|
74
83
|
answers?: Record<string, string>;
|
|
75
84
|
annotations?: AskUserExchange["annotations"];
|
|
76
85
|
};
|
|
77
|
-
|
|
78
|
-
|
|
86
|
+
if (payload && typeof payload === "object" && payload.answers) {
|
|
87
|
+
exchange.answers = payload.answers;
|
|
88
|
+
exchange.annotations = payload.annotations;
|
|
89
|
+
} else if (isBareAnswersMap(payload)) {
|
|
90
|
+
// The pi backend persists the bare answers map, without the
|
|
91
|
+
// `{answers}` envelope the Claude tool writes.
|
|
92
|
+
exchange.answers = payload;
|
|
93
|
+
}
|
|
79
94
|
} catch {
|
|
80
95
|
// Non-JSON output means the question was dismissed or errored out.
|
|
81
96
|
exchange.cancelled = true;
|
|
@@ -281,6 +296,18 @@ export function handleServerMessage(msg: ServerMessage) {
|
|
|
281
296
|
|
|
282
297
|
case "session_info": {
|
|
283
298
|
ensureActivitySubscription(msg.sessionId);
|
|
299
|
+
// Record backend ownership for renderer scoping — for ANY session, since
|
|
300
|
+
// background sessions keep their own transcript buffers. Older servers
|
|
301
|
+
// omit backendId; derive it from the pinned profile when still possible
|
|
302
|
+
// (fails only for since-hidden profiles, which then use the default).
|
|
303
|
+
const ownerBackendId =
|
|
304
|
+
msg.backendId ??
|
|
305
|
+
useProviderStore
|
|
306
|
+
.getState()
|
|
307
|
+
.available.find((p) => p.id === msg.providerId)?.backendId;
|
|
308
|
+
if (ownerBackendId) {
|
|
309
|
+
useChatStore.getState().setSessionBackend(msg.sessionId, ownerBackendId);
|
|
310
|
+
}
|
|
284
311
|
// bindDraftSession above handled draft adoption; an info frame may still
|
|
285
312
|
// re-pin the provider picker when it concerns the session in view.
|
|
286
313
|
const current = useChatStore.getState();
|