claude-ws 0.1.1 → 0.1.3

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.config.ts CHANGED
@@ -8,6 +8,11 @@ const nextConfig: NextConfig = {
8
8
  hostname: 'cdn.jsdelivr.net',
9
9
  pathname: '/npm/vscode-icons-js@*/**',
10
10
  },
11
+ {
12
+ protocol: 'https',
13
+ hostname: 'raw.githubusercontent.com',
14
+ pathname: '/vscode-icons/vscode-icons/**',
15
+ },
11
16
  ],
12
17
  },
13
18
  };
package/package.json CHANGED
@@ -1,9 +1,18 @@
1
1
  {
2
2
  "name": "claude-ws",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "private": false,
5
5
  "description": "A beautifully crafted workspace interface for Claude Code with real-time streaming and local SQLite database",
6
- "keywords": ["claude", "claude-code", "workspace", "task-management", "ai", "anthropic", "nextjs", "sqlite"],
6
+ "keywords": [
7
+ "claude",
8
+ "claude-code",
9
+ "workspace",
10
+ "task-management",
11
+ "ai",
12
+ "anthropic",
13
+ "nextjs",
14
+ "sqlite"
15
+ ],
7
16
  "license": "MIT",
8
17
  "repository": {
9
18
  "type": "git",
@@ -48,13 +57,15 @@
48
57
  "scripts": {
49
58
  "dev": "tsx server.ts",
50
59
  "dev:next": "next dev",
51
- "build": "next build",
60
+ "build": "NODE_ENV=production next build",
52
61
  "start": "NODE_ENV=production tsx server.ts",
53
62
  "lint": "eslint",
54
63
  "db:generate": "drizzle-kit generate",
55
64
  "db:migrate": "drizzle-kit migrate",
56
- "prepublish:build": "pnpm run build",
57
- "publish:npm": "pnpm run prepublish:build && npm publish --access public"
65
+ "version:patch": "npm version patch --no-git-tag-version",
66
+ "version:minor": "npm version minor --no-git-tag-version",
67
+ "version:major": "npm version major --no-git-tag-version",
68
+ "publish:npm": "pnpm run build && npm publish --access public"
58
69
  },
59
70
  "dependencies": {
60
71
  "@anthropic-ai/claude-agent-sdk": "^0.2.5",
@@ -115,6 +126,7 @@
115
126
  "tailwind-merge": "^3.4.0",
116
127
  "tar": "^7.5.2",
117
128
  "tsx": "^4.21.0",
129
+ "vscode-icons-js": "^11.6.1",
118
130
  "zustand": "^5.0.9"
119
131
  },
120
132
  "devDependencies": {
@@ -132,4 +144,4 @@
132
144
  "typescript": "^5"
133
145
  },
134
146
  "packageManager": "pnpm@10.28.0+sha512.05df71d1421f21399e053fde567cea34d446fa02c76571441bfc1c7956e98e363088982d940465fd34480d4d90a0668bc12362f8aa88000a64e83d0b0e47be48"
135
- }
147
+ }
@@ -25,12 +25,26 @@ export async function GET(
25
25
  let totalDeletions = 0;
26
26
  let filesChanged = 0;
27
27
 
28
- // Context usage: Use LATEST attempt only (most recent)
29
- // Each attempt is a separate session - context doesn't accumulate across attempts
28
+ // Context usage: Use LATEST attempt with actual context data
29
+ // When a new turn starts, the latest attempt may have 0 context (not yet updated)
30
+ // In that case, fall back to the previous completed attempt's context
30
31
  const latestAttempt = attempts[0]; // Already ordered by createdAt DESC
31
- const contextUsed = latestAttempt?.contextUsed || 0;
32
- const contextLimit = latestAttempt?.contextLimit || 200000;
33
- const contextPercentage = latestAttempt?.contextPercentage || 0;
32
+
33
+ // If latest attempt is running and has no context data yet, use previous attempt's context
34
+ let contextUsed = latestAttempt?.contextUsed || 0;
35
+ let contextLimit = latestAttempt?.contextLimit || 200000;
36
+ let contextPercentage = latestAttempt?.contextPercentage || 0;
37
+
38
+ // Fallback to previous attempt if current is running with no context data
39
+ if (latestAttempt?.status === 'running' && contextPercentage === 0 && attempts.length > 1) {
40
+ const previousAttempt = attempts[1];
41
+ if (previousAttempt?.contextPercentage && previousAttempt.contextPercentage > 0) {
42
+ contextUsed = previousAttempt.contextUsed || 0;
43
+ contextLimit = previousAttempt.contextLimit || 200000;
44
+ contextPercentage = previousAttempt.contextPercentage;
45
+ console.log(`[Stats] Using previous attempt context: ${contextPercentage}% (current attempt is running with no data)`);
46
+ }
47
+ }
34
48
 
35
49
  // Calculate context health metrics (ClaudeKit formulas)
36
50
  // Note: We approximate input/output split since DB only stores total contextUsed
@@ -303,11 +303,40 @@
303
303
  @layer base {
304
304
  * {
305
305
  @apply border-border outline-ring/50;
306
+ box-sizing: border-box;
306
307
  }
307
308
 
308
309
  body {
309
310
  @apply bg-background text-foreground;
310
311
  }
312
+
313
+ /* Prevent horizontal scrolling on mobile */
314
+ html, body {
315
+ overflow-x: hidden;
316
+ max-width: 100vw;
317
+ }
318
+
319
+ /* Prevent iOS Safari zoom on input focus (inputs smaller than 16px trigger zoom) */
320
+ @media screen and (max-width: 767px) {
321
+ input, textarea, select {
322
+ font-size: 16px !important;
323
+ }
324
+
325
+ /* Ensure all fixed elements respect viewport bounds */
326
+ .fixed {
327
+ max-width: 100vw !important;
328
+ overflow-x: hidden !important;
329
+ }
330
+
331
+ /* Force Radix ScrollArea to respect viewport width on mobile */
332
+ [data-slot="scroll-area"],
333
+ [data-slot="scroll-area-viewport"],
334
+ [data-slot="scroll-area-viewport"] > div {
335
+ max-width: 100vw !important;
336
+ width: 100% !important;
337
+ overflow-x: hidden !important;
338
+ }
339
+ }
311
340
  }
312
341
 
313
342
  /* Custom scrollbar styling - minimal and modern */
@@ -390,21 +419,21 @@
390
419
  animation: spin-border 2s linear infinite;
391
420
  }
