Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40ac83e632 | ||
|
|
58da395941 | ||
|
|
0e3ef94c9e | ||
|
|
1f68dfc2b5 | ||
|
|
84977a464c | ||
|
|
8cc1dc042d | ||
|
|
651d64f34d | ||
|
|
b98d4b59f5 | ||
|
|
df6d99c07d | ||
|
|
9843510e1f | ||
|
|
83bda3dfb2 |
@@ -279,4 +279,58 @@ mod tests {
|
|||||||
assert_eq!(grep.matched_files, 2);
|
assert_eq!(grep.matched_files, 2);
|
||||||
assert!(!grep.output.contains("c.txt"));
|
assert!(!grep.output.contains("c.txt"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grep_content_groups_lines_by_file_and_marks_matches() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
temp.path().join("first.txt"),
|
||||||
|
"before\nneedle one\nafter\nomitted one\nomitted two\nbefore distant\nneedle distant\nafter distant\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::write(temp.path().join("second.txt"), "needle two\n").unwrap();
|
||||||
|
let root = temp.path().canonicalize().unwrap();
|
||||||
|
let readable = RootAccess(root.clone());
|
||||||
|
|
||||||
|
let grep = run_grep(
|
||||||
|
&root,
|
||||||
|
root.clone(),
|
||||||
|
GrepRequest {
|
||||||
|
pattern: "needle".to_string(),
|
||||||
|
path: FsPath::root(),
|
||||||
|
glob: Some("*.txt".to_string()),
|
||||||
|
output_mode: GrepOutputMode::Content,
|
||||||
|
case_insensitive: false,
|
||||||
|
before_context: 1,
|
||||||
|
after_context: 1,
|
||||||
|
multiline: false,
|
||||||
|
file_type: None,
|
||||||
|
limit: 20,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
&readable,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(grep.match_count, 3);
|
||||||
|
assert_eq!(grep.matched_files, 2);
|
||||||
|
assert_eq!(
|
||||||
|
grep.output,
|
||||||
|
concat!(
|
||||||
|
"first.txt\n",
|
||||||
|
" 1 │ before\n",
|
||||||
|
" > 2 │ needle one\n",
|
||||||
|
" 3 │ after\n",
|
||||||
|
" …\n",
|
||||||
|
" 6 │ before distant\n",
|
||||||
|
" > 7 │ needle distant\n",
|
||||||
|
" 8 │ after distant\n",
|
||||||
|
"\n",
|
||||||
|
"second.txt\n",
|
||||||
|
" > 1 │ needle two\n",
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert_eq!(grep.output.matches("first.txt").count(), 1);
|
||||||
|
assert_eq!(grep.output.matches("second.txt").count(), 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fmt::Write as _;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::FsAccessPolicy;
|
use crate::FsAccessPolicy;
|
||||||
@@ -57,20 +59,11 @@ impl GrepReport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
GrepOutputMode::Content => {
|
GrepOutputMode::Content => {
|
||||||
for line in &self.lines {
|
output.push_str(&render_content_lines(
|
||||||
let separator = if line.is_match { ':' } else { '-' };
|
root,
|
||||||
let path = logical_display(root, &line.path);
|
&self.lines,
|
||||||
if self.show_line_numbers
|
self.show_line_numbers,
|
||||||
&& let Some(number) = line.line_number
|
|
||||||
{
|
|
||||||
output.push_str(&format!(
|
|
||||||
"{path}{separator}{number}{separator}{}\n",
|
|
||||||
line.text
|
|
||||||
));
|
));
|
||||||
} else {
|
|
||||||
output.push_str(&format!("{path}{separator}{}\n", line.text));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
GrepResult {
|
GrepResult {
|
||||||
@@ -82,6 +75,48 @@ impl GrepReport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_content_lines(root: &Path, lines: &[ContentLine], show_line_numbers: bool) -> String {
|
||||||
|
let mut grouped = BTreeMap::<&Path, Vec<&ContentLine>>::new();
|
||||||
|
for line in lines {
|
||||||
|
grouped.entry(&line.path).or_default().push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut output = String::new();
|
||||||
|
for (file_index, (path, file_lines)) in grouped.into_iter().enumerate() {
|
||||||
|
if file_index > 0 {
|
||||||
|
output.push('\n');
|
||||||
|
}
|
||||||
|
let _ = writeln!(output, "{}", logical_display(root, path));
|
||||||
|
|
||||||
|
let number_width = file_lines
|
||||||
|
.iter()
|
||||||
|
.filter_map(|line| line.line_number)
|
||||||
|
.map(|number| number.to_string().len())
|
||||||
|
.max()
|
||||||
|
.unwrap_or(1);
|
||||||
|
let mut previous_line_end = None;
|
||||||
|
for line in file_lines {
|
||||||
|
if let (Some(previous_end), Some(number)) = (previous_line_end, line.line_number)
|
||||||
|
&& number > previous_end
|
||||||
|
{
|
||||||
|
let _ = writeln!(output, " …");
|
||||||
|
}
|
||||||
|
|
||||||
|
let marker = if line.is_match { '>' } else { ' ' };
|
||||||
|
if show_line_numbers && let Some(number) = line.line_number {
|
||||||
|
let _ = writeln!(output, " {marker} {number:>number_width$} │ {}", line.text);
|
||||||
|
} else {
|
||||||
|
let _ = writeln!(output, " {marker} │ {}", line.text);
|
||||||
|
}
|
||||||
|
previous_line_end = line
|
||||||
|
.line_number
|
||||||
|
.map(|number| number + line.text.split('\n').count() as u64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
fn logical_display(root: &Path, path: &Path) -> String {
|
fn logical_display(root: &Path, path: &Path) -> String {
|
||||||
path.strip_prefix(root)
|
path.strip_prefix(root)
|
||||||
.unwrap_or(path)
|
.unwrap_or(path)
|
||||||
|
|||||||
@@ -946,7 +946,7 @@ fn apply_role_profile(
|
|||||||
value["feature"]["sub_worker"] = serde_json::json!({ "enabled": sub_worker });
|
value["feature"]["sub_worker"] = serde_json::json!({ "enabled": sub_worker });
|
||||||
value["feature"]["flow"] = serde_json::json!({ "enabled": slug == "coder" });
|
value["feature"]["flow"] = serde_json::json!({ "enabled": slug == "coder" });
|
||||||
value["feature"]["worker"] = serde_json::json!({
|
value["feature"]["worker"] = serde_json::json!({
|
||||||
"enabled": matches!(slug, "companion" | "orchestrator"),
|
"enabled": slug == "orchestrator",
|
||||||
"direct_spawn": slug != "orchestrator"
|
"direct_spawn": slug != "orchestrator"
|
||||||
});
|
});
|
||||||
value["feature"]["manage_workdir"] = serde_json::json!({
|
value["feature"]["manage_workdir"] = serde_json::json!({
|
||||||
@@ -1408,7 +1408,22 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn builtin_companion_can_manage_workdirs() {
|
fn builtin_coder_uses_sub_worker_control_without_worker_control() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let resolved = ProfileResolver::new()
|
||||||
|
.with_workspace_base(tmp.path())
|
||||||
|
.resolve(
|
||||||
|
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "coder"),
|
||||||
|
ProfileResolveOptions::with_worker_name("coder-worker"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(resolved.manifest.feature.sub_worker.enabled);
|
||||||
|
assert!(!resolved.manifest.feature.worker.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builtin_companion_uses_sub_worker_control_without_worker_control() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let resolved = ProfileResolver::new()
|
let resolved = ProfileResolver::new()
|
||||||
.with_workspace_base(tmp.path())
|
.with_workspace_base(tmp.path())
|
||||||
@@ -1419,6 +1434,8 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(resolved.manifest.feature.manage_workdir.enabled);
|
assert!(resolved.manifest.feature.manage_workdir.enabled);
|
||||||
|
assert!(resolved.manifest.feature.sub_worker.enabled);
|
||||||
|
assert!(!resolved.manifest.feature.worker.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ pub fn grep_tool(session: WorkdirSessionHandle) -> ToolDefinition {
|
|||||||
Arc::new(move || {
|
Arc::new(move || {
|
||||||
let schema = schemars::schema_for!(GrepParams);
|
let schema = schemars::schema_for!(GrepParams);
|
||||||
let meta = ToolMeta::new("Grep")
|
let meta = ToolMeta::new("Grep")
|
||||||
.description("Search Workdir file contents with a regex. Glob/Grep traversal executes inside the WorkdirSession provider. Results are bounded and Workdir-relative.")
|
.description("Search Workdir file contents with a regex. Content results group lines by file; `>` marks matching lines and unmarked lines are context. Glob/Grep traversal executes inside the WorkdirSession provider. Results are bounded and Workdir-relative.")
|
||||||
.input_schema(serde_json::to_value(schema).expect("Grep schema serialization"));
|
.input_schema(serde_json::to_value(schema).expect("Grep schema serialization"));
|
||||||
let tool: Arc<dyn Tool> = Arc::new(GrepTool {
|
let tool: Arc<dyn Tool> = Arc::new(GrepTool {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
|
|||||||
@@ -998,14 +998,6 @@ where
|
|||||||
|
|
||||||
if feature_config.sub_worker.enabled {
|
if feature_config.sub_worker.enabled {
|
||||||
worker.register_worker_orchestration_instruction();
|
worker.register_worker_orchestration_instruction();
|
||||||
if !feature_config.worker.enabled {
|
|
||||||
feature_registry.add_module(
|
|
||||||
crate::feature::builtin::manage_worker::sub_worker_control_feature(
|
|
||||||
worker.workspace_client_handle(),
|
|
||||||
spawned_registry.clone(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let host_worker_observation_provider = worker.worker_observation_provider();
|
let host_worker_observation_provider = worker.worker_observation_provider();
|
||||||
|
|||||||
@@ -4449,6 +4449,13 @@ mod tests {
|
|||||||
.resolve_profile("builtin:companion", root.path(), "embedded-test-companion")
|
.resolve_profile("builtin:companion", root.path(), "embedded-test-companion")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(companion.feature.manage_workdir.enabled);
|
assert!(companion.feature.manage_workdir.enabled);
|
||||||
|
assert!(companion.feature.sub_worker.enabled);
|
||||||
|
assert!(!companion.feature.worker.enabled);
|
||||||
|
let coder = archive
|
||||||
|
.resolve_profile("builtin:coder", root.path(), "embedded-test-coder")
|
||||||
|
.unwrap();
|
||||||
|
assert!(coder.feature.sub_worker.enabled);
|
||||||
|
assert!(!coder.feature.worker.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -4475,6 +4482,8 @@ mod tests {
|
|||||||
.resolve_profile("builtin:coder", root.path(), "remote-test-worker")
|
.resolve_profile("builtin:coder", root.path(), "remote-test-worker")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(manifest.worker.name, "remote-test-worker");
|
assert_eq!(manifest.worker.name, "remote-test-worker");
|
||||||
|
assert!(manifest.feature.sub_worker.enabled);
|
||||||
|
assert!(!manifest.feature.worker.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -9261,9 +9261,8 @@ fn cleanup_working_directory_for_runtime(
|
|||||||
result.diagnostics,
|
result.diagnostics,
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
let record = workdir_record_from_summary(&api, runtime_id, &working_directory.summary);
|
|
||||||
api.store.upsert_workdir_registry(&record)?;
|
|
||||||
let mut summary = working_directory.summary;
|
let mut summary = working_directory.summary;
|
||||||
|
persist_workdir_cleanup_observation(&api, runtime_id, &summary)?;
|
||||||
apply_workdir_occupancy_projection(&api, &mut summary)?;
|
apply_workdir_occupancy_projection(&api, &mut summary)?;
|
||||||
Ok(Json(BrowserWorkingDirectoryDetailResponse {
|
Ok(Json(BrowserWorkingDirectoryDetailResponse {
|
||||||
workspace_id: api.config.workspace_id.clone(),
|
workspace_id: api.config.workspace_id.clone(),
|
||||||
@@ -14154,11 +14153,7 @@ fn sync_runtime_workdir_observations(
|
|||||||
api.store.upsert_workdir_registry(&updated)?;
|
api.store.upsert_workdir_registry(&updated)?;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
record.materialization_status =
|
persist_workdir_runtime_miss(api, record, result.diagnostics.as_slice())?;
|
||||||
workdir_status_from_runtime_miss(result.diagnostics.as_slice()).to_string();
|
|
||||||
record.cleanliness = "unknown".to_string();
|
|
||||||
record.updated_at = now_registry_timestamp();
|
|
||||||
api.store.upsert_workdir_registry(&record)?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@@ -14172,17 +14167,54 @@ fn sync_runtime_workdir_observations(
|
|||||||
Ok(response.diagnostics)
|
Ok(response.diagnostics)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn workdir_status_from_runtime_miss(diagnostics: &[RuntimeDiagnostic]) -> &'static str {
|
fn persist_workdir_cleanup_observation(
|
||||||
if diagnostics
|
api: &WorkspaceApi,
|
||||||
|
runtime_id: &str,
|
||||||
|
summary: &WorkingDirectorySummary,
|
||||||
|
) -> ApiResult<()> {
|
||||||
|
if summary.status == WorkingDirectoryStatusKind::NotFound {
|
||||||
|
api.store.delete_workdir_registry(
|
||||||
|
&api.config.workspace_id,
|
||||||
|
summary.working_directory_id.as_str(),
|
||||||
|
)?;
|
||||||
|
} else {
|
||||||
|
let record = workdir_record_from_summary(api, runtime_id, summary);
|
||||||
|
api.store.upsert_workdir_registry(&record)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workdir_runtime_miss_is_not_found(diagnostics: &[RuntimeDiagnostic]) -> bool {
|
||||||
|
diagnostics
|
||||||
.iter()
|
.iter()
|
||||||
.any(|diagnostic| diagnostic.code == "working_directory_not_found")
|
.any(|diagnostic| diagnostic.code == "working_directory_not_found")
|
||||||
{
|
}
|
||||||
|
|
||||||
|
fn workdir_status_from_runtime_miss(diagnostics: &[RuntimeDiagnostic]) -> &'static str {
|
||||||
|
if workdir_runtime_miss_is_not_found(diagnostics) {
|
||||||
"not_found"
|
"not_found"
|
||||||
} else {
|
} else {
|
||||||
"unknown"
|
"unknown"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn persist_workdir_runtime_miss(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
mut record: WorkdirRegistryRecord,
|
||||||
|
diagnostics: &[RuntimeDiagnostic],
|
||||||
|
) -> ApiResult<()> {
|
||||||
|
if workdir_runtime_miss_is_not_found(diagnostics) {
|
||||||
|
api.store
|
||||||
|
.delete_workdir_registry(&api.config.workspace_id, record.workdir_id.as_str())?;
|
||||||
|
} else {
|
||||||
|
record.materialization_status = "unknown".to_string();
|
||||||
|
record.cleanliness = "unknown".to_string();
|
||||||
|
record.updated_at = now_registry_timestamp();
|
||||||
|
api.store.upsert_workdir_registry(&record)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn sync_all_runtime_workdir_observations(api: &WorkspaceApi) -> Vec<RuntimeDiagnostic> {
|
fn sync_all_runtime_workdir_observations(api: &WorkspaceApi) -> Vec<RuntimeDiagnostic> {
|
||||||
let mut diagnostics = Vec::new();
|
let mut diagnostics = Vec::new();
|
||||||
let runtimes = api.runtime.list_runtimes(api.config.max_records.min(200));
|
let runtimes = api.runtime.list_runtimes(api.config.max_records.min(200));
|
||||||
@@ -16980,22 +17012,24 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workdir_runtime_miss_uses_exact_typed_code() {
|
fn workdir_runtime_miss_uses_exact_typed_code() {
|
||||||
assert_eq!(
|
let typed_not_found = [RuntimeDiagnostic {
|
||||||
workdir_status_from_runtime_miss(&[RuntimeDiagnostic {
|
|
||||||
code: "working_directory_not_found".to_string(),
|
code: "working_directory_not_found".to_string(),
|
||||||
severity: DiagnosticSeverity::Warning,
|
severity: DiagnosticSeverity::Warning,
|
||||||
message: "missing".to_string(),
|
message: "missing".to_string(),
|
||||||
}]),
|
}];
|
||||||
|
assert_eq!(
|
||||||
|
workdir_status_from_runtime_miss(&typed_not_found),
|
||||||
"not_found"
|
"not_found"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert!(workdir_runtime_miss_is_not_found(&typed_not_found));
|
||||||
workdir_status_from_runtime_miss(&[RuntimeDiagnostic {
|
|
||||||
|
let unrelated = [RuntimeDiagnostic {
|
||||||
code: "some_other_not_found".to_string(),
|
code: "some_other_not_found".to_string(),
|
||||||
severity: DiagnosticSeverity::Warning,
|
severity: DiagnosticSeverity::Warning,
|
||||||
message: "not a typed workdir miss".to_string(),
|
message: "not a typed workdir miss".to_string(),
|
||||||
}]),
|
}];
|
||||||
"unknown"
|
assert_eq!(workdir_status_from_runtime_miss(&unrelated), "unknown");
|
||||||
);
|
assert!(!workdir_runtime_miss_is_not_found(&unrelated));
|
||||||
}
|
}
|
||||||
|
|
||||||
struct DeterministicExecutionBackend {
|
struct DeterministicExecutionBackend {
|
||||||
@@ -21308,6 +21342,87 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn confirmed_runtime_miss_removes_registry_record_but_unknown_is_retained() {
|
||||||
|
let workspace = tempfile::tempdir().unwrap();
|
||||||
|
init_clean_git_workspace(workspace.path());
|
||||||
|
let api = test_api(workspace.path()).await;
|
||||||
|
seed_cleanup_workdir(&api, "deleted-workdir", "present", "clean");
|
||||||
|
let deleted = api
|
||||||
|
.store
|
||||||
|
.get_workdir_registry(TEST_WORKSPACE_ID, "deleted-workdir")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
persist_workdir_runtime_miss(
|
||||||
|
&api,
|
||||||
|
deleted,
|
||||||
|
&[RuntimeDiagnostic {
|
||||||
|
code: "working_directory_not_found".to_string(),
|
||||||
|
severity: DiagnosticSeverity::Warning,
|
||||||
|
message: "missing".to_string(),
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
api.store
|
||||||
|
.get_workdir_registry(TEST_WORKSPACE_ID, "deleted-workdir")
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
|
||||||
|
seed_cleanup_workdir(&api, "unknown-workdir", "present", "clean");
|
||||||
|
let unknown = api
|
||||||
|
.store
|
||||||
|
.get_workdir_registry(TEST_WORKSPACE_ID, "unknown-workdir")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
persist_workdir_runtime_miss(
|
||||||
|
&api,
|
||||||
|
unknown,
|
||||||
|
&[RuntimeDiagnostic {
|
||||||
|
code: "runtime_unavailable".to_string(),
|
||||||
|
severity: DiagnosticSeverity::Warning,
|
||||||
|
message: "temporarily unavailable".to_string(),
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
api.store
|
||||||
|
.get_workdir_registry(TEST_WORKSPACE_ID, "unknown-workdir")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.materialization_status,
|
||||||
|
"unknown"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cleanup_not_found_observation_removes_registry_record() {
|
||||||
|
let workspace = tempfile::tempdir().unwrap();
|
||||||
|
init_clean_git_workspace(workspace.path());
|
||||||
|
let api = test_api(workspace.path()).await;
|
||||||
|
let working_directory_id = "cleanup-existing";
|
||||||
|
seed_cleanup_workdir(&api, working_directory_id, "present", "clean");
|
||||||
|
let record = api
|
||||||
|
.store
|
||||||
|
.get_workdir_registry(TEST_WORKSPACE_ID, working_directory_id)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let mut summary = workdir_summary_from_record(&record);
|
||||||
|
summary.status = WorkingDirectoryStatusKind::NotFound;
|
||||||
|
|
||||||
|
persist_workdir_cleanup_observation(&api, "runtime-test", &summary).unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
api.store
|
||||||
|
.get_workdir_registry(TEST_WORKSPACE_ID, working_directory_id)
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn seed_cleanup_link(api: &WorkspaceApi, runtime_worker_id: &str, workdir_id: &str) {
|
fn seed_cleanup_link(api: &WorkspaceApi, runtime_worker_id: &str, workdir_id: &str) {
|
||||||
let runtime_worker_id = runtime_worker_id.parse::<u64>().unwrap();
|
let runtime_worker_id = runtime_worker_id.parse::<u64>().unwrap();
|
||||||
api.store
|
api.store
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import "./base.dcdl" // {
|
|||||||
web = { enabled = true; };
|
web = { enabled = true; };
|
||||||
sub_worker = { enabled = true; };
|
sub_worker = { enabled = true; };
|
||||||
flow = { enabled = true; };
|
flow = { enabled = true; };
|
||||||
worker = { enabled = true; };
|
|
||||||
ticket = { enabled = true; thread = true; };
|
ticket = { enabled = true; thread = true; };
|
||||||
merge_request = {
|
merge_request = {
|
||||||
show = true;
|
show = true;
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import "./base.dcdl" // {
|
|||||||
memory = { enabled = true; };
|
memory = { enabled = true; };
|
||||||
web = { enabled = true; };
|
web = { enabled = true; };
|
||||||
sub_worker = { enabled = true; };
|
sub_worker = { enabled = true; };
|
||||||
worker = { enabled = true; };
|
|
||||||
manage_workdir = { enabled = true; };
|
manage_workdir = { enabled = true; };
|
||||||
ticket = { enabled = true; authoring = true; thread = true; };
|
ticket = { enabled = true; authoring = true; thread = true; };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||||
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||||
"test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts",
|
"test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts",
|
||||||
"build": "deno run -A npm:vite@7.2.7 build",
|
"build": "deno run -A npm:vite@7.2.7 build",
|
||||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+22
-12
@@ -9,9 +9,9 @@
|
|||||||
"npm:@codemirror/view@6.43.8": "6.43.8",
|
"npm:@codemirror/view@6.43.8": "6.43.8",
|
||||||
"npm:@fontsource/ibm-plex-mono@5.3.0": "5.3.0",
|
"npm:@fontsource/ibm-plex-mono@5.3.0": "5.3.0",
|
||||||
"npm:@lezer/highlight@1.2.3": "1.2.3",
|
"npm:@lezer/highlight@1.2.3": "1.2.3",
|
||||||
"npm:@sveltejs/adapter-static@3.0.9": "3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7__svelte@5.45.6__typescript@5.9.3__vite@7.2.7_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7",
|
"npm:@sveltejs/adapter-static@3.0.9": "3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7____yaml@2.9.0___yaml@2.9.0__svelte@5.45.6__typescript@5.9.3__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_yaml@2.9.0",
|
||||||
"npm:@sveltejs/kit@2.49.4": "2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7",
|
"npm:@sveltejs/kit@2.49.4": "2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_svelte@5.45.6_typescript@5.9.3_vite@7.2.7__yaml@2.9.0_yaml@2.9.0",
|
||||||
"npm:@sveltejs/vite-plugin-svelte@6.2.1": "6.2.1_svelte@5.45.6_vite@7.2.7",
|
"npm:@sveltejs/vite-plugin-svelte@6.2.1": "6.2.1_svelte@5.45.6_vite@7.2.7__yaml@2.9.0_yaml@2.9.0",
|
||||||
"npm:clsx@2.1.1": "2.1.1",
|
"npm:clsx@2.1.1": "2.1.1",
|
||||||
"npm:cookie@0.6.0": "0.6.0",
|
"npm:cookie@0.6.0": "0.6.0",
|
||||||
"npm:decodal-codemirror@0.3.0": "0.3.0_@codemirror+language@6.12.4_@codemirror+view@6.43.8_@lezer+highlight@1.2.3_@lezer+lr@1.4.10",
|
"npm:decodal-codemirror@0.3.0": "0.3.0_@codemirror+language@6.12.4_@codemirror+view@6.43.8_@lezer+highlight@1.2.3_@lezer+lr@1.4.10",
|
||||||
@@ -23,7 +23,8 @@
|
|||||||
"npm:svelte-check@4.3.4": "4.3.4_svelte@5.45.6_typescript@5.9.3",
|
"npm:svelte-check@4.3.4": "4.3.4_svelte@5.45.6_typescript@5.9.3",
|
||||||
"npm:svelte@5.45.6": "5.45.6",
|
"npm:svelte@5.45.6": "5.45.6",
|
||||||
"npm:typescript@5.9.3": "5.9.3",
|
"npm:typescript@5.9.3": "5.9.3",
|
||||||
"npm:vite@7.2.7": "7.2.7"
|
"npm:vite@7.2.7": "7.2.7_yaml@2.9.0",
|
||||||
|
"npm:yaml@2.9.0": "2.9.0"
|
||||||
},
|
},
|
||||||
"jsr": {
|
"jsr": {
|
||||||
"@std/assert@1.0.19": {
|
"@std/assert@1.0.19": {
|
||||||
@@ -433,13 +434,13 @@
|
|||||||
"acorn"
|
"acorn"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@sveltejs/adapter-static@3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7__svelte@5.45.6__typescript@5.9.3__vite@7.2.7_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7": {
|
"@sveltejs/adapter-static@3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7____yaml@2.9.0___yaml@2.9.0__svelte@5.45.6__typescript@5.9.3__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_yaml@2.9.0": {
|
||||||
"integrity": "sha512-aytHXcMi7lb9ljsWUzXYQ0p5X1z9oWud2olu/EpmH7aCu4m84h7QLvb5Wp+CFirKcwoNnYvYWhyP/L8Vh1ztdw==",
|
"integrity": "sha512-aytHXcMi7lb9ljsWUzXYQ0p5X1z9oWud2olu/EpmH7aCu4m84h7QLvb5Wp+CFirKcwoNnYvYWhyP/L8Vh1ztdw==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@sveltejs/kit"
|
"@sveltejs/kit"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@sveltejs/kit@2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7": {
|
"@sveltejs/kit@2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_svelte@5.45.6_typescript@5.9.3_vite@7.2.7__yaml@2.9.0_yaml@2.9.0": {
|
||||||
"integrity": "sha512-JFtOqDoU0DI/+QSG8qnq5bKcehVb3tCHhOG4amsSYth5/KgO4EkJvi42xSAiyKmXAAULW1/Zdb6lkgGEgSxdZg==",
|
"integrity": "sha512-JFtOqDoU0DI/+QSG8qnq5bKcehVb3tCHhOG4amsSYth5/KgO4EkJvi42xSAiyKmXAAULW1/Zdb6lkgGEgSxdZg==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@standard-schema/spec",
|
"@standard-schema/spec",
|
||||||
@@ -465,7 +466,7 @@
|
|||||||
],
|
],
|
||||||
"bin": true
|
"bin": true
|
||||||
},
|
},
|
||||||
"@sveltejs/vite-plugin-svelte-inspector@5.0.2_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_vite@7.2.7": {
|
"@sveltejs/vite-plugin-svelte-inspector@5.0.2_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_svelte@5.45.6_vite@7.2.7__yaml@2.9.0_yaml@2.9.0": {
|
||||||
"integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==",
|
"integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@sveltejs/vite-plugin-svelte",
|
"@sveltejs/vite-plugin-svelte",
|
||||||
@@ -474,7 +475,7 @@
|
|||||||
"vite"
|
"vite"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@sveltejs/vite-plugin-svelte@6.2.1_svelte@5.45.6_vite@7.2.7": {
|
"@sveltejs/vite-plugin-svelte@6.2.1_svelte@5.45.6_vite@7.2.7__yaml@2.9.0_yaml@2.9.0": {
|
||||||
"integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==",
|
"integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@sveltejs/vite-plugin-svelte-inspector",
|
"@sveltejs/vite-plugin-svelte-inspector",
|
||||||
@@ -966,7 +967,7 @@
|
|||||||
"vfile-message"
|
"vfile-message"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"vite@7.2.7": {
|
"vite@7.2.7_yaml@2.9.0": {
|
||||||
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
|
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"esbuild",
|
"esbuild",
|
||||||
@@ -974,14 +975,18 @@
|
|||||||
"picomatch",
|
"picomatch",
|
||||||
"postcss",
|
"postcss",
|
||||||
"rollup",
|
"rollup",
|
||||||
"tinyglobby"
|
"tinyglobby",
|
||||||
|
"yaml"
|
||||||
],
|
],
|
||||||
"optionalDependencies": [
|
"optionalDependencies": [
|
||||||
"fsevents"
|
"fsevents"
|
||||||
],
|
],
|
||||||
|
"optionalPeers": [
|
||||||
|
"yaml"
|
||||||
|
],
|
||||||
"bin": true
|
"bin": true
|
||||||
},
|
},
|
||||||
"vitefu@1.1.2_vite@7.2.7": {
|
"vitefu@1.1.2_vite@7.2.7__yaml@2.9.0_yaml@2.9.0": {
|
||||||
"integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==",
|
"integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"vite"
|
"vite"
|
||||||
@@ -993,6 +998,10 @@
|
|||||||
"w3c-keyname@2.2.8": {
|
"w3c-keyname@2.2.8": {
|
||||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="
|
||||||
},
|
},
|
||||||
|
"yaml@2.9.0": {
|
||||||
|
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||||
|
"bin": true
|
||||||
|
},
|
||||||
"zimmerframe@1.1.4": {
|
"zimmerframe@1.1.4": {
|
||||||
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="
|
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="
|
||||||
},
|
},
|
||||||
@@ -1024,7 +1033,8 @@
|
|||||||
"packageJson": {
|
"packageJson": {
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"npm:@fontsource/ibm-plex-mono@5.3.0",
|
"npm:@fontsource/ibm-plex-mono@5.3.0",
|
||||||
"npm:gen-interface-jp@0.8.0"
|
"npm:gen-interface-jp@0.8.0",
|
||||||
|
"npm:yaml@2.9.0"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/ibm-plex-mono": "5.3.0",
|
"@fontsource/ibm-plex-mono": "5.3.0",
|
||||||
"gen-interface-jp": "0.8.0"
|
"gen-interface-jp": "0.8.0",
|
||||||
|
"yaml": "2.9.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
let { item }: Props = $props();
|
let { item }: Props = $props();
|
||||||
|
let detailOpen = $state(false);
|
||||||
let nowMs = $state(Date.now());
|
let nowMs = $state(Date.now());
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -55,22 +56,20 @@
|
|||||||
line.kind !== 'activity' && line.kind !== 'task_reminder' && line.kind !== 'run_stats';
|
line.kind !== 'activity' && line.kind !== 'task_reminder' && line.kind !== 'run_stats';
|
||||||
}
|
}
|
||||||
|
|
||||||
function toolSummary(line: ConsoleLine): { label: string; suffix: string; rest: string } {
|
function toolLabel(line: ConsoleLine): string {
|
||||||
const [firstLine = '', ...rest] = line.body.split('\n');
|
return line.toolCallLabel ?? line.toolCall?.name ?? line.title;
|
||||||
const [label, suffix = ''] = firstLine.split(' — ', 2);
|
}
|
||||||
return {
|
|
||||||
label,
|
function toolStatus(line: ConsoleLine): string {
|
||||||
suffix,
|
return line.toolStatus ?? line.toolCall?.state ?? '';
|
||||||
rest: rest.join('\n')
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldRenderMarkdown(line: ConsoleLine): boolean {
|
function shouldRenderMarkdown(line: ConsoleLine): boolean {
|
||||||
return line.kind === 'user' || line.kind === 'assistant' || line.kind === 'system';
|
return line.kind === 'user' || line.kind === 'assistant' || line.kind === 'system';
|
||||||
}
|
}
|
||||||
|
|
||||||
function bodyTextAfterToolSummary(line: ConsoleLine): string {
|
function toolBodyText(line: ConsoleLine): string {
|
||||||
return toolSummary(line).rest;
|
return detailOpen ? (line.expandedBody ?? line.body) : line.body;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -107,20 +106,27 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if item.kind === 'tool'}
|
{:else if item.kind === 'tool'}
|
||||||
<div class="tool-summary">
|
<div class="tool-summary">
|
||||||
<span class="tool-label">{toolSummary(item).label}</span>
|
<span class="tool-label">{toolLabel(item)}</span>
|
||||||
<span class="tool-separator"> — </span>
|
<span class={`tool-status ${item.toolCall?.state ?? ''}`}>{toolStatus(item)}</span>
|
||||||
<span class={`tool-suffix ${item.toolCall?.state ?? ''}`}>{toolSummary(item).suffix}</span>
|
{#if item.detail}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tool-detail-button"
|
||||||
|
aria-expanded={detailOpen}
|
||||||
|
onclick={() => (detailOpen = !detailOpen)}
|
||||||
|
>detail</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if item.compaction}
|
{#if item.compaction}
|
||||||
<!-- rendered as one lifecycle item above -->
|
<!-- rendered as one lifecycle item above -->
|
||||||
{:else if item.kind === 'tool'}
|
{:else if item.kind === 'tool'}
|
||||||
{#if bodyTextAfterToolSummary(item)}
|
{#if toolBodyText(item)}
|
||||||
<p class="console-plain-text">
|
<p class="console-plain-text">
|
||||||
{#if isBashTool(item)}
|
{#if isBashTool(item)}
|
||||||
<AnsiText text={bodyTextAfterToolSummary(item)} />
|
<AnsiText text={toolBodyText(item)} />
|
||||||
{:else}
|
{:else}
|
||||||
{bodyTextAfterToolSummary(item)}
|
{toolBodyText(item)}
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -147,11 +153,10 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if item.detail}
|
{#if item.detail && detailOpen}
|
||||||
<details class="message-detail">
|
<div class="message-detail" role="region" aria-label={`${toolLabel(item)} detail`}>
|
||||||
<summary>detail</summary>
|
|
||||||
<p>{item.detail}</p>
|
<p>{item.detail}</p>
|
||||||
</details>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
@@ -306,48 +311,46 @@
|
|||||||
.tool-summary {
|
.tool-summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 0;
|
gap: 0.5rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-label {
|
.tool-label {
|
||||||
flex: 0 0 auto;
|
|
||||||
color: var(--tui-cyan);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-separator {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-suffix {
|
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-wrap: anywhere;
|
overflow: hidden;
|
||||||
|
color: var(--tui-cyan);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-separator,
|
.tool-status {
|
||||||
.tool-suffix {
|
flex: 0 0 auto;
|
||||||
color: var(--tui-dark-gray);
|
color: var(--tui-dark-gray);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-state-error .tool-suffix {
|
.tool-state-error .tool-status {
|
||||||
color: var(--tui-red);
|
color: var(--tui-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-state-running .tool-suffix,
|
.tool-state-running .tool-status,
|
||||||
.tool-state-streaming_args .tool-suffix,
|
.tool-state-streaming_args .tool-status,
|
||||||
.tool-state-pending .tool-suffix {
|
.tool-state-pending .tool-status {
|
||||||
color: var(--tui-yellow);
|
color: var(--tui-yellow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-state-done .tool-suffix {
|
.tool-state-done .tool-status {
|
||||||
color: var(--tui-dark-gray);
|
color: var(--tui-dark-gray);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.console-line.error-line .tool-status {
|
||||||
|
color: var(--tui-red);
|
||||||
|
}
|
||||||
|
|
||||||
.message-heading {
|
.message-heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -412,13 +415,47 @@
|
|||||||
color: var(--code);
|
color: var(--code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tool-detail-button {
|
||||||
|
margin-inline-start: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
padding: 0.08rem 0.35rem;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 750;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-line:hover .tool-detail-button,
|
||||||
|
.tool-detail-button:focus-visible,
|
||||||
|
.tool-detail-button[aria-expanded='true'] {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.message-detail {
|
.message-detail {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
border-left: 2px solid var(--line);
|
||||||
|
padding-left: 0.6rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.84rem;
|
font-size: 0.84rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-detail summary {
|
.message-detail p {
|
||||||
cursor: pointer;
|
margin: 0;
|
||||||
font-weight: 800;
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
.tool-detail-button {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -313,26 +313,25 @@ Deno.test("projectConsole groups tool call lifecycle into one Call block", () =>
|
|||||||
!toolLines[0].streaming,
|
!toolLines[0].streaming,
|
||||||
"completed tool call should not remain streaming",
|
"completed tool call should not remain streaming",
|
||||||
);
|
);
|
||||||
assert(
|
assertEquals(toolLines[0].toolCallLabel, "Bash($ pwd)");
|
||||||
toolLines[0].body.includes("$ pwd"),
|
assertEquals(toolLines[0].toolStatus, "done");
|
||||||
"Bash command should be summarized",
|
|
||||||
);
|
|
||||||
assert(
|
assert(
|
||||||
toolLines[0].body.includes("/repo"),
|
toolLines[0].body.includes("/repo"),
|
||||||
"tool result should be folded into the Call block",
|
"tool result should be folded into the Call block",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
toolLines[0].body.includes("line9"),
|
toolLines[0].body.includes("line9"),
|
||||||
"Bash result preview should include the ninth output line",
|
"Bash preview should include the ninth output line",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
!toolLines[0].body.includes("line10") &&
|
!toolLines[0].body.includes("line10") &&
|
||||||
!toolLines[0].body.includes("line12"),
|
toolLines[0].body.includes("… +3 more lines"),
|
||||||
"Bash result preview should be capped at ten display lines",
|
"Bash preview should retain its line cap",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
toolLines[0].body.includes("… +3 more lines"),
|
toolLines[0].expandedBody?.includes("line12") === true &&
|
||||||
"Bash result preview should show omitted output count",
|
!toolLines[0].expandedBody?.includes("more lines"),
|
||||||
|
"Bash detail should show every returned output line",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
toolLines[0].detail?.includes("id: call-1"),
|
toolLines[0].detail?.includes("id: call-1"),
|
||||||
@@ -421,7 +420,8 @@ Deno.test("projectConsole streams distinct Bash stdout and stderr through termin
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||||
assert(line.body.includes("Bash — failed (exit 7)"), line.body);
|
assertEquals(line.toolCallLabel, "Bash($ long-command)");
|
||||||
|
assertEquals(line.toolStatus, "failed (exit 7)");
|
||||||
assert(!line.body.includes("elapsed"), line.body);
|
assert(!line.body.includes("elapsed"), line.body);
|
||||||
assert(!line.body.includes("stdout:"), line.body);
|
assert(!line.body.includes("stdout:"), line.body);
|
||||||
assert(line.body.includes("ready\n"), line.body);
|
assert(line.body.includes("ready\n"), line.body);
|
||||||
@@ -463,7 +463,8 @@ Deno.test("snapshot restores bounded in-flight Bash command output", () => {
|
|||||||
|
|
||||||
const projection = projectConsole([{ eventId: "snapshot-command", event: snapshot }]);
|
const projection = projectConsole([{ eventId: "snapshot-command", event: snapshot }]);
|
||||||
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||||
assert(line.body.includes("Bash — running…"), line.body);
|
assertEquals(line.toolCallLabel, "Bash($ slow)");
|
||||||
|
assertEquals(line.toolStatus, "running…");
|
||||||
assert(!line.body.includes("elapsed"), line.body);
|
assert(!line.body.includes("elapsed"), line.body);
|
||||||
assert(!line.body.includes("stdout:"), line.body);
|
assert(!line.body.includes("stdout:"), line.body);
|
||||||
assert(line.body.includes("[… earlier stdout omitted]\ntail\n"), line.body);
|
assert(line.body.includes("[… earlier stdout omitted]\ntail\n"), line.body);
|
||||||
@@ -474,7 +475,7 @@ Deno.test("snapshot restores bounded in-flight Bash command output", () => {
|
|||||||
assertEquals(line.streaming, true);
|
assertEquals(line.streaming, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("projectConsole caps default tool request and result previews", () => {
|
Deno.test("projectConsole caps default preview but keeps complete detail body", () => {
|
||||||
const projection = projectConsole([
|
const projection = projectConsole([
|
||||||
{
|
{
|
||||||
eventId: "70",
|
eventId: "70",
|
||||||
@@ -508,19 +509,100 @@ Deno.test("projectConsole caps default tool request and result previews", () =>
|
|||||||
|
|
||||||
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||||
assertEquals(line.title, "Call · CustomTool");
|
assertEquals(line.title, "Call · CustomTool");
|
||||||
assertEquals(line.body.split("\n").length, 7);
|
assertEquals(line.toolCallLabel, 'CustomTool("first":"one","second":"two","third":"three","fourth":"four")');
|
||||||
assert(line.body.includes("CustomTool — done"), "tool state should be shown");
|
assertEquals(line.toolStatus, "done");
|
||||||
|
assertEquals(line.body.split("\n").length, 3);
|
||||||
assert(
|
assert(
|
||||||
line.body.includes('"first": "one"'),
|
line.body.includes("out1") && line.body.includes("… +3 more lines"),
|
||||||
"request preview should be shown",
|
"normal display should retain the capped response preview",
|
||||||
|
);
|
||||||
|
assert(!line.body.includes("first"), "request arguments should stay in the Call signature and detail");
|
||||||
|
assert(
|
||||||
|
line.detail?.includes("arguments:\nfirst: one") === true &&
|
||||||
|
line.detail?.includes("fourth: four") === true,
|
||||||
|
"detail metadata should render complete request arguments as YAML",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
line.expandedBody?.includes("out5") === true &&
|
||||||
|
!line.expandedBody?.includes("more lines"),
|
||||||
|
"detail body should contain the complete result",
|
||||||
);
|
);
|
||||||
assert(line.body.includes("out1"), "result preview should be shown");
|
|
||||||
assert(!line.body.includes("third"), "request preview should be capped");
|
|
||||||
assert(!line.body.includes("out3"), "result preview should be capped");
|
|
||||||
assert(line.body.includes("… +"), "overflow marker should be shown");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("projectConsole shows Grep query and caps result preview to five entries", () => {
|
Deno.test("projectConsole renders JSON tool responses as YAML", () => {
|
||||||
|
const projection = projectConsole([
|
||||||
|
{
|
||||||
|
eventId: "json-call",
|
||||||
|
event: {
|
||||||
|
event: "tool_call_done",
|
||||||
|
data: {
|
||||||
|
id: "json-tool",
|
||||||
|
name: "CustomTool",
|
||||||
|
arguments: "{}",
|
||||||
|
},
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "json-result",
|
||||||
|
event: {
|
||||||
|
event: "tool_result",
|
||||||
|
data: {
|
||||||
|
id: "json-tool",
|
||||||
|
summary: "json completed",
|
||||||
|
output: JSON.stringify({
|
||||||
|
status: "ok",
|
||||||
|
items: [{ id: 1 }, { id: 2 }],
|
||||||
|
}),
|
||||||
|
is_error: false,
|
||||||
|
},
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "invalid-json-call",
|
||||||
|
event: {
|
||||||
|
event: "tool_call_done",
|
||||||
|
data: {
|
||||||
|
id: "invalid-json-tool",
|
||||||
|
name: "CustomTool",
|
||||||
|
arguments: "{}",
|
||||||
|
},
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: "invalid-json-result",
|
||||||
|
event: {
|
||||||
|
event: "tool_result",
|
||||||
|
data: {
|
||||||
|
id: "invalid-json-tool",
|
||||||
|
summary: "invalid json",
|
||||||
|
output: '{"status": broken}',
|
||||||
|
is_error: false,
|
||||||
|
},
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const toolLines = projection.lines.filter((line) => line.kind === "tool");
|
||||||
|
const jsonLine = toolLines.find((line) => line.id.includes("json-tool"));
|
||||||
|
const invalidLine = toolLines.find((line) => line.id.includes("invalid-json-tool"));
|
||||||
|
assert(jsonLine, "JSON tool line should be projected");
|
||||||
|
assert(invalidLine, "invalid JSON tool line should be projected");
|
||||||
|
assert(
|
||||||
|
jsonLine.expandedBody?.includes("status: ok") === true &&
|
||||||
|
jsonLine.expandedBody?.includes(" - id: 2") === true,
|
||||||
|
"detail body should serialize parsed JSON as YAML",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
jsonLine.body.includes("more lines"),
|
||||||
|
"normal preview should cap the pretty-printed JSON",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
invalidLine.expandedBody?.includes('{"status": broken}') === true,
|
||||||
|
"invalid JSON-looking output should remain unchanged",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("projectConsole caps Grep preview but keeps complete detail body", () => {
|
||||||
const projection = projectConsole([
|
const projection = projectConsole([
|
||||||
{
|
{
|
||||||
eventId: "72",
|
eventId: "72",
|
||||||
@@ -549,17 +631,19 @@ Deno.test("projectConsole shows Grep query and caps result preview to five entri
|
|||||||
|
|
||||||
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||||
assertEquals(line.title, "Call · Grep");
|
assertEquals(line.title, "Call · Grep");
|
||||||
assert(
|
assertEquals(line.toolCallLabel, "Grep(needle)");
|
||||||
line.body.includes("Grep — 6 matches"),
|
assertEquals(line.toolStatus, "done");
|
||||||
"Grep summary should be shown",
|
|
||||||
);
|
|
||||||
assert(line.body.includes("query: needle"), "Grep query should be shown");
|
|
||||||
assert(line.body.includes("hit1"), "first result should be shown");
|
assert(line.body.includes("hit1"), "first result should be shown");
|
||||||
assert(line.body.includes("hit5"), "fifth result should be shown");
|
assert(line.body.includes("hit5"), "fifth result should be shown");
|
||||||
assert(!line.body.includes("hit6"), "sixth result should be capped");
|
assert(!line.body.includes("hit6"), "normal preview should retain its result cap");
|
||||||
assert(
|
assert(
|
||||||
line.body.includes("… +1 more results"),
|
line.body.includes("… +1 more results"),
|
||||||
"overflow marker should be shown",
|
"preview should show the omitted result count",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
line.expandedBody?.includes("hit6") === true &&
|
||||||
|
!line.expandedBody?.includes("more results"),
|
||||||
|
"detail body should show every Grep result",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -594,17 +678,15 @@ Deno.test("projectConsole keeps Grep error detail in the body", () => {
|
|||||||
|
|
||||||
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||||
assertEquals(line.title, "Call · Grep");
|
assertEquals(line.title, "Call · Grep");
|
||||||
assert(
|
assertEquals(line.toolCallLabel, "Grep(needle)");
|
||||||
line.body.includes("Grep — Failed"),
|
assertEquals(line.toolStatus, "error");
|
||||||
"error suffix should stay short",
|
|
||||||
);
|
|
||||||
assert(
|
assert(
|
||||||
line.body.includes(message),
|
line.body.includes(message),
|
||||||
"error detail should remain visible in the body",
|
"error detail should remain visible in the body",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
!line.body.includes(`Grep — ${message}`),
|
!line.toolCallLabel?.includes(message),
|
||||||
"error detail should not be repeated in the suffix",
|
"error detail should not be repeated in the Call signature",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -879,9 +961,10 @@ Deno.test("projectConsole keeps streaming tool call updates in the same Call blo
|
|||||||
assertEquals(toolLines.length, 1);
|
assertEquals(toolLines.length, 1);
|
||||||
assertEquals(toolLines[0].title, "Call · Read");
|
assertEquals(toolLines[0].title, "Call · Read");
|
||||||
assert(toolLines[0].streaming, "streaming tool call should remain streaming");
|
assert(toolLines[0].streaming, "streaming tool call should remain streaming");
|
||||||
|
assertEquals(toolLines[0].toolCallLabel, "Read(1 file)");
|
||||||
|
assertEquals(toolLines[0].toolStatus, "reading…");
|
||||||
assert(
|
assert(
|
||||||
toolLines[0].body.includes("/tmp/a.md") &&
|
toolLines[0].body.includes("/tmp/a.md"),
|
||||||
toolLines[0].body.includes("Read — reading"),
|
|
||||||
"Read call should render aggregate progress and path without content",
|
"Read call should render aggregate progress and path without content",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1018,10 +1101,8 @@ Deno.test("projectConsole aggregates Read calls without showing file content", (
|
|||||||
const toolLines = projection.lines.filter((line) => line.kind === "tool");
|
const toolLines = projection.lines.filter((line) => line.kind === "tool");
|
||||||
assertEquals(toolLines.length, 1);
|
assertEquals(toolLines.length, 1);
|
||||||
assertEquals(toolLines[0].title, "Call · Read");
|
assertEquals(toolLines[0].title, "Call · Read");
|
||||||
assert(
|
assertEquals(toolLines[0].toolCallLabel, "Read(2 files)");
|
||||||
toolLines[0].body.includes("Read — 2 files read"),
|
assertEquals(toolLines[0].toolStatus, "done");
|
||||||
"aggregate count should be shown",
|
|
||||||
);
|
|
||||||
assert(
|
assert(
|
||||||
toolLines[0].body.includes("/tmp/a.md"),
|
toolLines[0].body.includes("/tmp/a.md"),
|
||||||
"first path should be listed",
|
"first path should be listed",
|
||||||
@@ -1073,7 +1154,9 @@ Deno.test("projectConsole renders Edit calls with structured diff lines", () =>
|
|||||||
|
|
||||||
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||||
assertEquals(line.title, "Call · Edit");
|
assertEquals(line.title, "Call · Edit");
|
||||||
assert(line.body.includes("diff: -1 +2"), "diff summary should be shown");
|
assertEquals(line.toolCallLabel, "Edit(/tmp/a.md)");
|
||||||
|
assertEquals(line.toolStatus, "done");
|
||||||
|
assertEquals(line.body, "ok");
|
||||||
assertEquals(line.diff?.map((row) => row.kind), [
|
assertEquals(line.diff?.map((row) => row.kind), [
|
||||||
"context",
|
"context",
|
||||||
"remove",
|
"remove",
|
||||||
@@ -1242,13 +1325,13 @@ Deno.test("projectConsole renders snapshot entries and in-flight output", () =>
|
|||||||
assertEquals(projection.status, "running");
|
assertEquals(projection.status, "running");
|
||||||
assertEquals(
|
assertEquals(
|
||||||
projection.lines.map((line) =>
|
projection.lines.map((line) =>
|
||||||
`${line.kind}:${line.body}:${line.streaming}`
|
`${line.kind}:${line.toolCallLabel ? `${line.toolCallLabel}\n${line.body}` : line.body}:${line.streaming}`
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
"user:seed user:false",
|
"user:seed user:false",
|
||||||
"user:new user:false",
|
"user:new user:false",
|
||||||
"assistant:assistant reply:false",
|
"assistant:assistant reply:false",
|
||||||
"tool:Read — 1 file read\n /tmp/a.md:false",
|
"tool:Read(1 file)\n /tmp/a.md:false",
|
||||||
"status:Compacting…:true",
|
"status:Compacting…:true",
|
||||||
"in_flight:partial:true",
|
"in_flight:partial:true",
|
||||||
],
|
],
|
||||||
@@ -1476,25 +1559,26 @@ Deno.test("projectConsole relativizes known tool path displays from snapshot cwd
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const bodies = projection.lines.filter((line) => line.kind === "tool").map((
|
const toolLines = projection.lines.filter((line) => line.kind === "tool");
|
||||||
line,
|
const bodies = toolLines.map((line) => line.body);
|
||||||
) => line.body);
|
assertEquals(toolLines[0].toolCallLabel, "Read(1 file)");
|
||||||
assertEquals(bodies[0], "Read — 1 file read\n src/main.rs");
|
assertEquals(bodies[0], " src/main.rs");
|
||||||
assert(
|
assert(
|
||||||
projection.lines[0].detail?.includes("from src/main.rs"),
|
projection.lines[0].detail?.includes("from src/main.rs"),
|
||||||
"Read summary detail path should be relative",
|
"Read summary detail path should be relative",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
bodies.some((body) =>
|
toolLines.some((line) =>
|
||||||
body.includes("Write — out.txt") && body.includes("Wrote out.txt")
|
line.toolCallLabel === "Write(out.txt)" && line.body.includes("Wrote out.txt")
|
||||||
),
|
),
|
||||||
"Write header and known result path should be relative",
|
"Write signature and known result path should be relative",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
bodies.some((body) =>
|
toolLines.some((line) =>
|
||||||
body.includes("Edit — src/main.rs") && body.includes("Edited src/main.rs")
|
line.toolCallLabel === "Edit(src/main.rs)" &&
|
||||||
|
line.body.includes("Edited src/main.rs")
|
||||||
),
|
),
|
||||||
"Edit header and known result path should be relative",
|
"Edit signature and known result path should be relative",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
bodies.some((body) =>
|
bodies.some((body) =>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
InternalWorkerSnapshot,
|
InternalWorkerSnapshot,
|
||||||
Segment,
|
Segment,
|
||||||
} from "$lib/generated/protocol";
|
} from "$lib/generated/protocol";
|
||||||
|
import { stringify as stringifyYaml } from "yaml";
|
||||||
import { workspaceRoute } from "$lib/workspace/api/http";
|
import { workspaceRoute } from "$lib/workspace/api/http";
|
||||||
import {
|
import {
|
||||||
applyRunActivityEvent,
|
applyRunActivityEvent,
|
||||||
@@ -86,6 +87,9 @@ export type ConsoleLine = {
|
|||||||
kind: ConsoleLineKind;
|
kind: ConsoleLineKind;
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
expandedBody?: string;
|
||||||
|
toolCallLabel?: string;
|
||||||
|
toolStatus?: string;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
compaction?: ConsoleCompaction;
|
compaction?: ConsoleCompaction;
|
||||||
diff?: ConsoleDiffLine[];
|
diff?: ConsoleDiffLine[];
|
||||||
@@ -1376,7 +1380,10 @@ function refreshedToolLine(item: ConsoleLine): ConsoleLine {
|
|||||||
title: item.title.startsWith("Call · Tool result")
|
title: item.title.startsWith("Call · Tool result")
|
||||||
? item.title
|
? item.title
|
||||||
: `Call · ${toolCall.name}`,
|
: `Call · ${toolCall.name}`,
|
||||||
body: renderToolCall(toolCall),
|
body: renderToolResponse(toolCall),
|
||||||
|
expandedBody: renderToolResponse(toolCall, true),
|
||||||
|
toolCallLabel: toolCallSignature(toolCall),
|
||||||
|
toolStatus: toolCallStatus(toolCall),
|
||||||
detail: toolCallDetail(toolCall),
|
detail: toolCallDetail(toolCall),
|
||||||
diff: toolCall.name === "Edit" ? editDiff(toolCall) : undefined,
|
diff: toolCall.name === "Edit" ? editDiff(toolCall) : undefined,
|
||||||
streaming: !["done", "error"].includes(toolCall.state) && !commandTerminal,
|
streaming: !["done", "error"].includes(toolCall.state) && !commandTerminal,
|
||||||
@@ -1384,7 +1391,7 @@ function refreshedToolLine(item: ConsoleLine): ConsoleLine {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderToolCall(toolCall: ToolCallView): string {
|
function renderToolResponse(toolCall: ToolCallView, expanded = false): string {
|
||||||
switch (toolCall.name) {
|
switch (toolCall.name) {
|
||||||
case "Read":
|
case "Read":
|
||||||
return renderReadTool(toolCall);
|
return renderReadTool(toolCall);
|
||||||
@@ -1395,14 +1402,54 @@ function renderToolCall(toolCall: ToolCallView): string {
|
|||||||
case "Glob":
|
case "Glob":
|
||||||
return renderSearchTool(toolCall);
|
return renderSearchTool(toolCall);
|
||||||
case "Grep":
|
case "Grep":
|
||||||
return renderGrepTool(toolCall);
|
return renderGrepTool(toolCall, expanded);
|
||||||
case "Bash":
|
case "Bash":
|
||||||
return renderBashTool(toolCall);
|
return renderBashTool(toolCall, expanded);
|
||||||
default:
|
default:
|
||||||
return renderDefaultTool(toolCall);
|
return renderDefaultTool(toolCall, expanded);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toolCallSignature(toolCall: ToolCallView): string {
|
||||||
|
const args = parsedArgs(toolCall);
|
||||||
|
switch (toolCall.name) {
|
||||||
|
case "Read":
|
||||||
|
return `Read(${readPath(toolCall)})`;
|
||||||
|
case "Write":
|
||||||
|
case "Edit": {
|
||||||
|
const path = displayPath(stringField(args, "file_path") ?? "?", toolCall.cwd);
|
||||||
|
return `${toolCall.name}(${path})`;
|
||||||
|
}
|
||||||
|
case "Glob":
|
||||||
|
return `Glob(${stringField(args, "pattern") ?? genericCallArguments(toolCall)})`;
|
||||||
|
case "Grep":
|
||||||
|
return `Grep(${stringField(args, "pattern") ?? genericCallArguments(toolCall)})`;
|
||||||
|
case "Bash": {
|
||||||
|
const command = stringField(args, "command");
|
||||||
|
return `Bash(${command ? `$ ${singleLine(command)}` : genericCallArguments(toolCall)})`;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return `${toolCall.name}(${genericCallArguments(toolCall)})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function genericCallArguments(toolCall: ToolCallView): string {
|
||||||
|
const raw = toolCall.arguments ?? toolCall.argsStream;
|
||||||
|
if (!raw.trim()) return "";
|
||||||
|
const parsed = parseJson(raw);
|
||||||
|
if (parsed === undefined) return singleLine(raw);
|
||||||
|
const serialized = JSON.stringify(parsed) ?? "null";
|
||||||
|
return isRecord(parsed) ? serialized.slice(1, -1) : serialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function singleLine(value: string): string {
|
||||||
|
return value.replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolCallStatus(toolCall: ToolCallView): string {
|
||||||
|
return toolCall.name === "Bash" ? commandStateSuffix(toolCall) : stateSuffix(toolCall.state);
|
||||||
|
}
|
||||||
|
|
||||||
function aggregateReadToolLines(lines: ConsoleLine[]): ConsoleLine[] {
|
function aggregateReadToolLines(lines: ConsoleLine[]): ConsoleLine[] {
|
||||||
const result: ConsoleLine[] = [];
|
const result: ConsoleLine[] = [];
|
||||||
let index = 0;
|
let index = 0;
|
||||||
@@ -1434,9 +1481,6 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine {
|
|||||||
const paths = calls.map(readPath);
|
const paths = calls.map(readPath);
|
||||||
const visiblePaths = inProgress ? paths.slice(-3) : paths;
|
const visiblePaths = inProgress ? paths.slice(-3) : paths;
|
||||||
const body = compactLines([
|
const body = compactLines([
|
||||||
inProgress
|
|
||||||
? `Read — reading (${count} file${plural(count)}…)`
|
|
||||||
: `Read — ${count} file${plural(count)} read`,
|
|
||||||
visiblePaths.map((path) => ` ${path}`).join("\n"),
|
visiblePaths.map((path) => ` ${path}`).join("\n"),
|
||||||
inProgress && paths.length > visiblePaths.length
|
inProgress && paths.length > visiblePaths.length
|
||||||
? ` … (${paths.length - visiblePaths.length} earlier)`
|
? ` … (${paths.length - visiblePaths.length} earlier)`
|
||||||
@@ -1447,6 +1491,8 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine {
|
|||||||
kind: "tool",
|
kind: "tool",
|
||||||
title: "Call · Read",
|
title: "Call · Read",
|
||||||
body,
|
body,
|
||||||
|
toolCallLabel: `Read(${count} file${plural(count)})`,
|
||||||
|
toolStatus: hasError ? "failed" : inProgress ? "reading…" : "done",
|
||||||
detail: calls.map(readDetail).join("\n\n"),
|
detail: calls.map(readDetail).join("\n\n"),
|
||||||
eventId: group.at(-1)?.eventId,
|
eventId: group.at(-1)?.eventId,
|
||||||
source: "event",
|
source: "event",
|
||||||
@@ -1483,32 +1529,16 @@ function readDetail(toolCall: ToolCallView): string {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderReadTool(toolCall: ToolCallView): string {
|
function renderReadTool(_toolCall: ToolCallView): string {
|
||||||
return `Read — ${readPath(toolCall)} (${stateSuffix(toolCall.state)})`;
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderWriteTool(toolCall: ToolCallView): string {
|
function renderWriteTool(toolCall: ToolCallView): string {
|
||||||
const args = parsedArgs(toolCall);
|
return knownToolResultText(toolCall) ?? "";
|
||||||
const path = displayPath(stringField(args, "file_path") ?? "?", toolCall.cwd);
|
|
||||||
const content = stringField(args, "content");
|
|
||||||
return compactLines([
|
|
||||||
`Write — ${path} (${stateSuffix(toolCall.state)})`,
|
|
||||||
cappedSection(content, 5),
|
|
||||||
knownToolResultText(toolCall),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderEditTool(toolCall: ToolCallView): string {
|
function renderEditTool(toolCall: ToolCallView): string {
|
||||||
const args = parsedArgs(toolCall);
|
return knownToolResultText(toolCall) ?? "";
|
||||||
const path = displayPath(stringField(args, "file_path") ?? "?", toolCall.cwd);
|
|
||||||
const diff = editDiff(toolCall) ?? [];
|
|
||||||
const removes = diff.filter((line) => line.kind === "remove").length;
|
|
||||||
const adds = diff.filter((line) => line.kind === "add").length;
|
|
||||||
return compactLines([
|
|
||||||
`Edit — ${path} (${stateSuffix(toolCall.state)})`,
|
|
||||||
diff.length > 0 ? `diff: -${removes} +${adds}` : undefined,
|
|
||||||
knownToolResultText(toolCall),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function editDiff(toolCall: ToolCallView): ConsoleDiffLine[] | undefined {
|
function editDiff(toolCall: ToolCallView): ConsoleDiffLine[] | undefined {
|
||||||
@@ -1591,52 +1621,20 @@ function lcsTable(oldLines: string[], newLines: string[]): number[][] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderSearchTool(toolCall: ToolCallView): string {
|
function renderSearchTool(toolCall: ToolCallView): string {
|
||||||
const summary = toolCall.summary?.trim();
|
return knownToolResultText(toolCall) ?? "";
|
||||||
return compactLines([
|
|
||||||
`${toolCall.name} — ${toolHeaderSuffix(toolCall, summary)}`,
|
|
||||||
knownToolResultText(toolCall),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderGrepTool(toolCall: ToolCallView): string {
|
function renderGrepTool(toolCall: ToolCallView, expanded: boolean): string {
|
||||||
const summary = toolCall.summary?.trim();
|
const result = knownToolResultText(toolCall);
|
||||||
return compactLines([
|
return expanded ? result ?? "" : cappedResultSection(result, 5) ?? "";
|
||||||
`Grep — ${toolHeaderSuffix(toolCall, summary)}`,
|
|
||||||
grepQueryText(toolCall),
|
|
||||||
cappedResultSection(knownToolResultText(toolCall), 5),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toolHeaderSuffix(
|
function renderBashTool(toolCall: ToolCallView, expanded: boolean): string {
|
||||||
toolCall: ToolCallView,
|
if (["done", "error"].includes(toolCall.state)) {
|
||||||
summary?: string,
|
const result = resultText(toolCall);
|
||||||
): string {
|
return expanded ? result ?? "" : cappedDisplaySection(result, 10) ?? "";
|
||||||
if (toolCall.state === "error") {
|
|
||||||
return "Failed";
|
|
||||||
}
|
}
|
||||||
return summary ? firstLine(summary) : stateSuffix(toolCall.state);
|
return renderLiveCommandOutput(toolCall.command) ?? "";
|
||||||
}
|
|
||||||
|
|
||||||
function grepQueryText(toolCall: ToolCallView): string | undefined {
|
|
||||||
const args = parsedArgs(toolCall);
|
|
||||||
const pattern = stringField(args, "pattern");
|
|
||||||
if (pattern) {
|
|
||||||
return `query: ${pattern}`;
|
|
||||||
}
|
|
||||||
const renderedArgs = argsText(toolCall);
|
|
||||||
return renderedArgs ? `query:\n${renderedArgs}` : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderBashTool(toolCall: ToolCallView): string {
|
|
||||||
const args = parsedArgs(toolCall);
|
|
||||||
const command = stringField(args, "command");
|
|
||||||
return compactLines([
|
|
||||||
`Bash — ${commandStateSuffix(toolCall)}`,
|
|
||||||
command ? `$ ${command}` : argsText(toolCall),
|
|
||||||
["done", "error"].includes(toolCall.state)
|
|
||||||
? cappedDisplaySection(resultText(toolCall), 10)
|
|
||||||
: renderLiveCommandOutput(toolCall.command),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function commandStateSuffix(toolCall: ToolCallView): string {
|
function commandStateSuffix(toolCall: ToolCallView): string {
|
||||||
@@ -1690,12 +1688,9 @@ function renderLiveCommandOutput(command?: CommandSnapshot): string | undefined
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDefaultTool(toolCall: ToolCallView): string {
|
function renderDefaultTool(toolCall: ToolCallView, expanded: boolean): string {
|
||||||
return compactLines([
|
const result = resultText(toolCall);
|
||||||
`${toolCall.name} — ${stateSuffix(toolCall.state)}`,
|
return expanded ? result ?? "" : cappedDisplaySection(result, 3) ?? "";
|
||||||
cappedDisplaySection(argsText(toolCall), 3),
|
|
||||||
cappedDisplaySection(resultText(toolCall), 3),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toolCallDetail(toolCall: ToolCallView): string {
|
function toolCallDetail(toolCall: ToolCallView): string {
|
||||||
@@ -1713,10 +1708,30 @@ function toolCallDetail(toolCall: ToolCallView): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resultText(toolCall: ToolCallView): string | undefined {
|
function resultText(toolCall: ToolCallView): string | undefined {
|
||||||
if (toolCall.output) {
|
const text = toolCall.output || toolCall.summary;
|
||||||
return toolCall.output;
|
return text ? formatJsonResponseAsYaml(text) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatJsonResponseAsYaml(text: string): string {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
(trimmed.startsWith("{") && trimmed.endsWith("}")) ||
|
||||||
|
(trimmed.startsWith("[") && trimmed.endsWith("]"))
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(trimmed);
|
||||||
|
if (parsed === null || typeof parsed !== "object") {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return stringifyYaml(parsed).trimEnd();
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
}
|
}
|
||||||
return toolCall.summary;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function knownToolResultText(toolCall: ToolCallView): string | undefined {
|
function knownToolResultText(toolCall: ToolCallView): string | undefined {
|
||||||
@@ -1803,7 +1818,7 @@ function argsText(toolCall: ToolCallView): string {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
const parsed = parseJson(raw);
|
const parsed = parseJson(raw);
|
||||||
return parsed === undefined ? raw : jsonPreview(parsed);
|
return parsed === undefined ? raw : stringifyYaml(parsed).trimEnd();
|
||||||
}
|
}
|
||||||
|
|
||||||
function parsedArgs(
|
function parsedArgs(
|
||||||
@@ -1830,21 +1845,6 @@ function compactLines(lines: Array<string | undefined | null | false>): string {
|
|||||||
return lines.filter((line): line is string => Boolean(line)).join("\n");
|
return lines.filter((line): line is string => Boolean(line)).join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function cappedSection(
|
|
||||||
value: string | undefined,
|
|
||||||
cap: number,
|
|
||||||
): string | undefined {
|
|
||||||
if (!value) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const lines = value.split(/\r?\n/);
|
|
||||||
const shown = lines.slice(0, cap);
|
|
||||||
if (lines.length > cap) {
|
|
||||||
shown.push(`… +${lines.length - cap} more lines`);
|
|
||||||
}
|
|
||||||
return shown.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function cappedDisplaySection(
|
function cappedDisplaySection(
|
||||||
value: string | undefined,
|
value: string | undefined,
|
||||||
maxLines: number,
|
maxLines: number,
|
||||||
|
|||||||
@@ -251,7 +251,8 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
|
|||||||
ticketDetailLoad.includes("/repositories") &&
|
ticketDetailLoad.includes("/repositories") &&
|
||||||
ticketDetailPage.includes('mutate("state", "/state"') &&
|
ticketDetailPage.includes('mutate("state", "/state"') &&
|
||||||
ticketDetailPage.includes("async function queueTicket") &&
|
ticketDetailPage.includes("async function queueTicket") &&
|
||||||
ticketDetailPage.includes("`${ticketPath}/queue`") &&
|
ticketDetailPage.includes("const path = ticketPath") &&
|
||||||
|
ticketDetailPage.includes("`${path}/queue`") &&
|
||||||
!ticketDetailPage.includes("/merge-request/merge") &&
|
!ticketDetailPage.includes("/merge-request/merge") &&
|
||||||
ticketDetailPage.includes("mergeRequest.selector_from") &&
|
ticketDetailPage.includes("mergeRequest.selector_from") &&
|
||||||
ticketDetailPage.includes("mergeRequest.review_status") &&
|
ticketDetailPage.includes("mergeRequest.review_status") &&
|
||||||
@@ -402,7 +403,7 @@ Deno.test("Worker Console renders markdown only for message rows", async () => {
|
|||||||
consoleLine.includes("item.kind === 'tool'") &&
|
consoleLine.includes("item.kind === 'tool'") &&
|
||||||
consoleLine.includes("{#if isBashTool(item)}") &&
|
consoleLine.includes("{#if isBashTool(item)}") &&
|
||||||
consoleLine.includes(
|
consoleLine.includes(
|
||||||
"<AnsiText text={bodyTextAfterToolSummary(item)} />",
|
"<AnsiText text={toolBodyText(item)} />",
|
||||||
) &&
|
) &&
|
||||||
consoleLine.includes(
|
consoleLine.includes(
|
||||||
".console-line.tool-bash .console-plain-text",
|
".console-line.tool-bash .console-plain-text",
|
||||||
@@ -419,6 +420,30 @@ Deno.test("Worker Console renders markdown only for message rows", async () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("Worker Console expands uncapped tool body from the hover detail action", async () => {
|
||||||
|
const consoleLine = await Deno.readTextFile(
|
||||||
|
new URL("./ConsoleLineItem.svelte", import.meta.url),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
consoleLine.includes(
|
||||||
|
"return detailOpen ? (line.expandedBody ?? line.body) : line.body",
|
||||||
|
) &&
|
||||||
|
consoleLine.includes("line.toolCallLabel ?? line.toolCall?.name") &&
|
||||||
|
consoleLine.includes('class={`tool-status') &&
|
||||||
|
consoleLine.includes('class="tool-detail-button"') &&
|
||||||
|
consoleLine.includes("aria-expanded={detailOpen}") &&
|
||||||
|
consoleLine.includes("detailOpen = !detailOpen") &&
|
||||||
|
consoleLine.includes("item.detail && detailOpen") &&
|
||||||
|
consoleLine.includes('role="region"') &&
|
||||||
|
consoleLine.includes(".console-line:hover .tool-detail-button") &&
|
||||||
|
consoleLine.includes(".tool-detail-button:focus-visible") &&
|
||||||
|
consoleLine.includes("@media (hover: none)") &&
|
||||||
|
!consoleLine.includes('<details class="message-detail">'),
|
||||||
|
"Normal tool display should keep its preview while detail reveals the uncapped body and existing metadata",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("Worker Console renders Edit diffs without preformatted template gaps", async () => {
|
Deno.test("Worker Console renders Edit diffs without preformatted template gaps", async () => {
|
||||||
const consoleLine = await Deno.readTextFile(
|
const consoleLine = await Deno.readTextFile(
|
||||||
new URL("./ConsoleLineItem.svelte", import.meta.url),
|
new URL("./ConsoleLineItem.svelte", import.meta.url),
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
const initialData = untrack(() => data);
|
const initialData = untrack(() => data);
|
||||||
const loadedTicket = initialData.ticket.data;
|
const loadedTicket = initialData.ticket.data;
|
||||||
if (!loadedTicket) throw new Error(initialData.ticket.error ?? "ticket load failed");
|
if (!loadedTicket) throw new Error(initialData.ticket.error ?? "ticket load failed");
|
||||||
const loadedRepositories = initialData.repositories.data;
|
const loadedRepositories = $derived(data.repositories.data);
|
||||||
|
|
||||||
type QueueOutcome = {
|
type QueueOutcome = {
|
||||||
requested_ticket: string;
|
requested_ticket: string;
|
||||||
@@ -62,6 +62,8 @@
|
|||||||
let manualRuntimeId = $state("");
|
let manualRuntimeId = $state("");
|
||||||
let manualWorkerId = $state("");
|
let manualWorkerId = $state("");
|
||||||
let cancellationReason = $state("");
|
let cancellationReason = $state("");
|
||||||
|
let routeTicketSnapshot = `${initialData.ticketId}:${loadedTicket.item_revision}`;
|
||||||
|
let routeGeneration = 0;
|
||||||
const coderAssignment = $derived(
|
const coderAssignment = $derived(
|
||||||
ticket.assignments.find((assignment) => assignment.role === "coder") ?? null,
|
ticket.assignments.find((assignment) => assignment.role === "coder") ?? null,
|
||||||
);
|
);
|
||||||
@@ -95,13 +97,43 @@
|
|||||||
|
|
||||||
function applyTicket(updatedTicket: TicketDetail): void {
|
function applyTicket(updatedTicket: TicketDetail): void {
|
||||||
ticket = updatedTicket;
|
ticket = updatedTicket;
|
||||||
editTitle = ticket.title;
|
editTitle = updatedTicket.title;
|
||||||
editBody = ticket.body;
|
editBody = updatedTicket.body;
|
||||||
repositoryId = ticket.repository_id ?? "";
|
repositoryId = updatedTicket.repository_id ?? "";
|
||||||
refSelector = ticket.ref_selector ?? "";
|
refSelector = updatedTicket.ref_selector ?? "";
|
||||||
nextState = ticket.state;
|
nextState = updatedTicket.state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetTicketView(updatedTicket: TicketDetail): void {
|
||||||
|
applyTicket(updatedTicket);
|
||||||
|
editing = false;
|
||||||
|
transitionReason = "";
|
||||||
|
threadRole = "comment";
|
||||||
|
threadBody = "";
|
||||||
|
resolution = "";
|
||||||
|
busy = null;
|
||||||
|
errorMessage = null;
|
||||||
|
queueMessage = null;
|
||||||
|
readyOperationKey = null;
|
||||||
|
manualRuntimeId = "";
|
||||||
|
manualWorkerId = "";
|
||||||
|
cancellationReason = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const incomingTicketId = data.ticketId;
|
||||||
|
const incomingTicket = data.ticket.data;
|
||||||
|
if (!incomingTicket) return;
|
||||||
|
const incomingSnapshot = `${incomingTicketId}:${incomingTicket.item_revision}`;
|
||||||
|
|
||||||
|
untrack(() => {
|
||||||
|
if (incomingSnapshot === routeTicketSnapshot) return;
|
||||||
|
routeTicketSnapshot = incomingSnapshot;
|
||||||
|
routeGeneration += 1;
|
||||||
|
resetTicketView(incomingTicket);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
async function mutate(
|
async function mutate(
|
||||||
action: string,
|
action: string,
|
||||||
suffix: string,
|
suffix: string,
|
||||||
@@ -109,40 +141,51 @@
|
|||||||
method = "POST",
|
method = "POST",
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (busy) return false;
|
if (busy) return false;
|
||||||
|
const generation = routeGeneration;
|
||||||
|
const path = `${ticketPath}${suffix}`;
|
||||||
busy = action;
|
busy = action;
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
try {
|
try {
|
||||||
const path = `${ticketPath}${suffix}`;
|
|
||||||
const response = await workspaceApiJsonWithBody<TicketDetail>(path, {
|
const response = await workspaceApiJsonWithBody<TicketDetail>(path, {
|
||||||
method,
|
method,
|
||||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||||
});
|
});
|
||||||
|
if (generation !== routeGeneration) return false;
|
||||||
applyTicket(response);
|
applyTicket(response);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (generation === routeGeneration) {
|
||||||
errorMessage = error instanceof Error ? error.message : String(error);
|
errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
busy = null;
|
if (generation === routeGeneration) busy = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function queueTicket(): Promise<void> {
|
async function queueTicket(): Promise<void> {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
|
const generation = routeGeneration;
|
||||||
|
const path = ticketPath;
|
||||||
busy = "queue";
|
busy = "queue";
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
queueMessage = null;
|
queueMessage = null;
|
||||||
try {
|
try {
|
||||||
const outcome = await workspaceApiJsonWithBody<QueueOutcome>(
|
const outcome = await workspaceApiJsonWithBody<QueueOutcome>(
|
||||||
`${ticketPath}/queue`,
|
`${path}/queue`,
|
||||||
{ method: "POST", body: JSON.stringify({}) },
|
{ method: "POST", body: JSON.stringify({}) },
|
||||||
);
|
);
|
||||||
|
if (generation !== routeGeneration) return;
|
||||||
|
const updatedTicket = await workspaceApiJson<TicketDetail>(path);
|
||||||
|
if (generation !== routeGeneration) return;
|
||||||
queueMessage = `Queued ${outcome.queued_tickets.length} Ticket(s): ${outcome.queued_tickets.join(", ")}`;
|
queueMessage = `Queued ${outcome.queued_tickets.length} Ticket(s): ${outcome.queued_tickets.join(", ")}`;
|
||||||
applyTicket(await workspaceApiJson<TicketDetail>(ticketPath));
|
applyTicket(updatedTicket);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (generation === routeGeneration) {
|
||||||
errorMessage = error instanceof Error ? error.message : String(error);
|
errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
busy = null;
|
if (generation === routeGeneration) busy = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,11 +195,13 @@
|
|||||||
principal: Record<string, string>,
|
principal: Record<string, string>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
|
const generation = routeGeneration;
|
||||||
|
const path = ticketPath;
|
||||||
busy = action;
|
busy = action;
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
try {
|
try {
|
||||||
await workspaceApiJsonWithBody(
|
await workspaceApiJsonWithBody(
|
||||||
`${ticketPath}/assignments/${role}`,
|
`${path}/assignments/${role}`,
|
||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -166,11 +211,16 @@
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
applyTicket(await workspaceApiJson<TicketDetail>(ticketPath));
|
if (generation !== routeGeneration) return;
|
||||||
|
const updatedTicket = await workspaceApiJson<TicketDetail>(path);
|
||||||
|
if (generation !== routeGeneration) return;
|
||||||
|
applyTicket(updatedTicket);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (generation === routeGeneration) {
|
||||||
errorMessage = error instanceof Error ? error.message : String(error);
|
errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
busy = null;
|
if (generation === routeGeneration) busy = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { assert, assertStringIncludes } from "jsr:@std/assert";
|
||||||
|
|
||||||
|
const pageSource = await Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"../src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Deno.test("ticket detail synchronizes reused route data", () => {
|
||||||
|
const effectStart = pageSource.indexOf("$effect(() => {");
|
||||||
|
assert(effectStart >= 0, "ticket detail must react to reused route props");
|
||||||
|
|
||||||
|
const effectSource = pageSource.slice(effectStart);
|
||||||
|
for (
|
||||||
|
const token of [
|
||||||
|
"data.ticketId",
|
||||||
|
"data.ticket.data",
|
||||||
|
"incomingTicket.item_revision",
|
||||||
|
"routeGeneration += 1",
|
||||||
|
"resetTicketView(incomingTicket)",
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
assertStringIncludes(effectSource, token);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("ticket detail fences stale mutation responses", () => {
|
||||||
|
for (
|
||||||
|
const operation of [
|
||||||
|
"async function mutate(",
|
||||||
|
"async function queueTicket(",
|
||||||
|
"async function mutateAssignment(",
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
const operationStart = pageSource.indexOf(operation);
|
||||||
|
assert(operationStart >= 0, `missing ${operation}`);
|
||||||
|
const nextOperation = pageSource.indexOf(
|
||||||
|
"\n async function ",
|
||||||
|
operationStart + 1,
|
||||||
|
);
|
||||||
|
const operationSource = pageSource.slice(
|
||||||
|
operationStart,
|
||||||
|
nextOperation === -1 ? undefined : nextOperation,
|
||||||
|
);
|
||||||
|
assertStringIncludes(operationSource, "const generation = routeGeneration");
|
||||||
|
assertStringIncludes(operationSource, "generation !== routeGeneration");
|
||||||
|
assertStringIncludes(operationSource, "generation === routeGeneration");
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user