@ifc-lite/viewer 1.23.0 → 1.25.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 (109) hide show
  1. package/.turbo/turbo-build.log +34 -31
  2. package/CHANGELOG.md +96 -0
  3. package/dist/assets/{basketViewActivator-Dn_bHUl2.js → basketViewActivator-CU8_toGq.js} +7 -7
  4. package/dist/assets/{bcf-B9SFl84i.js → bcf-DXGDhw56.js} +23 -23
  5. package/dist/assets/{deflate-yMpdCIqk.js → deflate-Bb1_H2Yf.js} +1 -1
  6. package/dist/assets/{exporters-D-BvrNIg.js → exporters-DZhLN0ux.js} +1861 -1658
  7. package/dist/assets/geometry-controller.worker-DQOSYqtw.js +7 -0
  8. package/dist/assets/geometry.worker-B62e03Ao.js +1 -0
  9. package/dist/assets/{geotiff-D1tvcDCb.js → geotiff-y0ZxbRJd.js} +10 -10
  10. package/dist/assets/{ids-DZLs0snJ.js → ids-DruUNtfD.js} +4 -4
  11. package/dist/assets/ifc-lite-Ch2T9pP9.js +7 -0
  12. package/dist/assets/{ifc-lite_bg-DyHX37GQ.wasm → ifc-lite_bg-D7O1WHgP.wasm} +0 -0
  13. package/dist/assets/{ifc-lite_bg-BIryVCXQ.wasm → ifc-lite_bg-iH_07wf8.wasm} +0 -0
  14. package/dist/assets/index-Bws3UAkj.css +1 -0
  15. package/dist/assets/{index-CXSBhkcJ.js → index-Dr88ZlSY.js} +64100 -47030
  16. package/dist/assets/{jpeg-DUMcZp24.js → jpeg-B3_loqFe.js} +1 -1
  17. package/dist/assets/lens-PYsLu_MA.js +1 -0
  18. package/dist/assets/{lerc-IN4uWojP.js → lerc-nkwS8ZUe.js} +1 -1
  19. package/dist/assets/{lzw-Cnw0hH-m.js → lzw-D3cW5Wpg.js} +1 -1
  20. package/dist/assets/{native-bridge-BVf2uzoH.js → native-bridge-BcYJooq8.js} +2 -2
  21. package/dist/assets/{packbits-BskJCwk0.js → packbits-DDN4xzB5.js} +1 -1
  22. package/dist/assets/{parser.worker-BdtkkaGf.js → parser.worker-BW1IMUed.js} +3 -3
  23. package/dist/assets/raw-CoIXstQ-.js +1 -0
  24. package/dist/assets/{sandbox-VLI_y7cl.js → sandbox-DETNEyQb.js} +498 -470
  25. package/dist/assets/{server-client-BLcKaWQB.js → server-client-CmzJOeS7.js} +1 -1
  26. package/dist/assets/{wasm-bridge-BAfZh7YT.js → wasm-bridge-CT7mK9W0.js} +1 -1
  27. package/dist/assets/{webimage-Db2xzze3.js → webimage-CBjgg4up.js} +1 -1
  28. package/dist/assets/{workerHelpers--sAYm9yN.js → workerHelpers-IEQDo8r3.js} +1 -1
  29. package/dist/assets/{zstd-BDToOQyD.js → zstd-C8oQ6qdS.js} +1 -1
  30. package/dist/index.html +8 -8
  31. package/package.json +11 -9
  32. package/src/App.tsx +5 -2
  33. package/src/components/extensions/AuditLogPanel.tsx +259 -0
  34. package/src/components/extensions/BundlePreview.tsx +102 -0
  35. package/src/components/extensions/CapabilityReview.tsx +333 -0
  36. package/src/components/extensions/ExtensionDockHost.tsx +192 -0
  37. package/src/components/extensions/ExtensionToolbarSlot.tsx +106 -0
  38. package/src/components/extensions/ExtensionsPanel.tsx +481 -0
  39. package/src/components/extensions/FlavorDialog.tsx +398 -0
  40. package/src/components/extensions/FlavorImportPreview.tsx +79 -0
  41. package/src/components/extensions/FlavorIndicator.tsx +81 -0
  42. package/src/components/extensions/FlavorListView.tsx +318 -0
  43. package/src/components/extensions/FlavorMergeDialog.tsx +326 -0
  44. package/src/components/extensions/HelpHint.tsx +182 -0
  45. package/src/components/extensions/IdeasPanel.tsx +344 -0
  46. package/src/components/extensions/PlanCard.tsx +227 -0
  47. package/src/components/extensions/PrivacyPanel.tsx +312 -0
  48. package/src/components/extensions/PromoteToolDialog.tsx +313 -0
  49. package/src/components/extensions/RepairQueuePanel.tsx +222 -0
  50. package/src/components/extensions/icon-registry.ts +92 -0
  51. package/src/components/extensions/toast-helpers.ts +49 -0
  52. package/src/components/extensions/widget/WidgetErrorBoundary.tsx +62 -0
  53. package/src/components/extensions/widget/WidgetRenderer.tsx +428 -0
  54. package/src/components/viewer/ChatPanel.tsx +251 -3
  55. package/src/components/viewer/CommandPalette.tsx +74 -4
  56. package/src/components/viewer/Drawing2DCanvas.tsx +178 -1
  57. package/src/components/viewer/EntityContextMenu.tsx +70 -0
  58. package/src/components/viewer/ExportDialog.tsx +9 -1
  59. package/src/components/viewer/KeyboardShortcutsDialog.tsx +21 -6
  60. package/src/components/viewer/LensPanel.tsx +50 -0
  61. package/src/components/viewer/MainToolbar.tsx +170 -87
  62. package/src/components/viewer/ScriptPanel.tsx +105 -1
  63. package/src/components/viewer/Section2DPanel.tsx +58 -2
  64. package/src/components/viewer/StatusBar.tsx +18 -0
  65. package/src/components/viewer/ViewerLayout.tsx +53 -4
  66. package/src/components/viewer/Viewport.tsx +72 -0
  67. package/src/hooks/useActionLogger.test.ts +161 -0
  68. package/src/hooks/useActionLogger.ts +141 -0
  69. package/src/hooks/useForkExtension.ts +51 -0
  70. package/src/hooks/useIfcFederation.ts +7 -1
  71. package/src/hooks/useInstalledExtensions.ts +43 -0
  72. package/src/hooks/usePrivacyDisclosure.ts +48 -0
  73. package/src/hooks/useRunExtensionTests.ts +67 -0
  74. package/src/hooks/useSlotContributions.ts +38 -0
  75. package/src/hooks/useSymbolicAnnotations.test.ts +124 -0
  76. package/src/hooks/useSymbolicAnnotations.ts +776 -0
  77. package/src/lib/desktop-product.ts +7 -1
  78. package/src/lib/lens/adapter.ts +14 -0
  79. package/src/lib/llm/prompt-cache.ts +77 -0
  80. package/src/lib/llm/stream-client.ts +20 -2
  81. package/src/lib/llm/stream-direct.ts +11 -1
  82. package/src/lib/llm/system-prompt.ts +42 -0
  83. package/src/lib/safe-mode.ts +30 -0
  84. package/src/sdk/ExtensionHostProvider.tsx +103 -0
  85. package/src/services/extensions/flavor-service.ts +183 -0
  86. package/src/services/extensions/host-commands.ts +112 -0
  87. package/src/services/extensions/host-installer.ts +289 -0
  88. package/src/services/extensions/host.ts +514 -0
  89. package/src/services/extensions/idb-flavor-storage.test.ts +140 -0
  90. package/src/services/extensions/idb-flavor-storage.ts +241 -0
  91. package/src/services/extensions/idb-log-storage.test.ts +110 -0
  92. package/src/services/extensions/idb-log-storage.ts +171 -0
  93. package/src/services/extensions/idb-storage.ts +228 -0
  94. package/src/services/extensions/runtime-errors.ts +26 -0
  95. package/src/services/extensions/sandbox-factory.ts +217 -0
  96. package/src/store/constants.ts +48 -6
  97. package/src/store/index.ts +6 -1
  98. package/src/store/slices/drawing2DSlice.ts +8 -0
  99. package/src/store/slices/extensionsSlice.ts +90 -0
  100. package/src/store/slices/lensSlice.ts +28 -0
  101. package/src/store/slices/visibilitySlice.test.ts +6 -0
  102. package/src/store/slices/visibilitySlice.ts +17 -8
  103. package/src/store/types.ts +2 -0
  104. package/dist/assets/geometry-controller.worker-Cm5pvyR6.js +0 -7
  105. package/dist/assets/geometry.worker-ClNvXIrj.js +0 -1
  106. package/dist/assets/ifc-lite-BDg0iIbj.js +0 -7
  107. package/dist/assets/index-DS_xJQfP.css +0 -1
  108. package/dist/assets/lens-CpjUdqpw.js +0 -1
  109. package/dist/assets/raw-DzTtEZIY.js +0 -1
