claudeship 0.2.19 → 0.2.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/apps/server/package.json +1 -1
  2. package/apps/web/.next/BUILD_ID +1 -1
  3. package/apps/web/.next/app-build-manifest.json +3 -3
  4. package/apps/web/.next/build-manifest.json +2 -2
  5. package/apps/web/.next/cache/.previewinfo +1 -1
  6. package/apps/web/.next/cache/.rscinfo +1 -1
  7. package/apps/web/.next/cache/.tsbuildinfo +1 -1
  8. package/apps/web/.next/cache/config.json +3 -3
  9. package/apps/web/.next/cache/eslint/.cache_j3uhuz +1 -1
  10. package/apps/web/.next/cache/webpack/client-production/0.pack +0 -0
  11. package/apps/web/.next/cache/webpack/client-production/index.pack +0 -0
  12. package/apps/web/.next/cache/webpack/edge-server-production/index.pack +0 -0
  13. package/apps/web/.next/cache/webpack/server-production/0.pack +0 -0
  14. package/apps/web/.next/cache/webpack/server-production/index.pack +0 -0
  15. package/apps/web/.next/prerender-manifest.json +10 -10
  16. package/apps/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  17. package/apps/web/.next/server/app/_not-found.html +1 -1
  18. package/apps/web/.next/server/app/_not-found.rsc +2 -2
  19. package/apps/web/.next/server/app/index.html +1 -1
  20. package/apps/web/.next/server/app/index.rsc +2 -2
  21. package/apps/web/.next/server/app/page_client-reference-manifest.js +1 -1
  22. package/apps/web/.next/server/app/project/[id]/page.js +1 -1
  23. package/apps/web/.next/server/app/project/[id]/page_client-reference-manifest.js +1 -1
  24. package/apps/web/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  25. package/apps/web/.next/server/app/settings.html +1 -1
  26. package/apps/web/.next/server/app/settings.rsc +2 -2
  27. package/apps/web/.next/server/pages/404.html +1 -1
  28. package/apps/web/.next/server/pages/500.html +1 -1
  29. package/apps/web/.next/server/server-reference-manifest.json +1 -1
  30. package/apps/web/.next/static/chunks/87-e65fb39b36fc5ac8.js +1 -0
  31. package/apps/web/.next/static/chunks/app/project/[id]/page-3d9d2622b2801ab0.js +1 -0
  32. package/apps/web/.next/static/css/b92103813bcb2a3c.css +3 -0
  33. package/apps/web/.next/trace +18 -18
  34. package/apps/web/package.json +1 -1
  35. package/apps/web/src/components/chat/MessageInput.tsx +5 -8
  36. package/apps/web/src/components/chat/QueuePreview.tsx +98 -0
  37. package/apps/web/src/components/chat/StreamingMessage.tsx +126 -20
  38. package/apps/web/src/stores/useChatStore.ts +26 -6
  39. package/package.json +1 -1
  40. package/apps/web/.next/static/chunks/574-1fe2bcd6cfb41646.js +0 -1
  41. package/apps/web/.next/static/chunks/app/project/[id]/page-c28098a9b8a94336.js +0 -1
  42. package/apps/web/.next/static/css/0a24552d9794f8c8.css +0 -3
  43. /package/apps/web/.next/static/{oNlRdQOvyo3lMU4vZQSEf → 91tvQbwE6MrVEkEolpLDW}/_buildManifest.js +0 -0
  44. /package/apps/web/.next/static/{oNlRdQOvyo3lMU4vZQSEf → 91tvQbwE6MrVEkEolpLDW}/_ssgManifest.js +0 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claudeship/web",
3
- "version": "0.2.19",
3
+ "version": "0.2.20",
4
4
  "private": true,
