@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.
Files changed (79) hide show
  1. package/dist/components/activity/activity-page.d.ts.map +1 -1
  2. package/dist/components/activity/activity-page.js +13 -9
  3. package/dist/components/activity/activity-page.js.map +1 -1
  4. package/dist/components/activity/approval-card.d.ts +19 -1
  5. package/dist/components/activity/approval-card.d.ts.map +1 -1
  6. package/dist/components/activity/approval-card.js +30 -3
  7. package/dist/components/activity/approval-card.js.map +1 -1
  8. package/dist/components/activity/span-bits.d.ts +11 -0
  9. package/dist/components/activity/span-bits.d.ts.map +1 -1
  10. package/dist/components/activity/span-bits.js +41 -0
  11. package/dist/components/activity/span-bits.js.map +1 -1
  12. package/dist/components/chat/chat-page.d.ts.map +1 -1
  13. package/dist/components/chat/chat-page.js +3 -2
  14. package/dist/components/chat/chat-page.js.map +1 -1
  15. package/dist/components/chat/tool-call-timeline.d.ts.map +1 -1
  16. package/dist/components/chat/tool-call-timeline.js +9 -3
  17. package/dist/components/chat/tool-call-timeline.js.map +1 -1
  18. package/dist/components/chat/tool-views.d.ts +6 -0
  19. package/dist/components/chat/tool-views.d.ts.map +1 -1
  20. package/dist/components/chat/tool-views.js +8 -0
  21. package/dist/components/chat/tool-views.js.map +1 -1
  22. package/dist/components/files/file-panel.js +1 -1
  23. package/dist/components/layout/side-rail.d.ts +5 -1
  24. package/dist/components/layout/side-rail.d.ts.map +1 -1
  25. package/dist/components/layout/side-rail.js +13 -6
  26. package/dist/components/layout/side-rail.js.map +1 -1
  27. package/dist/components/layout/slide-panel.d.ts +14 -1
  28. package/dist/components/layout/slide-panel.d.ts.map +1 -1
  29. package/dist/components/layout/slide-panel.js +5 -5
  30. package/dist/components/layout/slide-panel.js.map +1 -1
  31. package/dist/components/quick-actions/add-form.d.ts.map +1 -1
  32. package/dist/components/quick-actions/add-form.js +4 -2
  33. package/dist/components/quick-actions/add-form.js.map +1 -1
  34. package/dist/components/quick-actions/search-modal.d.ts.map +1 -1
  35. package/dist/components/quick-actions/search-modal.js +4 -2
  36. package/dist/components/quick-actions/search-modal.js.map +1 -1
  37. package/dist/components/quick-actions/streaming-modal.d.ts.map +1 -1
  38. package/dist/components/quick-actions/streaming-modal.js +15 -2
  39. package/dist/components/quick-actions/streaming-modal.js.map +1 -1
  40. package/dist/components/quick-actions/whatsup-modal.d.ts.map +1 -1
  41. package/dist/components/quick-actions/whatsup-modal.js +16 -1
  42. package/dist/components/quick-actions/whatsup-modal.js.map +1 -1
  43. package/dist/components/settings/settings-panel.d.ts.map +1 -1
  44. package/dist/components/settings/settings-panel.js +8 -8
  45. package/dist/components/settings/settings-panel.js.map +1 -1
  46. package/dist/hooks/use-fine-pointer.d.ts +12 -0
  47. package/dist/hooks/use-fine-pointer.d.ts.map +1 -0
  48. package/dist/hooks/use-fine-pointer.js +15 -0
  49. package/dist/hooks/use-fine-pointer.js.map +1 -0
  50. package/dist/hooks/use-media-query.d.ts +5 -3
  51. package/dist/hooks/use-media-query.d.ts.map +1 -1
  52. package/dist/hooks/use-media-query.js +11 -7
  53. package/dist/hooks/use-media-query.js.map +1 -1
  54. package/dist/hooks/websocket-handlers/chat.d.ts.map +1 -1
  55. package/dist/hooks/websocket-handlers/chat.js +1 -1
  56. package/dist/hooks/websocket-handlers/chat.js.map +1 -1
  57. package/dist/stores/chat-state.d.ts +22 -1
  58. package/dist/stores/chat-state.d.ts.map +1 -1
  59. package/dist/stores/chat-state.js +12 -1
  60. package/dist/stores/chat-state.js.map +1 -1
  61. package/package.json +3 -3
  62. package/src/components/activity/activity-page.tsx +16 -9
  63. package/src/components/activity/approval-card.tsx +42 -5
  64. package/src/components/activity/span-bits.tsx +52 -0
  65. package/src/components/chat/chat-page.tsx +3 -2
  66. package/src/components/chat/tool-call-timeline.tsx +18 -6
  67. package/src/components/chat/tool-views.tsx +9 -0
  68. package/src/components/files/file-panel.tsx +1 -1
  69. package/src/components/layout/side-rail.tsx +13 -6
  70. package/src/components/layout/slide-panel.tsx +20 -4
  71. package/src/components/quick-actions/add-form.tsx +9 -1
  72. package/src/components/quick-actions/search-modal.tsx +10 -2
  73. package/src/components/quick-actions/streaming-modal.tsx +16 -2
  74. package/src/components/quick-actions/whatsup-modal.tsx +17 -1
  75. package/src/components/settings/settings-panel.tsx +11 -8
  76. package/src/hooks/use-fine-pointer.ts +15 -0
  77. package/src/hooks/use-media-query.ts +10 -7
  78. package/src/hooks/websocket-handlers/chat.ts +2 -1
  79. 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, always?: 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
- setReceipt({ text: approved ? (always ? "Always allowed" : "Allowed") : "Denied", target: tool ? tool.name : toolUseId, effect: always ? "write_policy" : approved ? "tool_approval" : "tool_denial" });
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
- if (approved) root.connection.send({ type: "tool_approval", toolUseId, ...(always ? { always: true } : {}) });
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={keys}
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
- {keys && (
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
- {keys && lens === "needs-you" && inbox.length > 0 && (
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 { ToolCall } from "../../stores/chat-store.js";
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.approvalKind !== "command" && (
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
- {p.keys && (
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
- {keys && <KeyCap>a</KeyCap>}
347
+ {printKeys && <KeyCap>a</KeyCap>}
336
348
  </button>
337
- {toolCall.approvalKind !== "command" && (
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
- {keys && <KeyCap>d</KeyCap>}
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, `.plan/design/desktop.md`): from `laptop:` up Files is
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
- <Kbd>Ctrl</Kbd>/<Kbd>Cmd</Kbd> + <Kbd>↵</Kbd> to save
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. <Kbd>↑</Kbd> <Kbd>↓</Kbd> to
218
- pick, <Kbd>↵</Kbd> to open.
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
- const isRunning = state === "running";
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={isRunning ? () => {} : onClose} title={title}>
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 />}