@@ -0,0 +1,182 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * `HelpHint` — small info-icon button that pops a short explanation.
7
+ *
8
+ * Renders the popover into `document.body` via a portal so it escapes
9
+ * the parent panel's overflow + width boundaries (the resizable dock
10
+ * panel can be as narrow as ~250px; a 288px inline popover would
11
+ * clip). The popover position is computed from the trigger's
12
+ * bounding rect on each open + window resize.
13
+ *
14
+ * Closes on outside click and Escape. Open state is a controlled
15
+ * `useState`, not a native `<details>`, because positioning the
16
+ * portal needs access to the trigger ref and open flag.
17
+ */
18
+
19
+ import {
20
+ useCallback,
21
+ useEffect,
22
+ useLayoutEffect,
23
+ useRef,
24
+ useState,
25
+ type ReactNode,
26
+ } from 'react';
27
+ import { createPortal } from 'react-dom';
28
+ import { HelpCircle } from 'lucide-react';
29
+ import { cn } from '@/lib/utils';
30
+
31
+ interface HelpHintProps {
32
+ /** Accessible label describing what the hint is for. */
33
+ label: string;
34
+ /** Hint body content. */
35
+ children: ReactNode;
36
+ /**
37
+ * Preferred horizontal anchor. `bottom-end` aligns the right edge
38
+ * of the popover with the right edge of the trigger; `bottom-start`
39
+ * mirrors. Both clamp inside the viewport regardless.
40
+ */
41
+ side?: 'bottom-start' | 'bottom-end';
42
+ /**
43
+ * Optional "Learn more" link rendered at the bottom of the popover.
44
+ * Useful for pointing at the full doc page in
45
+ * `docs/guide/extensions.md` or the authoring guide.
46
+ */
47
+ docLink?: { href: string; label?: string };
48
+ }
49
+
50
+ interface PopoverPosition {
51
+ top: number;
52
+ left: number;
53
+ width: number;
54
+ }
55
+
56
+ const VIEWPORT_PADDING = 8;
57
+ const POPOVER_OFFSET = 6;
58
+
59
+ export function HelpHint({
60
+ label,
61
+ children,
62
+ side = 'bottom-end',
63
+ docLink,
64
+ }: HelpHintProps) {
65
+ const triggerRef = useRef<HTMLButtonElement>(null);
66
+ const popoverRef = useRef<HTMLDivElement>(null);
67
+ const [open, setOpen] = useState(false);
68
+ const [position, setPosition] = useState<PopoverPosition | null>(null);
69
+
70
+ const computePosition = useCallback(() => {
71
+ if (!triggerRef.current) return;
72
+ const rect = triggerRef.current.getBoundingClientRect();
73
+ const vw = window.innerWidth;
74
+ // Width target — w-72 = 288px (Tailwind v3 default). Cap at the
75
+ // viewport width minus padding so we never overflow on narrow
76
+ // mobile / dock-panel widths.
77
+ const desiredWidth = Math.min(288, vw - VIEWPORT_PADDING * 2);
78
+ let left: number;
79
+ if (side === 'bottom-end') {
80
+ left = rect.right - desiredWidth;
81
+ } else {
82
+ left = rect.left;
83
+ }
84
+ // Clamp to viewport.
85
+ left = Math.max(VIEWPORT_PADDING, Math.min(left, vw - desiredWidth - VIEWPORT_PADDING));
86
+ const top = rect.bottom + POPOVER_OFFSET;
87
+ setPosition({ top, left, width: desiredWidth });
88
+ }, [side]);
89
+
90
+ useLayoutEffect(() => {
91
+ if (!open) return;
92
+ computePosition();
93
+ }, [open, computePosition]);
94
+
95
+ useEffect(() => {
96
+ if (!open) return;
97
+ const onResize = () => computePosition();
98
+ const onScroll = () => computePosition();
99
+ window.addEventListener('resize', onResize);
100
+ window.addEventListener('scroll', onScroll, true);
101
+ return () => {
102
+ window.removeEventListener('resize', onResize);
103
+ window.removeEventListener('scroll', onScroll, true);
104
+ };
105
+ }, [open, computePosition]);
106
+
107
+ useEffect(() => {
108
+ if (!open) return;
109
+ const onDocClick = (e: MouseEvent) => {
110
+ const t = e.target as Node | null;
111
+ if (!t) return;
112
+ if (triggerRef.current?.contains(t)) return;
113
+ if (popoverRef.current?.contains(t)) return;
114
+ setOpen(false);
115
+ };
116
+ const onKey = (e: KeyboardEvent) => {
117
+ if (e.key === 'Escape') {
118
+ setOpen(false);
119
+ triggerRef.current?.focus();
120
+ }
121
+ };
122
+ document.addEventListener('mousedown', onDocClick, true);
123
+ document.addEventListener('keydown', onKey);
124
+ return () => {
125
+ document.removeEventListener('mousedown', onDocClick, true);
126
+ document.removeEventListener('keydown', onKey);
127
+ };
128
+ }, [open]);
129
+
130
+ return (
131
+ <>
132
+ <button
133
+ ref={triggerRef}
134
+ type="button"
135
+ onClick={() => setOpen((v) => !v)}
136
+ aria-label={`Help: ${label}`}
137
+ aria-expanded={open}
138
+ title={`Help: ${label}`}
139
+ className={cn(
140
+ 'flex items-center justify-center h-6 w-6 rounded-full transition-colors',
141
+ open
142
+ ? 'text-foreground bg-muted'
143
+ : 'text-muted-foreground hover:bg-muted hover:text-foreground',
144
+ )}
145
+ >
146
+ <HelpCircle className="h-3.5 w-3.5" />
147
+ </button>
148
+
149
+ {open && position && typeof document !== 'undefined'
150
+ && createPortal(
151
+ <div
152
+ ref={popoverRef}
153
+ role="dialog"
154
+ aria-label={label}
155
+ style={{
156
+ position: 'fixed',
157
+ top: position.top,
158
+ left: position.left,
159
+ width: position.width,
160
+ zIndex: 70,
161
+ }}
162
+ className={cn(
163
+ 'rounded-md border bg-popover p-3 shadow-lg text-xs text-popover-foreground space-y-1.5 leading-relaxed',
164
+ )}
165
+ >
166
+ {children}
167
+ {docLink && (
168
+ <a
169
+ href={docLink.href}
170
+ target="_blank"
171
+ rel="noreferrer"
172
+ className="block pt-1 mt-1 border-t text-primary hover:underline"
173
+ >
174
+ {docLink.label ?? 'Learn more →'}
175
+ </a>
176
+ )}
177
+ </div>,
178
+ document.body,
179
+ )}
180
+ </>
181
+ );
182
+ }
@@ -0,0 +1,344 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * `IdeasPanel` — surface mined patterns as candidate one-click tools.
7
+ *
8
+ * The pattern miner runs on idle, scans the local action log, and emits
9
+ * recurring intent sequences. This panel lists them with a per-pattern
10
+ * "Author it" affordance: clicking turns the pattern into an
11
+ * `AuthoringPlan` stub via `host.acceptSuggestion()`, then shows the
12
+ * `PlanCard` so the user can prune / approve before chat routes it
13
+ * through the bundle synthesis pipeline.
14
+ *
15
+ * Privacy: everything here is local. Patterns are derived from
16
+ * content-free action metadata only.
17
+ *
18
+ * Spec: docs/architecture/ai-customization/06-self-improvement.md §3.
19
+ */
20
+
21
+ import { useEffect, useState } from 'react';
22
+ import { ArrowRight, Lightbulb, MessageSquarePlus, Sparkles, Wrench } from 'lucide-react';
23
+ import {
24
+ STARTER_IDEAS,
25
+ type AuthoringPlan,
26
+ type MinedPattern,
27
+ type MineEvent,
28
+ type StarterIdea,
29
+ } from '@ifc-lite/extensions';
30
+ import { Button } from '@/components/ui/button';
31
+ import { ScrollArea } from '@/components/ui/scroll-area';
32
+ import { useExtensionHost } from '@/sdk/ExtensionHostProvider';
33
+ import { useViewerStore } from '@/store';
34
+ import { PlanCard } from './PlanCard';
35
+ import { toast } from '@/components/ui/toast';
36
+ import { HelpHint } from './HelpHint';
37
+
38
+ interface IdeasPanelProps {
39
+ /** Optional override for the approve action. Defaults to seeding the chat panel. */
40
+ onApprovePlan?: (plan: AuthoringPlan) => void;
41
+ }
42
+
43
+ export function IdeasPanel({ onApprovePlan }: IdeasPanelProps) {
44
+ const host = useExtensionHost();
45
+ const queueChatPrompt = useViewerStore((s) => s.queueChatPrompt);
46
+ const setChatPanelVisible = useViewerStore((s) => s.setChatPanelVisible);
47
+ const setScriptPanelVisible = useViewerStore((s) => s.setScriptPanelVisible);
48
+ const setScriptEditorContent = useViewerStore((s) => s.setScriptEditorContent);
49
+ /** Deep-link from Command Palette → "Author from scratch". */
50
+ const ideasOpenEmptyPlan = useViewerStore((s) => s.ideasOpenEmptyPlan);
51
+ const setIdeasOpenEmptyPlan = useViewerStore((s) => s.setIdeasOpenEmptyPlan);
52
+ const [event, setEvent] = useState<MineEvent | undefined>(() => host.getSuggestions());
53
+ const [draft, setDraft] = useState<AuthoringPlan | undefined>();
54
+
55
+ useEffect(() => {
56
+ return host.onSuggestions((e) => setEvent(e));
57
+ }, [host]);
58
+
59
+ // Honour a deep-link request to open the empty-plan flow. The
60
+ // flag is one-shot — clear it once we've opened the draft so a
61
+ // tab switch doesn't reopen it.
62
+ useEffect(() => {
63
+ if (ideasOpenEmptyPlan) {
64
+ handleAuthorFromScratch();
65
+ setIdeasOpenEmptyPlan(false);
66
+ }
67
+ // handleAuthorFromScratch is defined below in this component and
68
+ // stable across renders (no deps), so referencing it here is safe.
69
+ // eslint-disable-next-line react-hooks/exhaustive-deps
70
+ }, [ideasOpenEmptyPlan]);
71
+
72
+ const patterns = event?.patterns ?? [];
73
+
74
+
75
+ /**
76
+ * Starter "Try it" routes directly to chat with the plan baked into
77
+ * the prompt AND opens the Script Editor so the user sees where
78
+ * generated code will land. We only seed the editor with a
79
+ * placeholder when it's empty / on the default boilerplate —
80
+ * clobbering a user's in-progress code would be hostile.
81
+ */
82
+ const handleAcceptStarter = (idea: StarterIdea) => {
83
+ const prompt = buildAuthoringPrompt(idea.plan);
84
+ queueChatPrompt(prompt);
85
+ setChatPanelVisible(true);
86
+ setScriptPanelVisible(true);
87
+ const current = useViewerStore.getState().scriptEditorContent ?? '';
88
+ const isPristine = current.trim().length === 0 || /Write your BIM script here/.test(current);
89
+ if (isPristine) {
90
+ setScriptEditorContent(
91
+ `// Authoring: ${idea.plan.summary}\n` +
92
+ `//\n` +
93
+ `// The AI is reading the plan in the chat panel.\n` +
94
+ `// Answer the follow-ups and the generated handler will land here.\n`,
95
+ );
96
+ }
97
+ toast.success(`Sent "${idea.plan.summary}" to chat — answer follow-ups to refine.`);
98
+ };
99
+
100
+ /** Power-user path: open the PlanCard with the starter pre-filled. */
101
+ const handleCustomizeStarter = (idea: StarterIdea) => {
102
+ setDraft({ ...idea.plan });
103
+ };
104
+
105
+ /** Mined-pattern accept — always uses the PlanCard since the
106
+ * generated plan is rougher and benefits from review. */
107
+ const handleAcceptMined = (pattern: MinedPattern) => {
108
+ setDraft(host.acceptSuggestion(pattern));
109
+ };
110
+
111
+ /**
112
+ * Open the Plan Card with an empty plan. The user describes the
113
+ * extension in the plan fields before approval kicks off chat —
114
+ * plan-before-code without needing a mined pattern or a starter.
115
+ */
116
+ const handleAuthorFromScratch = () => {
117
+ setDraft({
118
+ summary: '',
119
+ rationale: '',
120
+ contributions: [],
121
+ capabilities: [],
122
+ triggers: [],
123
+ widgets: [],
124
+ tests: [],
125
+ });
126
+ };
127
+
128
+ const handleApprove = (plan: AuthoringPlan) => {
129
+ setDraft(undefined);
130
+ if (onApprovePlan) {
131
+ onApprovePlan(plan);
132
+ return;
133
+ }
134
+ // Default routing: open chat AND the script editor, then seed
135
+ // chat with a prompt describing the approved plan. The script
136
+ // panel is where the generated code lands — opening both keeps
137
+ // this consistent with the "Try it" flow.
138
+ queueChatPrompt(buildAuthoringPrompt(plan));
139
+ setChatPanelVisible(true);
140
+ setScriptPanelVisible(true);
141
+ toast.success(`Routing "${plan.summary}" to the AI assistant…`);
142
+ };
143
+
144
+ if (draft) {
145
+ return (
146
+ <div className="p-3">
147
+ <PlanCard
148
+ plan={draft}
149
+ onApprove={handleApprove}
150
+ onCancel={() => setDraft(undefined)}
151
+ />
152
+ </div>
153
+ );
154
+ }
155
+
156
+ return (
157
+ <div className="flex flex-col h-full">
158
+ <div className="flex items-center justify-between border-b px-4 py-3">
159
+ <div className="flex items-center gap-2">
160
+ <Lightbulb className="h-4 w-4" />
161
+ <h2 className="text-sm font-semibold">Ideas</h2>
162
+ {event && (
163
+ <span className="text-[11px] text-muted-foreground">
164
+ {patterns.length} {patterns.length === 1 ? 'suggestion' : 'suggestions'}
165
+ {' · '}
166
+ {event.eventCount} events
167
+ </span>
168
+ )}
169
+ <HelpHint label="Ideas">
170
+ <p>
171
+ <strong>Curated starter ideas</strong> show what
172
+ one-click tools you can build today.
173
+ </p>
174
+ <p>
175
+ <strong>Recurring suggestions</strong> appear once a
176
+ workflow shows up repeatedly in your local activity log
177
+ (model loads, lens applies, exports). Thresholds relax
178
+ while the log is sparse so something appears early;
179
+ tightens as data accumulates.
180
+ </p>
181
+ <p>
182
+ Click <strong>Try it</strong> to send the idea to the AI
183
+ chat assistant — chat opens and you answer follow-ups.
184
+ Click <strong>Customize plan first…</strong> if you want
185
+ to prune capabilities or rename the command before chat
186
+ sees it.
187
+ </p>
188
+ <p>The action log is local. Nothing here leaves your device.</p>
189
+ </HelpHint>
190
+ </div>
191
+ <Button
192
+ size="sm"
193
+ variant="ghost"
194
+ onClick={() => {
195
+ const next = host.miner.fireNow();
196
+ setEvent({ ...next });
197
+ }}
198
+ aria-label="Re-mine now"
199
+ >
200
+ <Sparkles className="mr-1 h-3.5 w-3.5" />
201
+ Re-mine
202
+ </Button>
203
+ </div>
204
+
205
+ <div className="border-b px-4 py-2 text-xs text-muted-foreground">
206
+ Recurring sequences in your local activity log. Nothing here leaves your device.
207
+ </div>
208
+
209
+ <ScrollArea className="flex-1">
210
+ {/* Recurring (mined) patterns. Tightens as the log grows. */}
211
+ {patterns.length > 0 && (
212
+ <div>
213
+ <div className="px-4 pt-3 pb-1 text-[10px] uppercase tracking-wide font-semibold text-muted-foreground">
214
+ Recurring in your activity
215
+ </div>
216
+ <ul className="divide-y">
217
+ {patterns.map((pattern, i) => (
218
+ <li key={`${pattern.sequence.join('>')}:${i}`} className="px-4 py-3">
219
+ <div className="flex items-start justify-between gap-3">
220
+ <div className="flex-1 min-w-0">
221
+ <div className="flex flex-wrap items-center gap-1 text-xs">
222
+ {pattern.sequence.map((intent, idx) => (
223
+ <span key={`${intent}:${idx}`} className="flex items-center gap-1">
224
+ <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px]">
225
+ {intent}
226
+ </code>
227
+ {idx < pattern.sequence.length - 1 && (
228
+ <ArrowRight className="h-3 w-3 text-muted-foreground" />
229
+ )}
230
+ </span>
231
+ ))}
232
+ </div>
233
+ <div className="mt-1 text-[11px] text-muted-foreground">
234
+ {pattern.occurrences}× across {pattern.sessionsTouched}{' '}
235
+ {pattern.sessionsTouched === 1 ? 'session' : 'sessions'}
236
+ {' · last '}
237
+ {new Date(pattern.lastSeenAt).toLocaleString()}
238
+ {' · score '}
239
+ {pattern.score.toFixed(2)}
240
+ </div>
241
+ </div>
242
+ <Button
243
+ size="sm"
244
+ variant="outline"
245
+ onClick={() => handleAcceptMined(pattern)}
246
+ aria-label="Author one-click tool from pattern"
247
+ >
248
+ <Wrench className="mr-1 h-3.5 w-3.5" />
249
+ Author it
250
+ </Button>
251
+ </div>
252
+ </li>
253
+ ))}
254
+ </ul>
255
+ </div>
256
+ )}
257
+
258
+ {/* Always-on starter ideas. Hand-curated IFC/AEC workflows the
259
+ user can author without waiting for the miner to learn from
260
+ their activity. Tagged "Example" so they don't masquerade
261
+ as personalised suggestions. */}
262
+ <div>
263
+ <div className="px-4 pt-4 pb-1 text-[10px] uppercase tracking-wide font-semibold text-muted-foreground">
264
+ {patterns.length === 0 ? 'Try one of these to get started' : 'Examples — common AEC tools'}
265
+ </div>
266
+ <ul className="divide-y">
267
+ {STARTER_IDEAS.map((idea) => (
268
+ <li key={idea.id} className="px-4 py-3">
269
+ <div className="flex items-start justify-between gap-3">
270
+ <div className="flex-1 min-w-0">
271
+ <div className="flex items-center gap-2 text-xs font-medium">
272
+ <span aria-hidden>{idea.icon}</span>
273
+ <span className="truncate">{idea.plan.summary}</span>
274
+ <span className="text-[10px] uppercase tracking-wide bg-muted text-muted-foreground rounded px-1.5 py-0.5 font-semibold shrink-0">
275
+ {idea.category}
276
+ </span>
277
+ </div>
278
+ <p className="mt-1 text-[11px] text-muted-foreground leading-relaxed line-clamp-3">
279
+ {idea.plan.rationale}
280
+ </p>
281
+ <button
282
+ type="button"
283
+ onClick={() => handleCustomizeStarter(idea)}
284
+ className="mt-1 text-[10px] text-muted-foreground hover:text-foreground underline underline-offset-2"
285
+ >
286
+ Customize plan first…
287
+ </button>
288
+ </div>
289
+ <Button
290
+ size="sm"
291
+ onClick={() => handleAcceptStarter(idea)}
292
+ aria-label={`Send "${idea.plan.summary}" to chat`}
293
+ title="Sends a plan-based prompt to the AI chat assistant. Opens the chat panel."
294
+ className="shrink-0"
295
+ >
296
+ <MessageSquarePlus className="mr-1 h-3.5 w-3.5" />
297
+ Try it
298
+ </Button>
299
+ </div>
300
+ </li>
301
+ ))}
302
+ </ul>
303
+ </div>
304
+
305
+ {/* "Author from scratch" CTA — opens the Plan Card with an
306
+ empty plan so the user can describe whatever they want
307
+ before chat takes over. */}
308
+ <div className="px-4 py-4 border-t mt-2">
309
+ <Button
310
+ variant="ghost"
311
+ size="sm"
312
+ className="w-full justify-start"
313
+ onClick={handleAuthorFromScratch}
314
+ >
315
+ <MessageSquarePlus className="mr-2 h-3.5 w-3.5" />
316
+ Author an extension from scratch
317
+ </Button>
318
+ <p className="mt-1 text-[11px] text-muted-foreground">
319
+ Open an empty plan. Fill in what you want, then approve →
320
+ chat AI assembles the bundle.
321
+ </p>
322
+ </div>
323
+ </ScrollArea>
324
+ </div>
325
+ );
326
+ }
327
+
328
+ function buildAuthoringPrompt(plan: AuthoringPlan): string {
329
+ const contributions = plan.contributions
330
+ .map((c) => `- ${c.kind}: ${c.label}${c.slot ? ` (slot: ${c.slot})` : ''}`)
331
+ .join('\n');
332
+ const caps = plan.capabilities.map((c) => `\`${c}\``).join(', ') || '(none)';
333
+ return [
334
+ `Author an extension for me: ${plan.summary}`,
335
+ '',
336
+ `Rationale: ${plan.rationale}`,
337
+ '',
338
+ `Contributions:\n${contributions || '- (to be designed)'}`,
339
+ '',
340
+ `Capabilities requested: ${caps}`,
341
+ `Triggers: ${plan.triggers.join(', ') || '(to be designed)'}`,
342
+ plan.notes ? `\nNotes: ${plan.notes}` : '',
343
+ ].filter(Boolean).join('\n');
344
+ }