@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.
- package/app/app/api/ask/route.ts +31 -9
- package/app/app/api/bootstrap/route.ts +1 -0
- package/app/app/api/monitoring/route.ts +95 -0
- package/app/app/globals.css +14 -0
- package/app/app/setup/page.tsx +3 -2
- package/app/components/ActivityBar.tsx +183 -0
- package/app/components/AskFab.tsx +39 -97
- package/app/components/AskModal.tsx +13 -371
- package/app/components/Breadcrumb.tsx +4 -4
- package/app/components/FileTree.tsx +21 -4
- package/app/components/Logo.tsx +39 -0
- package/app/components/Panel.tsx +152 -0
- package/app/components/RightAskPanel.tsx +72 -0
- package/app/components/SettingsModal.tsx +9 -235
- package/app/components/SidebarLayout.tsx +426 -12
- package/app/components/SyncStatusBar.tsx +74 -53
- package/app/components/TableOfContents.tsx +4 -2
- package/app/components/ask/AskContent.tsx +418 -0
- package/app/components/ask/MessageList.tsx +2 -2
- package/app/components/panels/AgentsPanel.tsx +231 -0
- package/app/components/panels/PanelHeader.tsx +35 -0
- package/app/components/panels/PluginsPanel.tsx +106 -0
- package/app/components/panels/SearchPanel.tsx +178 -0
- package/app/components/panels/SyncPopover.tsx +105 -0
- package/app/components/renderers/csv/TableView.tsx +4 -4
- package/app/components/settings/AgentsTab.tsx +240 -0
- package/app/components/settings/AiTab.tsx +39 -1
- package/app/components/settings/KnowledgeTab.tsx +116 -2
- package/app/components/settings/McpTab.tsx +6 -6
- package/app/components/settings/MonitoringTab.tsx +202 -0
- package/app/components/settings/SettingsContent.tsx +343 -0
- package/app/components/settings/types.ts +1 -1
- package/app/components/setup/index.tsx +2 -23
- package/app/hooks/useResizeDrag.ts +78 -0
- package/app/instrumentation.ts +7 -2
- package/app/lib/agent/log.ts +1 -0
- package/app/lib/agent/model.ts +33 -10
- package/app/lib/api.ts +12 -3
- package/app/lib/core/csv.ts +2 -1
- package/app/lib/core/fs-ops.ts +7 -6
- package/app/lib/core/index.ts +1 -1
- package/app/lib/core/lines.ts +7 -6
- package/app/lib/core/search-index.ts +174 -0
- package/app/lib/core/search.ts +30 -1
- package/app/lib/core/security.ts +6 -3
- package/app/lib/errors.ts +108 -0
- package/app/lib/format.ts +19 -0
- package/app/lib/fs.ts +6 -3
- package/app/lib/i18n-en.ts +49 -6
- package/app/lib/i18n-zh.ts +48 -5
- package/app/lib/metrics.ts +81 -0
- package/app/next-env.d.ts +1 -1
- package/app/next.config.ts +1 -1
- package/app/package.json +2 -2
- package/bin/cli.js +27 -97
- package/package.json +4 -2
- package/scripts/setup.js +2 -12
- package/skills/mindos/SKILL.md +226 -8
- package/skills/mindos-zh/SKILL.md +226 -8
- package/app/package-lock.json +0 -15736
package/app/app/api/ask/route.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
const
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
}
|
|
@@ -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
|
+
}
|
package/app/app/globals.css
CHANGED
|
@@ -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;
|
package/app/app/setup/page.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
}
|