idea-manager 1.6.1 → 1.8.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 (132) hide show
  1. package/.next/build-manifest.json +2 -2
  2. package/.next/routes-manifest.json +18 -0
  3. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  4. package/.next/server/app/_global-error.html +2 -2
  5. package/.next/server/app/_global-error.rsc +1 -1
  6. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  7. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  8. package/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  9. package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  12. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  13. package/.next/server/app/_not-found.html +2 -2
  14. package/.next/server/app/_not-found.rsc +2 -2
  15. package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  16. package/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  17. package/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  18. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  19. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  20. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  21. package/.next/server/app/api/archive/route_client-reference-manifest.js +1 -1
  22. package/.next/server/app/api/filesystem/route_client-reference-manifest.js +1 -1
  23. package/.next/server/app/api/filesystem/tree/route_client-reference-manifest.js +1 -1
  24. package/.next/server/app/api/global-memo/route_client-reference-manifest.js +1 -1
  25. package/.next/server/app/api/health/route_client-reference-manifest.js +1 -1
  26. package/.next/server/app/api/projects/[id]/apply-distribute/route_client-reference-manifest.js +1 -1
  27. package/.next/server/app/api/projects/[id]/auto-distribute/route_client-reference-manifest.js +1 -1
  28. package/.next/server/app/api/projects/[id]/brainstorm/route_client-reference-manifest.js +1 -1
  29. package/.next/server/app/api/projects/[id]/git-sync/route_client-reference-manifest.js +1 -1
  30. package/.next/server/app/api/projects/[id]/route_client-reference-manifest.js +1 -1
  31. package/.next/server/app/api/projects/[id]/sub-projects/[subId]/route_client-reference-manifest.js +1 -1
  32. package/.next/server/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/chat/route_client-reference-manifest.js +1 -1
  33. package/.next/server/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/prompt/route_client-reference-manifest.js +1 -1
  34. package/.next/server/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/refine/route_client-reference-manifest.js +1 -1
  35. package/.next/server/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/route_client-reference-manifest.js +1 -1
  36. package/.next/server/app/api/projects/[id]/sub-projects/[subId]/tasks/route_client-reference-manifest.js +1 -1
  37. package/.next/server/app/api/projects/[id]/sub-projects/route_client-reference-manifest.js +1 -1
  38. package/.next/server/app/api/projects/route_client-reference-manifest.js +1 -1
  39. package/.next/server/app/api/search/route.js +127 -0
  40. package/.next/server/app/api/search/route_client-reference-manifest.js +1 -0
  41. package/.next/server/app/api/sync/route_client-reference-manifest.js +1 -1
  42. package/.next/server/app/api/update/route.js +1 -0
  43. package/.next/server/app/api/update/route_client-reference-manifest.js +1 -0
  44. package/.next/server/app/api/version/route.js +1 -0
  45. package/.next/server/app/api/version/route_client-reference-manifest.js +1 -0
  46. package/.next/server/app/index.html +2 -2
  47. package/.next/server/app/index.rsc +3 -3
  48. package/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  49. package/.next/server/app/index.segments/_full.segment.rsc +3 -3
  50. package/.next/server/app/index.segments/_head.segment.rsc +1 -1
  51. package/.next/server/app/index.segments/_index.segment.rsc +2 -2
  52. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  53. package/.next/server/app/page.js +12 -12
  54. package/.next/server/app/page_client-reference-manifest.js +1 -1
  55. package/.next/server/app/projects/[id]/page_client-reference-manifest.js +1 -1
  56. package/.next/server/app-paths-manifest.json +11 -8
  57. package/.next/server/pages/404.html +2 -2
  58. package/.next/server/pages/500.html +2 -2
  59. package/.next/static/Fy-Z5gkec2a0fese1C5rW/_buildManifest.js +1 -0
  60. package/.next/static/chunks/app/_global-error/page-e6a77f238d2cdbb9.js +1 -0
  61. package/.next/static/chunks/app/api/archive/route-e6a77f238d2cdbb9.js +1 -0
  62. package/.next/static/chunks/app/api/filesystem/route-e6a77f238d2cdbb9.js +1 -0
  63. package/.next/static/chunks/app/api/filesystem/tree/route-e6a77f238d2cdbb9.js +1 -0
  64. package/.next/static/chunks/app/api/global-memo/route-e6a77f238d2cdbb9.js +1 -0
  65. package/.next/static/chunks/app/api/health/route-e6a77f238d2cdbb9.js +1 -0
  66. package/.next/static/chunks/app/api/projects/[id]/apply-distribute/route-e6a77f238d2cdbb9.js +1 -0
  67. package/.next/static/chunks/app/api/projects/[id]/auto-distribute/route-e6a77f238d2cdbb9.js +1 -0
  68. package/.next/static/chunks/app/api/projects/[id]/brainstorm/route-e6a77f238d2cdbb9.js +1 -0
  69. package/.next/static/chunks/app/api/projects/[id]/git-sync/route-e6a77f238d2cdbb9.js +1 -0
  70. package/.next/static/chunks/app/api/projects/[id]/route-e6a77f238d2cdbb9.js +1 -0
  71. package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/route-e6a77f238d2cdbb9.js +1 -0
  72. package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/chat/route-e6a77f238d2cdbb9.js +1 -0
  73. package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/prompt/route-e6a77f238d2cdbb9.js +1 -0
  74. package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/refine/route-e6a77f238d2cdbb9.js +1 -0
  75. package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/route-e6a77f238d2cdbb9.js +1 -0
  76. package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/route-e6a77f238d2cdbb9.js +1 -0
  77. package/.next/static/chunks/app/api/projects/[id]/sub-projects/route-e6a77f238d2cdbb9.js +1 -0
  78. package/.next/static/chunks/app/api/projects/route-e6a77f238d2cdbb9.js +1 -0
  79. package/.next/static/chunks/app/api/search/route-e6a77f238d2cdbb9.js +1 -0
  80. package/.next/static/chunks/app/api/sync/route-e6a77f238d2cdbb9.js +1 -0
  81. package/.next/static/chunks/app/api/update/route-e6a77f238d2cdbb9.js +1 -0
  82. package/.next/static/chunks/app/api/version/route-e6a77f238d2cdbb9.js +1 -0
  83. package/.next/static/chunks/app/page-9a1dc101e82c397c.js +28 -0
  84. package/.next/static/chunks/next/dist/client/components/builtin/app-error-e6a77f238d2cdbb9.js +1 -0
  85. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-e6a77f238d2cdbb9.js +1 -0
  86. package/.next/static/chunks/next/dist/client/components/builtin/not-found-e6a77f238d2cdbb9.js +1 -0
  87. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-e6a77f238d2cdbb9.js +1 -0
  88. package/.next/static/css/eab748b03f49c43a.css +3 -0
  89. package/package.json +1 -1
  90. package/src/app/api/search/route.ts +149 -0
  91. package/src/app/api/update/route.ts +52 -0
  92. package/src/app/api/version/route.ts +68 -0
  93. package/src/cli.ts +10 -0
  94. package/src/components/search/GlobalSearch.tsx +156 -0
  95. package/src/components/search/QuickCapture.tsx +208 -0
  96. package/src/components/tabs/TabBar.tsx +2 -0
  97. package/src/components/tabs/TabShell.tsx +4 -0
  98. package/src/components/task/CommandPalette.tsx +48 -2
  99. package/src/components/task/NoteEditor.tsx +16 -1
  100. package/src/components/task/TaskChat.tsx +31 -20
  101. package/src/components/task/TaskDetail.tsx +62 -2
  102. package/src/components/update/UpdateButton.tsx +190 -0
  103. package/src/components/workspace/WorkspacePanel.tsx +1 -0
  104. package/src/lib/auto-update.ts +136 -0
  105. package/.next/static/chunks/app/_global-error/page-6ec0e723e471f87a.js +0 -1
  106. package/.next/static/chunks/app/api/archive/route-6ec0e723e471f87a.js +0 -1
  107. package/.next/static/chunks/app/api/filesystem/route-6ec0e723e471f87a.js +0 -1
  108. package/.next/static/chunks/app/api/filesystem/tree/route-6ec0e723e471f87a.js +0 -1
  109. package/.next/static/chunks/app/api/global-memo/route-6ec0e723e471f87a.js +0 -1
  110. package/.next/static/chunks/app/api/health/route-6ec0e723e471f87a.js +0 -1
  111. package/.next/static/chunks/app/api/projects/[id]/apply-distribute/route-6ec0e723e471f87a.js +0 -1
  112. package/.next/static/chunks/app/api/projects/[id]/auto-distribute/route-6ec0e723e471f87a.js +0 -1
  113. package/.next/static/chunks/app/api/projects/[id]/brainstorm/route-6ec0e723e471f87a.js +0 -1
  114. package/.next/static/chunks/app/api/projects/[id]/git-sync/route-6ec0e723e471f87a.js +0 -1
  115. package/.next/static/chunks/app/api/projects/[id]/route-6ec0e723e471f87a.js +0 -1
  116. package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/route-6ec0e723e471f87a.js +0 -1
  117. package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/chat/route-6ec0e723e471f87a.js +0 -1
  118. package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/prompt/route-6ec0e723e471f87a.js +0 -1
  119. package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/refine/route-6ec0e723e471f87a.js +0 -1
  120. package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/route-6ec0e723e471f87a.js +0 -1
  121. package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/route-6ec0e723e471f87a.js +0 -1
  122. package/.next/static/chunks/app/api/projects/[id]/sub-projects/route-6ec0e723e471f87a.js +0 -1
  123. package/.next/static/chunks/app/api/projects/route-6ec0e723e471f87a.js +0 -1
  124. package/.next/static/chunks/app/api/sync/route-6ec0e723e471f87a.js +0 -1
  125. package/.next/static/chunks/app/page-6a511af64da7531f.js +0 -28
  126. package/.next/static/chunks/next/dist/client/components/builtin/app-error-6ec0e723e471f87a.js +0 -1
  127. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-6ec0e723e471f87a.js +0 -1
  128. package/.next/static/chunks/next/dist/client/components/builtin/not-found-6ec0e723e471f87a.js +0 -1
  129. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-6ec0e723e471f87a.js +0 -1
  130. package/.next/static/css/cc32379d0efa7d1d.css +0 -3
  131. package/.next/static/eQXRVHrJt1cKjgp4hKYm8/_buildManifest.js +0 -1
  132. /package/.next/static/{eQXRVHrJt1cKjgp4hKYm8 → Fy-Z5gkec2a0fese1C5rW}/_ssgManifest.js +0 -0
