mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-11 10:41:28 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c46cf7719 | ||
|
|
5638c3386b |
No files matched your search
@@ -0,0 +1,270 @@
|
||||
//! Sessions the user started outside OpenIDEA (a terminal, another editor, the CLI):
|
||||
//! scanned so they can be listed and imported as tasks instead of re-explained (#312).
|
||||
//!
|
||||
//! Only the native providers keep transcripts on disk in a readable form: Claude Code
|
||||
//! (`<config>/projects/*/<id>.jsonl`, see `native::claude::history`) and Codex
|
||||
//! (`<home>/.codex/sessions/**`, rollout files with a `session_meta` line).
|
||||
|
||||
use super::native::claude::history;
|
||||
use serde::Serialize;
|
||||
use specta::Type;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A session on this machine that no task holds yet.
|
||||
#[derive(Clone, Debug, Serialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExternalSession {
|
||||
/// The [`crate::AgentProvider`] id that owns it (`claude`, `codex`).
|
||||
pub provider: String,
|
||||
/// The id resuming it names (`--resume`, `thread/resume`).
|
||||
pub session_id: String,
|
||||
pub title: String,
|
||||
/// Where it ran, when the transcript names one.
|
||||
pub cwd: Option<String>,
|
||||
/// File modification time, ms since the epoch.
|
||||
pub updated_ms: f64,
|
||||
}
|
||||
|
||||
/// Modification time of `path` in ms, else 0.
|
||||
fn mtime(path: &Path) -> f64 {
|
||||
path.metadata()
|
||||
.and_then(|m| m.modified())
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_millis() as f64)
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Short display text: collapsed whitespace, truncated to `MAX_TITLE` chars.
|
||||
fn short(text: &str) -> String {
|
||||
const MAX_TITLE: usize = 80;
|
||||
let flat: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if flat.chars().count() <= MAX_TITLE {
|
||||
flat
|
||||
} else {
|
||||
format!("{}…", flat.chars().take(MAX_TITLE - 1).collect::<String>())
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of a Claude Code transcript entry's message, when it carries prose.
|
||||
fn message_text(e: &serde_json::Value) -> Option<String> {
|
||||
match e.pointer("/message/content") {
|
||||
Some(serde_json::Value::String(s)) => Some(s.clone()),
|
||||
Some(serde_json::Value::Array(blocks)) => {
|
||||
let text: Vec<&str> = blocks
|
||||
.iter()
|
||||
.filter(|b| b.get("type").and_then(serde_json::Value::as_str) == Some("text"))
|
||||
.filter_map(|b| b.get("text").and_then(serde_json::Value::as_str))
|
||||
.collect();
|
||||
(!text.is_empty()).then(|| text.join("\n"))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Claude Code transcripts: `<config>/projects/*/<id>.jsonl`.
|
||||
pub fn claude_sessions(config_dir: &Path) -> Vec<ExternalSession> {
|
||||
let mut out = Vec::new();
|
||||
let projects = config_dir.join("projects");
|
||||
let dirs: Vec<PathBuf> = std::fs::read_dir(&projects).into_iter().flatten().flatten().map(|d| d.path()).collect();
|
||||
for dir in dirs {
|
||||
let files: Vec<PathBuf> = std::fs::read_dir(&dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|d| d.path())
|
||||
.filter(|p| p.extension().is_some_and(|e| e == "jsonl"))
|
||||
.collect();
|
||||
for path in files {
|
||||
let id = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default().to_owned();
|
||||
if id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let entries = history::read(&path).unwrap_or_default();
|
||||
if entries.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let title = history::title(&entries)
|
||||
.or_else(|| entries.iter().filter(|e| history::is_turn_start(e)).filter_map(message_text).next())
|
||||
.map(|t| short(&t))
|
||||
.unwrap_or_else(|| format!("Claude Code session {}", &id[..id.len().min(8)]));
|
||||
let cwd = entries
|
||||
.iter()
|
||||
.filter_map(|e| e.get("cwd").and_then(serde_json::Value::as_str))
|
||||
.next()
|
||||
.map(str::to_owned);
|
||||
out.push(ExternalSession {
|
||||
provider: "claude".to_owned(),
|
||||
session_id: id,
|
||||
title,
|
||||
cwd,
|
||||
updated_ms: mtime(&path),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The `session_meta` line of a Codex rollout file: `(id, cwd)`, across shapes.
|
||||
fn codex_meta(line: &serde_json::Value) -> Option<(String, Option<String>)> {
|
||||
let payload =
|
||||
line.get("payload").filter(|p| p.get("type").and_then(serde_json::Value::as_str) == Some("session_meta"));
|
||||
let inner = payload.and_then(|p| p.get("payload")).or_else(|| {
|
||||
line.get("type").and_then(serde_json::Value::as_str).filter(|t| *t == "session_meta").and(Some(line))
|
||||
})?;
|
||||
let id = inner.get("id").and_then(serde_json::Value::as_str)?.to_owned();
|
||||
let cwd = inner.get("cwd").and_then(serde_json::Value::as_str).map(str::to_owned);
|
||||
Some((id, cwd))
|
||||
}
|
||||
|
||||
/// A prose line of a Codex rollout file (a user message), across shapes.
|
||||
fn codex_user_text(line: &serde_json::Value) -> Option<String> {
|
||||
let payload = line.get("payload")?;
|
||||
let ty = payload.get("type").and_then(serde_json::Value::as_str)?;
|
||||
if !(ty == "user_message" || ty == "userMessage" || ty == "event_msg") {
|
||||
return None;
|
||||
}
|
||||
let inner = payload.get("payload").unwrap_or(&serde_json::Value::Null);
|
||||
for v in [payload, inner] {
|
||||
if let Some(s) = v.get("message").and_then(serde_json::Value::as_str) {
|
||||
return Some(s.to_owned());
|
||||
}
|
||||
if let Some(s) = v.get("text").and_then(serde_json::Value::as_str) {
|
||||
return Some(s.to_owned());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// One rollout file's session, or `None` when it names no `session_meta`.
|
||||
fn codex_file(path: &Path) -> Option<ExternalSession> {
|
||||
let text = std::fs::read_to_string(path).ok()?;
|
||||
let mut id: Option<(String, Option<String>)> = None;
|
||||
let mut title: Option<String> = None;
|
||||
// `session_meta` is first, but cap the scan: giant rollouts must not stall listing.
|
||||
for line in text.lines().take(400) {
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else { continue };
|
||||
if id.is_none()
|
||||
&& let Some(meta) = codex_meta(&v)
|
||||
{
|
||||
id = Some(meta);
|
||||
}
|
||||
if title.is_none()
|
||||
&& let Some(t) = codex_user_text(&v)
|
||||
{
|
||||
title = Some(short(&t));
|
||||
}
|
||||
if id.is_some() && title.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let (session_id, cwd) = id?;
|
||||
Some(ExternalSession {
|
||||
provider: "codex".to_owned(),
|
||||
title: title.unwrap_or_else(|| format!("Codex session {}", &session_id[..session_id.len().min(8)])),
|
||||
session_id,
|
||||
cwd,
|
||||
updated_ms: mtime(path),
|
||||
})
|
||||
}
|
||||
|
||||
/// Collects `*.jsonl` below `dir`, up to 4 levels (`sessions/YYYY/MM/DD/<rollout>`).
|
||||
fn rollout_files(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
|
||||
if depth == 0 {
|
||||
return;
|
||||
}
|
||||
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
rollout_files(&path, depth - 1, out);
|
||||
} else if path.extension().is_some_and(|e| e == "jsonl") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Codex sessions: `<home>/.codex/sessions/**`.
|
||||
pub fn codex_sessions(codex_home: &Path) -> Vec<ExternalSession> {
|
||||
let mut files = Vec::new();
|
||||
rollout_files(&codex_home.join("sessions"), 4, &mut files);
|
||||
files.iter().filter_map(|p| codex_file(p)).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::io::Write;
|
||||
|
||||
fn write(path: &Path, lines: &[serde_json::Value]) {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let mut f = std::fs::File::create(path).unwrap();
|
||||
for line in lines {
|
||||
writeln!(f, "{line}").unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn claude_user(uuid: &str, text: &str) -> serde_json::Value {
|
||||
json!({"type": "user", "uuid": uuid, "parentUuid": null,
|
||||
"message": {"role": "user", "content": text}, "cwd": "/work/app"})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_claude_transcripts_with_titles() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write(
|
||||
&tmp.path().join("projects/-work-app/abc123.jsonl"),
|
||||
&[
|
||||
claude_user("u1", "fix the login bug"),
|
||||
json!({"type": "assistant", "uuid": "a1", "parentUuid": "u1",
|
||||
"message": {"role": "assistant", "content": []}}),
|
||||
json!({"type": "ai-title", "aiTitle": "Login fix"}),
|
||||
],
|
||||
);
|
||||
write(&tmp.path().join("projects/-work-app/empty.jsonl"), &[]);
|
||||
let sessions = claude_sessions(tmp.path());
|
||||
assert_eq!(sessions.len(), 1);
|
||||
let s = &sessions[0];
|
||||
assert_eq!((s.provider.as_str(), s.session_id.as_str()), ("claude", "abc123"));
|
||||
assert_eq!(s.title, "Login fix");
|
||||
assert_eq!(s.cwd.as_deref(), Some("/work/app"));
|
||||
assert!(s.updated_ms > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_title_falls_back_to_the_first_prompt() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write(&tmp.path().join("projects/-x/def456.jsonl"), &[claude_user("u1", "hello there")]);
|
||||
let sessions = claude_sessions(tmp.path());
|
||||
assert_eq!(sessions.len(), 1);
|
||||
assert_eq!(sessions[0].title, "hello there");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_codex_rollouts_with_meta() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write(
|
||||
&tmp.path().join("sessions/2026/01/02/rollout-01.jsonl"),
|
||||
&[
|
||||
json!({"timestamp": "t", "payload": {"type": "session_meta",
|
||||
"payload": {"id": "thread-1", "cwd": "/work/app"}}}),
|
||||
json!({"timestamp": "t", "payload": {"type": "user_message",
|
||||
"payload": {"message": "add tests"}}}),
|
||||
],
|
||||
);
|
||||
write(&tmp.path().join("sessions/notes.jsonl"), &[json!({"nope": true})]);
|
||||
let sessions = codex_sessions(tmp.path());
|
||||
assert_eq!(sessions.len(), 1);
|
||||
let s = &sessions[0];
|
||||
assert_eq!((s.provider.as_str(), s.session_id.as_str()), ("codex", "thread-1"));
|
||||
assert_eq!(s.title, "add tests");
|
||||
assert_eq!(s.cwd.as_deref(), Some("/work/app"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_directories_list_nothing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
assert!(claude_sessions(&tmp.path().join("nope")).is_empty());
|
||||
assert!(codex_sessions(&tmp.path().join("nope")).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
mod acp;
|
||||
mod config;
|
||||
mod conversation;
|
||||
mod external;
|
||||
pub mod mcp;
|
||||
mod model;
|
||||
mod native;
|
||||
@@ -19,6 +20,7 @@ mod skills;
|
||||
mod timeline;
|
||||
mod worktree;
|
||||
|
||||
pub use external::ExternalSession;
|
||||
pub use model::*;
|
||||
|
||||
use conversation::{Conversation, SavedSubagent, Session};
|
||||
@@ -585,6 +587,91 @@ impl AgentManager {
|
||||
Ok(timeline.entries.clone())
|
||||
}
|
||||
|
||||
/// Session ids the tasks already hold (native drivers reuse the CLI's own id).
|
||||
fn imported_session_ids(&self) -> HashSet<String> {
|
||||
self.tasks.read().values().filter_map(|t| t.state.lock().session_id.clone()).collect()
|
||||
}
|
||||
|
||||
/// Sessions on this machine that no task holds yet, newest first (#312).
|
||||
pub fn external_sessions(&self) -> Vec<ExternalSession> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(spec) = self.shared.spec("claude")
|
||||
&& let Some(dir) = native::claude::cli::config_dir(&spec.env)
|
||||
{
|
||||
out.extend(external::claude_sessions(&dir));
|
||||
}
|
||||
let home = self.shared.paths.home_dir.clone().or_else(home_dir);
|
||||
if let Some(home) = home {
|
||||
out.extend(external::codex_sessions(&home.join(".codex")));
|
||||
}
|
||||
let known = self.imported_session_ids();
|
||||
out.retain(|s| !known.contains(&s.session_id));
|
||||
out.sort_by(|a, b| b.updated_ms.total_cmp(&a.updated_ms));
|
||||
out.truncate(50);
|
||||
out
|
||||
}
|
||||
|
||||
/// Imports an external session as a task: its first turn loads and replays it (#312).
|
||||
pub fn import_session(&self, provider: &str, session_id: &str) -> IdeResult<AgentTask> {
|
||||
if !matches!(provider, "claude" | "codex") {
|
||||
return Err(IdeError::invalid(format!("cannot import {provider} sessions")));
|
||||
}
|
||||
let spec = self.shared.spec(provider).ok_or_else(|| IdeError::invalid(format!("unknown agent {provider}")))?;
|
||||
if self.imported_session_ids().contains(session_id) {
|
||||
return Err(IdeError::invalid("that session is already a task"));
|
||||
}
|
||||
let found = self
|
||||
.external_sessions()
|
||||
.into_iter()
|
||||
.find(|s| s.provider == provider && s.session_id == session_id)
|
||||
.ok_or_else(|| IdeError::not_found(format!("unknown {provider} session {session_id}")))?;
|
||||
let root = self.shared.paths.project_root.clone();
|
||||
let cwd = found.cwd.map(PathBuf::from).filter(|p| p.is_absolute()).unwrap_or_else(|| root.clone());
|
||||
let config = self.shared.known_config(&spec.id);
|
||||
let now = now_ms();
|
||||
let id = uuid::Uuid::new_v4().simple().to_string();
|
||||
let task = AgentTask {
|
||||
project_root: Some(IpcPath(root)),
|
||||
id,
|
||||
title: found.title,
|
||||
provider: spec.id.clone(),
|
||||
model: model_of(&config),
|
||||
status: AgentStatus::Ready,
|
||||
isolation: Isolation::Local,
|
||||
cwd: IpcPath(cwd),
|
||||
branch: None,
|
||||
base: None,
|
||||
base_commit: None,
|
||||
error: None,
|
||||
created_ms: now,
|
||||
updated_ms: now,
|
||||
config,
|
||||
commands: self.shared.known_commands(&spec.id),
|
||||
usage: None,
|
||||
queue: Vec::new(),
|
||||
capabilities: self.shared.probes.lock().get(&spec.id).map(|p| p.capabilities).unwrap_or_default(),
|
||||
resets_ms: None,
|
||||
retry: None,
|
||||
background: Box::default(),
|
||||
};
|
||||
let file = TaskFile {
|
||||
task: task.clone(),
|
||||
session_id: Some(session_id.to_owned()),
|
||||
entries: Vec::new(),
|
||||
wanted: Vec::new(),
|
||||
before: BTreeMap::new(),
|
||||
saved: None,
|
||||
subagents: Vec::new(),
|
||||
pending_rollback: 0,
|
||||
title_renamed: true,
|
||||
};
|
||||
let handle = Task::restore(file, self.shared.clone());
|
||||
self.tasks.write().insert(task.id.clone(), handle.clone());
|
||||
handle.emit_task();
|
||||
handle.persist();
|
||||
Ok(task)
|
||||
}
|
||||
|
||||
/// Creates the task (and its worktree) and sends `req.prompt` as its first message.
|
||||
pub async fn create(&self, req: NewAgentTask) -> IdeResult<AgentTask> {
|
||||
let spec = self
|
||||
@@ -2429,6 +2516,11 @@ fn now_ms() -> f64 {
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0.0, |d| d.as_millis() as f64)
|
||||
}
|
||||
|
||||
/// The user's home, for tools that keep state there (Codex sessions).
|
||||
fn home_dir() -> Option<PathBuf> {
|
||||
std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from)
|
||||
}
|
||||
|
||||
fn write_file(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -135,7 +135,7 @@ impl Cli for Process {
|
||||
}
|
||||
|
||||
/// Claude Code's configuration directory (`CLAUDE_CONFIG_DIR`, else `~/.claude`).
|
||||
pub(super) fn config_dir(env: &[(String, String)]) -> Option<PathBuf> {
|
||||
pub(crate) fn config_dir(env: &[(String, String)]) -> Option<PathBuf> {
|
||||
let set = env
|
||||
.iter()
|
||||
.find(|(k, _)| k == "CLAUDE_CONFIG_DIR")
|
||||
|
||||
@@ -35,7 +35,7 @@ pub(super) fn escape(cwd: &Path) -> String {
|
||||
cwd.to_string_lossy().chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect()
|
||||
}
|
||||
|
||||
pub(super) fn read(path: &Path) -> std::io::Result<Vec<Value>> {
|
||||
pub(crate) fn read(path: &Path) -> std::io::Result<Vec<Value>> {
|
||||
let text = std::fs::read_to_string(path)?;
|
||||
Ok(text.lines().filter_map(|l| serde_json::from_str(l).ok()).collect())
|
||||
}
|
||||
@@ -111,7 +111,7 @@ fn block_ids<'a>(e: &'a Value, ty: &'a str, key: &'a str) -> impl Iterator<Item
|
||||
|
||||
/// Whether `e` is a message that started a turn: the user's own, not a tool result, a
|
||||
/// steer, or text Claude Code added.
|
||||
pub(super) fn is_turn_start(e: &Value) -> bool {
|
||||
pub(crate) fn is_turn_start(e: &Value) -> bool {
|
||||
if kind(e) != "user" || e.get("isMeta").and_then(Value::as_bool) == Some(true) {
|
||||
return false;
|
||||
}
|
||||
@@ -149,7 +149,7 @@ pub(super) fn cut(chain: &[&Value], turns: usize) -> Result<Option<String>, Stri
|
||||
}
|
||||
|
||||
/// The newest title Claude Code gave the session.
|
||||
pub(super) fn title(entries: &[Value]) -> Option<String> {
|
||||
pub(crate) fn title(entries: &[Value]) -> Option<String> {
|
||||
entries
|
||||
.iter()
|
||||
.rev()
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
//! <id>.json`): loading that session, from this connection or a fresh one, starts it that
|
||||
//! way.
|
||||
|
||||
mod cli;
|
||||
mod history;
|
||||
pub(crate) mod cli;
|
||||
pub(crate) mod history;
|
||||
mod settings;
|
||||
mod tools;
|
||||
mod translate;
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::rpc::Dispatcher;
|
||||
use ide_agent::registry::{AgentRegistry, RegistryPaths};
|
||||
use ide_agent::{
|
||||
AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask,
|
||||
McpScope, McpServer, McpServerInput, NewAgentTask,
|
||||
ExternalSession, McpScope, McpServer, McpServerInput, NewAgentTask,
|
||||
};
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
|
||||
@@ -181,6 +181,17 @@ pub fn entries(host: &Host, window: &str, task: String, subagent: Option<String>
|
||||
host.project(window)?.agents.entries(&task, subagent)
|
||||
}
|
||||
|
||||
/// Sessions on this machine that no task holds yet, newest first (#312).
|
||||
pub fn external_sessions(host: &Host, window: &str) -> IdeResult<Vec<ExternalSession>> {
|
||||
Ok(host.project(window)?.agents.external_sessions())
|
||||
}
|
||||
|
||||
/// Imports an external session as a task: its first turn loads and replays it (#312).
|
||||
pub fn import_session(host: &Host, window: &str, provider: String, session_id: String) -> IdeResult<AgentTask> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.import_session(&provider, &session_id)
|
||||
}
|
||||
|
||||
pub async fn create(host: &Host, window: &str, mut request: NewAgentTask) -> IdeResult<AgentTask> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
@@ -398,6 +409,10 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("agent_commands", |c| async move { commands(&c.host, &c.window, c.arg("provider")?, c.arg("task")?).await });
|
||||
d.add("agent_entries", |c| async move { entries(&c.host, &c.window, c.arg("task")?, c.arg("subagent")?) });
|
||||
d.add("agent_create", |c| async move { create(&c.host, &c.window, c.arg("request")?).await });
|
||||
d.add("agent_external_sessions", |c| async move { external_sessions(&c.host, &c.window) });
|
||||
d.add("agent_import_session", |c| async move {
|
||||
import_session(&c.host, &c.window, c.arg("provider")?, c.arg("sessionId")?)
|
||||
});
|
||||
d.add("agent_prompt", |c| async move {
|
||||
prompt(&c.host, &c.window, c.arg("task")?, c.arg("prompt")?, c.arg("now")?).await
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::state::AppState;
|
||||
use ide_agent::registry::AgentRegistry;
|
||||
use ide_agent::{
|
||||
AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask,
|
||||
McpServer, McpServerInput, NewAgentTask,
|
||||
ExternalSession, McpServer, McpServerInput, NewAgentTask,
|
||||
};
|
||||
use ide_core::{IdeResult, IpcPath, RequestId};
|
||||
use ide_host::ops::agent;
|
||||
@@ -166,6 +166,25 @@ pub async fn agent_create(window: Window, state: State<'_, AppState>, request: N
|
||||
agent::create(&state.host, window.label(), request).await
|
||||
}
|
||||
|
||||
/// Sessions on this machine that no task holds yet, newest first (#312).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn agent_external_sessions(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<ExternalSession>> {
|
||||
agent::external_sessions(&state.host, window.label())
|
||||
}
|
||||
|
||||
/// Imports an external session as a task: its first turn loads and replays it (#312).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn agent_import_session(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
provider: String,
|
||||
session_id: String,
|
||||
) -> IdeResult<AgentTask> {
|
||||
agent::import_session(&state.host, window.label(), provider, session_id)
|
||||
}
|
||||
|
||||
/// Sends a message; while the agent works it is queued, or with `now` it interrupts the turn.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
|
||||
@@ -128,6 +128,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
agent::agent_commands,
|
||||
agent::agent_entries,
|
||||
agent::agent_create,
|
||||
agent::agent_external_sessions,
|
||||
agent::agent_import_session,
|
||||
agent::agent_prompt,
|
||||
agent::agent_unqueue,
|
||||
agent::agent_send_queued_now,
|
||||
|
||||
@@ -162,6 +162,10 @@ export const commands = {
|
||||
/** Entries of `task`'s conversation, or of its subagent `subagent`'s. */
|
||||
agentEntries: (task: string, subagent: string | null) => typedError<AgentEntry[], IdeError>(__TAURI_INVOKE("agent_entries", { task, subagent })),
|
||||
agentCreate: (request: NewAgentTask) => typedError<AgentTask, IdeError>(__TAURI_INVOKE("agent_create", { request })),
|
||||
/** Sessions on this machine that no task holds yet, newest first (#312). */
|
||||
agentExternalSessions: () => typedError<ExternalSession[], IdeError>(__TAURI_INVOKE("agent_external_sessions")),
|
||||
/** Imports an external session as a task: its first turn loads and replays it (#312). */
|
||||
agentImportSession: (provider: string, sessionId: string) => typedError<AgentTask, IdeError>(__TAURI_INVOKE("agent_import_session", { provider, sessionId })),
|
||||
/** Sends a message; while the agent works it is queued, or with `now` it interrupts the turn. */
|
||||
agentPrompt: (task: string, prompt: AgentPrompt, now: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("agent_prompt", { task, prompt, now })),
|
||||
/** Drops a queued message and returns it (to edit it). */
|
||||
@@ -4299,6 +4303,19 @@ export type ExternalFormatterSettings = {
|
||||
runOnSave?: boolean,
|
||||
};
|
||||
|
||||
/** A session on this machine that no task holds yet. */
|
||||
export type ExternalSession = {
|
||||
/** The [`crate::AgentProvider`] id that owns it (`claude`, `codex`). */
|
||||
provider: string,
|
||||
/** The id resuming it names (`--resume`, `thread/resume`). */
|
||||
sessionId: string,
|
||||
title: string,
|
||||
/** Where it ran, when the transcript names one. */
|
||||
cwd: string | null,
|
||||
/** File modification time, ms since the epoch. */
|
||||
updatedMs: number | null,
|
||||
};
|
||||
|
||||
/** Tools › External Tools entry. */
|
||||
export type ExternalTool = {
|
||||
id?: string,
|
||||
|
||||
@@ -187,3 +187,15 @@
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Imported sessions below the task list: compact rows, not growing space. */
|
||||
.external {
|
||||
flex: none;
|
||||
overflow-y: auto;
|
||||
padding: 4px 12px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.external > .row {
|
||||
flex: none;
|
||||
}
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
sessionKey,
|
||||
type TaskGroup,
|
||||
} from "./model";
|
||||
import { openHome, useAgents } from "./store";
|
||||
import { importExternalSession, openHome, refreshExternalSessions, useAgents } from "./store";
|
||||
import { taskMenu } from "./taskActions";
|
||||
import { SessionEditor } from "./SessionEditor";
|
||||
import { HomeEditor } from "./HomeEditor";
|
||||
@@ -93,6 +93,19 @@ export function AgentsView() {
|
||||
const inline = docked !== null || homeOpen;
|
||||
const historyRef = useRef<HTMLButtonElement>(null);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const external = useAgents((s) => s.external);
|
||||
const externalError = useAgents((s) => s.externalError);
|
||||
const [importing, setImporting] = useState<string | null>(null);
|
||||
// Sessions started elsewhere (a terminal, another editor) list on demand (#312).
|
||||
useEffect(() => {
|
||||
if (historyOpen) void refreshExternalSessions();
|
||||
}, [historyOpen]);
|
||||
const importSession = (provider: string, sessionId: string) => {
|
||||
setImporting(`${provider}:${sessionId}`);
|
||||
void importExternalSession(provider, sessionId)
|
||||
.catch(() => {})
|
||||
.finally(() => setImporting(null));
|
||||
};
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set(["g:archived"]));
|
||||
const [selected, setSelected] = useState<Set<string>>(() => {
|
||||
const task = sessionTask(maybeEditorService()?.activeInput?.());
|
||||
@@ -285,6 +298,37 @@ export function AgentsView() {
|
||||
<T>Agent sessions</T>
|
||||
</div>
|
||||
{taskList}
|
||||
{(external.length > 0 || externalError) && (
|
||||
<>
|
||||
<div className={styles.historyTitle}>
|
||||
<T>Sessions from other apps</T>
|
||||
</div>
|
||||
{externalError ? (
|
||||
<div className={styles.empty}>{externalError}</div>
|
||||
) : (
|
||||
<div className={styles.external}>
|
||||
{external.map((s) => {
|
||||
const key = `${s.provider}:${s.sessionId}`;
|
||||
return (
|
||||
<div key={key} className={styles.row} title={s.cwd ?? s.sessionId}>
|
||||
<Icon name={providerIcon(s.provider)} size={16} label={providerName(s.provider)} />
|
||||
<span className={styles.title}>{s.title}</span>
|
||||
<span className={styles.age}>{age(s.updatedMs ?? 0)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.link}
|
||||
disabled={importing === key}
|
||||
onClick={() => importSession(s.provider, s.sessionId)}
|
||||
>
|
||||
<T>Import</T>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Popup>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,15 @@ const backend = vi.hoisted(() => ({
|
||||
fetched: [] as string[],
|
||||
entryError: null as string | null,
|
||||
entryWait: null as Promise<void> | null,
|
||||
external: [] as Array<{
|
||||
provider: string;
|
||||
sessionId: string;
|
||||
title: string;
|
||||
cwd: string | null;
|
||||
updatedMs: number;
|
||||
}>,
|
||||
imported: [] as Array<[string, string]>,
|
||||
externalError: null as string | null,
|
||||
}));
|
||||
vi.mock("@/platform/ipc", async (original) => ({
|
||||
...(await original<typeof import("@/platform/ipc")>()),
|
||||
@@ -40,6 +49,16 @@ vi.mock("@/platform/ipc", async (original) => ({
|
||||
backend.answers.push([task, permission, option]);
|
||||
return { status: "ok", data: null };
|
||||
},
|
||||
agentExternalSessions: async () => {
|
||||
if (backend.externalError !== null) throw new Error(backend.externalError);
|
||||
return { status: "ok", data: backend.external };
|
||||
},
|
||||
agentImportSession: async (provider: string, sessionId: string) => {
|
||||
backend.imported.push([provider, sessionId]);
|
||||
const found = backend.external.find((e) => e.provider === provider && e.sessionId === sessionId);
|
||||
if (!found) throw new Error("unknown session");
|
||||
return { status: "ok", data: { ...task("imported"), id: "imported", title: found.title, provider } };
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@tauri-apps/api/webview", () => ({
|
||||
@@ -118,6 +137,9 @@ beforeEach(() => {
|
||||
disconnect();
|
||||
localStorage.clear();
|
||||
backend.changes = [];
|
||||
backend.external = [];
|
||||
backend.imported = [];
|
||||
backend.externalError = null;
|
||||
backend.entries = new Map([
|
||||
["A", [{ type: "agent", text: "Answer A" }, subagent]],
|
||||
[entriesKey("A", "child"), [{ type: "agent", text: "Child answer" }]],
|
||||
@@ -129,6 +151,8 @@ beforeEach(() => {
|
||||
homeOpen: false,
|
||||
tasks: new Map([task("A"), task("B")].map((t) => [t.id, t])),
|
||||
entries: new Map(backend.entries),
|
||||
external: [],
|
||||
externalError: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1256,3 +1280,36 @@ describe("meters in the task header", () => {
|
||||
expect(screen.getByText("$0.42")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessions from other apps", () => {
|
||||
const openHistory = async () => {
|
||||
act(() => useAgents.setState({ docked: "A" }));
|
||||
render(<AgentsView />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Agent sessions" }));
|
||||
await waitFor(() => expect(screen.getByText("Sessions from other apps")).toBeInTheDocument());
|
||||
};
|
||||
|
||||
it("lists external sessions in the history popup and imports one as a task", async () => {
|
||||
backend.external = [
|
||||
{ provider: "claude", sessionId: "abc", title: "Login fix", cwd: "/work/app", updatedMs: Date.now() },
|
||||
{ provider: "codex", sessionId: "thread-1", title: "Add tests", cwd: null, updatedMs: 0 },
|
||||
];
|
||||
await openHistory();
|
||||
expect(screen.getByText("Login fix")).toBeInTheDocument();
|
||||
expect(screen.getByText("Add tests")).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Import" })[0]!);
|
||||
});
|
||||
expect(backend.imported).toEqual([["claude", "abc"]]);
|
||||
const state = useAgents.getState();
|
||||
expect(state.tasks.get("imported")?.title).toBe("Login fix");
|
||||
expect(state.external.map((e) => e.sessionId)).toEqual(["thread-1"]);
|
||||
});
|
||||
|
||||
it("shows listing errors instead of the section", async () => {
|
||||
backend.externalError = "no home";
|
||||
await openHistory();
|
||||
expect(screen.getByText("no home")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Import" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
AgentLimit,
|
||||
AgentProvider,
|
||||
AgentTask,
|
||||
ExternalSession,
|
||||
Isolation,
|
||||
} from "@/bindings";
|
||||
import { useProject } from "@/platform/project";
|
||||
@@ -77,6 +78,10 @@ interface AgentsState {
|
||||
lastComposer: string;
|
||||
/** First messages of past tasks, newest first (New Task page ↑). */
|
||||
history: string[];
|
||||
/** Sessions on this machine that no task holds yet (#312). */
|
||||
external: ExternalSession[];
|
||||
/** Why the external sessions could not be listed. */
|
||||
externalError: string | null;
|
||||
/** The composer asked to take focus (`seq` tells repeated requests apart). */
|
||||
focus: { key: string; seq: number } | null;
|
||||
/** Task shown inside the Agents tool window instead of an editor tab. */
|
||||
@@ -138,6 +143,8 @@ export const useAgents = create<AgentsState>(() => ({
|
||||
composers: new Map(),
|
||||
lastComposer: HOME,
|
||||
history: load<string[]>(HISTORY_KEY, []),
|
||||
external: [],
|
||||
externalError: null,
|
||||
focus: null,
|
||||
docked: null,
|
||||
sessionLocation: load<unknown>(SESSION_LOCATION_KEY, null) === "toolWindow" ? "toolWindow" : "editor",
|
||||
@@ -715,6 +722,26 @@ export async function startTask(
|
||||
if ((await editorService().closeInput?.(HOME_KEY)) !== false) openTask(task.id);
|
||||
}
|
||||
|
||||
/** Sessions on this machine that no task holds yet (a terminal, another editor: #312). */
|
||||
export async function refreshExternalSessions() {
|
||||
try {
|
||||
const external = await call(commands.agentExternalSessions());
|
||||
useAgents.setState({ external, externalError: null });
|
||||
} catch (e) {
|
||||
useAgents.setState({ externalError: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
/** Imports an external session as a task and opens it: its first turn loads it (#312). */
|
||||
export async function importExternalSession(provider: string, sessionId: string) {
|
||||
const task = await call(commands.agentImportSession(provider, sessionId));
|
||||
useAgents.setState((s) => ({
|
||||
tasks: s.tasks.has(task.id) ? s.tasks : new Map(s.tasks).set(task.id, task),
|
||||
external: s.external.filter((e) => !(e.provider === provider && e.sessionId === sessionId)),
|
||||
}));
|
||||
openTask(task.id);
|
||||
}
|
||||
|
||||
function remember(text: string) {
|
||||
if (!text) return;
|
||||
const history = [text, ...useAgents.getState().history.filter((h) => h !== text)].slice(0, HISTORY_SIZE);
|
||||
|
||||
@@ -2333,6 +2333,7 @@
|
||||
"Services": "服务",
|
||||
"Services actions": "服务操作",
|
||||
"Session finished": "会话已结束",
|
||||
"Sessions from other apps": "来自其他应用的智能体会话",
|
||||
"Set Active Changelist": "设置活动更改列表",
|
||||
"Set DEFAULT": "设为默认值",
|
||||
"Set NULL": "设为 NULL",
|
||||
|
||||
Reference in new issue
Block a user