fix: remove copy icon, reduce padding, reposition badges in dropdown

This commit is contained in:
Nathan Flurry 2026-01-27 20:42:45 -08:00
parent d85f55a75b
commit d30ddc24f2
8 changed files with 246 additions and 68 deletions

View file

@ -477,13 +477,14 @@
border: 1px solid transparent; border: 1px solid transparent;
color: var(--text); color: var(--text);
text-align: left; text-align: left;
padding: 6px 8px; padding: 6px;
border-radius: 6px; border-radius: 6px;
font-size: 12px; font-size: 12px;
cursor: pointer; cursor: pointer;
transition: all var(--transition); transition: all var(--transition);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
gap: 8px; gap: 8px;
} }
@ -496,6 +497,13 @@
color: rgba(255, 255, 255, 0.6); color: rgba(255, 255, 255, 0.6);
} }
.agent-option-left {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.agent-option-name { .agent-option-name {
white-space: nowrap; white-space: nowrap;
} }
@ -509,6 +517,7 @@
.agent-badge.installed { .agent-badge.installed {
color: var(--muted); color: var(--muted);
flex-shrink: 0;
} }
.agent-badge.version { .agent-badge.version {

View file

@ -81,9 +81,11 @@ const SessionSidebar = ({
setShowMenu(false); setShowMenu(false);
}} }}
> >
<span className="agent-option-name">{agentLabels[agent.id] ?? agent.id}</span> <div className="agent-option-left">
<span className="agent-option-name">{agentLabels[agent.id] ?? agent.id}</span>
{agent.version && <span className="agent-badge version">v{agent.version}</span>}
</div>
{agent.installed && <span className="agent-badge installed">Installed</span>} {agent.installed && <span className="agent-badge installed">Installed</span>}
{agent.version && <span className="agent-badge version">v{agent.version}</span>}
</button> </button>
))} ))}
</div> </div>

View file

@ -201,9 +201,11 @@ const ChatPanel = ({
setShowAgentMenu(false); setShowAgentMenu(false);
}} }}
> >
<span className="agent-option-name">{agentLabels[agent.id] ?? agent.id}</span> <div className="agent-option-left">
<span className="agent-option-name">{agentLabels[agent.id] ?? agent.id}</span>
{agent.version && <span className="agent-badge version">v{agent.version}</span>}
</div>
{agent.installed && <span className="agent-badge installed">Installed</span>} {agent.installed && <span className="agent-badge installed">Installed</span>}
{agent.version && <span className="agent-badge version">v{agent.version}</span>}
</button> </button>
))} ))}
</div> </div>

View file

