claude-ws 0.3.0 → 0.3.1
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/package.json +2 -1
- package/server.ts +19 -0
- package/src/app/[locale]/page.tsx +1 -16
- package/src/app/api/tasks/[id]/route.ts +20 -1
- package/src/components/auth/api-key-dialog.tsx +68 -10
- package/src/components/editor/markdown-file-viewer.tsx +205 -97
- package/src/components/kanban/create-task-dialog.tsx +1 -1
- package/src/components/right-sidebar.tsx +57 -1
- package/src/components/sidebar/file-browser/file-tab-content.tsx +72 -3
- package/src/components/sidebar/file-browser/file-tree-item.tsx +1 -0
- package/src/lib/session-manager.ts +7 -4
- package/src/lib/utils.ts +39 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-ws",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "A beautifully crafted workspace interface for Claude Code with real-time streaming and local SQLite database",
|
|
6
6
|
"keywords": [
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"sqlite"
|
|
17
17
|
],
|
|
18
18
|
"license": "MIT",
|
|
19
|
+
"author": "Claude Workspace",
|
|
19
20
|
"repository": {
|
|
20
21
|
"type": "git",
|
|
21
22
|
"url": "https://github.com/Claude-Workspace/claude-ws.git"
|
package/server.ts
CHANGED
|
@@ -26,12 +26,31 @@ const dev = process.env.NODE_ENV !== 'production';
|
|
|
26
26
|
const hostname = 'localhost';
|
|
27
27
|
const port = parseInt(process.env.PORT || '8556', 10);
|
|
28
28
|
|
|
29
|
+
// API authentication key (optional)
|
|
30
|
+
const API_ACCESS_KEY = process.env.API_ACCESS_KEY;
|
|
31
|
+
|
|
29
32
|
const app = next({ dev, hostname, port, turbopack: false });
|
|
30
33
|
const handle = app.getRequestHandler();
|
|
31
34
|
|
|
32
35
|
app.prepare().then(async () => {
|
|
33
36
|
const httpServer = createServer((req, res) => {
|
|
34
37
|
const parsedUrl = parse(req.url!, true);
|
|
38
|
+
const pathname = parsedUrl.pathname || '';
|
|
39
|
+
|
|
40
|
+
// API authentication check
|
|
41
|
+
const isApiRoute = pathname.startsWith('/api/');
|
|
42
|
+
const isVerifyEndpoint = pathname === '/api/auth/verify';
|
|
43
|
+
|
|
44
|
+
if (isApiRoute && !isVerifyEndpoint && API_ACCESS_KEY && API_ACCESS_KEY.length > 0) {
|
|
45
|
+
const providedKey = req.headers['x-api-key'];
|
|
46
|
+
|
|
47
|
+
if (!providedKey || providedKey !== API_ACCESS_KEY) {
|
|
48
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
49
|
+
res.end(JSON.stringify({ error: 'Unauthorized', message: 'Valid API key required' }));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
35
54
|
handle(req, res, parsedUrl);
|
|
36
55
|
});
|
|
37
56
|
|
|
@@ -11,7 +11,7 @@ import { SettingsPage } from '@/components/settings/settings-page';
|
|
|
11
11
|
import { SetupDialog } from '@/components/settings/setup-dialog';
|
|
12
12
|
import { SidebarPanel, FileTabsPanel, DiffTabsPanel } from '@/components/sidebar';
|
|
13
13
|
import { RightSidebar } from '@/components/right-sidebar';
|
|
14
|
-
import { ApiKeyProvider
|
|
14
|
+
import { ApiKeyProvider } from '@/components/auth/api-key-dialog';
|
|
15
15
|
import { PluginList } from '@/components/agent-factory/plugin-list';
|
|
16
16
|
import { useProjectStore } from '@/stores/project-store';
|
|
17
17
|
import { useTaskStore } from '@/stores/task-store';
|
|
@@ -23,10 +23,8 @@ import { useSettingsUIStore } from '@/stores/settings-ui-store';
|
|
|
23
23
|
function KanbanApp() {
|
|
24
24
|
const [createTaskOpen, setCreateTaskOpen] = useState(false);
|
|
25
25
|
const [setupOpen, setSetupOpen] = useState(false);
|
|
26
|
-
const [apiKeyRefresh, setApiKeyRefresh] = useState(0);
|
|
27
26
|
const [searchQuery, setSearchQuery] = useState('');
|
|
28
27
|
|
|
29
|
-
const { needsApiKey } = useApiKeyCheck(apiKeyRefresh);
|
|
30
28
|
const { open: agentFactoryOpen, setOpen: setAgentFactoryOpen } = useAgentFactoryUIStore();
|
|
31
29
|
const { open: settingsOpen, setOpen: setSettingsOpen } = useSettingsUIStore();
|
|
32
30
|
|
|
@@ -228,19 +226,6 @@ function KanbanApp() {
|
|
|
228
226
|
onTaskCreated={handleTaskCreated}
|
|
229
227
|
/>
|
|
230
228
|
<SetupDialog open={setupOpen || autoShowSetup} onOpenChange={setSetupOpen} />
|
|
231
|
-
<ApiKeyDialog
|
|
232
|
-
open={needsApiKey}
|
|
233
|
-
onOpenChange={(open) => {
|
|
234
|
-
// Allow closing only if not needed
|
|
235
|
-
if (!open && !needsApiKey) return;
|
|
236
|
-
}}
|
|
237
|
-
onSuccess={() => {
|
|
238
|
-
// Trigger refresh to re-check API key status
|
|
239
|
-
setApiKeyRefresh(prev => prev + 1);
|
|
240
|
-
// Refetch projects after API key is set
|
|
241
|
-
fetchProjects();
|
|
242
|
-
}}
|
|
243
|
-
/>
|
|
244
229
|
|
|
245
230
|
{/* Agent Factory Dialog */}
|
|
246
231
|
{agentFactoryOpen && (
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from 'next/server';
|
|
2
2
|
import { db, schema } from '@/lib/db';
|
|
3
3
|
import { eq } from 'drizzle-orm';
|
|
4
|
+
import { rm } from 'fs/promises';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { UPLOADS_DIR } from '@/lib/file-utils';
|
|
4
7
|
import type { TaskStatus } from '@/types';
|
|
5
8
|
|
|
6
9
|
// GET /api/tasks/[id] - Get a single task
|
|
@@ -106,7 +109,7 @@ export async function PATCH(
|
|
|
106
109
|
return PUT(request, { params });
|
|
107
110
|
}
|
|
108
111
|
|
|
109
|
-
// DELETE /api/tasks/[id] - Delete a task
|
|
112
|
+
// DELETE /api/tasks/[id] - Delete a task and its uploaded files
|
|
110
113
|
export async function DELETE(
|
|
111
114
|
request: NextRequest,
|
|
112
115
|
{ params }: { params: Promise<{ id: string }> }
|
|
@@ -114,6 +117,22 @@ export async function DELETE(
|
|
|
114
117
|
try {
|
|
115
118
|
const { id } = await params;
|
|
116
119
|
|
|
120
|
+
// Query attempt IDs to clean up upload directories before DB cascade
|
|
121
|
+
const attempts = await db
|
|
122
|
+
.select({ id: schema.attempts.id })
|
|
123
|
+
.from(schema.attempts)
|
|
124
|
+
.where(eq(schema.attempts.taskId, id));
|
|
125
|
+
|
|
126
|
+
// Delete physical upload files for each attempt
|
|
127
|
+
for (const attempt of attempts) {
|
|
128
|
+
const attemptDir = join(UPLOADS_DIR, attempt.id);
|
|
129
|
+
try {
|
|
130
|
+
await rm(attemptDir, { recursive: true, force: true });
|
|
131
|
+
} catch {
|
|
132
|
+
// Directory may not exist if no files were uploaded
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
117
136
|
const result = await db
|
|
118
137
|
.delete(schema.tasks)
|
|
119
138
|
.where(eq(schema.tasks.id, id));
|
|
@@ -273,11 +273,38 @@ function headersToObject(headers: HeadersInit): Record<string, string> {
|
|
|
273
273
|
return headers as Record<string, string>;
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
+
// Event name for triggering API key dialog from fetch interceptor
|
|
277
|
+
const API_KEY_REQUIRED_EVENT = 'claude-kanban:api-key-required';
|
|
278
|
+
|
|
276
279
|
/**
|
|
277
|
-
*
|
|
280
|
+
* Dispatch event to trigger API key dialog
|
|
281
|
+
*/
|
|
282
|
+
function dispatchApiKeyRequired(): void {
|
|
283
|
+
if (typeof window !== 'undefined') {
|
|
284
|
+
window.dispatchEvent(new CustomEvent(API_KEY_REQUIRED_EVENT));
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Provider that patches global fetch to include API key and handle 401 responses
|
|
278
290
|
* Must be a client component and wrap the app
|
|
279
291
|
*/
|
|
280
292
|
export function ApiKeyProvider({ children }: { children: React.ReactNode }) {
|
|
293
|
+
const [showAuthDialog, setShowAuthDialog] = useState(false);
|
|
294
|
+
const [refreshCounter, setRefreshCounter] = useState(0);
|
|
295
|
+
|
|
296
|
+
// Listen for API key required events from fetch interceptor
|
|
297
|
+
useEffect(() => {
|
|
298
|
+
const handleApiKeyRequired = () => {
|
|
299
|
+
setShowAuthDialog(true);
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
window.addEventListener(API_KEY_REQUIRED_EVENT, handleApiKeyRequired);
|
|
303
|
+
return () => {
|
|
304
|
+
window.removeEventListener(API_KEY_REQUIRED_EVENT, handleApiKeyRequired);
|
|
305
|
+
};
|
|
306
|
+
}, []);
|
|
307
|
+
|
|
281
308
|
// Patch fetch immediately on render (synchronously)
|
|
282
309
|
// This ensures it's available before any useEffect in child components runs
|
|
283
310
|
if (typeof window !== 'undefined' && !window.fetch._apiKeyPatched) {
|
|
@@ -286,33 +313,64 @@ export function ApiKeyProvider({ children }: { children: React.ReactNode }) {
|
|
|
286
313
|
window.fetch = async (url, options) => {
|
|
287
314
|
const apiKey = getStoredApiKey();
|
|
288
315
|
|
|
289
|
-
//
|
|
290
|
-
if (!apiKey) {
|
|
291
|
-
return originalFetch(url, options);
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// Build new headers object with API key
|
|
316
|
+
// Build new headers object with API key if available
|
|
295
317
|
const existingHeaders = options?.headers
|
|
296
318
|
? headersToObject(options.headers)
|
|
297
319
|
: {};
|
|
298
320
|
|
|
299
321
|
const newHeaders: Record<string, string> = {
|
|
300
322
|
...existingHeaders,
|
|
301
|
-
'x-api-key': apiKey,
|
|
302
323
|
};
|
|
303
324
|
|
|
325
|
+
// Add API key if stored
|
|
326
|
+
if (apiKey) {
|
|
327
|
+
newHeaders['x-api-key'] = apiKey;
|
|
328
|
+
}
|
|
329
|
+
|
|
304
330
|
// Create new options with merged headers
|
|
305
331
|
const newOptions: RequestInit = {
|
|
306
332
|
...options,
|
|
307
333
|
headers: newHeaders,
|
|
308
334
|
};
|
|
309
335
|
|
|
310
|
-
|
|
336
|
+
const response = await originalFetch(url, newOptions);
|
|
337
|
+
|
|
338
|
+
// Check for 401 on API routes (except auth/verify endpoint)
|
|
339
|
+
const urlString = typeof url === 'string' ? url : url.toString();
|
|
340
|
+
const isApiRoute = urlString.includes('/api/');
|
|
341
|
+
const isVerifyEndpoint = urlString.includes('/api/auth/verify');
|
|
342
|
+
|
|
343
|
+
if (response.status === 401 && isApiRoute && !isVerifyEndpoint) {
|
|
344
|
+
// Clear stored key if it's invalid
|
|
345
|
+
if (apiKey) {
|
|
346
|
+
clearStoredApiKey();
|
|
347
|
+
}
|
|
348
|
+
// Trigger API key dialog
|
|
349
|
+
dispatchApiKeyRequired();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return response;
|
|
311
353
|
};
|
|
312
354
|
|
|
313
355
|
// Mark as patched to avoid double-patching
|
|
314
356
|
window.fetch._apiKeyPatched = true;
|
|
315
357
|
}
|
|
316
358
|
|
|
317
|
-
|
|
359
|
+
const handleAuthSuccess = () => {
|
|
360
|
+
setShowAuthDialog(false);
|
|
361
|
+
setRefreshCounter((prev) => prev + 1);
|
|
362
|
+
// Reload the page to refetch all data with new API key
|
|
363
|
+
window.location.reload();
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
return (
|
|
367
|
+
<>
|
|
368
|
+
{children}
|
|
369
|
+
<ApiKeyDialog
|
|
370
|
+
open={showAuthDialog}
|
|
371
|
+
onOpenChange={setShowAuthDialog}
|
|
372
|
+
onSuccess={handleAuthSuccess}
|
|
373
|
+
/>
|
|
374
|
+
</>
|
|
375
|
+
);
|
|
318
376
|
}
|
|
@@ -1,119 +1,227 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { memo } from 'react';
|
|
3
|
+
import { memo, useMemo } from 'react';
|
|
4
4
|
import ReactMarkdown from 'react-markdown';
|
|
5
5
|
import remarkGfm from 'remark-gfm';
|
|
6
6
|
import { cn } from '@/lib/utils';
|
|
7
7
|
import { CodeBlock } from '@/components/claude/code-block';
|
|
8
|
+
import { ExternalLink, FileText, Folder } from 'lucide-react';
|
|
8
9
|
|
|
9
10
|
interface MarkdownFileViewerProps {
|
|
10
11
|
content: string;
|
|
11
12
|
className?: string;
|
|
13
|
+
/** Current file path (used to resolve relative links) */
|
|
14
|
+
currentFilePath?: string;
|
|
15
|
+
/** Callback when a local file link is clicked */
|
|
16
|
+
onLocalFileClick?: (resolvedPath: string) => void;
|
|
12
17
|
}
|
|
13
18
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const isMultiLine = codeString.includes('\n');
|
|
54
|
-
if (!inline && (match || isMultiLine)) {
|
|
55
|
-
return <CodeBlock code={codeString} language={match?.[1]} />;
|
|
19
|
+
/**
|
|
20
|
+
* Check if a URL is an external/absolute URL (http, https, mailto, tel, etc.)
|
|
21
|
+
* Returns true for external links, false for local file references
|
|
22
|
+
*/
|
|
23
|
+
function isExternalUrl(url: string): boolean {
|
|
24
|
+
if (!url) return false;
|
|
25
|
+
// Match common external protocols
|
|
26
|
+
const externalProtocols = /^(https?:\/\/|mailto:|tel:|ftp:\/\/|file:\/\/|data:|javascript:|#)/i;
|
|
27
|
+
return externalProtocols.test(url);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve a relative path from the current file's directory
|
|
32
|
+
* @param currentFilePath - The path of the current markdown file
|
|
33
|
+
* @param relativePath - The relative path from the link
|
|
34
|
+
* @returns The resolved absolute path
|
|
35
|
+
*/
|
|
36
|
+
function resolveRelativePath(currentFilePath: string, relativePath: string): string {
|
|
37
|
+
// Get the directory of the current file
|
|
38
|
+
const lastSlashIndex = currentFilePath.lastIndexOf('/');
|
|
39
|
+
const currentDir = lastSlashIndex >= 0 ? currentFilePath.substring(0, lastSlashIndex) : '';
|
|
40
|
+
|
|
41
|
+
// Handle paths starting with ./
|
|
42
|
+
if (relativePath.startsWith('./')) {
|
|
43
|
+
relativePath = relativePath.substring(2);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Split paths into segments
|
|
47
|
+
const baseParts = currentDir.split('/').filter(Boolean);
|
|
48
|
+
const relativeParts = relativePath.split('/').filter(Boolean);
|
|
49
|
+
|
|
50
|
+
// Process each segment of the relative path
|
|
51
|
+
for (const part of relativeParts) {
|
|
52
|
+
if (part === '..') {
|
|
53
|
+
// Go up one directory
|
|
54
|
+
baseParts.pop();
|
|
55
|
+
} else if (part !== '.') {
|
|
56
|
+
// Add the segment
|
|
57
|
+
baseParts.push(part);
|
|
56
58
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return baseParts.join('/');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Create markdown components with access to file link handling
|
|
66
|
+
*/
|
|
67
|
+
function createMarkdownComponents(
|
|
68
|
+
currentFilePath: string | undefined,
|
|
69
|
+
onLocalFileClick: ((resolvedPath: string) => void) | undefined
|
|
70
|
+
) {
|
|
71
|
+
return {
|
|
72
|
+
h1: ({ children }: any) => (
|
|
73
|
+
<h1 className="text-2xl font-bold mt-8 mb-4 pb-2 border-b first:mt-0">{children}</h1>
|
|
74
|
+
),
|
|
75
|
+
h2: ({ children }: any) => (
|
|
76
|
+
<h2 className="text-xl font-semibold mt-6 mb-3 pb-1.5 border-b first:mt-0">{children}</h2>
|
|
77
|
+
),
|
|
78
|
+
h3: ({ children }: any) => (
|
|
79
|
+
<h3 className="text-lg font-semibold mt-5 mb-2 first:mt-0">{children}</h3>
|
|
80
|
+
),
|
|
81
|
+
h4: ({ children }: any) => (
|
|
82
|
+
<h4 className="text-base font-semibold mt-4 mb-2 first:mt-0">{children}</h4>
|
|
83
|
+
),
|
|
84
|
+
p: ({ children }: any) => (
|
|
85
|
+
<p className="mb-4 last:mb-0 leading-7">{children}</p>
|
|
86
|
+
),
|
|
87
|
+
ul: ({ children }: any) => (
|
|
88
|
+
<ul className="list-disc list-outside ml-6 mb-4 space-y-1.5">{children}</ul>
|
|
89
|
+
),
|
|
90
|
+
ol: ({ children }: any) => (
|
|
91
|
+
<ol className="list-decimal list-outside ml-6 mb-4 space-y-1.5">{children}</ol>
|
|
92
|
+
),
|
|
93
|
+
li: ({ children }: any) => (
|
|
94
|
+
<li className="leading-7">{children}</li>
|
|
95
|
+
),
|
|
96
|
+
code({ inline, className, children, ...props }: any) {
|
|
97
|
+
const match = /language-(\w+)/.exec(className || '');
|
|
98
|
+
let codeString = '';
|
|
99
|
+
if (Array.isArray(children)) {
|
|
100
|
+
codeString = children.map(child => (typeof child === 'string' ? child : '')).join('');
|
|
101
|
+
} else if (typeof children === 'string') {
|
|
102
|
+
codeString = children;
|
|
103
|
+
} else if (children && typeof children === 'object' && 'props' in children) {
|
|
104
|
+
codeString = String(children.props?.children || '');
|
|
105
|
+
} else {
|
|
106
|
+
codeString = String(children || '');
|
|
107
|
+
}
|
|
108
|
+
codeString = codeString.replace(/\n$/, '');
|
|
109
|
+
const isMultiLine = codeString.includes('\n');
|
|
110
|
+
if (!inline && (match || isMultiLine)) {
|
|
111
|
+
return <CodeBlock code={codeString} language={match?.[1]} />;
|
|
112
|
+
}
|
|
113
|
+
return (
|
|
114
|
+
<code className="px-1.5 py-0.5 bg-muted rounded text-sm font-mono" {...props}>
|
|
115
|
+
{children}
|
|
116
|
+
</code>
|
|
117
|
+
);
|
|
118
|
+
},
|
|
119
|
+
pre: ({ children }: any) => (
|
|
120
|
+
<div className="my-4 w-full max-w-full overflow-x-auto">{children}</div>
|
|
121
|
+
),
|
|
122
|
+
strong: ({ children }: any) => (
|
|
123
|
+
<strong className="font-semibold">{children}</strong>
|
|
124
|
+
),
|
|
125
|
+
em: ({ children }: any) => (
|
|
126
|
+
<em className="italic">{children}</em>
|
|
127
|
+
),
|
|
128
|
+
// Link component with local file handling
|
|
129
|
+
a: ({ href, children }: any) => {
|
|
130
|
+
const hrefString = href || '';
|
|
131
|
+
|
|
132
|
+
// External links: open in new tab
|
|
133
|
+
if (isExternalUrl(hrefString)) {
|
|
134
|
+
return (
|
|
135
|
+
<a
|
|
136
|
+
href={hrefString}
|
|
137
|
+
className="text-primary underline hover:no-underline inline-flex items-center gap-1"
|
|
138
|
+
target="_blank"
|
|
139
|
+
rel="noopener noreferrer"
|
|
140
|
+
>
|
|
141
|
+
{children}
|
|
142
|
+
<ExternalLink className="size-3 opacity-50" />
|
|
143
|
+
</a>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Determine if path looks like a folder (ends with / or no extension)
|
|
148
|
+
const hasExtension = hrefString.includes('.') && !hrefString.endsWith('/');
|
|
149
|
+
const looksLikeFolder = hrefString.endsWith('/') || !hasExtension;
|
|
150
|
+
const Icon = looksLikeFolder ? Folder : FileText;
|
|
151
|
+
|
|
152
|
+
// Local file links: handle with callback or show as non-interactive
|
|
153
|
+
if (currentFilePath && onLocalFileClick) {
|
|
154
|
+
const resolvedPath = resolveRelativePath(currentFilePath, hrefString);
|
|
155
|
+
return (
|
|
156
|
+
<button
|
|
157
|
+
type="button"
|
|
158
|
+
onClick={() => onLocalFileClick(resolvedPath)}
|
|
159
|
+
className="text-primary underline hover:no-underline cursor-pointer inline-flex items-center gap-1 bg-transparent border-none p-0 font-inherit text-inherit"
|
|
160
|
+
title={looksLikeFolder ? `Navigate to ${resolvedPath}` : `Open ${resolvedPath}`}
|
|
161
|
+
>
|
|
162
|
+
<Icon className="size-3 opacity-50" />
|
|
163
|
+
{children}
|
|
164
|
+
</button>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Fallback: render as plain text with appropriate icon (no handler available)
|
|
169
|
+
return (
|
|
170
|
+
<span className="text-muted-foreground inline-flex items-center gap-1" title={hrefString}>
|
|
171
|
+
<Icon className="size-3 opacity-50" />
|
|
172
|
+
{children}
|
|
173
|
+
</span>
|
|
174
|
+
);
|
|
175
|
+
},
|
|
176
|
+
blockquote: ({ children }: any) => (
|
|
177
|
+
<blockquote className="border-l-4 border-muted-foreground/30 pl-4 my-4 text-muted-foreground italic">
|
|
59
178
|
{children}
|
|
60
|
-
</
|
|
61
|
-
)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
{children}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
),
|
|
93
|
-
td: ({ children }: any) => (
|
|
94
|
-
<td className="border border-border px-3 py-2">{children}</td>
|
|
95
|
-
),
|
|
96
|
-
hr: () => <hr className="my-6 border-border" />,
|
|
97
|
-
img: ({ src, alt }: any) => (
|
|
98
|
-
<img src={src} alt={alt || ''} className="max-w-full h-auto my-4 rounded-lg" />
|
|
99
|
-
),
|
|
100
|
-
// Task list items (GFM)
|
|
101
|
-
input: ({ checked, ...props }: any) => (
|
|
102
|
-
<input
|
|
103
|
-
type="checkbox"
|
|
104
|
-
checked={checked}
|
|
105
|
-
disabled
|
|
106
|
-
className="mr-2 pointer-events-none"
|
|
107
|
-
{...props}
|
|
108
|
-
/>
|
|
109
|
-
),
|
|
110
|
-
};
|
|
179
|
+
</blockquote>
|
|
180
|
+
),
|
|
181
|
+
table: ({ children }: any) => (
|
|
182
|
+
<div className="overflow-x-auto my-4">
|
|
183
|
+
<table className="min-w-full text-sm border-collapse border border-border">{children}</table>
|
|
184
|
+
</div>
|
|
185
|
+
),
|
|
186
|
+
thead: ({ children }: any) => (
|
|
187
|
+
<thead className="bg-muted">{children}</thead>
|
|
188
|
+
),
|
|
189
|
+
th: ({ children }: any) => (
|
|
190
|
+
<th className="border border-border px-3 py-2 font-medium text-left">{children}</th>
|
|
191
|
+
),
|
|
192
|
+
td: ({ children }: any) => (
|
|
193
|
+
<td className="border border-border px-3 py-2">{children}</td>
|
|
194
|
+
),
|
|
195
|
+
hr: () => <hr className="my-6 border-border" />,
|
|
196
|
+
img: ({ src, alt }: any) => (
|
|
197
|
+
<img src={src} alt={alt || ''} className="max-w-full h-auto my-4 rounded-lg" />
|
|
198
|
+
),
|
|
199
|
+
// Task list items (GFM)
|
|
200
|
+
input: ({ checked, ...props }: any) => (
|
|
201
|
+
<input
|
|
202
|
+
type="checkbox"
|
|
203
|
+
checked={checked}
|
|
204
|
+
disabled
|
|
205
|
+
className="mr-2 pointer-events-none"
|
|
206
|
+
{...props}
|
|
207
|
+
/>
|
|
208
|
+
),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
111
211
|
|
|
112
212
|
// Memoized markdown viewer for file content
|
|
113
213
|
export const MarkdownFileViewer = memo(function MarkdownFileViewer({
|
|
114
214
|
content,
|
|
115
|
-
className
|
|
215
|
+
className,
|
|
216
|
+
currentFilePath,
|
|
217
|
+
onLocalFileClick
|
|
116
218
|
}: MarkdownFileViewerProps) {
|
|
219
|
+
// Memoize markdown components to avoid recreating on every render
|
|
220
|
+
const markdownComponents = useMemo(
|
|
221
|
+
() => createMarkdownComponents(currentFilePath, onLocalFileClick),
|
|
222
|
+
[currentFilePath, onLocalFileClick]
|
|
223
|
+
);
|
|
224
|
+
|
|
117
225
|
return (
|
|
118
226
|
<div className={cn('h-full overflow-auto', className)}>
|
|
119
227
|
<div className="max-w-4xl mx-auto px-6 py-8 prose-sm">
|
|
@@ -184,7 +184,7 @@ export function CreateTaskDialog({ open, onOpenChange, onTaskCreated }: CreateTa
|
|
|
184
184
|
|
|
185
185
|
return (
|
|
186
186
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
187
|
-
<DialogContent className="sm:max-w-[500px] overflow-
|
|
187
|
+
<DialogContent className="sm:max-w-[500px] overflow-visible" onKeyDown={handleKeyDown}>
|
|
188
188
|
<DialogHeader>
|
|
189
189
|
<DialogTitle>{t('createNewTask')}</DialogTitle>
|
|
190
190
|
<DialogDescription>
|
|
@@ -2,19 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect } from 'react';
|
|
4
4
|
import { useTheme } from 'next-themes';
|
|
5
|
-
import { Plus, Settings, Package, X, Sun, Moon } from 'lucide-react';
|
|
5
|
+
import { Plus, Settings, Package, X, Sun, Moon, LogOut } from 'lucide-react';
|
|
6
6
|
import { Button } from '@/components/ui/button';
|
|
7
7
|
import { cn } from '@/lib/utils';
|
|
8
8
|
import { useRightSidebarStore } from '@/stores/right-sidebar-store';
|
|
9
9
|
import { useAgentFactoryUIStore } from '@/stores/agent-factory-ui-store';
|
|
10
10
|
import { useSettingsUIStore } from '@/stores/settings-ui-store';
|
|
11
11
|
import { LanguageSwitcher } from '@/components/ui/language-switcher';
|
|
12
|
+
import { clearStoredApiKey } from '@/components/auth/api-key-dialog';
|
|
12
13
|
import {
|
|
13
14
|
Tooltip,
|
|
14
15
|
TooltipContent,
|
|
15
16
|
TooltipProvider,
|
|
16
17
|
TooltipTrigger,
|
|
17
18
|
} from '@/components/ui/tooltip';
|
|
19
|
+
import {
|
|
20
|
+
Dialog,
|
|
21
|
+
DialogContent,
|
|
22
|
+
DialogDescription,
|
|
23
|
+
DialogFooter,
|
|
24
|
+
DialogHeader,
|
|
25
|
+
DialogTitle,
|
|
26
|
+
} from '@/components/ui/dialog';
|
|
18
27
|
import { useTranslations } from 'next-intl';
|
|
19
28
|
|
|
20
29
|
interface RightSidebarProps {
|
|
@@ -30,6 +39,7 @@ export function RightSidebar({ projectId, onCreateTask, className }: RightSideba
|
|
|
30
39
|
const { setOpen: setSettingsOpen } = useSettingsUIStore();
|
|
31
40
|
const { theme, setTheme, resolvedTheme } = useTheme();
|
|
32
41
|
const [mounted, setMounted] = useState(false);
|
|
42
|
+
const [showLogoutConfirm, setShowLogoutConfirm] = useState(false);
|
|
33
43
|
|
|
34
44
|
// Prevent hydration mismatch
|
|
35
45
|
useEffect(() => {
|
|
@@ -40,6 +50,20 @@ export function RightSidebar({ projectId, onCreateTask, className }: RightSideba
|
|
|
40
50
|
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
|
|
41
51
|
};
|
|
42
52
|
|
|
53
|
+
const handleLogout = () => {
|
|
54
|
+
clearStoredApiKey();
|
|
55
|
+
window.location.reload();
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const handleLogoutClick = () => {
|
|
59
|
+
setShowLogoutConfirm(true);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const handleLogoutConfirm = () => {
|
|
63
|
+
setShowLogoutConfirm(false);
|
|
64
|
+
handleLogout();
|
|
65
|
+
};
|
|
66
|
+
|
|
43
67
|
if (!isOpen) return null;
|
|
44
68
|
|
|
45
69
|
return (
|
|
@@ -136,7 +160,39 @@ export function RightSidebar({ projectId, onCreateTask, className }: RightSideba
|
|
|
136
160
|
<div className="pl-6">
|
|
137
161
|
<LanguageSwitcher />
|
|
138
162
|
</div>
|
|
163
|
+
|
|
164
|
+
{/* Logout button - under language switcher */}
|
|
165
|
+
<div className="pl-6">
|
|
166
|
+
<Button
|
|
167
|
+
variant="outline"
|
|
168
|
+
onClick={handleLogoutClick}
|
|
169
|
+
className="w-full justify-start gap-2 text-destructive hover:text-destructive"
|
|
170
|
+
>
|
|
171
|
+
<LogOut className="h-4 w-4" />
|
|
172
|
+
{t('logout')}
|
|
173
|
+
</Button>
|
|
174
|
+
</div>
|
|
139
175
|
</div>
|
|
176
|
+
|
|
177
|
+
{/* Logout Confirmation Dialog */}
|
|
178
|
+
<Dialog open={showLogoutConfirm} onOpenChange={setShowLogoutConfirm}>
|
|
179
|
+
<DialogContent>
|
|
180
|
+
<DialogHeader>
|
|
181
|
+
<DialogTitle>{t('logoutConfirmTitle')}</DialogTitle>
|
|
182
|
+
<DialogDescription>
|
|
183
|
+
{t('logoutConfirmMessage')}
|
|
184
|
+
</DialogDescription>
|
|
185
|
+
</DialogHeader>
|
|
186
|
+
<DialogFooter>
|
|
187
|
+
<Button variant="outline" onClick={() => setShowLogoutConfirm(false)}>
|
|
188
|
+
{t('cancel')}
|
|
189
|
+
</Button>
|
|
190
|
+
<Button variant="destructive" onClick={handleLogoutConfirm}>
|
|
191
|
+
{t('logout')}
|
|
192
|
+
</Button>
|
|
193
|
+
</DialogFooter>
|
|
194
|
+
</DialogContent>
|
|
195
|
+
</Dialog>
|
|
140
196
|
</>
|
|
141
197
|
);
|
|
142
198
|
}
|
|
@@ -11,6 +11,7 @@ import { useActiveProject } from '@/hooks/use-active-project';
|
|
|
11
11
|
import { useTaskStore } from '@/stores/task-store';
|
|
12
12
|
import { useContextMentionStore } from '@/stores/context-mention-store';
|
|
13
13
|
import { useProjectStore } from '@/stores/project-store';
|
|
14
|
+
import { waitForElement } from '@/lib/utils';
|
|
14
15
|
|
|
15
16
|
interface FileContent {
|
|
16
17
|
content: string | null;
|
|
@@ -285,7 +286,7 @@ export function FileTabContent({ tabId, filePath }: FileTabContentProps) {
|
|
|
285
286
|
if (matchPositions[nextMatch - 1]) {
|
|
286
287
|
setEditorPosition(matchPositions[nextMatch - 1]);
|
|
287
288
|
}
|
|
288
|
-
|
|
289
|
+
requestAnimationFrame(() => searchInputRef.current?.focus());
|
|
289
290
|
}, [searchQuery, totalMatches, currentMatch, matchPositions]);
|
|
290
291
|
|
|
291
292
|
const handlePrevMatch = useCallback(() => {
|
|
@@ -295,7 +296,7 @@ export function FileTabContent({ tabId, filePath }: FileTabContentProps) {
|
|
|
295
296
|
if (matchPositions[prevMatch - 1]) {
|
|
296
297
|
setEditorPosition(matchPositions[prevMatch - 1]);
|
|
297
298
|
}
|
|
298
|
-
|
|
299
|
+
requestAnimationFrame(() => searchInputRef.current?.focus());
|
|
299
300
|
}, [searchQuery, totalMatches, currentMatch, matchPositions]);
|
|
300
301
|
|
|
301
302
|
const closeSearch = useCallback(() => {
|
|
@@ -685,7 +686,75 @@ export function FileTabContent({ tabId, filePath }: FileTabContentProps) {
|
|
|
685
686
|
<span className="text-xs mt-1">{formatFileSize(content.size)}</span>
|
|
686
687
|
</div>
|
|
687
688
|
) : isMarkdownFile && viewMode === 'preview' ? (
|
|
688
|
-
<MarkdownFileViewer
|
|
689
|
+
<MarkdownFileViewer
|
|
690
|
+
content={editedContent}
|
|
691
|
+
className="h-full"
|
|
692
|
+
currentFilePath={filePath}
|
|
693
|
+
onLocalFileClick={async (resolvedPath) => {
|
|
694
|
+
const store = useSidebarStore.getState();
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Helper to navigate file explorer to a folder path
|
|
698
|
+
* Opens sidebar, switches to files tab, expands folders, sets selection
|
|
699
|
+
*/
|
|
700
|
+
const navigateToFolder = async (folderPath: string) => {
|
|
701
|
+
// 1. Open sidebar if not open
|
|
702
|
+
if (!store.isOpen) {
|
|
703
|
+
store.setIsOpen(true);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// 2. Switch to files tab
|
|
707
|
+
store.setActiveTab('files');
|
|
708
|
+
|
|
709
|
+
// 3. Expand all parent folders and target folder
|
|
710
|
+
const pathParts = folderPath.replace(/\/$/, '').split('/').filter(Boolean);
|
|
711
|
+
let currentPath = '';
|
|
712
|
+
for (const part of pathParts) {
|
|
713
|
+
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
|
714
|
+
store.expandFolder(currentPath);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// 4. Set selected file to highlight and trigger scroll
|
|
718
|
+
store.setSelectedFile(folderPath.replace(/\/$/, ''));
|
|
719
|
+
|
|
720
|
+
// 5. Wait for element to appear in DOM, then scroll into view
|
|
721
|
+
const element = await waitForElement(`[data-path="${folderPath.replace(/\/$/, '')}"]`);
|
|
722
|
+
element?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
// Check if path looks like a folder (ends with / or no extension)
|
|
726
|
+
const hasExtension = resolvedPath.includes('.') && !resolvedPath.endsWith('/');
|
|
727
|
+
const looksLikeFolder = resolvedPath.endsWith('/') || !hasExtension;
|
|
728
|
+
|
|
729
|
+
if (looksLikeFolder) {
|
|
730
|
+
navigateToFolder(resolvedPath);
|
|
731
|
+
} else {
|
|
732
|
+
// Try to open as a file
|
|
733
|
+
try {
|
|
734
|
+
const res = await fetch(
|
|
735
|
+
`/api/files/content?basePath=${encodeURIComponent(activeProject?.path || '')}&path=${encodeURIComponent(resolvedPath)}`
|
|
736
|
+
);
|
|
737
|
+
|
|
738
|
+
if (res.ok) {
|
|
739
|
+
// It's a valid file - open it in a new tab
|
|
740
|
+
store.openTab(resolvedPath);
|
|
741
|
+
} else {
|
|
742
|
+
const data = await res.json();
|
|
743
|
+
if (data.error === 'Path is not a file' || res.status === 400) {
|
|
744
|
+
// It's a directory - navigate to it
|
|
745
|
+
navigateToFolder(resolvedPath);
|
|
746
|
+
} else {
|
|
747
|
+
// Other error - try to open anyway
|
|
748
|
+
store.openTab(resolvedPath);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
} catch {
|
|
752
|
+
// On error, just try to open the tab
|
|
753
|
+
store.openTab(resolvedPath);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}}
|
|
757
|
+
/>
|
|
689
758
|
) : (
|
|
690
759
|
<CodeEditorWithInlineEdit
|
|
691
760
|
value={editedContent}
|
|
@@ -177,6 +177,7 @@ export function FileTreeItem({
|
|
|
177
177
|
return (
|
|
178
178
|
<DropdownMenu open={contextMenuOpen} onOpenChange={setContextMenuOpen}>
|
|
179
179
|
<div
|
|
180
|
+
data-path={entry.path}
|
|
180
181
|
className={cn(
|
|
181
182
|
'flex items-center gap-1 py-1 px-2 cursor-pointer rounded-sm text-sm relative group',
|
|
182
183
|
// Hover: only apply when not selected
|
|
@@ -29,15 +29,18 @@ export class SessionManager {
|
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
31
|
* Get the last session ID for a task (for resume)
|
|
32
|
-
* Returns sessions from completed
|
|
33
|
-
*
|
|
34
|
-
*
|
|
32
|
+
* Returns sessions from completed, cancelled, OR failed attempts
|
|
33
|
+
* Session IDs are captured early in the SDK stream (from system message),
|
|
34
|
+
* so even failed attempts typically have valid sessions.
|
|
35
|
+
* Including failed attempts preserves conversation context on retry —
|
|
36
|
+
* without this, retrying after an API error (400/429/500) would start
|
|
37
|
+
* a fresh session and Claude would lose all prior context.
|
|
35
38
|
*/
|
|
36
39
|
async getLastSessionId(taskId: string): Promise<string | null> {
|
|
37
40
|
const lastResumableAttempt = await db.query.attempts.findFirst({
|
|
38
41
|
where: and(
|
|
39
42
|
eq(schema.attempts.taskId, taskId),
|
|
40
|
-
inArray(schema.attempts.status, ['completed', 'cancelled'])
|
|
43
|
+
inArray(schema.attempts.status, ['completed', 'cancelled', 'failed'])
|
|
41
44
|
),
|
|
42
45
|
orderBy: [desc(schema.attempts.createdAt)],
|
|
43
46
|
});
|
package/src/lib/utils.ts
CHANGED
|
@@ -4,3 +4,42 @@ import { twMerge } from "tailwind-merge"
|
|
|
4
4
|
export function cn(...inputs: ClassValue[]) {
|
|
5
5
|
return twMerge(clsx(inputs))
|
|
6
6
|
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Wait for an element to appear in the DOM using MutationObserver.
|
|
10
|
+
* More reliable than setTimeout for waiting on dynamic content.
|
|
11
|
+
* @param selector - CSS selector for the target element
|
|
12
|
+
* @param timeout - Max wait time in ms (default: 5000)
|
|
13
|
+
* @returns Promise resolving to the element, or null if timeout
|
|
14
|
+
*/
|
|
15
|
+
export function waitForElement<T extends Element = Element>(
|
|
16
|
+
selector: string,
|
|
17
|
+
timeout = 5000
|
|
18
|
+
): Promise<T | null> {
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
// Check if element already exists
|
|
21
|
+
const existing = document.querySelector<T>(selector);
|
|
22
|
+
if (existing) {
|
|
23
|
+
return resolve(existing);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const observer = new MutationObserver(() => {
|
|
27
|
+
const element = document.querySelector<T>(selector);
|
|
28
|
+
if (element) {
|
|
29
|
+
observer.disconnect();
|
|
30
|
+
resolve(element);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
observer.observe(document.body, {
|
|
35
|
+
childList: true,
|
|
36
|
+
subtree: true,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Timeout fallback
|
|
40
|
+
setTimeout(() => {
|
|
41
|
+
observer.disconnect();
|
|
42
|
+
resolve(null);
|
|
43
|
+
}, timeout);
|
|
44
|
+
});
|
|
45
|
+
}
|