@@ -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
0 commit comments