@ -1,4 +1,4 @@
import { ChevronDown, ChevronRight, Copy, Check } from "lucide-react"; import { ChevronDown, ChevronRight } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { UniversalEvent } from "sandbox-agent"; import type { UniversalEvent } from "sandbox-agent";
import { formatJson, formatTime } from "../../utils/format"; import { formatJson, formatTime } from "../../utils/format";
@ -52,7 +52,6 @@ const EventsTab = ({
disabled={events.length === 0} disabled={events.length === 0}
title="Copy all events as JSON" title="Copy all events as JSON"
> >
{copied ? <Check size={14} /> : <Copy size={14} />}
{copied ? "Copied" : "Copy"} {copied ? "Copied" : "Copy"}
</button> </button>
<button className="button ghost small" onClick={onClear}> <button className="button ghost small" onClick={onClear}>

View file

@ -351,67 +351,6 @@ async fn test_multi_turn_for_agent(app: &Router, agent: AgentId) -> Result<(), S
Ok(()) Ok(())
} }
#[tokio::test]
async fn multi_turn_mock_agent() {
let test_app = TestApp::new();
// Mock agent should always support multi-turn as the reference implementation
let result = test_multi_turn_for_agent(&test_app.app, AgentId::Mock).await;
assert!(
result.is_ok(),
"Mock agent multi-turn failed: {:?}",
result.err()
);
}
#[tokio::test]
async fn multi_turn_real_agents() {
let configs = match test_agents_from_env() {
Ok(configs) => configs,
Err(err) => {
eprintln!("Failed to get agent configs: {:?}. Skipping multi-turn test.", err);
return;
}
};
if configs.is_empty() {
eprintln!("No agents configured for testing. Skipping multi-turn test.");
return;
}
let test_app = TestApp::new();
for config in configs {
let _guard = apply_credentials(&config.credentials);
install_agent(&test_app.app, config.agent).await;
let result = test_multi_turn_for_agent(&test_app.app, config.agent).await;
match config.agent {
AgentId::Claude | AgentId::Amp | AgentId::Opencode => {
// These agents should support multi-turn via resumption
assert!(
result.is_ok(),
"{} multi-turn failed (should support resumption): {:?}",
config.agent,
result.err()
);
}
AgentId::Codex => {
// Codex now supports multi-turn via the shared app-server model
assert!(
result.is_ok(),
"{} multi-turn failed (should support shared app-server): {:?}",
config.agent,
result.err()
);
}
AgentId::Mock => {
// Mock is tested separately
}
}
}
}
/// Test that verifies the session can be reopened after ending /// Test that verifies the session can be reopened after ending
#[tokio::test] #[tokio::test]
async fn session_reopen_after_end() { async fn session_reopen_after_end() {

View file

@ -1,4 +1,5 @@
mod session_lifecycle; mod session_lifecycle;
mod multi_turn;
mod permissions; mod permissions;
mod questions; mod questions;
mod reasoning; mod reasoning;

View file

@ -0,0 +1,128 @@
// Multi-turn session snapshots use the mock baseline as the single source of truth.
include!("../common/http.rs");
const FIRST_PROMPT: &str = "Reply with exactly the word FIRST.";
const SECOND_PROMPT: &str = "Reply with exactly the word SECOND.";
fn session_snapshot_suffix(prefix: &str) -> String {
snapshot_name(prefix, Some(AgentId::Mock))
}
fn assert_session_snapshot(prefix: &str, value: Value) {
insta::with_settings!({
snapshot_suffix => session_snapshot_suffix(prefix),
}, {
insta::assert_yaml_snapshot!(value);
});
}
async fn send_message_with_text(app: &Router, session_id: &str, text: &str) {
let status = send_status(
app,
Method::POST,
&format!("/v1/sessions/{session_id}/messages"),
Some(json!({ "message": text })),
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT, "send message");
}
async fn poll_events_until_from(
app: &Router,
session_id: &str,
offset: u64,
timeout: Duration,
) -> (Vec<Value>, u64) {
let start = Instant::now();
let mut offset = offset;
let mut events = Vec::new();
while start.elapsed() < timeout {
let path = format!("/v1/sessions/{session_id}/events?offset={offset}&limit=200");
let (status, payload) = send_json(app, Method::GET, &path, None).await;
assert_eq!(status, StatusCode::OK, "poll events");
let new_events = payload
.get("events")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
if !new_events.is_empty() {
if let Some(last) = new_events
.last()
.and_then(|event| event.get("sequence"))
.and_then(Value::as_u64)
{
offset = last;
}
events.extend(new_events);
if should_stop(&events) {
break;
}
}
tokio::time::sleep(Duration::from_millis(800)).await;
}
(events, offset)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn multi_turn_snapshots() {
let configs = test_agents_from_env().expect("configure SANDBOX_TEST_AGENTS or install agents");
for config in &configs {
let app = TestApp::new();
let capabilities = fetch_capabilities(&app.app).await;
let caps = capabilities
.get(config.agent.as_str())
.expect("capabilities missing");
if !caps.session_lifecycle {
continue;
}
let _guard = apply_credentials(&config.credentials);
install_agent(&app.app, config.agent).await;
let session_id = format!("multi-turn-{}", config.agent.as_str());
create_session(&app.app, config.agent, &session_id, test_permission_mode(config.agent))
.await;
send_message_with_text(&app.app, &session_id, FIRST_PROMPT).await;
let (first_events, offset) =
poll_events_until_from(&app.app, &session_id, 0, Duration::from_secs(120)).await;
let first_events = truncate_after_first_stop(&first_events);
assert!(
!first_events.is_empty(),
"no events collected for first turn {}",
config.agent
);
assert!(
should_stop(&first_events),
"timed out waiting for assistant/error event for first turn {}",
config.agent
);
send_message_with_text(&app.app, &session_id, SECOND_PROMPT).await;
let (second_events, _offset) = poll_events_until_from(
&app.app,
&session_id,
offset,
Duration::from_secs(120),
)
.await;
let second_events = truncate_after_first_stop(&second_events);
assert!(
!second_events.is_empty(),
"no events collected for second turn {}",
config.agent
);
assert!(
should_stop(&second_events),
"timed out waiting for assistant/error event for second turn {}",
config.agent
);
let snapshot = json!({
"first": normalize_events(&first_events),
"second": normalize_events(&second_events),
});
assert_session_snapshot("multi_turn", snapshot);
}
}

View file

@ -0,0 +1,98 @@
---
source: server/packages/sandbox-agent/tests/sessions/multi_turn.rs
expression: value
---
first:
- metadata: true
seq: 1
session: started
type: session.started
- item:
content_types:
- text
kind: message
role: user
status: in_progress
seq: 2
type: item.started
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 3
type: item.delta
- item:
content_types:
- text
kind: message
role: user
status: completed
seq: 4
type: item.completed
- item:
content_types:
- text
kind: message
role: assistant
status: in_progress
seq: 5
type: item.started
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 6
type: item.delta
- item:
content_types:
- text
kind: message
role: assistant
status: completed
seq: 7
type: item.completed
second:
- item:
content_types:
- text
kind: message
role: user
status: in_progress
seq: 1
type: item.started
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 2
type: item.delta
- item:
content_types:
- text
kind: message
role: user
status: completed
seq: 3
type: item.completed
- item:
content_types:
- text
kind: message
role: assistant
status: in_progress
seq: 4
type: item.started
- delta:
delta: "<redacted>"
item_id: "<redacted>"
native_item_id: "<redacted>"
seq: 5
type: item.delta
- item:
content_types:
- text
kind: message
role: assistant
status: completed
seq: 6
type: item.completed