@schlessera/brain-ui-react 0.36.0 → 0.37.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/activity/activity-page.d.ts.map +1 -1
- package/dist/components/activity/activity-page.js +13 -9
- package/dist/components/activity/activity-page.js.map +1 -1
- package/dist/components/activity/approval-card.d.ts +19 -1
- package/dist/components/activity/approval-card.d.ts.map +1 -1
- package/dist/components/activity/approval-card.js +30 -3
- package/dist/components/activity/approval-card.js.map +1 -1
- package/dist/components/activity/span-bits.d.ts +11 -0
- package/dist/components/activity/span-bits.d.ts.map +1 -1
- package/dist/components/activity/span-bits.js +41 -0
- package/dist/components/activity/span-bits.js.map +1 -1
- package/dist/components/chat/chat-page.d.ts.map +1 -1
- package/dist/components/chat/chat-page.js +3 -2
- package/dist/components/chat/chat-page.js.map +1 -1
- package/dist/components/chat/tool-call-timeline.d.ts.map +1 -1
- package/dist/components/chat/tool-call-timeline.js +9 -3
- package/dist/components/chat/tool-call-timeline.js.map +1 -1
- package/dist/components/chat/tool-views.d.ts +6 -0
- package/dist/components/chat/tool-views.d.ts.map +1 -1
- package/dist/components/chat/tool-views.js +8 -0
- package/dist/components/chat/tool-views.js.map +1 -1
- package/dist/components/files/file-panel.js +1 -1
- package/dist/components/layout/side-rail.d.ts +5 -1
- package/dist/components/layout/side-rail.d.ts.map +1 -1
- package/dist/components/layout/side-rail.js +13 -6
- package/dist/components/layout/side-rail.js.map +1 -1
- package/dist/components/layout/slide-panel.d.ts +14 -1
- package/dist/components/layout/slide-panel.d.ts.map +1 -1
- package/dist/components/layout/slide-panel.js +5 -5
- package/dist/components/layout/slide-panel.js.map +1 -1
- package/dist/components/quick-actions/add-form.d.ts.map +1 -1
- package/dist/components/quick-actions/add-form.js +4 -2
- package/dist/components/quick-actions/add-form.js.map +1 -1
- package/dist/components/quick-actions/search-modal.d.ts.map +1 -1
- package/dist/components/quick-actions/search-modal.js +4 -2
- package/dist/components/quick-actions/search-modal.js.map +1 -1
- package/dist/components/quick-actions/streaming-modal.d.ts.map +1 -1
- package/dist/components/quick-actions/streaming-modal.js +15 -2
- package/dist/components/quick-actions/streaming-modal.js.map +1 -1
- package/dist/components/quick-actions/whatsup-modal.d.ts.map +1 -1
- package/dist/components/quick-actions/whatsup-modal.js +16 -1
- package/dist/components/quick-actions/whatsup-modal.js.map +1 -1
- package/dist/components/settings/settings-panel.d.ts.map +1 -1
- package/dist/components/settings/settings-panel.js +8 -8
- package/dist/components/settings/settings-panel.js.map +1 -1
- package/dist/hooks/use-fine-pointer.d.ts +12 -0
- package/dist/hooks/use-fine-pointer.d.ts.map +1 -0
- package/dist/hooks/use-fine-pointer.js +15 -0
- package/dist/hooks/use-fine-pointer.js.map +1 -0
- package/dist/hooks/use-media-query.d.ts +5 -3
- package/dist/hooks/use-media-query.d.ts.map +1 -1
- package/dist/hooks/use-media-query.js +11 -7
- package/dist/hooks/use-media-query.js.map +1 -1
- package/dist/hooks/websocket-handlers/chat.d.ts.map +1 -1
- package/dist/hooks/websocket-handlers/chat.js +1 -1
- package/dist/hooks/websocket-handlers/chat.js.map +1 -1
- package/dist/stores/chat-state.d.ts +22 -1
- package/dist/stores/chat-state.d.ts.map +1 -1
- package/dist/stores/chat-state.js +12 -1
- package/dist/stores/chat-state.js.map +1 -1
- package/package.json +3 -3
- package/src/components/activity/activity-page.tsx +16 -9
- package/src/components/activity/approval-card.tsx +42 -5
- package/src/components/activity/span-bits.tsx +52 -0
- package/src/components/chat/chat-page.tsx +3 -2
- package/src/components/chat/tool-call-timeline.tsx +18 -6
- package/src/components/chat/tool-views.tsx +9 -0
- package/src/components/files/file-panel.tsx +1 -1
- package/src/components/layout/side-rail.tsx +13 -6
- package/src/components/layout/slide-panel.tsx +20 -4
- package/src/components/quick-actions/add-form.tsx +9 -1
- package/src/components/quick-actions/search-modal.tsx +10 -2
- package/src/components/quick-actions/streaming-modal.tsx +16 -2
- package/src/components/quick-actions/whatsup-modal.tsx +17 -1
- package/src/components/settings/settings-panel.tsx +11 -8
- package/src/hooks/use-fine-pointer.ts +15 -0
- package/src/hooks/use-media-query.ts +10 -7
- package/src/hooks/websocket-handlers/chat.ts +2 -1
- package/src/stores/chat-state.ts +29 -2
|
@@ -12,7 +12,7 @@ import { useActivityStore } from "../../stores/activity-store.js";
|
|
|
12
12
|
import { useChatStore, pendingApprovals } from "../../stores/chat-store.js";
|
|
13
13
|
import { EmptyState, FilterRow, InlineToast, Label } from "@schlessera/brain-ui-kit";
|
|
14
14
|
import { cn } from "../../lib/utils.js";
|
|
15
|
-
import { ApprovalCard } from "./approval-card.js";
|
|
15
|
+
import { ApprovalCard, approvalOutcome } from "./approval-card.js";
|
|
16
16
|
import { useUIStore } from "../../stores/ui-store.js";
|
|
17
17
|
import { formatRelativeTime } from "../chat/tool-views.js";
|
|
18
18
|
import { RollupCards } from "./activity-rollups.js";
|
|
@@ -20,6 +20,7 @@ import { LiveRow, RunRow } from "./activity-run-list.js";
|
|
|
20
20
|
import { RunDetail, RunTraceSteps } from "./activity-run-detail.js";
|
|
21
21
|
import { IntentCard } from "./activity-views.js";
|
|
22
22
|
import { focusAfterDecision, singleKey } from "../../lib/single-key.js";
|
|
23
|
+
import { useFinePointer } from "../../hooks/use-fine-pointer.js";
|
|
23
24
|
import { PushToggle } from "./push-toggle.js";
|
|
24
25
|
import { SettingsPanel } from "../settings/settings-panel.js";
|
|
25
26
|
|
|
@@ -209,6 +210,11 @@ export function ActivityPage() {
|
|
|
209
210
|
}
|
|
210
211
|
|
|
211
212
|
const keys = useUIStore((s) => s.singleKeyShortcuts);
|
|
213
|
+
// The keys bind on `keys` alone; they are PRINTED only while a mouse or
|
|
214
|
+
// trackpad is present (#86, #100), like the approval card's caps. A
|
|
215
|
+
// touch-only tablet past `laptop:` would otherwise read keys it cannot fire.
|
|
216
|
+
const finePointer = useFinePointer();
|
|
217
|
+
const printKeys = keys && finePointer;
|
|
212
218
|
const INTENT_CARD = "[data-intent-card] > [role=\"button\"]";
|
|
213
219
|
/**
|
|
214
220
|
* `j` / `k` move inside the focused list and `d` dismisses the focused
|
|
@@ -243,13 +249,13 @@ export function ActivityPage() {
|
|
|
243
249
|
setDrained(approvals.length === 0);
|
|
244
250
|
void acknowledgeAllIntents();
|
|
245
251
|
}
|
|
246
|
-
function decideApproval(key: string | null, toolUseId: string, approved: boolean,
|
|
252
|
+
function decideApproval(key: string | null, toolUseId: string, approved: boolean, asked?: boolean) {
|
|
247
253
|
const tool = approvals.find((a) => a.tool.id === toolUseId)?.tool;
|
|
248
|
-
|
|
254
|
+
const { receipt, frame } = approvalOutcome(tool, toolUseId, approved, asked);
|
|
255
|
+
setReceipt(receipt);
|
|
249
256
|
setDrained(inbox.length + approvals.length <= 1);
|
|
250
257
|
resolveToolApproval(key, toolUseId, approved);
|
|
251
|
-
|
|
252
|
-
else root.connection.send({ type: "tool_denial", toolUseId, message: "Denied by user" });
|
|
258
|
+
root.connection.send(frame);
|
|
253
259
|
}
|
|
254
260
|
const needsYouCount = inbox.length + approvals.length;
|
|
255
261
|
const runningCount = liveRoots.length + restLive.length;
|
|
@@ -360,7 +366,7 @@ export function ActivityPage() {
|
|
|
360
366
|
<IntentCard
|
|
361
367
|
intent={intent}
|
|
362
368
|
when={formatRelativeTime(intent.createdAt)}
|
|
363
|
-
keyHint={
|
|
369
|
+
keyHint={printKeys}
|
|
364
370
|
onOpen={() => {
|
|
365
371
|
void acknowledgeIntent(intent.id);
|
|
366
372
|
openIntent(intent);
|
|
@@ -370,7 +376,7 @@ export function ActivityPage() {
|
|
|
370
376
|
</div>
|
|
371
377
|
))}
|
|
372
378
|
</div>
|
|
373
|
-
{
|
|
379
|
+
{printKeys && (
|
|
374
380
|
<p className="mt-1.5 font-[family-name:var(--font-mono)] text-[10px] text-muted-foreground/70 laptop:hidden">
|
|
375
381
|
j / k move · d dismiss · ⏎ open{approvals.length > 0 ? " · a allow" : ""}
|
|
376
382
|
</p>
|
|
@@ -422,8 +428,9 @@ export function ActivityPage() {
|
|
|
422
428
|
</div>
|
|
423
429
|
{/* The column's footer (D4): the printed keys, pinned under the list
|
|
424
430
|
from `laptop:`. Only while the keys can fire — they act inside the
|
|
425
|
-
inbox list — so a lens without cards advertises nothing
|
|
426
|
-
|
|
431
|
+
inbox list — so a lens without cards advertises nothing, and
|
|
432
|
+
only with a fine pointer (#100). */}
|
|
433
|
+
{printKeys && lens === "needs-you" && inbox.length > 0 && (
|
|
427
434
|
<p className="sticky bottom-0 mt-auto hidden border-t border-border-subtle bg-background px-4 py-2 font-[family-name:var(--font-mono)] text-[10px] text-muted-foreground/70 laptop:block">
|
|
428
435
|
j / k move · d dismiss
|
|
429
436
|
</p>
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { ActionCard, Button } from "@schlessera/brain-ui-kit";
|
|
2
2
|
import type { KeyboardEvent } from "react";
|
|
3
|
-
import type {
|
|
3
|
+
import type { ClientMessage } from "@schlessera/brain-ui-sdk/protocol";
|
|
4
|
+
import { offersAlwaysAllow, type ToolCall } from "../../stores/chat-store.js";
|
|
4
5
|
import { focusAfterDecision, singleKey } from "../../lib/single-key.js";
|
|
5
|
-
import { getToolLabel, getToolSummary } from "../chat/tool-views.js";
|
|
6
|
+
import { effectOf, getToolLabel, getToolSummary } from "../chat/tool-views.js";
|
|
6
7
|
import { KeyCap } from "../layout/key-cap.js";
|
|
8
|
+
import { useFinePointer } from "../../hooks/use-fine-pointer.js";
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* A pending tool approval as an Actions-pane card (D37): the transcript copy
|
|
@@ -12,7 +14,9 @@ import { KeyCap } from "../layout/key-cap.js";
|
|
|
12
14
|
* border that says "a run is stopped on this"; the buttons are the same
|
|
13
15
|
* three the transcript draws, with the same keys — `a` / `d` while the card
|
|
14
16
|
* holds focus, printed on the buttons, and "Always allow" deliberately
|
|
15
|
-
* without one, carrying its `write_policy` effect chip instead.
|
|
17
|
+
* without one, carrying its `write_policy` effect chip instead. The hint
|
|
18
|
+
* is printed only while a fine pointer is present (#86); the letters follow
|
|
19
|
+
* `keys` alone, so a paired keyboard works even if the pointer query stays coarse.
|
|
16
20
|
*
|
|
17
21
|
* The buttons follow the kit `ApprovalCard`'s rule (seventh drop, ruling 10):
|
|
18
22
|
* Allow takes the remaining width, Deny is content-sized with a 96 x 44 floor
|
|
@@ -32,9 +36,41 @@ export interface ApprovalCardProps {
|
|
|
32
36
|
|
|
33
37
|
export const APPROVAL_CARD = "[data-approval-card]";
|
|
34
38
|
|
|
39
|
+
/**
|
|
40
|
+
* What a decision on an Actions card prints and sends. The receipt follows
|
|
41
|
+
* what the card was allowed to offer, not the click (#147): "Always allowed"
|
|
42
|
+
* claims a policy write, and the host refuses one the card did not offer —
|
|
43
|
+
* so an `always` the card could not have offered is neither printed nor
|
|
44
|
+
* sent. A card no longer in the list (`tool` undefined) offered nothing.
|
|
45
|
+
*/
|
|
46
|
+
export function approvalOutcome(
|
|
47
|
+
tool: ToolCall | undefined,
|
|
48
|
+
toolUseId: string,
|
|
49
|
+
approved: boolean,
|
|
50
|
+
asked?: boolean
|
|
51
|
+
): {
|
|
52
|
+
receipt: { text: string; target: string; effect: string };
|
|
53
|
+
frame: Extract<ClientMessage, { type: "tool_approval" | "tool_denial" }>;
|
|
54
|
+
} {
|
|
55
|
+
const always = approved && asked === true && tool !== undefined && offersAlwaysAllow(tool);
|
|
56
|
+
return {
|
|
57
|
+
receipt: {
|
|
58
|
+
text: approved ? (always ? "Always allowed" : "Allowed") : "Denied",
|
|
59
|
+
target: tool ? tool.name : toolUseId,
|
|
60
|
+
effect: always ? "write_policy" : approved ? "tool_approval" : "tool_denial",
|
|
61
|
+
},
|
|
62
|
+
// Every decision on this surface is made on the card (#113).
|
|
63
|
+
frame: approved
|
|
64
|
+
? { type: "tool_approval", toolUseId, ...(always ? { always: true } : {}), channel: "card" }
|
|
65
|
+
: { type: "tool_denial", toolUseId, message: "Denied by user", channel: "card" },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
35
69
|
export function ApprovalCard(p: ApprovalCardProps) {
|
|
36
70
|
const label = getToolLabel(p.tool.name);
|
|
37
71
|
const summary = getToolSummary(p.tool);
|
|
72
|
+
const finePointer = useFinePointer();
|
|
73
|
+
const printKeys = p.keys && finePointer;
|
|
38
74
|
function decide(card: HTMLElement | null, approved: boolean, always?: boolean) {
|
|
39
75
|
if (card) focusAfterDecision(card, APPROVAL_CARD, "[data-needs-you-heading]");
|
|
40
76
|
p.onDecide(approved, always);
|
|
@@ -53,6 +89,7 @@ export function ApprovalCard(p: ApprovalCardProps) {
|
|
|
53
89
|
title={summary ? `${label} · ${summary}` : label}
|
|
54
90
|
rightMeta="blocks a run"
|
|
55
91
|
rightMetaTone="red"
|
|
92
|
+
{...(effectOf(p.tool) ? { body: effectOf(p.tool) } : {})}
|
|
56
93
|
footMeta={p.origin}
|
|
57
94
|
footDot="amber"
|
|
58
95
|
footPulse
|
|
@@ -60,11 +97,11 @@ export function ApprovalCard(p: ApprovalCardProps) {
|
|
|
60
97
|
>
|
|
61
98
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
62
99
|
<Button label="Allow" icon="confirm" tone="primary" size="sm" center block={false} style={ALLOW_MOUNT} onClick={() => decide(cardOf(), true)} />
|
|
63
|
-
{p.tool
|
|
100
|
+
{offersAlwaysAllow(p.tool) && (
|
|
64
101
|
<Button label="Always allow" effect="write_policy" tone="ghost" size="sm" block={false} onClick={() => decide(cardOf(), true, true)} />
|
|
65
102
|
)}
|
|
66
103
|
<Button label="Deny" icon="deny" tone="danger" size="sm" center block={false} style={DENY_MOUNT} onClick={() => decide(cardOf(), false)} />
|
|
67
|
-
{
|
|
104
|
+
{printKeys && (
|
|
68
105
|
<span className="ml-auto flex items-center gap-2 text-[10px] text-muted-foreground" aria-hidden="true">
|
|
69
106
|
<span>allow <KeyCap>a</KeyCap></span>
|
|
70
107
|
<span>deny <KeyCap>d</KeyCap></span>
|
|
@@ -117,12 +117,64 @@ export function eventTypeLabel(eventType: string): string {
|
|
|
117
117
|
return eventType.replace(/_/g, " ");
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
// Maps, not object literals: a stored payload is untrusted, and indexing a
|
|
121
|
+
// literal with "toString" or "__proto__" finds Object.prototype, not a miss.
|
|
122
|
+
const DECISION_TEXT = new Map([
|
|
123
|
+
["allow", "Allowed"],
|
|
124
|
+
["always_allow", "Always allowed"],
|
|
125
|
+
["deny", "Denied"],
|
|
126
|
+
]);
|
|
127
|
+
const CHANNEL_TEXT = new Map([
|
|
128
|
+
["card", "on the card"],
|
|
129
|
+
["voice", "by voice"],
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* An `approval_decision` event as a sentence: what was decided and, when the
|
|
134
|
+
* client said, the channel it was decided on (#113) — "who said yes and how"
|
|
135
|
+
* is the question the record exists to answer. A decision recorded without a
|
|
136
|
+
* channel reads as the decision alone rather than guessing one. Anything
|
|
137
|
+
* this does not recognise returns null and falls back to the raw block.
|
|
138
|
+
*/
|
|
139
|
+
export function readApprovalDecision(
|
|
140
|
+
payload: unknown
|
|
141
|
+
): { text: string; facts: string | null } | null {
|
|
142
|
+
if (!payload || typeof payload !== "object") return null;
|
|
143
|
+
const p = payload as Record<string, unknown>;
|
|
144
|
+
const decision = typeof p.decision === "string" ? DECISION_TEXT.get(p.decision) : undefined;
|
|
145
|
+
if (!decision) return null;
|
|
146
|
+
const channel = typeof p.channel === "string" ? CHANNEL_TEXT.get(p.channel) : undefined;
|
|
147
|
+
if (p.channel !== undefined && !channel) return null;
|
|
148
|
+
const facts = [
|
|
149
|
+
typeof p.principalId === "string" ? `principal ${p.principalId}` : null,
|
|
150
|
+
p.requestKind === "command" ? "command confirmation" : null,
|
|
151
|
+
].filter(Boolean);
|
|
152
|
+
return {
|
|
153
|
+
text: channel ? `${decision} ${channel}` : decision,
|
|
154
|
+
facts: facts.length > 0 ? facts.join(" · ") : null,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
120
158
|
/**
|
|
121
159
|
* ONE labelled block per recorded event — the shared renderer behind the tool
|
|
122
160
|
* payload expander and the run detail's narrative stream, so every event type
|
|
123
161
|
* lands somewhere visible instead of only the two the expander knows.
|
|
124
162
|
*/
|
|
125
163
|
export function SpanEventBlock({ event }: { event: ActivitySpanEvent }) {
|
|
164
|
+
const approval = event.eventType === "approval_decision" ? readApprovalDecision(event.payload) : null;
|
|
165
|
+
if (approval) {
|
|
166
|
+
return (
|
|
167
|
+
<div>
|
|
168
|
+
<div className="mb-0.5 text-[10px] uppercase text-muted-foreground/60">Approval</div>
|
|
169
|
+
<p data-testid="approval-decision" className="text-[11px] text-foreground">
|
|
170
|
+
{approval.text}
|
|
171
|
+
</p>
|
|
172
|
+
{approval.facts && (
|
|
173
|
+
<p className="text-[10px] text-muted-foreground/60">{approval.facts}</p>
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
126
178
|
const text =
|
|
127
179
|
typeof event.payload === "string" ? event.payload : JSON.stringify(event.payload, null, 2);
|
|
128
180
|
return (
|
|
@@ -142,10 +142,11 @@ export function ChatPage() {
|
|
|
142
142
|
const handleToolApproval = useCallback(
|
|
143
143
|
(toolUseId: string, approved: boolean, always?: boolean) => {
|
|
144
144
|
root.stores.chat.getState().resolveToolApproval(sessionId, toolUseId, approved);
|
|
145
|
+
// The transcript's approval card is the only caller: channel "card" (#113).
|
|
145
146
|
if (approved) {
|
|
146
|
-
send({ type: "tool_approval", toolUseId, ...(always ? { always: true } : {}) });
|
|
147
|
+
send({ type: "tool_approval", toolUseId, ...(always ? { always: true } : {}), channel: "card" });
|
|
147
148
|
} else {
|
|
148
|
-
send({ type: "tool_denial", toolUseId, message: "Denied by user" });
|
|
149
|
+
send({ type: "tool_denial", toolUseId, message: "Denied by user", channel: "card" });
|
|
149
150
|
}
|
|
150
151
|
},
|
|
151
152
|
[send, sessionId, root]
|
|
@@ -11,10 +11,10 @@ import {
|
|
|
11
11
|
FileText,
|
|
12
12
|
} from "lucide-react";
|
|
13
13
|
import { type ToolSemantics } from "@schlessera/brain-ui-sdk/client";
|
|
14
|
-
import { useChatStore, type ToolCall } from "../../stores/chat-store.js";
|
|
14
|
+
import { offersAlwaysAllow, useChatStore, type ToolCall } from "../../stores/chat-store.js";
|
|
15
15
|
import { cn } from "../../lib/utils.js";
|
|
16
16
|
import { motion, AnimatePresence } from "framer-motion";
|
|
17
|
-
import { getToolLabel, getTouchedFile, formatDuration, formatTokenCount } from "./tool-views.js";
|
|
17
|
+
import { effectOf, getToolLabel, getTouchedFile, formatDuration, formatTokenCount } from "./tool-views.js";
|
|
18
18
|
import { registerBuiltinRenderers, GENERIC_RENDERER } from "./renderers/index.js";
|
|
19
19
|
import { riskHints } from "./risk-hints.js";
|
|
20
20
|
import { useShallow } from "zustand/react/shallow";
|
|
@@ -23,6 +23,7 @@ import { KeyCap } from "../layout/key-cap.js";
|
|
|
23
23
|
import { useActivityStore, spanForTool, childSpans } from "../../stores/activity-store.js";
|
|
24
24
|
import { useUIStore } from "../../stores/ui-store.js";
|
|
25
25
|
import { useNow } from "../../hooks/use-now.js";
|
|
26
|
+
import { useFinePointer } from "../../hooks/use-fine-pointer.js";
|
|
26
27
|
import { SpanStatusDot } from "../activity/span-bits.js";
|
|
27
28
|
|
|
28
29
|
// Sessions that predate backend stamping (old servers, cleared stores) scope
|
|
@@ -189,6 +190,11 @@ function ToolCallEntry({
|
|
|
189
190
|
: renderer.label ?? getToolLabel(toolCall.name);
|
|
190
191
|
const isPending = toolCall.status === "pending_approval";
|
|
191
192
|
const keys = useUIStore((s) => s.singleKeyShortcuts);
|
|
193
|
+
// The caps are printed only while a fine pointer is present (#86); the letters
|
|
194
|
+
// themselves follow the Settings switch alone, so a paired keyboard works
|
|
195
|
+
// even if the pointer query stays coarse.
|
|
196
|
+
const finePointer = useFinePointer();
|
|
197
|
+
const printKeys = keys && finePointer;
|
|
192
198
|
/**
|
|
193
199
|
* A decision hands focus on before the card goes (D36): the next pending
|
|
194
200
|
* approval in the transcript, else the composer — the thing the reader
|
|
@@ -306,6 +312,11 @@ function ToolCallEntry({
|
|
|
306
312
|
{/* Tool input */}
|
|
307
313
|
{Input && <Input tool={toolCall} />}
|
|
308
314
|
|
|
315
|
+
{/* What a confirmed command will do, in words (#112) */}
|
|
316
|
+
{isPending && effectOf(toolCall) && (
|
|
317
|
+
<p className="text-[11px] text-muted-foreground">{effectOf(toolCall)}</p>
|
|
318
|
+
)}
|
|
319
|
+
|
|
309
320
|
{/* Risk hints — advisory only, never blocks approval */}
|
|
310
321
|
{isPending && (
|
|
311
322
|
<RiskHints toolCall={toolCall} semantics={renderer.semantics} />
|
|
@@ -313,7 +324,8 @@ function ToolCallEntry({
|
|
|
313
324
|
|
|
314
325
|
{/* Approval buttons. "Always allow" only for grantable tool
|
|
315
326
|
requests — a destructive-command confirmation (kind
|
|
316
|
-
"command") stays per-use
|
|
327
|
+
"command") stays per-use, and a grant the host said it
|
|
328
|
+
will not keep (#147) is not offered. */}
|
|
317
329
|
{isPending && (
|
|
318
330
|
// The card is the focus scope for `a` / `d` (D36): the keys
|
|
319
331
|
// act only while it, or a button inside it, holds focus, and
|
|
@@ -332,9 +344,9 @@ function ToolCallEntry({
|
|
|
332
344
|
>
|
|
333
345
|
<Check className="h-3 w-3" />
|
|
334
346
|
Allow
|
|
335
|
-
{
|
|
347
|
+
{printKeys && <KeyCap>a</KeyCap>}
|
|
336
348
|
</button>
|
|
337
|
-
{toolCall
|
|
349
|
+
{offersAlwaysAllow(toolCall) && (
|
|
338
350
|
<button
|
|
339
351
|
onClick={(e) => decide(e.currentTarget.closest("[data-approval-card]"), true, true)}
|
|
340
352
|
title={`Allow ${toolCall.name} without asking from now on (revocable in Settings → Models)`}
|
|
@@ -350,7 +362,7 @@ function ToolCallEntry({
|
|
|
350
362
|
>
|
|
351
363
|
<X className="h-3 w-3" />
|
|
352
364
|
Deny
|
|
353
|
-
{
|
|
365
|
+
{printKeys && <KeyCap>d</KeyCap>}
|
|
354
366
|
</button>
|
|
355
367
|
</div>
|
|
356
368
|
)}
|
|
@@ -735,3 +735,12 @@ function ReadOutputStub({ output }: { output: string }) {
|
|
|
735
735
|
</div>
|
|
736
736
|
);
|
|
737
737
|
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* What a confirmed command will do, in words, when the request said (#112).
|
|
741
|
+
* Only a "command" confirmation's description is an effect; a tool grant's
|
|
742
|
+
* is the runtime's own text, so it is not drawn.
|
|
743
|
+
*/
|
|
744
|
+
export function effectOf(tool: ToolCall): string | undefined {
|
|
745
|
+
return tool.approvalKind === "command" ? tool.approvalDescription : undefined;
|
|
746
|
+
}
|
|
@@ -155,7 +155,7 @@ export function FilePanel({ open, onClose }: { open: boolean; onClose: () => voi
|
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
/**
|
|
158
|
-
* D3 (the fifth drop
|
|
158
|
+
* D3 (the fifth design drop): from `laptop:` up Files is
|
|
159
159
|
* not a drawer but a layer of panes over the content area, beside the rail —
|
|
160
160
|
* a 300px tree, the reading pane at a 720px measure, and from `wide:` a
|
|
161
161
|
* 320px evidence rail. No backdrop: the rail stays reachable, and the layer
|
|
@@ -5,6 +5,7 @@ import { useActivityStore } from "../../stores/activity-store.js";
|
|
|
5
5
|
import { useChatStore, pendingApprovals } from "../../stores/chat-store.js";
|
|
6
6
|
import { useUIStore } from "../../stores/ui-store.js";
|
|
7
7
|
import { useMediaQuery } from "../../hooks/use-media-query.js";
|
|
8
|
+
import { useFinePointer } from "../../hooks/use-fine-pointer.js";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* The desktop navigation, on the kit's `SideRail`. The kit owns the rail —
|
|
@@ -24,7 +25,11 @@ import { useMediaQuery } from "../../hooks/use-media-query.js";
|
|
|
24
25
|
* Widths follow D22's ladder: the phone bar to 479px, this rail collapsed
|
|
25
26
|
* to 60px from 480 (`tablet:`), expanded to 208px from 900 (`laptop:`). The
|
|
26
27
|
* rail appears at the breakpoint the phone bar disappears at, so the two
|
|
27
|
-
* never show together.
|
|
28
|
+
* never show together. Width is not a keyboard, though: a tablet in
|
|
29
|
+
* landscape crosses `tablet:` with nothing to press ⌘ on, so the caps are
|
|
30
|
+
* printed only while a fine pointer is present (#86). The bindings below
|
|
31
|
+
* stay registered either way — a paired keyboard fires them even if the
|
|
32
|
+
* query stays coarse.
|
|
28
33
|
*
|
|
29
34
|
* The connection status takes the wordmark's line: teal while live, amber
|
|
30
35
|
* while reconnecting, red when the socket is gone. No spend meter — the app
|
|
@@ -42,19 +47,21 @@ export function SideRail() {
|
|
|
42
47
|
const approvalCount = useChatStore((s) => pendingApprovals(s).length);
|
|
43
48
|
const needsYou = inboxCount + approvalCount;
|
|
44
49
|
const expanded = useMediaQuery("(min-width: 900px)");
|
|
50
|
+
const finePointer = useFinePointer();
|
|
51
|
+
const cap = (key: string) => (finePointer ? key : undefined);
|
|
45
52
|
|
|
46
53
|
const items: RailItem[] = [
|
|
47
|
-
{ icon: "brain", label: "Chat", shortcut: "⌘1", onClick: () => setActiveView("chat") },
|
|
54
|
+
{ icon: "brain", label: "Chat", shortcut: cap("⌘1"), onClick: () => setActiveView("chat") },
|
|
48
55
|
{
|
|
49
56
|
icon: "resolved",
|
|
50
57
|
label: "Actions",
|
|
51
|
-
shortcut: "⌘2",
|
|
58
|
+
shortcut: cap("⌘2"),
|
|
52
59
|
badge: needsYou > 0 ? (needsYou > 9 ? "9+" : String(needsYou)) : undefined,
|
|
53
60
|
onClick: () => setActiveView("activity"),
|
|
54
61
|
},
|
|
55
|
-
{ icon: "files", label: "Files", shortcut: "⌘3", onClick: toggleFilePanel },
|
|
56
|
-
{ icon: "graph", label: "Graph", shortcut: "⌘4", onClick: () => setActiveView("graph") },
|
|
57
|
-
{ icon: "settings", label: "Settings", shortcut: "⌘5", onClick: toggleSettingsPanel },
|
|
62
|
+
{ icon: "files", label: "Files", shortcut: cap("⌘3"), onClick: toggleFilePanel },
|
|
63
|
+
{ icon: "graph", label: "Graph", shortcut: cap("⌘4"), onClick: () => setActiveView("graph") },
|
|
64
|
+
{ icon: "settings", label: "Settings", shortcut: cap("⌘5"), onClick: toggleSettingsPanel },
|
|
58
65
|
];
|
|
59
66
|
|
|
60
67
|
// A panel over the view is "here" while it is open; otherwise the view is.
|
|
@@ -16,12 +16,26 @@ const SLIDE_OUT_MS = 300;
|
|
|
16
16
|
*/
|
|
17
17
|
export type SlidePanelMode = "drawer" | "pane";
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* What dismisses the panel besides its own close control, in `<dialog
|
|
21
|
+
* closedby>`'s vocabulary so a later move to the element is a rename-free
|
|
22
|
+
* step. `any` (the default) is light dismiss: a click on the backdrop or
|
|
23
|
+
* Escape — right for a preview you glance at and leave. `closerequest`
|
|
24
|
+
* keeps the backdrop inert but honours Escape — for a panel holding
|
|
25
|
+
* something a stray click must not discard, like a finished log. `none`
|
|
26
|
+
* ignores both — for a panel running a job, where a reflexive Escape would
|
|
27
|
+
* cancel it. The header's X closes the drawer under every value: a rendered
|
|
28
|
+
* close control is never inert.
|
|
29
|
+
*/
|
|
30
|
+
export type SlidePanelClosedBy = "any" | "closerequest" | "none";
|
|
31
|
+
|
|
19
32
|
export function SlidePanel({
|
|
20
33
|
open,
|
|
21
34
|
onClose,
|
|
22
35
|
title,
|
|
23
36
|
wide,
|
|
24
37
|
mode = "drawer",
|
|
38
|
+
closedBy = "any",
|
|
25
39
|
children,
|
|
26
40
|
}: {
|
|
27
41
|
open: boolean;
|
|
@@ -29,16 +43,17 @@ export function SlidePanel({
|
|
|
29
43
|
title: string;
|
|
30
44
|
wide?: boolean;
|
|
31
45
|
mode?: SlidePanelMode;
|
|
46
|
+
closedBy?: SlidePanelClosedBy;
|
|
32
47
|
children: ReactNode;
|
|
33
48
|
}) {
|
|
34
49
|
useEffect(() => {
|
|
35
|
-
if (!open) return;
|
|
50
|
+
if (!open || closedBy === "none") return;
|
|
36
51
|
const handler = (e: KeyboardEvent) => {
|
|
37
52
|
if (e.key === "Escape") onClose();
|
|
38
53
|
};
|
|
39
54
|
document.addEventListener("keydown", handler);
|
|
40
55
|
return () => document.removeEventListener("keydown", handler);
|
|
41
|
-
}, [open, onClose]);
|
|
56
|
+
}, [open, onClose, closedBy]);
|
|
42
57
|
|
|
43
58
|
/**
|
|
44
59
|
* A closed panel renders nothing. The shell (the sliding frame and its
|
|
@@ -62,11 +77,11 @@ export function SlidePanel({
|
|
|
62
77
|
|
|
63
78
|
return (
|
|
64
79
|
<>
|
|
65
|
-
{/* Backdrop */}
|
|
80
|
+
{/* Backdrop: a click on it dismisses only a light-dismiss drawer. */}
|
|
66
81
|
{open && (
|
|
67
82
|
<div
|
|
68
83
|
className="fixed inset-0 z-40 bg-black/40 transition-opacity md:bg-black/20"
|
|
69
|
-
onClick={onClose}
|
|
84
|
+
onClick={closedBy === "any" ? onClose : undefined}
|
|
70
85
|
/>
|
|
71
86
|
)}
|
|
72
87
|
|
|
@@ -89,6 +104,7 @@ export function SlidePanel({
|
|
|
89
104
|
</h2>
|
|
90
105
|
<button
|
|
91
106
|
onClick={onClose}
|
|
107
|
+
aria-label={`Close ${title}`}
|
|
92
108
|
className="rounded-lg p-2.5 md:p-1.5 text-muted-foreground transition-colors hover:bg-surface-raised hover:text-foreground"
|
|
93
109
|
>
|
|
94
110
|
<X className="h-5 w-5 md:h-4 md:w-4" />
|
|
@@ -3,6 +3,7 @@ import { CheckCircle2 } from "lucide-react";
|
|
|
3
3
|
import type { ReactNode, RefObject } from "react";
|
|
4
4
|
import { useId } from "react";
|
|
5
5
|
import { Kbd } from "../layout/kbd.js";
|
|
6
|
+
import { useFinePointer } from "../../hooks/use-fine-pointer.js";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* The "Add to brain" form, rendered from props (S6). `AddPanel` is the
|
|
@@ -52,6 +53,7 @@ export function AddForm(p: AddFormProps) {
|
|
|
52
53
|
const typesId = useId();
|
|
53
54
|
const saving = p.state === "saving";
|
|
54
55
|
const canSave = p.draft.content.trim().length > 0 && !saving;
|
|
56
|
+
const finePointer = useFinePointer();
|
|
55
57
|
|
|
56
58
|
function onKeyDown(e: React.KeyboardEvent) {
|
|
57
59
|
// Cmd/Ctrl+Enter saves from anywhere in the form. The container refuses
|
|
@@ -155,8 +157,14 @@ export function AddForm(p: AddFormProps) {
|
|
|
155
157
|
</div>
|
|
156
158
|
|
|
157
159
|
<div className="flex items-center justify-between border-t border-border px-5 py-3">
|
|
160
|
+
{/* The hint is printed only while a fine pointer is present (#86); the
|
|
161
|
+
span stays so the buttons keep their side of the footer. */}
|
|
158
162
|
<span className="text-[11px] text-muted-foreground">
|
|
159
|
-
|
|
163
|
+
{finePointer && (
|
|
164
|
+
<>
|
|
165
|
+
<Kbd>Ctrl</Kbd>/<Kbd>Cmd</Kbd> + <Kbd>↵</Kbd> to save
|
|
166
|
+
</>
|
|
167
|
+
)}
|
|
160
168
|
</span>
|
|
161
169
|
<div className="flex gap-2">
|
|
162
170
|
<Button label="Cancel" tone="quiet" size="sm" block={false} onClick={p.onClose} />
|
|
@@ -6,6 +6,7 @@ import { useFileStore } from "../../stores/file-store.js";
|
|
|
6
6
|
import { useUIStore } from "../../stores/ui-store.js";
|
|
7
7
|
import type { BrainSearchHit } from "../../lib/api-client.js";
|
|
8
8
|
import { useBrainApi } from "../../root-context.js";
|
|
9
|
+
import { useFinePointer } from "../../hooks/use-fine-pointer.js";
|
|
9
10
|
import { parseSnippet } from "../../lib/search-snippet.js";
|
|
10
11
|
import { cn } from "../../lib/utils.js";
|
|
11
12
|
|
|
@@ -41,6 +42,7 @@ export function SearchPanel({
|
|
|
41
42
|
const setFilePanelOpen = useUIStore((s) => s.setFilePanelOpen);
|
|
42
43
|
|
|
43
44
|
const trimmed = query.trim();
|
|
45
|
+
const finePointer = useFinePointer();
|
|
44
46
|
const currentResults = resultQuery === trimmed ? results : [];
|
|
45
47
|
|
|
46
48
|
const cancelSearch = useCallback(() => {
|
|
@@ -214,8 +216,14 @@ export function SearchPanel({
|
|
|
214
216
|
) : trimmed.length < MIN_QUERY ? (
|
|
215
217
|
<Placeholder>
|
|
216
218
|
<span>
|
|
217
|
-
Type at least {MIN_QUERY} characters.
|
|
218
|
-
|
|
219
|
+
Type at least {MIN_QUERY} characters.
|
|
220
|
+
{/* The keys are printed only while a fine pointer is present (#86). */}
|
|
221
|
+
{finePointer && (
|
|
222
|
+
<>
|
|
223
|
+
{" "}
|
|
224
|
+
<Kbd>↑</Kbd> <Kbd>↓</Kbd> to pick, <Kbd>↵</Kbd> to open.
|
|
225
|
+
</>
|
|
226
|
+
)}
|
|
219
227
|
</span>
|
|
220
228
|
</Placeholder>
|
|
221
229
|
) : state === "done" && results.length === 0 ? (
|
|
@@ -66,6 +66,7 @@ export function StreamingPanel({
|
|
|
66
66
|
reader = res.body.getReader();
|
|
67
67
|
const decoder = new TextDecoder();
|
|
68
68
|
let buffer = "";
|
|
69
|
+
let finished = false;
|
|
69
70
|
|
|
70
71
|
while (true) {
|
|
71
72
|
const { done, value } = await reader.read();
|
|
@@ -88,11 +89,20 @@ export function StreamingPanel({
|
|
|
88
89
|
setLines((l) => [...l, data.text]);
|
|
89
90
|
}
|
|
90
91
|
if (data.type === "done") {
|
|
92
|
+
finished = true;
|
|
91
93
|
setState(data.success ? "success" : "error");
|
|
92
94
|
}
|
|
93
95
|
} catch {}
|
|
94
96
|
}
|
|
95
97
|
}
|
|
98
|
+
|
|
99
|
+
// EOF without a `done` frame — a dropped connection, or a route that
|
|
100
|
+
// unwound past its last send. Running is not terminal, so staying in
|
|
101
|
+
// it would strand the panel on a Cancel for a stream that is gone.
|
|
102
|
+
if (!finished) {
|
|
103
|
+
setLines((l) => [...l, "The connection closed before the job reported a result."]);
|
|
104
|
+
setState("error");
|
|
105
|
+
}
|
|
96
106
|
} catch (err: any) {
|
|
97
107
|
if (disposed || controller.signal.aborted) return;
|
|
98
108
|
if (err.name === "AbortError") {
|
|
@@ -121,10 +131,14 @@ export function StreamingPanel({
|
|
|
121
131
|
scrollRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
122
132
|
}, [lines]);
|
|
123
133
|
|
|
124
|
-
|
|
134
|
+
// Closing unmounts the panel, and the unmount aborts the stream, so a stray
|
|
135
|
+
// click on the backdrop must not close it: mid-run it would cancel the job,
|
|
136
|
+
// and afterwards it would discard a log nobody has read. Escape is refused
|
|
137
|
+
// only while the job runs; the header's X is always the way out.
|
|
138
|
+
const closedBy = state === "running" ? "none" : "closerequest";
|
|
125
139
|
|
|
126
140
|
return (
|
|
127
|
-
<SlidePanel open={open} onClose={
|
|
141
|
+
<SlidePanel open={open} onClose={onClose} title={title} closedBy={closedBy}>
|
|
128
142
|
<StreamingOutput
|
|
129
143
|
state={state}
|
|
130
144
|
lines={lines.map((line) => linkifyPaths(line))}
|
|
@@ -58,6 +58,7 @@ export function WhatsupPanel({
|
|
|
58
58
|
const decoder = new TextDecoder();
|
|
59
59
|
let buffer = "";
|
|
60
60
|
const lines: string[] = [];
|
|
61
|
+
let finished = false;
|
|
61
62
|
|
|
62
63
|
while (true) {
|
|
63
64
|
const { done, value } = await reader.read();
|
|
@@ -80,12 +81,20 @@ export function WhatsupPanel({
|
|
|
80
81
|
lines.push(data.text);
|
|
81
82
|
}
|
|
82
83
|
if (data.type === "done") {
|
|
84
|
+
finished = true;
|
|
83
85
|
setState(data.success ? "done" : "error");
|
|
84
86
|
}
|
|
85
87
|
} catch {}
|
|
86
88
|
}
|
|
87
89
|
}
|
|
88
90
|
|
|
91
|
+
// EOF without a `done` frame — a dropped connection, or a route that
|
|
92
|
+
// unwound past its last send. Loading is not terminal, so staying in
|
|
93
|
+
// it would strand the panel behind the skeleton and a Cancel.
|
|
94
|
+
if (!finished) {
|
|
95
|
+
lines.push("", "_The connection closed before the job reported a result._");
|
|
96
|
+
setState("error");
|
|
97
|
+
}
|
|
89
98
|
setContent(lines.join("\n"));
|
|
90
99
|
} catch (err: any) {
|
|
91
100
|
if (disposed || controller.signal.aborted) return;
|
|
@@ -110,8 +119,15 @@ export function WhatsupPanel({
|
|
|
110
119
|
};
|
|
111
120
|
}, [open, root]);
|
|
112
121
|
|
|
122
|
+
// Closing unmounts the panel, and the unmount aborts the briefing, so a stray
|
|
123
|
+
// click on the backdrop must not close it: while it loads the click cancels a
|
|
124
|
+
// model call, and afterwards it discards a briefing that call paid for.
|
|
125
|
+
// Escape is refused only while the briefing loads; the header's X is always
|
|
126
|
+
// the way out.
|
|
127
|
+
const closedBy = state === "loading" ? "none" : "closerequest";
|
|
128
|
+
|
|
113
129
|
return (
|
|
114
|
-
<SlidePanel open={open} onClose={onClose} title="Whatsup" wide>
|
|
130
|
+
<SlidePanel open={open} onClose={onClose} title="Whatsup" wide closedBy={closedBy}>
|
|
115
131
|
<BriefingOutput
|
|
116
132
|
state={state}
|
|
117
133
|
content={<BrainMarkdown content={content} className="whatsup-briefing brain-prose" entityTags fileLinks />}
|