@geminilight/mindos 0.5.21 → 0.5.23

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 (60) hide show
  1. package/app/app/api/ask/route.ts +31 -9
  2. package/app/app/api/bootstrap/route.ts +1 -0
  3. package/app/app/api/monitoring/route.ts +95 -0
  4. package/app/app/globals.css +14 -0
  5. package/app/app/setup/page.tsx +3 -2
  6. package/app/components/ActivityBar.tsx +183 -0
  7. package/app/components/AskFab.tsx +39 -97
  8. package/app/components/AskModal.tsx +13 -371
  9. package/app/components/Breadcrumb.tsx +4 -4
  10. package/app/components/FileTree.tsx +21 -4
  11. package/app/components/Logo.tsx +39 -0
  12. package/app/components/Panel.tsx +152 -0
  13. package/app/components/RightAskPanel.tsx +72 -0
  14. package/app/components/SettingsModal.tsx +9 -235
  15. package/app/components/SidebarLayout.tsx +426 -12
  16. package/app/components/SyncStatusBar.tsx +74 -53
  17. package/app/components/TableOfContents.tsx +4 -2
  18. package/app/components/ask/AskContent.tsx +418 -0
  19. package/app/components/ask/MessageList.tsx +2 -2
  20. package/app/components/panels/AgentsPanel.tsx +231 -0
  21. package/app/components/panels/PanelHeader.tsx +35 -0
  22. package/app/components/panels/PluginsPanel.tsx +106 -0
  23. package/app/components/panels/SearchPanel.tsx +178 -0
  24. package/app/components/panels/SyncPopover.tsx +105 -0
  25. package/app/components/renderers/csv/TableView.tsx +4 -4
  26. package/app/components/settings/AgentsTab.tsx +240 -0
  27. package/app/components/settings/AiTab.tsx +39 -1
  28. package/app/components/settings/KnowledgeTab.tsx +116 -2
  29. package/app/components/settings/McpTab.tsx +6 -6
  30. package/app/components/settings/MonitoringTab.tsx +202 -0
  31. package/app/components/settings/SettingsContent.tsx +343 -0
  32. package/app/components/settings/types.ts +1 -1
  33. package/app/components/setup/index.tsx +2 -23
  34. package/app/hooks/useResizeDrag.ts +78 -0
  35. package/app/instrumentation.ts +7 -2
  36. package/app/lib/agent/log.ts +1 -0
  37. package/app/lib/agent/model.ts +33 -10
  38. package/app/lib/api.ts +12 -3
  39. package/app/lib/core/csv.ts +2 -1
  40. package/app/lib/core/fs-ops.ts +7 -6
  41. package/app/lib/core/index.ts +1 -1
  42. package/app/lib/core/lines.ts +7 -6
  43. package/app/lib/core/search-index.ts +174 -0
  44. package/app/lib/core/search.ts +30 -1
  45. package/app/lib/core/security.ts +6 -3
  46. package/app/lib/errors.ts +108 -0
  47. package/app/lib/format.ts +19 -0
  48. package/app/lib/fs.ts +6 -3
  49. package/app/lib/i18n-en.ts +49 -6
  50. package/app/lib/i18n-zh.ts +48 -5
  51. package/app/lib/metrics.ts +81 -0
  52. package/app/next-env.d.ts +1 -1
  53. package/app/next.config.ts +1 -1
  54. package/app/package.json +2 -2
  55. package/bin/cli.js +27 -97
  56. package/package.json +4 -2
  57. package/scripts/setup.js +2 -12
  58. package/skills/mindos/SKILL.md +226 -8
  59. package/skills/mindos-zh/SKILL.md +226 -8
  60. package/app/package-lock.json +0 -15736
@@ -14,6 +14,8 @@ import {
14
14
  } from '@/lib/agent/context';
15
15
  import { logAgentOp } from '@/lib/agent/log';
16
16
  import { readSettings } from '@/lib/settings';
