mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-10 02:24:35 +08:00
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.
This commit is contained in:
@@ -17,6 +17,8 @@ import {
|
||||
closeCliSession,
|
||||
createCliSession,
|
||||
createCliSessionShareToken,
|
||||
fetchCliSessionShares,
|
||||
revokeCliSessionShareToken,
|
||||
executeInCliSession,
|
||||
fetchCliSessionBuffer,
|
||||
fetchCliSessions,
|
||||
@@ -55,6 +57,11 @@ export function IssueTerminalTab({ issueId }: { issueId: string }) {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
const [shareUrl, setShareUrl] = useState<string>('');
|
||||
const [shareToken, setShareToken] = useState<string>('');
|
||||
const [shareExpiresAt, setShareExpiresAt] = useState<string>('');
|
||||
const [shareRecords, setShareRecords] = useState<Array<{ shareToken: string; expiresAt: string; mode: 'read' | 'write' }>>([]);
|
||||
const [isLoadingShares, setIsLoadingShares] = useState(false);
|
||||
const [isRevokingShare, setIsRevokingShare] = useState(false);
|
||||
|
||||
const terminalHostRef = useRef<HTMLDivElement | null>(null);
|
||||
const xtermRef = useRef<XTerm | null>(null);
|
||||
@@ -103,6 +110,39 @@ export function IssueTerminalTab({ issueId }: { issueId: string }) {
|
||||
setSelectedSessionKey(sessions[sessions.length - 1]?.sessionKey ?? '');
|
||||
}, [sessions, selectedSessionKey]);
|
||||
|
||||
const buildShareLink = (sessionKey: string, token: string): string => {
|
||||
const url = new URL(window.location.href);
|
||||
const base = (import.meta.env.BASE_URL ?? '/').replace(/\/$/, '');
|
||||
url.pathname = `${base}/cli-sessions/share`;
|
||||
url.search = `sessionKey=${encodeURIComponent(sessionKey)}&shareToken=${encodeURIComponent(token)}`;
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const refreshShares = async (sessionKey: string) => {
|
||||
if (!sessionKey) {
|
||||
setShareRecords([]);
|
||||
return;
|
||||
}
|
||||
setIsLoadingShares(true);
|
||||
try {
|
||||
const r = await fetchCliSessionShares(sessionKey, projectPath || undefined);
|
||||
setShareRecords(r.shares || []);
|
||||
} catch {
|
||||
setShareRecords([]);
|
||||
} finally {
|
||||
setIsLoadingShares(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Refresh share tokens when session changes
|
||||
useEffect(() => {
|
||||
setShareUrl('');
|
||||
setShareToken('');
|
||||
setShareExpiresAt('');
|
||||
void refreshShares(selectedSessionKey);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedSessionKey, projectPath]);
|
||||
|
||||
// Init xterm
|
||||
useEffect(() => {
|
||||
if (!terminalHostRef.current) return;
|
||||
@@ -282,18 +322,38 @@ export function IssueTerminalTab({ issueId }: { issueId: string }) {
|
||||
if (!selectedSessionKey) return;
|
||||
setError(null);
|
||||
setShareUrl('');
|
||||
setShareToken('');
|
||||
setShareExpiresAt('');
|
||||
try {
|
||||
const r = await createCliSessionShareToken(selectedSessionKey, { mode: 'read' }, projectPath || undefined);
|
||||
const url = new URL(window.location.href);
|
||||
const base = (import.meta.env.BASE_URL ?? '/').replace(/\/$/, '');
|
||||
url.pathname = `${base}/cli-sessions/share`;
|
||||
url.search = `sessionKey=${encodeURIComponent(selectedSessionKey)}&shareToken=${encodeURIComponent(r.shareToken)}`;
|
||||
setShareUrl(url.toString());
|
||||
setShareUrl(buildShareLink(selectedSessionKey, r.shareToken));
|
||||
setShareToken(r.shareToken);
|
||||
setShareExpiresAt(r.expiresAt);
|
||||
void refreshShares(selectedSessionKey);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevokeShareLink = async (token: string) => {
|
||||
if (!selectedSessionKey || !token) return;
|
||||
setIsRevokingShare(true);
|
||||
setError(null);
|
||||
try {
|
||||
await revokeCliSessionShareToken(selectedSessionKey, { shareToken: token }, projectPath || undefined);
|
||||
if (token === shareToken) {
|
||||
setShareUrl('');
|
||||
setShareToken('');
|
||||
setShareExpiresAt('');
|
||||
}
|
||||
void refreshShares(selectedSessionKey);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setIsRevokingShare(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyShareLink = async () => {
|
||||
if (!shareUrl) return;
|
||||
try {
|
||||
@@ -352,12 +412,67 @@ export function IssueTerminalTab({ issueId }: { issueId: string }) {
|
||||
</div>
|
||||
|
||||
{shareUrl && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input value={shareUrl} readOnly />
|
||||
<Button variant="outline" onClick={handleCopyShareLink}>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'common.actions.copy' })}
|
||||
</Button>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input value={shareUrl} readOnly />
|
||||
<Button variant="outline" onClick={handleCopyShareLink}>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'common.actions.copy' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleRevokeShareLink(shareToken)}
|
||||
disabled={isRevokingShare || !shareToken}
|
||||
>
|
||||
{formatMessage({ id: 'issues.terminal.session.revokeShare' })}
|
||||
</Button>
|
||||
</div>
|
||||
{shareExpiresAt && (
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{formatMessage({ id: 'issues.terminal.session.expiresAt' })}: {shareExpiresAt}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedSessionKey && shareRecords.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'issues.terminal.session.activeShares' })}
|
||||
{isLoadingShares ? '…' : ''}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{shareRecords.map((s) => (
|
||||
<div key={s.shareToken} className="flex items-center gap-2">
|
||||
<div className="text-xs font-mono truncate flex-1 min-w-0">
|
||||
{s.shareToken.slice(0, 6)}…{s.shareToken.slice(-6)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">{s.mode}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono truncate max-w-[220px]">{s.expiresAt}</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(buildShareLink(selectedSessionKey, s.shareToken));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}}
|
||||
>
|
||||
{formatMessage({ id: 'common.actions.copy' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isRevokingShare}
|
||||
onClick={() => handleRevokeShareLink(s.shareToken)}
|
||||
>
|
||||
{formatMessage({ id: 'issues.terminal.session.revokeShare' })}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { QueueCard } from '@/components/issue/queue/QueueCard';
|
||||
import { QueueBoard } from '@/components/issue/queue/QueueBoard';
|
||||
import { SolutionDrawer } from '@/components/issue/queue/SolutionDrawer';
|
||||
import { useIssueQueue, useQueueHistory, useQueueMutations } from '@/hooks';
|
||||
import { useIssueQueue, useIssueQueueById, useQueueHistory, useQueueMutations } from '@/hooks';
|
||||
import type { QueueItem } from '@/lib/api';
|
||||
|
||||
// ========== Loading Skeleton ==========
|
||||
@@ -74,9 +74,11 @@ function QueueEmptyState() {
|
||||
export function QueuePanel() {
|
||||
const { formatMessage } = useIntl();
|
||||
const [selectedItem, setSelectedItem] = useState<QueueItem | null>(null);
|
||||
const [selectedQueueId, setSelectedQueueId] = useState<string>('');
|
||||
|
||||
const { data: queueData, isLoading, error } = useIssueQueue();
|
||||
const activeQueueQuery = useIssueQueue();
|
||||
const { data: historyIndex } = useQueueHistory();
|
||||
const selectedQueueQuery = useIssueQueueById(selectedQueueId);
|
||||
const {
|
||||
activateQueue,
|
||||
deactivateQueue,
|
||||
@@ -90,8 +92,12 @@ export function QueuePanel() {
|
||||
isSplitting,
|
||||
} = useQueueMutations();
|
||||
|
||||
// Get queue data with proper type
|
||||
const queue = queueData;
|
||||
const queue = selectedQueueId && selectedQueueQuery.data ? selectedQueueQuery.data : activeQueueQuery.data;
|
||||
const isLoading =
|
||||
activeQueueQuery.isLoading ||
|
||||
(selectedQueueId ? selectedQueueQuery.isLoading && !selectedQueueQuery.data : false);
|
||||
const error = activeQueueQuery.error || selectedQueueQuery.error;
|
||||
|
||||
const taskCount = queue?.tasks?.length || 0;
|
||||
const solutionCount = queue?.solutions?.length || 0;
|
||||
const conflictCount = queue?.conflicts?.length || 0;
|
||||
@@ -100,13 +106,13 @@ export function QueuePanel() {
|
||||
const activeQueueId = historyIndex?.active_queue_id || null;
|
||||
const activeQueueIds = historyIndex?.active_queue_ids || [];
|
||||
const queueId = queue?.id;
|
||||
const [selectedQueueId, setSelectedQueueId] = useState<string>('');
|
||||
|
||||
// Keep selector in sync with active queue id
|
||||
useEffect(() => {
|
||||
if (selectedQueueId) return;
|
||||
if (activeQueueId) setSelectedQueueId(activeQueueId);
|
||||
else if (queueId) setSelectedQueueId(queueId);
|
||||
}, [activeQueueId, queueId]);
|
||||
}, [activeQueueId, queueId, selectedQueueId]);
|
||||
|
||||
const handleActivate = async (queueId: string) => {
|
||||
try {
|
||||
|
||||
@@ -130,7 +130,9 @@ export function QueueActions({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => onActivate(queueId)}
|
||||
onClick={() => {
|
||||
if (queueId) onActivate(queueId);
|
||||
}}
|
||||
disabled={isActivating || !queueId}
|
||||
title={formatMessage({ id: 'issues.queue.actions.activate' })}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user