idea-manager 1.6.0 → 1.7.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.
- package/.next/build-manifest.json +2 -2
- package/.next/routes-manifest.json +18 -0
- package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_global-error.html +2 -2
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +2 -2
- package/.next/server/app/_not-found.rsc +2 -2
- package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/api/archive/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/filesystem/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/filesystem/tree/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/global-memo/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/health/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/[id]/apply-distribute/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/[id]/auto-distribute/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/[id]/brainstorm/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/[id]/git-sync/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/[id]/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/[id]/sub-projects/[subId]/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/chat/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/prompt/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/refine/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/[id]/sub-projects/[subId]/tasks/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/[id]/sub-projects/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/projects/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/search/route.js +127 -0
- package/.next/server/app/api/search/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/sync/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/update/route.js +1 -0
- package/.next/server/app/api/update/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/version/route.js +1 -0
- package/.next/server/app/api/version/route_client-reference-manifest.js +1 -0
- package/.next/server/app/index.html +2 -2
- package/.next/server/app/index.rsc +3 -3
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/index.segments/_full.segment.rsc +3 -3
- package/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/.next/server/app/index.segments/_index.segment.rsc +2 -2
- package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/page.js +12 -12
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app/projects/[id]/page_client-reference-manifest.js +1 -1
- package/.next/server/app-paths-manifest.json +10 -7
- package/.next/server/pages/404.html +2 -2
- package/.next/server/pages/500.html +2 -2
- package/.next/static/chunks/app/_global-error/page-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/archive/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/filesystem/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/filesystem/tree/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/global-memo/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/health/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/apply-distribute/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/auto-distribute/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/brainstorm/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/git-sync/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/chat/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/prompt/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/refine/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/projects/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/search/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/sync/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/update/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/api/version/route-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/app/page-9a1dc101e82c397c.js +28 -0
- package/.next/static/chunks/next/dist/client/components/builtin/app-error-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/forbidden-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/not-found-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-e6a77f238d2cdbb9.js +1 -0
- package/.next/static/css/eab748b03f49c43a.css +3 -0
- package/.next/static/mxrEVQX3r5YlDPZgpDvSp/_buildManifest.js +1 -0
- package/README.ja.md +4 -1
- package/README.ko.md +36 -6
- package/README.md +31 -6
- package/README.zh.md +4 -1
- package/package.json +1 -1
- package/src/app/api/search/route.ts +149 -0
- package/src/app/api/update/route.ts +52 -0
- package/src/app/api/version/route.ts +68 -0
- package/src/components/search/GlobalSearch.tsx +156 -0
- package/src/components/search/QuickCapture.tsx +208 -0
- package/src/components/tabs/TabBar.tsx +2 -0
- package/src/components/tabs/TabShell.tsx +4 -0
- package/src/components/task/CommandPalette.tsx +48 -2
- package/src/components/task/NoteEditor.tsx +16 -1
- package/src/components/task/TaskChat.tsx +31 -20
- package/src/components/task/TaskDetail.tsx +62 -2
- package/src/components/update/UpdateButton.tsx +190 -0
- package/src/components/workspace/WorkspacePanel.tsx +1 -0
- package/.next/static/63zinfEtSLCdG9nUZ3W-E/_buildManifest.js +0 -1
- package/.next/static/chunks/app/_global-error/page-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/archive/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/filesystem/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/filesystem/tree/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/global-memo/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/health/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/projects/[id]/apply-distribute/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/projects/[id]/auto-distribute/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/projects/[id]/brainstorm/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/projects/[id]/git-sync/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/projects/[id]/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/chat/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/prompt/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/refine/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/[subId]/tasks/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/projects/[id]/sub-projects/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/projects/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/api/sync/route-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/app/page-6a511af64da7531f.js +0 -28
- package/.next/static/chunks/next/dist/client/components/builtin/app-error-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/forbidden-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/not-found-6ec0e723e471f87a.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-6ec0e723e471f87a.js +0 -1
- package/.next/static/css/cc32379d0efa7d1d.css +0 -3
- /package/.next/static/{63zinfEtSLCdG9nUZ3W-E → mxrEVQX3r5YlDPZgpDvSp}/_ssgManifest.js +0 -0
|
@@ -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
|
|
@@ -19,6 +19,28 @@ const COMMANDS: { key: RefineCommand; label: string; hint: string }[] = [
|
|
|
19
19
|
{ key: 'custom', label: '직접 입력…', hint: '임의 명령을 프롬프트로 전달' },
|
|
20
20
|
];
|
|
21
21
|
|
|
22
|
+
const HISTORY_KEY = 'im-refine-custom-history';
|
|
23
|
+
const HISTORY_MAX = 8;
|
|
24
|
+
|
|
25
|
+
function loadHistory(): string[] {
|
|
26
|
+
if (typeof window === 'undefined') return [];
|
|
27
|
+
try {
|
|
28
|
+
const raw = localStorage.getItem(HISTORY_KEY);
|
|
29
|
+
if (!raw) return [];
|
|
30
|
+
const arr = JSON.parse(raw);
|
|
31
|
+
return Array.isArray(arr) ? arr.filter((v): v is string => typeof v === 'string') : [];
|
|
32
|
+
} catch { return []; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function pushHistory(entry: string) {
|
|
36
|
+
if (typeof window === 'undefined') return;
|
|
37
|
+
const trimmed = entry.trim();
|
|
38
|
+
if (!trimmed) return;
|
|
39
|
+
const prev = loadHistory().filter(x => x !== trimmed);
|
|
40
|
+
const next = [trimmed, ...prev].slice(0, HISTORY_MAX);
|
|
41
|
+
try { localStorage.setItem(HISTORY_KEY, JSON.stringify(next)); } catch { /* quota */ }
|
|
42
|
+
}
|
|
43
|
+
|
|
22
44
|
export default function CommandPalette({
|
|
23
45
|
open,
|
|
24
46
|
hasSelection,
|
|
@@ -33,6 +55,7 @@ export default function CommandPalette({
|
|
|
33
55
|
const [idx, setIdx] = useState(0);
|
|
34
56
|
const [customMode, setCustomMode] = useState(false);
|
|
35
57
|
const [custom, setCustom] = useState('');
|
|
58
|
+
const [history, setHistory] = useState<string[]>([]);
|
|
36
59
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
37
60
|
|
|
38
61
|
useEffect(() => {
|
|
@@ -44,7 +67,10 @@ export default function CommandPalette({
|
|
|
44
67
|
}, [open]);
|
|
45
68
|
|
|
46
69
|
useEffect(() => {
|
|
47
|
-
if (customMode)
|
|
70
|
+
if (customMode) {
|
|
71
|
+
inputRef.current?.focus();
|
|
72
|
+
setHistory(loadHistory());
|
|
73
|
+
}
|
|
48
74
|
}, [customMode]);
|
|
49
75
|
|
|
50
76
|
if (!open) return null;
|
|
@@ -61,6 +87,7 @@ export default function CommandPalette({
|
|
|
61
87
|
const submitCustom = () => {
|
|
62
88
|
const t = custom.trim();
|
|
63
89
|
if (!t) return;
|
|
90
|
+
pushHistory(t);
|
|
64
91
|
onRun('custom', t);
|
|
65
92
|
};
|
|
66
93
|
|
|
@@ -114,11 +141,30 @@ export default function CommandPalette({
|
|
|
114
141
|
onChange={(e) => setCustom(e.target.value)}
|
|
115
142
|
onKeyDown={(e) => {
|
|
116
143
|
if (e.key === 'Enter') submitCustom();
|
|
117
|
-
if (e.key === 'Escape')
|
|
144
|
+
if (e.key === 'Escape') { e.preventDefault(); setCustomMode(false); }
|
|
118
145
|
}}
|
|
119
146
|
placeholder="예: 이 부분 markdown 표로 만들어줘"
|
|
120
147
|
className="w-full bg-input border border-border rounded-md px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
|
121
148
|
/>
|
|
149
|
+
{history.length > 0 && (
|
|
150
|
+
<div className="flex flex-col gap-1.5">
|
|
151
|
+
<div className="text-[10px] uppercase tracking-wider text-muted-foreground/70">최근 명령</div>
|
|
152
|
+
<div className="flex flex-wrap gap-1.5">
|
|
153
|
+
{history.map((h) => (
|
|
154
|
+
<button
|
|
155
|
+
key={h}
|
|
156
|
+
onClick={() => setCustom(h)}
|
|
157
|
+
title={h}
|
|
158
|
+
className="text-xs px-2 py-1 rounded border border-border text-muted-foreground
|
|
159
|
+
hover:text-foreground hover:border-muted-foreground transition-colors
|
|
160
|
+
max-w-[220px] truncate text-left"
|
|
161
|
+
>
|
|
162
|
+
{h}
|
|
163
|
+
</button>
|
|
164
|
+
))}
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
122
168
|
<div className="flex justify-end gap-2">
|
|
123
169
|
<button onClick={() => setCustomMode(false)} className="text-xs text-muted-foreground px-2 py-1">뒤로</button>
|
|
124
170
|
<button
|
|
@@ -285,18 +285,32 @@ const mdHighlight = HighlightStyle.define([
|
|
|
285
285
|
// ─────────────────────────────────────────────────────────────
|
|
286
286
|
// Component
|
|
287
287
|
// ─────────────────────────────────────────────────────────────
|
|
288
|
+
// Extract the current bullet/checkbox line text suitable for promoting to a task.
|
|
289
|
+
// Returns the "content" portion (without the `- [ ]` marker) and the line range.
|
|
290
|
+
export function getPromotableLine(view: EditorView): { content: string; from: number; to: number } | null {
|
|
291
|
+
const state = view.state;
|
|
292
|
+
const pos = state.selection.main.head;
|
|
293
|
+
const line = state.doc.lineAt(pos);
|
|
294
|
+
const m = line.text.match(/^(\s*)([-*+])\s(?:\[[ xX]\]\s)?(.*)$/);
|
|
295
|
+
if (!m) return null;
|
|
296
|
+
const content = m[3]?.trim();
|
|
297
|
+
if (!content) return null;
|
|
298
|
+
return { content, from: line.from, to: line.to };
|
|
299
|
+
}
|
|
300
|
+
|
|
288
301
|
export interface NoteEditorProps {
|
|
289
302
|
value: string;
|
|
290
303
|
onChange: (v: string) => void;
|
|
291
304
|
onBlur?: () => void;
|
|
292
305
|
onOpenCommand?: () => void;
|
|
306
|
+
onPromoteLine?: () => void;
|
|
293
307
|
placeholder?: string;
|
|
294
308
|
/** Extra text blobs (sibling tasks, brainstorm, …) to widen the autocomplete corpus. */
|
|
295
309
|
extraCorpus?: string[];
|
|
296
310
|
}
|
|
297
311
|
|
|
298
312
|
const NoteEditor = forwardRef<ReactCodeMirrorRef, NoteEditorProps>(function NoteEditor(
|
|
299
|
-
{ value, onChange, onBlur, onOpenCommand, placeholder, extraCorpus },
|
|
313
|
+
{ value, onChange, onBlur, onOpenCommand, onPromoteLine, placeholder, extraCorpus },
|
|
300
314
|
ref,
|
|
301
315
|
) {
|
|
302
316
|
// Mutable ref keeps the plugin in sync with the latest corpus without
|
|
@@ -315,6 +329,7 @@ const NoteEditor = forwardRef<ReactCodeMirrorRef, NoteEditorProps>(function Note
|
|
|
315
329
|
{ key: 'Escape', run: dismissGhost },
|
|
316
330
|
{ key: 'Enter', run: continueList },
|
|
317
331
|
{ key: 'Mod-k', run: () => { onOpenCommand?.(); return true; } },
|
|
332
|
+
{ key: 'Mod-Shift-t', run: () => { onPromoteLine?.(); return true; } },
|
|
318
333
|
])),
|
|
319
334
|
EditorView.lineWrapping,
|
|
320
335
|
EditorView.theme({
|
|
@@ -149,27 +149,38 @@ export default function TaskChat({
|
|
|
149
149
|
노트 작성을 도와드립니다. 질문하거나 "이 부분 정리해줘" 같이 요청해보세요
|
|
150
150
|
</div>
|
|
151
151
|
)}
|
|
152
|
-
{messages.filter(msg => msg.content).map((msg) =>
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
152
|
+
{messages.filter(msg => msg.content).map((msg) => {
|
|
153
|
+
const isProgress = msg.role === 'assistant' && msg.content.startsWith('[진행 중]');
|
|
154
|
+
return (
|
|
155
|
+
<div key={msg.id} className={`flex flex-col ${msg.role === 'user' ? 'items-end' : 'items-start'}`}>
|
|
156
|
+
{isProgress && (
|
|
157
|
+
<div className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-warning mb-0.5 pl-1">
|
|
158
|
+
<span className="inline-block w-1.5 h-1.5 rounded-full bg-warning animate-pulse" />
|
|
159
|
+
Watch 실행 중 · 실시간 출력
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
162
|
+
<div className={`max-w-[90%] px-3 py-2 rounded-lg text-sm leading-relaxed ${
|
|
163
|
+
msg.role === 'user'
|
|
164
|
+
? 'bg-accent text-white rounded-br-sm whitespace-pre-wrap'
|
|
165
|
+
: isProgress
|
|
166
|
+
? 'bg-warning/10 text-foreground rounded-bl-sm chat-markdown border border-warning/30'
|
|
167
|
+
: 'bg-muted text-foreground rounded-bl-sm chat-markdown'
|
|
168
|
+
}`}>
|
|
169
|
+
{msg.role === 'assistant'
|
|
170
|
+
? <ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
|
|
171
|
+
: msg.content}
|
|
172
|
+
</div>
|
|
173
|
+
{msg.role === 'assistant' && !isProgress && (
|
|
174
|
+
<button
|
|
175
|
+
onClick={() => onInsertToNote(msg.content)}
|
|
176
|
+
className="text-xs text-muted-foreground hover:text-primary mt-0.5 px-1 transition-colors"
|
|
177
|
+
>
|
|
178
|
+
↓ 노트에 삽입
|
|
179
|
+
</button>
|
|
180
|
+
)}
|
|
162
181
|
</div>
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
onClick={() => onInsertToNote(msg.content)}
|
|
166
|
-
className="text-xs text-muted-foreground hover:text-primary mt-0.5 px-1 transition-colors"
|
|
167
|
-
>
|
|
168
|
-
↓ 노트에 삽입
|
|
169
|
-
</button>
|
|
170
|
-
)}
|
|
171
|
-
</div>
|
|
172
|
-
))}
|
|
182
|
+
);
|
|
183
|
+
})}
|
|
173
184
|
{loading && (
|
|
174
185
|
<div className="flex gap-1 px-2 py-2">
|
|
175
186
|
<div className="w-1.5 h-1.5 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: '0ms' }} />
|
|
@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
|
|
4
4
|
import type { ITask, TaskStatus, ItemPriority } from '@/types';
|
|
5
5
|
import StatusFlow from './StatusFlow';
|
|
6
6
|
import TaskChat from './TaskChat';
|
|
7
|
-
import NoteEditor from './NoteEditor';
|
|
7
|
+
import NoteEditor, { getPromotableLine } from './NoteEditor';
|
|
8
8
|
import CommandPalette, { type RefineCommand } from './CommandPalette';
|
|
9
9
|
import type { ReactCodeMirrorRef } from '@uiw/react-codemirror';
|
|
10
10
|
|
|
@@ -15,6 +15,7 @@ export default function TaskDetail({
|
|
|
15
15
|
siblingTasks,
|
|
16
16
|
onUpdate,
|
|
17
17
|
onDelete,
|
|
18
|
+
onTaskPromoted,
|
|
18
19
|
onChatStateChange,
|
|
19
20
|
}: {
|
|
20
21
|
task: ITask;
|
|
@@ -24,6 +25,8 @@ export default function TaskDetail({
|
|
|
24
25
|
siblingTasks?: ITask[];
|
|
25
26
|
onUpdate: (data: Partial<ITask>) => void;
|
|
26
27
|
onDelete: () => void;
|
|
28
|
+
/** Fired after a checkbox line is promoted to a new task. Parent should refresh its task list. */
|
|
29
|
+
onTaskPromoted?: (newTask: ITask) => void;
|
|
27
30
|
onChatStateChange?: (taskId: string, state: 'idle' | 'loading' | 'done') => void;
|
|
28
31
|
}) {
|
|
29
32
|
const [title, setTitle] = useState(task.title);
|
|
@@ -31,6 +34,16 @@ export default function TaskDetail({
|
|
|
31
34
|
const [editingTitle, setEditingTitle] = useState(false);
|
|
32
35
|
const [copied, setCopied] = useState(false);
|
|
33
36
|
const [chatOpen, setChatOpen] = useState(false);
|
|
37
|
+
const chatWasManuallyToggled = useRef(false);
|
|
38
|
+
|
|
39
|
+
// Auto-open the chat panel while the task is being executed by the watcher —
|
|
40
|
+
// that's where streaming progress shows up. Don't override a manual toggle
|
|
41
|
+
// the user made in this session.
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (task.status === 'testing' && !chatOpen && !chatWasManuallyToggled.current) {
|
|
44
|
+
setChatOpen(true);
|
|
45
|
+
}
|
|
46
|
+
}, [task.status, chatOpen]);
|
|
34
47
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
|
35
48
|
const [refining, setRefining] = useState(false);
|
|
36
49
|
const [refineElapsed, setRefineElapsed] = useState(0);
|
|
@@ -230,6 +243,46 @@ export default function TaskDetail({
|
|
|
230
243
|
setUndoSnapshot(null);
|
|
231
244
|
}, [undoSnapshot, task.id, onUpdate]);
|
|
232
245
|
|
|
246
|
+
const [promoteNotice, setPromoteNotice] = useState<string | null>(null);
|
|
247
|
+
|
|
248
|
+
const promoteCheckbox = useCallback(async () => {
|
|
249
|
+
const view = editorRef.current?.view;
|
|
250
|
+
if (!view) return;
|
|
251
|
+
const line = getPromotableLine(view);
|
|
252
|
+
if (!line) {
|
|
253
|
+
setRefineError('체크박스나 불릿 목록 줄에 커서를 두고 실행하세요');
|
|
254
|
+
setTimeout(() => setRefineError(null), 3000);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const titleText = line.content.slice(0, 200);
|
|
258
|
+
try {
|
|
259
|
+
const res = await fetch(`/api/projects/${projectId}/sub-projects/${subProjectId}/tasks`, {
|
|
260
|
+
method: 'POST',
|
|
261
|
+
headers: { 'Content-Type': 'application/json' },
|
|
262
|
+
body: JSON.stringify({ title: titleText }),
|
|
263
|
+
});
|
|
264
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
265
|
+
const newTask = await res.json() as ITask;
|
|
266
|
+
|
|
267
|
+
// Remove the promoted line from the note (and a trailing newline, if any).
|
|
268
|
+
const doc = view.state.doc.toString();
|
|
269
|
+
const before = doc.slice(0, line.from);
|
|
270
|
+
const afterRaw = doc.slice(line.to);
|
|
271
|
+
const trimmed = afterRaw.startsWith('\n') ? afterRaw.slice(1) : afterRaw;
|
|
272
|
+
const nextDoc = before + trimmed;
|
|
273
|
+
setDescription(nextDoc);
|
|
274
|
+
onUpdate({ description: nextDoc });
|
|
275
|
+
onTaskPromoted?.(newTask);
|
|
276
|
+
|
|
277
|
+
setPromoteNotice(`→ 태스크 "${titleText}" 생성됨`);
|
|
278
|
+
setTimeout(() => setPromoteNotice(null), 3000);
|
|
279
|
+
} catch (err) {
|
|
280
|
+
const msg = err instanceof Error ? err.message : '태스크 생성 실패';
|
|
281
|
+
setRefineError(msg);
|
|
282
|
+
setTimeout(() => setRefineError(null), 4000);
|
|
283
|
+
}
|
|
284
|
+
}, [projectId, subProjectId, onUpdate, onTaskPromoted]);
|
|
285
|
+
|
|
233
286
|
const priorities: ItemPriority[] = ['high', 'medium', 'low'];
|
|
234
287
|
|
|
235
288
|
return (
|
|
@@ -302,7 +355,7 @@ export default function TaskDetail({
|
|
|
302
355
|
{copied ? '✓ Copied' : 'Copy as Prompt'}
|
|
303
356
|
</button>
|
|
304
357
|
<button
|
|
305
|
-
onClick={() => setChatOpen(v => !v)}
|
|
358
|
+
onClick={() => { chatWasManuallyToggled.current = true; setChatOpen(v => !v); }}
|
|
306
359
|
className={`text-xs px-2 py-0.5 rounded transition-colors border ${
|
|
307
360
|
chatOpen
|
|
308
361
|
? 'bg-accent/15 text-accent border-accent/30'
|
|
@@ -336,6 +389,7 @@ export default function TaskDetail({
|
|
|
336
389
|
onChange={setDescription}
|
|
337
390
|
onBlur={saveDescription}
|
|
338
391
|
onOpenCommand={openPalette}
|
|
392
|
+
onPromoteLine={promoteCheckbox}
|
|
339
393
|
extraCorpus={extraCorpus}
|
|
340
394
|
placeholder="자유롭게 작성하세요. 배경 · 목표 · 관련 파일 · 결정사항 · 질문 · 링크 등 뭐든..."
|
|
341
395
|
/>
|
|
@@ -353,6 +407,12 @@ export default function TaskDetail({
|
|
|
353
407
|
</button>
|
|
354
408
|
</div>
|
|
355
409
|
)}
|
|
410
|
+
{promoteNotice && (
|
|
411
|
+
<div className="absolute bottom-2 right-3 text-xs px-3 py-1.5 rounded bg-success/15 text-success flex items-center gap-2 shadow-lg border border-success/30">
|
|
412
|
+
<span>✓</span>
|
|
413
|
+
<span className="truncate max-w-[50ch]">{promoteNotice}</span>
|
|
414
|
+
</div>
|
|
415
|
+
)}
|
|
356
416
|
{!refining && undoSnapshot && undoSnapshot.taskId === task.id && (
|
|
357
417
|
<div className="absolute bottom-2 right-3 text-xs px-2 py-1 rounded bg-accent/15 text-foreground flex items-center gap-2 shadow-lg border border-accent/30">
|
|
358
418
|
<span className="text-accent">✓</span>
|