@@ -0,0 +1,149 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { getDb } from '@/lib/db';
3
+ import { ensureDb } from '@/lib/db';
4
+
5
+ export interface ISearchResult {
6
+ type: 'task' | 'project' | 'sub-project';
7
+ projectId: string;
8
+ projectName: string;
9
+ subProjectId?: string;
10
+ subProjectName?: string;
11
+ taskId?: string;
12
+ title: string;
13
+ snippet?: string;
14
+ status?: string;
15
+ isArchived?: boolean;
16
+ score: number;
17
+ }
18
+
19
+ interface TaskSearchRow {
20
+ id: string;
21
+ title: string;
22
+ description: string;
23
+ project_id: string;
24
+ project_name: string;
25
+ sub_project_id: string;
26
+ sub_project_name: string;
27
+ status: string;
28
+ is_archived: number;
29
+ }
30
+
31
+ interface ProjectSearchRow {
32
+ id: string;
33
+ name: string;
34
+ description: string;
35
+ }
36
+
37
+ interface SubProjectSearchRow {
38
+ id: string;
39
+ name: string;
40
+ project_id: string;
41
+ project_name: string;
42
+ }
43
+
44
+ function escapeLike(s: string): string {
45
+ return s.replace(/[\\%_]/g, m => '\\' + m);
46
+ }
47
+
48
+ function buildSnippet(body: string, query: string): string | undefined {
49
+ if (!body) return undefined;
50
+ const lower = body.toLowerCase();
51
+ const idx = lower.indexOf(query.toLowerCase());
52
+ if (idx < 0) return body.slice(0, 120);
53
+ const start = Math.max(0, idx - 30);
54
+ const end = Math.min(body.length, idx + query.length + 60);
55
+ return (start > 0 ? '…' : '') + body.slice(start, end) + (end < body.length ? '…' : '');
56
+ }
57
+
58
+ function scoreMatch(title: string, body: string, query: string): number {
59
+ const q = query.toLowerCase();
60
+ const t = (title ?? '').toLowerCase();
61
+ const b = (body ?? '').toLowerCase();
62
+ let score = 0;
63
+ if (t === q) score += 100;
64
+ else if (t.startsWith(q)) score += 50;
65
+ else if (t.includes(q)) score += 30;
66
+ if (b.includes(q)) score += 10;
67
+ return score;
68
+ }
69
+
70
+ export async function GET(request: NextRequest) {
71
+ await ensureDb();
72
+ const { searchParams } = new URL(request.url);
73
+ const raw = (searchParams.get('q') ?? '').trim();
74
+ if (raw.length < 1) return NextResponse.json([]);
75
+
76
+ const q = raw.slice(0, 200);
77
+ const like = `%${escapeLike(q)}%`;
78
+ const db = getDb();
79
+
80
+ const taskRows = db.prepare(`
81
+ SELECT t.id, t.title, t.description, t.status, t.is_archived,
82
+ t.project_id, p.name AS project_name,
83
+ t.sub_project_id, sp.name AS sub_project_name
84
+ FROM tasks t
85
+ JOIN projects p ON p.id = t.project_id
86
+ JOIN sub_projects sp ON sp.id = t.sub_project_id
87
+ WHERE (t.title LIKE ? ESCAPE '\\' OR t.description LIKE ? ESCAPE '\\')
88
+ ORDER BY t.is_archived ASC, t.updated_at DESC
89
+ LIMIT 40
90
+ `).all(like, like) as TaskSearchRow[];
91
+
92
+ const projectRows = db.prepare(`
93
+ SELECT id, name, description FROM projects
94
+ WHERE name LIKE ? ESCAPE '\\' OR description LIKE ? ESCAPE '\\'
95
+ LIMIT 10
96
+ `).all(like, like) as ProjectSearchRow[];
97
+
98
+ const subProjectRows = db.prepare(`
99
+ SELECT sp.id, sp.name, sp.project_id, p.name AS project_name
100
+ FROM sub_projects sp
101
+ JOIN projects p ON p.id = sp.project_id
102
+ WHERE sp.name LIKE ? ESCAPE '\\'
103
+ LIMIT 10
104
+ `).all(like) as SubProjectSearchRow[];
105
+
106
+ const results: ISearchResult[] = [];
107
+
108
+ for (const r of taskRows) {
109
+ results.push({
110
+ type: 'task',
111
+ projectId: r.project_id,
112
+ projectName: r.project_name,
113
+ subProjectId: r.sub_project_id,
114
+ subProjectName: r.sub_project_name,
115
+ taskId: r.id,
116
+ title: r.title,
117
+ snippet: buildSnippet(r.description, q),
118
+ status: r.status,
119
+ isArchived: r.is_archived === 1,
120
+ score: scoreMatch(r.title, r.description, q) + (r.is_archived ? -5 : 0),
121
+ });
122
+ }
123
+
124
+ for (const r of projectRows) {
125
+ results.push({
126
+ type: 'project',
127
+ projectId: r.id,
128
+ projectName: r.name,
129
+ title: r.name,
130
+ snippet: buildSnippet(r.description, q),
131
+ score: scoreMatch(r.name, r.description ?? '', q),
132
+ });
133
+ }
134
+
135
+ for (const r of subProjectRows) {
136
+ results.push({
137
+ type: 'sub-project',
138
+ projectId: r.project_id,
139
+ projectName: r.project_name,
140
+ subProjectId: r.id,
141
+ subProjectName: r.name,
142
+ title: r.name,
143
+ score: scoreMatch(r.name, '', q),
144
+ });
145
+ }
146
+
147
+ results.sort((a, b) => b.score - a.score);
148
+ return NextResponse.json(results.slice(0, 30));
149
+ }
@@ -0,0 +1,52 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { spawn } from 'child_process';
3
+
4
+ export const dynamic = 'force-dynamic';
5
+
6
+ // Runs `npm install -g idea-manager@latest` as the current user. This only
7
+ // makes sense for local installs of IM — the API is same-origin so there's no
8
+ // remote caller to worry about. After a successful install the running Node
9
+ // process still holds the OLD code; the response tells the client to prompt
10
+ // the user to restart `im start` (or to let PM2 auto-restart).
11
+ export async function POST() {
12
+ const started = Date.now();
13
+ return await new Promise<Response>((resolve) => {
14
+ const child = spawn('npm', ['install', '-g', 'idea-manager@latest', '--no-fund', '--no-audit'], {
15
+ stdio: ['ignore', 'pipe', 'pipe'],
16
+ shell: process.platform === 'win32',
17
+ env: process.env,
18
+ });
19
+
20
+ let stdout = '';
21
+ let stderr = '';
22
+ child.stdout?.on('data', (c: Buffer) => { stdout += c.toString(); });
23
+ child.stderr?.on('data', (c: Buffer) => { stderr += c.toString(); });
24
+
25
+ // 3-minute hard timeout — npm install on stale registry can hang otherwise.
26
+ const timeout = setTimeout(() => {
27
+ child.kill('SIGTERM');
28
+ }, 3 * 60 * 1000);
29
+
30
+ child.on('error', (err) => {
31
+ clearTimeout(timeout);
32
+ resolve(NextResponse.json({
33
+ ok: false,
34
+ error: err.message,
35
+ durationMs: Date.now() - started,
36
+ }, { status: 500 }));
37
+ });
38
+
39
+ child.on('exit', (code, signal) => {
40
+ clearTimeout(timeout);
41
+ const ok = code === 0;
42
+ resolve(NextResponse.json({
43
+ ok,
44
+ code,
45
+ signal: signal ?? null,
46
+ stdout: stdout.slice(-4000),
47
+ stderr: stderr.slice(-4000),
48
+ durationMs: Date.now() - started,
49
+ }, { status: ok ? 200 : 500 }));
50
+ });
51
+ });
52
+ }
@@ -0,0 +1,68 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { readFileSync } from 'fs';
3
+ import { join } from 'path';
4
+
5
+ export const dynamic = 'force-dynamic';
6
+
7
+ // Read installed version from the package that is actually running.
8
+ // process.cwd() may differ when launched via the CLI, so we resolve
9
+ // relative to this file's runtime location by walking up.
10
+ function readInstalledVersion(): string {
11
+ const candidates = [
12
+ // Running from built standalone output
13
+ join(process.cwd(), 'package.json'),
14
+ // Running from source (dev)
15
+ join(process.cwd(), '..', 'package.json'),
16
+ ];
17
+ for (const p of candidates) {
18
+ try {
19
+ const raw = readFileSync(p, 'utf8');
20
+ const parsed = JSON.parse(raw) as { name?: string; version?: string };
21
+ if (parsed.name === 'idea-manager' && typeof parsed.version === 'string') {
22
+ return parsed.version;
23
+ }
24
+ } catch { /* try next */ }
25
+ }
26
+ return '0.0.0';
27
+ }
28
+
29
+ function cmp(a: string, b: string): number {
30
+ const as = a.split('.').map(n => parseInt(n, 10));
31
+ const bs = b.split('.').map(n => parseInt(n, 10));
32
+ for (let i = 0; i < Math.max(as.length, bs.length); i++) {
33
+ const av = as[i] ?? 0, bv = bs[i] ?? 0;
34
+ if (av !== bv) return av - bv;
35
+ }
36
+ return 0;
37
+ }
38
+
39
+ interface CacheEntry { latest: string; at: number }
40
+ let cache: CacheEntry | null = null;
41
+ const CACHE_MS = 10 * 60 * 1000; // 10 minutes
42
+
43
+ async function fetchLatest(): Promise<string | null> {
44
+ if (cache && Date.now() - cache.at < CACHE_MS) return cache.latest;
45
+ try {
46
+ const controller = new AbortController();
47
+ const timer = setTimeout(() => controller.abort(), 5000);
48
+ const res = await fetch('https://registry.npmjs.org/idea-manager/latest', {
49
+ signal: controller.signal,
50
+ cache: 'no-store',
51
+ });
52
+ clearTimeout(timer);
53
+ if (!res.ok) return null;
54
+ const data = await res.json() as { version?: string };
55
+ if (typeof data.version === 'string') {
56
+ cache = { latest: data.version, at: Date.now() };
57
+ return data.version;
58
+ }
59
+ } catch { /* network / timeout */ }
60
+ return null;
61
+ }
62
+
63
+ export async function GET() {
64
+ const current = readInstalledVersion();
65
+ const latest = await fetchLatest();
66
+ const updateAvailable = !!latest && cmp(latest, current) > 0;
67
+ return NextResponse.json({ current, latest, updateAvailable });
68
+ }
package/src/cli.ts CHANGED
@@ -10,6 +10,7 @@ import { getTaskPrompt } from './lib/db/queries/task-prompts';
10
10
  import type { McpToolContext } from './lib/mcp/tools';
