Skip to content

Commit d0cdee2

Browse files
author
catlog22
committed
feat: add CLI session sharing functionality
- Implemented share token creation and revocation for CLI sessions. - Added a new page for viewing shared CLI sessions with SSE support. - Introduced hooks for fetching and managing CLI session shares. - Enhanced the IssueTerminalTab component to handle share tokens and display active shares. - Updated API routes to support fetching and revoking share tokens. - Added unit tests for the CLI session share manager and rate limiter. - Updated localization files to include new strings for sharing functionality.
1 parent 362f354 commit d0cdee2

18 files changed

Lines changed: 748 additions & 23 deletions

ccw/frontend/src/components/issue/hub/IssueTerminalTab.tsx

Lines changed: 126 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import {
1717
closeCliSession,
1818
createCliSession,
1919
createCliSessionShareToken,
20+
fetchCliSessionShares,
21+
revokeCliSessionShareToken,
2022
executeInCliSession,
2123
fetchCliSessionBuffer,
2224
fetchCliSessions,
@@ -55,6 +57,11 @@ export function IssueTerminalTab({ issueId }: { issueId: string }) {
5557
const [prompt, setPrompt] = useState('');
5658
const [isExecuting, setIsExecuting] = useState(false);
5759
const [shareUrl, setShareUrl] = useState<string>('');
60+
const [shareToken, setShareToken] = useState<string>('');
61+
const [shareExpiresAt, setShareExpiresAt] = useState<string>('');
62+
const [shareRecords, setShareRecords] = useState<Array<{ shareToken: string; expiresAt: string; mode: 'read' | 'write' }>>([]);
63+
const [isLoadingShares, setIsLoadingShares] = useState(false);
64+
const [isRevokingShare, setIsRevokingShare] = useState(false);
5865

5966
const terminalHostRef = useRef<HTMLDivElement | null>(null);
6067
const xtermRef = useRef<XTerm | null>(null);
@@ -103,6 +110,39 @@ export function IssueTerminalTab({ issueId }: { issueId: string }) {
103110
setSelectedSessionKey(sessions[sessions.length - 1]?.sessionKey ?? '');
104111
}, [sessions, selectedSessionKey]);
105112

113+
const buildShareLink = (sessionKey: string, token: string): string => {
114+
const url = new URL(window.location.href);
115+
const base = (import.meta.env.BASE_URL ?? '/').replace(/\/$/, '');
116+
url.pathname = `${base}/cli-sessions/share`;
117+
url.search = `sessionKey=${encodeURIComponent(sessionKey)}&shareToken=${encodeURIComponent(token)}`;
118+
return url.toString();
119+
};
120+
121+
const refreshShares = async (sessionKey: string) => {
122+
if (!sessionKey) {
123+
setShareRecords([]);
124+
return;
125+
}
126+
setIsLoadingShares(true);
127+
try {
128+
const r = await fetchCliSessionShares(sessionKey, projectPath || undefined);
129+
setShareRecords(r.shares || []);
130+
} catch {
131+
setShareRecords([]);
132+
} finally {
133+
setIsLoadingShares(false);
134+
}
135+
};
136+
137+
// Refresh share tokens when session changes
138+
useEffect(() => {
139+
setShareUrl('');
140+
setShareToken('');
141+
setShareExpiresAt('');
142+
void refreshShares(selectedSessionKey);
143+
// eslint-disable-next-line react-hooks/exhaustive-deps
144+
}, [selectedSessionKey, projectPath]);
145+
106146
// Init xterm
107147
useEffect(() => {
108148
if (!terminalHostRef.current) return;
@@ -282,15 +322,35 @@ export function IssueTerminalTab({ issueId }: { issueId: string }) {
282322
if (!selectedSessionKey) return;
283323
setError(null);
284324
setShareUrl('');
325+
setShareToken('');
326+
setShareExpiresAt('');
285327
try {
286328
const r = await createCliSessionShareToken(selectedSessionKey, { mode: 'read' }, projectPath || undefined);
287-
const url = new URL(window.location.href);
288-
const base = (import.meta.env.BASE_URL ?? '/').replace(/\/$/, '');
289-
url.pathname = `${base}/cli-sessions/share`;
290-
url.search = `sessionKey=${encodeURIComponent(selectedSessionKey)}&shareToken=${encodeURIComponent(r.shareToken)}`;
291-
setShareUrl(url.toString());
329+
setShareUrl(buildShareLink(selectedSessionKey, r.shareToken));
330+
setShareToken(r.shareToken);
331+
setShareExpiresAt(r.expiresAt);
332+
void refreshShares(selectedSessionKey);
333+
} catch (e) {
334+
setError(e instanceof Error ? e.message : String(e));
335+
}
336+
};
337+
338+
const handleRevokeShareLink = async (token: string) => {
339+
if (!selectedSessionKey || !token) return;
340+
setIsRevokingShare(true);
341+
setError(null);
342+
try {
343+
await revokeCliSessionShareToken(selectedSessionKey, { shareToken: token }, projectPath || undefined);
344+
if (token === shareToken) {
345+
setShareUrl('');
346+
setShareToken('');
347+
setShareExpiresAt('');
348+
}
349+
void refreshShares(selectedSessionKey);
292350
} catch (e) {
293351
setError(e instanceof Error ? e.message : String(e));
352+
} finally {
353+
setIsRevokingShare(false);
294354
}
295355
};
296356

@@ -352,12 +412,67 @@ export function IssueTerminalTab({ issueId }: { issueId: string }) {
352412
</div>
353413

354414
{shareUrl && (
355-
<div className="flex items-center gap-2">
356-
<Input value={shareUrl} readOnly />
357-
<Button variant="outline" onClick={handleCopyShareLink}>
358-
<Copy className="w-4 h-4 mr-2" />
359-
{formatMessage({ id: 'common.actions.copy' })}
360-
</Button>
415+
<div className="space-y-2">
416+
<div className="flex items-center gap-2">
417+
<Input value={shareUrl} readOnly />
418+
<Button variant="outline" onClick={handleCopyShareLink}>
419+
<Copy className="w-4 h-4 mr-2" />
420+
{formatMessage({ id: 'common.actions.copy' })}
421+
</Button>
422+
<Button
423+
variant="outline"
424+
onClick={() => handleRevokeShareLink(shareToken)}
425+
disabled={isRevokingShare || !shareToken}
426+
>
427+
{formatMessage({ id: 'issues.terminal.session.revokeShare' })}
428+
</Button>
429+
</div>
430+
{shareExpiresAt && (
431+
<div className="text-xs text-muted-foreground font-mono">
432+
{formatMessage({ id: 'issues.terminal.session.expiresAt' })}: {shareExpiresAt}
433+
</div>
434+
)}
435+
</div>
436+
)}
437+
438+
{selectedSessionKey && shareRecords.length > 0 && (
439+
<div className="space-y-2">
440+
<div className="text-xs text-muted-foreground">
441+
{formatMessage({ id: 'issues.terminal.session.activeShares' })}
442+
{isLoadingShares ? '…' : ''}
443+
</div>
444+
<div className="space-y-1">
445+
{shareRecords.map((s) => (
446+
<div key={s.shareToken} className="flex items-center gap-2">
447+
<div className="text-xs font-mono truncate flex-1 min-w-0">
448+
{s.shareToken.slice(0, 6)}{s.shareToken.slice(-6)}
449+
</div>
450+
<div className="text-xs text-muted-foreground font-mono">{s.mode}</div>
451+
<div className="text-xs text-muted-foreground font-mono truncate max-w-[220px]">{s.expiresAt}</div>
452+
<Button
453+
variant="outline"
454+
size="sm"
455+
onClick={async () => {
456+
try {
457+
await navigator.clipboard.writeText(buildShareLink(selectedSessionKey, s.shareToken));
458+
} catch {
459+
// ignore
460+
}
461+
}}
462+
>
463+
{formatMessage({ id: 'common.actions.copy' })}
464+
</Button>
465+
<Button
466+
variant="outline"
467+
size="sm"
468+
disabled={isRevokingShare}
469+
onClick={() => handleRevokeShareLink(s.shareToken)}
470+
>
471+
{formatMessage({ id: 'issues.terminal.session.revokeShare' })}
472+
</Button>
473+
</div>
474+
))}
475+
</div>
361476
</div>
362477
)}
363478

ccw/frontend/src/components/issue/hub/QueuePanel.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
1919
import { QueueCard } from '@/components/issue/queue/QueueCard';
2020
import { QueueBoard } from '@/components/issue/queue/QueueBoard';
2121
import { SolutionDrawer } from '@/components/issue/queue/SolutionDrawer';
22-
import { useIssueQueue, useQueueHistory, useQueueMutations } from '@/hooks';
22+
import { useIssueQueue, useIssueQueueById, useQueueHistory, useQueueMutations } from '@/hooks';
2323
import type { QueueItem } from '@/lib/api';
2424

2525
// ========== Loading Skeleton ==========
@@ -74,9 +74,11 @@ function QueueEmptyState() {
7474
export function QueuePanel() {
7575
const { formatMessage } = useIntl();
7676
const [selectedItem, setSelectedItem] = useState<QueueItem | null>(null);
77+
const [selectedQueueId, setSelectedQueueId] = useState<string>('');
7778

78-
const { data: queueData, isLoading, error } = useIssueQueue();
79+
const activeQueueQuery = useIssueQueue();
7980
const { data: historyIndex } = useQueueHistory();
81+
const selectedQueueQuery = useIssueQueueById(selectedQueueId);
8082
const {
8183
activateQueue,
8284
deactivateQueue,
@@ -90,8 +92,12 @@ export function QueuePanel() {
9092
isSplitting,
9193
} = useQueueMutations();
9294

93-
// Get queue data with proper type
94-
const queue = queueData;
95+
const queue = selectedQueueId && selectedQueueQuery.data ? selectedQueueQuery.data : activeQueueQuery.data;
96+
const isLoading =
97+
activeQueueQuery.isLoading ||
98+
(selectedQueueId ? selectedQueueQuery.isLoading && !selectedQueueQuery.data : false);
99+
const error = activeQueueQuery.error || selectedQueueQuery.error;
100+
95101
const taskCount = queue?.tasks?.length || 0;
96102
const solutionCount = queue?.solutions?.length || 0;
97103
const conflictCount = queue?.conflicts?.length || 0;
@@ -100,13 +106,13 @@ export function QueuePanel() {
100106
const activeQueueId = historyIndex?.active_queue_id || null;
101107
const activeQueueIds = historyIndex?.active_queue_ids || [];
102108
const queueId = queue?.id;
103-
const [selectedQueueId, setSelectedQueueId] = useState<string>('');
104109

105110
// Keep selector in sync with active queue id
106111
useEffect(() => {
112+
if (selectedQueueId) return;
107113
if (activeQueueId) setSelectedQueueId(activeQueueId);
108114
else if (queueId) setSelectedQueueId(queueId);
109-
}, [activeQueueId, queueId]);
115+
}, [activeQueueId, queueId, selectedQueueId]);
110116

111117
const handleActivate = async (queueId: string) => {
112118
try {

ccw/frontend/src/components/issue/queue/QueueActions.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,9 @@ export function QueueActions({
130130
variant="ghost"
131131
size="sm"
132132
className="h-8 w-8 p-0"
133-
onClick={() => onActivate(queueId)}
133+
onClick={() => {
134+
if (queueId) onActivate(queueId);
135+
}}
134136
disabled={isActivating || !queueId}
135137
title={formatMessage({ id: 'issues.queue.actions.activate' })}
136138
>

ccw/frontend/src/components/shared/IssueCard.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { Card } from '@/components/ui/Card';
2222
import { Badge } from '@/components/ui/Badge';
2323
import { Button } from '@/components/ui/Button';
2424
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/Dropdown';
25+
import type { DraggableProvidedDragHandleProps, DraggableProvidedDraggableProps } from '@hello-pangea/dnd';
2526
import type { Issue } from '@/lib/api';
2627

2728
// ========== Types ==========
@@ -35,8 +36,8 @@ export interface IssueCardProps {
3536
className?: string;
3637
compact?: boolean;
3738
showActions?: boolean;
38-
draggableProps?: Record<string, unknown>;
39-
dragHandleProps?: Record<string, unknown>;
39+
draggableProps?: DraggableProvidedDraggableProps;
40+
dragHandleProps?: DraggableProvidedDragHandleProps | null;
4041
innerRef?: React.Ref<HTMLDivElement>;
4142
}
4243

ccw/frontend/src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export type {
6969
export {
7070
useIssues,
7171
useIssueQueue,
72+
useIssueQueueById,
7273
useQueueHistory,
7374
useCreateIssue,
7475
useUpdateIssue,

ccw/frontend/src/hooks/useIssues.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
fetchIssues,
99
fetchIssueHistory,
1010
fetchIssueQueue,
11+
fetchQueueById,
1112
fetchQueueHistory,
1213
createIssue,
1314
updateIssue,
@@ -208,6 +209,23 @@ export function useIssueQueue(): UseQueryResult<IssueQueue> {
208209
});
209210
}
210211

212+
/**
213+
* Hook for fetching a specific queue by ID
214+
*/
215+
export function useIssueQueueById(queueId?: string): UseQueryResult<IssueQueue> {
216+
const projectPath = useWorkflowStore(selectProjectPath);
217+
return useQuery<IssueQueue>({
218+
queryKey:
219+
projectPath && queueId
220+
? workspaceQueryKeys.issueQueueById(projectPath, queueId)
221+
: ['issueQueueById', projectPath ?? 'no-project', queueId ?? 'no-queue'],
222+
queryFn: () => fetchQueueById(queueId!, projectPath),
223+
staleTime: STALE_TIME,
224+
enabled: !!projectPath && !!queueId,
225+
retry: 2,
226+
});
227+
}
228+
211229
// ========== Mutations ==========
212230

213231
export interface UseCreateIssueReturn {
@@ -346,6 +364,7 @@ export function useQueueMutations(): UseQueueMutationsReturn {
346364
mutationFn: (queueId: string) => activateQueue(queueId, projectPath),
347365
onSuccess: () => {
348366
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) });
367+
queryClient.invalidateQueries({ queryKey: [...workspaceQueryKeys.issues(projectPath), 'queueById'] });
349368
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueueHistory(projectPath) });
350369
},
351370
});
@@ -354,6 +373,7 @@ export function useQueueMutations(): UseQueueMutationsReturn {
354373
mutationFn: () => deactivateQueue(projectPath),
355374
onSuccess: () => {
356375
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) });
376+
queryClient.invalidateQueries({ queryKey: [...workspaceQueryKeys.issues(projectPath), 'queueById'] });
357377
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueueHistory(projectPath) });
358378
},
359379
});
@@ -362,6 +382,7 @@ export function useQueueMutations(): UseQueueMutationsReturn {
362382
mutationFn: (queueId: string) => deleteQueueApi(queueId, projectPath),
363383
onSuccess: () => {
364384
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) });
385+
queryClient.invalidateQueries({ queryKey: [...workspaceQueryKeys.issues(projectPath), 'queueById'] });
365386
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueueHistory(projectPath) });
366387
},
367388
});
@@ -371,6 +392,7 @@ export function useQueueMutations(): UseQueueMutationsReturn {
371392
mergeQueuesApi(sourceId, targetId, projectPath),
372393
onSuccess: () => {
373394
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) });
395+
queryClient.invalidateQueries({ queryKey: [...workspaceQueryKeys.issues(projectPath), 'queueById'] });
374396
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueueHistory(projectPath) });
375397
},
376398
});
@@ -380,6 +402,7 @@ export function useQueueMutations(): UseQueueMutationsReturn {
380402
splitQueueApi(sourceQueueId, itemIds, projectPath),
381403
onSuccess: () => {
382404
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) });
405+
queryClient.invalidateQueries({ queryKey: [...workspaceQueryKeys.issues(projectPath), 'queueById'] });
383406
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueueHistory(projectPath) });
384407
},
385408
});
@@ -389,6 +412,7 @@ export function useQueueMutations(): UseQueueMutationsReturn {
389412
reorderQueueGroupApi(projectPath, { groupId, newOrder }),
390413
onSuccess: () => {
391414
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) });
415+
queryClient.invalidateQueries({ queryKey: [...workspaceQueryKeys.issues(projectPath), 'queueById'] });
392416
},
393417
});
394418

