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

View File

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

View File

@@ -69,6 +69,7 @@ export type {
export {
useIssues,
useIssueQueue,
useIssueQueueById,
useQueueHistory,
useCreateIssue,
useUpdateIssue,

View File

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

View File

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

View File

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

View File

@@ -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",

View File

@@ -120,7 +120,18 @@
"refresh": "刷新",
"new": "新建会话",
"close": "关闭",
"share": "分享(只读)"
"share": "分享(只读)",
"revokeShare": "撤销分享",
"expiresAt": "过期时间",
"activeShares": "分享列表"
},
"share": {
"pageTitle": "共享终端会话",
"missingParams": "链接缺少 sessionKey 或 shareToken 参数",
"connecting": "连接中",
"connected": "实时",
"error": "错误",
"linkLabel": "分享链接"
},
"exec": {
"tool": "工具",

View 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;

View File

@@ -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';

View File

@@ -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 />,