11
11
  import { startWatcher } from './lib/watcher';
12
12
  import { syncInit, syncPush, syncPull, syncStatus } from './lib/sync/index';
13
+ import { maybeAutoUpdate, respawnSelf } from './lib/auto-update';
13
14
  import { spawn } from 'child_process';
14
15
  import path from 'path';
15
16
  import { fileURLToPath } from 'url';
@@ -118,6 +119,15 @@ program
118
119
  const port = opts.port;
119
120
  const fs = await import('fs');
120
121
 
122
+ // Auto-update on boot — fetches npm latest, installs if newer, respawns.
123
+ // Only runs for `im start`; `im mcp` / `im watch` stay untouched because
124
+ // they may be long-running integrations we shouldn't disrupt.
125
+ const upd = await maybeAutoUpdate(PKG_ROOT);
126
+ if (upd.upgraded) {
127
+ respawnSelf();
128
+ return;
129
+ }
130
+
121
131
  // Resolve next CLI
122
132
  let nextCli: string;
123
133
  try {
@@ -0,0 +1,156 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState, useCallback } from 'react';
4
+ import { useTabContext } from '@/components/tabs/TabContext';
5
+ import type { ISearchResult } from '@/app/api/search/route';
6
+
7
+ export default function GlobalSearch() {
8
+ const [open, setOpen] = useState(false);
9
+ const [query, setQuery] = useState('');
10
+ const [results, setResults] = useState<ISearchResult[]>([]);
11
+ const [idx, setIdx] = useState(0);
12
+ const [loading, setLoading] = useState(false);
13
+ const inputRef = useRef<HTMLInputElement>(null);
14
+ const { openProject } = useTabContext();
15
+ const abortRef = useRef<AbortController | null>(null);
16
+
17
+ // Global ⌘P / Ctrl+P shortcut (K is also acceptable in some editors;
18
+ // P is used because ⌘K is the note-refine palette).
19
+ useEffect(() => {
20
+ const onKey = (e: KeyboardEvent) => {
21
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'p') {
22
+ e.preventDefault();
23
+ setOpen(prev => !prev);
24
+ }
25
+ };
26
+ window.addEventListener('keydown', onKey);
27
+ return () => window.removeEventListener('keydown', onKey);
28
+ }, []);
29
+
30
+ // Reset state each time it opens
31
+ useEffect(() => {
32
+ if (!open) return;
33
+ setQuery('');
34
+ setResults([]);
35
+ setIdx(0);
36
+ // defer focus until after modal animation paint
37
+ const id = requestAnimationFrame(() => inputRef.current?.focus());
38
+ return () => cancelAnimationFrame(id);
39
+ }, [open]);
40
+
41
+ // Debounced search
42
+ useEffect(() => {
43
+ if (!open) return;
44
+ const q = query.trim();
45
+ if (q.length < 1) { setResults([]); setLoading(false); return; }
46
+ setLoading(true);
47
+ const timer = setTimeout(() => {
48
+ abortRef.current?.abort();
49
+ const abort = new AbortController();
50
+ abortRef.current = abort;
51
+ fetch(`/api/search?q=${encodeURIComponent(q)}`, { signal: abort.signal })
52
+ .then(r => r.ok ? r.json() : [])
53
+ .then((data: ISearchResult[]) => {
54
+ setResults(Array.isArray(data) ? data : []);
55
+ setIdx(0);
56
+ setLoading(false);
57
+ })
58
+ .catch(() => { /* aborted or network — leave UI steady */ });
59
+ }, 120);
60
+ return () => clearTimeout(timer);
61
+ }, [query, open]);
62
+
63
+ const pick = useCallback((r: ISearchResult) => {
64
+ openProject(r.projectId, r.projectName, r.subProjectId, r.taskId);
65
+ setOpen(false);
66
+ }, [openProject]);
67
+
68
+ const handleKey = (e: React.KeyboardEvent) => {
69
+ if (e.key === 'Escape') { setOpen(false); return; }
70
+ if (e.key === 'ArrowDown') { e.preventDefault(); setIdx(i => Math.min(i + 1, results.length - 1)); return; }
71
+ if (e.key === 'ArrowUp') { e.preventDefault(); setIdx(i => Math.max(i - 1, 0)); return; }
72
+ if (e.key === 'Enter') {
73
+ e.preventDefault();
74
+ const target = results[idx];
75
+ if (target) pick(target);
76
+ }
77
+ };
78
+
79
+ if (!open) return null;
80
+
81
+ return (
82
+ <div
83
+ onClick={() => setOpen(false)}
84
+ className="fixed inset-0 z-[60] flex items-start justify-center pt-[14vh]"
85
+ style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(3px)' }}
86
+ >
87
+ <div
88
+ onClick={(e) => e.stopPropagation()}
89
+ className="bg-card border border-border rounded-xl shadow-2xl w-full max-w-xl animate-dialog-in"
90
+ >
91
+ <div className="px-4 py-3 border-b border-border flex items-center gap-2">
92
+ <span className="text-muted-foreground">🔎</span>
93
+ <input
94
+ ref={inputRef}
95
+ value={query}
96
+ onChange={(e) => setQuery(e.target.value)}
97
+ onKeyDown={handleKey}
98
+ placeholder="태스크 · 프로젝트 · 워크스페이스 검색… (⌘P)"
99
+ className="flex-1 bg-transparent text-sm text-foreground focus:outline-none"
100
+ />
101
+ <span className="text-[10px] text-muted-foreground/70 px-1.5 py-0.5 border border-border rounded">
102
+ ↑↓ · ↵ · Esc
103
+ </span>
104
+ </div>
105
+
106
+ <div className="max-h-[55vh] overflow-y-auto">
107
+ {loading && (
108
+ <div className="px-4 py-6 text-xs text-muted-foreground">검색 중…</div>
109
+ )}
110
+ {!loading && query.trim() && results.length === 0 && (
111
+ <div className="px-4 py-6 text-xs text-muted-foreground">일치하는 항목 없음</div>
112
+ )}
113
+ {!loading && !query.trim() && (
114
+ <div className="px-4 py-6 text-xs text-muted-foreground">
115
+ 무엇을 찾으시나요? 태스크 제목·본문, 프로젝트·워크스페이스 이름을 검색합니다.
116
+ </div>
117
+ )}
118
+ <ul>
119
+ {results.map((r, i) => (
120
+ <li
121
+ key={`${r.type}-${r.taskId ?? r.subProjectId ?? r.projectId}`}
122
+ onMouseEnter={() => setIdx(i)}
123
+ onClick={() => pick(r)}
124
+ className={`px-4 py-2.5 cursor-pointer border-l-2 ${
125
+ i === idx ? 'bg-muted border-primary' : 'border-transparent'
126
+ }`}
127
+ >
128
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
129
+ <span className={`px-1.5 py-0.5 rounded text-[10px] uppercase tracking-wide ${
130
+ r.type === 'task' ? 'bg-primary/15 text-primary' :
131
+ r.type === 'project' ? 'bg-accent/15 text-accent' :
132
+ 'bg-warning/15 text-warning'
133
+ }`}>
134
+ {r.type === 'sub-project' ? 'project' : r.type}
135
+ </span>
136
+ <span>{r.projectName}</span>
137
+ {r.subProjectName && r.type === 'task' && (
138
+ <>
139
+ <span className="opacity-50">›</span>
140
+ <span>{r.subProjectName}</span>
141
+ </>
142
+ )}
143
+ {r.isArchived && <span className="text-muted-foreground/70 italic">(archived)</span>}
144
+ </div>
145
+ <div className="text-sm text-foreground mt-0.5 truncate">{r.title}</div>
146
+ {r.snippet && (
147
+ <div className="text-xs text-muted-foreground/80 mt-0.5 truncate">{r.snippet}</div>
148
+ )}
149
+ </li>
150
+ ))}
151
+ </ul>
152
+ </div>
153
+ </div>
154
+ </div>
155
+ );
156
+ }
@@ -0,0 +1,208 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { useTabContext } from '@/components/tabs/TabContext';
5
+ import type { IProject, ISubProject, ITask } from '@/types';
6
+
7
+ interface ProjectWithSubs extends IProject {
8
+ subProjects?: ISubProject[];
9
+ loaded?: boolean;
10
+ }
11
+
12
+ const LAST_DEST_KEY = 'im-quick-capture-last-dest';
13
+
14
+ interface LastDest { projectId: string; subProjectId: string }
15
+
16
+ function loadLastDest(): LastDest | null {
17
+ if (typeof window === 'undefined') return null;
18
+ try {
19
+ const raw = localStorage.getItem(LAST_DEST_KEY);
20
+ if (!raw) return null;
21
+ const parsed = JSON.parse(raw);
22
+ if (parsed?.projectId && parsed?.subProjectId) return parsed as LastDest;
23
+ } catch { /* ignore */ }
24
+ return null;
25
+ }
26
+
27
+ function saveLastDest(dest: LastDest) {
28
+ try { localStorage.setItem(LAST_DEST_KEY, JSON.stringify(dest)); } catch { /* quota */ }
29
+ }
30
+
31
+ export default function QuickCapture() {
32
+ const [open, setOpen] = useState(false);
33
+ const [projects, setProjects] = useState<ProjectWithSubs[]>([]);
34
+ const [projectId, setProjectId] = useState<string>('');
35
+ const [subProjectId, setSubProjectId] = useState<string>('');
36
+ const [title, setTitle] = useState('');
37
+ const [busy, setBusy] = useState(false);
38
+ const [err, setErr] = useState<string | null>(null);
39
+ const titleRef = useRef<HTMLInputElement>(null);
40
+ const { openProject } = useTabContext();
41
+
42
+ // Global ⌘N / Ctrl+N shortcut
43
+ useEffect(() => {
44
+ const onKey = (e: KeyboardEvent) => {
45
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'n') {
46
+ e.preventDefault();
47
+ setOpen(prev => !prev);
48
+ }
49
+ };
50
+ window.addEventListener('keydown', onKey);
51
+ return () => window.removeEventListener('keydown', onKey);
52
+ }, []);
53
+
54
+ // Load projects on first open
55
+ useEffect(() => {
56
+ if (!open) return;
57
+ fetch('/api/projects')
58
+ .then(r => r.ok ? r.json() : [])
59
+ .then((data: IProject[]) => {
60
+ setProjects(data.map(p => ({ ...p })));
61
+ const last = loadLastDest();
62
+ const fallback = data[0]?.id;
63
+ const initial = last?.projectId && data.some(p => p.id === last.projectId) ? last.projectId : fallback;
64
+ if (initial) setProjectId(initial);
65
+ });
66
+ }, [open]);
67
+
68
+ // Load sub-projects when project changes
69
+ useEffect(() => {
70
+ if (!projectId) { setSubProjectId(''); return; }
71
+ const existing = projects.find(p => p.id === projectId);
72
+ if (existing?.loaded && existing.subProjects) {
73
+ const last = loadLastDest();
74
+ const fallback = existing.subProjects[0]?.id ?? '';
75
+ setSubProjectId(
76
+ last?.projectId === projectId && existing.subProjects.some(s => s.id === last.subProjectId)
77
+ ? last.subProjectId
78
+ : fallback,
79
+ );
80
+ return;
81
+ }
82
+ fetch(`/api/projects/${projectId}/sub-projects`)
83
+ .then(r => r.ok ? r.json() : [])
84
+ .then((subs: ISubProject[]) => {
85
+ setProjects(prev => prev.map(p =>
86
+ p.id === projectId ? { ...p, subProjects: subs, loaded: true } : p
87
+ ));
88
+ const last = loadLastDest();
89
+ const fallback = subs[0]?.id ?? '';
90
+ setSubProjectId(
91
+ last?.projectId === projectId && subs.some(s => s.id === last.subProjectId)
92
+ ? last.subProjectId
93
+ : fallback,
94
+ );
95
+ });
96
+ // projects is mutated above; keep deps minimal to avoid refetch loops
97
+ // eslint-disable-next-line react-hooks/exhaustive-deps
98
+ }, [projectId]);
99
+
100
+ useEffect(() => {
101
+ if (!open) return;
102
+ setTitle('');
103
+ setErr(null);
104
+ setBusy(false);
105
+ const id = requestAnimationFrame(() => titleRef.current?.focus());
106
+ return () => cancelAnimationFrame(id);
107
+ }, [open]);
108
+
109
+ const submit = async () => {
110
+ const t = title.trim();
111
+ if (!t || !projectId || !subProjectId || busy) return;
112
+ setBusy(true);
113
+ setErr(null);
114
+ try {
115
+ const res = await fetch(`/api/projects/${projectId}/sub-projects/${subProjectId}/tasks`, {
116
+ method: 'POST',
117
+ headers: { 'Content-Type': 'application/json' },
118
+ body: JSON.stringify({ title: t }),
119
+ });
120
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
121
+ const task = await res.json() as ITask;
122
+ saveLastDest({ projectId, subProjectId });
123
+ const project = projects.find(p => p.id === projectId);
124
+ if (project) openProject(project.id, project.name, subProjectId, task.id);
125
+ setOpen(false);
126
+ } catch (e) {
127
+ setErr(e instanceof Error ? e.message : '생성 실패');
128
+ setBusy(false);
129
+ }
130
+ };
131
+
132
+ if (!open) return null;
133
+
134
+ const currentProject = projects.find(p => p.id === projectId);
135
+ const subs = currentProject?.subProjects ?? [];
136
+
137
+ return (
138
+ <div
139
+ onClick={() => setOpen(false)}
140
+ className="fixed inset-0 z-[60] flex items-start justify-center pt-[16vh]"
141
+ style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(3px)' }}
142
+ >
143
+ <div
144
+ onClick={(e) => e.stopPropagation()}
145
+ className="bg-card border border-border rounded-xl shadow-2xl w-full max-w-md animate-dialog-in p-4 flex flex-col gap-3"
146
+ >
147
+ <div className="flex items-center justify-between">
148
+ <div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">빠른 태스크 캡처</div>
149
+ <span className="text-[10px] text-muted-foreground/70 px-1.5 py-0.5 border border-border rounded">⌘N · Esc</span>
150
+ </div>
151
+
152
+ <div className="grid grid-cols-2 gap-2">
153
+ <select
154
+ value={projectId}
155
+ onChange={(e) => setProjectId(e.target.value)}
156
+ className="bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground focus:border-primary focus:outline-none"
157
+ >
158
+ {projects.map(p => (
159
+ <option key={p.id} value={p.id}>{p.name}</option>
160
+ ))}
161
+ </select>
162
+ <select
163
+ value={subProjectId}
164
+ onChange={(e) => setSubProjectId(e.target.value)}
165
+ disabled={!subs.length}
166
+ className="bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground focus:border-primary focus:outline-none disabled:opacity-50"
167
+ >
168
+ {subs.length === 0 ? (
169
+ <option value="">프로젝트 없음</option>
170
+ ) : subs.map(s => (
171
+ <option key={s.id} value={s.id}>{s.name}</option>
172
+ ))}
173
+ </select>
174
+ </div>
175
+
176
+ <input
177
+ ref={titleRef}
178
+ value={title}
179
+ onChange={(e) => setTitle(e.target.value)}
180
+ onKeyDown={(e) => {
181
+ if (e.key === 'Enter') { e.preventDefault(); submit(); }
182
+ if (e.key === 'Escape') { e.preventDefault(); setOpen(false); }
183
+ }}
184
+ placeholder="태스크 제목을 입력하고 Enter…"
185
+ className="w-full bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground focus:border-primary focus:outline-none"
186
+ />
187
+
188
+ {err && <div className="text-xs text-destructive">⚠ {err}</div>}
189
+
190
+ <div className="flex items-center justify-between">
191
+ <div className="text-[10px] text-muted-foreground/70">
192
+ 저장 후 해당 태스크 워크스페이스로 바로 이동
193
+ </div>
194
+ <div className="flex gap-2">
195
+ <button onClick={() => setOpen(false)} className="text-xs text-muted-foreground px-2 py-1">취소</button>
196
+ <button
197
+ onClick={submit}
198
+ disabled={!title.trim() || !projectId || !subProjectId || busy}
199
+ className="text-xs px-3 py-1 bg-primary text-primary-foreground rounded disabled:opacity-40"
200
+ >
201
+ {busy ? '…' : '생성'}
202
+ </button>
203
+ </div>
204
+ </div>
205
+ </div>
206
+ </div>
207
+ );
208
+ }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useTabContext } from './TabContext';
4
4
  import ThemePicker from '@/components/theme/ThemePicker';