@@ -397,6 +421,7 @@ export function useQueueMutations(): UseQueueMutationsReturn {
397421
moveQueueItemApi(projectPath, { itemId, toGroupId, toIndex }),
398422
onSuccess: () => {
399423
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) });
424+
queryClient.invalidateQueries({ queryKey: [...workspaceQueryKeys.issues(projectPath), 'queueById'] });
400425
},
401426
});
402427

ccw/frontend/src/lib/api.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -842,6 +842,13 @@ export async function fetchQueueHistory(projectPath: string): Promise<QueueHisto
842842
return fetchApi<QueueHistoryIndex>(`/api/queue/history?path=${encodeURIComponent(projectPath)}`);
843843
}
844844

845+
/**
846+
* Fetch a specific queue by ID
847+
*/
848+
export async function fetchQueueById(queueId: string, projectPath: string): Promise<IssueQueue> {
849+
return fetchApi<IssueQueue>(`/api/queue/${encodeURIComponent(queueId)}?path=${encodeURIComponent(projectPath)}`);
850+
}
851+
845852
/**
846853
* Activate a queue
847854
*/
@@ -5797,3 +5804,23 @@ export async function createCliSessionShareToken(
57975804
{ method: 'POST', body: JSON.stringify(input) }
57985805
);
57995806
}
5807+
5808+
export async function fetchCliSessionShares(
5809+
sessionKey: string,
5810+
projectPath?: string
5811+
): Promise<{ shares: Array<{ shareToken: string; expiresAt: string; mode: 'read' | 'write' }> }> {
5812+
return fetchApi<{ shares: Array<{ shareToken: string; expiresAt: string; mode: 'read' | 'write' }> }>(
5813+
withPath(`/api/cli-sessions/${encodeURIComponent(sessionKey)}/shares`, projectPath)
5814+
);
5815+
}
5816+
5817+
export async function revokeCliSessionShareToken(
5818+
sessionKey: string,
5819+
input: { shareToken: string },
5820+
projectPath?: string
5821+
): Promise<{ success: boolean; revoked: boolean }> {
5822+
return fetchApi<{ success: boolean; revoked: boolean }>(
5823+
withPath(`/api/cli-sessions/${encodeURIComponent(sessionKey)}/share/revoke`, projectPath),
5824+
{ method: 'POST', body: JSON.stringify(input) }
5825+
);
5826+
}

0 commit comments

Comments
 (0)