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:
catlog22
2026-02-09 22:57:05 +08:00
parent 362f354f1c
commit d0cdee2e68
18 changed files with 748 additions and 23 deletions

View File

@@ -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>
)}

View File

@@ -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 {

View File

@@ -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' })}
>