5
+ import UpdateButton from '@/components/update/UpdateButton';
5
6
 
6
7
  export default function TabBar() {
7
8
  const { state, setActiveTab, closeTab } = useTabContext();
@@ -43,6 +44,7 @@ export default function TabBar() {
43
44
  );
44
45
  })}
45
46
  <div className="tab-bar-spacer" />
47
+ <UpdateButton />
46
48
  <ThemePicker />
47
49
  </div>
48
50
  );
@@ -4,6 +4,8 @@ import { useTabContext } from './TabContext';
4
4
  import TabBar from './TabBar';
5
5
  import DashboardPanel from '@/components/dashboard/DashboardPanel';
6
6
  import WorkspacePanel from '@/components/workspace/WorkspacePanel';
7
+ import GlobalSearch from '@/components/search/GlobalSearch';
8
+ import QuickCapture from '@/components/search/QuickCapture';
7
9
 
8
10
  export default function TabShell() {
9
11
  const { state } = useTabContext();
@@ -11,6 +13,8 @@ export default function TabShell() {
11
13
  return (
12
14
  <div className="h-screen flex flex-col">
13
15
  <TabBar />
16
+ <GlobalSearch />
17
+ <QuickCapture />
14
18
  <div className="flex-1 min-h-0 relative">
15
19
  {state.tabs.map((tab) => (
16
20
  <div