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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-ws",
3
- "version": "0.3.0",
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, ApiKeyDialog, useApiKeyCheck } from '@/components/auth/api-key-dialog';
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
- * Provider that patches global fetch to include API key
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
- // If no API key stored, just pass through
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
- return originalFetch(url, newOptions);
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
- return <>{children}</>;
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
- // Markdown rendering components optimized for file viewing
15
- const markdownComponents = {
16
- h1: ({ children }: any) => (
17
- <h1 className="text-2xl font-bold mt-8 mb-4 pb-2 border-b first:mt-0">{children}</h1>
18
- ),
19
- h2: ({ children }: any) => (
20
- <h2 className="text-xl font-semibold mt-6 mb-3 pb-1.5 border-b first:mt-0">{children}</h2>
21
- ),
22
- h3: ({ children }: any) => (
23
- <h3 className="text-lg font-semibold mt-5 mb-2 first:mt-0">{children}</h3>
24
- ),
25
- h4: ({ children }: any) => (
26
- <h4 className="text-base font-semibold mt-4 mb-2 first:mt-0">{children}</h4>
27
- ),
28
- p: ({ children }: any) => (
29
- <p className="mb-4 last:mb-0 leading-7">{children}</p>
30
- ),
31
- ul: ({ children }: any) => (
32
- <ul className="list-disc list-outside ml-6 mb-4 space-y-1.5">{children}</ul>
33
- ),
34
- ol: ({ children }: any) => (
35
- <ol className="list-decimal list-outside ml-6 mb-4 space-y-1.5">{children}</ol>
36
- ),
37
- li: ({ children }: any) => (
38
- <li className="leading-7">{children}</li>
39
- ),
40
- code({ inline, className, children, ...props }: any) {
41
- const match = /language-(\w+)/.exec(className || '');
42
- let codeString = '';
43
- if (Array.isArray(children)) {
44
- codeString = children.map(child => (typeof child === 'string' ? child : '')).join('');
45
- } else if (typeof children === 'string') {
46
- codeString = children;
47
- } else if (children && typeof children === 'object' && 'props' in children) {
48
- codeString = String(children.props?.children || '');
49
- } else {
50
- codeString = String(children || '');
51
- }
52
- codeString = codeString.replace(/\n$/, '');
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
- return (
58
- <code className="px-1.5 py-0.5 bg-muted rounded text-sm font-mono" {...props}>
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
- </code>
61
- );
62
- },
63
- pre: ({ children }: any) => (
64
- <div className="my-4 w-full max-w-full overflow-x-auto">{children}</div>
65
- ),
66
- strong: ({ children }: any) => (
67
- <strong className="font-semibold">{children}</strong>
68
- ),
69
- em: ({ children }: any) => (
70
- <em className="italic">{children}</em>
71
- ),
72
- a: ({ href, children }: any) => (
73
- <a href={href} className="text-primary underline hover:no-underline" target="_blank" rel="noopener noreferrer">
74
- {children}
75
- </a>
76
- ),
77
- blockquote: ({ children }: any) => (
78
- <blockquote className="border-l-4 border-muted-foreground/30 pl-4 my-4 text-muted-foreground italic">
79
- {children}
80
- </blockquote>
81
- ),
82
- table: ({ children }: any) => (
83
- <div className="overflow-x-auto my-4">
84
- <table className="min-w-full text-sm border-collapse border border-border">{children}</table>
85
- </div>
86
- ),
87
- thead: ({ children }: any) => (
88
- <thead className="bg-muted">{children}</thead>
89
- ),
90
- th: ({ children }: any) => (
91
- <th className="border border-border px-3 py-2 font-medium text-left">{children}</th>
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-hidden" onKeyDown={handleKeyDown}>
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
- setTimeout(() => searchInputRef.current?.focus(), 0);
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
- setTimeout(() => searchInputRef.current?.focus(), 0);
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 content={editedContent} className="h-full" />
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 or cancelled attempts
33
- * Cancelled attempts have valid sessions - user just stopped the work
34
- * Only 'failed' attempts (errors) may have corrupted sessions
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
+ }