5
5
  "scripts": {
6
6
  "dev": "node scripts/dev-with-recovery.mjs",
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
6
6
  import { useTranslation } from "@/lib/i18n";
7
7
  import { ModeToggle } from "./ModeToggle";
8
8
  import { FilePreview } from "./FilePreview";
9
+ import { QueuePreview } from "./QueuePreview";
9
10
  import { useChatStore } from "@/stores/useChatStore";
10
11
 
11
12
  interface MessageInputProps {
@@ -34,7 +35,7 @@ const MAX_FILES = 5;
34
35
 
35
36
  export function MessageInput({ onSend, projectId, disabled, isStreaming, queueCount = 0 }: MessageInputProps) {
36
37
  const { t } = useTranslation();
37
- const { mode, attachedFiles, addFiles, removeFile, uploadFiles, isUploading } = useChatStore();
38
+ const { mode, attachedFiles, addFiles, removeFile, uploadFiles, isUploading, messageQueue, deleteFromQueue } = useChatStore();
38
39
  const [content, setContent] = useState("");
39
40
  const [isDragging, setIsDragging] = useState(false);
40
41
  const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -173,7 +174,7 @@ export function MessageInput({ onSend, projectId, disabled, isStreaming, queueCo
173
174
  </Button>
174
175
  </div>
175
176
  {(isStreaming || isUploading) && (
176
- <div className="px-4 pb-4 flex items-center gap-2 text-xs text-muted-foreground">
177
+ <div className="px-4 pb-2 flex items-center gap-2 text-xs text-muted-foreground">
177
178
  {isUploading ? (
178
179
  <span className="flex items-center gap-1">
179
180
  <span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500" />
@@ -185,14 +186,10 @@ export function MessageInput({ onSend, projectId, disabled, isStreaming, queueCo
185
186
  {t("chat.thinking")}
186
187
  </span>
187
188
  )}
188
- {queueCount > 0 && (
189
- <span className="flex items-center gap-1 text-blue-500">
190
- <Clock className="h-3 w-3" />
191
- {t("chat.queueCount", { count: queueCount })}
192
- </span>
193
- )}
194
189
  </div>
195
190
  )}
191
+ {/* Queue Preview */}
192
+ <QueuePreview items={messageQueue} onDelete={deleteFromQueue} />
196
193
  </div>
197
194
  );
198
195
  }
@@ -0,0 +1,98 @@
1
+ "use client";
2
+
3
+ import { ChevronDown, ChevronUp, Trash2, Play } from "lucide-react";
4
+ import { useState } from "react";
5
+ import { Button } from "@/components/ui/button";
6
+ import { useChatStore, type QueuedMessage } from "@/stores/useChatStore";
7
+
8
+ interface QueuePreviewProps {
9
+ items: QueuedMessage[];
10
+ onDelete?: (id: string) => void;
11
+ }
12
+
13
+ export function QueuePreview({ items, onDelete }: QueuePreviewProps) {
14
+ const [isExpanded, setIsExpanded] = useState(true);
15
+
16
+ if (items.length === 0) return null;
17
+
18
+ return (
19
+ <div className="border-t bg-muted/30">
20
+ {/* Header */}
21
+ <button
22
+ onClick={() => setIsExpanded(!isExpanded)}
23
+ className="flex items-center justify-between w-full px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
24
+ >
25
+ <div className="flex items-center gap-2">
26
+ {isExpanded ? (
27
+ <ChevronUp className="h-4 w-4" />
28
+ ) : (
29
+ <ChevronDown className="h-4 w-4" />
30
+ )}
31
+ <span>Queue ({items.length})</span>
32
+ </div>
33
+ </button>
34
+
35
+ {/* Queue Items */}
36
+ {isExpanded && (
37
+ <div className="px-4 pb-3 space-y-2">
38
+ {items.map((item, index) => (
39
+ <QueueItem
40
+ key={item.id}
41
+ item={item}
42
+ isNext={index === 0}
43
+ onDelete={onDelete}
44
+ />
45
+ ))}
46
+ </div>
47
+ )}
48
+ </div>
49
+ );
50
+ }
51
+
52
+ interface QueueItemProps {
53
+ item: QueuedMessage;
54
+ isNext: boolean;
55
+ onDelete?: (id: string) => void;
56
+ }
57
+
58
+ function QueueItem({ item, isNext, onDelete }: QueueItemProps) {
59
+ const isProcessing = item.status === "processing";
60
+
61
+ return (
62
+ <div
63
+ className={`relative flex items-start gap-2 p-3 rounded-md border ${
64
+ isProcessing
65
+ ? "bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800"
66
+ : "bg-background border-border"
67
+ }`}
68
+ >
69
+ <div className="flex-1 min-w-0">
70
+ <p className="text-sm line-clamp-2 break-words">
71
+ {item.content}
72
+ </p>
73
+ </div>
74
+ <div className="flex items-center gap-1 shrink-0">
75
+ {isNext && !isProcessing && (
76
+ <span className="text-xs font-medium text-blue-600 dark:text-blue-400 px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900 rounded">
77
+ Next
78
+ </span>
79
+ )}
80
+ {isProcessing && (
81
+ <span className="text-xs font-medium text-blue-600 dark:text-blue-400 px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900 rounded animate-pulse">
82
+ Processing
83
+ </span>
84
+ )}
85
+ {onDelete && !isProcessing && (
86
+ <Button
87
+ variant="ghost"
88
+ size="icon"
89
+ className="h-6 w-6 text-muted-foreground hover:text-destructive"
90
+ onClick={() => onDelete(item.id)}
91
+ >
92
+ <Trash2 className="h-3 w-3" />
93
+ </Button>
94
+ )}
95
+ </div>
96
+ </div>
97
+ );
98
+ }
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { useState } from "react";
3
4
  import { useChatStore, type StreamingBlock } from "@/stores/useChatStore";
4
5
  import {
5
6
  FileText,
@@ -12,6 +13,8 @@ import {
12
13
  Loader2,
13
14
  ListTodo,
14
15
  Bot,
16
+ ChevronDown,
17
+ ChevronUp,
15
18
  } from "lucide-react";
16
19
  import { MarkdownRenderer } from "./MarkdownRenderer";
17
20
  import { AskUserQuestionBlock } from "./AskUserQuestionBlock";
@@ -22,6 +25,9 @@ interface StreamingMessageProps {
22
25
  projectId: string;
23
26
  }
24
27
 
28
+ const COLLAPSE_THRESHOLD = 5; // Number of tool blocks before collapsing
29
+ const VISIBLE_WHEN_COLLAPSED = 2; // Number of items to show at start and end when collapsed
30
+
25
31
  const toolIcons: Record<string, React.ReactNode> = {
26
32
  Read: <FileText className="h-4 w-4" />,
27
33
  Glob: <FolderSearch className="h-4 w-4" />,
@@ -89,6 +95,13 @@ function TextBlock({ content }: { content: string }) {
89
95
  return <MarkdownRenderer content={content} />;
90
96
  }
91
97
 
98
+ function formatDuration(ms: number): string {
99
+ if (ms < 1000) {
100
+ return `${ms}ms`;
101
+ }
102
+ return `${(ms / 1000).toFixed(1)}s`;
103
+ }
104
+
92
105
  function ToolUseBlock({ block }: { block: StreamingBlock }) {
93
106
  const isRunning = block.status === "running";
94
107
  const toolName = block.tool?.name || "Unknown";
@@ -113,17 +126,106 @@ function ToolUseBlock({ block }: { block: StreamingBlock }) {
113
126
  {getToolDisplayName(toolName)}
114
127
  </span>
115
128
  {getToolDescription(block) && (
116
- <span className={`truncate ${isRunning ? "text-blue-600 dark:text-blue-400" : "text-muted-foreground"}`}>
129
+ <span className={`truncate flex-1 ${isRunning ? "text-blue-600 dark:text-blue-400" : "text-muted-foreground"}`}>
117
130
  {getToolDescription(block)}
118
131
  </span>
119
132
  )}
133
+ {block.duration !== undefined && (
134
+ <span className="text-xs text-muted-foreground ml-auto tabular-nums">
135
+ ({formatDuration(block.duration)})
136
+ </span>
137
+ )}
120
138
  </div>
121
139
  );
122
140
  }
123
141
 
142
+ interface ToolBlockGroupProps {
143
+ blocks: StreamingBlock[];
144
+ }
145
+
146
+ function ToolBlockGroup({ blocks }: ToolBlockGroupProps) {
147
+ const [isExpanded, setIsExpanded] = useState(false);
148
+ const shouldCollapse = blocks.length > COLLAPSE_THRESHOLD;
149
+ const hiddenCount = blocks.length - (VISIBLE_WHEN_COLLAPSED * 2);
150
+
151
+ if (!shouldCollapse || isExpanded) {
152
+ return (
153
+ <div className="space-y-1">
154
+ {blocks.map((block) => (
155
+ <ToolUseBlock key={block.id} block={block} />
156
+ ))}
157
+ {shouldCollapse && isExpanded && (
158
+ <button
159
+ onClick={() => setIsExpanded(false)}
160
+ className="flex items-center gap-1 px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
161
+ >
162
+ <ChevronUp className="h-4 w-4" />
163
+ <span>접기</span>
164
+ </button>
165
+ )}
166
+ </div>
167
+ );
168
+ }
169
+
170
+ // Show first few, collapse button, then last few
171
+ const firstBlocks = blocks.slice(0, VISIBLE_WHEN_COLLAPSED);
172
+ const lastBlocks = blocks.slice(-VISIBLE_WHEN_COLLAPSED);
173
+
174
+ return (
175
+ <div className="space-y-1">
176
+ {firstBlocks.map((block) => (
177
+ <ToolUseBlock key={block.id} block={block} />
178
+ ))}
179
+ <button
180
+ onClick={() => setIsExpanded(true)}
181
+ className="flex items-center gap-1 px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors w-full justify-center border border-dashed border-border rounded-md hover:bg-muted/50"
182
+ >
183
+ <ChevronDown className="h-4 w-4" />
184
+ <span>{hiddenCount}개 더 보기</span>
185
+ </button>
186
+ {lastBlocks.map((block) => (
187
+ <ToolUseBlock key={block.id} block={block} />
188
+ ))}
189
+ </div>
190
+ );
191
+ }
192
+
193
+ // Group consecutive blocks by type
194
+ interface BlockGroup {
195
+ type: "tool_group" | "other";
196
+ blocks: StreamingBlock[];
197
+ }
198
+
199
+ function groupBlocks(blocks: StreamingBlock[]): BlockGroup[] {
200
+ const groups: BlockGroup[] = [];
201
+ let currentToolGroup: StreamingBlock[] = [];
202
+
203
+ for (const block of blocks) {
204
+ if (block.type === "tool_use") {
205
+ currentToolGroup.push(block);
206
+ } else {
207
+ // Flush current tool group if exists
208
+ if (currentToolGroup.length > 0) {
209
+ groups.push({ type: "tool_group", blocks: currentToolGroup });
210
+ currentToolGroup = [];
211
+ }
212
+ // Add non-tool block as single item
213
+ groups.push({ type: "other", blocks: [block] });
214
+ }
215
+ }
216
+
217
+ // Flush remaining tool group
218
+ if (currentToolGroup.length > 0) {
219
+ groups.push({ type: "tool_group", blocks: currentToolGroup });
220
+ }
221
+
222
+ return groups;
223
+ }
224
+
124
225
  export function StreamingMessage({ blocks, isStreaming = true, projectId }: StreamingMessageProps) {
125
226
  const hasBlocks = blocks.length > 0;
126
- const { respondToQuestion, pendingQuestion } = useChatStore();
227
+ const { respondToQuestion } = useChatStore();
228
+ const blockGroups = groupBlocks(blocks);
127
229
 
128
230
  const handleQuestionSubmit = (answers: Record<string, string>) => {
129
231
  respondToQuestion(projectId, answers);
@@ -135,25 +237,29 @@ export function StreamingMessage({ blocks, isStreaming = true, projectId }: Stre
135
237
  AI
136
238
  </div>
137
239
  <div className="flex-1 space-y-2 overflow-hidden">
138
- {/* Render blocks in order */}
139
- {blocks.map((block) => {
140
- if (block.type === "text") {
141
- return <TextBlock key={block.id} content={block.content || ""} />;
142
- }
143
- if (block.type === "tool_use") {
144
- return <ToolUseBlock key={block.id} block={block} />;
240
+ {/* Render block groups */}
241
+ {blockGroups.map((group, groupIndex) => {
242
+ if (group.type === "tool_group") {
243
+ return <ToolBlockGroup key={`group-${groupIndex}`} blocks={group.blocks} />;
145
244
  }
146
- if (block.type === "ask_user_question" && block.askUserQuestion) {
147
- return (
148
- <AskUserQuestionBlock
149
- key={block.id}
150
- data={block.askUserQuestion}
151
- isWaiting={block.status === "waiting"}
152
- onSubmit={handleQuestionSubmit}
153
- />
154
- );
155
- }
156
- return null;
245
+
246
+ // Render other blocks individually
247
+ return group.blocks.map((block) => {
248
+ if (block.type === "text") {
249
+ return <TextBlock key={block.id} content={block.content || ""} />;
250
+ }
251
+ if (block.type === "ask_user_question" && block.askUserQuestion) {
252
+ return (
253
+ <AskUserQuestionBlock
254
+ key={block.id}
255
+ data={block.askUserQuestion}
256
+ isWaiting={block.status === "waiting"}
257
+ onSubmit={handleQuestionSubmit}
258
+ />
259
+ );
260
+ }
261
+ return null;
262
+ });
157
263
  })}
158
264
 
159
265
  {/* Show thinking state when streaming but no blocks yet */}
@@ -28,6 +28,8 @@ export interface StreamingBlock {
28
28
  askUserQuestion?: AskUserQuestionData;
29
29
  status?: "running" | "completed" | "error" | "waiting";
30
30
  result?: string;
31
+ timestamp?: number; // Unix timestamp in milliseconds
32
+ duration?: number; // Duration in milliseconds
31
33
  }
32
34
 
33
35
  export interface QueuedMessage {
@@ -60,6 +62,7 @@ interface ChatState {
60
62
  fetchActiveSession: (projectId: string) => Promise<void>;
61
63
  sendMessage: (projectId: string, content: string, fromQueue?: boolean) => Promise<void>;
62
64
  queueMessage: (projectId: string, content: string) => void;
65
+ deleteFromQueue: (id: string) => void;
63
66
  processQueue: (projectId: string) => Promise<void>;
64
67
  respondToQuestion: (projectId: string, answers: Record<string, string>) => Promise<void>;
65
68
  addMessage: (message: ChatMessage) => void;
@@ -263,19 +266,24 @@ export const useChatStore = create<ChatState>((set, get) => ({
263
266
  input: data.tool.input,
264
267
  },
265
268
  status: "running",
269
+ timestamp: Date.now(),
266
270
  };
267
271
  set((state) => ({
268
272
  streamingBlocks: [...state.streamingBlocks, toolBlock],
269
273
  }));
270
274
  } else if (data.type === "tool_result") {
275
+ const now = Date.now();
271
276
  set((state) => {
272
277
  const blocks = [...state.streamingBlocks];
273
278
  for (let i = blocks.length - 1; i >= 0; i--) {
274
- if (blocks[i].type === "tool_use" && blocks[i].status === "running") {
279
+ const block = blocks[i];
280
+ if (block.type === "tool_use" && block.status === "running") {
281
+ const duration = block.timestamp ? now - block.timestamp : undefined;
275
282
  blocks[i] = {
276
- ...blocks[i],
283
+ ...block,
277
284
  status: "completed",
278
285
  result: data.content,
286
+ duration,
279
287
  };
280
288
  break;
281
289
  }
@@ -341,6 +349,13 @@ export const useChatStore = create<ChatState>((set, get) => ({
341
349
  }));
342
350
  },
343
351
 
352
+ // Delete a message from the queue
353
+ deleteFromQueue: (id: string) => {
354
+ set((state) => ({
355
+ messageQueue: state.messageQueue.filter((m) => m.id !== id),
356
+ }));
357
+ },
358
+
344
359
  // Process the message queue sequentially
345
360
  processQueue: async (projectId: string) => {
346
361
  const state = get();
@@ -487,7 +502,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
487
502
  }));
488
503
  }
489
504
  } else if (data.type === "tool_use" && data.tool) {
490
- // Add tool_use block
505
+ // Add tool_use block with timestamp
491
506
  const toolBlock: StreamingBlock = {
492
507
  id: `tool-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
493
508
  type: "tool_use",
@@ -496,21 +511,26 @@ export const useChatStore = create<ChatState>((set, get) => ({
496
511
  input: data.tool.input,
497
512
  },
498
513
  status: "running",
514
+ timestamp: Date.now(),
499
515
  };
500
516
  set((state) => ({
501
517
  streamingBlocks: [...state.streamingBlocks, toolBlock],
502
518
  }));
503
519
  } else if (data.type === "tool_result") {
504
- // Update the last running tool_use block with result
520
+ // Update the last running tool_use block with result and duration
521
+ const now = Date.now();
505
522
  set((state) => {
506
523
  const blocks = [...state.streamingBlocks];
507
524
  // Find the last running tool_use block
508
525
  for (let i = blocks.length - 1; i >= 0; i--) {
509
- if (blocks[i].type === "tool_use" && blocks[i].status === "running") {
526
+ const block = blocks[i];
527
+ if (block.type === "tool_use" && block.status === "running") {
528
+ const duration = block.timestamp ? now - block.timestamp : undefined;
510
529
  blocks[i] = {
511
- ...blocks[i],
530
+ ...block,
512
531
  status: "completed",
513
532
  result: data.content,
533
+ duration,
514
534
  };
515
535
  break;
516
536
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeship",
3
- "version": "0.2.19",
3
+ "version": "0.2.20",
4
4
  "description": "AI-Powered App Builder using Claude Code CLI",
5
5
  "bin": {
6
6
  "claudeship": "./bin/claudeship.js"