@atercates/claude-deck 0.2.14 → 0.2.16

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.
@@ -0,0 +1,57 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { isCloudflaredInstalled, startTunnel, stopTunnel } from "@/lib/tunnels";
3
+
4
+ export async function POST(
5
+ _request: NextRequest,
6
+ { params }: { params: Promise<{ port: string }> }
7
+ ) {
8
+ try {
9
+ const { port: portStr } = await params;
10
+ const port = parseInt(portStr, 10);
11
+ if (isNaN(port) || port <= 0) {
12
+ return NextResponse.json({ error: "Invalid port" }, { status: 400 });
13
+ }
14
+
15
+ const installed = await isCloudflaredInstalled();
16
+ if (!installed) {
17
+ return NextResponse.json(
18
+ {
19
+ error: "cloudflared is not installed",
20
+ code: "CLOUDFLARED_NOT_FOUND",
21
+ },
22
+ { status: 400 }
23
+ );
24
+ }
25
+
26
+ const result = await startTunnel(port);
27
+ return NextResponse.json(result);
28
+ } catch (error) {
29
+ console.error("Error starting tunnel:", error);
30
+ return NextResponse.json(
31
+ { error: "Failed to start tunnel" },
32
+ { status: 500 }
33
+ );
34
+ }
35
+ }
36
+
37
+ export async function DELETE(
38
+ _request: NextRequest,
39
+ { params }: { params: Promise<{ port: string }> }
40
+ ) {
41
+ try {
42
+ const { port: portStr } = await params;
43
+ const port = parseInt(portStr, 10);
44
+ if (isNaN(port) || port <= 0) {
45
+ return NextResponse.json({ error: "Invalid port" }, { status: 400 });
46
+ }
47
+
48
+ await stopTunnel(port);
49
+ return NextResponse.json({ ok: true });
50
+ } catch (error) {
51
+ console.error("Error stopping tunnel:", error);
52
+ return NextResponse.json(
53
+ { error: "Failed to stop tunnel" },
54
+ { status: 500 }
55
+ );
56
+ }
57
+ }
@@ -0,0 +1,18 @@
1
+ import { NextResponse } from "next/server";
2
+ import { isCloudflaredInstalled, getTunnelsList } from "@/lib/tunnels";
3
+
4
+ export async function GET() {
5
+ try {
6
+ const [installed, tunnels] = await Promise.all([
7
+ isCloudflaredInstalled(),
8
+ Promise.resolve(getTunnelsList()),
9
+ ]);
10
+ return NextResponse.json({ installed, tunnels });
11
+ } catch (error) {
12
+ console.error("Error getting tunnels:", error);
13
+ return NextResponse.json(
14
+ { error: "Failed to get tunnels" },
15
+ { status: 500 }
16
+ );
17
+ }
18
+ }
@@ -0,0 +1,29 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { createWorktree } from "@/lib/worktrees";
3
+
4
+ export async function POST(request: NextRequest) {
5
+ try {
6
+ const { projectPath, featureName, baseBranch } = await request.json();
7
+
8
+ if (!projectPath || !featureName) {
9
+ return NextResponse.json(
10
+ { error: "projectPath and featureName are required" },
11
+ { status: 400 }
12
+ );
13
+ }
14
+
15
+ const worktree = await createWorktree({
16
+ projectPath,
17
+ featureName,
18
+ baseBranch,
19
+ });
20
+
21
+ return NextResponse.json(worktree, { status: 201 });
22
+ } catch (error) {
23
+ const message = error instanceof Error ? error.message : "Unknown error";
24
+ return NextResponse.json(
25
+ { error: `Failed to create worktree: ${message}` },
26
+ { status: 400 }
27
+ );
28
+ }
29
+ }
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useState, useEffect, useRef } from "react";
3
+ import { useState, useEffect, useRef, useCallback } from "react";
4
4
  import {
5
5
  Dialog,
6
6
  DialogContent,
@@ -16,7 +16,7 @@ import {
16
16
  SelectTrigger,
17
17
  SelectValue,
18
18
  } from "@/components/ui/select";
19
- import { Sparkles } from "lucide-react";
19
+ import { Sparkles, GitBranch, Loader2 } from "lucide-react";
20
20
  import type { ClaudeProject } from "@/data/claude";
21
21
 
22
22
  const ADJECTIVES = [
@@ -50,6 +50,12 @@ function generateName() {
50
50
  return `${adj}-${noun}`;
51
51
  }
52
52
 
53
+ interface GitInfo {
54
+ isGitRepo: boolean;
55
+ branches: string[];
56
+ defaultBranch: string | null;
57
+ }
58
+
53
59
  interface NewClaudeSessionDialogProps {
54
60
  open: boolean;
55
61
  projectName: string;
@@ -69,10 +75,57 @@ export function NewClaudeSessionDialog({
69
75
  const [selectedProject, setSelectedProject] = useState(projectName);
70
76
  const inputRef = useRef<HTMLInputElement>(null);
71
77
 
78
+ // Worktree state
79
+ const [gitInfo, setGitInfo] = useState<GitInfo | null>(null);
80
+ const [useWorktree, setUseWorktree] = useState(false);
81
+ const [featureName, setFeatureName] = useState("");
82
+ const [baseBranch, setBaseBranch] = useState("main");
83
+ const [creating, setCreating] = useState(false);
84
+
85
+ const cwd = (() => {
86
+ if (selectedProject) {
87
+ const project = projects?.find((p) => p.name === selectedProject);
88
+ return project?.directory || undefined;
89
+ }
90
+ return undefined;
91
+ })();
92
+
93
+ // Check git repo when cwd changes
94
+ useEffect(() => {
95
+ if (!cwd) {
96
+ setGitInfo(null);
97
+ setUseWorktree(false);
98
+ return;
99
+ }
100
+
101
+ let cancelled = false;
102
+ fetch("/api/git/check", {
103
+ method: "POST",
104
+ headers: { "Content-Type": "application/json" },
105
+ body: JSON.stringify({ path: cwd }),
106
+ })
107
+ .then((r) => r.json())
108
+ .then((data) => {
109
+ if (cancelled) return;
110
+ setGitInfo(data);
111
+ if (data.defaultBranch) setBaseBranch(data.defaultBranch);
112
+ })
113
+ .catch(() => {
114
+ if (!cancelled) setGitInfo(null);
115
+ });
116
+
117
+ return () => {
118
+ cancelled = true;
119
+ };
120
+ }, [cwd]);
121
+
72
122
  useEffect(() => {
73
123
  if (open) {
74
124
  setName(generateName());
75
125
  setSelectedProject(projectName);
126
+ setUseWorktree(false);
127
+ setFeatureName("");
128
+ setCreating(false);
76
129
  setTimeout(() => {
77
130
  inputRef.current?.focus();
78
131
  inputRef.current?.select();
@@ -80,20 +133,63 @@ export function NewClaudeSessionDialog({
80
133
  }
81
134
  }, [open, projectName]);
82
135
 
83
- const handleSubmit = (e: React.FormEvent) => {
84
- e.preventDefault();
85
- const project = projects?.find((p) => p.name === selectedProject);
86
- onConfirm(
87
- name.trim() || generateName(),
88
- project?.directory || undefined,
89
- selectedProject || undefined
90
- );
91
- };
136
+ const handleSubmit = useCallback(
137
+ async (e: React.FormEvent) => {
138
+ e.preventDefault();
139
+ const sessionName = name.trim() || generateName();
140
+ let targetCwd = cwd;
141
+
142
+ if (useWorktree && featureName.trim() && cwd) {
143
+ setCreating(true);
144
+ try {
145
+ const res = await fetch("/api/worktrees", {
146
+ method: "POST",
147
+ headers: { "Content-Type": "application/json" },
148
+ body: JSON.stringify({
149
+ projectPath: cwd,
150
+ featureName: featureName.trim(),
151
+ baseBranch,
152
+ }),
153
+ });
154
+ if (!res.ok) {
155
+ const data = await res.json();
156
+ alert(data.error || "Failed to create worktree");
157
+ setCreating(false);
158
+ return;
159
+ }
160
+ const worktree = await res.json();
161
+ targetCwd = worktree.worktreePath;
162
+ } catch {
163
+ alert("Failed to create worktree");
164
+ setCreating(false);
165
+ return;
166
+ }
167
+ setCreating(false);
168
+ }
169
+
170
+ onConfirm(sessionName, targetCwd, selectedProject || undefined);
171
+ },
172
+ [
173
+ name,
174
+ cwd,
175
+ useWorktree,
176
+ featureName,
177
+ baseBranch,
178
+ selectedProject,
179
+ onConfirm,
180
+ ]
181
+ );
92
182
 
93
183
  const showProjectSelector = projects && projects.length > 0 && !projectName;
184
+ const branchPreview = featureName
185
+ .trim()
186
+ .toLowerCase()
187
+ .replace(/[^a-z0-9]+/g, "-")
188
+ .replace(/^-+|-+$/g, "")
189
+ .slice(0, 50);
94
190
 
95
191
  return (
96
- <Dialog open={open} onOpenChange={(o) => !o && onClose()}>
192
+ <Dialog open={open} onOpenChange={(o) => !o && !creating && onClose()}>
97
193
  <DialogContent className="sm:max-w-sm">
98
194
  <DialogHeader>
99
195
  <DialogTitle className="text-base">New session</DialogTitle>
@@ -127,6 +223,7 @@ export function NewClaudeSessionDialog({
127
223
  <p className="text-muted-foreground text-xs">{projectName}</p>
128
224
  )
129
225
  )}
226
+
130
227
  <div>
131
228
  <div className="flex gap-2">
132
229
  <Input
@@ -135,6 +232,7 @@ export function NewClaudeSessionDialog({
135
232
  onChange={(e) => setName(e.target.value)}
136
233
  placeholder="Session name"
137
234
  className="h-9"
235
+ disabled={creating}
138
236
  />
139
237
  <Button
140
238
  type="button"
@@ -142,21 +240,105 @@ export function NewClaudeSessionDialog({
142
240
  size="icon-sm"
143
241
  className="h-9 w-9 shrink-0"
144
242
  onClick={() => setName(generateName())}
243
+ disabled={creating}
145
244
  >
146
245
  <Sparkles className="h-3.5 w-3.5" />
147
246
  </Button>
148
247
  </div>
149
248
  </div>
249
+
250
+ {gitInfo?.isGitRepo && (
251
+ <div className="bg-accent/40 space-y-3 rounded-lg p-3">
252
+ <div className="flex items-center gap-2">
253
+ <input
254
+ type="checkbox"
255
+ id="useWorktree"
256
+ checked={useWorktree}
257
+ onChange={(e) => setUseWorktree(e.target.checked)}
258
+ className="border-border bg-background accent-primary h-4 w-4 rounded"
259
+ disabled={creating}
260
+ />
261
+ <label
262
+ htmlFor="useWorktree"
263
+ className="flex cursor-pointer items-center gap-1.5 text-sm font-medium"
264
+ >
265
+ <GitBranch className="h-3.5 w-3.5" />
266
+ Create isolated worktree
267
+ </label>
268
+ </div>
269
+
270
+ {useWorktree && (
271
+ <div className="space-y-3 pl-6">
272
+ <div className="space-y-1">
273
+ <label className="text-muted-foreground text-xs">
274
+ Feature name
275
+ </label>
276
+ <Input
277
+ value={featureName}
278
+ onChange={(e) => setFeatureName(e.target.value)}
279
+ placeholder="add-dark-mode"
280
+ className="h-8 text-sm"
281
+ disabled={creating}
282
+ />
283
+ {branchPreview && (
284
+ <p className="text-muted-foreground text-xs">
285
+ Branch: feature/{branchPreview}
286
+ </p>
287
+ )}
288
+ </div>
289
+ <div className="space-y-1">
290
+ <label className="text-muted-foreground text-xs">
291
+ Base branch
292
+ </label>
293
+ <Select
294
+ value={baseBranch}
295
+ onValueChange={setBaseBranch}
296
+ disabled={creating}
297
+ >
298
+ <SelectTrigger className="h-8 text-sm">
299
+ <SelectValue />
300
+ </SelectTrigger>
301
+ <SelectContent>
302
+ {gitInfo.branches.map((branch) => (
303
+ <SelectItem key={branch} value={branch}>
304
+ {branch}
305
+ </SelectItem>
306
+ ))}
307
+ </SelectContent>
308
+ </Select>
309
+ </div>
310
+ </div>
311
+ )}
312
+ </div>
313
+ )}
314
+
150
315
  <div className="flex justify-end gap-2">
151
- <Button type="button" variant="ghost" size="sm" onClick={onClose}>
316
+ <Button
317
+ type="button"
318
+ variant="ghost"
319
+ size="sm"
320
+ onClick={onClose}
321
+ disabled={creating}
322
+ >
152
323
  Cancel
153
324
  </Button>
154
325
  <Button
155
326
  type="submit"
156
327
  size="sm"
157
- disabled={showProjectSelector && !selectedProject}
328
+ disabled={
329
+ creating ||
330
+ (showProjectSelector && !selectedProject) ||
331
+ (useWorktree && !featureName.trim())
332
+ }
158
333
  >
159
- Create
334
+ {creating ? (
335
+ <>
336
+ <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
337
+ Creating worktree...
338
+ </>
339
+ ) : (
340
+ "Create"
341
+ )}
160
342
  </Button>
161
343
  </div>
162
344
  </form>
@@ -291,6 +291,17 @@ export function DesktopTabBar({
291
291
 
292
292
  {/* Pane Controls */}
293
293
  <div className="ml-auto flex items-center gap-0.5 px-2">
294
+ {session?.branch_name && (
295
+ <Tooltip>
296
+ <TooltipTrigger asChild>
297
+ <span className="text-muted-foreground flex items-center gap-1 text-[10px]">
298
+ <GitBranch className="h-3 w-3" />
299
+ {session.branch_name}
300
+ </span>
301
+ </TooltipTrigger>
302
+ <TooltipContent>Worktree branch</TooltipContent>
303
+ </Tooltip>
304
+ )}
294
305
  {session?.working_directory && session.working_directory !== "~" && (
295
306
  <OpenInVSCode workingDirectory={session.working_directory} />
296
307
  )}
@@ -122,6 +122,12 @@ export function MobileTabBar({
122
122
  <span className="truncate text-sm font-medium">
123
123
  {session?.name || "No session"}
124
124
  </span>
125
+ {session?.branch_name && (
126
+ <span className="text-muted-foreground flex items-center gap-0.5 text-[10px]">
127
+ <GitBranch className="h-2.5 w-2.5" />
128
+ {session.branch_name}
129
+ </span>
130
+ )}
125
131
  {sessionList.length > 0 && (
126
132
  <ChevronDown className="text-muted-foreground h-3 w-3 shrink-0" />
127
133
  )}
@@ -1,9 +1,25 @@
1
1
  "use client";
2
2
 
3
- import { useMemo, useState, useEffect } from "react";
3
+ import { useMemo, useState, useEffect, useCallback } from "react";
4
4
  import { cn } from "@/lib/utils";
5
- import { ChevronRight, Activity, AlertCircle, Moon } from "lucide-react";
5
+ import {
6
+ ChevronRight,
7
+ Activity,
8
+ AlertCircle,
9
+ Moon,
10
+ Globe,
11
+ Share2,
12
+ X,
13
+ Loader2,
14
+ Copy,
15
+ Check,
16
+ } from "lucide-react";
6
17
  import type { SessionStatus } from "@/components/views/types";
18
+ import {
19
+ useCloudflaredStatus,
20
+ useStartTunnel,
21
+ useStopTunnel,
22
+ } from "@/data/tunnels/queries";
7
23
 
8
24
  interface ActiveSessionsSectionProps {
9
25
  sessionStatuses: Record<string, SessionStatus>;
@@ -37,11 +53,13 @@ export function ActiveSessionsSection({
37
53
  const hasWaiting = activeSessions.some((s) => s.status === "waiting");
38
54
  const [expanded, setExpanded] = useState(hasWaiting);
39
55
 
40
- // Auto-expand when a session starts waiting
41
56
  useEffect(() => {
42
57
  if (hasWaiting) setExpanded(true);
43
58
  }, [hasWaiting]);
44
59
 
60
+ const { data: cfStatus } = useCloudflaredStatus();
61
+ const cloudflaredInstalled = cfStatus?.installed ?? false;
62
+
45
63
  if (activeSessions.length === 0) return null;
46
64
 
47
65
  return (
@@ -92,6 +110,19 @@ export function ActiveSessionsSection({
92
110
  {session.lastLine}
93
111
  </span>
94
112
  )}
113
+ {session.listeningPorts &&
114
+ session.listeningPorts.length > 0 && (
115
+ <div className="mt-0.5 space-y-0.5">
116
+ {session.listeningPorts.map((port) => (
117
+ <PortBadge
118
+ key={port}
119
+ port={port}
120
+ tunnelUrl={session.tunnelUrls?.[port]}
121
+ cloudflaredInstalled={cloudflaredInstalled}
122
+ />
123
+ ))}
124
+ </div>
125
+ )}
95
126
  </div>
96
127
  </button>
97
128
  ))}
@@ -101,6 +132,112 @@ export function ActiveSessionsSection({
101
132
  );
102
133
  }
103
134
 
135
+ function PortBadge({
136
+ port,
137
+ tunnelUrl,
138
+ cloudflaredInstalled,
139
+ }: {
140
+ port: number;
141
+ tunnelUrl?: string;
142
+ cloudflaredInstalled: boolean;
143
+ }) {
144
+ const startTunnel = useStartTunnel();
145
+ const stopTunnel = useStopTunnel();
146
+ const [copied, setCopied] = useState(false);
147
+
148
+ const isStarting = startTunnel.isPending;
149
+ const hasTunnel = !!tunnelUrl;
150
+
151
+ const handleShare = useCallback(
152
+ (e: React.MouseEvent) => {
153
+ e.stopPropagation();
154
+ if (hasTunnel) {
155
+ stopTunnel.mutate(port);
156
+ } else {
157
+ startTunnel.mutate(port);
158
+ }
159
+ },
160
+ [hasTunnel, port, startTunnel, stopTunnel]
161
+ );
162
+
163
+ const handleCopy = useCallback(
164
+ (e: React.MouseEvent) => {
165
+ e.stopPropagation();
166
+ if (tunnelUrl) {
167
+ navigator.clipboard.writeText(tunnelUrl);
168
+ setCopied(true);
169
+ setTimeout(() => setCopied(false), 2000);
170
+ }
171
+ },
172
+ [tunnelUrl]
173
+ );
174
+
175
+ return (
176
+ <div className="flex flex-col gap-0.5">
177
+ <div className="flex items-center gap-1">
178
+ <a
179
+ href={`http://localhost:${port}`}
180
+ target="_blank"
181
+ rel="noopener noreferrer"
182
+ onClick={(e) => e.stopPropagation()}
183
+ className="inline-flex items-center gap-0.5 rounded bg-sky-500/15 px-1.5 py-0.5 font-mono text-[10px] text-sky-400 transition-colors hover:bg-sky-500/25"
184
+ >
185
+ <Globe className="h-2.5 w-2.5" />
186
+ {port}
187
+ </a>
188
+ {cloudflaredInstalled && (
189
+ <button
190
+ onClick={handleShare}
191
+ disabled={isStarting}
192
+ className={cn(
193
+ "rounded p-0.5 transition-colors",
194
+ hasTunnel
195
+ ? "text-emerald-400 hover:bg-emerald-500/20"
196
+ : "text-muted-foreground hover:bg-muted hover:text-foreground"
197
+ )}
198
+ title={hasTunnel ? "Stop sharing" : "Share via tunnel"}
199
+ >
200
+ {isStarting ? (
201
+ <Loader2 className="h-3 w-3 animate-spin" />
202
+ ) : hasTunnel ? (
203
+ <X className="h-3 w-3" />
204
+ ) : (
205
+ <Share2 className="h-3 w-3" />
206
+ )}
207
+ </button>
208
+ )}
209
+ </div>
210
+ {tunnelUrl && (
211
+ <div className="flex items-center gap-1">
212
+ <a
213
+ href={tunnelUrl}
214
+ target="_blank"
215
+ rel="noopener noreferrer"
216
+ onClick={(e) => e.stopPropagation()}
217
+ className="inline-flex max-w-[180px] items-center gap-0.5 truncate rounded bg-emerald-500/15 px-1.5 py-0.5 font-mono text-[10px] text-emerald-400 transition-colors hover:bg-emerald-500/25"
218
+ >
219
+ <Globe className="h-2.5 w-2.5 flex-shrink-0" />
220
+ {tunnelUrl
221
+ .replace("https://", "")
222
+ .replace(".trycloudflare.com", "")}
223
+ </a>
224
+ <button
225
+ onClick={handleCopy}
226
+ className="text-muted-foreground hover:text-foreground rounded p-0.5 transition-colors"
227
+ title="Copy URL"
228
+ >
229
+ {copied ? (
230
+ <Check className="h-3 w-3 text-emerald-400" />
231
+ ) : (
232
+ <Copy className="h-3 w-3" />
233
+ )}
234
+ </button>
235
+ </div>
236
+ )}
237
+ </div>
238
+ );
239
+ }
240
+
104
241
  function StatusIcon({ status }: { status: string }) {
105
242
  if (status === "running") {
106
243
  return (
@@ -34,7 +34,14 @@ export function createWebSocketConnection(
34
34
  intentionalCloseRef: React.MutableRefObject<boolean>
35
35
  ): WebSocketManager {
36
36
  const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
37
- const ws = new WebSocket(`${protocol}//${window.location.host}/ws/terminal`);
37
+ let ptyId: string | null = null;
38
+
39
+ function buildWsUrl() {
40
+ const base = `${protocol}//${window.location.host}/ws/terminal`;
41
+ return ptyId ? `${base}?ptyId=${ptyId}` : base;
42
+ }
43
+
44
+ const ws = new WebSocket(buildWsUrl());
38
45
  wsRef.current = ws;
39
46
 
40
47
  const sendResize = (cols: number, rows: number) => {
@@ -93,10 +100,11 @@ export function createWebSocketConnection(
93
100
  callbacks.onConnectionStateChange("reconnecting");
94
101
  reconnectDelayRef.current = WS_RECONNECT_BASE_DELAY;
95
102
 
96
- // Create fresh connection with saved handlers
97
- const newWs = new WebSocket(
98
- `${protocol}//${window.location.host}/ws/terminal`
99
- );
103
+ // Reattach to the same PTY if we have an ID
104
+ if (ptyId) {
105
+ term.clear();
106
+ }
107
+ const newWs = new WebSocket(buildWsUrl());
100
108
  wsRef.current = newWs;
101
109
  newWs.onopen = savedHandlers.onopen;
102
110
  newWs.onmessage = savedHandlers.onmessage;
@@ -135,6 +143,10 @@ export function createWebSocketConnection(
135
143
  resetInactivityTimer();
136
144
  try {
137
145
  const msg = JSON.parse(event.data);
146
+ if (msg.type === "pty-id") {
147
+ ptyId = msg.ptyId;
148
+ return;
149
+ }
138
150
  if (msg.type === "output") {
139
151
  const buffer = term.buffer.active;
140
152
  const scrollYBefore = buffer.viewportY;
@@ -10,6 +10,8 @@ export interface SessionStatus {
10
10
  lastLine?: string;
11
11
  waitingContext?: string;
12
12
  claudeSessionId?: string | null;
13
+ listeningPorts?: number[];
14
+ tunnelUrls?: Record<number, string>;
13
15
  }
14
16
 
15
17
  export interface ViewProps {