392
421
 
393
- /* Green glow animation for processing spinner */
394
- @keyframes glow-green {
422
+ /* Warm glow animation for processing spinner */
423
+ @keyframes glow-warm {
395
424
 
396
425
  0%,
397
426
  100% {
398
- filter: drop-shadow(0 0 2px #22c55e) drop-shadow(0 0 4px #22c55e);
427
+ filter: drop-shadow(0 0 2px #b9664a) drop-shadow(0 0 4px #b9664a);
399
428
  }
400
429
 
401
430
  50% {
402
- filter: drop-shadow(0 0 4px #22c55e) drop-shadow(0 0 12px #22c55e) drop-shadow(0 0 16px #22c55e);
431
+ filter: drop-shadow(0 0 4px #b9664a) drop-shadow(0 0 12px #b9664a) drop-shadow(0 0 16px #b9664a);
403
432
  }
404
433
  }
405
434
 
406
- .animate-glow-green {
407
- animation: glow-green 1.5s ease-in-out infinite;
435
+ .animate-glow-warm {
436
+ animation: glow-warm 2s ease-in-out infinite;
408
437
  }
409
438
 
410
439
  /* Inline Edit Diff Styling */
@@ -1,4 +1,4 @@
1
- import type { Metadata } from 'next';
1
+ import type { Metadata, Viewport } from 'next';
2
2
  import { Geist, Geist_Mono } from 'next/font/google';
3
3
  import { Toaster } from 'sonner';
4
4
  import { ThemeProvider } from '@/components/providers/theme-provider';
@@ -15,14 +15,17 @@ const geistMono = Geist_Mono({
15
15
  subsets: ['latin'],
16
16
  });
17
17
 
18
+ export const viewport: Viewport = {
19
+ width: 'device-width',
20
+ initialScale: 1,
21
+ maximumScale: 1,
22
+ userScalable: false,
23
+ interactiveWidget: 'resizes-content',
24
+ };
25
+
18
26
  export const metadata: Metadata = {
19
27
  title: 'Claude Workspace',
20
28
  description: 'Workspace powered by Claude Code CLI',
21
- viewport: {
22
- width: 'device-width',
23
- initialScale: 1,
24
- interactiveWidget: 'resizes-content',
25
- },
26
29
  icons: {
27
30
  icon: '/favicon.ico',
28
31
  apple: '/logo.png',
@@ -21,9 +21,9 @@ export function CodeBlock({ code, language, className }: CodeBlockProps) {
21
21
  };
22
22
 
23
23
  return (
24
- <div className={cn('relative group rounded-md overflow-hidden border border-border', className)}>
24
+ <div className={cn('relative group rounded-md overflow-hidden border border-border w-full max-w-full', className)}>
25
25
  {/* Header with language label and copy button */}
26
- <div className="flex items-center justify-between px-3 py-1.5 bg-muted/50 border-b border-border">
26
+ <div className="flex items-center justify-between px-3 py-1.5 bg-muted/50 border-b border-border w-full">
27
27
  <span className="text-xs font-mono text-muted-foreground">
28
28
  {language || 'text'}
29
29
  </span>
@@ -31,7 +31,7 @@ export function CodeBlock({ code, language, className }: CodeBlockProps) {
31
31
  variant="ghost"
32
32
  size="icon-sm"
33
33
  onClick={handleCopy}
34
- className="size-6 opacity-0 group-hover:opacity-100 transition-opacity"
34
+ className="size-6 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
35
35
  >
36
36
  {copied ? (
37
37
  <Check className="size-3" />
@@ -40,7 +40,7 @@ export function CodeBlock({ code, language, className }: CodeBlockProps) {
40
40
  )}
41
41
  </Button>
42
42
  </div>
43
- <pre className="p-3 bg-muted/30 text-[13px] leading-relaxed whitespace-pre-wrap break-words font-mono">
43
+ <pre className="p-3 bg-muted/30 text-[13px] leading-relaxed whitespace-pre-wrap break-words font-mono w-full max-w-full overflow-x-auto">
44
44
  <code className={language ? `language-${language}` : ''}>{code}</code>
45
45
  </pre>
46
46
  </div>
@@ -118,11 +118,11 @@ export function DiffView({ oldText, newText, filePath, className }: DiffViewProp
118
118
  }, [diffLines]);
119
119
 
120
120
  return (
121
- <div className={cn('rounded-md border border-border overflow-hidden text-xs font-mono', className)}>
121
+ <div className={cn('rounded-md border border-border overflow-hidden text-xs font-mono w-full max-w-full', className)}>
122
122
  {/* Header */}
123
- <div className="flex items-center justify-between px-3 py-1.5 bg-muted/50 border-b border-border">
124
- <span className="text-muted-foreground truncate">{filePath || 'changes'}</span>
125
- <div className="flex items-center gap-2 text-[11px]">
123
+ <div className="flex items-center justify-between px-3 py-1.5 bg-muted/50 border-b border-border w-full">
124
+ <span className="text-muted-foreground truncate min-w-0 flex-1">{filePath || 'changes'}</span>
125
+ <div className="flex items-center gap-2 text-[11px] shrink-0">
126
126
  {stats.added > 0 && (
127
127
  <span className="text-green-600 dark:text-green-400">+{stats.added}</span>
128
128
  )}
@@ -133,7 +133,7 @@ export function DiffView({ oldText, newText, filePath, className }: DiffViewProp
133
133
  </div>
134
134
 
135
135
  {/* Diff content */}
136
- <div className="overflow-x-auto max-h-64">
136
+ <div className="overflow-x-auto max-h-64 w-full max-w-full">
137
137
  <table className="w-full border-collapse">
138
138
  <tbody>
139
139
  {diffLines.map((line, idx) => (
@@ -163,7 +163,7 @@ export function DiffView({ oldText, newText, filePath, className }: DiffViewProp
163
163
 
164
164
  {/* Content */}
165
165
  <td className={cn(
166
- 'px-2 py-0 whitespace-pre',
166
+ 'px-2 py-0 whitespace-pre-wrap break-all',
167
167
  line.type === 'added' && 'text-green-700 dark:text-green-300',
168
168
  line.type === 'removed' && 'text-red-700 dark:text-red-300'
169
169
  )}>
@@ -85,8 +85,8 @@ export function MessageBlock({ content, isThinking = false, isStreaming = false,
85
85
  ) : (
86
86
  <ChevronRight className="size-3" />
87
87
  )}
88
- <RunningDots className="text-primary" />
89
- <span className="font-mono text-[14px]">Thinking...</span>
88
+ <RunningDots />
89
+ <span className="font-mono text-[14px]" style={{ color: '#b9664a' }}>Thinking...</span>
90
90
  </button>
91
91
 
92
92
  {isExpanded && (
@@ -99,7 +99,7 @@ export function MessageBlock({ content, isThinking = false, isStreaming = false,
99
99
  }
100
100
 
101
101
  return (
102
- <div className={cn('text-[15px] leading-7 max-w-full overflow-hidden', className)}>
102
+ <div className={cn('text-[15px] leading-7 max-w-full w-full overflow-hidden', className)}>
103
103
  <MarkdownContent content={displayContent} />
104
104
  </div>
105
105
  );
@@ -176,7 +176,7 @@ function MarkdownContent({ content }: { content: string }) {
176
176
  },
177
177
  // Pre blocks
178
178
  pre: ({ children }) => (
179
- <div className="my-2 overflow-x-auto">{children}</div>
179
+ <div className="my-2 w-full max-w-full overflow-x-auto">{children}</div>
180
180
  ),
181
181
  // Strong/Bold
182
182
  strong: ({ children }) => (
@@ -14,10 +14,49 @@ interface ResponseRendererProps {
14
14
 
15
15
  export function ResponseRenderer({ messages, className }: ResponseRendererProps) {
16
16
  const bottomRef = useRef<HTMLDivElement>(null);
17
+ const scrollAreaRef = useRef<HTMLDivElement>(null);
18
+ const userScrollingRef = useRef(false);
19
+ const userScrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
20
+
21
+ // Check if user is near bottom
22
+ const isNearBottom = () => {
23
+ const viewport = scrollAreaRef.current?.querySelector('[data-slot="scroll-area-viewport"]');
24
+ if (!viewport) return true;
25
+ const threshold = 150;
26
+ return viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < threshold;
27
+ };
17
28
 
18
- // Auto-scroll to bottom on new messages
29
+ // Detect user scroll to pause auto-scroll
30
+ useEffect(() => {
31
+ const viewport = scrollAreaRef.current?.querySelector('[data-slot="scroll-area-viewport"]');
32
+ if (!viewport) return;
33
+
34
+ const handleScroll = () => {
35
+ userScrollingRef.current = true;
36
+ if (userScrollTimeoutRef.current) {
37
+ clearTimeout(userScrollTimeoutRef.current);
38
+ }
39
+ userScrollTimeoutRef.current = setTimeout(() => {
40
+ if (isNearBottom()) {
41
+ userScrollingRef.current = false;
42
+ }
43
+ }, 150);
44
+ };
45
+
46
+ viewport.addEventListener('scroll', handleScroll, { passive: true });
47
+ return () => {
48
+ viewport.removeEventListener('scroll', handleScroll);
49
+ if (userScrollTimeoutRef.current) {
50
+ clearTimeout(userScrollTimeoutRef.current);
51
+ }
52
+ };
53
+ }, []);
54
+
55
+ // Auto-scroll to bottom on new messages (only if not manually scrolling)
19
56
  useEffect(() => {
20
- bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
57
+ if (!userScrollingRef.current && isNearBottom()) {
58
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
59
+ }
21
60
  }, [messages]);
22
61
 
23
62
  const renderContentBlock = (block: ClaudeContentBlock, index: number) => {
@@ -103,7 +142,7 @@ export function ResponseRenderer({ messages, className }: ResponseRendererProps)
103
142
  }
104
143
 
105
144
  return (
106
- <ScrollArea className={cn('h-full', className)}>
145
+ <ScrollArea ref={scrollAreaRef} className={cn('h-full', className)}>
107
146
  <div className="space-y-4 p-4">
108
147
  {messages.map((message, index) => renderMessage(message, index))}
109
148
  <div ref={bottomRef} />
@@ -161,17 +161,17 @@ function BashBlock({ command, output, isError }: { command: string; output?: str
161
161
  const outputLines = output?.split('\n').length || 0;
162
162
 
163
163
  return (
164
- <div className="rounded-md border border-border overflow-hidden text-xs font-mono">
164
+ <div className="rounded-md border border-border overflow-hidden text-xs font-mono w-full max-w-full">
165
165
  {/* Command header */}
166
166
  <div
167
167
  className={cn(
168
- 'flex items-center gap-2 px-3 py-2 bg-zinc-900 dark:bg-zinc-950',
168
+ 'flex items-center gap-2 px-3 py-2 bg-zinc-900 dark:bg-zinc-950 w-full max-w-full',
169
169
  hasOutput && 'cursor-pointer hover:bg-zinc-800 dark:hover:bg-zinc-900'
170
170
  )}
171
171
  onClick={() => hasOutput && setIsExpanded(!isExpanded)}
172
172
  >
173
173
  <Terminal className="size-3.5 text-zinc-400 shrink-0" />
174
- <code className="text-zinc-100 flex-1 truncate">{command}</code>
174
+ <code className="text-zinc-100 flex-1 truncate min-w-0">{command}</code>
175
175
  <div className="flex items-center gap-1">
176
176
  <Button
177
177
  variant="ghost"
@@ -249,11 +249,11 @@ export function ToolUseBlock({ name, input, result, isError, isStreaming, classN
249
249
  const isCompleted = !isStreaming && result && !isError;
250
250
 
251
251
  return (
252
- <div className={cn('group max-w-full overflow-hidden my-2', className)}>
252
+ <div className={cn('group w-full max-w-full overflow-hidden my-2', className)}>
253
253
  {/* Main status line */}
254
254
  <div
255
255
  className={cn(
256
- 'flex items-start gap-2.5 py-1.5 px-2 rounded-md transition-colors min-w-0 border border-transparent',
256
+ 'flex items-start gap-2.5 py-1.5 px-2 rounded-md transition-colors min-w-0 w-full max-w-full border border-transparent',
257
257
  isStreaming ? 'text-foreground bg-accent/30 border-accent/20' : 'text-muted-foreground hover:bg-accent/20',
258
258
  hasOtherDetails && 'cursor-pointer'
259
259
  )}
@@ -272,9 +272,9 @@ export function ToolUseBlock({ name, input, result, isError, isStreaming, classN
272
272
 
273
273
  {/* Streaming spinner or icon */}
274
274
  {isStreaming ? (
275
- <RunningDots className="shrink-0 text-green-500 dark:text-green-400 mt-1 animate-glow-green" />
275
+ <RunningDots className="shrink-0" />
276
276
  ) : isCompleted ? null : (
277
- <Icon className={cn('size-4 shrink-0 mt-1', isError && 'text-destructive')} />
277
+ <Icon className={cn('size-4 shrink-0', isError && 'text-destructive')} />
278
278
  )}
279
279
 
280
280
  {/* Tool name and target - allow wrapping */}
@@ -310,7 +310,7 @@ export function ToolUseBlock({ name, input, result, isError, isStreaming, classN
310
310
 
311
311
  {/* Special view for Bash */}
312
312
  {isBash && Boolean(inputObj?.command) && (
313
- <div className="mt-1.5 ml-5">
313
+ <div className="mt-1.5 ml-5 w-full max-w-full overflow-hidden pr-5">
314
314
  <BashBlock
315
315
  command={String(inputObj?.command)}
316
316
  output={result}
@@ -321,14 +321,14 @@ export function ToolUseBlock({ name, input, result, isError, isStreaming, classN
321
321
 
322
322
  {/* Special view for Edit with diff */}
323
323
  {hasEditDiff && (
324
- <div className="mt-1.5 ml-5">
324
+ <div className="mt-1.5 ml-5 w-full max-w-full overflow-hidden pr-5">
325
325
  <EditBlock input={inputObj} result={result} isError={isError} />
326
326
  </div>
327
327
  )}
328
328
 
329
329
  {/* Standard expandable details for other tools */}
330
330
  {isExpanded && hasOtherDetails && (
331
- <div className="ml-5 mt-1 pl-4 border-l border-border/50 text-[13px] text-muted-foreground space-y-2 max-w-full overflow-hidden">
331
+ <div className="ml-5 mt-1 pl-4 border-l border-border/50 text-[13px] text-muted-foreground space-y-2 w-full max-w-full overflow-hidden pr-5">
332
332
  {inputObj && Object.keys(inputObj).length > 1 && (
333
333
  <pre className="font-mono bg-muted/30 p-2 rounded overflow-x-auto max-h-32 whitespace-pre-wrap break-all">
334
334
  {JSON.stringify(inputObj, null, 2)}
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useMemo, useState } from 'react';
3
+ import { useEffect, useMemo, useRef, useState, useTransition } from 'react';
4
4
  import {
5
5
  DndContext,
6
6
  DragEndEvent,
@@ -24,8 +24,22 @@ interface BoardProps {
24
24
  }
25
25
 
26
26
  export function Board({ attempts = [], onCreateTask }: BoardProps) {
27
- const { tasks, reorderTasks } = useTaskStore();
27
+ const { tasks, reorderTasks, selectTask, setPendingAutoStartTask } = useTaskStore();
28
28
  const [activeTask, setActiveTask] = useState<Task | null>(null);
29
+ const [, startTransition] = useTransition();
30
+ const lastReorderRef = useRef<string>('');
31
+ const [pendingNewTaskStart, setPendingNewTaskStart] = useState<{ taskId: string; description: string } | null>(null);
32
+
33
+ // Handle auto-start for newly created tasks moved to In Progress
34
+ useEffect(() => {
35
+ if (pendingNewTaskStart) {
36
+ const { taskId, description } = pendingNewTaskStart;
37
+ // Select the task and trigger auto-start
38
+ selectTask(taskId);
39
+ setPendingAutoStartTask(taskId, description);
40
+ setPendingNewTaskStart(null);
41
+ }
42
+ }, [pendingNewTaskStart, selectTask, setPendingAutoStartTask]);
29
43
 
30
44
  const sensors = useSensors(
31
45
  useSensor(PointerSensor, {
@@ -94,39 +108,11 @@ export function Board({ attempts = [], onCreateTask }: BoardProps) {
94
108
  // Check if dropping over a column
95
109
  const overColumn = KANBAN_COLUMNS.find((col) => col.id === overId);
96
110
  if (overColumn) {
97
- // Moving to a different column
98
- if (activeTask.status !== overColumn.id) {
99
- const targetTasks = tasksByStatus.get(overColumn.id) || [];
100
- reorderTasks(activeTask.id, overColumn.id, targetTasks.length);
101
- }
102
- } else {
103
- // Dropping over another task
104
- const overTask = tasks.find((t) => t.id === overId);
105
- if (overTask) {
106
- const targetColumn = overTask.status;
107
- const columnTasks = tasksByStatus.get(targetColumn) || [];
108
-
109
- // Find current position in the active task's current column
110
- const oldIndex = columnTasks.findIndex((t) => t.id === activeId);
111
-
112
- // Find position in target column
113
- const newIndex = columnTasks.findIndex((t) => t.id === overId);
114
-
115
- // If moving to different column or reordering within same column
116
- if (activeTask.status !== targetColumn || oldIndex !== newIndex) {
117
- // Handle the move in the target column
118
- if (activeTask.status !== targetColumn) {
119
- // Moving to different column - place at the position of overTask
120
- reorderTasks(activeTask.id, targetColumn, newIndex);
121
- } else if (oldIndex !== -1 && newIndex !== -1) {
122
- // Reordering within same column
123
- const reordered = arrayMove(columnTasks, oldIndex, newIndex);
124
- const newPosition = reordered.findIndex((t) => t.id === activeId);
125
- reorderTasks(activeTask.id, activeTask.status, newPosition);
126
- }
127
- }
128
- }
111
+ // Moving to a different column - don't reorder during drag, just for visual
112
+ // The actual reorder happens in handleDragEnd
113
+ return;
129
114
  }
115
+ // Don't do anything during dragOver - let handleDragEnd handle the reordering
130
116
  };
131
117
 
132
118
  const handleDragEnd = (event: DragEndEvent) => {
@@ -143,41 +129,70 @@ export function Board({ attempts = [], onCreateTask }: BoardProps) {
143
129
  const activeTask = tasks.find((t) => t.id === activeId);
144
130
  if (!activeTask) return;
145
131
 
146
- // Check if dropping over a column
147
- const overColumn = KANBAN_COLUMNS.find((col) => col.id === overId);
148
- if (overColumn) {
149
- if (activeTask.status !== overColumn.id) {
150
- const targetTasks = tasksByStatus.get(overColumn.id) || [];
151
- reorderTasks(activeTask.id, overColumn.id, targetTasks.length);
152
- }
153
- } else {
154
- // Dropping over another task
155
- const overTask = tasks.find((t) => t.id === overId);
156
- if (overTask) {
157
- const targetColumn = overTask.status;
158
- const columnTasks = tasksByStatus.get(targetColumn) || [];
159
-
160
- // Find current position in the active task's current column
161
- const oldIndex = columnTasks.findIndex((t) => t.id === activeId);
162
-
163
- // Find position in target column
164
- const newIndex = columnTasks.findIndex((t) => t.id === overId);
165
-
166
- // If moving to different column or reordering within same column
167
- if (activeTask.status !== targetColumn || oldIndex !== newIndex) {
168
- // Handle the move in the target column
169
- if (activeTask.status !== targetColumn) {
170
- // Moving to different column - place at the position of overTask
171
- reorderTasks(activeTask.id, targetColumn, newIndex);
172
- } else if (oldIndex !== -1 && newIndex !== -1) {
173
- // Reordering within same column
174
- const reordered = arrayMove(columnTasks, oldIndex, newIndex);
175
- const newPosition = reordered.findIndex((t) => t.id === activeId);
176
- reorderTasks(activeTask.id, activeTask.status, newPosition);
132
+ // Skip if we just processed this exact same reorder
133
+ if (lastReorderRef.current === `${activeId}-${overId}`) {
134
+ return;
135
+ }
136
+
137
+ // Mark this reorder as in-progress
138
+ lastReorderRef.current = `${activeId}-${overId}`;
139
+
140
+ // Check if this is a newly created task moving to In Progress
141
+ const isNewTaskToInProgress = !activeTask.chatInit && activeTask.status === 'todo';
142
+
143
+ // Wrap in startTransition to avoid blocking the UI during reordering
144
+ startTransition(async () => {
145
+ // Check if dropping over a column
146
+ const overColumn = KANBAN_COLUMNS.find((col) => col.id === overId);
147
+ if (overColumn) {
148
+ if (activeTask.status !== overColumn.id) {
149
+ const targetTasks = tasksByStatus.get(overColumn.id) || [];
150
+ await reorderTasks(activeTask.id, overColumn.id, targetTasks.length);
151
+
152
+ // If this is a newly created task moving to In Progress, trigger auto-start
153
+ if (isNewTaskToInProgress && overColumn.id === 'in_progress' && activeTask.description) {
154
+ setPendingNewTaskStart({ taskId: activeTask.id, description: activeTask.description });
155
+ }
156
+ }
157
+ } else {
158
+ // Dropping over another task
159
+ const overTask = tasks.find((t) => t.id === overId);
160
+ if (overTask) {
161
+ const targetColumn = overTask.status;
162
+ const columnTasks = tasksByStatus.get(targetColumn) || [];
163
+
164
+ // Find current position in the active task's current column
165
+ const oldIndex = columnTasks.findIndex((t) => t.id === activeId);
166
+
167
+ // Find position in target column
168
+ const newIndex = columnTasks.findIndex((t) => t.id === overId);
169
+
170
+ // If moving to different column or reordering within same column
171
+ if (activeTask.status !== targetColumn || oldIndex !== newIndex) {
172
+ // Handle the move in the target column
173
+ if (activeTask.status !== targetColumn) {
174
+ // Moving to different column - place at the position of overTask
175
+ await reorderTasks(activeTask.id, targetColumn, newIndex);
176
+
177
+ // If this is a newly created task moving to In Progress, trigger auto-start
178
+ if (isNewTaskToInProgress && targetColumn === 'in_progress' && activeTask.description) {
179
+ setPendingNewTaskStart({ taskId: activeTask.id, description: activeTask.description });
180
+ }
181
+ } else if (oldIndex !== -1 && newIndex !== -1) {
182
+ // Reordering within same column
183
+ const reordered = arrayMove(columnTasks, oldIndex, newIndex);
184
+ const newPosition = reordered.findIndex((t) => t.id === activeId);
185
+ await reorderTasks(activeTask.id, activeTask.status, newPosition);
186
+ }
177
187
  }
178
188
  }
179
189
  }
180
- }
190
+
191
+ // Reset the ref after a short delay to allow for rapid reordering of different tasks
192
+ setTimeout(() => {
193
+ lastReorderRef.current = '';
194
+ }, 100);
195
+ });
181
196
  };
182
197
 
183
198
  const handleDragCancel = () => {
@@ -20,7 +20,8 @@ export function SocketProvider({ children }: { children: React.ReactNode }) {
20
20
 
21
21
  socketInstance.on('connect', () => {
22
22
  console.log('[SocketProvider] Connected:', socketInstance.id);
23
- setSocket(socketInstance);
23
+ // Defer setSocket to avoid setState during render
24
+ Promise.resolve().then(() => setSocket(socketInstance));
24
25
  });
25
26
 
26
27
  socketInstance.on('disconnect', () => {