feat: add console turn navigation and pending previews

This commit is contained in:
2026-09-30 09:08:04 +09:00
parent 2006b390b4
commit 6d5d979012
26 changed files with 1145 additions and 959 deletions
+41 -2
View File
@@ -790,13 +790,17 @@ pub enum SubmissionDisposition {
Queued,
}
/// Bounded public projection of one pending submission. Payload segments and
/// provenance remain in the session log and are intentionally not exposed.
/// Bounded public projection of one pending submission. Only a short display
/// preview is exposed; full payload segments and provenance remain in the log.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct PendingSubmissionSummary {
pub submission_id: String,
/// Single-line display text, at most 240 Unicode scalars plus an ellipsis.
/// Absent on older runtimes. Never used to reconstruct the submitted input.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview: Option<String>,
pub accepted_at_ms: u64,
pub segment_count: u32,
pub byte_len: u64,
@@ -810,6 +814,10 @@ pub struct PendingSubmissionsSnapshot {
pub revision: u64,
#[serde(default)]
pub notification_count: u32,
/// Ordered display-only previews of waiting notifications, each bounded like
/// PendingSubmissionSummary::preview. Older runtimes omit this field.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notification_previews: Vec<String>,
#[serde(default)]
pub head_id: Option<String>,
#[serde(default)]
@@ -2852,6 +2860,37 @@ mod tests {
));
}
#[test]
fn pending_notification_previews_are_optional_and_roundtrip() {
let legacy = serde_json::json!({
"revision": 1, "notification_count": 1,
"head_id": "notification-head", "submissions": []
});
let mut snapshot: PendingSubmissionsSnapshot =
serde_json::from_value(legacy.clone()).unwrap();
assert!(snapshot.notification_previews.is_empty());
assert_eq!(serde_json::to_value(&snapshot).unwrap(), legacy);
snapshot.notification_previews = vec!["通知内容".into()];
let decoded: PendingSubmissionsSnapshot =
serde_json::from_value(serde_json::to_value(&snapshot).unwrap()).unwrap();
assert_eq!(decoded, snapshot);
}
#[test]
fn pending_submission_preview_is_optional_and_roundtrips() {
let legacy = serde_json::json!({
"submission_id": "queued-1", "accepted_at_ms": 1,
"segment_count": 1, "byte_len": 12
});
let mut summary: PendingSubmissionSummary = serde_json::from_value(legacy.clone()).unwrap();
assert_eq!(summary.preview, None);
assert_eq!(serde_json::to_value(&summary).unwrap(), legacy);
summary.preview = Some("確認 <b>text</b>".into());
let decoded: PendingSubmissionSummary =
serde_json::from_value(serde_json::to_value(&summary).unwrap()).unwrap();
assert_eq!(decoded, summary);
}
#[test]
fn snapshot_defaults_internal_workers_to_empty() {
let snapshot: Event = serde_json::from_value(serde_json::json!({
+2
View File
@@ -3533,9 +3533,11 @@ mod completion_flow_tests {
pending: protocol::PendingSubmissionsSnapshot {
revision: 3,
notification_count: 0,
notification_previews: vec![],
head_id: Some("submission-1".into()),
submissions: vec![protocol::PendingSubmissionSummary {
submission_id: "submission-1".into(),
preview: None,
accepted_at_ms: 7,
segment_count: 2,
byte_len: 42,
+4
View File
@@ -2098,9 +2098,11 @@ mod tests {
pending: protocol::PendingSubmissionsSnapshot {
revision: 2,
notification_count: 0,
notification_previews: vec![],
head_id: Some("submission-1".into()),
submissions: vec![protocol::PendingSubmissionSummary {
submission_id: "submission-1".into(),
preview: None,
accepted_at_ms: 1,
segment_count: 1,
byte_len: 6,
@@ -2153,9 +2155,11 @@ mod tests {
pending: protocol::PendingSubmissionsSnapshot {
revision: 2,
notification_count: 0,
notification_previews: vec![],
head_id: Some("submission-1".into()),
submissions: vec![protocol::PendingSubmissionSummary {
submission_id: "submission-1".into(),
preview: None,
accepted_at_ms: 1,
segment_count: 1,
byte_len: 6,
+2
View File
@@ -2166,9 +2166,11 @@ mod tests {
pending: protocol::PendingSubmissionsSnapshot {
revision: 1,
notification_count: 0,
notification_previews: vec![],
head_id: Some(id.into()),
submissions: vec![protocol::PendingSubmissionSummary {
submission_id: id.into(),
preview: None,
accepted_at_ms: 1,
segment_count: 1,
byte_len: 1,
+180
View File
@@ -187,12 +187,18 @@ impl PendingActivationState {
protocol::PendingSubmissionsSnapshot {
revision: self.revision,
notification_count: u32::try_from(self.pending_notifications.len()).unwrap_or(u32::MAX),
notification_previews: self
.pending_notifications
.iter()
.map(|notification| pending_input_preview(notification.message.chars()))
.collect(),
head_id,
submissions: self
.pending
.iter()
.map(|pending| protocol::PendingSubmissionSummary {
submission_id: pending.submission_id.clone(),
preview: Some(pending_submission_preview(&pending.input)),
accepted_at_ms: pending.accepted_at_ms,
segment_count: u32::try_from(pending.input.len()).unwrap_or(u32::MAX),
byte_len: submission_payload_len(&pending.input),
@@ -293,6 +299,55 @@ fn notification_head_id(source_namespace: &str, request_id: &str) -> String {
)
}
/// Display-only preview: no artifact reads, payload clones, or input mutation.
fn pending_submission_preview(input: &[Segment]) -> String {
let chars = input.iter().flat_map(|segment| {
let parts: [&str; 3] = match segment {
Segment::Text { content } | Segment::Paste { content, .. } => ["", content, ""],
Segment::UploadedFile { file } => ["[Attached file: ", &file.file_name, "]"],
Segment::PasteArtifact { .. } => ["[Large paste]", "", ""],
Segment::FileRef { path } => ["@", path, ""],
Segment::Flow { selector } => ["[Flow: ", selector, "]"],
Segment::Unknown => ["[Unknown input]", "", ""],
};
parts
.into_iter()
.flat_map(str::chars)
.chain(std::iter::once(' '))
});
pending_input_preview(chars)
}
fn pending_input_preview(chars: impl Iterator<Item = char>) -> String {
const LIMIT: usize = 240;
let mut preview = String::new();
let mut count = 0;
let mut space = false;
for ch in chars {
if ch.is_whitespace() || ch.is_control() {
space = !preview.is_empty();
continue;
}
if space && count < LIMIT {
preview.push(' ');
count += 1;
}
space = false;
if count == LIMIT {
preview.truncate(preview.trim_end().len());
preview.push('…');
return preview;
}
preview.push(ch);
count += 1;
}
if preview.is_empty() {
"[Empty input]".into()
} else {
preview
}
}
fn submission_payload_len(input: &[Segment]) -> u64 {
serde_json::to_vec(input)
.map(|bytes| u64::try_from(bytes.len()).unwrap_or(u64::MAX))
@@ -11219,6 +11274,131 @@ mod build_summary_prompt_tests {
);
}
#[test]
fn pending_notification_previews_are_bounded_ordered_and_survive_queue_cancel() {
let temp = tempfile::tempdir().unwrap();
let handle = PendingSubmissionHandle::for_test(temp.path());
let submit = handle
.accept("submit".into(), vec![Segment::text("queued")], false)
.unwrap();
let long = "界".repeat(1000);
handle
.accept_notification("n1".into(), "通知\n\t<b>内容</b>".into())
.unwrap();
handle
.accept_notification("n2".into(), long.clone())
.unwrap();
let before = handle.snapshot();
assert_eq!(before.notification_count, 2);
assert_eq!(
before.notification_previews,
vec![
"通知 <b>内容</b>".to_owned(),
format!("{}…", "界".repeat(240))
]
);
let after = handle
.cancel(&submit.submission_id, before.revision)
.unwrap();
assert!(after.submissions.is_empty());
assert_eq!(after.notification_previews, before.notification_previews);
let entries = handle.persisted_entries_for_test();
let restored: PendingActivationState = entries
.iter()
.rev()
.find_map(|entry| match entry {
LogEntry::Extension {
domain, payload, ..
} if domain == SESSION_PENDING_ACTIVATIONS_EXTENSION_DOMAIN => {
Some(serde_json::from_value(payload.clone()).unwrap())
}
_ => None,
})
.unwrap();
assert_eq!(
restored.snapshot().notification_previews,
before.notification_previews
);
let batch = handle.prepare_notification_batch();
assert_eq!(batch[0].0.message, "通知\n\t<b>内容</b>");
assert_eq!(batch[1].0.message, long);
assert_eq!(handle.snapshot().notification_count, 0);
assert!(handle.snapshot().notification_previews.is_empty());
}
#[test]
fn pending_submission_preview_is_single_line_bounded_and_display_only() {
let input = vec![
Segment::text(" 日本語\n\tの確認 "),
Segment::Paste {
id: 1,
chars: 4,
lines: 2,
content: "<b>\r\ntext</b>".into(),
},
Segment::FileRef {
path: "src/main.rs".into(),
},
];
assert_eq!(
pending_submission_preview(&input),
"日本語 の確認 <b> text</b> @src/main.rs"
);
assert_eq!(
pending_submission_preview(&[Segment::text("\n\t")]),
"[Empty input]"
);
assert_eq!(
pending_submission_preview(&[Segment::Unknown]),
"[Unknown input]"
);
assert_eq!(
pending_submission_preview(&[Segment::text("界".repeat(240))]),
"界".repeat(240)
);
let long = pending_submission_preview(&[Segment::text("界".repeat(10000))]);
assert_eq!(long, format!("{}…", "界".repeat(240)));
assert_eq!(long.chars().count(), 241);
let temp = tempfile::tempdir().unwrap();
let handle = PendingSubmissionHandle::for_test(temp.path());
handle
.accept("preview".into(), input.clone(), false)
.unwrap();
let snapshot = handle.snapshot();
assert_eq!(
snapshot.submissions[0].preview.as_deref(),
Some("日本語 の確認 <b> text</b> @src/main.rs")
);
let activation = handle.prepare_next_activation(None).unwrap().unwrap();
let PendingActivation::Submission(submission) = activation;
assert_eq!(submission.input, input);
}
#[test]
fn pending_submission_preview_labels_attachments_without_resolving_them() {
let file: protocol::UploadedFileRef = serde_json::from_value(serde_json::json!({
"artifact_id": "hidden-id", "file_name": "図\n面.png", "media_type": "image/png",
"created_at_ms": 1, "availability": "available", "byte_len": 20,
"sha256": "hidden-digest"
}))
.unwrap();
assert_eq!(
pending_submission_preview(&[Segment::UploadedFile { file }]),
"[Attached file: 図 面.png]"
);
let artifact: protocol::PasteArtifactRef = serde_json::from_value(serde_json::json!({
"artifact_id": "hidden-id", "created_at_ms": 1, "media_type": "text_plain_utf8",
"availability": "available", "byte_len": 500, "char_count": 500, "line_count": 10,
"sha256": "hidden-digest", "source_entry_id": "hidden-entry"
}))
.unwrap();
assert_eq!(
pending_submission_preview(&[Segment::PasteArtifact { artifact }]),
"[Large paste]"
);
}
#[test]
fn pending_submission_queue_is_durable_idempotent_and_bounded() {
let temp = tempfile::tempdir().unwrap();
+1 -1
View File
@@ -6,7 +6,7 @@
"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",
"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,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.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-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.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/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
"test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.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-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.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/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
"test:component": "deno run -A npm:vitest@3.2.4 run --config vitest.config.ts",
"build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview",
File diff suppressed because one or more lines are too long
+12 -2
View File
@@ -149,9 +149,19 @@ entry_id: string,
*/
timestamp: number, provenance: SessionEntryProvenance, derived_from?: Array<string>, } & ({ "kind": "user_input", segments: Array<Segment>, } | { "kind": "message", role: SessionMessageRole, content: Array<SessionContentPart>, } | { "kind": "tool_call", call_id: string, name: string, arguments: string, } | { "kind": "tool_result", call_id: string, summary: string, content?: string | null, is_error: boolean, attachments?: Array<SessionToolAttachment>, } | { "kind": "system_item", item_kind: string, content: string, data?: unknown, } | { "kind": "run_error", message: string, });
export type PendingSubmissionSummary = { submission_id: string, accepted_at_ms: number, segment_count: number, byte_len: number, };
export type PendingSubmissionSummary = { submission_id: string,
/**
* Single-line display text, at most 240 Unicode scalars plus an ellipsis.
* Absent on older runtimes. Never used to reconstruct the submitted input.
*/
preview?: string | null, accepted_at_ms: number, segment_count: number, byte_len: number, };
export type PendingSubmissionsSnapshot = { revision: number, notification_count: number, head_id: string | null, submissions: Array<PendingSubmissionSummary>, };
export type PendingSubmissionsSnapshot = { revision: number, notification_count: number,
/**
* Ordered display-only previews of waiting notifications, each bounded like
* PendingSubmissionSummary::preview. Older runtimes omit this field.
*/
notification_previews?: Array<string>, head_id: string | null, submissions: Array<PendingSubmissionSummary>, };
export type SubmissionDisposition = "started" | "queued";
@@ -571,8 +571,8 @@
.composer-input :global(.cm-content) {
min-height: 2.65rem;
padding: 0.55rem 0.65rem 0.35rem;
line-height: 1.45;
padding: var(--space-2) var(--space-2) var(--space-1);
line-height: var(--line-height-body);
}
.composer-input :global(.cm-line) {
@@ -583,8 +583,8 @@
display: inline-flex;
align-items: center;
max-width: min(26rem, 70vw);
margin: 0 0.15rem;
padding: 0.08rem 0.42rem;
margin: 0 var(--space-1);
padding: 0 var(--space-2);
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--accent) 42%, var(--line));
border-radius: 999px;
@@ -592,7 +592,7 @@
color: var(--text-muted);
font-size: var(--font-size-compact);
font-weight: 600;
line-height: 1.35;
line-height: var(--line-height-compact);
text-overflow: ellipsis;
vertical-align: baseline;
white-space: nowrap;
@@ -102,12 +102,13 @@
flex: 0 0 auto;
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.4rem 0.75rem;
padding: var(--space-1) var(--space-3);
background: var(--bg);
color: var(--text-strong);
cursor: pointer;
font: inherit;
font-size: var(--font-size-compact);
line-height: var(--line-height-compact);
font-weight: 700;
}
@@ -157,7 +157,7 @@
<style>
.console-line {
min-width: 0;
padding: 0.2rem 0;
padding: var(--space-1) 0;
background: transparent;
}
@@ -172,14 +172,14 @@
.user-message {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.55rem;
gap: var(--space-2);
align-items: start;
}
.user-prompt {
color: var(--tui-green);
font-weight: 700;
line-height: 1.55;
line-height: var(--line-height-body);
}
.activity-summary,
@@ -187,7 +187,7 @@
margin: 0;
color: var(--text-muted);
font-size: var(--font-size-compact);
line-height: 1.55;
line-height: var(--line-height-compact);
white-space: pre-line;
}
@@ -198,20 +198,23 @@
color: var(--text-muted);
font-family: var(--font-mono);
font-size: var(--font-size-compact);
line-height: var(--line-height-compact);
font-variant-numeric: tabular-nums;
font-weight: 750;
}
.compaction-activity {
margin: var(--space-1) 0;
padding-left: 1.25rem;
padding-left: var(--space-4);
color: var(--tui-dark-gray);
font-family: var(--font-mono);
font-size: var(--font-size-compact);
line-height: var(--line-height-compact);
}
.activity-summary {
font-size: var(--font-size-body);
line-height: var(--line-height-body);
}
.task-reminder-summary {
@@ -229,6 +232,7 @@
color: var(--text-muted);
font-family: var(--font-mono);
font-size: var(--font-size-compact);
line-height: var(--line-height-compact);
font-variant-numeric: tabular-nums;
text-align: right;
white-space: nowrap;
@@ -249,9 +253,9 @@
}
.console-plain-text {
margin: 0.45rem 0;
margin: var(--space-2) 0;
color: inherit;
line-height: 1.55;
line-height: var(--line-height-body);
overflow-wrap: anywhere;
white-space: pre-wrap;
}
@@ -271,7 +275,7 @@
margin: 0;
font-family: var(--font-mono);
font-size: var(--font-size-compact);
line-height: 1.1;
line-height: var(--line-height-compact);
overflow-x: auto;
white-space: pre;
}
@@ -287,9 +291,10 @@
.tool-summary {
display: flex;
align-items: baseline;
gap: 0.5rem;
gap: var(--space-2);
color: var(--text-muted);
font-size: var(--font-size-compact);
line-height: var(--line-height-compact);
font-weight: 750;
}
@@ -306,6 +311,7 @@
flex: 0 0 auto;
color: var(--tui-dark-gray);
font-size: var(--font-size-compact);
line-height: var(--line-height-compact);
white-space: nowrap;
}
@@ -334,6 +340,7 @@
gap: var(--space-2);
color: var(--text-muted);
font-size: var(--font-size-compact);
line-height: var(--line-height-compact);
font-weight: 750;
}
@@ -344,10 +351,10 @@
color: var(--text);
font-family: var(--font-mono);
font-size: var(--font-size-compact);
line-height: 1.45;
margin: 0.6rem 0 0;
line-height: var(--line-height-compact);
margin: var(--space-2) 0 0;
overflow-x: auto;
padding: 0.45rem 0;
padding: var(--space-2) 0;
}
.diff-line {
@@ -373,13 +380,13 @@
.diff-gutter,
.diff-marker {
color: var(--tui-gray);
padding: 0 0.5rem;
padding: 0 var(--space-2);
text-align: right;
user-select: none;
}
.diff-content {
padding-right: 0.75rem;
padding-right: var(--space-3);
white-space: pre;
}
@@ -395,12 +402,13 @@
margin-inline-start: auto;
border: 1px solid var(--line);
border-radius: 0.35rem;
padding: 0.08rem 0.35rem;
padding: 0 var(--space-1);
background: var(--bg-raised);
color: var(--text-muted);
cursor: pointer;
font: inherit;
font-size: var(--font-size-compact);
line-height: var(--line-height-compact);
font-weight: 750;
opacity: 0;
pointer-events: none;
@@ -415,15 +423,17 @@
}
.message-detail {
margin-top: 0.35rem;
margin-top: var(--space-1);
border-left: 2px solid var(--line);
padding-left: 0.6rem;
padding-left: var(--space-2);
color: var(--text-muted);
font-size: var(--font-size-compact);
line-height: var(--line-height-compact);
}
.message-detail p {
margin: 0;
line-height: inherit;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
@@ -121,11 +121,11 @@
.task-mini {
display: grid;
gap: 0.1rem;
gap: var(--space-1);
min-width: 0;
padding-inline: 0.75rem;
padding-inline: var(--space-3);
font-size: var(--font-size-compact);
line-height: 1.35;
line-height: var(--line-height-compact);
}
.task-mini-row,
@@ -133,7 +133,7 @@
.task-summary-row {
display: flex;
min-width: 0;
gap: 0.5rem;
gap: var(--space-2);
}
.task-summary-row {
@@ -230,12 +230,12 @@
min-width: 0;
min-height: 0;
overflow: auto;
padding-inline: 1rem;
padding-inline: var(--space-4);
border-left: 1px solid var(--line);
}
.task-pane h3 {
margin: 0 0 1rem;
margin: 0 0 var(--space-4);
color: var(--accent);
font-size: var(--font-size-body);
}
@@ -246,7 +246,7 @@
.task-list {
display: grid;
gap: 1rem;
gap: var(--space-4);
margin: 0;
padding: 0;
list-style: none;
@@ -1,185 +0,0 @@
<script lang="ts">
type TimelineKind = 'turn' | 'assistant';
export type TimelineMark = {
id: string;
lineId: string;
label: string;
detail: string;
timeLabel: string;
position: number;
sourcePosition: number;
kind: TimelineKind;
};
type Props = {
marks: TimelineMark[];
thumbStyle: string;
axisStyle: string;
expanded: boolean;
onRailPointerDown: (event: PointerEvent) => void;
onMarkClick: (mark: TimelineMark) => void;
};
let {
marks,
thumbStyle,
axisStyle,
expanded,
onRailPointerDown,
onMarkClick,
}: Props = $props();
</script>
<aside class="console-timeline" aria-label="Console timeline">
<div class="timeline-axis" style={axisStyle}>
<button
type="button"
class="timeline-rail"
aria-label="Scroll console"
onpointerdown={onRailPointerDown}
>
<span class="timeline-thumb" style={thumbStyle}></span>
</button>
<div class="timeline-marks" aria-label="Timeline marks">
{#each marks as mark (mark.id)}
<button
type="button"
class={`timeline-mark ${mark.kind} ${expanded ? 'expanded' : 'folded'}`}
style={`top: ${mark.position}px`}
aria-label={mark.detail ? `${mark.label}: ${mark.detail}` : mark.label}
onclick={() => onMarkClick(mark)}
>
<span class="timeline-dot"></span>
<span class="timeline-card">
<span>{mark.label}</span>
</span>
</button>
{/each}
</div>
</div>
</aside>
<style>
.console-timeline {
--timeline-axis-padding: 3rem;
position: relative;
grid-column: 2;
grid-row: 2;
width: 100%;
min-width: 0;
min-height: 0;
height: 100%;
opacity: 1;
}
.timeline-axis {
position: absolute;
right: 0;
left: 0;
}
.timeline-rail,
.timeline-marks {
position: absolute;
top: 0;
bottom: 0;
left: 0;
}
.timeline-rail {
width: 0.65rem;
border: 0;
border-radius: 999px;
background: color-mix(in srgb, var(--line) 70%, transparent);
cursor: ns-resize;
padding: 0;
touch-action: none;
user-select: none;
}
.timeline-thumb {
position: absolute;
left: 0;
width: 100%;
min-height: 1.25rem;
border-radius: inherit;
background: var(--tui-cyan);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--tui-cyan) 30%, transparent);
pointer-events: none;
}
.timeline-marks {
width: 12rem;
pointer-events: none;
}
.timeline-mark {
position: absolute;
left: 0;
display: flex;
width: 11.5rem;
align-items: center;
gap: var(--space-2);
border: 0;
background: transparent;
color: var(--text-muted);
cursor: pointer;
padding: 0;
pointer-events: auto;
transform: translateY(-50%);
}
.timeline-dot {
width: 0.6rem;
height: 0.6rem;
flex: 0 0 auto;
border: 1px solid var(--bg);
border-radius: 999px;
background: var(--tui-gray);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--line) 80%, transparent);
}
.timeline-mark.turn .timeline-dot {
background: var(--tui-blue);
}
.timeline-mark.assistant .timeline-dot {
background: var(--tui-green);
}
.timeline-card {
display: none;
width: 10rem;
min-width: 0;
grid-template-columns: minmax(0, 1fr);
justify-items: start;
text-align: left;
border: 1px solid var(--line);
border-radius: 10px;
background: color-mix(in srgb, var(--bg-raised) 92%, transparent);
box-shadow: var(--shadow-overlay);
opacity: 1;
padding: 0.25rem 0.45rem;
transform: none;
}
.timeline-mark.expanded .timeline-card {
display: grid;
}
.timeline-card span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.timeline-mark.turn .timeline-card {
border-color: color-mix(in srgb, var(--tui-blue) 55%, var(--line));
}
.timeline-mark.assistant .timeline-card {
border-color: color-mix(in srgb, var(--tui-green) 55%, var(--line));
}
</style>
@@ -0,0 +1,68 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render } from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import ConsoleTurnNavigation from "./ConsoleTurnNavigation.svelte";
const turns = [
{ id: "u1", user: "First question", assistant: "First answer" },
{ id: "u2", user: "Second question", assistant: "" },
];
afterEach(cleanup);
test("renders one bar per turn and previews both roles on hover", async () => {
const view = render(ConsoleTurnNavigation, { turns, onSelect: vi.fn() });
expect(view.getAllByRole("button")).toHaveLength(2);
expect(view.queryByRole("tooltip")).toBeNull();
const button = view.getAllByRole("button")[0];
await fireEvent.mouseEnter(button);
const tooltip = view.getByRole("tooltip");
expect(tooltip.textContent).toContain("First question");
expect(tooltip.textContent).toContain("First answer");
expect(button.getAttribute("aria-describedby")).toBe(tooltip.id);
await fireEvent.mouseLeave(view.getByRole("navigation"));
expect(view.queryByRole("tooltip")).toBeNull();
});
test("focus previews unanswered turns, click selects the user anchor, Escape dismisses", async () => {
const onSelect = vi.fn();
const view = render(ConsoleTurnNavigation, { turns, onSelect });
const button = view.getAllByRole("button")[1];
await fireEvent.focus(button);
expect(view.getByRole("tooltip").textContent).toContain("No response yet");
await fireEvent.click(button);
expect(onSelect).toHaveBeenCalledWith("u2");
await fireEvent.keyDown(window, { key: "Escape" });
expect(view.queryByRole("tooltip")).toBeNull();
await fireEvent.blur(button);
await fireEvent.focus(button);
expect(view.getByRole("tooltip")).toBeTruthy();
await fireEvent.blur(button);
expect(view.queryByRole("tooltip")).toBeNull();
});
test("updates the visible response in place and removes stale previews on history replacement", async () => {
const onSelect = vi.fn();
const view = render(ConsoleTurnNavigation, { turns, onSelect });
await fireEvent.mouseEnter(view.getAllByRole("button")[1]);
await view.rerender({ turns: [turns[0], { ...turns[1], assistant: "Streaming answer" }], onSelect });
expect(view.getByRole("tooltip").textContent).toContain("Streaming answer");
expect(view.getAllByRole("button")).toHaveLength(2);
await view.rerender({ turns: [], onSelect });
expect(view.queryByRole("tooltip")).toBeNull();
expect(view.queryAllByRole("button")).toHaveLength(0);
});
test("preview content stays text and scrolling the rail dismisses its old position", async () => {
const view = render(ConsoleTurnNavigation, {
turns: [{ id: "u", user: "<img src=x onerror=alert(1)>", assistant: "<script>bad()</script>" }],
onSelect: vi.fn(),
});
await fireEvent.mouseEnter(view.getByRole("button"));
const tooltip = view.getByRole("tooltip");
expect(tooltip.querySelector("img, script")).toBeNull();
expect(tooltip.textContent).toContain("<script>bad()</script>");
await fireEvent.scroll(view.container.querySelector(".turn-bars")!);
expect(view.queryByRole("tooltip")).toBeNull();
});
@@ -0,0 +1,166 @@
<script lang="ts">
import type { ConsoleTurn } from './turn-navigation';
let { turns, onSelect }: {
turns: ConsoleTurn[];
onSelect: (id: string) => void;
} = $props();
const tooltipId = $props.id();
let hoveredId = $state<string | null>(null);
let focusedId = $state<string | null>(null);
let dismissed = $state(false);
let anchorY = $state(0);
let railHeight = $state(0);
let previewHeight = $state(0);
let rail: HTMLElement;
const preview = $derived(dismissed ? undefined : turns.find(
(turn) => turn.id === (hoveredId ?? focusedId),
));
const previewTop = $derived(Math.max(0, Math.min(anchorY, railHeight - previewHeight)));
function show(event: MouseEvent | FocusEvent, id: string) {
const button = event.currentTarget as HTMLElement;
anchorY = button.getBoundingClientRect().top - rail.getBoundingClientRect().top;
dismissed = false;
if (event.type === 'focus') focusedId = id;
else hoveredId = id;
}
</script>
<svelte:window onkeydown={(event) => { if (event.key === 'Escape') dismissed = true; }} />
<nav
class="turn-navigation"
aria-label="Conversation turns"
bind:this={rail}
bind:clientHeight={railHeight}
onmouseleave={() => { hoveredId = null; }}
>
<div class="turn-bars" onscroll={() => { dismissed = true; }}>
{#each turns as turn, index (turn.id)}
<button
type="button"
class="turn-button"
class:previewing={preview?.id === turn.id}
aria-label={`Turn ${index + 1}: ${turn.user || 'User message'}`}
aria-describedby={preview?.id === turn.id ? tooltipId : undefined}
onmouseenter={(event) => show(event, turn.id)}
onfocus={(event) => show(event, turn.id)}
onblur={() => { focusedId = null; }}
onclick={() => onSelect(turn.id)}
><span class="turn-bar" aria-hidden="true"></span></button>
{/each}
</div>
{#if preview}
<div
class="turn-preview-position"
style:top={`${previewTop}px`}
bind:clientHeight={previewHeight}
>
<div class="turn-preview" id={tooltipId} role="tooltip">
<p class="preview-user">{preview.user || '—'}</p>
<p class="preview-assistant">{preview.assistant || 'No response yet'}</p>
</div>
</div>
{/if}
</nav>
<style>
.turn-navigation {
position: relative;
min-height: 0;
min-width: 0;
}
.turn-bars {
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
overflow-y: auto;
scrollbar-width: none;
overscroll-behavior: contain;
}
.turn-bars::-webkit-scrollbar { display: none; }
/* Center short lists without making overflowing turns unreachable. */
.turn-button:first-child { margin-top: auto; }
.turn-button:last-child { margin-bottom: auto; }
.turn-button {
display: grid;
place-items: center;
flex: 0 0 24px;
width: 32px;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
cursor: pointer;
}
.turn-bar {
width: 18px;
height: 3px;
border-radius: 999px;
background: var(--text-muted);
opacity: 0.5;
}
.turn-button.previewing .turn-bar,
.turn-button:hover .turn-bar,
.turn-button:focus-visible .turn-bar {
width: 26px;
background: var(--tui-cyan);
opacity: 1;
}
.turn-button:focus-visible {
outline: 1px solid var(--tui-cyan);
outline-offset: -1px;
}
.turn-preview-position {
position: absolute;
z-index: 20;
right: 100%;
width: min(24rem, calc(100vw - 5rem));
max-height: 100%;
overflow: hidden;
padding-right: var(--space-2);
box-sizing: border-box;
}
.turn-preview {
display: grid;
gap: var(--space-2);
padding: var(--space-3);
border: 1px solid var(--line);
border-radius: 8px;
background: var(--bg-raised);
color: var(--text-strong);
box-shadow: var(--shadow-overlay);
font-size: var(--font-size-body);
line-height: var(--line-height-body);
}
.turn-preview p { margin: 0; min-width: 0; line-height: inherit; }
.preview-user {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.preview-assistant {
color: var(--text-muted);
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
line-clamp: 3;
overflow: hidden;
overflow-wrap: anywhere;
}
</style>
@@ -55,7 +55,7 @@
<style>
.rich-markdown {
color: inherit;
line-height: 1.55;
line-height: var(--line-height-body);
}
:global(.rich-markdown > :first-child) {
@@ -70,18 +70,18 @@
:global(.rich-markdown ul),
:global(.rich-markdown blockquote),
:global(.rich-markdown pre) {
margin: 0.45rem 0;
margin: var(--space-2) 0;
}
:global(.rich-markdown ul) {
padding-left: 1.2rem;
padding-left: var(--space-4);
}
:global(.rich-markdown h1),
:global(.rich-markdown h2),
:global(.rich-markdown h3),
:global(.rich-markdown h4) {
margin: 0.7rem 0 0.35rem;
margin: var(--space-3) 0 var(--space-1);
color: var(--text-strong);
font-size: var(--font-size-body);
}
@@ -89,7 +89,7 @@
:global(.rich-markdown blockquote) {
border-left: 2px solid var(--tui-dark-gray);
color: var(--text-muted);
padding-left: 0.8rem;
padding-left: var(--space-3);
}
:global(.rich-markdown :not(pre) > code) {
@@ -97,7 +97,7 @@
border: 1px solid var(--line);
border-radius: 0.35rem;
color: var(--tui-blue);
padding: 0.05rem 0.28rem;
padding: 0 var(--space-1);
}
:global(.rich-markdown a) {
@@ -109,6 +109,6 @@
border: 1px solid var(--line);
border-radius: 0.65rem;
overflow: auto;
padding: 0.75rem;
padding: var(--space-3);
}
</style>
@@ -67,11 +67,12 @@
.worker-run-status {
display: flex;
align-items: center;
gap: 0.42rem;
gap: var(--space-2);
min-height: 1.35rem;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: var(--font-size-compact);
line-height: var(--line-height-compact);
font-variant-numeric: tabular-nums;
}
</style>
@@ -15,7 +15,6 @@ import {
resolveConsoleViewScrollTop,
resolveConsoleWorkerView,
segmentsToText,
selectConsoleTimelineLines,
} from "./model.ts";
import { workerConsoleHref } from "../resource-links.ts";
@@ -2229,25 +2228,6 @@ Deno.test("projectConsole reseeds visible rows from segment rotation", () => {
);
});
Deno.test("selectConsoleTimelineLines keeps all users and only last assistant per turn", () => {
const items = [
consoleLine("u1", "user"),
consoleLine("a1", "assistant"),
consoleLine("tool1", "tool"),
consoleLine("a2", "assistant"),
consoleLine("u2", "user"),
consoleLine("a3", "assistant"),
consoleLine("a4", "assistant"),
];
assertEquals(
selectConsoleTimelineLines(items).map(({ item, index }) =>
`${index}:${item.id}`
),
["0:u1", "3:a2", "4:u2", "6:a4"],
);
});
Deno.test("projectConsole relativizes known tool path displays from snapshot cwd", () => {
const projection = projectConsole([
{ eventId: "cwd-0", event: snapshotEvent("/repo") },
@@ -176,39 +176,6 @@ export type ConsoleProjection = {
removedInternalWorkers: Record<string, number>;
};
export type ConsoleTimelineLineSelection = {
item: ConsoleLine;
index: number;
};
export function selectConsoleTimelineLines(
items: ConsoleLine[],
): ConsoleTimelineLineSelection[] {
const selected: ConsoleTimelineLineSelection[] = [];
let lastAssistant: ConsoleTimelineLineSelection | null = null;
const flushAssistant = () => {
if (lastAssistant) {
selected.push(lastAssistant);
lastAssistant = null;
}
};
items.forEach((item, index) => {
if (item.kind === "user") {
flushAssistant();
selected.push({ item, index });
return;
}
if (item.kind === "assistant") {
lastAssistant = { item, index };
}
});
flushAssistant();
return selected;
}
export type WorkerTarget = {
runtime_id: string;
worker_id: string;
@@ -0,0 +1,77 @@
declare const Deno: { test(name: string, fn: () => void): void };
import type { ConsoleLine } from "./model.ts";
import { consoleTurns } from "./turn-navigation.ts";
function line(id: string, kind: ConsoleLine["kind"], body: string, title = kind): ConsoleLine {
return { id, kind, body, title, source: "event" };
}
function equal(actual: unknown, expected: unknown) {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(`Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
}
Deno.test("turn navigation pairs each user with the final assistant report, excluding progress, tools and thinking", () => {
equal(consoleTurns([
line("orphan", "assistant", "Earlier response"),
line("u1", "user", "First\n question"),
line("a1", "assistant", "Checking now."),
line("t", "tool", "Tool output"),
line("progress", "assistant", "Still working."),
line("thinking", "thinking", "Private reasoning"),
line("a2", "assistant", "Here is the answer."),
line("empty", "assistant", " \n "),
line("stats", "run_stats", "Tokens"),
line("u2", "user", "Second question"),
line("a3", "assistant", "Second answer"),
]), [
{ id: "u1", user: "First question", assistant: "Here is the answer." },
{ id: "u2", user: "Second question", assistant: "Second answer" },
]);
});
Deno.test("unanswered and consecutive user messages retain their own bars", () => {
equal(consoleTurns([
line("u1", "user", "One"),
line("u2", "user", "Two"),
line("system", "system", "Not an answer"),
]), [
{ id: "u1", user: "One", assistant: "" },
{ id: "u2", user: "Two", assistant: "" },
]);
equal(consoleTurns([]), []);
});
Deno.test("streaming updates change the preview without creating another turn", () => {
const user = line("u", "user", "Question");
const answer = { ...line("a", "assistant", "Hi"), streaming: true };
equal(consoleTurns([user, answer]), [{ id: "u", user: "Question", assistant: "Hi" }]);
equal(consoleTurns([user, { ...answer, body: "Hi there", streaming: false }]), [
{ id: "u", user: "Question", assistant: "Hi there" },
]);
equal(answer.body, "Hi");
});
Deno.test("snapshot in-flight assistant text is previewed but in-flight thinking is not", () => {
equal(consoleTurns([
line("u", "user", "Question"),
{ ...line("reasoning", "in_flight", "Private"), title: "in-flight thinking" },
{ ...line("text", "in_flight", "Answer in progress"), title: "in-flight assistant text" },
]), [{ id: "u", user: "Question", assistant: "Answer in progress" }]);
});
Deno.test("turn preview text is bounded and recomputed on view or snapshot replacement", () => {
const [turn] = consoleTurns([
line("u", "user", "x".repeat(2000)),
line("a", "assistant", "y".repeat(2000)),
line("b", "assistant", "z".repeat(2000)),
]);
equal(turn.user, "x".repeat(1200) + "…");
equal(turn.assistant, "z".repeat(1200) + "…");
equal(consoleTurns([line("other", "user", "Other view")]), [
{ id: "other", user: "Other view", assistant: "" },
]);
equal(consoleTurns([]), []);
});
@@ -0,0 +1,36 @@
import type { ConsoleLine } from "./model.ts";
export type ConsoleTurn = {
id: string;
user: string;
assistant: string;
};
const PREVIEW_LIMIT = 1200;
function previewText(text: string): string {
const compact = text.replace(/\s+/g, " ").trim();
return compact.length > PREVIEW_LIMIT
? `${compact.slice(0, PREVIEW_LIMIT)}…`
: compact;
}
/** One user message and its last nonempty assistant message before the next user. */
export function consoleTurns(lines: readonly ConsoleLine[]): ConsoleTurn[] {
const turns: ConsoleTurn[] = [];
let current: ConsoleTurn | undefined;
for (const line of lines) {
if (line.kind === "user") {
current = { id: line.id, user: previewText(line.body), assistant: "" };
turns.push(current);
} else if (
current &&
(line.kind === "assistant" ||
(line.kind === "in_flight" && line.title === "in-flight assistant text"))
) {
const assistant = previewText(line.body);
if (assistant) current.assistant = assistant;
}
}
return turns;
}
@@ -670,7 +670,7 @@ Deno.test("Worker Console renders markdown only for message rows", async () => {
".console-line.tool.tool-bash .console-plain-text",
) &&
consoleLine.includes("font-size: var(--font-size-compact);") &&
consoleLine.includes("line-height: 1.1;") &&
consoleLine.includes("line-height: var(--line-height-compact);") &&
consoleLine.includes("{:else if shouldRenderMarkdown(item)}") &&
consoleLine.includes(
"<RichMarkdown text={item.body || '—'} streamId={item.id} />",
@@ -720,38 +720,109 @@ Deno.test("Worker Console renders Edit diffs without preformatted template gaps"
);
});
Deno.test("Worker Console exposes a foldable timeline beside the scroll body", async () => {
Deno.test("Console spacing and text metrics use existing design tokens", async () => {
const files = [
"ComposerInput.svelte", "ConsoleLineItem.svelte", "RichMarkdown.svelte",
"ConsoleTasks.svelte", "WorkerRunStatus.svelte", "ConsoleDisplayState.svelte",
"ConsoleTurnNavigation.svelte",
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
];
for (const file of files) {
const source = await Deno.readTextFile(new URL(file, import.meta.url));
const css = source.split("<style>")[1];
assert(
!/(?:gap|padding(?:-[\w]+)?|margin(?:-[\w]+)?)\s*:[^;]*\d(?:\.\d+)?rem/.test(css),
`${file} should use shared spacing tokens, not a local rem spacing scale`,
);
assert(
!/line-height:\s*\d/.test(css),
`${file} should use the body/compact line-height tokens`,
);
}
const navigation = await Deno.readTextFile(new URL("./ConsoleTurnNavigation.svelte", import.meta.url));
assert(
navigation.includes("box-shadow: var(--shadow-overlay)") &&
navigation.includes("font-size: var(--font-size-body)") &&
navigation.includes("line-height: var(--line-height-body)") &&
navigation.includes("gap: var(--space-2)") &&
navigation.includes("padding: var(--space-3)") &&
navigation.includes("line-height: inherit"),
"Preview text should use body typography, shared spacing and overlay shadow",
);
const page = await Deno.readTextFile(new URL(files.at(-1)!, import.meta.url));
assert(
page.includes("grid-template-columns: minmax(0, 1fr) max-content"),
"The turn column should fit the button instead of independently sizing to 2rem",
);
});
Deno.test("Console and app shell keep compact layout spacing", async () => {
const layout = await Deno.readTextFile(
new URL("./../../../routes/+layout.svelte", import.meta.url),
);
const page = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
import.meta.url,
),
);
const mainRules = [...layout.matchAll(/^\s*\.app-shell__main\s*\{([^}]+)\}/gm)];
assert(
mainRules.length === 2 &&
mainRules.every((rule) => rule[1].includes("padding: var(--space-4);")),
"Desktop and mobile main padding should both use 16px",
);
const shell = page.match(/\.worker-console-shell\s*\{([^}]+)\}/)?.[1] ?? "";
assert(
shell.includes("gap: var(--space-2);") &&
shell.includes("height: calc(100dvh - (var(--space-4) * 2));"),
"Console should use an 8px gap and account for the 16px main padding",
);
});
Deno.test("Worker Console uses turn bars with hidden scrollbars and preserves scrolling", async () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
import.meta.url,
),
);
const consoleLine = await Deno.readTextFile(
new URL("./ConsoleLineItem.svelte", import.meta.url),
);
const consoleTimeline = await Deno.readTextFile(
new URL("./ConsoleTimeline.svelte", import.meta.url),
const navigation = await Deno.readTextFile(new URL("./ConsoleTurnNavigation.svelte", import.meta.url));
assert(
!/timeline/i.test(consolePage) &&
consolePage.includes("const turns = $derived(consoleTurns(lines))") &&
consolePage.includes("<ConsoleTurnNavigation {turns} onSelect={selectConsoleTurn}") &&
consolePage.includes("CSS.escape(id)") &&
consolePage.includes("scrollbar-width: none") &&
consolePage.includes("::-webkit-scrollbar"),
"Console should replace the scrollbar with conversation turn navigation, not the former timeline",
);
assert(
consoleTimeline.includes('class="console-timeline"') &&
consolePage.includes("timelineMarks") &&
consolePage.includes("jumpToTimelineMark") &&
consoleLine.includes("data-console-line-id={item.id}") &&
consolePage.includes("class:timeline-open={timelineOpen}") &&
consolePage.includes('class="timeline-fold"') &&
consolePage.includes("expanded={timelineOpen}") &&
!consolePage.includes("{#if timelineOpen}") &&
consolePage.includes("handleTimelineRailPointerDown") &&
consolePage.includes("projectTimelineAxisPosition") &&
consolePage.includes("scrollbar-width: none") &&
consoleTimeline.includes("onpointerdown={onRailPointerDown}") &&
consoleTimeline.includes("expanded ? 'expanded' : 'folded'") &&
consoleTimeline.includes(".timeline-mark.expanded .timeline-card") &&
!consoleTimeline.includes("{#if expanded}") &&
consoleTimeline.includes(".timeline-thumb") &&
consoleTimeline.includes(".timeline-card"),
"Worker Console should expose a foldable timeline with scroll and line jump markers",
navigation.includes("{#each turns as turn, index (turn.id)}") &&
navigation.includes('class="turn-bar"') &&
navigation.includes("onmouseenter=") &&
navigation.includes("onfocus=") &&
navigation.includes("onclick={() => onSelect(turn.id)}") &&
navigation.includes('role="tooltip"') &&
navigation.includes("aria-describedby=") &&
navigation.includes("event.key === 'Escape'") &&
navigation.includes("text-overflow: ellipsis") &&
navigation.includes("-webkit-line-clamp: 3") &&
navigation.includes("railHeight - previewHeight") &&
!navigation.includes("{@html"),
"Turn bars need keyboard and hover previews, one user line, three AI lines, bounded positioning and escaped content",
);
assert(
consolePage.includes('class="console-scroll"') &&
consolePage.includes("overflow-y: auto") &&
consolePage.includes("onscroll={handleConsoleScroll}") &&
consolePage.includes("autoFollowConsole = isNearConsoleBottom(consoleBodyElement)") &&
consolePage.includes("void scrollConsoleToBottom()") &&
consolePage.includes("rememberConsoleWorkerViewScroll()") &&
consolePage.includes("consoleBodyElement.scrollTop = resolveConsoleViewScrollTop(") &&
consolePage.includes('event.key === "PageUp" || event.key === "PageDown"') &&
consolePage.includes("consoleBodyElement.scrollBy({"),
"Native scroll, PageUp/PageDown, conditional bottom-follow and per-Worker scroll restoration must remain",
);
});
@@ -836,7 +907,7 @@ Deno.test("Worker Console composer separates the editor from its icon toolbar",
);
assert(
composerInput.includes("min-height: 2.65rem") &&
composerInput.includes("padding: 0.55rem 0.65rem 0.35rem") &&
composerInput.includes("padding: var(--space-2) var(--space-2) var(--space-1)") &&
!composerInput.includes("padding: 0.55rem 3.4rem 3rem 0.65rem"),
"Editor must not reserve an overlapping toolbar inside its scrolling content",
);
@@ -1416,7 +1487,47 @@ Deno.test("Web Console switches main and direct SubWorker views from the Tasks r
);
});
Deno.test("Web Console uses Notify while running and keeps recovery pending controls", async () => {
Deno.test("Pending inputs are single-line previews between Tasks and Composer", async () => {
const page = await Deno.readTextFile(new URL(
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
import.meta.url,
));
const pending = page.indexOf('<section class="pending-submissions"');
assert(
page.indexOf("<ConsoleTasks") < pending && pending < page.indexOf('<form class="console-composer"'),
"Pending inputs should sit between Tasks and Composer",
);
const section = page.slice(pending, page.indexOf("</section>", pending));
assert(
!section.includes("<details") && !page.includes('method: "continue_pending"') &&
!section.includes("{submission.submission_id}") && !section.includes("segment_count") &&
section.includes('submission.preview || "Preview unavailable"') &&
section.includes('aria-label={`Cancel queued input ${index + 1}`}') &&
section.includes('aria-label="Notifications"') &&
section.includes("pendingSubmissions.notification_previews ?? []") &&
!page.includes('method: "clear_pending_submissions"'),
"Always-visible previews should replace UUIDs and use named icon-only cancellation controls",
);
const css = page.split("<style>")[1];
const previewRule = css.match(/\.pending-submission-preview,[^{]+\{([^}]+)\}/)?.[1] ?? "";
assert(
previewRule.includes("white-space: nowrap") && previewRule.includes("min-width: 0") &&
previewRule.includes("overflow: hidden") && previewRule.includes("text-overflow: ellipsis"),
"Preview text must truncate instead of wrapping or expanding the row",
);
assert(
/\.pending-queue li\s*\{[^}]*grid-template-columns: minmax\(0, 1fr\) auto/.test(css) &&
css.includes("grid-template-columns: repeat(2, minmax(0, 1fr))") &&
!section.includes("BevelLine") &&
/\.pending-submissions\s*\{[^}]*font-size: var\(--font-size-compact\);[^}]*line-height: var\(--line-height-compact\);/.test(css) &&
css.includes(".pending-queue li:hover .pending-icon-button") &&
css.includes(".pending-queue li:focus-within .pending-icon-button") &&
css.includes("@media (hover: none)") && section.includes('<path d="M5 12h14"'),
"Cancel icons must retain their column when preview text is long",
);
});
Deno.test("Web Console uses Notify while running and exposes queued input cancellation", async () => {
const consolePage = await Deno.readTextFile(
new URL(
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
@@ -1432,8 +1543,6 @@ Deno.test("Web Console uses Notify while running and keeps recovery pending cont
"submission_request_id: crypto.randomUUID()",
'payload.event === "pending_submissions_changed"',
'method: "cancel_pending_submission"',
'method: "clear_pending_submissions"',
'method: "continue_pending"',
"handleNotifySubmit",
"disabled={!canNotifyDraft}",
'class="composer-notify-button"',
@@ -42,6 +42,39 @@ Deno.test('generated subscription validator accepts a current exact frame', () =
assert(result.value.frame === 'response', 'expected response frame');
});
Deno.test('pending submission previews accept new and legacy runtime payloads', () => {
for (const preview of [undefined, '日本語 <b>literal</b>', null]) {
const result = decode(subscribedFrame({
selector: { topic: 'worker_protocol', worker_id: 'worker-1' },
snapshot: { topic: 'worker_protocol', data: {
worker_id: 'worker-1',
events: [{ event: 'pending_submissions_changed', data: { pending: {
revision: 1, notification_count: 0, head_id: 'queued-1',
submissions: [{ submission_id: 'queued-1', accepted_at_ms: 1,
segment_count: 1, byte_len: 10, ...(preview === undefined ? {} : { preview }) }],
} } }],
} },
}));
assert(result.ok, `pending preview rejected: ${JSON.stringify(result)}`);
}
});
Deno.test('pending notification previews accept new and legacy runtime payloads', () => {
for (const previews of [undefined, [], ['通知 <b>literal</b>']]) {
const result = decode(subscribedFrame({
selector: { topic: 'worker_protocol', worker_id: 'worker-1' },
snapshot: { topic: 'worker_protocol', data: {
worker_id: 'worker-1',
events: [{ event: 'pending_submissions_changed', data: { pending: {
revision: 1, notification_count: 1, head_id: 'notification-head', submissions: [],
...(previews === undefined ? {} : { notification_previews: previews }),
} } }],
} },
}));
assert(result.ok, `notification previews rejected: ${JSON.stringify(result)}`);
}
});
Deno.test('generated subscription validator accepts an external Workdir Worker', () => {
const result = decode(subscribedFrame({
snapshot: {
+2 -2
View File
@@ -178,7 +178,7 @@
max-width: 1280px;
margin-inline: auto;
overflow-y: auto;
padding: var(--space-6);
padding: var(--space-4);
}
@media (max-width: 760px) {
@@ -208,7 +208,7 @@
grid-column: 1;
grid-row: 2;
overflow-y: auto;
padding: var(--space-5) var(--space-4);
padding: var(--space-4);
}
.app-shell.sidebar-open .app-shell__main {
File diff suppressed because it is too large Load Diff
@@ -167,6 +167,123 @@ afterEach(() => {
vi.restoreAllMocks();
});
test("pending inputs show literal previews above Composer with revision-fenced icon actions", async () => {
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ availability: "live_protocol" })));
const view = render(ConsolePage, { data: pageData() });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
const session = emptySession();
session.pending_submissions = {
revision: 7, head_id: "private-submission-id", notification_count: 1,
notification_previews: ["<b>通知内容</b>"],
submissions: [
{ submission_id: "private-submission-id", preview: "<b>実際の入力</b> " + "長文".repeat(100), accepted_at_ms: 1, segment_count: 1, byte_len: 500 },
{ submission_id: "legacy-submission-id", accepted_at_ms: 2, segment_count: 1, byte_len: 10 },
],
};
latestListener().onFrame(subscribedFrame(session));
const region = await screen.findByRole("region", { name: "Pending activations" });
expect(region.querySelector("details")).toBeNull();
expect(region.textContent).not.toContain("private-submission-id");
expect(region.textContent).not.toContain("legacy-submission-id");
expect(region.textContent).toContain("<b>実際の入力</b>");
expect(region.querySelector("b")).toBeNull();
expect(region.textContent).toContain("Preview unavailable");
const queue = screen.getByRole("list", { name: "Queued inputs" });
const notifications = screen.getByRole("group", { name: "Notifications" });
expect(queue.querySelectorAll("li")).toHaveLength(2);
expect(notifications.textContent).toContain("<b>通知内容</b>");
expect(notifications.querySelector("button")).toBeNull();
expect(notifications.querySelector("b")).toBeNull();
expect(region.textContent).toContain("2 Queued");
expect(screen.queryByRole("button", { name: /Clear all/ })).toBeNull();
expect(region.nextElementSibling).toBe(view.container.querySelector(".console-composer"));
expect(screen.queryByRole("button", { name: "Continue next" })).toBeNull();
const cancel = screen.getByRole("button", { name: "Cancel queued input 1" });
expect(cancel.textContent?.trim()).toBe("");
expect(cancel.querySelector("path")?.getAttribute("d")).toBe("M5 12h14");
await fireEvent.focus(cancel);
await fireEvent.click(cancel);
expect(multiplexer.sendWorkerMethod).toHaveBeenLastCalledWith({
method: "cancel_pending_submission",
params: { submission_id: "private-submission-id", expected_revision: 7 },
});
// Do not remove a row optimistically before the authoritative snapshot arrives.
expect(queue.querySelectorAll("li")).toHaveLength(2);
latestListener().onFrame({ frame: "event", message: { event: "event", data: {
payload: { event: "worker_protocol", data: { worker_id: "worker-a", event: {
event: "pending_submissions_changed", data: { pending: {
...session.pending_submissions, revision: 8, head_id: "legacy-submission-id",
submissions: session.pending_submissions.submissions.slice(1),
} },
} } },
} } });
await waitFor(() => expect(queue.querySelectorAll("li")).toHaveLength(1));
expect(notifications.textContent).toContain("<b>通知内容</b>");
await fireEvent.click(screen.getByRole("button", { name: "Cancel queued input 1" }));
expect(multiplexer.sendWorkerMethod).toHaveBeenLastCalledWith({
method: "cancel_pending_submission", params: { submission_id: "legacy-submission-id", expected_revision: 8 },
});
latestListener().onFrame(subscribedFrame(emptySession()));
await waitFor(() => expect(screen.queryByRole("region", { name: "Pending activations" })).toBeNull());
});
test("notification-only state displays previews without actions and supports legacy runtimes", async () => {
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ availability: "live_protocol" })));
render(ConsolePage, { data: pageData() });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
const session = emptySession();
session.pending_submissions.notification_count = 2;
session.pending_submissions.notification_previews = ["一件目の通知", "二件目の通知"];
session.pending_submissions.head_id = "notification-head";
latestListener().onFrame(subscribedFrame(session));
const region = await screen.findByRole("region", { name: "Pending activations" });
expect(region.textContent).toContain("0 Queued");
expect(region.textContent).toContain("一件目の通知");
expect(region.textContent).toContain("二件目の通知");
expect(region.querySelectorAll("li")).toHaveLength(2);
expect(region.querySelector("button")).toBeNull();
delete session.pending_submissions.notification_previews;
latestListener().onFrame(subscribedFrame(session));
await waitFor(() => expect(region.textContent).toContain("2 pending · Preview unavailable"));
expect(region.querySelector("button")).toBeNull();
latestListener().onFrame(subscribedFrame(emptySession()));
await waitFor(() => expect(screen.queryByRole("region", { name: "Pending activations" })).toBeNull());
});
test("queue-only state keeps the notification column empty and disables cancellation when disconnected", async () => {
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ availability: "live_protocol" })));
render(ConsolePage, { data: pageData() });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
const session = emptySession();
session.pending_submissions.submissions = [{ submission_id: "queue-1", preview: "送信内容", accepted_at_ms: 1, segment_count: 1, byte_len: 12 }];
latestListener().onFrame(subscribedFrame(session));
await screen.findByText("送信内容");
expect(screen.getByRole("group", { name: "Notifications" }).querySelector("ol")).toBeNull();
latestListener().onStatus?.("closed", "connection lost");
await waitFor(() => expect((screen.getByRole("button", { name: "Cancel queued input 1" }) as HTMLButtonElement).disabled).toBe(true));
});
test("turn navigation jumps only the transcript to its user message and stops bottom-follow", async () => {
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ availability: "live_protocol" })));
const view = render(ConsolePage, { data: pageData() });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
latestListener().onFrame(subscribedFrame(sessionWithUserMessage("Navigation question")));
const button = await screen.findByRole("button", { name: "Turn 1: Navigation question" });
const scroll = view.container.querySelector<HTMLElement>(".console-scroll")!;
const user = view.container.querySelector<HTMLElement>(".console-line.user")!;
Object.defineProperties(scroll, {
scrollHeight: { configurable: true, value: 2000 },
clientHeight: { configurable: true, value: 400 },
});
scroll.scrollTop = 400;
vi.spyOn(scroll, "getBoundingClientRect").mockReturnValue({ top: 200 } as DOMRect);
vi.spyOn(user, "getBoundingClientRect").mockReturnValue({ top: 600 } as DOMRect);
await fireEvent.click(button);
expect(scroll.scrollTop).toBe(800);
await settleMicrotasks();
expect(scroll.scrollTop).toBe(800);
});
test("does not call Runtime APIs before the Worker execution target resolves", async () => {
const request = vi.fn();
vi.stubGlobal("fetch", request);