mirror of
https://github.com/alphaXiv/OpenResearch.git
synced 2026-11-12 10:05:24 +08:00
Persist side chats and add a per-session picker (#716)
This commit is contained in:
+1
-8
@@ -164,14 +164,6 @@ pub async fn run(args: UpArgs) -> Result<()> {
|
||||
let gate = state.data_dir_gate.clone();
|
||||
tokio::task::spawn_blocking(move || local::demo::prewarm(move_in_progress, gate));
|
||||
}
|
||||
if !persistent_host {
|
||||
let chat = state.chat.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = local::chat::delete_side_chats(&chat).await {
|
||||
eprintln!("orx up: side chat cleanup: {err}");
|
||||
}
|
||||
});
|
||||
}
|
||||
// Deliver explicitly registered run wake-ups once their chat becomes idle.
|
||||
tokio::spawn(local::chat::watch_runs(
|
||||
state.chat.clone(),
|
||||
@@ -7659,6 +7651,7 @@ async fn create_chat_session(
|
||||
autonomy: req.autonomy.map(|autonomy| autonomy.id().to_string()),
|
||||
active_leaf_id: None,
|
||||
side_parent_session_id: None,
|
||||
side_parent_leaf_id: None,
|
||||
created_at: now_ms(),
|
||||
updated_at: now_ms(),
|
||||
};
|
||||
|
||||
+160
-25
@@ -1197,6 +1197,7 @@ pub fn import_native_chat(
|
||||
autonomy: None,
|
||||
active_leaf_id: None,
|
||||
side_parent_session_id: None,
|
||||
side_parent_leaf_id: None,
|
||||
created_at: now_ms(),
|
||||
updated_at: now_ms(),
|
||||
};
|
||||
@@ -1734,7 +1735,8 @@ impl ChatHost {
|
||||
harness: &dyn crate::local::harness::Harness,
|
||||
snapshot: &str,
|
||||
) -> Result<String> {
|
||||
let prior = session.bootstrap_context.as_deref().unwrap_or("");
|
||||
let bootstrap = effective_bootstrap_context(&Store::open()?, session)?;
|
||||
let prior = bootstrap.as_deref().unwrap_or("");
|
||||
let prompt = format!(
|
||||
"<orx-transcript>\n{prior}{}{snapshot}\n</orx-transcript>",
|
||||
if prior.is_empty() { "" } else { "\n\n" }
|
||||
@@ -5621,13 +5623,18 @@ impl ChatHost {
|
||||
prepare_project_slash_worktree(&project, &message.text, &session.id).await;
|
||||
}
|
||||
}
|
||||
let bootstrap = if prepared_input.is_none() && session.native_session_id.is_none() {
|
||||
effective_bootstrap_context(&store, &session)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut turn_text = prepared_input.unwrap_or_else(|| {
|
||||
let expanded = contextualize_messages(messages, |text| {
|
||||
expand_slash_skills(&project, text, Some(&session.harness), Some(&session.id))
|
||||
});
|
||||
with_turn_context(
|
||||
session.native_session_id.as_deref(),
|
||||
session.bootstrap_context.as_deref(),
|
||||
bootstrap.as_deref(),
|
||||
session.goal.as_deref(),
|
||||
Autonomy::from_stored(session.autonomy.as_deref()).turn_context(),
|
||||
super::demo::turn_context(&project.id),
|
||||
@@ -6424,9 +6431,14 @@ impl ChatHost {
|
||||
/// live branch as it is right now.
|
||||
pub async fn open_side_chat(&self, parent: &StoredChatSession) -> Result<StoredChatSession> {
|
||||
let store = Store::open()?;
|
||||
let transaction = store.begin_immediate()?;
|
||||
let parent = store
|
||||
.get_chat_session(&parent.id)?
|
||||
.ok_or_else(|| anyhow!("chat session is gone"))?;
|
||||
let messages = store.list_chat_messages(&parent.id)?;
|
||||
let session = side_chat_session(parent, &messages);
|
||||
let session = side_chat_session(&parent, &messages);
|
||||
store.create_chat_session(&session)?;
|
||||
transaction.commit()?;
|
||||
// Checked after the insert: a parent delete that began earlier has
|
||||
// already listed its side chats and would miss this one.
|
||||
if self.deleting_sessions.lock().unwrap().contains(&parent.id)
|
||||
@@ -6588,7 +6600,7 @@ impl ChatHost {
|
||||
.begin_session_delete(session_id)
|
||||
.ok_or_else(|| anyhow!("session deletion is already in progress"))?;
|
||||
for side_chat in Store::open()?.side_chats_of(session_id)? {
|
||||
let _ = Box::pin(self.delete_session(&side_chat)).await;
|
||||
Box::pin(self.delete_session(&side_chat)).await?;
|
||||
}
|
||||
self.clear_queue(session_id)?;
|
||||
let _ = self.interrupt(session_id).await;
|
||||
@@ -8000,19 +8012,49 @@ fn active_path<'a>(
|
||||
path
|
||||
}
|
||||
|
||||
fn side_chat_context<'a>(
|
||||
messages: impl IntoIterator<Item = &'a StoredChatMessage>,
|
||||
) -> Option<String> {
|
||||
let snapshot = crate::local::harness::transcript_snapshot(messages);
|
||||
(!snapshot.is_empty()).then(|| format!(
|
||||
"<orx-side-chat-context>\nThis is a side chat branched from another chat, which may still be running in this same worktree. Use this snapshot of that chat, taken when the side chat opened, to answer questions about its work. Do not modify files unless the user asks.\n{snapshot}\n</orx-side-chat-context>"
|
||||
))
|
||||
}
|
||||
|
||||
fn effective_bootstrap_context(
|
||||
store: &Store,
|
||||
session: &StoredChatSession,
|
||||
) -> Result<Option<String>> {
|
||||
if let Some(context) = &session.bootstrap_context {
|
||||
return Ok(Some(context.clone()));
|
||||
}
|
||||
let (Some(parent), Some(leaf)) = (
|
||||
&session.side_parent_session_id,
|
||||
&session.side_parent_leaf_id,
|
||||
) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let messages = store.list_chat_messages(parent)?;
|
||||
if !messages.iter().any(|message| &message.id == leaf) {
|
||||
return Err(anyhow!("side chat parent context is unavailable"));
|
||||
}
|
||||
Ok(side_chat_context(active_path(&messages, Some(leaf))))
|
||||
}
|
||||
|
||||
fn side_chat_session(
|
||||
parent: &StoredChatSession,
|
||||
messages: &[StoredChatMessage],
|
||||
) -> StoredChatSession {
|
||||
let snapshot = crate::local::harness::transcript_snapshot(active_path(
|
||||
messages,
|
||||
parent.active_leaf_id.as_deref(),
|
||||
));
|
||||
let bootstrap_context = (!snapshot.is_empty()).then(|| {
|
||||
format!(
|
||||
"<orx-side-chat-context>\nThis is a side chat branched from another chat, which may still be running in this same worktree. Use this snapshot of that chat, taken when the side chat opened, to answer questions about its work. Do not modify files unless the user asks.\n{snapshot}\n</orx-side-chat-context>"
|
||||
)
|
||||
});
|
||||
let leaf = parent
|
||||
.active_leaf_id
|
||||
.as_ref()
|
||||
.filter(|id| messages.iter().any(|m| &m.id == *id));
|
||||
// Legacy transcripts without a valid leaf keep their existing snapshot semantics.
|
||||
let bootstrap_context = if leaf.is_none() && !messages.is_empty() {
|
||||
side_chat_context(messages.iter())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let now = now_ms();
|
||||
StoredChatSession {
|
||||
id: format!("chat_{}", uuid::Uuid::new_v4()),
|
||||
@@ -8038,6 +8080,7 @@ fn side_chat_session(
|
||||
autonomy: parent.autonomy.clone(),
|
||||
active_leaf_id: None,
|
||||
side_parent_session_id: Some(parent.id.clone()),
|
||||
side_parent_leaf_id: leaf.cloned(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
@@ -8343,17 +8386,6 @@ async fn process_run_monitoring_alerts(
|
||||
|
||||
// --- spawned agents -------------------------------------------------------------
|
||||
|
||||
/// How much of a helper's closing reply, and of the brief echoed back with it,
|
||||
/// rides into the parent's context. Both are agent-authored and unbounded; the
|
||||
/// user can open the helper's session for the rest.
|
||||
/// Side chats last only as long as the app run that opened them.
|
||||
pub async fn delete_side_chats(chat: &Arc<ChatHost>) -> Result<()> {
|
||||
for session_id in Store::open()?.side_chat_ids()? {
|
||||
chat.delete_session(&session_id).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resume explicitly subscribed agent sessions after a run finishes. Busy and
|
||||
/// draining sessions retain their durable wake-up until they become idle.
|
||||
pub async fn watch_runs(
|
||||
@@ -9018,6 +9050,7 @@ mod cap_tests {
|
||||
autonomy: None,
|
||||
active_leaf_id: None,
|
||||
side_parent_session_id: None,
|
||||
side_parent_leaf_id: None,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
})
|
||||
@@ -9126,6 +9159,7 @@ mod cap_tests {
|
||||
autonomy: None,
|
||||
active_leaf_id: None,
|
||||
side_parent_session_id: None,
|
||||
side_parent_leaf_id: None,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
};
|
||||
@@ -9183,6 +9217,7 @@ mod cap_tests {
|
||||
autonomy: None,
|
||||
active_leaf_id: None,
|
||||
side_parent_session_id: None,
|
||||
side_parent_leaf_id: None,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
};
|
||||
@@ -9957,6 +9992,7 @@ mod bridge_tests {
|
||||
autonomy: None,
|
||||
active_leaf_id: None,
|
||||
side_parent_session_id: None,
|
||||
side_parent_leaf_id: None,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
}
|
||||
@@ -10113,6 +10149,7 @@ mod run_wakeup_tests {
|
||||
autonomy: None,
|
||||
active_leaf_id: None,
|
||||
side_parent_session_id: None,
|
||||
side_parent_leaf_id: None,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
})
|
||||
@@ -10517,6 +10554,7 @@ mod transcript_tree_tests {
|
||||
autonomy: None,
|
||||
active_leaf_id: Some("a3".into()),
|
||||
side_parent_session_id: None,
|
||||
side_parent_leaf_id: None,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
}
|
||||
@@ -10532,7 +10570,10 @@ mod transcript_tree_tests {
|
||||
said("a3", "assistant", Some("u2"), "loss is falling"),
|
||||
];
|
||||
let side = side_chat_session(&parent_session("codex"), &messages);
|
||||
let context = side.bootstrap_context.unwrap();
|
||||
assert_eq!(side.bootstrap_context, None);
|
||||
assert_eq!(side.side_parent_leaf_id.as_deref(), Some("a3"));
|
||||
let context =
|
||||
side_chat_context(active_path(&messages, side.side_parent_leaf_id.as_deref())).unwrap();
|
||||
assert!(context.contains("kept branch") && context.contains("loss is falling"));
|
||||
assert!(!context.contains("abandoned retry"));
|
||||
assert_eq!(side.side_parent_session_id.as_deref(), Some("chat_parent"));
|
||||
@@ -10556,6 +10597,99 @@ mod transcript_tree_tests {
|
||||
assert_eq!(side.bootstrap_context, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_context_references_survive_restart_and_parent_branch_changes() {
|
||||
let dir = std::env::temp_dir().join(format!("orx-side-context-{}", uuid::Uuid::new_v4()));
|
||||
let store = Store::open_at(dir.clone()).unwrap();
|
||||
let parent = parent_session("codex");
|
||||
store.create_chat_session(&parent).unwrap();
|
||||
let mut messages = vec![
|
||||
said("u1", "user", None, "train the model"),
|
||||
said("a1", "assistant", Some("u1"), "kept branch"),
|
||||
said("a2", "assistant", Some("u1"), "abandoned retry"),
|
||||
said("u2", "user", Some("a1"), "how is it going"),
|
||||
said("a3", "assistant", Some("u2"), "loss is falling"),
|
||||
];
|
||||
for message in &mut messages {
|
||||
message.session_id = parent.id.clone();
|
||||
store.upsert_chat_message(message).unwrap();
|
||||
}
|
||||
let side = side_chat_session(&parent, &messages);
|
||||
store.create_chat_session(&side).unwrap();
|
||||
let expected = side_chat_context(active_path(&messages, parent.active_leaf_id.as_deref()));
|
||||
assert_eq!(
|
||||
effective_bootstrap_context(&store, &side).unwrap(),
|
||||
expected
|
||||
);
|
||||
|
||||
store
|
||||
.set_chat_session_active_leaf(&parent.id, Some("a2"))
|
||||
.unwrap();
|
||||
let mut later = said("later", "user", Some("a3"), "do not inherit this");
|
||||
later.session_id = parent.id.clone();
|
||||
store.upsert_chat_message(&later).unwrap();
|
||||
drop(store);
|
||||
let store = Store::open_at(dir.clone()).unwrap();
|
||||
let mut side = store.get_chat_session(&side.id).unwrap().unwrap();
|
||||
assert_eq!(side.side_parent_leaf_id.as_deref(), Some("a3"));
|
||||
assert_eq!(
|
||||
effective_bootstrap_context(&store, &side).unwrap(),
|
||||
expected
|
||||
);
|
||||
|
||||
// Parent content changes are accepted, but the saved branch boundary stays fixed.
|
||||
messages[4].parts_json = said("a3", "assistant", None, "updated result").parts_json;
|
||||
store.upsert_chat_message(&messages[4]).unwrap();
|
||||
assert!(effective_bootstrap_context(&store, &side)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.contains("updated result"));
|
||||
side.bootstrap_context = Some("compaction or legacy snapshot".into());
|
||||
assert_eq!(
|
||||
effective_bootstrap_context(&store, &side)
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("compaction or legacy snapshot")
|
||||
);
|
||||
side.bootstrap_context = None;
|
||||
side.side_parent_leaf_id = Some("missing".into());
|
||||
assert!(effective_bootstrap_context(&store, &side).is_err());
|
||||
drop(store);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_side_context_stays_empty_after_parent_messages_arrive() {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("orx-empty-side-context-{}", uuid::Uuid::new_v4()));
|
||||
let store = Store::open_at(dir.clone()).unwrap();
|
||||
let mut parent = parent_session("codex");
|
||||
parent.active_leaf_id = None;
|
||||
store.create_chat_session(&parent).unwrap();
|
||||
let side = side_chat_session(&parent, &[]);
|
||||
store.create_chat_session(&side).unwrap();
|
||||
let mut later = said("later", "user", None, "new parent context");
|
||||
later.session_id = parent.id.clone();
|
||||
store.upsert_chat_message(&later).unwrap();
|
||||
assert_eq!(effective_bootstrap_context(&store, &side).unwrap(), None);
|
||||
drop(store);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_context_keeps_legacy_fallback_and_utf8_snapshot_limit() {
|
||||
let mut parent = parent_session("codex");
|
||||
parent.active_leaf_id = None;
|
||||
let messages = vec![said("u1", "user", None, &"界".repeat(20_000))];
|
||||
let side = side_chat_session(&parent, &messages);
|
||||
assert_eq!(side.side_parent_leaf_id, None);
|
||||
let expected = crate::local::harness::transcript_snapshot(messages.iter());
|
||||
assert!(expected.len() <= 32 * 1024);
|
||||
assert!(expected.ends_with('界'));
|
||||
assert_eq!(side.bootstrap_context, side_chat_context(messages.iter()));
|
||||
assert!(side.bootstrap_context.unwrap().contains(&expected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_path_without_a_leaf_keeps_the_whole_transcript() {
|
||||
let messages = forked();
|
||||
@@ -10653,6 +10787,7 @@ mod steering_tests {
|
||||
autonomy: None,
|
||||
active_leaf_id: None,
|
||||
side_parent_session_id: None,
|
||||
side_parent_leaf_id: None,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
})
|
||||
|
||||
@@ -567,6 +567,7 @@ fn seed_at(
|
||||
autonomy: None,
|
||||
active_leaf_id: Some(ASSISTANT_MESSAGE_ID.into()),
|
||||
side_parent_session_id: None,
|
||||
side_parent_leaf_id: None,
|
||||
created_at: ago(seeded_at, 240, 0),
|
||||
// Sessions list by updated_at DESC, the order validate_snapshot asserts.
|
||||
updated_at: ago(seeded_at, 9, 30),
|
||||
@@ -613,6 +614,7 @@ fn seed_at(
|
||||
autonomy: None,
|
||||
active_leaf_id: Some(FIGURE_ASSISTANT_MESSAGE_ID.into()),
|
||||
side_parent_session_id: None,
|
||||
side_parent_leaf_id: None,
|
||||
created_at: ago(seeded_at, 70, 0),
|
||||
updated_at: ago(seeded_at, 69, 40),
|
||||
};
|
||||
@@ -661,6 +663,7 @@ fn seed_at(
|
||||
autonomy: None,
|
||||
active_leaf_id: Some(LITERATURE_ASSISTANT_MESSAGE_ID.into()),
|
||||
side_parent_session_id: None,
|
||||
side_parent_leaf_id: None,
|
||||
created_at: ago(seeded_at, 95, 0),
|
||||
updated_at: ago(seeded_at, 94, 40),
|
||||
};
|
||||
|
||||
+25
-17
@@ -751,6 +751,7 @@ impl Store {
|
||||
"ALTER TABLE chat_sessions ADD COLUMN goal TEXT",
|
||||
"ALTER TABLE chat_sessions ADD COLUMN autonomy TEXT",
|
||||
"ALTER TABLE chat_sessions ADD COLUMN side_parent_session_id TEXT",
|
||||
"ALTER TABLE chat_sessions ADD COLUMN side_parent_leaf_id TEXT",
|
||||
"ALTER TABLE ui_state ADD COLUMN preferred_service_tier TEXT",
|
||||
"ALTER TABLE ui_state ADD COLUMN workspace_state_json TEXT",
|
||||
"ALTER TABLE ui_state ADD COLUMN preferred_autonomy TEXT",
|
||||
@@ -1643,8 +1644,8 @@ impl Store {
|
||||
title_source, model, service_tier, permission_mode, plan_mode,
|
||||
plan_reset_pending, reasoning_level,
|
||||
archived, context_usage_json, bootstrap_context,
|
||||
active_leaf_id, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
|
||||
active_leaf_id, created_at, updated_at, side_parent_session_id, side_parent_leaf_id)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
|
||||
params![
|
||||
session.id,
|
||||
session.project_id,
|
||||
@@ -1664,6 +1665,8 @@ impl Store {
|
||||
session.active_leaf_id,
|
||||
session.created_at,
|
||||
session.updated_at,
|
||||
session.side_parent_session_id,
|
||||
session.side_parent_leaf_id,
|
||||
],
|
||||
)?;
|
||||
if let Some(state) = self.acp_launch_for_session(session)? {
|
||||
@@ -1904,8 +1907,8 @@ impl Store {
|
||||
"INSERT INTO chat_sessions (id, project_id, harness, native_session_id, title, title_source, model,
|
||||
service_tier, permission_mode, plan_mode, plan_reset_pending, reasoning_level, archived, bootstrap_context,
|
||||
active_leaf_id, created_at, updated_at, autonomy,
|
||||
side_parent_session_id)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
|
||||
side_parent_session_id, side_parent_leaf_id)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
|
||||
params![
|
||||
s.id,
|
||||
s.project_id,
|
||||
@@ -1926,6 +1929,7 @@ impl Store {
|
||||
s.updated_at,
|
||||
s.autonomy,
|
||||
s.side_parent_session_id,
|
||||
s.side_parent_leaf_id,
|
||||
],
|
||||
)?;
|
||||
if let Some(state) = acp_state {
|
||||
@@ -2014,14 +2018,6 @@ impl Store {
|
||||
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
|
||||
}
|
||||
|
||||
pub fn side_chat_ids(&self) -> Result<Vec<String>> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT id FROM chat_sessions WHERE side_parent_session_id IS NOT NULL")?;
|
||||
let rows = stmt.query_map([], |row| row.get(0))?;
|
||||
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
|
||||
}
|
||||
|
||||
pub fn side_chats_of(&self, parent_id: &str) -> Result<Vec<String>> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
@@ -2124,7 +2120,15 @@ impl Store {
|
||||
}
|
||||
|
||||
pub fn delete_chat_session(&self, id: &str) -> Result<()> {
|
||||
let tx = self.begin()?;
|
||||
let tx = self.begin_immediate()?;
|
||||
let has_children: bool = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM chat_sessions WHERE side_parent_session_id = ?1)",
|
||||
[id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if has_children {
|
||||
return Err(anyhow!("delete side chats before deleting their parent"));
|
||||
}
|
||||
tx.execute(
|
||||
"DELETE FROM chat_queued_messages WHERE session_id = ?1",
|
||||
params![id],
|
||||
@@ -3165,6 +3169,8 @@ pub struct StoredChatSession {
|
||||
/// Chat this side chat branched from. Side chats share that chat's
|
||||
/// worktree, stay out of history, and are deleted with it.
|
||||
pub side_parent_session_id: Option<String>,
|
||||
/// Parent branch tip captured when this side chat was created.
|
||||
pub side_parent_leaf_id: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
@@ -3314,7 +3320,7 @@ fn row_to_chat_turn(
|
||||
const CHAT_SESSION_COLS: &str = "id, project_id, harness, native_session_id, title, model, service_tier, \
|
||||
permission_mode, plan_mode, plan_reset_pending, reasoning_level, archived, context_usage_json, \
|
||||
created_at, updated_at, title_source, bootstrap_context, active_leaf_id, \
|
||||
goal, autonomy, side_parent_session_id";
|
||||
goal, autonomy, side_parent_session_id, side_parent_leaf_id";
|
||||
|
||||
fn row_to_chat_session(
|
||||
row: &rusqlite::Row<'_>,
|
||||
@@ -3341,6 +3347,7 @@ fn row_to_chat_session(
|
||||
goal: row.get(18)?,
|
||||
autonomy: row.get(19)?,
|
||||
side_parent_session_id: row.get(20)?,
|
||||
side_parent_leaf_id: row.get(21)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4431,6 +4438,7 @@ mod tests {
|
||||
autonomy: None,
|
||||
active_leaf_id: None,
|
||||
side_parent_session_id: None,
|
||||
side_parent_leaf_id: None,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
}
|
||||
@@ -4632,15 +4640,15 @@ mod tests {
|
||||
.collect();
|
||||
assert_eq!(all, ["chat_parent"]);
|
||||
assert_eq!(store.side_chats_of("chat_parent").unwrap(), ["chat_side"]);
|
||||
assert_eq!(store.side_chat_ids().unwrap(), ["chat_side"]);
|
||||
assert_eq!(
|
||||
store.chat_worktree_owner("chat_side").unwrap(),
|
||||
"chat_parent"
|
||||
);
|
||||
assert_eq!(store.chat_worktree_owner("chat_gone").unwrap(), "chat_gone");
|
||||
// An orphan whose parent row is gone owns its own worktree again.
|
||||
assert!(store.delete_chat_session("chat_parent").is_err());
|
||||
assert!(store.get_chat_session("chat_parent").unwrap().is_some());
|
||||
store.delete_chat_session("chat_side").unwrap();
|
||||
store.delete_chat_session("chat_parent").unwrap();
|
||||
assert_eq!(store.chat_worktree_owner("chat_side").unwrap(), "chat_side");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
+20
-2
@@ -98,7 +98,7 @@ pub enum Pane {
|
||||
spawn_part_id: String,
|
||||
},
|
||||
Side {
|
||||
session_id: String,
|
||||
session_id: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@ impl Pane {
|
||||
session_id,
|
||||
spawn_part_id,
|
||||
} => nonempty(session_id) && nonempty(spawn_part_id),
|
||||
Self::Side { session_id } => nonempty(session_id),
|
||||
Self::Side { session_id } => session_id.as_ref().is_none_or(nonempty),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -306,6 +306,24 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn side_chat_drafts_and_saved_chats_have_valid_workspace_locations() {
|
||||
for session_id in [json!(null), json!("chat_saved")] {
|
||||
let value = json!({"kind":"side","sessionId":session_id});
|
||||
let pane: Pane = serde_json::from_value(value.clone()).unwrap();
|
||||
assert!(pane.valid());
|
||||
assert_eq!(serde_json::to_value(pane).unwrap(), value);
|
||||
assert!(valid_location(&format!(
|
||||
"/projects/demo/tasks/parent?pane={}",
|
||||
urlencoding::encode(&value.to_string())
|
||||
)));
|
||||
}
|
||||
assert!(!Pane::Side {
|
||||
session_id: Some(String::new()),
|
||||
}
|
||||
.valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_viewport_is_optional_and_validated() {
|
||||
let mut value = json!({"version":1,"tasks":{"new":{
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
var a1=Object.defineProperty;var cw=h=>{throw TypeError(h)};var o1=(h,e,t)=>e in h?a1(h,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):h[e]=t;var L=(h,e,t)=>o1(h,typeof e!="symbol"?e+"":e,t),By=(h,e,t)=>e.has(h)||cw("Cannot "+t);var a=(h,e,t)=>(By(h,e,"read from private field"),t?t.call(h):e.get(h)),b=(h,e,t)=>e.has(h)?cw("Cannot add the same private member more than once"):e instanceof WeakSet?e.add(h):e.set(h,t),g=(h,e,t,n)=>(By(h,e,"write to private field"),n?n.call(h,t):e.set(h,t),t),y=(h,e,t)=>(By(h,e,"access private method"),t);var Zt=(h,e,t,n)=>({set _(i){g(h,e,i,t)},get _(){return a(h,e,n)}});import{g as vy,c as LA,r as gi,M as l1,j as fe,f as ku,I as Bu,a as c1,i as hw,P as h1,b as dw,S as d1,d as u1,C as f1,e as p1,h as g1}from"./index-nILKpF0z.js";const m1=()=>"Find in document",v1=()=>"在文档中查找",y1=()=>"جستوجو در سند",b1=()=>"البحث في المستند",E1=()=>"Buscar en el documento",w1=()=>"दस्तावेज़ में खोजें",A1=()=>"문서에서 찾기",x1=()=>"文書内を検索",S1=()=>"Znajdź w dokumencie",uw=((h={},e={})=>{const t=e.locale??vy();return t==="zh-CN"?v1():t==="fa"?y1():t==="ar"?b1():t==="es"?E1():t==="hi"?w1():t==="ko"?A1():t==="ja"?x1():t==="pl"?S1():m1()}),T1=()=>"Fit to width",C1=()=>"适应宽度",P1=()=>"متناسب با عرض",I1=()=>"ملاءمة العرض",R1=()=>"Ajustar al ancho",O1=()=>"चौड़ाई में फ़िट करें",M1=()=>"너비에 맞추기",D1=()=>"幅に合わせる",L1=()=>"Dopasuj do szerokości",fw=((h={},e={})=>{const t=e.locale??vy();return t==="zh-CN"?C1():t==="fa"?P1():t==="ar"?I1():t==="es"?R1():t==="hi"?O1():t==="ko"?M1():t==="ja"?D1():t==="pl"?L1():T1()}),N1=()=>"Next match",F1=()=>"下一个匹配项",k1=()=>"مورد بعدی",B1=()=>"التطابق التالي",j1=()=>"Coincidencia siguiente",U1=()=>"अगला मिलान",$1=()=>"다음 일치 항목",H1=()=>"次の一致",V1=()=>"Następne dopasowanie",pw=((h={},e={})=>{const t=e.locale??vy();return t==="zh-CN"?F1():t==="fa"?k1():t==="ar"?B1():t==="es"?j1():t==="hi"?U1():t==="ko"?$1():t==="ja"?H1():t==="pl"?V1():N1()}),z1=()=>"Previous match",W1=()=>"上一个匹配项",G1=()=>"مورد قبلی",_1=()=>"التطابق السابق",X1=()=>"Coincidencia anterior",Y1=()=>"पिछला मिलान",K1=()=>"이전 일치 항목",J1=()=>"前の一致",Z1=()=>"Poprzednie dopasowanie",gw=((h={},e={})=>{const t=e.locale??vy();return t==="zh-CN"?W1():t==="fa"?G1():t==="ar"?_1():t==="es"?X1():t==="hi"?Y1():t==="ko"?K1():t==="ja"?J1():t==="pl"?Z1():z1()});/**
|
||||
var a1=Object.defineProperty;var cw=h=>{throw TypeError(h)};var o1=(h,e,t)=>e in h?a1(h,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):h[e]=t;var L=(h,e,t)=>o1(h,typeof e!="symbol"?e+"":e,t),By=(h,e,t)=>e.has(h)||cw("Cannot "+t);var a=(h,e,t)=>(By(h,e,"read from private field"),t?t.call(h):e.get(h)),b=(h,e,t)=>e.has(h)?cw("Cannot add the same private member more than once"):e instanceof WeakSet?e.add(h):e.set(h,t),g=(h,e,t,n)=>(By(h,e,"write to private field"),n?n.call(h,t):e.set(h,t),t),y=(h,e,t)=>(By(h,e,"access private method"),t);var Zt=(h,e,t,n)=>({set _(i){g(h,e,i,t)},get _(){return a(h,e,n)}});import{g as vy,c as LA,r as gi,M as l1,j as fe,f as ku,I as Bu,a as c1,i as hw,P as h1,b as dw,S as d1,d as u1,C as f1,e as p1,h as g1}from"./index-D_oC8Dze.js";const m1=()=>"Find in document",v1=()=>"在文档中查找",y1=()=>"جستوجو در سند",b1=()=>"البحث في المستند",E1=()=>"Buscar en el documento",w1=()=>"दस्तावेज़ में खोजें",A1=()=>"문서에서 찾기",x1=()=>"文書内を検索",S1=()=>"Znajdź w dokumencie",uw=((h={},e={})=>{const t=e.locale??vy();return t==="zh-CN"?v1():t==="fa"?y1():t==="ar"?b1():t==="es"?E1():t==="hi"?w1():t==="ko"?A1():t==="ja"?x1():t==="pl"?S1():m1()}),T1=()=>"Fit to width",C1=()=>"适应宽度",P1=()=>"متناسب با عرض",I1=()=>"ملاءمة العرض",R1=()=>"Ajustar al ancho",O1=()=>"चौड़ाई में फ़िट करें",M1=()=>"너비에 맞추기",D1=()=>"幅に合わせる",L1=()=>"Dopasuj do szerokości",fw=((h={},e={})=>{const t=e.locale??vy();return t==="zh-CN"?C1():t==="fa"?P1():t==="ar"?I1():t==="es"?R1():t==="hi"?O1():t==="ko"?M1():t==="ja"?D1():t==="pl"?L1():T1()}),N1=()=>"Next match",F1=()=>"下一个匹配项",k1=()=>"مورد بعدی",B1=()=>"التطابق التالي",j1=()=>"Coincidencia siguiente",U1=()=>"अगला मिलान",$1=()=>"다음 일치 항목",H1=()=>"次の一致",V1=()=>"Następne dopasowanie",pw=((h={},e={})=>{const t=e.locale??vy();return t==="zh-CN"?F1():t==="fa"?k1():t==="ar"?B1():t==="es"?j1():t==="hi"?U1():t==="ko"?$1():t==="ja"?H1():t==="pl"?V1():N1()}),z1=()=>"Previous match",W1=()=>"上一个匹配项",G1=()=>"مورد قبلی",_1=()=>"التطابق السابق",X1=()=>"Coincidencia anterior",Y1=()=>"पिछला मिलान",K1=()=>"이전 일치 항목",J1=()=>"前の一致",Z1=()=>"Poprzednie dopasowanie",gw=((h={},e={})=>{const t=e.locale??vy();return t==="zh-CN"?W1():t==="fa"?G1():t==="ar"?_1():t==="es"?X1():t==="hi"?Y1():t==="ko"?K1():t==="ja"?J1():t==="pl"?Z1():z1()});/**
|
||||
* @license lucide-react v1.23.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+1207
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-1207
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -49,8 +49,8 @@
|
||||
html { background: #ffffff; }
|
||||
html[data-theme="dark"] { background: #0e0c0c; }
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/index-nILKpF0z.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CcyD7olD.css">
|
||||
<script type="module" crossorigin src="/assets/index-D_oC8Dze.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Wf7ls04H.css">
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
|
||||
+8
-3
@@ -542,7 +542,7 @@
|
||||
"status_cancelled": "ملغى",
|
||||
"status_editing": "قيد التعديل",
|
||||
"status_idle": "خامل",
|
||||
"chat_delete_session_confirm": "حذف «{title}»؟\n\nسيُحذف سجل المحادثة نهائيًا.",
|
||||
"chat_delete_session_confirm": "هل تريد حذف «{title}»؟\n\nسيُحذف سجلها وأي محادثات جانبية نهائيًا.",
|
||||
"chat_delete_session_failed": "تعذّر حذف «{title}»: {error}",
|
||||
"chat_untitled": "بدون عنوان",
|
||||
"skills_delete_skill_label": "حذف المهارة {name}",
|
||||
@@ -1278,11 +1278,16 @@
|
||||
"tree_restore_above": "استعادة الأسلاف المؤرشفين",
|
||||
"tree_restore_down": "استعادة الفروع التابعة",
|
||||
"tree_all_experiments_archived": "جميع التجارب مؤرشفة. فعّل إظهار المؤرشف لعرضها.",
|
||||
"side_command_description": "اطرح سؤالًا سريعًا في محادثة جانبية مؤقتة",
|
||||
"side_command_description": "اطرح سؤالًا سريعًا في محادثة جانبية",
|
||||
"side_chat_list": "المحادثات الجانبية ({count})",
|
||||
"side_chat_new": "محادثة جانبية جديدة",
|
||||
"side_chat_search": "البحث في المحادثات الجانبية…",
|
||||
"side_chat_untitled": "محادثة جانبية بلا عنوان",
|
||||
"side_chat_delete": "حذف المحادثة الجانبية",
|
||||
"side_chat_open": "محادثة جانبية",
|
||||
"side_chat_needs_a_chat": "افتح محادثة أولًا، ثم ابدأ منها محادثة جانبية.",
|
||||
"side_chat_open_failed": "تعذّر فتح محادثة جانبية: {error}",
|
||||
"side_chat_empty": "تُحذف المحادثات الجانبية عند إغلاقها أو الخروج من التطبيق.",
|
||||
"side_chat_empty": "اطرح سؤال متابعة باستخدام سياق المحادثة الأصلية. يمكنك إعادة فتح هذه المحادثة الجانبية لاحقًا.",
|
||||
"workspace_more_tools": "مزيد من الأدوات",
|
||||
"side_chat_nested": "لا يمكن للمحادثة الجانبية فتح محادثة جانبية خاصة بها.",
|
||||
"side_chat_close_failed": "تعذّر حذف المحادثة الجانبية: {error}",
|
||||
|
||||
+8
-3
@@ -543,7 +543,7 @@
|
||||
"status_cancelled": "Cancelled",
|
||||
"status_editing": "Editing",
|
||||
"status_idle": "Idle",
|
||||
"chat_delete_session_confirm": "Delete “{title}”?\n\nIts transcript will be permanently removed.",
|
||||
"chat_delete_session_confirm": "Delete “{title}”?\n\nIts transcript and any side chats will be permanently removed.",
|
||||
"chat_delete_session_failed": "Failed to delete “{title}”: {error}",
|
||||
"chat_untitled": "Untitled",
|
||||
"skills_delete_skill_label": "Delete skill {name}",
|
||||
@@ -1278,11 +1278,16 @@
|
||||
"tree_restore_above": "Restore archived ancestors",
|
||||
"tree_restore_down": "Restore descendants",
|
||||
"tree_all_experiments_archived": "All experiments are archived. Turn on Show archived to view them.",
|
||||
"side_command_description": "Ask a quick question in a temporary side chat",
|
||||
"side_command_description": "Ask a quick question in a side chat",
|
||||
"side_chat_list": "Side chats ({count})",
|
||||
"side_chat_new": "New side chat",
|
||||
"side_chat_search": "Search side chats…",
|
||||
"side_chat_untitled": "Untitled side chat",
|
||||
"side_chat_delete": "Delete side chat",
|
||||
"side_chat_open": "Side chat",
|
||||
"side_chat_needs_a_chat": "Open a chat first, then start a side chat from it.",
|
||||
"side_chat_open_failed": "Couldn't open a side chat: {error}",
|
||||
"side_chat_empty": "Side chats are deleted when you close them or quit the app.",
|
||||
"side_chat_empty": "Ask a follow-up using the context from the original chat. You can reopen this side chat later.",
|
||||
"workspace_more_tools": "More tools",
|
||||
"side_chat_nested": "A side chat can't open its own side chat.",
|
||||
"side_chat_close_failed": "Couldn't delete the side chat: {error}",
|
||||
|
||||
+8
-3
@@ -543,7 +543,7 @@
|
||||
"status_cancelled": "Cancelado",
|
||||
"status_editing": "Editando",
|
||||
"status_idle": "Inactivo",
|
||||
"chat_delete_session_confirm": "¿Eliminar “{title}”?\n\nSu transcripción se eliminará de forma permanente.",
|
||||
"chat_delete_session_confirm": "¿Eliminar «{title}»?\n\nSu transcripción y sus chats secundarios se eliminarán permanentemente.",
|
||||
"chat_delete_session_failed": "No se pudo eliminar “{title}”: {error}",
|
||||
"chat_untitled": "Sin título",
|
||||
"skills_delete_skill_label": "Eliminar la skill {name}",
|
||||
@@ -1278,11 +1278,16 @@
|
||||
"tree_restore_above": "Restaurar antepasados archivados",
|
||||
"tree_restore_down": "Restaurar descendientes",
|
||||
"tree_all_experiments_archived": "Todos los experimentos están archivados. Activa Mostrar archivados para verlos.",
|
||||
"side_command_description": "Haz una pregunta rápida en un chat lateral temporal",
|
||||
"side_command_description": "Haz una pregunta rápida en un chat secundario",
|
||||
"side_chat_list": "Chats secundarios ({count})",
|
||||
"side_chat_new": "Nuevo chat secundario",
|
||||
"side_chat_search": "Buscar chats secundarios…",
|
||||
"side_chat_untitled": "Chat secundario sin título",
|
||||
"side_chat_delete": "Eliminar chat secundario",
|
||||
"side_chat_open": "Chat lateral",
|
||||
"side_chat_needs_a_chat": "Abre primero un chat y luego inicia un chat lateral desde él.",
|
||||
"side_chat_open_failed": "No se pudo abrir un chat lateral: {error}",
|
||||
"side_chat_empty": "Los chats laterales se eliminan cuando los cierras o sales de la app.",
|
||||
"side_chat_empty": "Haz una pregunta de seguimiento con el contexto del chat original. Puedes volver a abrir este chat secundario más tarde.",
|
||||
"workspace_more_tools": "Más herramientas",
|
||||
"side_chat_nested": "Un chat lateral no puede abrir su propio chat lateral.",
|
||||
"side_chat_close_failed": "No se pudo eliminar el chat lateral: {error}",
|
||||
|
||||
+8
-3
@@ -539,7 +539,7 @@
|
||||
"status_cancelled": "لغوشده",
|
||||
"status_editing": "در حال ویرایش",
|
||||
"status_idle": "بیکار",
|
||||
"chat_delete_session_confirm": "«{title}» حذف شود؟\n\nرونوشت آن برای همیشه حذف خواهد شد.",
|
||||
"chat_delete_session_confirm": "«{title}» حذف شود؟\n\nمتن گفتگو و تمام گفتگوهای جانبی آن برای همیشه حذف خواهند شد.",
|
||||
"chat_delete_session_failed": "حذف «{title}» ناموفق بود: {error}",
|
||||
"chat_untitled": "بدون عنوان",
|
||||
"skills_delete_skill_label": "حذف مهارت {name}",
|
||||
@@ -1278,11 +1278,16 @@
|
||||
"tree_restore_above": "بازیابی نیاکان بایگانیشده",
|
||||
"tree_restore_down": "بازیابی نوادگان",
|
||||
"tree_all_experiments_archived": "همه آزمایشها بایگانی شدهاند. برای دیدنشان نمایش بایگانیشدهها را فعال کنید.",
|
||||
"side_command_description": "یک پرسش کوتاه را در گفتوگوی جانبی موقت بپرسید",
|
||||
"side_command_description": "یک سؤال سریع در گفتگوی جانبی بپرسید",
|
||||
"side_chat_list": "گفتگوهای جانبی ({count})",
|
||||
"side_chat_new": "گفتگوی جانبی جدید",
|
||||
"side_chat_search": "جستجوی گفتگوهای جانبی…",
|
||||
"side_chat_untitled": "گفتگوی جانبی بدون عنوان",
|
||||
"side_chat_delete": "حذف گفتگوی جانبی",
|
||||
"side_chat_open": "گفتوگوی جانبی",
|
||||
"side_chat_needs_a_chat": "ابتدا یک گفتوگو باز کنید، سپس از آن گفتوگوی جانبی شروع کنید.",
|
||||
"side_chat_open_failed": "باز کردن گفتوگوی جانبی ممکن نشد: {error}",
|
||||
"side_chat_empty": "گفتوگوهای جانبی با بستن آنها یا خروج از برنامه حذف میشوند.",
|
||||
"side_chat_empty": "با استفاده از زمینهٔ گفتگوی اصلی سؤال بعدی را بپرسید. میتوانید این گفتگوی جانبی را بعداً دوباره باز کنید.",
|
||||
"workspace_more_tools": "ابزارهای بیشتر",
|
||||
"side_chat_nested": "گفتوگوی جانبی نمیتواند گفتوگوی جانبی خودش را باز کند.",
|
||||
"side_chat_close_failed": "حذف گفتوگوی جانبی ممکن نشد: {error}",
|
||||
|
||||
+8
-3
@@ -543,7 +543,7 @@
|
||||
"status_cancelled": "रद्द किया गया",
|
||||
"status_editing": "संपादित हो रहा है",
|
||||
"status_idle": "निष्क्रिय",
|
||||
"chat_delete_session_confirm": "“{title}” हटाएं?\n\nइसका ट्रांसक्रिप्ट स्थायी रूप से हट जाएगा।",
|
||||
"chat_delete_session_confirm": "“{title}” हटाएँ?\n\nइसका वार्तालाप और सभी साइड चैट स्थायी रूप से हटा दिए जाएँगे।",
|
||||
"chat_delete_session_failed": "“{title}” हटाने में विफल: {error}",
|
||||
"chat_untitled": "बिना शीर्षक",
|
||||
"skills_delete_skill_label": "{name} स्किल हटाएं",
|
||||
@@ -1278,11 +1278,16 @@
|
||||
"tree_restore_above": "संग्रहीत पूर्वजों को पुनर्स्थापित करें",
|
||||
"tree_restore_down": "वंशजों को पुनर्स्थापित करें",
|
||||
"tree_all_experiments_archived": "सभी प्रयोग संग्रहीत हैं। उन्हें देखने के लिए संग्रहीत दिखाएँ चालू करें।",
|
||||
"side_command_description": "अस्थायी साइड चैट में एक छोटा सवाल पूछें",
|
||||
"side_command_description": "साइड चैट में एक त्वरित सवाल पूछें",
|
||||
"side_chat_list": "साइड चैट ({count})",
|
||||
"side_chat_new": "नई साइड चैट",
|
||||
"side_chat_search": "साइड चैट खोजें…",
|
||||
"side_chat_untitled": "बिना शीर्षक वाली साइड चैट",
|
||||
"side_chat_delete": "साइड चैट हटाएँ",
|
||||
"side_chat_open": "साइड चैट",
|
||||
"side_chat_needs_a_chat": "पहले कोई चैट खोलें, फिर उससे साइड चैट शुरू करें।",
|
||||
"side_chat_open_failed": "साइड चैट नहीं खुल सकी: {error}",
|
||||
"side_chat_empty": "साइड चैट उन्हें बंद करने या ऐप से बाहर निकलने पर हटा दी जाती हैं।",
|
||||
"side_chat_empty": "मूल चैट के संदर्भ का उपयोग करके अगला सवाल पूछें। आप इस साइड चैट को बाद में फिर से खोल सकते हैं।",
|
||||
"workspace_more_tools": "और टूल",
|
||||
"side_chat_nested": "साइड चैट अपनी खुद की साइड चैट नहीं खोल सकती।",
|
||||
"side_chat_close_failed": "साइड चैट हटाई नहीं जा सकी: {error}",
|
||||
|
||||
+8
-3
@@ -543,7 +543,7 @@
|
||||
"status_cancelled": "キャンセル済み",
|
||||
"status_editing": "編集中",
|
||||
"status_idle": "待機中",
|
||||
"chat_delete_session_confirm": "「{title}」を削除しますか?\n\n会話履歴は完全に削除されます。",
|
||||
"chat_delete_session_confirm": "「{title}」を削除しますか?\n\n会話履歴と関連するサイドチャットが完全に削除されます。",
|
||||
"chat_delete_session_failed": "「{title}」の削除に失敗: {error}",
|
||||
"chat_untitled": "無題",
|
||||
"skills_delete_skill_label": "スキル {name} を削除",
|
||||
@@ -1278,11 +1278,16 @@
|
||||
"tree_restore_above": "アーカイブ済みの祖先の実験を復元",
|
||||
"tree_restore_down": "子孫の実験を復元",
|
||||
"tree_all_experiments_archived": "すべての実験がアーカイブされています。「アーカイブ済みを表示」を有効にして確認してください。",
|
||||
"side_command_description": "一時的なサイドチャットで簡単な質問をする",
|
||||
"side_command_description": "サイドチャットで簡単な質問をする",
|
||||
"side_chat_list": "サイドチャット ({count})",
|
||||
"side_chat_new": "新しいサイドチャット",
|
||||
"side_chat_search": "サイドチャットを検索…",
|
||||
"side_chat_untitled": "無題のサイドチャット",
|
||||
"side_chat_delete": "サイドチャットを削除",
|
||||
"side_chat_open": "サイドチャット",
|
||||
"side_chat_needs_a_chat": "先にチャットを開き、そこからサイドチャットを開始してください。",
|
||||
"side_chat_open_failed": "サイドチャットを開けませんでした: {error}",
|
||||
"side_chat_empty": "サイドチャットは、閉じるかアプリを終了すると削除されます。",
|
||||
"side_chat_empty": "元のチャットのコンテキストを使って追加の質問をしましょう。このサイドチャットは後で再び開けます。",
|
||||
"workspace_more_tools": "その他のツール",
|
||||
"side_chat_nested": "サイドチャットから別のサイドチャットを開くことはできません。",
|
||||
"side_chat_close_failed": "サイドチャットを削除できませんでした: {error}",
|
||||
|
||||
+8
-3
@@ -543,7 +543,7 @@
|
||||
"status_cancelled": "취소됨",
|
||||
"status_editing": "편집 중",
|
||||
"status_idle": "대기 중",
|
||||
"chat_delete_session_confirm": "“{title}”을(를) 삭제할까요?\n\n대화 기록이 영구적으로 삭제됩니다.",
|
||||
"chat_delete_session_confirm": "“{title}”을(를) 삭제할까요?\n\n대화 기록과 관련 사이드 채팅이 영구적으로 삭제됩니다.",
|
||||
"chat_delete_session_failed": "“{title}” 삭제 실패: {error}",
|
||||
"chat_untitled": "제목 없음",
|
||||
"skills_delete_skill_label": "{name} 스킬 삭제",
|
||||
@@ -1278,11 +1278,16 @@
|
||||
"tree_restore_above": "보관된 상위 실험 복원",
|
||||
"tree_restore_down": "하위 실험 복원",
|
||||
"tree_all_experiments_archived": "모든 실험이 보관되었습니다. 보려면 보관된 실험 표시를 켜세요.",
|
||||
"side_command_description": "임시 보조 채팅에서 간단한 질문하기",
|
||||
"side_command_description": "사이드 채팅에서 간단한 질문하기",
|
||||
"side_chat_list": "사이드 채팅 ({count})",
|
||||
"side_chat_new": "새 사이드 채팅",
|
||||
"side_chat_search": "사이드 채팅 검색…",
|
||||
"side_chat_untitled": "제목 없는 사이드 채팅",
|
||||
"side_chat_delete": "사이드 채팅 삭제",
|
||||
"side_chat_open": "보조 채팅",
|
||||
"side_chat_needs_a_chat": "먼저 채팅을 연 뒤 보조 채팅을 시작하세요.",
|
||||
"side_chat_open_failed": "보조 채팅을 열 수 없습니다: {error}",
|
||||
"side_chat_empty": "보조 채팅은 닫거나 앱을 종료하면 삭제됩니다.",
|
||||
"side_chat_empty": "원래 채팅의 맥락을 바탕으로 후속 질문을 하세요. 이 사이드 채팅은 나중에 다시 열 수 있습니다.",
|
||||
"workspace_more_tools": "추가 도구",
|
||||
"side_chat_nested": "보조 채팅에서 다른 보조 채팅을 열 수 없습니다.",
|
||||
"side_chat_close_failed": "보조 채팅을 삭제할 수 없습니다: {error}",
|
||||
|
||||
+8
-3
@@ -543,7 +543,7 @@
|
||||
"status_cancelled": "Anulowano",
|
||||
"status_editing": "Edytowanie",
|
||||
"status_idle": "Bezczynny",
|
||||
"chat_delete_session_confirm": "Usunąć „{title}”?\n\nHistoria tej sesji zostanie trwale usunięta.",
|
||||
"chat_delete_session_confirm": "Usunąć „{title}”?\n\nHistoria rozmowy i wszystkie czaty poboczne zostaną trwale usunięte.",
|
||||
"chat_delete_session_failed": "Nie udało się usunąć „{title}”: {error}",
|
||||
"chat_untitled": "Bez tytułu",
|
||||
"skills_delete_skill_label": "Usuń umiejętność {name}",
|
||||
@@ -1278,11 +1278,16 @@
|
||||
"tree_restore_above": "Przywróć zarchiwizowane eksperymenty nadrzędne",
|
||||
"tree_restore_down": "Przywróć eksperymenty podrzędne",
|
||||
"tree_all_experiments_archived": "Wszystkie eksperymenty są zarchiwizowane. Włącz „Pokaż zarchiwizowane”, aby je wyświetlić.",
|
||||
"side_command_description": "Zadaj szybkie pytanie w tymczasowym czacie bocznym",
|
||||
"side_command_description": "Zadaj szybkie pytanie na czacie pobocznym",
|
||||
"side_chat_list": "Czaty poboczne ({count})",
|
||||
"side_chat_new": "Nowy czat poboczny",
|
||||
"side_chat_search": "Szukaj czatów pobocznych…",
|
||||
"side_chat_untitled": "Czat poboczny bez tytułu",
|
||||
"side_chat_delete": "Usuń czat poboczny",
|
||||
"side_chat_open": "Czat boczny",
|
||||
"side_chat_needs_a_chat": "Najpierw otwórz czat, a następnie rozpocznij z niego czat boczny.",
|
||||
"side_chat_open_failed": "Nie udało się otworzyć czatu bocznego: {error}",
|
||||
"side_chat_empty": "Czaty boczne są usuwane po ich zamknięciu lub wyjściu z aplikacji.",
|
||||
"side_chat_empty": "Zadaj kolejne pytanie, korzystając z kontekstu pierwotnego czatu. Ten czat poboczny możesz otworzyć ponownie później.",
|
||||
"workspace_more_tools": "Więcej narzędzi",
|
||||
"side_chat_nested": "Nie można otworzyć kolejnego czatu bocznego z czatu bocznego.",
|
||||
"side_chat_close_failed": "Nie udało się usunąć czatu bocznego: {error}",
|
||||
|
||||
@@ -539,7 +539,7 @@
|
||||
"status_cancelled": "已取消",
|
||||
"status_editing": "正在编辑",
|
||||
"status_idle": "空闲",
|
||||
"chat_delete_session_confirm": "删除“{title}”?\n\n其对话记录将被永久移除。",
|
||||
"chat_delete_session_confirm": "删除“{title}”?\n\n其聊天记录及所有侧边聊天都将被永久删除。",
|
||||
"chat_delete_session_failed": "删除“{title}”失败:{error}",
|
||||
"chat_untitled": "未命名",
|
||||
"skills_delete_skill_label": "删除技能 {name}",
|
||||
@@ -1278,11 +1278,16 @@
|
||||
"tree_restore_above": "恢复已归档的祖先节点",
|
||||
"tree_restore_down": "恢复后代节点",
|
||||
"tree_all_experiments_archived": "所有实验均已归档。开启“显示已归档”即可查看。",
|
||||
"side_command_description": "在临时侧边对话中快速提问",
|
||||
"side_command_description": "在侧边聊天中快速提问",
|
||||
"side_chat_list": "侧边聊天 ({count})",
|
||||
"side_chat_new": "新建侧边聊天",
|
||||
"side_chat_search": "搜索侧边聊天…",
|
||||
"side_chat_untitled": "未命名的侧边聊天",
|
||||
"side_chat_delete": "删除侧边聊天",
|
||||
"side_chat_open": "侧边对话",
|
||||
"side_chat_needs_a_chat": "请先打开一个对话,再从中开启侧边对话。",
|
||||
"side_chat_open_failed": "无法打开侧边对话:{error}",
|
||||
"side_chat_empty": "关闭侧边对话或退出应用后,侧边对话会被删除。",
|
||||
"side_chat_empty": "使用原聊天的上下文提出后续问题。你可以稍后重新打开此侧边聊天。",
|
||||
"workspace_more_tools": "更多工具",
|
||||
"side_chat_nested": "侧边对话不能再开启自己的侧边对话。",
|
||||
"side_chat_close_failed": "无法删除侧边对话:{error}",
|
||||
|
||||
+46
-30
@@ -1,5 +1,8 @@
|
||||
import {
|
||||
setScopedQueryData,
|
||||
deletedSessionIds,
|
||||
isCurrentScope,
|
||||
workspaceScope,
|
||||
queryClient,
|
||||
} from "./queries/client";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
@@ -17,7 +20,7 @@ import {
|
||||
|
||||
import { listChatSessionsQuery, getChatMessagesQuery } from "./queries/chat";
|
||||
import { listProjectsQuery, getUiStateQuery, listRunsQuery, listExperimentsQuery } from "./queries/projects";
|
||||
import { deleteChatSession, openSideChat, sendChatMessage, setExperimentArchived } from "./api";
|
||||
import { openSideChat, sendChatMessage, setExperimentArchived } from "./api";
|
||||
import { getArtifactsQuery } from "./queries/files";
|
||||
import { readHarnesses } from "./queries/settings";
|
||||
import { useBlocker, useRouter, useRouterState } from "@tanstack/react-router";
|
||||
@@ -1279,7 +1282,7 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
|
||||
[forgetRightTab, rightTab, subagentTabs],
|
||||
);
|
||||
|
||||
const openSideTab = useCallback((sessionId: string) => {
|
||||
const openSideTab = useCallback((sessionId: string | null) => {
|
||||
const tab: SideChatViewDef = { kind: "side", sessionId };
|
||||
setSideTabs((prev) => prev.some((t) => t.sessionId === sessionId) ? prev : [...prev, tab]);
|
||||
setPanelWidth((width) => Math.max(width, sideChatPanelWidth()));
|
||||
@@ -1295,14 +1298,19 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
|
||||
[forgetRightTab, rightTab, sideTabs],
|
||||
);
|
||||
|
||||
// Side chats are deleted when orx restarts; drop saved tabs whose chat is gone.
|
||||
// Drop saved tabs only when their persisted session has been deleted.
|
||||
useEffect(() => {
|
||||
const live = sessionsQuery.data;
|
||||
if (!live) return;
|
||||
for (const tab of sideTabs) if (!live.some((session) => session.id === tab.sessionId)) forgetSideTab(tab);
|
||||
for (const tab of sideTabs) if (tab.sessionId !== null && !live.some((session) => session.id === tab.sessionId)) forgetSideTab(tab);
|
||||
}, [sessionsQuery.data, sideTabs, forgetSideTab]);
|
||||
|
||||
const startSideChat = useCallback(async (parentSessionId: string, question: string) => {
|
||||
if (!question.trim()) {
|
||||
openSideTab(null);
|
||||
return;
|
||||
}
|
||||
const scope = workspaceScope();
|
||||
let session;
|
||||
try {
|
||||
session = await openSideChat(parentSessionId);
|
||||
@@ -1310,16 +1318,10 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
|
||||
showAlert(m.side_chat_open_failed({ error: errorText(error) }), "error");
|
||||
return;
|
||||
}
|
||||
// The user moved to another task while it opened; its tab belongs nowhere now.
|
||||
if (navigationRef.current.activeSessionId !== parentSessionId) {
|
||||
void deleteChatSession(session.id).catch((error) => {
|
||||
showAlert(m.side_chat_close_failed({ error: errorText(error) }), "error");
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!isCurrentScope(scope) || deletedSessionIds.has(session.id) || deletedSessionIds.has(parentSessionId)) return;
|
||||
setScopedQueryData(listChatSessionsQuery(session.projectId).queryKey, (current) =>
|
||||
current && [session, ...current.filter((row) => row.id !== session.id)]);
|
||||
openSideTab(session.id);
|
||||
if (navigationRef.current.activeSessionId === parentSessionId) openSideTab(session.id);
|
||||
if (!question) return;
|
||||
try {
|
||||
// Seed the transcript first so the live stream has a cache to land the question in.
|
||||
@@ -1330,16 +1332,7 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
|
||||
}
|
||||
}, [openSideTab]);
|
||||
|
||||
// Side chats are temporary: closing the tab is what deletes one.
|
||||
const closeSideTab = useCallback(
|
||||
(tab: SideChatViewDef) => {
|
||||
forgetSideTab(tab);
|
||||
void deleteChatSession(tab.sessionId).catch((error) => {
|
||||
showAlert(m.side_chat_close_failed({ error: errorText(error) }), "error");
|
||||
});
|
||||
},
|
||||
[forgetSideTab],
|
||||
);
|
||||
const closeSideTab = forgetSideTab;
|
||||
|
||||
// Live title + running state for open sub-agent tabs, straight off the spawn
|
||||
// parts' message stream — so a tab is named for its task and shimmers while
|
||||
@@ -1586,6 +1579,16 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
|
||||
? rightTab
|
||||
: null;
|
||||
const sideSession = sideTab ? sessionsQuery.data?.find((session) => session.id === sideTab.sessionId) : undefined;
|
||||
const sideParentSession = sessionsQuery.data?.find((session) => session.id === activeSessionId);
|
||||
const sideDraftAgent = useMemo(() => sideParentSession ? {
|
||||
harness: sideParentSession.harness,
|
||||
model: sideParentSession.model,
|
||||
serviceTier: sideParentSession.serviceTier,
|
||||
permissionMode: sideParentSession.permissionMode === "plan" ? null : sideParentSession.permissionMode,
|
||||
reasoningLevel: sideParentSession.reasoningLevel,
|
||||
} : null, [sideParentSession?.harness, sideParentSession?.model,
|
||||
sideParentSession?.serviceTier, sideParentSession?.permissionMode,
|
||||
sideParentSession?.reasoningLevel]);
|
||||
const requestedCodeTab =
|
||||
typeof rightTab === "object" && "code" in rightTab ? rightTab : null;
|
||||
const codeTab = requestedCodeTab
|
||||
@@ -1663,7 +1666,7 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
|
||||
<ClosableTab
|
||||
key={rightTabKey(tab)}
|
||||
active={sideTab !== null && sideTab.sessionId === tab.sessionId}
|
||||
label={session?.title?.trim() || m.side_chat_open()}
|
||||
label={tab.sessionId === null ? m.side_chat_new() : session?.title?.trim() || m.side_chat_open()}
|
||||
shimmer={session?.busy ?? false}
|
||||
icon={<MessagesSquare size={12} className="shrink-0" />}
|
||||
preview={isPreviewTab(tab)}
|
||||
@@ -1779,9 +1782,6 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
|
||||
onRunSetup={runtime.kind === "ssh" ? undefined : runSetup}
|
||||
composerFocusNonce={composerFocusNonce}
|
||||
runtime={runtime}
|
||||
onOpenDemoWelcome={
|
||||
activeProject && isDemoProjectId(activeProject.id) ? openDemoWelcome : undefined
|
||||
}
|
||||
activeSessionId={activeSessionId}
|
||||
onActiveSessionChange={onActiveSessionChange}
|
||||
preferredAgent={uiState.preferredAgent}
|
||||
@@ -1822,6 +1822,8 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
|
||||
onArtifacts={openArtifactsTab}
|
||||
onExperiments={() => openExperimentsTab()}
|
||||
onSideChat={activeSessionId ? () => void startSideChat(activeSessionId, "") : undefined}
|
||||
sessions={sessionsQuery.data ?? []}
|
||||
onSelectSideChat={openSideTab}
|
||||
/>
|
||||
)}
|
||||
{mainView === "chat" && panelOpen && (
|
||||
@@ -2218,10 +2220,9 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
|
||||
</TabBody>
|
||||
) : sideTab && projectId ? (
|
||||
<TabBody>
|
||||
{sideSession ? (
|
||||
{sideSession || sideTab.sessionId === null ? (
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<ChatPanel
|
||||
key={sideTab.sessionId}
|
||||
embedded
|
||||
projectId={projectId}
|
||||
projectName={activeProject?.name ?? ""}
|
||||
@@ -2246,17 +2247,32 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
|
||||
}
|
||||
runtime={runtime}
|
||||
activeSessionId={sideTab.sessionId}
|
||||
sideParentSessionId={sideTab.sessionId === null ? activeSessionId ?? undefined : undefined}
|
||||
onOpenSideChat={(parentSessionId, question) => void startSideChat(parentSessionId, question)}
|
||||
onActiveSessionChange={(sessionId, options) => {
|
||||
if (sideTab.sessionId === null && sessionId !== null) {
|
||||
const session = queryClient.getQueryData(listChatSessionsQuery(projectId).queryKey)
|
||||
?.find((row) => row.id === sessionId);
|
||||
if (session?.sideParentSessionId !== activeSessionId) {
|
||||
onActiveSessionChange(sessionId, options);
|
||||
return;
|
||||
}
|
||||
const navigation = navigationRef.current;
|
||||
if (navigation.activeSessionId !== activeSessionId || navigation.pane?.kind !== "side" || navigation.pane.sessionId !== null) return;
|
||||
setSideTabs((current) => current.filter((tab) => tab.sessionId !== null));
|
||||
forgetRightTab(sideTab, false);
|
||||
openSideTab(sessionId);
|
||||
return;
|
||||
}
|
||||
// Null after a delete drops the tab; anything else (`/resume`) is the main chat's.
|
||||
const deleted = !queryClient.getQueryData(listChatSessionsQuery(projectId).queryKey)
|
||||
?.some((session) => session.id === sideTab.sessionId);
|
||||
if (sessionId === null && deleted) forgetSideTab(sideTab);
|
||||
else onActiveSessionChange(sessionId, options);
|
||||
}}
|
||||
preferredAgent={uiState.preferredAgent}
|
||||
preferredAgent={sideTab.sessionId === null ? sideDraftAgent : uiState.preferredAgent}
|
||||
onPreferredAgentChange={persistPreferredAgent}
|
||||
preferredAutonomy={uiState.preferredAutonomy ?? DEFAULT_AUTONOMY}
|
||||
preferredAutonomy={sideTab.sessionId === null ? sideParentSession?.autonomy ?? DEFAULT_AUTONOMY : uiState.preferredAutonomy ?? DEFAULT_AUTONOMY}
|
||||
onPreferredAutonomyChange={persistPreferredAutonomy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -1991,7 +1991,7 @@ export interface ChatSession {
|
||||
/** Hidden from the default Recents list, but fully intact and resumable. */
|
||||
archived: boolean;
|
||||
/** Chat this side chat branched from. Side chats share its worktree and
|
||||
* stay out of history; closing the tab deletes one. */
|
||||
* stay out of top-level history; deleting the parent deletes its side chats. */
|
||||
sideParentSessionId?: string | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
@@ -2084,7 +2084,7 @@ export const setChatSessionPlanMode = (sessionId: string, planMode: boolean) =>
|
||||
(r) => r.session,
|
||||
);
|
||||
|
||||
/** Branch a temporary side chat off a snapshot of `sessionId`'s transcript. */
|
||||
/** Branch a persistent side chat off a snapshot of `sessionId`'s transcript. */
|
||||
export const openSideChat = (sessionId: string) =>
|
||||
post<{ session: ChatSession }>(`/api/chat/sessions/${sessionId}/side`, {}).then(
|
||||
(r) => r.session,
|
||||
|
||||
+2
-2
@@ -365,9 +365,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Reserve room for the four workspace buttons and their gaps. */
|
||||
/* Reserve room for the workspace toolbar. */
|
||||
.workspace-body > .chat-pane .chat-header {
|
||||
padding-inline-end: 12rem;
|
||||
padding-inline-end: 11rem;
|
||||
}
|
||||
|
||||
.workspace-card-visible > .chat-pane {
|
||||
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
SquareTerminal,
|
||||
ToggleRight,
|
||||
TriangleAlert,
|
||||
Trash2,
|
||||
Users,
|
||||
WifiOff,
|
||||
X,
|
||||
@@ -89,6 +90,7 @@ import {
|
||||
cancelQueuedMessage,
|
||||
chatAttachmentUrl,
|
||||
createChatSession,
|
||||
openSideChat,
|
||||
getChatSession,
|
||||
deleteChatSession,
|
||||
DEMO_FIGURE_SESSION_ID,
|
||||
@@ -4264,7 +4266,6 @@ export function ChatPanel({
|
||||
onOpenPlan,
|
||||
onOpenSubagent,
|
||||
runtime,
|
||||
onOpenDemoWelcome,
|
||||
composerFocusNonce = 0,
|
||||
activeSessionId,
|
||||
onActiveSessionChange,
|
||||
@@ -4273,6 +4274,7 @@ export function ChatPanel({
|
||||
preferredAutonomy,
|
||||
onPreferredAutonomyChange,
|
||||
embedded = false,
|
||||
sideParentSessionId,
|
||||
onOpenSideChat,
|
||||
onRunSetup,
|
||||
children,
|
||||
@@ -4330,8 +4332,6 @@ export function ChatPanel({
|
||||
intent: TabOpenIntent,
|
||||
) => void;
|
||||
runtime: RuntimeInfo;
|
||||
/** Reopen the demo welcome modal from the chat header. */
|
||||
onOpenDemoWelcome?: () => void;
|
||||
/** Increments when the demo welcome hands focus to the composer. */
|
||||
composerFocusNonce?: number;
|
||||
/** Demo run currently executing, for the monitor-it hint above the composer. */
|
||||
@@ -4345,6 +4345,7 @@ export function ChatPanel({
|
||||
/** A second instance hosting a side chat in the right pane: no rail, and
|
||||
* global shortcuts act on it only while focus is inside it. */
|
||||
embedded?: boolean;
|
||||
sideParentSessionId?: string;
|
||||
/** Branch a side chat off `parentSessionId`; a question, if given, is its first message. */
|
||||
onOpenSideChat?: (parentSessionId: string, question: string) => void;
|
||||
/** Middle-pane content when a settings section is active. */
|
||||
@@ -4355,6 +4356,7 @@ export function ChatPanel({
|
||||
const setChatSessionAutonomyMutation = useMutation({ mutationFn: (args: Parameters<typeof setChatSessionAutonomy>) => setChatSessionAutonomy(...args) });
|
||||
const setChatSessionGoalMutation = useMutation({ mutationFn: (args: Parameters<typeof setChatSessionGoal>) => setChatSessionGoal(...args) });
|
||||
const importNativeChatMutation = useMutation({ mutationFn: (args: Parameters<typeof importNativeChat>) => importNativeChat(...args) });
|
||||
const openSideChatMutation = useMutation({ mutationFn: openSideChat });
|
||||
const createChatSessionMutation = useMutation({ mutationFn: (args: Parameters<typeof createChatSession>) => createChatSession(...args) });
|
||||
const setChatSessionArchivedMutation = useMutation({ mutationFn: (args: Parameters<typeof setChatSessionArchived>) => setChatSessionArchived(...args) });
|
||||
const renameChatSessionMutation = useMutation({ mutationFn: (args: Parameters<typeof renameChatSession>) => renameChatSession(...args) });
|
||||
@@ -4506,6 +4508,8 @@ export function ChatPanel({
|
||||
// active session changes. Distinct from `selection`, which is the sticky
|
||||
// global preference that seeds *new* sessions.
|
||||
const [sessionOverride, setSessionOverride] = useState<Partial<ModelSelection>>({});
|
||||
const [sideDraftAutonomy, setSideDraftAutonomy] = useState<Autonomy | null>(null);
|
||||
useEffect(() => setSideDraftAutonomy(null), [sideParentSessionId]);
|
||||
const [recoveryOverrides, setRecoveryOverrides] = useState<
|
||||
Partial<ModelSelection> & { planMode?: boolean }
|
||||
>(EMPTY_RECOVERY_OVERRIDES);
|
||||
@@ -4885,7 +4889,8 @@ export function ChatPanel({
|
||||
const setReasoningLevel = (id: string) => selectModel({ reasoningLevel: id });
|
||||
const setAutonomy = (autonomy: Autonomy) => {
|
||||
if (!openSession) {
|
||||
onPreferredAutonomyChange(autonomy);
|
||||
if (sideParentSessionId) setSideDraftAutonomy(autonomy);
|
||||
else onPreferredAutonomyChange(autonomy);
|
||||
return;
|
||||
}
|
||||
const sessionId = openSession.id;
|
||||
@@ -4985,7 +4990,8 @@ export function ChatPanel({
|
||||
case "new":
|
||||
// In a side chat, a new chat is another side chat off the same parent.
|
||||
if (embedded) {
|
||||
if (openSession?.sideParentSessionId) onOpenSideChat?.(openSession.sideParentSessionId, "");
|
||||
const parent = openSession?.sideParentSessionId ?? sideParentSessionId;
|
||||
if (parent) onOpenSideChat?.(parent, "");
|
||||
} else startNewTask();
|
||||
return false;
|
||||
case "resume":
|
||||
@@ -5838,19 +5844,24 @@ export function ChatPanel({
|
||||
async function openNewSession(selection: ModelSelection, planMode: boolean | undefined) {
|
||||
const scope = composerScopeRef.current;
|
||||
const visit = projectVisitRef.current;
|
||||
const session = await createChatSessionMutation.mutateAsync([projectId, selection.harness, {
|
||||
model: selection.model,
|
||||
serviceTier: selection.serviceTier,
|
||||
permissionMode: selection.permissionMode,
|
||||
planMode,
|
||||
reasoningLevel: selection.reasoningLevel,
|
||||
autonomy: preferredAutonomy,
|
||||
}]);
|
||||
if (projectVisitRef.current === visit) {
|
||||
let session = sideParentSessionId
|
||||
? await openSideChatMutation.mutateAsync(sideParentSessionId)
|
||||
: await createChatSessionMutation.mutateAsync([projectId, selection.harness, {
|
||||
model: selection.model,
|
||||
serviceTier: selection.serviceTier,
|
||||
permissionMode: selection.permissionMode,
|
||||
planMode,
|
||||
reasoningLevel: selection.reasoningLevel,
|
||||
autonomy: preferredAutonomy,
|
||||
}]);
|
||||
if (sideParentSessionId && sideDraftAutonomy !== null && session.autonomy !== sideDraftAutonomy) {
|
||||
session = await setChatSessionAutonomyMutation.mutateAsync([session.id, sideDraftAutonomy]);
|
||||
}
|
||||
if (isCurrentScope(sessionsOptions.queryKey) && projectVisitRef.current === visit) {
|
||||
setSessions((cur) => [session, ...cur.filter((row) => row.id !== session.id)]);
|
||||
if (!queryClient.getQueryData(sessionsOptions.queryKey)) void syncSessionList(true);
|
||||
}
|
||||
if (projectVisitRef.current === visit && composerScopeRef.current === scope) {
|
||||
if (isCurrentScope(sessionsOptions.queryKey) && projectVisitRef.current === visit && composerScopeRef.current === scope) {
|
||||
onActiveSessionChangeRef.current(session.id, { replace: true });
|
||||
composerScopeRef.current = { ...scope, activeId: session.id };
|
||||
}
|
||||
@@ -6522,13 +6533,9 @@ export function ChatPanel({
|
||||
</span>
|
||||
</>}
|
||||
</div>
|
||||
{onOpenDemoWelcome && (
|
||||
<IconButton
|
||||
data-tip={m.chat_panel_about_this_demo()}
|
||||
aria-label={m.chat_panel_about_this_demo()}
|
||||
onClick={onOpenDemoWelcome}
|
||||
>
|
||||
<HelpCircle size={15} />
|
||||
{embedded && activeSession && (
|
||||
<IconButton aria-label={m.side_chat_delete()} data-tip={m.side_chat_delete()} onClick={() => void removeSession(activeSession)}>
|
||||
<Trash2 size={15} />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
@@ -6561,7 +6568,7 @@ export function ChatPanel({
|
||||
<div className="ms-auto h-16 w-1/2 shrink-0 rounded-lg bg-panel" />
|
||||
</div>
|
||||
</div>
|
||||
) : !threadMounted && activeSession?.sideParentSessionId ? (
|
||||
) : !threadMounted && (activeSession?.sideParentSessionId || sideParentSessionId) ? (
|
||||
<div className="chat-empty flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center text-subtext">
|
||||
<MessagesSquare size={22} />
|
||||
<p className="m-0 text-balance">{m.side_chat_empty()}</p>
|
||||
@@ -7211,9 +7218,9 @@ export function ChatPanel({
|
||||
reasoningChoices={activeHarness?.agentReady ? reasoning.choices : []}
|
||||
defaultReasoningId={reasoning.defaultId}
|
||||
onSelectReasoning={setReasoningLevel}
|
||||
autonomy={openSession?.autonomy ?? preferredAutonomy}
|
||||
autonomy={openSession?.autonomy ?? sideDraftAutonomy ?? preferredAutonomy}
|
||||
onSelectAutonomy={setAutonomy}
|
||||
lockHarness={!!openSession}
|
||||
lockHarness={!!openSession || !!sideParentSessionId}
|
||||
openRequest={modelPickerRequest}
|
||||
nativeOptions={openSession?.harness.startsWith("acp:") ? acpOptions.filter((option) => option.category !== "model") : []}
|
||||
onSelectNativeOption={(optionId, value) => {
|
||||
|
||||
@@ -565,6 +565,8 @@ export function ModelPicker({
|
||||
* with a "· Default" note, then the full numbered list. */
|
||||
export function OptionPicker({
|
||||
triggerIcon,
|
||||
onTriggerClick,
|
||||
menuAction,
|
||||
choices,
|
||||
value,
|
||||
defaultId,
|
||||
@@ -579,10 +581,13 @@ export function OptionPicker({
|
||||
renderIcon,
|
||||
renderLabel,
|
||||
floating = false,
|
||||
openOnHover = false,
|
||||
onSelect,
|
||||
className,
|
||||
}: {
|
||||
triggerIcon?: ReactNode;
|
||||
onTriggerClick?: () => void;
|
||||
menuAction?: { label: string; onSelect: () => void };
|
||||
choices: OptionChoice[];
|
||||
value: string | null;
|
||||
/** The harness's default id — pinned at top of the menu and used when
|
||||
@@ -602,6 +607,7 @@ export function OptionPicker({
|
||||
renderIcon?: (choice: OptionChoice) => ReactNode;
|
||||
renderLabel?: (choice: OptionChoice) => ReactNode;
|
||||
floating?: boolean;
|
||||
openOnHover?: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
@@ -609,6 +615,10 @@ export function OptionPicker({
|
||||
const { open, setOpen, ref } = usePopover(buttonRef);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [filter, setFilter] = useState("");
|
||||
const openedByHover = useRef(false);
|
||||
const touchTrigger = useRef(false);
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
useEffect(() => () => clearTimeout(closeTimer.current), []);
|
||||
useLayoutEffect(() => {
|
||||
const menu = menuRef.current;
|
||||
const anchor = ref.current;
|
||||
@@ -637,7 +647,7 @@ export function OptionPicker({
|
||||
window.removeEventListener("scroll", position, true);
|
||||
};
|
||||
}, [open, floating, dropDown, filter, choices.length, ref]);
|
||||
if (choices.length === 0) return null;
|
||||
if (choices.length === 0 && !menuAction) return null;
|
||||
|
||||
const effectiveId = value ?? defaultId ?? choices[0]?.id ?? null;
|
||||
const current = choices.find((c) => c.id === effectiveId);
|
||||
@@ -655,6 +665,17 @@ export function OptionPicker({
|
||||
const visible = rest.filter((c) => `${c.label} ${c.id}`.toLowerCase().includes(filter.toLowerCase()));
|
||||
const label = current?.label ?? choices[0]?.label ?? "";
|
||||
|
||||
const activateTrigger = () => {
|
||||
openedByHover.current = false;
|
||||
setFilter("");
|
||||
if (onTriggerClick && !touchTrigger.current) {
|
||||
setOpen(false);
|
||||
onTriggerClick();
|
||||
} else {
|
||||
setOpen((value) => !value);
|
||||
}
|
||||
};
|
||||
|
||||
const choose = (id: string) => {
|
||||
onSelect(id);
|
||||
setOpen(false);
|
||||
@@ -662,16 +683,44 @@ export function OptionPicker({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`option-picker relative inline-flex${variant === "field" ? " w-full" : ""}`} ref={ref}>
|
||||
<div className={`option-picker relative inline-flex${variant === "field" ? " w-full" : ""}`} ref={ref}
|
||||
onPointerEnter={(event) => {
|
||||
if (!openOnHover || disabled || event.pointerType !== "mouse") return;
|
||||
clearTimeout(closeTimer.current);
|
||||
if (!open) {
|
||||
openedByHover.current = true;
|
||||
setFilter("");
|
||||
setOpen(true);
|
||||
}
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
if (!openOnHover) return;
|
||||
closeTimer.current = setTimeout(() => {
|
||||
if (!menuRef.current?.contains(document.activeElement)) setOpen(false);
|
||||
}, 150);
|
||||
}}
|
||||
>
|
||||
{triggerIcon ? (
|
||||
<IconButton
|
||||
ref={buttonRef}
|
||||
size="small"
|
||||
active={open}
|
||||
className={className}
|
||||
aria-label={title ?? header ?? label}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
onClick={() => { setFilter(""); setOpen((v) => !v); }}
|
||||
onPointerDown={(event) => { touchTrigger.current = event.pointerType === "touch"; }}
|
||||
onClick={activateTrigger}
|
||||
onKeyDown={(event) => {
|
||||
touchTrigger.current = false;
|
||||
if (!onTriggerClick || event.key !== "ArrowDown") return;
|
||||
event.preventDefault();
|
||||
openedByHover.current = false;
|
||||
setFilter("");
|
||||
setOpen(true);
|
||||
menuRef.current?.querySelector("input")?.focus();
|
||||
}}
|
||||
>
|
||||
{triggerIcon}
|
||||
</IconButton>
|
||||
@@ -689,7 +738,7 @@ export function OptionPicker({
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
onClick={() => { setFilter(""); setOpen((v) => !v); }}
|
||||
onClick={activateTrigger}
|
||||
>
|
||||
<span className="inline-flex min-w-0 items-center gap-2">
|
||||
{current && renderIcon?.(current)}
|
||||
@@ -703,7 +752,12 @@ export function OptionPicker({
|
||||
style={floating ? { position: "fixed", inset: "auto", margin: 0 } : undefined}
|
||||
className={`option-menu absolute bottom-[calc(100%_+_8px)] start-0 max-h-95 flex flex-col bg-background border border-border rounded-lg shadow-menu z-50 overflow-hidden min-w-47.5 p-1.5 [&.align-right]:start-auto [&.align-right]:end-0 [&.drop-down]:bottom-auto [&.drop-down]:top-[calc(100%_+_4px)] [&.session-menu]:start-auto [&.session-menu]:end-1.5 [&.session-menu]:top-[calc(100%_-_2px)] [&.session-menu]:min-w-35 ${choices.some((choice) => choice.description) ? "min-w-80" : ""} ${variant === "field" ? "min-w-full text-text" : ""} ${align === "right" ? "align-right" : ""} ${dropDown ? "drop-down" : ""}`}>
|
||||
{header && <div className={MODEL_GROUP_CLASS_NAME}>{header}</div>}
|
||||
{searchPlaceholder && <input autoFocus aria-label={searchPlaceholder} placeholder={searchPlaceholder} value={filter} onChange={(e) => setFilter(e.target.value)} className="shrink-0 border-b border-border bg-background px-2 py-2 text-sm outline-none" />}
|
||||
{menuAction && <MenuItem type="button" onClick={() => {
|
||||
setOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
menuAction.onSelect();
|
||||
}}><span className="flex items-center gap-2"><Plus size={14} />{menuAction.label}</span></MenuItem>}
|
||||
{searchPlaceholder && <input autoFocus={!openedByHover.current} aria-label={searchPlaceholder} placeholder={searchPlaceholder} value={filter} onChange={(e) => setFilter(e.target.value)} className="mb-2 shrink-0 border-b border-border bg-background px-2 py-2 text-sm outline-none" />}
|
||||
<div className="min-h-0 overflow-y-auto">
|
||||
{pinned && (
|
||||
<>
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { type Experiment, type Run, runDisplayStatus, fmtNumber } from "../api";
|
||||
import { type ChatSession, type Experiment, type Run, runDisplayStatus, fmtNumber, timeAgo } from "../api";
|
||||
import { activeWorkspaceRuns } from "../workspaceRuns";
|
||||
import { statusLabel } from "./StatusBadge";
|
||||
import { getComputeSettingsQuery } from "../queries/settings";
|
||||
import { TARGET_LABELS } from "../computeTargets";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { FlaskConical, FolderOpen, Package, GitBranch, Cpu, MessagesSquare, MoreHorizontal, Terminal } from "lucide-react";
|
||||
import { FlaskConical, FolderOpen, Package, GitBranch, Cpu, MessagesSquare, Terminal } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getSessionWorktreeQuery } from "../queries/files";
|
||||
import { countChanges, parseDiffFiles } from "./GitDiff";
|
||||
import { m } from "../paraglide/messages.js";
|
||||
import { BackendLogo } from "./BackendLogos";
|
||||
import { IconButton, MenuItem, StatusIndicator } from "./ui";
|
||||
import { usePopover } from "./ModelPicker";
|
||||
import { IconButton, MenuItem, StatusIndicator, Spinner } from "./ui";
|
||||
import { OptionPicker } from "./ModelPicker";
|
||||
|
||||
export function WorkspaceTools({ expanded, experiments, runs, onOpenExperiment, rightOffset, activeView, projectId, onCompute, sessionId, busy, onChanges, onFiles, onTerminal, onArtifacts, onExperiments, onSideChat }: {
|
||||
export function WorkspaceTools({ expanded, experiments, runs, onOpenExperiment, rightOffset, activeView, projectId, onCompute, sessionId, busy, onChanges, onFiles, onTerminal, onArtifacts, onExperiments, onSideChat, sessions, onSelectSideChat }: {
|
||||
expanded: boolean;
|
||||
experiments: Experiment[];
|
||||
runs: Run[];
|
||||
@@ -31,10 +31,10 @@ export function WorkspaceTools({ expanded, experiments, runs, onOpenExperiment,
|
||||
onExperiments: () => void;
|
||||
/** Absent until there is a chat to branch from. */
|
||||
onSideChat?: () => void;
|
||||
sessions: ChatSession[];
|
||||
onSelectSideChat: (sessionId: string) => void;
|
||||
}) {
|
||||
const experimentRows = activeWorkspaceRuns(experiments, runs, sessionId);
|
||||
const moreTrigger = useRef<HTMLButtonElement>(null);
|
||||
const more = usePopover(moreTrigger);
|
||||
const compute = useQuery({ ...getComputeSettingsQuery(projectId), enabled: expanded });
|
||||
const defaultBackend = compute.data?.configuredDefaultBackend ?? compute.data?.defaultBackend;
|
||||
const computeLabel = defaultBackend ? TARGET_LABELS[defaultBackend]() : compute.isPending ? "…" : compute.isError ? m.model_picker_unavailable() : m.settings_not_set();
|
||||
@@ -42,38 +42,23 @@ export function WorkspaceTools({ expanded, experiments, runs, onOpenExperiment,
|
||||
{ id: "files", label: m.app_files(), Icon: FolderOpen, onClick: onFiles },
|
||||
{ id: "artifacts", label: m.app_artifacts(), Icon: Package, onClick: onArtifacts },
|
||||
{ id: "experiments", label: m.app_experiments(), Icon: FlaskConical, onClick: onExperiments },
|
||||
{ id: "terminal", label: m.workspace_terminal(), Icon: Terminal, onClick: onTerminal },
|
||||
];
|
||||
return (
|
||||
<div className={`workspace-tools absolute end-3.5 top-7 z-30 ${rightOffset === undefined ? "win-titlebar:top-10" : ""}`} style={{ insetInlineEnd: rightOffset }}>
|
||||
{!expanded ? (
|
||||
<nav aria-label={m.workspace_tools_heading()} className="flex items-center justify-end gap-3">
|
||||
<nav aria-label={m.workspace_tools_heading()} className="flex items-center justify-end gap-1">
|
||||
{items.map(({ id, label, Icon, onClick }) => (
|
||||
<IconButton key={id} active={activeView === id} className="text-text [&.active]:text-text" data-tip={label} data-tip-align={id === "experiments" ? "end" : undefined} aria-label={label} aria-pressed={activeView === id} onClick={onClick}>
|
||||
<Icon size={15} />
|
||||
</IconButton>
|
||||
))}
|
||||
<div className="relative">
|
||||
<IconButton ref={moreTrigger} active={activeView === "terminal" || more.open} className="text-text [&.active]:text-text" data-tip={more.open ? undefined : m.workspace_more_tools()} data-tip-align="end" aria-label={m.workspace_more_tools()} aria-expanded={more.open} aria-haspopup="menu" onClick={() => more.setOpen((open) => !open)}>
|
||||
<MoreHorizontal size={15} />
|
||||
</IconButton>
|
||||
{more.open && (
|
||||
<div ref={more.ref} role="menu" className="absolute end-0 top-[calc(100%_+_4px)] z-50 flex min-w-40 flex-col rounded-lg border border-border bg-background p-1.5 shadow-menu">
|
||||
<MenuItem role="menuitem" onClick={() => { more.setOpen(false); onTerminal(); }}>
|
||||
<span className="flex items-center gap-2.5"><Terminal size={15} />{m.workspace_terminal()}</span>
|
||||
</MenuItem>
|
||||
{onSideChat && (
|
||||
<MenuItem role="menuitem" onClick={() => { more.setOpen(false); onSideChat(); }}>
|
||||
<span className="flex items-center gap-2.5"><MessagesSquare size={15} />{m.side_chat_open()}</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{onSideChat && sessionId && <SideChatPicker parentId={sessionId} sessions={sessions} onSelect={onSelectSideChat} onCreate={onSideChat} />}
|
||||
</nav>
|
||||
) : (
|
||||
<nav aria-label={m.workspace_tools_heading()} className="workspace-tools-card flex w-60 flex-col gap-0.5 rounded-xl border border-border bg-background px-1.5 py-2 shadow-elevated">
|
||||
<h2 className="m-0 px-2 pt-1 pb-2 text-sm font-normal text-subtext">{m.workspace_tools_heading()}</h2>
|
||||
{items.filter((item) => item.id !== "files").map(({ id, label: itemLabel, Icon, onClick }) => (
|
||||
{items.filter((item) => item.id !== "files" && item.id !== "terminal").map(({ id, label: itemLabel, Icon, onClick }) => (
|
||||
<MenuItem key={id} className="min-h-7 py-1"
|
||||
data-onboarding={id === "artifacts" ? "nav-artifacts" : undefined}
|
||||
onClick={onClick}>
|
||||
@@ -97,10 +82,11 @@ export function WorkspaceTools({ expanded, experiments, runs, onOpenExperiment,
|
||||
<MenuItem className="min-h-7 py-1" onClick={onTerminal}>
|
||||
<span className="flex items-center gap-4"><Terminal size={15} />{m.workspace_terminal()}</span>
|
||||
</MenuItem>
|
||||
{onSideChat && (
|
||||
<MenuItem className="min-h-7 py-1" onClick={onSideChat}>
|
||||
<span className="flex items-center gap-4"><MessagesSquare size={15} />{m.side_chat_open()}</span>
|
||||
</MenuItem>
|
||||
{onSideChat && sessionId && (
|
||||
<div className="flex items-center gap-3 px-2">
|
||||
<SideChatPicker parentId={sessionId} sessions={sessions} onSelect={onSelectSideChat} onCreate={onSideChat} />
|
||||
<span className="text-sm text-text">{m.side_chat_open()}</span>
|
||||
</div>
|
||||
)}
|
||||
{sessionId && <ChatBranch key={sessionId} sessionId={sessionId} busy={busy} onChanges={onChanges} />}
|
||||
{experimentRows.length > 0 && (
|
||||
@@ -123,6 +109,38 @@ export function WorkspaceTools({ expanded, experiments, runs, onOpenExperiment,
|
||||
);
|
||||
}
|
||||
|
||||
function SideChatPicker({ parentId, sessions, onSelect, onCreate }: {
|
||||
parentId: string;
|
||||
sessions: ChatSession[];
|
||||
onSelect: (sessionId: string) => void;
|
||||
onCreate: () => void;
|
||||
}) {
|
||||
const children = sessions.filter((session) => session.sideParentSessionId === parentId)
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt || a.id.localeCompare(b.id));
|
||||
return <OptionPicker
|
||||
triggerIcon={<MessagesSquare size={15} />}
|
||||
title={m.side_chat_new()}
|
||||
onTriggerClick={onCreate}
|
||||
header={m.side_chat_list({ count: children.length })}
|
||||
openOnHover
|
||||
className="text-text [&.active]:text-text"
|
||||
choices={children.map((session) => ({
|
||||
id: session.id,
|
||||
label: session.title?.trim() || m.side_chat_untitled(),
|
||||
description: timeAgo(session.updatedAt),
|
||||
}))}
|
||||
value=""
|
||||
dropDown
|
||||
align="right"
|
||||
searchPlaceholder={m.side_chat_search()}
|
||||
menuAction={{ label: m.side_chat_new(), onSelect: onCreate }}
|
||||
renderLabel={(choice) => <span className="block max-w-60 truncate">{choice.label}</span>}
|
||||
renderIcon={(choice) => children.find((session) => session.id === choice.id)?.busy
|
||||
? <Spinner /> : <MessagesSquare size={14} />}
|
||||
onSelect={onSelect}
|
||||
/>;
|
||||
}
|
||||
|
||||
function ChatBranch({ sessionId, busy, onChanges }: { sessionId: string; busy: boolean; onChanges: () => void }) {
|
||||
const worktree = useQuery({ ...getSessionWorktreeQuery(sessionId), refetchInterval: busy ? 5_000 : false });
|
||||
const wasBusy = useRef(busy);
|
||||
|
||||
@@ -5,7 +5,7 @@ export type Pane =
|
||||
| { kind: "code"; experimentId: string; branch: string; view: "files" | "changes" }
|
||||
| { kind: "plan"; sessionId: string; promptId: string }
|
||||
| { kind: "subagent"; sessionId: string; spawnPartId: string }
|
||||
| { kind: "side"; sessionId: string };
|
||||
| { kind: "side"; sessionId: string | null };
|
||||
|
||||
export interface TaskWorkspace {
|
||||
tabs: Pane[];
|
||||
@@ -81,7 +81,7 @@ export function parsePane(value: unknown): Pane | undefined {
|
||||
if (only("kind", "sessionId", "spawnPartId") && nonempty(value.sessionId) && nonempty(value.spawnPartId)) return { kind: "subagent", sessionId: value.sessionId, spawnPartId: value.spawnPartId };
|
||||
break;
|
||||
case "side":
|
||||
if (only("kind", "sessionId") && nonempty(value.sessionId)) return { kind: "side", sessionId: value.sessionId };
|
||||
if (only("kind", "sessionId") && (value.sessionId === null || nonempty(value.sessionId))) return { kind: "side", sessionId: value.sessionId };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -174,10 +174,10 @@ export interface SubagentViewDef {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** A temporary side chat branched off the task's chat, live beside it. */
|
||||
/** A side-chat tab; a null session ID is an unsaved draft. */
|
||||
export interface SideChatViewDef {
|
||||
kind: "side";
|
||||
sessionId: string;
|
||||
sessionId: string | null;
|
||||
}
|
||||
|
||||
/** One committed code-browser tab per experiment branch. Source, selected
|
||||
@@ -212,7 +212,7 @@ export function rightTabKey(tab: RightTab): string {
|
||||
if ("code" in tab) return `code:${tab.branch}`;
|
||||
if ("kind" in tab) {
|
||||
if (tab.kind === "plan") return `plan:${tab.promptId}`;
|
||||
return tab.kind === "side" ? `side:${tab.sessionId}` : `subagent:${tab.spawnPartId}`;
|
||||
return tab.kind === "side" ? `side:${tab.sessionId ?? "draft"}` : `subagent:${tab.spawnPartId}`;
|
||||
}
|
||||
if ("path" in tab) return `file:${fileTabKey(tab)}`;
|
||||
return `experiment:${tab.id}:${tab.view}`;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
function loadFunction(file, name, globals) {
|
||||
const text = readFileSync(new URL(`../src/components/${file}`, import.meta.url), "utf8");
|
||||
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
let expression;
|
||||
const visit = (node) => {
|
||||
if (ts.isFunctionDeclaration(node) && node.name?.text === name) {
|
||||
expression = node.getText(source).replace(/^export /, "");
|
||||
} else if (ts.isVariableDeclaration(node) && node.name.getText(source) === name) {
|
||||
expression = node.initializer.getText(source);
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(source);
|
||||
assert.ok(expression, name);
|
||||
const code = ts.transpileModule(`exports.target = ${expression}`, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX },
|
||||
}).outputText;
|
||||
const exports = {};
|
||||
new Function("require", "exports", ...Object.keys(globals), code)(require, exports, ...Object.values(globals));
|
||||
return exports.target;
|
||||
}
|
||||
|
||||
test("side-chat trigger opens the list on touch and a draft on mouse click", () => {
|
||||
const opened = [];
|
||||
let created = 0;
|
||||
const OptionPicker = loadFunction("ModelPicker.tsx", "OptionPicker", {
|
||||
useRef: (value) => ({ current: value }),
|
||||
useState: (value) => [value, () => {}],
|
||||
useEffect: () => {},
|
||||
useLayoutEffect: () => {},
|
||||
usePopover: () => ({ open: false, setOpen: (value) => opened.push(typeof value === "function" ? value(false) : value), ref: { current: null } }),
|
||||
IconButton: "button",
|
||||
REASONING_DEFAULT_ID: "default",
|
||||
});
|
||||
const tree = OptionPicker({ triggerIcon: "chat", onTriggerClick: () => created++, openOnHover: true, choices: [{ id: "saved", label: "Saved chat" }] });
|
||||
const trigger = tree.props.children[0];
|
||||
trigger.props.onPointerDown({ pointerType: "touch" });
|
||||
trigger.props.onClick();
|
||||
assert.equal(created, 0);
|
||||
assert.equal(opened.at(-1), true);
|
||||
trigger.props.onPointerDown({ pointerType: "mouse" });
|
||||
trigger.props.onClick();
|
||||
assert.equal(created, 1);
|
||||
assert.equal(opened.at(-1), false);
|
||||
});
|
||||
|
||||
test("unsaved side-chat autonomy stays local while root drafts update defaults", () => {
|
||||
for (const sideParentSessionId of ["parent", undefined]) {
|
||||
const local = [];
|
||||
const defaults = [];
|
||||
const setAutonomy = loadFunction("ChatPanel.tsx", "setAutonomy", {
|
||||
openSession: null, sideParentSessionId,
|
||||
setSideDraftAutonomy: (value) => local.push(value),
|
||||
onPreferredAutonomyChange: (value) => defaults.push(value),
|
||||
});
|
||||
setAutonomy("copilot");
|
||||
assert.deepEqual(local, sideParentSessionId ? ["copilot"] : []);
|
||||
assert.deepEqual(defaults, sideParentSessionId ? [] : ["copilot"]);
|
||||
}
|
||||
});
|
||||
|
||||
test("first send applies draft autonomy to the newly created side chat", async () => {
|
||||
const changes = [];
|
||||
let cached;
|
||||
const openNewSession = loadFunction("ChatPanel.tsx", "openNewSession", {
|
||||
composerScopeRef: { current: {} }, projectVisitRef: { current: {} },
|
||||
sideParentSessionId: "parent", sideDraftAutonomy: "copilot",
|
||||
openSideChatMutation: { mutateAsync: async (parent) => ({ id: "child", autonomy: "agentic", sideParentSessionId: parent }) },
|
||||
setChatSessionAutonomyMutation: { mutateAsync: async ([id, autonomy]) => { changes.push([id, autonomy]); return { id, autonomy }; } },
|
||||
isCurrentScope: () => true, sessionsOptions: { queryKey: [] },
|
||||
setSessions: (update) => { cached = update([]); },
|
||||
queryClient: { getQueryData: () => [] },
|
||||
onActiveSessionChangeRef: { current: () => {} },
|
||||
});
|
||||
const session = await openNewSession({}, undefined);
|
||||
assert.deepEqual(changes, [["child", "copilot"]]);
|
||||
assert.equal(session.autonomy, "copilot");
|
||||
assert.equal(cached[0].autonomy, "copilot");
|
||||
});
|
||||
@@ -106,7 +106,7 @@ function savedTask(panes = [file, code]) {
|
||||
}
|
||||
|
||||
test("all tab variants retain ordering, preview, history, expansion and view metadata", () => {
|
||||
const panes = [file, code, { kind: "experiment", experimentId: "exp", view: "terminal", runId: "old" }, { kind: "plan", sessionId: "one", promptId: "plan" }, { kind: "subagent", sessionId: "one", spawnPartId: "spawn" }, { kind: "side", sessionId: "two" }];
|
||||
const panes = [file, code, { kind: "experiment", experimentId: "exp", view: "terminal", runId: "old" }, { kind: "plan", sessionId: "one", promptId: "plan" }, { kind: "subagent", sessionId: "one", spawnPartId: "spawn" }, { kind: "side", sessionId: "two" }, { kind: "side", sessionId: null }];
|
||||
const saved = savedTask(panes);
|
||||
saved.previewKey = tabs.rightTabKey(tabs.paneTab(file));
|
||||
saved.expanded = { files: ["src"], [tabs.rightTabKey(tabs.paneTab(code))]: ["src/utils"] };
|
||||
|
||||
@@ -24,6 +24,7 @@ const panes = [
|
||||
{ kind: "plan", sessionId: "session", promptId: "prompt" },
|
||||
{ kind: "subagent", sessionId: "session", spawnPartId: "part" },
|
||||
{ kind: "side", sessionId: "session" },
|
||||
{ kind: "side", sessionId: null },
|
||||
];
|
||||
|
||||
test("every pane variant round-trips through a concrete task URL", () => {
|
||||
@@ -59,6 +60,8 @@ test("pane validation rejects malformed identifiers, views, line numbers and ext
|
||||
{ kind: "code", experimentId: "exp", branch: "", view: "files" },
|
||||
{ kind: "plan", sessionId: "session", promptId: "" },
|
||||
{ kind: "subagent", sessionId: "", spawnPartId: "part" },
|
||||
{ kind: "side", sessionId: "" },
|
||||
{ kind: "side" },
|
||||
...panes.map((pane) => ({ ...pane, content: "do not persist file or transcript content" })),
|
||||
]) assert.equal(parsePane(invalid), undefined, JSON.stringify(invalid));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user