Skip to content

Commit 44ee598

Browse files
committed
feat(desktop): copy a session from the session row menu
Add "Copy session" to the session row menu (the row's more button and the right-click menu share one menu). The copy is produced by the existing session fork port instead of a second copy algorithm: ForkSessionRequest.source_turn_id becomes optional, and omitting it copies the whole session through its latest persisted turn, so the controller never has to load a session's turns for a navigation action. - route the web/WS adapter by body: session/forkAtTurn with a turn id, session/fork without one - keep the copy out of the peer-device retry and idempotent allowlists: the host has no idempotency key for it, so a retry would create a second copy - disable the action while a turn is running (not a stable copy boundary) and for sessions owned by another device; archived sessions can be copied and the active session is not switched - reuse the existing fork lineage naming (title -> "title (2)") - i18n: en-US / zh-CN / zh-TW
1 parent 9e51317 commit 44ee598

15 files changed

Lines changed: 471 additions & 27 deletions

File tree

‎src/apps/desktop/src/api/session_api.rs‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,12 @@ fn default_include_hidden_subagents() -> bool {
240240
#[derive(Debug, Clone, Serialize, Deserialize)]
241241
pub struct ForkSessionRequest {
242242
pub source_session_id: String,
243-
pub source_turn_id: String,
243+
/// Turn to copy the history through. Omitted means "copy the whole
244+
/// session": the host then forks at the session's latest persisted turn.
245+
///
246+
/// The field stays on the wire for existing callers and older frontends.
247+
#[serde(default, skip_serializing_if = "Option::is_none")]
248+
pub source_turn_id: Option<String>,
244249
#[serde(default)]
245250
pub workspace_id: Option<String>,
246251
#[serde(default)]
@@ -723,6 +728,8 @@ pub async fn load_persisted_session_metadata(
723728
result
724729
}
725730

731+
/// Forks a session through a selected turn, or copies the whole session when
732+
/// `source_turn_id` is omitted.
726733
#[tauri::command]
727734
pub async fn fork_session(
728735
request: ForkSessionRequest,

‎src/apps/desktop/src/runtime/session_application.rs‎

Lines changed: 155 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ use std::time::Instant;
1111
use async_trait::async_trait;
1212
use openbitfun_agent_runtime::sdk::{
1313
AgentLocalCommandTurnRecordRequest, AgentRuntime, AgentSessionArchiveStateRequest,
14-
AgentSessionDeleteRequest, AgentSessionForkAtTurnRequest, AgentSessionLineageRequest,
15-
AgentSessionLineageSnapshot, AgentSessionRenameRequest, AgentSessionUsageRequest,
16-
PortErrorKind, RuntimeError, SessionEventBackfill, SessionEventJournal,
17-
SessionEventProjectionSnapshot, SessionInteractionSnapshot,
14+
AgentSessionDeleteRequest, AgentSessionForkAtTurnRequest, AgentSessionForkRequest,
15+
AgentSessionLineageRequest, AgentSessionLineageSnapshot, AgentSessionRenameRequest,
16+
AgentSessionUsageRequest, PortErrorKind, RuntimeError, SessionEventBackfill,
17+
SessionEventJournal, SessionEventProjectionSnapshot, SessionInteractionSnapshot,
1818
};
1919
use openbitfun_core::agentic::coordination::{ConversationCoordinator, DialogScheduler};
2020
use openbitfun_core::agentic::core::Session;
@@ -621,31 +621,28 @@ impl DesktopSessionApplication {
621621
Ok(report)
622622
}
623623

624+
/// Copies a session into a new independent session.
625+
///
626+
/// `source_turn_id` selects a prefix of the source history. Omitting it
627+
/// copies the whole session, which is the runtime's latest-persisted-turn
628+
/// fork (`AgentSessionForkRequest`); the Desktop "Copy session" action uses
629+
/// that form so the controller never has to load a session's turns just to
630+
/// ask for a copy.
624631
pub(crate) async fn fork_session(
625632
&self,
626633
request: DesktopSessionScopeRequest,
627634
source_session_id: String,
628-
source_turn_id: String,
635+
source_turn_id: Option<String>,
629636
) -> DesktopSessionApplicationResult<SessionBranchResult> {
630637
let scope = self.resolved_scope(request).await?;
631638
self.ensure_runtime_ownership(&scope)?;
632-
let result = self
633-
.agent_runtime
634-
.fork_session_at_turn(AgentSessionForkAtTurnRequest {
635-
workspace_id: Some(scope.workspace_id.clone()),
636-
workspace_path: String::new(),
637-
source_session_id,
638-
source_turn_id,
639-
remote_connection_id: scope.remote_connection_id,
640-
remote_ssh_host: scope.resolved_remote_ssh_host,
641-
})
642-
.await
643-
.map_err(|error| DesktopSessionApplicationError::Runtime(error.into_message()))?;
644-
Ok(SessionBranchResult {
645-
session_id: result.session_id,
646-
session_name: result.session_name,
647-
agent_type: result.agent_type,
648-
})
639+
fork_session_in_scope(
640+
&self.agent_runtime,
641+
scope,
642+
source_session_id,
643+
source_turn_id,
644+
)
645+
.await
649646
}
650647

651648
pub(crate) async fn set_session_archived(
@@ -917,6 +914,55 @@ impl DesktopSessionApplication {
917914
}
918915
}
919916

917+
/// Copies a session into a new independent session.
918+
///
919+
/// This is the Agent Runtime's session fork, which is the only owner of session
920+
/// copy semantics: it copies every persisted turn, turn context snapshot, skill
921+
/// snapshot, compression transcript, and evidence-ledger event under a new
922+
/// session id, and resets the inherited child/relationship state. Passing a
923+
/// turn id copies the history through that turn; passing `None` copies the whole
924+
/// session by forking at the latest persisted turn. The source session keeps its
925+
/// identity, history, and metadata.
926+
async fn fork_session_in_scope(
927+
agent_runtime: &AgentRuntime,
928+
scope: ResolvedDesktopSessionScope,
929+
source_session_id: String,
930+
source_turn_id: Option<String>,
931+
) -> DesktopSessionApplicationResult<SessionBranchResult> {
932+
let workspace_id = Some(scope.workspace_id.clone());
933+
let result = match source_turn_id {
934+
Some(source_turn_id) => {
935+
agent_runtime
936+
.fork_session_at_turn(AgentSessionForkAtTurnRequest {
937+
workspace_id,
938+
workspace_path: String::new(),
939+
source_session_id,
940+
source_turn_id,
941+
remote_connection_id: scope.remote_connection_id,
942+
remote_ssh_host: scope.resolved_remote_ssh_host,
943+
})
944+
.await
945+
}
946+
None => {
947+
agent_runtime
948+
.fork_session(AgentSessionForkRequest {
949+
workspace_id,
950+
workspace_path: String::new(),
951+
source_session_id,
952+
remote_connection_id: scope.remote_connection_id,
953+
remote_ssh_host: scope.resolved_remote_ssh_host,
954+
})
955+
.await
956+
}
957+
}
958+
.map_err(|error| DesktopSessionApplicationError::Runtime(error.into_message()))?;
959+
Ok(SessionBranchResult {
960+
session_id: result.session_id,
961+
session_name: result.session_name,
962+
agent_type: result.agent_type,
963+
})
964+
}
965+
920966
async fn delete_session_with_host_effects(
921967
agent_runtime: &AgentRuntime,
922968
host_effects: &dyn DesktopSessionHostEffects,
@@ -970,6 +1016,7 @@ mod tests {
9701016
use super::*;
9711017
use openbitfun_agent_runtime::sdk::{
9721018
AgentRuntimeBuilder, AgentSessionCreateRequest, AgentSessionCreateResult,
1019+
AgentSessionForkPort, AgentSessionForkRequest, AgentSessionForkResult,
9731020
AgentSessionListRequest, AgentSessionManagementPort, AgentSessionSummary,
9741021
AgentSessionWorkspaceBinding, AgentSessionWorkspaceRequest, AgentSubmissionPort,
9751022
AgentSubmissionRequest, AgentSubmissionResult, PortError, PortErrorKind, PortResult,
@@ -1164,6 +1211,92 @@ mod tests {
11641211
.expect("delete test runtime")
11651212
}
11661213

1214+
struct RecordingForkPort {
1215+
calls: Arc<Mutex<Vec<&'static str>>>,
1216+
workspace_id: Arc<Mutex<Option<String>>>,
1217+
}
1218+
1219+
#[async_trait]
1220+
impl AgentSessionForkPort for RecordingForkPort {
1221+
async fn fork_session(
1222+
&self,
1223+
request: AgentSessionForkRequest,
1224+
) -> PortResult<AgentSessionForkResult> {
1225+
self.calls.lock().unwrap().push("whole_session");
1226+
*self.workspace_id.lock().unwrap() = request.workspace_id.clone();
1227+
Ok(AgentSessionForkResult {
1228+
session_id: "copy-1".to_string(),
1229+
session_name: "Source (1)".to_string(),
1230+
agent_type: "Standard".to_string(),
1231+
})
1232+
}
1233+
1234+
async fn fork_session_at_turn(
1235+
&self,
1236+
_request: openbitfun_agent_runtime::sdk::AgentSessionForkAtTurnRequest,
1237+
) -> PortResult<AgentSessionForkResult> {
1238+
self.calls.lock().unwrap().push("through_turn");
1239+
Ok(AgentSessionForkResult {
1240+
session_id: "prefix-1".to_string(),
1241+
session_name: "Source (2)".to_string(),
1242+
agent_type: "Standard".to_string(),
1243+
})
1244+
}
1245+
}
1246+
1247+
fn fork_test_runtime(
1248+
calls: Arc<Mutex<Vec<&'static str>>>,
1249+
workspace_id: Arc<Mutex<Option<String>>>,
1250+
) -> AgentRuntime {
1251+
AgentRuntimeBuilder::new()
1252+
.with_submission_port(Arc::new(NoopSubmissionPort))
1253+
.with_session_fork_port(Arc::new(RecordingForkPort {
1254+
calls,
1255+
workspace_id,
1256+
}))
1257+
.build()
1258+
.expect("fork test runtime")
1259+
}
1260+
1261+
/// The Desktop "copy session" action must copy the whole session without a
1262+
/// caller-supplied turn id, so it rides the runtime's latest-persisted-turn
1263+
/// fork. A selected-turn copy keeps using the explicit-turn fork.
1264+
#[tokio::test]
1265+
async fn whole_session_copy_uses_the_latest_persisted_turn_fork() {
1266+
let calls = Arc::new(Mutex::new(Vec::new()));
1267+
let workspace_id = Arc::new(Mutex::new(None));
1268+
let runtime = fork_test_runtime(calls.clone(), workspace_id.clone());
1269+
1270+
let copied =
1271+
fork_session_in_scope(&runtime, delete_test_scope(), "session-1".to_string(), None)
1272+
.await
1273+
.expect("whole-session copy should succeed");
1274+
1275+
assert_eq!(calls.lock().unwrap().as_slice(), ["whole_session"]);
1276+
assert_eq!(
1277+
workspace_id.lock().unwrap().as_deref(),
1278+
Some("test-workspace")
1279+
);
1280+
assert_eq!(copied.session_id, "copy-1");
1281+
assert_eq!(copied.session_name, "Source (1)");
1282+
assert_eq!(copied.agent_type, "Standard");
1283+
1284+
let prefixed = fork_session_in_scope(
1285+
&runtime,
1286+
delete_test_scope(),
1287+
"session-1".to_string(),
1288+
Some("turn-2".to_string()),
1289+
)
1290+
.await
1291+
.expect("turn-prefix copy should succeed");
1292+
1293+
assert_eq!(
1294+
calls.lock().unwrap().as_slice(),
1295+
["whole_session", "through_turn"]
1296+
);
1297+
assert_eq!(prefixed.session_id, "prefix-1");
1298+
}
1299+
11671300
#[test]
11681301
fn live_processing_state_overlays_sanitized_view_state() {
11691302
let mut restored = Session::new_with_id(

‎src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,8 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
294294
/** Second level of the session menu: pick what a Markdown export includes. */
295295
const [isExportScopeMenu, setIsExportScopeMenu] = useState(false);
296296
const [exportingSessionId, setExportingSessionId] = useState<string | null>(null);
297+
/** Session whose copy request is in flight; one copy runs at a time. */
298+
const [duplicatingSessionId, setDuplicatingSessionId] = useState<string | null>(null);
297299
const orderingRevision = useSyncExternalStore(
298300
sessionNavStatusService.subscribeOrdering,
299301
sessionNavStatusService.getOrderingSnapshot,
@@ -1289,6 +1291,39 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
12891291
[t]
12901292
);
12911293

1294+
/**
1295+
* Copy the session into a new independent session. The copy is created by the
1296+
* host and appears in this list; the current row and the active session stay
1297+
* untouched (delete and archive never move the user either).
1298+
*/
1299+
const handleDuplicateSession = useCallback(
1300+
async (e: React.MouseEvent, sessionId: string) => {
1301+
e.stopPropagation();
1302+
closeSessionMenu();
1303+
if (duplicatingSessionId) return;
1304+
1305+
setDuplicatingSessionId(sessionId);
1306+
try {
1307+
const copied = await flowChatManager.duplicateChatSession(sessionId);
1308+
notificationService.success(
1309+
t('nav.sessions.duplicateSuccess', { name: copied.sessionName }),
1310+
{ duration: 3000 },
1311+
);
1312+
} catch (error) {
1313+
log.error('Failed to copy session', { sessionId, error });
1314+
notificationService.error(
1315+
error instanceof Error && error.message
1316+
? error.message
1317+
: t('nav.sessions.duplicateFailed'),
1318+
{ duration: 4000 },
1319+
);
1320+
} finally {
1321+
setDuplicatingSessionId(null);
1322+
}
1323+
},
1324+
[closeSessionMenu, duplicatingSessionId, t],
1325+
);
1326+
12921327
/**
12931328
* A worktree directory is registered for execution but not opened, so the
12941329
* session row is the only place it can be reached from. Opening it as a
@@ -1938,6 +1973,28 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
19381973
>
19391974
<span>{t('nav.sessions.copySessionId')}</span>
19401975
</MenuItem>
1976+
<MenuItem
1977+
type="button"
1978+
leading={duplicatingSessionId === session.sessionId
1979+
? <Loader2 className="openbitfun-nav-panel__inline-toggle-spinner" aria-hidden />
1980+
: <Icon name="files" size="sm" />}
1981+
onClick={e => { void handleDuplicateSession(e, session.sessionId); }}
1982+
// A running turn is still being persisted, so its
1983+
// history is not a stable copy boundary; a session
1984+
// owned by another device has no local history.
1985+
disabled={isDispatched
1986+
|| runningSessionIds.has(session.sessionId)
1987+
|| duplicatingSessionId === session.sessionId}
1988+
title={isDispatched
1989+
? t('nav.sessions.duplicateDispatchUnsupported')
1990+
: runningSessionIds.has(session.sessionId)
1991+
? t('nav.sessions.duplicateRunning')
1992+
: undefined}
1993+
data-testid="nav-session-menu-duplicate"
1994+
data-session-id={session.sessionId}
1995+
>
1996+
<span>{t('nav.sessions.duplicate')}</span>
1997+
</MenuItem>
19411998
<MenuItem
19421999
type="button"
19432000
leading={<Icon glyph={FolderOpen} size="sm" />}

‎src/web-ui/src/app/components/NavPanel/sections/sessions/sessionRowContextMenu.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,27 @@ describe('SessionsSection session row context menu', () => {
4242
expect(source).toContain("visibility: sessionMenuPosition ? 'visible' : 'hidden'");
4343
expect(source).toContain('data-testid="nav-session-menu"');
4444
});
45+
46+
it('exposes the copy action to both menu entry points as one shared row', () => {
47+
const source = readSessionsSectionSource();
48+
// The copy action lives in the single shared Menu, so the "more" button and
49+
// right click open the same entry with the same handler.
50+
expect(source).toContain('data-testid="nav-session-menu-duplicate"');
51+
expect(source).toMatch(/handleDuplicateSession = useCallback\(\s*async \(e: React\.MouseEvent, sessionId: string\)/);
52+
expect(source).toContain('await flowChatManager.duplicateChatSession(sessionId)');
53+
expect(source).toContain("t('nav.sessions.duplicateSuccess'");
54+
expect(source).toContain('{ name: copied.sessionName }');
55+
expect(source).toContain("t('nav.sessions.duplicateFailed')");
56+
});
57+
58+
it('keeps copy unavailable while a turn runs or another device owns the session', () => {
59+
const source = readSessionsSectionSource();
60+
// A running turn is not a stable copy boundary, and a dispatch-owned session
61+
// has no local history to copy. Both entry points share these guards.
62+
expect(source).toContain('disabled={isDispatched');
63+
expect(source).toContain('|| runningSessionIds.has(session.sessionId)');
64+
expect(source).toContain('|| duplicatingSessionId === session.sessionId}');
65+
expect(source).toContain("t('nav.sessions.duplicateDispatchUnsupported')");
66+
expect(source).toContain("t('nav.sessions.duplicateRunning')");
67+
});
4568
});

‎src/web-ui/src/flow_chat/services/FlowChatManager.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
renameChatSessionTitle as renameChatSessionTitleModule,
5151
reloadSessionTitle as reloadSessionTitleModule,
5252
forkChatSession as forkChatSessionModule,
53+
duplicateChatSession as duplicateChatSessionModule,
5354
cleanupSaveState,
5455
cleanupSessionBuffers,
5556
sendMessage as sendMessageModule,
@@ -746,6 +747,12 @@ export class FlowChatManager {
746747
return forkChatSessionModule(this.context, sourceSessionId, sourceTurnId);
747748
}
748749

750+
async duplicateChatSession(
751+
sourceSessionId: string,
752+
): Promise<{ sessionId: string; sessionName: string }> {
753+
return duplicateChatSessionModule(this.context, sourceSessionId);
754+
}
755+
749756
async resetWorkspaceSessions(
750757
workspace: Pick<WorkspaceInfo, 'id' | 'rootPath' | 'workspaceKind' | 'connectionId' | 'sshHost'>,
751758
options?: {

0 commit comments

Comments
 (0)