17
+ import { MindOSError, apiError, ErrorCodes } from '@/lib/errors';
18
+ import { metrics } from '@/lib/metrics';
17
19
  import { assertNotProtected } from '@/lib/core';
18
20
  import type { Message as FrontendMessage } from '@/lib/types';
19
21
 
@@ -160,7 +162,7 @@ export async function POST(req: NextRequest) {
160
162
  try {
161
163
  body = await req.json();
162
164
  } catch {
163
- return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
165
+ return apiError(ErrorCodes.INVALID_REQUEST, 'Invalid JSON body', 400);
164
166
  }
165
167
 
166
168
  const { messages, currentFile, attachedFiles, uploadedFiles } = body;
@@ -176,12 +178,16 @@ export async function POST(req: NextRequest) {
176
178
  const contextStrategy = agentConfig.contextStrategy ?? 'auto';
177
179
 
178
180
  // Auto-load skill + bootstrap context for each request.
179
- // TODO (optimization): Consider caching bootstrap files with TTL to reduce per-request IO overhead.
180
- // Current behavior: 8 synchronous file reads per request. For users with large knowledge bases,
181
- // this adds ~10-50ms latency. Mitigation: Cache with 5min TTL or lazy-load on-demand.
182
- const skillPath = path.resolve(process.cwd(), 'data/skills/mindos/SKILL.md');
181
+ // 1. SKILL.md complete skill with operating rules (always loaded)
182
+ // 2. user-skill-rules.md user's personalized rules from KB root (if exists)
183
+ const isZh = serverSettings.disabledSkills?.includes('mindos') ?? false;
184
+ const skillDirName = isZh ? 'mindos-zh' : 'mindos';
185
+ const skillPath = path.resolve(process.cwd(), `data/skills/${skillDirName}/SKILL.md`);
183
186
  const skill = readAbsoluteFile(skillPath);
184
187
 
188
+ const mindRoot = getMindRoot();
189
+ const userSkillRules = readKnowledgeFile('user-skill-rules.md');
190
+
185
191
  const targetDir = dirnameOf(currentFile);
186
192
  const bootstrap = {
187
193
  instruction: readKnowledgeFile('INSTRUCTION.md'),
@@ -199,6 +205,7 @@ export async function POST(req: NextRequest) {
199
205
  const truncationWarnings: string[] = [];
200
206
  if (!skill.ok) initFailures.push(`skill.mindos: failed (${skill.error})`);
201
207
  if (skill.ok && skill.truncated) truncationWarnings.push('skill.mindos was truncated');
208
+ if (userSkillRules.ok && userSkillRules.truncated) truncationWarnings.push('user-skill-rules.md was truncated');
202
209
  if (!bootstrap.instruction.ok) initFailures.push(`bootstrap.instruction: failed (${bootstrap.instruction.error})`);
203
210
  if (bootstrap.instruction.ok && bootstrap.instruction.truncated) truncationWarnings.push('bootstrap.instruction was truncated');
204
211
  if (!bootstrap.index.ok) initFailures.push(`bootstrap.index: failed (${bootstrap.index.error})`);
@@ -222,6 +229,10 @@ export async function POST(req: NextRequest) {
222
229
 
223
230
  const initContextBlocks: string[] = [];
224
231
  if (skill.ok) initContextBlocks.push(`## mindos_skill_md\n\n${skill.content}`);
232
+ // User personalization rules (from knowledge base root)
233
+ if (userSkillRules.ok && !userSkillRules.truncated && userSkillRules.content.trim()) {
234
+ initContextBlocks.push(`## user_skill_rules\n\nUser personalization rules (user-skill-rules.md):\n\n${userSkillRules.content}`);
235
+ }
225
236
  if (bootstrap.instruction.ok) initContextBlocks.push(`## bootstrap_instruction\n\n${bootstrap.instruction.content}`);
226
237
  if (bootstrap.index.ok) initContextBlocks.push(`## bootstrap_index\n\n${bootstrap.index.content}`);
227
238
  if (bootstrap.config_json.ok) initContextBlocks.push(`## bootstrap_config_json\n\n${bootstrap.config_json.content}`);
@@ -381,6 +392,7 @@ export async function POST(req: NextRequest) {
381
392
 
382
393
  // ── SSE Stream ──
383
394
  const encoder = new TextEncoder();
395
+ const requestStartTime = Date.now();
384
396
  const stream = new ReadableStream({
385
397
  start(controller) {
386
398
  function send(event: MindOSSSEvent) {
@@ -404,6 +416,7 @@ export async function POST(req: NextRequest) {
404
416
  });
405
417
  } else if (isToolExecutionEndEvent(event)) {
406
418
  const { toolCallId, output, isError } = getToolExecutionEnd(event);
419
+ metrics.recordToolExecution();
407
420
  send({
408
421
  type: 'tool_end',
409
422
  toolCallId,
@@ -413,6 +426,12 @@ export async function POST(req: NextRequest) {
413
426
  } else if (isTurnEndEvent(event)) {
414
427
  stepCount++;
415
428
 
429
+ // Record token usage if available from the turn
430
+ const turnUsage = (event as any).usage;
431
+ if (turnUsage && typeof turnUsage.inputTokens === 'number') {
432
+ metrics.recordTokens(turnUsage.inputTokens, turnUsage.outputTokens ?? 0);
433
+ }
434
+
416
435
  // Track tool calls for loop detection (lock-free batch update).
417
436
  // Deterministic JSON.stringify ensures consistent input comparison.
418
437
  const { toolResults } = getTurnEndData(event);
@@ -452,9 +471,12 @@ export async function POST(req: NextRequest) {
452
471
  });
453
472
 
454
473
  agent.prompt(lastUserContent).then(() => {
474
+ metrics.recordRequest(Date.now() - requestStartTime);
455
475
  send({ type: 'done' });
456
476
  controller.close();
457
477
  }).catch((err) => {
478
+ metrics.recordRequest(Date.now() - requestStartTime);
479
+ metrics.recordError();
458
480
  send({ type: 'error', message: err instanceof Error ? err.message : String(err) });
459
481
  controller.close();
460
482
  });
@@ -471,9 +493,9 @@ export async function POST(req: NextRequest) {
471
493
  });
472
494
  } catch (err) {
473
495
  console.error('[ask] Failed to initialize model:', err);
474
- return NextResponse.json(
475
- { error: err instanceof Error ? err.message : 'Failed to initialize AI model' },
476
- { status: 500 },
477
- );
496
+ if (err instanceof MindOSError) {
497
+ return apiError(err.code, err.message);
498
+ }
499
+ return apiError(ErrorCodes.MODEL_INIT_FAILED, err instanceof Error ? err.message : 'Failed to initialize AI model', 500);
478
500
  }
479
501
  }
@@ -21,6 +21,7 @@ export async function GET(req: NextRequest) {
21
21
  index: tryRead('README.md'),
22
22
  config_json: tryRead('CONFIG.json'),
23
23
  config_md: tryRead('CONFIG.md'),
24
+ user_skill_rules: tryRead('user-skill-rules.md'),
24
25
  };
25
26
 
26
27
  if (targetDir) {
@@ -0,0 +1,95 @@
1
+ export const dynamic = 'force-dynamic';
2
+ import { NextResponse } from 'next/server';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import { getMindRoot } from '@/lib/fs';
6
+ import { metrics } from '@/lib/metrics';
7
+
8
+ // Aligned with IGNORED_DIRS in lib/fs.ts and lib/core/tree.ts
9
+ const IGNORED_DIRS = new Set(['.git', 'node_modules', 'app', '.next', '.DS_Store', 'mcp']);
10
+
11
+ /**
12
+ * Recursively count files and sum their sizes under a directory.
13
+ * Skips directories in IGNORED_DIRS (aligned with the rest of the codebase).
14
+ */
15
+ function walkStats(dir: string): { fileCount: number; totalSizeBytes: number } {
16
+ let fileCount = 0;
17
+ let totalSizeBytes = 0;
18
+
19
+ function walk(current: string) {
20
+ let entries: fs.Dirent[];
21
+ try {
22
+ entries = fs.readdirSync(current, { withFileTypes: true });
23
+ } catch {
24
+ return;
25
+ }
26
+ for (const entry of entries) {
27
+ if (IGNORED_DIRS.has(entry.name)) continue;
28
+ const fullPath = path.join(current, entry.name);
29
+ if (entry.isDirectory()) {
30
+ walk(fullPath);
31
+ } else if (entry.isFile()) {
32
+ try {
33
+ const stat = fs.statSync(fullPath);
34
+ fileCount++;
35
+ totalSizeBytes += stat.size;
36
+ } catch { /* skip unreadable files */ }
37
+ }
38
+ }
39
+ }
40
+
41
+ walk(dir);
42
+ return { fileCount, totalSizeBytes };
43
+ }
44
+
45
+ // ── TTL cache for walkStats (avoid blocking event loop every 5s poll) ──
46
+ let cachedKbStats: { fileCount: number; totalSizeBytes: number } | null = null;
47
+ let cachedKbStatsTs = 0;
48
+ const KB_STATS_TTL = 30_000; // 30s
49
+
50
+ function getCachedKbStats(mindRoot: string): { fileCount: number; totalSizeBytes: number } {
51
+ const now = Date.now();
52
+ if (cachedKbStats && now - cachedKbStatsTs < KB_STATS_TTL) return cachedKbStats;
53
+ cachedKbStats = walkStats(mindRoot);
54
+ cachedKbStatsTs = now;
55
+ return cachedKbStats;
56
+ }
57
+
58
+ export async function GET() {
59
+ const snap = metrics.getSnapshot();
60
+ const mem = process.memoryUsage();
61
+ const mindRoot = getMindRoot();
62
+
63
+ const kbStats = getCachedKbStats(mindRoot);
64
+
65
+ // Detect MCP status from environment / config
66
+ const mcpPort = Number(process.env.MCP_PORT) || 3457;
67
+
68
+ return NextResponse.json({
69
+ system: {
70
+ uptimeMs: Date.now() - snap.processStartTime,
71
+ memory: {
72
+ heapUsed: mem.heapUsed,
73
+ heapTotal: mem.heapTotal,
74
+ rss: mem.rss,
75
+ },
76
+ nodeVersion: process.version,
77
+ },
78
+ application: {
79
+ agentRequests: snap.agentRequests,
80
+ toolExecutions: snap.toolExecutions,
81
+ totalTokens: snap.totalTokens,
82
+ avgResponseTimeMs: snap.avgResponseTimeMs,
83
+ errors: snap.errors,
84
+ },
85
+ knowledgeBase: {
86
+ root: mindRoot,
87
+ fileCount: kbStats.fileCount,
88
+ totalSizeBytes: kbStats.totalSizeBytes,
89
+ },
90
+ mcp: {
91
+ running: true, // If this endpoint responds, the server is running
92
+ port: mcpPort,
93
+ },
94
+ });
95
+ }
@@ -212,6 +212,20 @@ body {
212
212
  .prose img { max-width: 100%; border-radius: 6px; border: 1px solid var(--prose-code-border); }
213
213
  .prose hr { border: none; border-top: 1px solid var(--prose-border); margin: 2.5em 0; }
214
214
 
215
+ /* Ask Panel — compact prose for side panel chat bubbles */
216
+ .prose.prose-panel {
217
+ font-size: 0.8125rem !important;
218
+ line-height: 1.6;
219
+ }
220
+ .prose.prose-panel h1, .prose.prose-panel h2, .prose.prose-panel h3, .prose.prose-panel h4 {
221
+ font-size: 0.8125rem !important;
222
+ margin-top: 1em;
223
+ margin-bottom: 0.4em;
224
+ }
225
+ .prose.prose-panel p { margin-bottom: 0.6em; }
226
+ .prose.prose-panel pre { padding: 0.6em 0.8em; margin: 0.8em 0; }
227
+ .prose.prose-panel pre code { font-size: 0.78em; }
228
+
215
229
  :root {
216
230
  --prose-body: #3a3730;
217
231
  --prose-heading: #1c1a17;
@@ -4,9 +4,10 @@ import SetupWizard from '@/components/SetupWizard';
4
4
 
5
5
  export const dynamic = 'force-dynamic';
6
6
 
7
- export default function SetupPage({ searchParams }: { searchParams: { force?: string } }) {
7
+ export default async function SetupPage({ searchParams }: { searchParams: Promise<{ force?: string }> }) {
8
8
  const settings = readSettings();
9
- const force = searchParams.force === '1';
9
+ const { force: forceParam } = await searchParams;
10
+ const force = forceParam === '1';
10
11
  if (!settings.setupPending && !force) redirect('/');
11
12
  return <SetupWizard />;
12
13
  }
@@ -0,0 +1,183 @@
1
+ 'use client';
2
+
3
+ import { useRef, useCallback } from 'react';
4
+ import Link from 'next/link';
5
+ import { FolderTree, Search, Settings, RefreshCw, Blocks, Bot, ChevronLeft, ChevronRight } from 'lucide-react';
6
+ import { DOT_COLORS, getStatusLevel } from './SyncStatusBar';
7
+ import type { SyncStatus } from './settings/SyncTab';
8
+ import Logo from './Logo';
9
+
10
+ export type PanelId = 'files' | 'search' | 'plugins' | 'agents';
11
+
12
+ export const RAIL_WIDTH_COLLAPSED = 48;
13
+ export const RAIL_WIDTH_EXPANDED = 180;
14
+
15
+ interface ActivityBarProps {
16
+ activePanel: PanelId | null;
17
+ onPanelChange: (id: PanelId | null) => void;
18
+ syncStatus: SyncStatus | null;
19
+ expanded: boolean;
20
+ onExpandedChange: (expanded: boolean) => void;
21
+ onSettingsClick: () => void;
22
+ onSyncClick: (rect: DOMRect) => void;
23
+ }
24
+
25
+ interface RailButtonProps {
26
+ icon: React.ReactNode;
27
+ label: string;
28
+ shortcut?: string;
29
+ active?: boolean;
30
+ expanded: boolean;
31
+ onClick: () => void;
32
+ buttonRef?: React.Ref<HTMLButtonElement>;
33
+ /** Optional overlay badge (e.g. status dot) rendered inside the button */
34
+ badge?: React.ReactNode;
35
+ }
36
+
37
+ function RailButton({ icon, label, shortcut, active = false, expanded, onClick, buttonRef, badge }: RailButtonProps) {
38
+ return (
39
+ <button
40
+ ref={buttonRef}
41
+ onClick={onClick}
42
+ aria-pressed={active}
43
+ aria-label={label}
44
+ title={expanded ? undefined : (shortcut ? `${label} (${shortcut})` : label)}
45
+ className={`
46
+ relative flex items-center ${expanded ? 'justify-start px-3 w-full' : 'justify-center w-10'} h-10 rounded-md transition-colors
47
+ ${active
48
+ ? 'text-[var(--amber)] bg-[var(--amber-dim)]'
49
+ : 'text-muted-foreground hover:text-foreground hover:bg-muted'
50
+ }
51
+ focus-visible:ring-2 focus-visible:ring-ring
52
+ `}
53
+ >
54
+ {active && (
55
+ <span className="absolute left-0 top-1/2 -translate-y-1/2 w-[2px] h-[18px] rounded-r-full" style={{ background: 'var(--amber)' }} />
56
+ )}
57
+ <span className="shrink-0 flex items-center justify-center w-[18px]">{icon}</span>
58
+ {badge}
59
+ {expanded && (
60
+ <>
61
+ <span className="ml-2.5 text-sm whitespace-nowrap">{label}</span>
62
+ {shortcut && (
63
+ <span className="ml-auto text-2xs text-muted-foreground/60 font-mono shrink-0">{shortcut}</span>
64
+ )}
65
+ </>
66
+ )}
67
+ </button>
68
+ );
69
+ }
70
+
71
+ export default function ActivityBar({
72
+ activePanel,
73
+ onPanelChange,
74
+ syncStatus,
75
+ expanded,
76
+ onExpandedChange,
77
+ onSettingsClick,
78
+ onSyncClick,
79
+ }: ActivityBarProps) {
80
+ const lastClickRef = useRef(0);
81
+ const syncBtnRef = useRef<HTMLButtonElement>(null);
82
+
83
+ /** Debounce rapid clicks (300ms) — shared across all Rail buttons */
84
+ const debounced = useCallback((fn: () => void) => {
85
+ const now = Date.now();
86
+ if (now - lastClickRef.current < 300) return;
87
+ lastClickRef.current = now;
88
+ fn();
89
+ }, []);
90
+
91
+ const toggle = useCallback((id: PanelId) => {
92
+ debounced(() => onPanelChange(activePanel === id ? null : id));
93
+ }, [activePanel, onPanelChange, debounced]);
94
+
95
+ const syncLevel = getStatusLevel(syncStatus, false);
96
+ const showSyncDot = syncLevel !== 'off' && syncLevel !== 'synced';
97
+
98
+ const railWidth = expanded ? RAIL_WIDTH_EXPANDED : RAIL_WIDTH_COLLAPSED;
99
+
100
+ // Sync dot badge — positioned differently in collapsed vs expanded
101
+ const syncBadge = showSyncDot ? (
102
+ <span className={`absolute ${expanded ? 'left-[26px] top-1.5' : 'top-1.5 right-1.5'} w-2 h-2 rounded-full ${DOT_COLORS[syncLevel]} ${syncLevel === 'error' || syncLevel === 'conflicts' ? 'animate-pulse' : ''}`} />
103
+ ) : undefined;
104
+
105
+ return (
106
+ <aside
107
+ className="group hidden md:flex fixed top-0 left-0 h-screen z-[31] flex-col bg-background border-r border-border transition-[width] duration-200 ease-out"
108
+ style={{ width: `${railWidth}px` }}
109
+ role="toolbar"
110
+ aria-label="Navigation"
111
+ aria-orientation="vertical"
112
+ >
113
+ {/* Content wrapper — overflow-hidden prevents text flash during width transitions */}
114
+ <div className="flex flex-col h-full w-full overflow-hidden">
115
+ {/* ── Top: Logo ── */}
116
+ <Link
117
+ href="/"
118
+ className={`flex items-center ${expanded ? 'px-3 gap-2' : 'justify-center'} w-full py-3 hover:opacity-80 transition-opacity`}
119
+ aria-label="MindOS Home"
120
+ >
121
+ <Logo id="rail" className="w-7 h-3.5 shrink-0" />
122
+ {expanded && <span className="text-sm font-semibold text-foreground font-display whitespace-nowrap">MindOS</span>}
123
+ </Link>
124
+
125
+ <div className={`${expanded ? 'mx-3' : 'mx-auto w-6'} border-t border-border`} />
126
+
127
+ {/* ── Middle: Core panel toggles ── */}
128
+ <div className={`flex flex-col ${expanded ? 'px-1.5' : 'items-center'} gap-1 py-2`}>
129
+ <RailButton icon={<FolderTree size={18} />} label="Files" active={activePanel === 'files'} expanded={expanded} onClick={() => toggle('files')} />
130
+ <RailButton icon={<Search size={18} />} label="Search" shortcut="⌘K" active={activePanel === 'search'} expanded={expanded} onClick={() => toggle('search')} />
131
+ <RailButton icon={<Blocks size={18} />} label="Plugins" active={activePanel === 'plugins'} expanded={expanded} onClick={() => toggle('plugins')} />
132
+ <RailButton icon={<Bot size={18} />} label="Agents" active={activePanel === 'agents'} expanded={expanded} onClick={() => toggle('agents')} />
133
+ </div>
134
+
135
+ {/* ── Spacer ── */}
136
+ <div className="flex-1" />
137
+
138
+ {/* ── Bottom: Action buttons (not panel toggles) ── */}
139
+ <div className={`${expanded ? 'mx-3' : 'mx-auto w-6'} border-t border-border`} />
140
+ <div className={`flex flex-col ${expanded ? 'px-1.5' : 'items-center'} gap-1 py-2`}>
141
+ <RailButton
142
+ icon={<Settings size={18} />}
143
+ label="Settings"
144
+ shortcut="⌘,"
145
+ expanded={expanded}
146
+ onClick={() => debounced(onSettingsClick)}
147
+ />
148
+ <RailButton
149
+ icon={<RefreshCw size={18} />}
150
+ label="Sync"
151
+ expanded={expanded}
152
+ buttonRef={syncBtnRef}
153
+ badge={syncBadge}
154
+ onClick={() => debounced(() => {
155
+ const rect = syncBtnRef.current?.getBoundingClientRect();
156
+ if (rect) onSyncClick(rect);
157
+ })}
158
+ />
159
+ </div>
160
+ </div>
161
+
162
+ {/* ── Hover expand/collapse button — vertically centered on right edge ── */}
163
+ {/* z-[32] ensures it paints above Panel (z-30). Shows on Rail hover OR self-hover. */}
164
+ <button
165
+ onClick={() => onExpandedChange(!expanded)}
166
+ className="
167
+ absolute right-0 top-1/2 -translate-y-1/2 translate-x-1/2 z-[32]
168
+ w-5 h-5 rounded-full
169
+ bg-card border border-border shadow-sm
170
+ flex items-center justify-center
171
+ opacity-0 group-hover:opacity-100 hover:!opacity-100
172
+ transition-opacity duration-200
173
+ text-muted-foreground hover:text-foreground hover:bg-muted
174
+ focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring
175
+ "
176
+ aria-label={expanded ? 'Collapse sidebar' : 'Expand sidebar'}
177
+ title={expanded ? 'Collapse' : 'Expand'}
178
+ >
179
+ {expanded ? <ChevronLeft size={10} /> : <ChevronRight size={10} />}
180
+ </button>
181
+ </aside>
182
+ );
183
+ }
@@ -1,105 +1,47 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useCallback } from 'react';
4
- import { usePathname } from 'next/navigation';
5
3
  import { Sparkles } from 'lucide-react';
6
- import AskModal from './AskModal';
7
- import { useAskModal } from '@/hooks/useAskModal';
8
4
 
9
- export default function AskFab() {
10
- const [open, setOpen] = useState(false);
11
- const pathname = usePathname();
12
- const currentFile = pathname.startsWith('/view/')
13
- ? pathname.slice('/view/'.length).split('/').map(decodeURIComponent).join('/')
14
- : undefined;
15
-
16
- // Listen to useAskModal store for cross-component open requests (e.g. from GuideCard)
17
- const askModal = useAskModal();
18
- const [initialMessage, setInitialMessage] = useState('');
19
- const [openSource, setOpenSource] = useState<'user' | 'guide' | 'guide-next'>('user');
20
-
21
- useEffect(() => {
22
- if (askModal.open) {
23
- setInitialMessage(askModal.initialMessage);
24
- setOpenSource(askModal.source);
25
- setOpen(true);
26
- askModal.close(); // Reset store state after consuming
27
- }
28
- }, [askModal.open, askModal.initialMessage, askModal.source, askModal.close]);
29
-
30
- const handleClose = useCallback(() => {
31
- setOpen(false);
32
- setInitialMessage('');
33
- setOpenSource('user');
34
- }, []);
35
-
36
- // Dispatch correct PATCH based on how the modal was opened
37
- const handleFirstMessage = useCallback(() => {
38
- const notifyGuide = () => window.dispatchEvent(new Event('guide-state-updated'));
39
-
40
- if (openSource === 'guide') {
41
- // Task ② completion: mark askedAI
42
- fetch('/api/setup', {
43
- method: 'PATCH',
44
- headers: { 'Content-Type': 'application/json' },
45
- body: JSON.stringify({ guideState: { askedAI: true } }),
46
- }).then(notifyGuide).catch(() => {});
47
- } else if (openSource === 'guide-next') {
48
- // Next-step advancement: GuideCard already PATCHed nextStepIndex optimistically.
49
- // Just notify GuideCard to re-fetch for consistency; no additional PATCH needed.
50
- notifyGuide();
51
- }
52
- // For 'user' source: no guide action needed
53
- }, [openSource]);
5
+ interface AskFabProps {
6
+ /** Toggle the right-side Ask AI panel */
7
+ onToggle: () => void;
8
+ /** Whether the right panel is currently open (FAB hides when open) */
9
+ askPanelOpen: boolean;
10
+ }
54
11
 
12
+ export default function AskFab({ onToggle, askPanelOpen }: AskFabProps) {
55
13
  return (
56
- <>
57
- <button
58
- onClick={() => { setInitialMessage(''); setOpenSource('user'); setOpen(true); }}
59
- className="
60
- group
61
- fixed z-40
62
- bottom-5 right-5
63
- md:bottom-5 md:right-5
64
- flex items-center justify-center
65
- gap-0 hover:gap-2
66
- p-3 md:p-[11px] rounded-xl
67
- text-white font-medium text-[13px]
68
- shadow-md shadow-amber-900/15
69
- transition-all duration-200 ease-out
70
- hover:shadow-lg hover:shadow-amber-800/25
71
- active:scale-95
72
- cursor-pointer
73
- overflow-hidden
74
- font-display
75
- "
76
- style={{
77
- background: 'linear-gradient(135deg, #b07c2e 0%, #c8873a 50%, #d4943f 100%)',
78
- marginBottom: 'env(safe-area-inset-bottom, 0px)',
79
- }}
80
- title="MindOS Agent (⌘/)"
81
- aria-label="MindOS Agent"
82
- >
83
- <Sparkles size={16} className="relative z-10 shrink-0" />
84
-
85
- <span className="
86
- relative z-10
87
- max-w-0 group-hover:max-w-[120px]
88
- opacity-0 group-hover:opacity-100
89
- transition-all duration-200 ease-out
90
- whitespace-nowrap overflow-hidden
91
- ">
92
- MindOS Agent
93
- </span>
94
- </button>
95
-
96
- <AskModal
97
- open={open}
98
- onClose={handleClose}
99
- currentFile={currentFile}
100
- initialMessage={initialMessage}
101
- onFirstMessage={handleFirstMessage}
102
- />
103
- </>
14
+ <button
15
+ onClick={onToggle}
16
+ className={`
17
+ group hidden md:flex
18
+ fixed z-40 bottom-5 right-5
19
+ items-center justify-center
20
+ gap-0 hover:gap-2
21
+ p-[11px] rounded-xl
22
+ text-white font-medium text-[13px]
23
+ shadow-md shadow-amber-900/15
24
+ transition-all duration-200 ease-out
25
+ hover:shadow-lg hover:shadow-amber-800/25
26
+ active:scale-95 cursor-pointer overflow-hidden font-display
27
+ ${askPanelOpen ? 'opacity-0 pointer-events-none translate-y-2' : 'opacity-100 translate-y-0'}
28
+ `}
29
+ style={{
30
+ background: 'linear-gradient(135deg, #b07c2e 0%, #c8873a 50%, #d4943f 100%)',
31
+ }}
32
+ title="MindOS Agent (⌘/)"
33
+ aria-label="MindOS Agent"
34
+ >
35
+ <Sparkles size={16} className="relative z-10 shrink-0" />
36
+ <span className="
37
+ relative z-10
38
+ group-hover:max-w-[120px]
39
+ opacity-0 group-hover:opacity-100
40
+ transition-all duration-200 ease-out
41
+ whitespace-nowrap overflow-hidden
42
+ ">
43
+ MindOS Agent
44
+ </span>
45
+ </button>
104
46
  );
105
47
  }