diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index c725c85e..9309665d 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -26,6 +26,7 @@ import { ackRun, runWorkflowNow } from '@/shared/state/workflowsSlice'; import { setPendingBrowserUrl } from '@/shared/state/tempStateSlice'; import { fetchOutputs } from '@/shared/state/outputsSlice'; import UpdateReadyPill from '@/app/components/Layout/UpdateReadyPill'; +import ShareRequestHost from '@/app/components/share/ShareRequestHost'; import { findBrowserByWebContentsId } from '@/shared/browserRegistry'; import { byPreviewRecency } from '@/shared/previewOrder'; import { useClaudeTokens, useThemeAccent, useThemeWash } from '@/shared/styles/ThemeContext'; @@ -574,6 +575,8 @@ const AppShell: React.FC = () => { + + ); }; diff --git a/frontend/src/app/components/share/ShareRequestHost.tsx b/frontend/src/app/components/share/ShareRequestHost.tsx new file mode 100644 index 00000000..f80705ab --- /dev/null +++ b/frontend/src/app/components/share/ShareRequestHost.tsx @@ -0,0 +1,27 @@ +import React, { useEffect, useState } from 'react'; +import ShareModal from './ShareModal'; +import type { ShareTarget } from './shareTypes'; + +export const SHARE_REQUEST_EVENT = 'openswarm:share-entity'; + +export function requestShare(target: ShareTarget): void { + window.dispatchEvent(new CustomEvent(SHARE_REQUEST_EVENT, { detail: target })); +} + +/** One global mount that turns share requests from stateless menu rows (card context menu, dock + * tiles) into the ShareModal; the row can't own modal state because the menu unmounts on click. */ +const ShareRequestHost: React.FC = () => { + const [target, setTarget] = useState(null); + useEffect(() => { + const onShare = (e: Event): void => { + const detail = (e as CustomEvent).detail as ShareTarget | undefined; + if (detail && detail.kind && detail.id) setTarget(detail); + }; + window.addEventListener(SHARE_REQUEST_EVENT, onShare); + return () => window.removeEventListener(SHARE_REQUEST_EVENT, onShare); + }, []); + if (!target) return null; + return setTarget(null)} />; +}; + +export default ShareRequestHost; diff --git a/frontend/src/app/components/share/shareTypes.ts b/frontend/src/app/components/share/shareTypes.ts index 737efcf8..dff1bb45 100644 --- a/frontend/src/app/components/share/shareTypes.ts +++ b/frontend/src/app/components/share/shareTypes.ts @@ -1,6 +1,6 @@ // Shared types for the .swarm share/import UI. The *Response shapes mirror the backend pydantic models in backend/apps/swarm/models.py; keep them in sync. -export type ShareKind = 'skill' | 'app' | 'workflow' | 'dashboard'; +export type ShareKind = 'skill' | 'app' | 'workflow' | 'dashboard' | 'session'; export interface ShareTarget { kind: ShareKind; diff --git a/frontend/src/app/pages/Dashboard/cards/agentCardMenuRows.ts b/frontend/src/app/pages/Dashboard/cards/agentCardMenuRows.ts index c34e591a..4c2f05d3 100644 --- a/frontend/src/app/pages/Dashboard/cards/agentCardMenuRows.ts +++ b/frontend/src/app/pages/Dashboard/cards/agentCardMenuRows.ts @@ -1,6 +1,9 @@ import { clearSessionMessages, deleteSession, duplicateSession, expandSession, collapseSession, stopAgent, fetchSession, type AgentSession } from '@/shared/state/agentsSlice'; import { placeCard, bringToFront } from '@/shared/state/dashboardLayoutSlice'; import { setClipboardCards } from '@/shared/dashboardClipboard'; +import { copySessionResponse } from '@/shared/copySessionResponse'; +import { requestShare } from '@/app/components/share/ShareRequestHost'; +import { displaySessionName } from '@/shared/state/sessionDisplay'; import { handleSlashCommand } from '@/app/pages/AgentChat/ChatInput/hooks/slashCommands'; import type { AppDispatch } from '@/shared/state/store'; import type { CardMenuRow } from '../desktop/openCardContextMenu'; @@ -46,6 +49,8 @@ export function agentCardMenuRows({ session, dispatch, expanded, tileZone, expan x: card.x, y: card.y, width: card.width, height: card.height, expanded, }]), }, + { label: 'Copy response', onClick: () => { copySessionResponse(session.id); } }, + { label: 'Share as .swarm…', onClick: () => requestShare({ kind: 'session', id: session.id, name: displaySessionName(session.name) }) }, { kind: 'separator' }, { kind: 'header', label: 'Session' }, { label: 'Stop turn', disabled: !running, onClick: () => { void dispatch(stopAgent({ sessionId: session.id })); } }, diff --git a/frontend/src/app/pages/Dashboard/desktop/dockTileMenuRows.ts b/frontend/src/app/pages/Dashboard/desktop/dockTileMenuRows.ts index b81bf079..d77d5079 100644 --- a/frontend/src/app/pages/Dashboard/desktop/dockTileMenuRows.ts +++ b/frontend/src/app/pages/Dashboard/desktop/dockTileMenuRows.ts @@ -1,5 +1,7 @@ import { bringToFront, recordClosedCard, removeCard, removeWorkflowCard } from '@/shared/state/dashboardLayoutSlice'; import { closeSession } from '@/shared/state/agentsSlice'; +import { copySessionResponse } from '@/shared/copySessionResponse'; +import { requestShare } from '@/app/components/share/ShareRequestHost'; import { removeBrowserCardCleanly } from '@/shared/browserTeardown'; import { removeViewCardCleanly } from '@/shared/viewTeardown'; import type { AppDispatch } from '@/shared/state/store'; @@ -10,6 +12,10 @@ export function dockTileMenuRows(entry: DockEntry, dispatch: AppDispatch, onFocu return [ { label: 'Show on canvas', onClick: onFocus }, { label: 'Bring to front', onClick: () => dispatch(bringToFront({ id: entry.id, type: entry.kind })) }, + ...(entry.kind === 'agent' ? [ + { label: 'Copy response', onClick: () => { copySessionResponse(entry.id); } } as CardMenuRow, + { label: 'Share as .swarm…', onClick: () => requestShare({ kind: 'session', id: entry.id, name: entry.label }) } as CardMenuRow, + ] : []), { kind: 'separator' }, { label: 'Close', diff --git a/frontend/src/shared/copySessionResponse.ts b/frontend/src/shared/copySessionResponse.ts new file mode 100644 index 00000000..54b6e6cb --- /dev/null +++ b/frontend/src/shared/copySessionResponse.ts @@ -0,0 +1,13 @@ +import { store } from '@/shared/state/store'; + +/** Copy the chat's latest assistant answer to the clipboard; true when something was copied. + * Menu rows (card context menu, dock tiles) call this statelessly. */ +export function copySessionResponse(sessionId: string): boolean { + const session = store.getState().agents.sessions[sessionId]; + const last = [...(session?.messages ?? [])] + .reverse() + .find((m) => m.role === 'assistant' && typeof m.content === 'string' && m.content.trim()); + if (!last) return false; + void navigator.clipboard.writeText(last.content as string); + return true; +}