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' })}
|
||||
>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/Dropdown';
|
||||
import type { DraggableProvidedDragHandleProps, DraggableProvidedDraggableProps } from '@hello-pangea/dnd';
|
||||
import type { Issue } from '@/lib/api';
|
||||
|
||||
// ========== Types ==========
|
||||
@@ -35,8 +36,8 @@ export interface IssueCardProps {
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
showActions?: boolean;
|
||||
draggableProps?: Record<string, unknown>;
|
||||
dragHandleProps?: Record<string, unknown>;
|
||||
draggableProps?: DraggableProvidedDraggableProps;
|
||||
dragHandleProps?: DraggableProvidedDragHandleProps | null;
|
||||
innerRef?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ export type {
|
||||
export {
|
||||
useIssues,
|
||||
useIssueQueue,
|
||||
useIssueQueueById,
|
||||
useQueueHistory,
|
||||
useCreateIssue,
|
||||
useUpdateIssue,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
fetchIssues,
|
||||
fetchIssueHistory,
|
||||
fetchIssueQueue,
|
||||
fetchQueueById,
|
||||
fetchQueueHistory,
|
||||
createIssue,
|
||||
updateIssue,
|
||||
@@ -208,6 +209,23 @@ export function useIssueQueue(): UseQueryResult<IssueQueue> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching a specific queue by ID
|
||||
*/
|
||||
export function useIssueQueueById(queueId?: string): UseQueryResult<IssueQueue> {
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
return useQuery<IssueQueue>({
|
||||
queryKey:
|
||||
projectPath && queueId
|
||||
? workspaceQueryKeys.issueQueueById(projectPath, queueId)
|
||||
: ['issueQueueById', projectPath ?? 'no-project', queueId ?? 'no-queue'],
|
||||
queryFn: () => fetchQueueById(queueId!, projectPath),
|
||||
staleTime: STALE_TIME,
|
||||
enabled: !!projectPath && !!queueId,
|
||||
retry: 2,
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Mutations ==========
|
||||
|
||||
export interface UseCreateIssueReturn {
|
||||
@@ -346,6 +364,7 @@ export function useQueueMutations(): UseQueueMutationsReturn {
|
||||
mutationFn: (queueId: string) => activateQueue(queueId, projectPath),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) });
|
||||
queryClient.invalidateQueries({ queryKey: [...workspaceQueryKeys.issues(projectPath), 'queueById'] });
|
||||
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueueHistory(projectPath) });
|
||||
},
|
||||
});
|
||||
@@ -354,6 +373,7 @@ export function useQueueMutations(): UseQueueMutationsReturn {
|
||||
mutationFn: () => deactivateQueue(projectPath),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) });
|
||||
queryClient.invalidateQueries({ queryKey: [...workspaceQueryKeys.issues(projectPath), 'queueById'] });
|
||||
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueueHistory(projectPath) });
|
||||
},
|
||||
});
|
||||
@@ -362,6 +382,7 @@ export function useQueueMutations(): UseQueueMutationsReturn {
|
||||
mutationFn: (queueId: string) => deleteQueueApi(queueId, projectPath),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) });
|
||||
queryClient.invalidateQueries({ queryKey: [...workspaceQueryKeys.issues(projectPath), 'queueById'] });
|
||||
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueueHistory(projectPath) });
|
||||
},
|
||||
});
|
||||
@@ -371,6 +392,7 @@ export function useQueueMutations(): UseQueueMutationsReturn {
|
||||
mergeQueuesApi(sourceId, targetId, projectPath),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) });
|
||||
queryClient.invalidateQueries({ queryKey: [...workspaceQueryKeys.issues(projectPath), 'queueById'] });
|
||||
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueueHistory(projectPath) });
|
||||
},
|
||||
});
|
||||
@@ -380,6 +402,7 @@ export function useQueueMutations(): UseQueueMutationsReturn {
|
||||
splitQueueApi(sourceQueueId, itemIds, projectPath),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) });
|
||||
queryClient.invalidateQueries({ queryKey: [...workspaceQueryKeys.issues(projectPath), 'queueById'] });
|
||||
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueueHistory(projectPath) });
|
||||
},
|
||||
});
|
||||
@@ -389,6 +412,7 @@ export function useQueueMutations(): UseQueueMutationsReturn {
|
||||
reorderQueueGroupApi(projectPath, { groupId, newOrder }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) });
|
||||
queryClient.invalidateQueries({ queryKey: [...workspaceQueryKeys.issues(projectPath), 'queueById'] });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -397,6 +421,7 @@ export function useQueueMutations(): UseQueueMutationsReturn {
|
||||
moveQueueItemApi(projectPath, { itemId, toGroupId, toIndex }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) });
|
||||
queryClient.invalidateQueries({ queryKey: [...workspaceQueryKeys.issues(projectPath), 'queueById'] });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -842,6 +842,13 @@ export async function fetchQueueHistory(projectPath: string): Promise<QueueHisto
|
||||
return fetchApi<QueueHistoryIndex>(`/api/queue/history?path=${encodeURIComponent(projectPath)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a specific queue by ID
|
||||
*/
|
||||
export async function fetchQueueById(queueId: string, projectPath: string): Promise<IssueQueue> {
|
||||
return fetchApi<IssueQueue>(`/api/queue/${encodeURIComponent(queueId)}?path=${encodeURIComponent(projectPath)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate a queue
|
||||
*/
|
||||
@@ -5797,3 +5804,23 @@ export async function createCliSessionShareToken(
|
||||
{ method: 'POST', body: JSON.stringify(input) }
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchCliSessionShares(
|
||||
sessionKey: string,
|
||||
projectPath?: string
|
||||
): Promise<{ shares: Array<{ shareToken: string; expiresAt: string; mode: 'read' | 'write' }> }> {
|
||||
return fetchApi<{ shares: Array<{ shareToken: string; expiresAt: string; mode: 'read' | 'write' }> }>(
|
||||
withPath(`/api/cli-sessions/${encodeURIComponent(sessionKey)}/shares`, projectPath)
|
||||
);
|
||||
}
|
||||
|
||||
export async function revokeCliSessionShareToken(
|
||||
sessionKey: string,
|
||||
input: { shareToken: string },
|
||||
projectPath?: string
|
||||
): Promise<{ success: boolean; revoked: boolean }> {
|
||||
return fetchApi<{ success: boolean; revoked: boolean }>(
|
||||
withPath(`/api/cli-sessions/${encodeURIComponent(sessionKey)}/share/revoke`, projectPath),
|
||||
{ method: 'POST', body: JSON.stringify(input) }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ export const workspaceQueryKeys = {
|
||||
issuesList: (projectPath: string) => [...workspaceQueryKeys.issues(projectPath), 'list'] as const,
|
||||
issuesHistory: (projectPath: string) => [...workspaceQueryKeys.issues(projectPath), 'history'] as const,
|
||||
issueQueue: (projectPath: string) => [...workspaceQueryKeys.issues(projectPath), 'queue'] as const,
|
||||
issueQueueById: (projectPath: string, queueId: string) =>
|
||||
[...workspaceQueryKeys.issues(projectPath), 'queueById', queueId] as const,
|
||||
issueQueueHistory: (projectPath: string) => [...workspaceQueryKeys.issues(projectPath), 'queueHistory'] as const,
|
||||
|
||||
// ========== Discoveries ==========
|
||||
|
||||
@@ -120,7 +120,18 @@
|
||||
"refresh": "Refresh",
|
||||
"new": "New Session",
|
||||
"close": "Close",
|
||||
"share": "Share (Read-only)"
|
||||
"share": "Share (Read-only)",
|
||||
"revokeShare": "Revoke",
|
||||
"expiresAt": "Expires at",
|
||||
"activeShares": "Active shares"
|
||||
},
|
||||
"share": {
|
||||
"pageTitle": "Shared CLI Session",
|
||||
"missingParams": "Missing sessionKey or shareToken in URL",
|
||||
"connecting": "Connecting",
|
||||
"connected": "Live",
|
||||
"error": "Error",
|
||||
"linkLabel": "Share link"
|
||||
},
|
||||
"exec": {
|
||||
"tool": "Tool",
|
||||
|
||||
@@ -120,7 +120,18 @@
|
||||
"refresh": "刷新",
|
||||
"new": "新建会话",
|
||||
"close": "关闭",
|
||||
"share": "分享(只读)"
|
||||
"share": "分享(只读)",
|
||||
"revokeShare": "撤销分享",
|
||||
"expiresAt": "过期时间",
|
||||
"activeShares": "分享列表"
|
||||
},
|
||||
"share": {
|
||||
"pageTitle": "共享终端会话",
|
||||
"missingParams": "链接缺少 sessionKey 或 shareToken 参数",
|
||||
"connecting": "连接中",
|
||||
"connected": "实时",
|
||||
"error": "错误",
|
||||
"linkLabel": "分享链接"
|
||||
},
|
||||
"exec": {
|
||||
"tool": "工具",
|
||||
|
||||
219
ccw/frontend/src/pages/CliSessionSharePage.tsx
Normal file
219
ccw/frontend/src/pages/CliSessionSharePage.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
// ========================================
|
||||
// CLI Session Share Page
|
||||
// ========================================
|
||||
// Read-only viewer for a PTY-backed CLI session using SSE + shareToken.
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Terminal as XTerm } from 'xterm';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import { Copy, Monitor } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type ConnectionState = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'error';
|
||||
|
||||
function buildStreamUrl(sessionKey: string, shareToken: string): string {
|
||||
const apiPath = `/api/cli-sessions/${encodeURIComponent(sessionKey)}/stream`;
|
||||
const params = new URLSearchParams({
|
||||
shareToken,
|
||||
includeBuffer: '1',
|
||||
});
|
||||
return `${apiPath}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function CliSessionSharePage() {
|
||||
const { formatMessage } = useIntl();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const sessionKey = searchParams.get('sessionKey') ?? '';
|
||||
const shareToken = searchParams.get('shareToken') ?? '';
|
||||
|
||||
const shareUrl = useMemo(() => (typeof window !== 'undefined' ? window.location.href : ''), []);
|
||||
|
||||
const [connection, setConnection] = useState<ConnectionState>('idle');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const terminalHostRef = useRef<HTMLDivElement | null>(null);
|
||||
const xtermRef = useRef<XTerm | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
|
||||
// Init xterm
|
||||
useEffect(() => {
|
||||
if (!terminalHostRef.current) return;
|
||||
if (xtermRef.current) return;
|
||||
|
||||
const term = new XTerm({
|
||||
convertEol: true,
|
||||
cursorBlink: false,
|
||||
disableStdin: true,
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||
fontSize: 12,
|
||||
scrollback: 10_000,
|
||||
});
|
||||
const fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(terminalHostRef.current);
|
||||
fitAddon.fit();
|
||||
|
||||
xtermRef.current = term;
|
||||
fitAddonRef.current = fitAddon;
|
||||
|
||||
return () => {
|
||||
try {
|
||||
term.dispose();
|
||||
} finally {
|
||||
xtermRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Resize observer -> fit
|
||||
useEffect(() => {
|
||||
const host = terminalHostRef.current;
|
||||
const fitAddon = fitAddonRef.current;
|
||||
if (!host || !fitAddon) return;
|
||||
|
||||
const ro = new ResizeObserver(() => fitAddon.fit());
|
||||
ro.observe(host);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Connect SSE
|
||||
useEffect(() => {
|
||||
const term = xtermRef.current;
|
||||
if (!term) return;
|
||||
|
||||
if (!sessionKey || !shareToken) {
|
||||
setConnection('error');
|
||||
setError(formatMessage({ id: 'issues.terminal.share.missingParams' }));
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setConnection('connecting');
|
||||
|
||||
const streamUrl = buildStreamUrl(sessionKey, shareToken);
|
||||
const es = new EventSource(streamUrl);
|
||||
|
||||
let hasConnected = false;
|
||||
|
||||
const onBuffer = (event: MessageEvent) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as { buffer?: string };
|
||||
term.reset();
|
||||
term.clear();
|
||||
term.write(payload.buffer || '');
|
||||
hasConnected = true;
|
||||
setConnection('connected');
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
const onOutput = (event: MessageEvent) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as { data?: string };
|
||||
if (typeof payload.data === 'string') {
|
||||
term.write(payload.data);
|
||||
}
|
||||
if (!hasConnected) {
|
||||
hasConnected = true;
|
||||
setConnection('connected');
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
const onError = () => {
|
||||
setConnection(hasConnected ? 'reconnecting' : 'connecting');
|
||||
};
|
||||
|
||||
es.addEventListener('buffer', onBuffer as any);
|
||||
es.addEventListener('output', onOutput as any);
|
||||
es.onerror = onError;
|
||||
|
||||
return () => {
|
||||
try {
|
||||
es.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
}, [formatMessage, sessionKey, shareToken]);
|
||||
|
||||
const connectionBadge = useMemo(() => {
|
||||
switch (connection) {
|
||||
case 'connected':
|
||||
return <Badge variant="success">{formatMessage({ id: 'issues.terminal.share.connected' })}</Badge>;
|
||||
case 'reconnecting':
|
||||
case 'connecting':
|
||||
return <Badge variant="secondary">{formatMessage({ id: 'issues.terminal.share.connecting' })}</Badge>;
|
||||
case 'error':
|
||||
return <Badge variant="destructive">{formatMessage({ id: 'issues.terminal.share.error' })}</Badge>;
|
||||
default:
|
||||
return <Badge variant="secondary">—</Badge>;
|
||||
}
|
||||
}, [connection, formatMessage]);
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
if (!shareUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-6">
|
||||
<div className="max-w-5xl mx-auto space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="w-5 h-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold text-foreground truncate">
|
||||
{formatMessage({ id: 'issues.terminal.share.pageTitle' })}
|
||||
</h1>
|
||||
{connectionBadge}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground font-mono truncate">
|
||||
{sessionKey || '—'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button variant="outline" onClick={handleCopyLink} disabled={!shareUrl}>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'common.actions.copy' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Card className="p-4 border-destructive/50 bg-destructive/5">
|
||||
<div className="text-sm text-destructive">{error}</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="p-3 space-y-2">
|
||||
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'issues.terminal.share.linkLabel' })}</div>
|
||||
<Input value={shareUrl} readOnly />
|
||||
</Card>
|
||||
|
||||
<div className={cn('rounded-md border border-border bg-black/90 overflow-hidden')}>
|
||||
<div ref={terminalHostRef} className="h-[70vh] w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CliSessionSharePage;
|
||||
@@ -33,5 +33,6 @@ export { GraphExplorerPage } from './GraphExplorerPage';
|
||||
export { CodexLensManagerPage } from './CodexLensManagerPage';
|
||||
export { ApiSettingsPage } from './ApiSettingsPage';
|
||||
export { CliViewerPage } from './CliViewerPage';
|
||||
export { CliSessionSharePage } from './CliSessionSharePage';
|
||||
export { IssueManagerPage } from './IssueManagerPage';
|
||||
export { TeamPage } from './TeamPage';
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
CodexLensManagerPage,
|
||||
ApiSettingsPage,
|
||||
CliViewerPage,
|
||||
CliSessionSharePage,
|
||||
TeamPage,
|
||||
} from '@/pages';
|
||||
|
||||
@@ -46,6 +47,10 @@ import {
|
||||
* All routes are wrapped in AppShell layout
|
||||
*/
|
||||
const routes: RouteObject[] = [
|
||||
{
|
||||
path: 'cli-sessions/share',
|
||||
element: <CliSessionSharePage />,
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
element: <AppShell />,
|
||||
|
||||
Reference in New Issue
Block a user