feat: add console turn navigation and pending previews
This commit is contained in:
@@ -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!({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+215
-646
File diff suppressed because it is too large
Load Diff
+117
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user