Resume a session as a Claude tab so Shift+Enter inserts a newline #67

Merged
jknapp merged 1 commits from fix/resume-opens-claude-session into main 2026-10-17 02:22:14 +00:00
15 changed files with 135 additions and 102 deletions

No files matched your search

-1
View File
@@ -107,7 +107,6 @@
"allow-get-gateway-auth-token",
"allow-regenerate-gateway-auth-token",
"allow-list-claude-sessions",
"allow-resume-session-command",
"allow-list-container-capabilities",
"allow-list-scheduled-tasks",
"allow-add-scheduled-task",
+1 -1
View File
@@ -1 +1 @@
{"__app-acl__":{"default_permission":null,"permissions":{"allow-acquire-claude-token":{"identifier":"allow-acquire-claude-token","description":"Enables the acquire_claude_token command without any pre-configured scope.","commands":{"allow":["acquire_claude_token"],"deny":[]}},"allow-add-marketplace":{"identifier":"allow-add-marketplace","description":"Enables the add_marketplace command without any pre-configured scope.","commands":{"allow":["add_marketplace"],"deny":[]}},"allow-add-marketplace-gh-host-account":{"identifier":"allow-add-marketplace-gh-host-account","description":"Enables the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_gh_host_account"],"deny":[]}},"allow-add-marketplace-token-account":{"identifier":"allow-add-marketplace-token-account","description":"Enables the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_token_account"],"deny":[]}},"allow-add-project":{"identifier":"allow-add-project","description":"Enables the add_project command without any pre-configured scope.","commands":{"allow":["add_project"],"deny":[]}},"allow-add-scheduled-task":{"identifier":"allow-add-scheduled-task","description":"Enables the add_scheduled_task command without any pre-configured scope.","commands":{"allow":["add_scheduled_task"],"deny":[]}},"allow-apply-marketplace-now":{"identifier":"allow-apply-marketplace-now","description":"Enables the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":["apply_marketplace_now"],"deny":[]}},"allow-apply-settings-import":{"identifier":"allow-apply-settings-import","description":"Enables the apply_settings_import command without any pre-configured scope.","commands":{"allow":["apply_settings_import"],"deny":[]}},"allow-aws-sso-refresh":{"identifier":"allow-aws-sso-refresh","description":"Enables the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":["aws_sso_refresh"],"deny":[]}},"allow-build-gateway-image":{"identifier":"allow-build-gateway-image","description":"Enables the build_gateway_image command without any pre-configured scope.","commands":{"allow":["build_gateway_image"],"deny":[]}},"allow-build-image":{"identifier":"allow-build-image","description":"Enables the build_image command without any pre-configured scope.","commands":{"allow":["build_image"],"deny":[]}},"allow-build-stt-image":{"identifier":"allow-build-stt-image","description":"Enables the build_stt_image command without any pre-configured scope.","commands":{"allow":["build_stt_image"],"deny":[]}},"allow-cancel-claude-token":{"identifier":"allow-cancel-claude-token","description":"Enables the cancel_claude_token command without any pre-configured scope.","commands":{"allow":["cancel_claude_token"],"deny":[]}},"allow-cancel-marketplace-gh-login":{"identifier":"allow-cancel-marketplace-gh-login","description":"Enables the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":["cancel_marketplace_gh_login"],"deny":[]}},"allow-check-browser-view-support":{"identifier":"allow-check-browser-view-support","description":"Enables the check_browser_view_support command without any pre-configured scope.","commands":{"allow":["check_browser_view_support"],"deny":[]}},"allow-check-docker":{"identifier":"allow-check-docker","description":"Enables the check_docker command without any pre-configured scope.","commands":{"allow":["check_docker"],"deny":[]}},"allow-check-for-updates":{"identifier":"allow-check-for-updates","description":"Enables the check_for_updates command without any pre-configured scope.","commands":{"allow":["check_for_updates"],"deny":[]}},"allow-check-gateway-health":{"identifier":"allow-check-gateway-health","description":"Enables the check_gateway_health command without any pre-configured scope.","commands":{"allow":["check_gateway_health"],"deny":[]}},"allow-check-image-exists":{"identifier":"allow-check-image-exists","description":"Enables the check_image_exists command without any pre-configured scope.","commands":{"allow":["check_image_exists"],"deny":[]}},"allow-check-image-update":{"identifier":"allow-check-image-update","description":"Enables the check_image_update command without any pre-configured scope.","commands":{"allow":["check_image_update"],"deny":[]}},"allow-clear-claude-token":{"identifier":"allow-clear-claude-token","description":"Enables the clear_claude_token command without any pre-configured scope.","commands":{"allow":["clear_claude_token"],"deny":[]}},"allow-clear-gateway-api-key":{"identifier":"allow-clear-gateway-api-key","description":"Enables the clear_gateway_api_key command without any pre-configured scope.","commands":{"allow":["clear_gateway_api_key"],"deny":[]}},"allow-clear-scheduler-notifications":{"identifier":"allow-clear-scheduler-notifications","description":"Enables the clear_scheduler_notifications command without any pre-conLine truncated
{"__app-acl__":{"default_permission":null,"permissions":{"allow-acquire-claude-token":{"identifier":"allow-acquire-claude-token","description":"Enables the acquire_claude_token command without any pre-configured scope.","commands":{"allow":["acquire_claude_token"],"deny":[]}},"allow-add-marketplace":{"identifier":"allow-add-marketplace","description":"Enables the add_marketplace command without any pre-configured scope.","commands":{"allow":["add_marketplace"],"deny":[]}},"allow-add-marketplace-gh-host-account":{"identifier":"allow-add-marketplace-gh-host-account","description":"Enables the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_gh_host_account"],"deny":[]}},"allow-add-marketplace-token-account":{"identifier":"allow-add-marketplace-token-account","description":"Enables the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_token_account"],"deny":[]}},"allow-add-project":{"identifier":"allow-add-project","description":"Enables the add_project command without any pre-configured scope.","commands":{"allow":["add_project"],"deny":[]}},"allow-add-scheduled-task":{"identifier":"allow-add-scheduled-task","description":"Enables the add_scheduled_task command without any pre-configured scope.","commands":{"allow":["add_scheduled_task"],"deny":[]}},"allow-apply-marketplace-now":{"identifier":"allow-apply-marketplace-now","description":"Enables the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":["apply_marketplace_now"],"deny":[]}},"allow-apply-settings-import":{"identifier":"allow-apply-settings-import","description":"Enables the apply_settings_import command without any pre-configured scope.","commands":{"allow":["apply_settings_import"],"deny":[]}},"allow-aws-sso-refresh":{"identifier":"allow-aws-sso-refresh","description":"Enables the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":["aws_sso_refresh"],"deny":[]}},"allow-build-gateway-image":{"identifier":"allow-build-gateway-image","description":"Enables the build_gateway_image command without any pre-configured scope.","commands":{"allow":["build_gateway_image"],"deny":[]}},"allow-build-image":{"identifier":"allow-build-image","description":"Enables the build_image command without any pre-configured scope.","commands":{"allow":["build_image"],"deny":[]}},"allow-build-stt-image":{"identifier":"allow-build-stt-image","description":"Enables the build_stt_image command without any pre-configured scope.","commands":{"allow":["build_stt_image"],"deny":[]}},"allow-cancel-claude-token":{"identifier":"allow-cancel-claude-token","description":"Enables the cancel_claude_token command without any pre-configured scope.","commands":{"allow":["cancel_claude_token"],"deny":[]}},"allow-cancel-marketplace-gh-login":{"identifier":"allow-cancel-marketplace-gh-login","description":"Enables the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":["cancel_marketplace_gh_login"],"deny":[]}},"allow-check-browser-view-support":{"identifier":"allow-check-browser-view-support","description":"Enables the check_browser_view_support command without any pre-configured scope.","commands":{"allow":["check_browser_view_support"],"deny":[]}},"allow-check-docker":{"identifier":"allow-check-docker","description":"Enables the check_docker command without any pre-configured scope.","commands":{"allow":["check_docker"],"deny":[]}},"allow-check-for-updates":{"identifier":"allow-check-for-updates","description":"Enables the check_for_updates command without any pre-configured scope.","commands":{"allow":["check_for_updates"],"deny":[]}},"allow-check-gateway-health":{"identifier":"allow-check-gateway-health","description":"Enables the check_gateway_health command without any pre-configured scope.","commands":{"allow":["check_gateway_health"],"deny":[]}},"allow-check-image-exists":{"identifier":"allow-check-image-exists","description":"Enables the check_image_exists command without any pre-configured scope.","commands":{"allow":["check_image_exists"],"deny":[]}},"allow-check-image-update":{"identifier":"allow-check-image-update","description":"Enables the check_image_update command without any pre-configured scope.","commands":{"allow":["check_image_update"],"deny":[]}},"allow-clear-claude-token":{"identifier":"allow-clear-claude-token","description":"Enables the clear_claude_token command without any pre-configured scope.","commands":{"allow":["clear_claude_token"],"deny":[]}},"allow-clear-gateway-api-key":{"identifier":"allow-clear-gateway-api-key","description":"Enables the clear_gateway_api_key command without any pre-configured scope.","commands":{"allow":["clear_gateway_api_key"],"deny":[]}},"allow-clear-scheduler-notifications":{"identifier":"allow-clear-scheduler-notifications","description":"Enables the clear_scheduler_notifications command without any pre-conLine truncated
+1 -1
View File
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — plugin and core grants verified against tauri 2.11.0's `PLUGINS` table in tauri's own `build.rs` rather than assumed from a plugin's `default` set, app-command grants (the bare `allow-*` entries) cross-checked by this crate's `build.rs` against `generate_handler!`. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` is **gone**. It could not be narrowed by host — `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist would have deleted the feature rather than bounded it — and it was carried here as an accepted residual risk: a compromised webview could make the OS open an attacker-chosen http(s) URL, an outbound channel. That risk is now closed rather than recorded. Every host-browser open in the app goes through the `open_url_external` command in `url_open.rs`, which exists because the AppImage environment leaks into a cold-launched browser on Linux (triple-c#34) and which re-validates the URL in Rust — scheme allowlist, no embedded credentials, no control characters, length cap, ASCII asserted before `execvp`. On macOS and Windows that command reaches the same plugin as before, via `OpenerExt::open_url`, whose desktop implementation calls `crate::open::open` directly and is therefore not gated by this file at all (`tauri-plugin-opener-2.5.3/src/lib.rs:60`). The plugin stays a dependency for exactly that reason; what is removed is the webview's ability to reach it without passing the Rust validation. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`) and the plugin is no longer a dependency. Getting a file *out* of a container is either \"Back up container\" on the project's Overview tab, which archives a tree through the Docker API, or the Files tab's per-row \"Save to host…\", which copies one file; getting one *in* is a drop on the Terminal or the Files tab's \"Upload…\". None of the four touches this permission. The Files tab's two are worth separating out here, because they are the only host-path commands in the app whose dialog is opened by **Rust** rather than by the webview — `pick_save_path` and `pick_files_to_upload` in `commands/file_commands.rs` drive `tauri-plugin-dialog` from the backend, so a compromised webview can ask for a picker and nothing more: it cannot name a host path as an *input* to either command. Be precise about the limit of that claim — host paths do still travel outward in error text (`Failed to create /home/j/Documents/x.txt.triple-c-part-1a2b3c4d: Permission denied`), including canonicalized ones, which disclose symlink targets. That is accepted; the app already hands the webview the project paths. What is closed is the direction that mattered — the webview naming where bytes go. That is the shape an earlier revision of this file named as the honest one if the Files tab ever regained host I/O, and it is the shape it regained it in. The `dialog:allow-open` / `dialog:allow-save` grants below are therefore *not* what those two use; they remain for the frontend pickers in Add Project, the Config tab's workspace and access sections, the CA-certificate fielLine truncated
{"default":{"identifier":"default","description":"Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — plugin and core grants verified against tauri 2.11.0's `PLUGINS` table in tauri's own `build.rs` rather than assumed from a plugin's `default` set, app-command grants (the bare `allow-*` entries) cross-checked by this crate's `build.rs` against `generate_handler!`. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` is **gone**. It could not be narrowed by host — `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist would have deleted the feature rather than bounded it — and it was carried here as an accepted residual risk: a compromised webview could make the OS open an attacker-chosen http(s) URL, an outbound channel. That risk is now closed rather than recorded. Every host-browser open in the app goes through the `open_url_external` command in `url_open.rs`, which exists because the AppImage environment leaks into a cold-launched browser on Linux (triple-c#34) and which re-validates the URL in Rust — scheme allowlist, no embedded credentials, no control characters, length cap, ASCII asserted before `execvp`. On macOS and Windows that command reaches the same plugin as before, via `OpenerExt::open_url`, whose desktop implementation calls `crate::open::open` directly and is therefore not gated by this file at all (`tauri-plugin-opener-2.5.3/src/lib.rs:60`). The plugin stays a dependency for exactly that reason; what is removed is the webview's ability to reach it without passing the Rust validation. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`) and the plugin is no longer a dependency. Getting a file *out* of a container is either \"Back up container\" on the project's Overview tab, which archives a tree through the Docker API, or the Files tab's per-row \"Save to host…\", which copies one file; getting one *in* is a drop on the Terminal or the Files tab's \"Upload…\". None of the four touches this permission. The Files tab's two are worth separating out here, because they are the only host-path commands in the app whose dialog is opened by **Rust** rather than by the webview — `pick_save_path` and `pick_files_to_upload` in `commands/file_commands.rs` drive `tauri-plugin-dialog` from the backend, so a compromised webview can ask for a picker and nothing more: it cannot name a host path as an *input* to either command. Be precise about the limit of that claim — host paths do still travel outward in error text (`Failed to create /home/j/Documents/x.txt.triple-c-part-1a2b3c4d: Permission denied`), including canonicalized ones, which disclose symlink targets. That is accepted; the app already hands the webview the project paths. What is closed is the direction that mattered — the webview naming where bytes go. That is the shape an earlier revision of this file named as the honest one if the Files tab ever regained host I/O, and it is the shape it regained it in. The `dialog:allow-open` / `dialog:allow-save` grants below are therefore *not* what those two use; they remain for the frontend pickers in Add Project, the Config tab's workspace and access sections, the CA-certificate fielLine truncated
@@ -896,12 +896,6 @@
"const": "allow-rename-container-path",
"markdownDescription": "Enables the rename_container_path command without any pre-configured scope."
},
{
"description": "Enables the resume_session_command command without any pre-configured scope.",
"type": "string",
"const": "allow-resume-session-command",
"markdownDescription": "Enables the resume_session_command command without any pre-configured scope."
},
{
"description": "Enables the rollback_migration command without any pre-configured scope.",
"type": "string",
@@ -1712,12 +1706,6 @@
"const": "deny-rename-container-path",
"markdownDescription": "Denies the rename_container_path command without any pre-configured scope."
},
{
"description": "Denies the resume_session_command command without any pre-configured scope.",
"type": "string",
"const": "deny-resume-session-command",
"markdownDescription": "Denies the resume_session_command command without any pre-configured scope."
},
{
"description": "Denies the rollback_migration command without any pre-configured scope.",
"type": "string",
@@ -896,12 +896,6 @@
"const": "allow-rename-container-path",
"markdownDescription": "Enables the rename_container_path command without any pre-configured scope."
},
{
"description": "Enables the resume_session_command command without any pre-configured scope.",
"type": "string",
"const": "allow-resume-session-command",
"markdownDescription": "Enables the resume_session_command command without any pre-configured scope."
},
{
"description": "Enables the rollback_migration command without any pre-configured scope.",
"type": "string",
@@ -1712,12 +1706,6 @@
"const": "deny-rename-container-path",
"markdownDescription": "Denies the rename_container_path command without any pre-configured scope."
},
{
"description": "Denies the resume_session_command command without any pre-configured scope.",
"type": "string",
"const": "deny-resume-session-command",
"markdownDescription": "Denies the resume_session_command command without any pre-configured scope."
},
{
"description": "Denies the rollback_migration command without any pre-configured scope.",
"type": "string",
+1 -31
View File
@@ -301,7 +301,7 @@ fn validate_task_id(id: &str) -> Result<(), String> {
}
/// Strict allowlist for session ids (Claude Code uses UUIDs).
fn validate_session_id(id: &str) -> Result<(), String> {
pub(crate) fn validate_session_id(id: &str) -> Result<(), String> {
let valid = !id.is_empty()
&& id.len() <= 64
&& id.starts_with(|c: char| c.is_ascii_alphanumeric())
@@ -422,36 +422,6 @@ pub async fn list_claude_sessions(
.collect())
}
/// Build the shell command line that resumes a session, for the frontend to
/// drop into a terminal.
///
/// The flag spelling was checked against the CLI in the container image:
/// `claude --resume <session-id>` (short form `-r`).
///
/// The project's permission mode is folded in so the resumed session behaves
/// like a freshly opened one. The session id is validated first, and the
/// returned string contains only allowlisted characters.
#[tauri::command]
pub async fn resume_session_command(
project_id: String,
session_id: String,
state: State<'_, AppState>,
) -> Result<String, String> {
validate_session_id(&session_id)?;
let project = state
.projects_store
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
let mut parts = vec!["claude".to_string()];
parts.extend(project.effective_permission_mode().cli_args());
parts.push("--resume".to_string());
parts.push(session_id);
Ok(parts.join(" "))
}
// ─────────────────────────────────────────────────────────────────────────────
// 2. Capabilities
// ─────────────────────────────────────────────────────────────────────────────
+65 -10
View File
@@ -11,12 +11,18 @@ use crate::AppState;
/// validates the AWS session first, and runs `aws sso login` if it has expired
/// so the user can re-authenticate (the URL is clickable via xterm.js
/// WebLinksAddon).
fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<&str>) -> Vec<String> {
fn build_terminal_cmd(
project: &Project,
state: &AppState,
session_name: Option<&str>,
resume_session_id: Option<&str>,
) -> Vec<String> {
let settings = state.settings_store.get();
build_claude_terminal_cmd(
project,
settings.global_aws.aws_profile.as_deref(),
session_name,
resume_session_id,
)
}
@@ -53,10 +59,16 @@ fn shell_quote_arg(arg: &str) -> String {
/// The testable core of [`build_terminal_cmd`], taking the resolved global AWS
/// profile rather than the whole [`AppState`].
///
/// `resume_session_id` adds `--resume <id>` (flag spelling checked against the
/// CLI in the container image) and drops `-n`: the resumed conversation keeps
/// the name it already has, and the tab label the caller passes as
/// `session_name` — often just "resume" — must not overwrite it.
fn build_claude_terminal_cmd(
project: &Project,
global_aws_profile: Option<&str>,
session_name: Option<&str>,
resume_session_id: Option<&str>,
) -> Vec<String> {
let is_bedrock_profile = project.backend == Backend::Bedrock
&& project
@@ -69,12 +81,15 @@ fn build_claude_terminal_cmd(
// The args are interpolated into a shell script string, so single-quote
// each one.
let name_flag = session_name
.filter(|n| !n.is_empty())
.map(|n| format!(" -n{}", shell_quote_arg(n)))
.unwrap_or_default();
let session_flags = match resume_session_id {
Some(id) => format!(" --resume{}", shell_quote_arg(id)),
None => session_name
.filter(|n| !n.is_empty())
.map(|n| format!(" -n{}", shell_quote_arg(n)))
.unwrap_or_default(),
};
let permission_flags: String = permission_args.iter().map(|a| shell_quote_arg(a)).collect();
let claude_cmd = format!("exec claude{}{}", permission_flags, name_flag);
let claude_cmd = format!("exec claude{}{}", permission_flags, session_flags);
if !is_bedrock_profile {
return vec![
@@ -135,6 +150,7 @@ pub async fn open_terminal_session(
session_id: String,
session_type: Option<String>,
session_name: Option<String>,
resume_session_id: Option<String>,
app_handle: AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
@@ -148,9 +164,21 @@ pub async fn open_terminal_session(
.as_ref()
.ok_or_else(|| "Container not running".to_string())?;
if let Some(id) = resume_session_id.as_deref() {
crate::commands::inspect_commands::validate_session_id(id)?;
}
let cmd = match session_type.as_deref() {
Some("bash") if resume_session_id.is_some() => {
return Err("Only a Claude session can resume a conversation".to_string());
}
Some("bash") => vec!["bash".to_string(), "-l".to_string()],
_ => build_terminal_cmd(&project, &state, session_name.as_deref()),
_ => build_terminal_cmd(
&project,
&state,
session_name.as_deref(),
resume_session_id.as_deref(),
),
};
let output_event = format!("terminal-output-{}", session_id);
@@ -414,7 +442,7 @@ mod tests {
/// wrapper rather than a bare `claude` argv.
#[test]
fn build_terminal_cmd_updates_before_launching_claude() {
let cmd = build_claude_terminal_cmd(&project("anthropic", serde_json::Value::Null), None, None);
let cmd = build_claude_terminal_cmd(&project("anthropic", serde_json::Value::Null), None, None, None);
assert_eq!(cmd[0], "bash");
assert_eq!(cmd[1], "-c");
@@ -444,6 +472,7 @@ mod tests {
&project("anthropic", serde_json::Value::Null),
None,
Some("Bob's tab; rm -rf /"),
None,
);
assert!(
@@ -459,7 +488,7 @@ mod tests {
fn build_terminal_cmd_quotes_permission_flags_and_omits_an_empty_name() {
let mut p = project("anthropic", serde_json::Value::Null);
p.full_permissions = true;
let cmd = build_claude_terminal_cmd(&p, None, Some(""));
let cmd = build_claude_terminal_cmd(&p, None, Some(""), None);
assert!(
cmd[2].contains("exec claude '--dangerously-skip-permissions'\n"),
@@ -474,7 +503,7 @@ mod tests {
fn build_terminal_cmd_passes_auto_permission_mode() {
let mut p = project("anthropic", serde_json::Value::Null);
p.permission_mode = Some(crate::models::project::PermissionMode::Auto);
let cmd = build_claude_terminal_cmd(&p, None, None);
let cmd = build_claude_terminal_cmd(&p, None, None, None);
assert!(
cmd[2].contains("exec claude '--permission-mode' 'auto'"),
@@ -497,6 +526,7 @@ mod tests {
})),
None,
Some("it's fine"),
None,
);
assert_eq!(cmd[0], "bash");
@@ -512,6 +542,31 @@ mod tests {
);
}
/// Resume goes through the same launch as a new session — prelude,
/// permission flags — and passes the id instead of the tab label, so a
/// label of "resume" does not rename the conversation.
#[test]
fn build_terminal_cmd_resumes_without_renaming() {
let mut p = project("anthropic", serde_json::Value::Null);
p.full_permissions = true;
let cmd = build_claude_terminal_cmd(
&p,
None,
Some("resume"),
Some("e13d312d-2f38-4cf6-b0e0-1db60208a74c"),
);
assert!(cmd[2].contains(UPDATE_PRELUDE), "got: {}", cmd[2]);
assert!(
cmd[2].contains(
"exec claude '--dangerously-skip-permissions' --resume 'e13d312d-2f38-4cf6-b0e0-1db60208a74c'\n"
),
"got: {}",
cmd[2]
);
assert!(!cmd[2].contains(" -n "), "resume must not rename: {}", cmd[2]);
}
#[test]
fn a_dropped_file_keeps_the_name_the_user_dropped() {
use crate::commands::file_commands::host_upload_name;
-1
View File
@@ -645,7 +645,6 @@ pub fn run() {
commands::gateway_commands::regenerate_gateway_auth_token,
// Container introspection (sessions / capabilities / scheduler)
commands::inspect_commands::list_claude_sessions,
commands::inspect_commands::resume_session_command,
commands::inspect_commands::list_container_capabilities,
commands::inspect_commands::list_scheduled_tasks,
commands::inspect_commands::add_scheduled_task,
@@ -17,6 +17,7 @@ vi.mock("../../hooks/useProjectActions", () => ({
handleBackup: vi.fn(),
openClaudeTerminal: mockOpenClaudeTerminal,
openShell: vi.fn(),
resumeClaudeSession: vi.fn(),
openTerminalWithCommand: vi.fn(),
}),
}));
@@ -4,7 +4,6 @@ import {
listClaudeSessions,
listScheduledTasks,
getSchedulerNotifications,
resumeSessionCommand,
} from "../../../lib/tauri-commands";
import type { useProjectActions } from "../../../hooks/useProjectActions";
import type { SaveState } from "../../../hooks/useSaveState";
@@ -78,14 +77,8 @@ export default function OverviewTab({
};
}, [project.id, running, project.container_id]);
const handleResume = async (session: ClaudeSession) => {
try {
const command = await resumeSessionCommand(project.id, session.id);
await actions.openTerminalWithCommand(command, session.name ?? "resume");
} catch (e) {
console.error("Failed to build the resume command:", e);
}
};
const handleResume = (session: ClaudeSession) =>
actions.resumeClaudeSession(session.id, session.name ?? "resume");
return (
<div className="p-4 space-y-6 max-w-4xl">
@@ -1,8 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import type { ClaudeSession, Project } from "../../../lib/types";
import { listClaudeSessions, resumeSessionCommand } from "../../../lib/tauri-commands";
import { listClaudeSessions } from "../../../lib/tauri-commands";
import type { useProjectActions } from "../../../hooks/useProjectActions";
import { useAppState } from "../../../store/appState";
import Button from "../../ui/Button";
import { formatAge, formatBytes } from "./format";
@@ -18,7 +17,6 @@ interface Props {
export default function SessionsTab({ project, actions }: Props) {
const [sessions, setSessions] = useState<ClaudeSession[]>([]);
const [loading, setLoading] = useState(false);
const pushToast = useAppState((s) => s.pushToast);
const running = project.status === "running";
const load = useCallback(() => {
@@ -35,18 +33,8 @@ export default function SessionsTab({ project, actions }: Props) {
useEffect(load, [load]);
const resume = async (session: ClaudeSession) => {
try {
const command = await resumeSessionCommand(project.id, session.id);
await actions.openTerminalWithCommand(command, session.name ?? "resume");
} catch (e) {
pushToast({
kind: "error",
message: "Could not resume that session",
detail: String(e),
});
}
};
const resume = (session: ClaudeSession) =>
actions.resumeClaudeSession(session.id, session.name ?? "resume");
return (
<div className="p-4 max-w-4xl">
+22 -3
View File
@@ -96,9 +96,27 @@ Reset again, or remove ${resetLeftoverPronoun(outcome)} manually once stopped.`,
}, [openTerminal, project.id, project.name, fail]);
/**
* Open a shell tab and type `command` into it. Used for [Resume] and for the
* capability drawer's "Manage in terminal" — the backend has no
* run-a-command entry point, so we drive the shell we just opened.
* Open a Claude tab on a saved conversation. A real Claude session, not a
* shell with `claude --resume` typed into it: the tab's session type is
* what Shift+Enter, the status-bar hint and Send to agent key off, so a
* resumed conversation in a "bash" tab submitted on Shift+Enter.
*/
const resumeClaudeSession = useCallback(
async (claudeSessionId: string, sessionLabel?: string) => {
try {
return await openTerminal(project.id, project.name, "claude", sessionLabel, claudeSessionId);
} catch (e) {
fail("Could not resume that session", e);
return null;
}
},
[openTerminal, project.id, project.name, fail],
);
/**
* Open a shell tab and type `command` into it. Used for the capability
* drawer's "Manage in terminal" — the backend has no run-a-command entry
* point, so we drive the shell we just opened.
*/
const openTerminalWithCommand = useCallback(
async (command: string, sessionLabel?: string) => {
@@ -170,6 +188,7 @@ Reset again, or remove ${resetLeftoverPronoun(outcome)} manually once stopped.`,
handleBackup,
openClaudeTerminal,
openShell,
resumeClaudeSession,
openTerminalWithCommand,
};
}
+23
View File
@@ -81,3 +81,26 @@ describe("useTerminal input ordering", () => {
expect(bySession).toContainEqual(["b", "2"]);
});
});
describe("useTerminal open", () => {
it("opens a resumed conversation as a Claude session", async () => {
// Resume used to open a "bash" tab and type `claude --resume` into it, and
// Shift+Enter keys off the tab's type — so it submitted instead of adding
// a newline. The id has to travel to the backend *and* the tab has to be
// registered as "claude".
const commands = await import("../lib/tauri-commands");
const { useAppState } = await import("../store/appState");
const { result } = renderHook(() => useTerminal());
const id = await result.current.open("p1", "proj", "claude", "my chat", "e13d312d-2f38");
expect(commands.openTerminalSession).toHaveBeenCalledWith(
"p1",
id,
"claude",
"my chat",
"e13d312d-2f38",
);
expect(useAppState.getState().sessions.find((s) => s.id === id)?.sessionType).toBe("claude");
});
});
+8 -2
View File
@@ -97,9 +97,15 @@ export function useTerminal() {
);
const open = useCallback(
async (projectId: string, projectName: string, sessionType: "claude" | "bash" = "claude", sessionName?: string) => {
async (
projectId: string,
projectName: string,
sessionType: "claude" | "bash" = "claude",
sessionName?: string,
resumeSessionId?: string,
) => {
const sessionId = crypto.randomUUID();
await commands.openTerminalSession(projectId, sessionId, sessionType, sessionName);
await commands.openTerminalSession(projectId, sessionId, sessionType, sessionName, resumeSessionId);
addSession({ id: sessionId, projectId, projectName, sessionType, sessionName: sessionName ?? null });
return sessionId;
},
+8 -4
View File
@@ -65,8 +65,14 @@ export const awsSsoRefresh = (projectId: string) =>
invoke<void>("aws_sso_refresh", { projectId });
// Terminal
export const openTerminalSession = (projectId: string, sessionId: string, sessionType?: string, sessionName?: string) =>
invoke<void>("open_terminal_session", { projectId, sessionId, sessionType, sessionName });
export const openTerminalSession = (
projectId: string,
sessionId: string,
sessionType?: string,
sessionName?: string,
resumeSessionId?: string,
) =>
invoke<void>("open_terminal_session", { projectId, sessionId, sessionType, sessionName, resumeSessionId });
export const terminalInput = (sessionId: string, data: number[]) =>
invoke<void>("terminal_input", { sessionId, data });
export const terminalResize = (sessionId: string, cols: number, rows: number) =>
@@ -168,8 +174,6 @@ export const runDockerInstall = () => invoke<void>("run_docker_install");
// Container introspection — sessions
export const listClaudeSessions = (projectId: string) =>
invoke<ClaudeSession[]>("list_claude_sessions", { projectId });
export const resumeSessionCommand = (projectId: string, sessionId: string) =>
invoke<string>("resume_session_command", { projectId, sessionId });
// Container introspection — capabilities
export const listContainerCapabilities = (projectId: string) =>