mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 08:04:25 -10:00
wip: recover unowned engine owner-contract change (88 files)
Captured read-only from the /Volumes/VIXinSSD/CW/codewhale working tree
(branch feat/engine-owner-contract-20260924, base 283695cf2, last edited
2026-09-24 20:07 PDT). No live session claimed it. Committed as-is for review;
not verified to compile. Backup: CW/backups/engine-owner-contract-wip-20260925.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
283695cf25
commit
741867bcda
90 files changed
+1760
-932
No files matched your search
@@ -0,0 +1,272 @@
|
||||
//! Safe, ordered Engine owner projection shared by desktop and web hosts.
|
||||
//!
|
||||
//! The Engine owns operation and turn facts. Apps may bind this session-scoped
|
||||
//! projection to an authenticated account and serve it to another client; the
|
||||
//! account identity is intentionally not supplied by the Engine.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::event_msg::TurnOutcomeStatus;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OwnerActivityKind {
|
||||
Reading,
|
||||
Editing,
|
||||
Searching,
|
||||
Testing,
|
||||
Executing,
|
||||
Browsing,
|
||||
Computer,
|
||||
Memory,
|
||||
Tool,
|
||||
Thinking,
|
||||
Responding,
|
||||
Delegating,
|
||||
}
|
||||
|
||||
impl OwnerActivityKind {
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Reading => "reading",
|
||||
Self::Editing => "editing",
|
||||
Self::Searching => "searching",
|
||||
Self::Testing => "testing",
|
||||
Self::Executing => "executing",
|
||||
Self::Browsing => "browsing",
|
||||
Self::Computer => "computer",
|
||||
Self::Memory => "memory",
|
||||
Self::Tool => "tool",
|
||||
Self::Thinking => "thinking",
|
||||
Self::Responding => "responding",
|
||||
Self::Delegating => "delegating",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OwnerOperationOutcome {
|
||||
Succeeded,
|
||||
Failed,
|
||||
Cancelled,
|
||||
Denied,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OwnerPresence {
|
||||
Unknown,
|
||||
Working,
|
||||
NeedsYou,
|
||||
Done,
|
||||
Idle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OwnerFreshness {
|
||||
Missing,
|
||||
Fresh,
|
||||
Stale,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct OwnerActiveSpan {
|
||||
pub activity_kind: OwnerActivityKind,
|
||||
pub started_at_ms: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct OwnerFailedToolAge {
|
||||
pub activity_kind: OwnerActivityKind,
|
||||
pub age_ms: f64,
|
||||
}
|
||||
|
||||
/// Ordered, account-neutral read model. `cursor` is monotonic within the
|
||||
/// existing session owner. The Apps control plane supplies account isolation
|
||||
/// by authenticating the caller and resolving the requested session there.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct EngineOwnerProjection {
|
||||
pub schema_version: u8,
|
||||
pub session_id: Option<String>,
|
||||
pub cursor: u64,
|
||||
pub observed: bool,
|
||||
pub freshness: OwnerFreshness,
|
||||
pub authoritative_presence: OwnerPresence,
|
||||
pub activity_kind: Option<OwnerActivityKind>,
|
||||
pub observed_at_ms: Option<f64>,
|
||||
pub parallel_agent_count: u32,
|
||||
pub active_spans: Vec<OwnerActiveSpan>,
|
||||
pub turn_id: Option<String>,
|
||||
pub turn_outcome: Option<TurnOutcomeStatus>,
|
||||
pub done_effect_id: Option<String>,
|
||||
pub failed_tool_age: Option<OwnerFailedToolAge>,
|
||||
}
|
||||
|
||||
impl EngineOwnerProjection {
|
||||
#[must_use]
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.schema_version == 1
|
||||
&& self.session_id.as_ref().is_none_or(|id| {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 256
|
||||
&& id.bytes().all(|byte| !byte.is_ascii_control())
|
||||
})
|
||||
&& self.turn_id.as_ref().is_none_or(|id| {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 256
|
||||
&& id.bytes().all(|byte| !byte.is_ascii_control())
|
||||
})
|
||||
&& self.active_spans.len() <= 4
|
||||
&& self.parallel_agent_count <= 100_000
|
||||
&& self
|
||||
.observed_at_ms
|
||||
.is_none_or(|time| time.is_finite() && time >= 0.0)
|
||||
&& self.active_spans.iter().all(|span| {
|
||||
span.started_at_ms.is_finite()
|
||||
&& span.started_at_ms >= 0.0
|
||||
&& self
|
||||
.observed_at_ms
|
||||
.is_none_or(|observed_at| span.started_at_ms <= observed_at)
|
||||
})
|
||||
&& self.failed_tool_age.as_ref().is_none_or(|failure| {
|
||||
failure.age_ms.is_finite() && failure.age_ms >= 0.0
|
||||
})
|
||||
&& (self.turn_outcome.is_none() || self.turn_id.is_some())
|
||||
&& match self.freshness {
|
||||
OwnerFreshness::Missing => {
|
||||
!self.observed
|
||||
&& self.observed_at_ms.is_none()
|
||||
&& self.activity_kind.is_none()
|
||||
&& self.active_spans.is_empty()
|
||||
&& self.parallel_agent_count == 0
|
||||
&& self.authoritative_presence == OwnerPresence::Unknown
|
||||
&& self.turn_id.is_none()
|
||||
&& self.turn_outcome.is_none()
|
||||
&& self.failed_tool_age.is_none()
|
||||
&& self.done_effect_id.is_none()
|
||||
}
|
||||
OwnerFreshness::Stale => {
|
||||
self.observed
|
||||
&& self.session_id.is_some()
|
||||
&& self.observed_at_ms.is_some()
|
||||
&& self.activity_kind.is_none()
|
||||
&& self.active_spans.is_empty()
|
||||
&& self.parallel_agent_count == 0
|
||||
&& self.authoritative_presence == OwnerPresence::Unknown
|
||||
&& self.failed_tool_age.is_none()
|
||||
&& self.done_effect_id.is_none()
|
||||
}
|
||||
OwnerFreshness::Fresh => {
|
||||
self.observed
|
||||
&& self.session_id.is_some()
|
||||
&& self.observed_at_ms.is_some()
|
||||
&& (self.authoritative_presence != OwnerPresence::Done
|
||||
|| (self.turn_outcome == Some(TurnOutcomeStatus::Completed)
|
||||
&& self.turn_id.is_some()
|
||||
&& self.done_effect_id == self.turn_id))
|
||||
&& (self.done_effect_id.is_none()
|
||||
|| self.authoritative_presence == OwnerPresence::Done)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn projection() -> EngineOwnerProjection {
|
||||
EngineOwnerProjection {
|
||||
schema_version: 1,
|
||||
session_id: Some("session:opaque".into()),
|
||||
cursor: 4,
|
||||
observed: true,
|
||||
freshness: OwnerFreshness::Fresh,
|
||||
authoritative_presence: OwnerPresence::Working,
|
||||
activity_kind: Some(OwnerActivityKind::Reading),
|
||||
observed_at_ms: Some(100.0),
|
||||
parallel_agent_count: 0,
|
||||
active_spans: vec![OwnerActiveSpan {
|
||||
activity_kind: OwnerActivityKind::Reading,
|
||||
started_at_ms: 50.0,
|
||||
}],
|
||||
turn_id: Some("turn-1".into()),
|
||||
turn_outcome: None,
|
||||
done_effect_id: None,
|
||||
failed_tool_age: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_accepts_trusted_scoped_activity_and_rejects_bad_clocks() {
|
||||
assert!(projection().is_valid());
|
||||
|
||||
let mut future_span = projection();
|
||||
future_span.active_spans[0].started_at_ms = 101.0;
|
||||
assert!(!future_span.is_valid());
|
||||
|
||||
let mut too_many = projection();
|
||||
too_many.active_spans = vec![too_many.active_spans[0].clone(); 5];
|
||||
assert!(!too_many.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_needs_authoritative_presence_for_wait_and_completed_turn_for_done() {
|
||||
let mut waiting = projection();
|
||||
waiting.authoritative_presence = OwnerPresence::NeedsYou;
|
||||
assert!(waiting.is_valid());
|
||||
|
||||
let mut done = projection();
|
||||
done.authoritative_presence = OwnerPresence::Done;
|
||||
done.activity_kind = None;
|
||||
done.active_spans.clear();
|
||||
done.turn_outcome = Some(TurnOutcomeStatus::Completed);
|
||||
done.done_effect_id = done.turn_id.clone();
|
||||
assert!(done.is_valid());
|
||||
|
||||
done.turn_outcome = Some(TurnOutcomeStatus::Interrupted);
|
||||
assert!(!done.is_valid());
|
||||
done.turn_outcome = Some(TurnOutcomeStatus::Failed);
|
||||
assert!(!done.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_and_stale_projection_cannot_claim_specific_activity() {
|
||||
let missing = EngineOwnerProjection {
|
||||
schema_version: 1,
|
||||
session_id: None,
|
||||
cursor: 0,
|
||||
observed: false,
|
||||
freshness: OwnerFreshness::Missing,
|
||||
authoritative_presence: OwnerPresence::Unknown,
|
||||
activity_kind: None,
|
||||
observed_at_ms: None,
|
||||
parallel_agent_count: 0,
|
||||
active_spans: Vec::new(),
|
||||
turn_id: None,
|
||||
turn_outcome: None,
|
||||
done_effect_id: None,
|
||||
failed_tool_age: None,
|
||||
};
|
||||
assert!(missing.is_valid());
|
||||
|
||||
let mut stale = projection();
|
||||
stale.freshness = OwnerFreshness::Stale;
|
||||
stale.authoritative_presence = OwnerPresence::Unknown;
|
||||
stale.activity_kind = None;
|
||||
stale.parallel_agent_count = 0;
|
||||
stale.active_spans.clear();
|
||||
stale.failed_tool_age = None;
|
||||
stale.done_effect_id = None;
|
||||
assert!(stale.is_valid());
|
||||
|
||||
stale.activity_kind = Some(OwnerActivityKind::Reading);
|
||||
assert!(!stale.is_valid());
|
||||
}
|
||||
}
|
||||
@@ -363,6 +363,22 @@ pub enum EventMsg {
|
||||
tool_name: String,
|
||||
result: ToolCallOutcome,
|
||||
},
|
||||
/// Trusted Engine-owned activity for an operation that passed dispatch
|
||||
/// and authority checks. No tool name, arguments, command, or result is
|
||||
/// included in the pet-facing activity contract.
|
||||
OperationActivityStarted {
|
||||
thread_id: ThreadId,
|
||||
session_id: SessionId,
|
||||
span_id: String,
|
||||
activity_kind: crate::engine_owner::OwnerActivityKind,
|
||||
},
|
||||
OperationActivityCompleted {
|
||||
thread_id: ThreadId,
|
||||
session_id: SessionId,
|
||||
span_id: String,
|
||||
activity_kind: crate::engine_owner::OwnerActivityKind,
|
||||
outcome: crate::engine_owner::OwnerOperationOutcome,
|
||||
},
|
||||
|
||||
// === Turn lifecycle ===
|
||||
TurnStarted {
|
||||
@@ -749,6 +765,8 @@ pub const EVENT_KINDS: &[&str] = &[
|
||||
"tool_call_started",
|
||||
"tool_call_heartbeat",
|
||||
"tool_call_complete",
|
||||
"operation_activity_started",
|
||||
"operation_activity_completed",
|
||||
"turn_started",
|
||||
"tool_request_snapshot",
|
||||
"route_dispatched",
|
||||
@@ -802,6 +820,8 @@ impl EventMsg {
|
||||
Self::ToolCallStarted { .. } => "tool_call_started",
|
||||
Self::ToolCallHeartbeat { .. } => "tool_call_heartbeat",
|
||||
Self::ToolCallComplete { .. } => "tool_call_complete",
|
||||
Self::OperationActivityStarted { .. } => "operation_activity_started",
|
||||
Self::OperationActivityCompleted { .. } => "operation_activity_completed",
|
||||
Self::TurnStarted { .. } => "turn_started",
|
||||
Self::ToolRequestSnapshot { .. } => "tool_request_snapshot",
|
||||
Self::RouteDispatched { .. } => "route_dispatched",
|
||||
@@ -855,6 +875,8 @@ impl EventMsg {
|
||||
| Self::ToolCallStarted { thread_id, .. }
|
||||
| Self::ToolCallHeartbeat { thread_id, .. }
|
||||
| Self::ToolCallComplete { thread_id, .. }
|
||||
| Self::OperationActivityStarted { thread_id, .. }
|
||||
| Self::OperationActivityCompleted { thread_id, .. }
|
||||
| Self::TurnStarted { thread_id, .. }
|
||||
| Self::ToolRequestSnapshot { thread_id, .. }
|
||||
| Self::RouteDispatched { thread_id, .. }
|
||||
@@ -908,6 +930,8 @@ impl EventMsg {
|
||||
| Self::ToolCallStarted { session_id, .. }
|
||||
| Self::ToolCallHeartbeat { session_id, .. }
|
||||
| Self::ToolCallComplete { session_id, .. }
|
||||
| Self::OperationActivityStarted { session_id, .. }
|
||||
| Self::OperationActivityCompleted { session_id, .. }
|
||||
| Self::TurnStarted { session_id, .. }
|
||||
| Self::ToolRequestSnapshot { session_id, .. }
|
||||
| Self::RouteDispatched { session_id, .. }
|
||||
|
||||
@@ -12,6 +12,7 @@ use serde_json::Value;
|
||||
pub mod agent_mail;
|
||||
pub mod agent_run;
|
||||
pub mod event_msg;
|
||||
pub mod engine_owner;
|
||||
pub mod fleet;
|
||||
pub mod ids;
|
||||
pub mod journal;
|
||||
|
||||
@@ -8298,7 +8298,7 @@ pub(crate) mod reviewer;
|
||||
mod streaming;
|
||||
mod token_estimate_cache;
|
||||
pub(crate) mod tool_catalog;
|
||||
mod tool_execution;
|
||||
pub(crate) mod tool_execution;
|
||||
mod tool_media;
|
||||
mod tool_preparation;
|
||||
mod tool_setup;
|
||||
|
||||
@@ -20387,6 +20387,7 @@ async fn execute_tools_dispatches_through_common_executor() {
|
||||
tx_event,
|
||||
None,
|
||||
EXECUTE_TOOLS_TOOL_NAME.to_string(),
|
||||
None,
|
||||
json!({"code": code}),
|
||||
tmp.path().to_path_buf(),
|
||||
Some(®istry),
|
||||
@@ -20425,6 +20426,7 @@ async fn code_execution_scenario() {
|
||||
tx_event,
|
||||
None,
|
||||
CODE_EXECUTION_TOOL_NAME.to_string(),
|
||||
None,
|
||||
json!({"code":"print('common executor code exec')"}),
|
||||
tmp.path().to_path_buf(),
|
||||
None,
|
||||
|
||||
@@ -16,6 +16,129 @@ use super::*;
|
||||
|
||||
const TOOL_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(10);
|
||||
|
||||
type OwnerActivityKind = codewhale_protocol::engine_owner::OwnerActivityKind;
|
||||
type OwnerOperationOutcome = codewhale_protocol::engine_owner::OwnerOperationOutcome;
|
||||
|
||||
pub(crate) fn owner_activity_kind_for_operation(name: &str) -> OwnerActivityKind {
|
||||
use OwnerActivityKind as Kind;
|
||||
match name {
|
||||
"read" | "read_file" | "list_dir" | "read_media" => Kind::Reading,
|
||||
"write" | "edit" | "write_file" | "edit_file" | "apply_patch" | "fim_edit" => {
|
||||
Kind::Editing
|
||||
}
|
||||
"file_search" | "grep_files" | "search_files" => Kind::Searching,
|
||||
"run_tests" | "run_verifiers" => Kind::Testing,
|
||||
"bash" | "Bash" | "exec_shell" | "exec_shell_wait" | "exec_shell_interact" | "exec_shell_cancel"
|
||||
| CODE_EXECUTION_TOOL_NAME | JS_EXECUTION_TOOL_NAME | "task_gate_run"
|
||||
| "automation_run" | "rlm_eval" => Kind::Executing,
|
||||
"web_search" | "fetch_url" | "wait_for_dev_server" | "rlm_open" => Kind::Browsing,
|
||||
"memory_search" => Kind::Memory,
|
||||
_ => Kind::Tool,
|
||||
}
|
||||
}
|
||||
|
||||
fn owner_activity_kind_for_mcp_server(server: &str) -> OwnerActivityKind {
|
||||
if server == "computer-use" || server.ends_with("-computer-use") {
|
||||
OwnerActivityKind::Computer
|
||||
} else {
|
||||
OwnerActivityKind::Tool
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve activity from the same registry/MCP authority that dispatches the
|
||||
/// call. Concrete tool identity and trusted MCP registration are the only
|
||||
/// classification inputs. Action wrappers report from their selected
|
||||
/// underlying dispatch arm; their arguments are not inspected here.
|
||||
async fn resolved_owner_activity_kind(
|
||||
tool_name: &str,
|
||||
registry: Option<&crate::tools::ToolRegistry>,
|
||||
mcp_pool: Option<&Arc<AsyncMutex<McpPool>>>,
|
||||
) -> Option<OwnerActivityKind> {
|
||||
if McpPool::is_mcp_tool(tool_name) {
|
||||
let pool = mcp_pool?;
|
||||
let servers = pool.lock().await.resolved_tool_servers();
|
||||
let server = servers.get(tool_name)?;
|
||||
return Some(owner_activity_kind_for_mcp_server(server));
|
||||
}
|
||||
|
||||
// execute_tools is a wrapper. Its nested calls report their own resolved
|
||||
// operations through the existing registry; the wrapper itself is not an
|
||||
// activity classifier.
|
||||
if tool_name == EXECUTE_TOOLS_TOOL_NAME {
|
||||
return None;
|
||||
}
|
||||
|
||||
if crate::tools::canonical_action::is_action_family(tool_name) {
|
||||
// These calls dispatch on an action inside their tool implementation.
|
||||
// That implementation reports after selecting the concrete operation.
|
||||
return None;
|
||||
}
|
||||
if matches!(tool_name, CODE_EXECUTION_TOOL_NAME | JS_EXECUTION_TOOL_NAME) {
|
||||
return Some(OwnerActivityKind::Executing);
|
||||
}
|
||||
let registered = registry
|
||||
.and_then(|registry| registry.get(tool_name))
|
||||
.is_some();
|
||||
registered.then(|| owner_activity_kind_for_operation(tool_name))
|
||||
}
|
||||
|
||||
pub(crate) fn owner_operation_outcome(
|
||||
outcome: &Result<RichToolResult, ToolError>,
|
||||
cancelled: bool,
|
||||
) -> OwnerOperationOutcome {
|
||||
if cancelled {
|
||||
return OwnerOperationOutcome::Cancelled;
|
||||
}
|
||||
match outcome {
|
||||
Ok(result) if result.result.success => OwnerOperationOutcome::Succeeded,
|
||||
Ok(_) => OwnerOperationOutcome::Failed,
|
||||
Err(ToolError::Cancelled { .. }) => OwnerOperationOutcome::Cancelled,
|
||||
Err(
|
||||
ToolError::InvalidInput { .. }
|
||||
| ToolError::MissingField { .. }
|
||||
| ToolError::PathEscape { .. }
|
||||
| ToolError::NotAvailable { .. }
|
||||
| ToolError::PermissionDenied { .. },
|
||||
) => OwnerOperationOutcome::Denied,
|
||||
Err(ToolError::ExecutionFailed { .. } | ToolError::Timeout { .. }) => {
|
||||
OwnerOperationOutcome::Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EngineOperationActivityReporter {
|
||||
tx_event: mpsc::Sender<Event>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::tools::spec::OperationActivityReporter for EngineOperationActivityReporter {
|
||||
async fn started(&self, span_id: String, activity_kind: OwnerActivityKind) {
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::OperationActivityStarted {
|
||||
span_id,
|
||||
activity_kind,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn completed(
|
||||
&self,
|
||||
span_id: String,
|
||||
activity_kind: OwnerActivityKind,
|
||||
outcome: OwnerOperationOutcome,
|
||||
) {
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::OperationActivityCompleted {
|
||||
span_id,
|
||||
activity_kind,
|
||||
outcome,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
fn inherited_interactive_shell_refusal(tool_name: &str, interactive: bool) -> Option<ToolError> {
|
||||
if !interactive || !matches!(tool_name, "bash" | "Bash" | "exec_shell") {
|
||||
return None;
|
||||
@@ -393,6 +516,7 @@ impl Engine {
|
||||
tx_event,
|
||||
Some(cancel_token),
|
||||
tool_name.clone(),
|
||||
Some(format!("parallel:{}:{index}", uuid::Uuid::new_v4())),
|
||||
tool_input.clone(),
|
||||
workspace,
|
||||
Some(registry_ref),
|
||||
@@ -455,6 +579,7 @@ impl Engine {
|
||||
tx_event: mpsc::Sender<Event>,
|
||||
cancel_token: Option<CancellationToken>,
|
||||
tool_name: String,
|
||||
activity_call_id: Option<String>,
|
||||
tool_input: serde_json::Value,
|
||||
workspace: PathBuf,
|
||||
registry: Option<&crate::tools::ToolRegistry>,
|
||||
@@ -557,6 +682,32 @@ impl Engine {
|
||||
}
|
||||
}
|
||||
|
||||
let activity_kind = if activity_call_id.is_some() {
|
||||
resolved_owner_activity_kind(&tool_name, registry, mcp_pool.as_ref()).await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let (Some(span_id), Some(activity_kind)) =
|
||||
(activity_call_id.as_ref(), activity_kind)
|
||||
{
|
||||
let _ = tx_event
|
||||
.send(Event::OperationActivityStarted {
|
||||
span_id: span_id.clone(),
|
||||
activity_kind,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
let registry_context = registry.map(|registry| {
|
||||
let mut context = context_override
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| registry.context().clone());
|
||||
context.operation_activity_reporter = Some(Arc::new(EngineOperationActivityReporter {
|
||||
tx_event: tx_event.clone(),
|
||||
}));
|
||||
context
|
||||
});
|
||||
let outcome: Result<RichToolResult, ToolError> = if McpPool::is_mcp_tool(&tool_name) {
|
||||
if let Some(pool) = mcp_pool {
|
||||
let disallowed_tools = context_override
|
||||
@@ -587,7 +738,7 @@ impl Engine {
|
||||
.map(RichToolResult::plain)
|
||||
} else if tool_name == EXECUTE_TOOLS_TOOL_NAME {
|
||||
if let Some(registry) = registry {
|
||||
let context = context_override
|
||||
let context = registry_context
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| registry.context().clone());
|
||||
@@ -601,7 +752,7 @@ impl Engine {
|
||||
}
|
||||
} else if let Some(registry) = registry {
|
||||
registry
|
||||
.execute_rich_full_with_context(&tool_name, tool_input, context_override.as_ref())
|
||||
.execute_rich_full_with_context(&tool_name, tool_input, registry_context.as_ref())
|
||||
.await
|
||||
} else {
|
||||
Err(ToolError::not_available(format!(
|
||||
@@ -609,6 +760,22 @@ impl Engine {
|
||||
)))
|
||||
};
|
||||
|
||||
if let (Some(span_id), Some(activity_kind)) =
|
||||
(activity_call_id, activity_kind)
|
||||
{
|
||||
let cancelled = cancel_token
|
||||
.as_ref()
|
||||
.is_some_and(CancellationToken::is_cancelled);
|
||||
let operation_outcome = owner_operation_outcome(&outcome, cancelled);
|
||||
let _ = tx_event
|
||||
.send(Event::OperationActivityCompleted {
|
||||
span_id,
|
||||
activity_kind,
|
||||
outcome: operation_outcome,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
let duration_ms = started_at.elapsed().as_millis() as u64;
|
||||
// The surface-agnostic choke point for every tool call, so this one
|
||||
// bump covers exec and the CLI as well as the TUI. `memory_search` is
|
||||
@@ -676,6 +843,29 @@ mod tests {
|
||||
|
||||
const TEST_HEARTBEAT_INTERVAL: Duration = Duration::from_millis(10);
|
||||
|
||||
#[test]
|
||||
fn activity_kind_uses_resolved_tool_identity_and_covers_lowercase_mutations() {
|
||||
use OwnerActivityKind as Kind;
|
||||
|
||||
assert_eq!(owner_activity_kind_for_operation("write"), Kind::Editing);
|
||||
assert_eq!(owner_activity_kind_for_operation("edit"), Kind::Editing);
|
||||
assert_eq!(owner_activity_kind_for_operation("read"), Kind::Reading);
|
||||
assert_eq!(owner_activity_kind_for_operation("computer-use"), Kind::Tool);
|
||||
assert_eq!(owner_activity_kind_for_operation("unknown_tool"), Kind::Tool);
|
||||
assert_eq!(owner_activity_kind_for_mcp_server("computer-use"), Kind::Computer);
|
||||
assert_eq!(owner_activity_kind_for_mcp_server("mcp-computer-use"), Kind::Computer);
|
||||
assert_eq!(owner_activity_kind_for_mcp_server("browser"), Kind::Tool);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn action_wrappers_wait_for_the_selected_underlying_operation() {
|
||||
assert!(resolved_owner_activity_kind("File", None, None).await.is_none());
|
||||
assert!(resolved_owner_activity_kind("Web", None, None).await.is_none());
|
||||
assert!(resolved_owner_activity_kind(EXECUTE_TOOLS_TOOL_NAME, None, None)
|
||||
.await
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_heartbeat_emits_for_slow_tool() {
|
||||
let (tx, mut rx) = mpsc::channel(4);
|
||||
|
||||
@@ -3925,6 +3925,7 @@ impl Engine {
|
||||
tx_event.clone(),
|
||||
Some(cancel_token),
|
||||
plan.name.clone(),
|
||||
Some(plan.id.clone()),
|
||||
plan.input.clone(),
|
||||
workspace,
|
||||
registry,
|
||||
@@ -4467,6 +4468,7 @@ impl Engine {
|
||||
self.tx_event.clone(),
|
||||
Some(self.cancel_token.clone()),
|
||||
tool_name.clone(),
|
||||
Some(tool_id.clone()),
|
||||
tool_input.clone(),
|
||||
self.session.workspace.clone(),
|
||||
tool_registry,
|
||||
|
||||
@@ -252,6 +252,19 @@ pub enum Event {
|
||||
result: Result<ToolResult, ToolError>,
|
||||
},
|
||||
|
||||
/// Trusted operation activity emitted only after dispatch and authority
|
||||
/// gates resolve the underlying operation. The payload deliberately
|
||||
/// excludes tool names, arguments, commands, and results.
|
||||
OperationActivityStarted {
|
||||
span_id: String,
|
||||
activity_kind: codewhale_protocol::engine_owner::OwnerActivityKind,
|
||||
},
|
||||
OperationActivityCompleted {
|
||||
span_id: String,
|
||||
activity_kind: codewhale_protocol::engine_owner::OwnerActivityKind,
|
||||
outcome: codewhale_protocol::engine_owner::OwnerOperationOutcome,
|
||||
},
|
||||
|
||||
// === Turn Lifecycle ===
|
||||
/// A new turn has started (user sent a message)
|
||||
TurnStarted {
|
||||
|
||||
@@ -513,6 +513,26 @@ pub fn event_to_protocol(event: &Event, ids: &ProtocolIds) -> wire::EventMsg {
|
||||
},
|
||||
},
|
||||
},
|
||||
Event::OperationActivityStarted {
|
||||
span_id,
|
||||
activity_kind,
|
||||
} => wire::EventMsg::OperationActivityStarted {
|
||||
thread_id,
|
||||
session_id,
|
||||
span_id: span_id.clone(),
|
||||
activity_kind: *activity_kind,
|
||||
},
|
||||
Event::OperationActivityCompleted {
|
||||
span_id,
|
||||
activity_kind,
|
||||
outcome,
|
||||
} => wire::EventMsg::OperationActivityCompleted {
|
||||
thread_id,
|
||||
session_id,
|
||||
span_id: span_id.clone(),
|
||||
activity_kind: *activity_kind,
|
||||
outcome: *outcome,
|
||||
},
|
||||
Event::TurnStarted {
|
||||
turn_id,
|
||||
created_at,
|
||||
|
||||
@@ -244,10 +244,35 @@ impl ToolInvoker for CodemodeInvoker {
|
||||
return Err(self.refused(name, started, err.to_string()));
|
||||
}
|
||||
|
||||
match spec
|
||||
let activity_kind = (!crate::tools::canonical_action::is_action_family(name))
|
||||
.then(|| crate::core::engine::tool_execution::owner_activity_kind_for_operation(name));
|
||||
let activity_span_id = activity_kind.map(|_| format!("codemode:{}", uuid::Uuid::new_v4()));
|
||||
if let (Some(span_id), Some(activity_kind), Some(reporter)) = (
|
||||
activity_span_id.as_ref(),
|
||||
activity_kind,
|
||||
self.context.operation_activity_reporter.as_ref(),
|
||||
) {
|
||||
reporter
|
||||
.started(span_id.clone(), activity_kind)
|
||||
.await;
|
||||
}
|
||||
|
||||
let invoked = spec
|
||||
.execute_rich(request.input.clone(), &self.context)
|
||||
.await
|
||||
{
|
||||
.await;
|
||||
if let (Some(span_id), Some(activity_kind), Some(reporter)) = (
|
||||
activity_span_id,
|
||||
activity_kind,
|
||||
self.context.operation_activity_reporter.as_ref(),
|
||||
) {
|
||||
let outcome = crate::core::engine::tool_execution::owner_operation_outcome(
|
||||
&invoked,
|
||||
false,
|
||||
);
|
||||
reporter.completed(span_id, activity_kind, outcome).await;
|
||||
}
|
||||
|
||||
match invoked {
|
||||
Ok(rich) => {
|
||||
let result = rich.into_result();
|
||||
// Stable envelope: the tool's text content (parsed as JSON
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! without teaching new sessions the older action-family schema.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use codewhale_protocol::engine_owner::OwnerActivityKind;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::apply_patch::ApplyPatchTool;
|
||||
@@ -14,7 +15,7 @@ use super::file_search::FileSearchTool;
|
||||
use super::search::GrepFilesTool;
|
||||
use super::spec::{
|
||||
ApprovalRequirement, RichToolResult, ToolCapability, ToolContext, ToolError, ToolResult,
|
||||
ToolSpec,
|
||||
ToolSpec, with_operation_activity,
|
||||
};
|
||||
|
||||
/// Lift a parameter description out of the tool that implements the action.
|
||||
@@ -477,19 +478,54 @@ impl ToolSpec for FileTool {
|
||||
let input = self.strip_action(input)?;
|
||||
|
||||
match action.as_str() {
|
||||
"read" => ReadFileTool.execute(input, context).await,
|
||||
"list" => ListDirTool.execute(input, context).await,
|
||||
"read" => with_operation_activity(
|
||||
context,
|
||||
OwnerActivityKind::Reading,
|
||||
ReadFileTool.execute(input, context),
|
||||
)
|
||||
.await,
|
||||
"list" => with_operation_activity(
|
||||
context,
|
||||
OwnerActivityKind::Reading,
|
||||
ListDirTool.execute(input, context),
|
||||
)
|
||||
.await,
|
||||
// The cross-action spellings the wrapper advertises
|
||||
// (`max_results` on search_name, `query`/`limit` on
|
||||
// search_content) used to be copied here. They are alias-table
|
||||
// entries on the implementing tools now — one mechanism, applied
|
||||
// before the same unknown-parameter check every other action runs,
|
||||
// and a direct call to the inner tool behaves identically.
|
||||
"search_name" => FileSearchTool.execute(input, context).await,
|
||||
"search_content" => GrepFilesTool.execute(input, context).await,
|
||||
"write" => WriteFileTool.execute(input, context).await,
|
||||
"edit" => EditFileTool.execute(input, context).await,
|
||||
"patch" => ApplyPatchTool.execute(input, context).await,
|
||||
"search_name" => with_operation_activity(
|
||||
context,
|
||||
OwnerActivityKind::Searching,
|
||||
FileSearchTool.execute(input, context),
|
||||
)
|
||||
.await,
|
||||
"search_content" => with_operation_activity(
|
||||
context,
|
||||
OwnerActivityKind::Searching,
|
||||
GrepFilesTool.execute(input, context),
|
||||
)
|
||||
.await,
|
||||
"write" => with_operation_activity(
|
||||
context,
|
||||
OwnerActivityKind::Editing,
|
||||
WriteFileTool.execute(input, context),
|
||||
)
|
||||
.await,
|
||||
"edit" => with_operation_activity(
|
||||
context,
|
||||
OwnerActivityKind::Editing,
|
||||
EditFileTool.execute(input, context),
|
||||
)
|
||||
.await,
|
||||
"patch" => with_operation_activity(
|
||||
context,
|
||||
OwnerActivityKind::Editing,
|
||||
ApplyPatchTool.execute(input, context),
|
||||
)
|
||||
.await,
|
||||
other => Err(ToolError::invalid_input(format!(
|
||||
"Unknown File action \"{other}\"; nothing was run. Pass one of: {}.",
|
||||
self.available_actions().join(", ")
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
//! family is read-only end to end *except* fetch; `merge_tree` is a pure read.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use codewhale_protocol::engine_owner::OwnerActivityKind;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::canonical_action::required_action;
|
||||
@@ -16,6 +17,7 @@ use super::git::{GitCommitPlanTool, GitDiffTool, GitStatusTool};
|
||||
use super::git_history::{GitBlameTool, GitFetchTool, GitLogTool, GitMergeTreeTool, GitShowTool};
|
||||
use super::spec::{
|
||||
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
|
||||
with_operation_activity,
|
||||
};
|
||||
|
||||
pub struct GitTool {
|
||||
@@ -200,14 +202,14 @@ impl ToolSpec for GitTool {
|
||||
let input = self.strip_action(input)?;
|
||||
|
||||
match action.as_str() {
|
||||
"status" => GitStatusTool.execute(input, context).await,
|
||||
"diff" => GitDiffTool.execute(input, context).await,
|
||||
"log" => GitLogTool.execute(input, context).await,
|
||||
"show" => GitShowTool.execute(input, context).await,
|
||||
"blame" => GitBlameTool.execute(input, context).await,
|
||||
"commit_plan" => GitCommitPlanTool.execute(input, context).await,
|
||||
"fetch" => GitFetchTool.execute(input, context).await,
|
||||
"merge_tree" => GitMergeTreeTool.execute(input, context).await,
|
||||
"status" => with_operation_activity(context, OwnerActivityKind::Reading, GitStatusTool.execute(input, context)).await,
|
||||
"diff" => with_operation_activity(context, OwnerActivityKind::Reading, GitDiffTool.execute(input, context)).await,
|
||||
"log" => with_operation_activity(context, OwnerActivityKind::Reading, GitLogTool.execute(input, context)).await,
|
||||
"show" => with_operation_activity(context, OwnerActivityKind::Reading, GitShowTool.execute(input, context)).await,
|
||||
"blame" => with_operation_activity(context, OwnerActivityKind::Reading, GitBlameTool.execute(input, context)).await,
|
||||
"commit_plan" => with_operation_activity(context, OwnerActivityKind::Reading, GitCommitPlanTool.execute(input, context)).await,
|
||||
"fetch" => with_operation_activity(context, OwnerActivityKind::Tool, GitFetchTool.execute(input, context)).await,
|
||||
"merge_tree" => with_operation_activity(context, OwnerActivityKind::Reading, GitMergeTreeTool.execute(input, context)).await,
|
||||
other => Err(ToolError::invalid_input(format!(
|
||||
"Unknown Git action \"{other}\"; nothing was run. Pass one of: {}.",
|
||||
Self::ACTIONS.join(", ")
|
||||
|
||||
@@ -15,6 +15,7 @@ use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use codewhale_protocol::engine_owner::OwnerActivityKind;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::client::CodewhaleClient;
|
||||
@@ -332,11 +333,36 @@ impl ToolSpec for RlmTool {
|
||||
|
||||
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
match self.resolve_action(&input)? {
|
||||
"session_objects" => self.execute_session_objects(context).await,
|
||||
"open" => self.execute_open(&input, context).await,
|
||||
"eval" => self.execute_eval(&input, context).await,
|
||||
"configure" => self.execute_configure(&input, context).await,
|
||||
"close" => self.execute_close(&input, context).await,
|
||||
"session_objects" => super::spec::with_operation_activity(
|
||||
context,
|
||||
OwnerActivityKind::Memory,
|
||||
self.execute_session_objects(context),
|
||||
)
|
||||
.await,
|
||||
"open" => super::spec::with_operation_activity(
|
||||
context,
|
||||
OwnerActivityKind::Tool,
|
||||
self.execute_open(&input, context),
|
||||
)
|
||||
.await,
|
||||
"eval" => super::spec::with_operation_activity(
|
||||
context,
|
||||
OwnerActivityKind::Executing,
|
||||
self.execute_eval(&input, context),
|
||||
)
|
||||
.await,
|
||||
"configure" => super::spec::with_operation_activity(
|
||||
context,
|
||||
OwnerActivityKind::Tool,
|
||||
self.execute_configure(&input, context),
|
||||
)
|
||||
.await,
|
||||
"close" => super::spec::with_operation_activity(
|
||||
context,
|
||||
OwnerActivityKind::Tool,
|
||||
self.execute_close(&input, context),
|
||||
)
|
||||
.await,
|
||||
action => Err(ToolError::invalid_input(format!(
|
||||
"rlm: invalid action `{action}`"
|
||||
))),
|
||||
|
||||
@@ -5,11 +5,13 @@
|
||||
//! in v0.9.3.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use codewhale_protocol::engine_owner::OwnerActivityKind;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::canonical_action::required_action;
|
||||
use super::spec::{
|
||||
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
|
||||
with_operation_activity,
|
||||
};
|
||||
use super::test_runner::RunTestsTool;
|
||||
use super::verifier::RunVerifiersTool;
|
||||
@@ -158,8 +160,18 @@ impl ToolSpec for RunTool {
|
||||
let input = self.strip_action(input)?;
|
||||
|
||||
match action.as_str() {
|
||||
"tests" => RunTestsTool.execute(input, context).await,
|
||||
"verifiers" => RunVerifiersTool.execute(input, context).await,
|
||||
"tests" => with_operation_activity(
|
||||
context,
|
||||
OwnerActivityKind::Testing,
|
||||
RunTestsTool.execute(input, context),
|
||||
)
|
||||
.await,
|
||||
"verifiers" => with_operation_activity(
|
||||
context,
|
||||
OwnerActivityKind::Testing,
|
||||
RunVerifiersTool.execute(input, context),
|
||||
)
|
||||
.await,
|
||||
other => Err(ToolError::invalid_input(format!(
|
||||
"Unknown Run action \"{other}\"; nothing was run. Pass one of: {}.",
|
||||
Self::ACTIONS.join(", ")
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! - `ToolCapability`: Capabilities and requirements of tools
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::fs;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
@@ -185,6 +186,60 @@ pub(crate) fn new_shared_file_read_tracker() -> SharedFileReadTracker {
|
||||
Arc::new(Mutex::new(FileReadTracker::default()))
|
||||
}
|
||||
|
||||
/// Narrow callback used by wrapper tools to report an underlying operation
|
||||
/// only after their normal registry/action resolution has selected it.
|
||||
#[async_trait::async_trait]
|
||||
pub(crate) trait OperationActivityReporter: Send + Sync {
|
||||
async fn started(
|
||||
&self,
|
||||
span_id: String,
|
||||
activity_kind: codewhale_protocol::engine_owner::OwnerActivityKind,
|
||||
);
|
||||
async fn completed(
|
||||
&self,
|
||||
span_id: String,
|
||||
activity_kind: codewhale_protocol::engine_owner::OwnerActivityKind,
|
||||
outcome: codewhale_protocol::engine_owner::OwnerOperationOutcome,
|
||||
);
|
||||
}
|
||||
|
||||
/// Report only after a wrapper's dispatcher has selected the concrete arm.
|
||||
/// The span id is opaque and never appears in the user-facing projection.
|
||||
pub(crate) async fn with_operation_activity(
|
||||
context: &ToolContext,
|
||||
activity_kind: codewhale_protocol::engine_owner::OwnerActivityKind,
|
||||
operation: impl Future<Output = Result<ToolResult, ToolError>>,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let Some(reporter) = context.operation_activity_reporter.as_ref() else {
|
||||
return operation.await;
|
||||
};
|
||||
let reporter = Arc::clone(reporter);
|
||||
let span_id = format!("resolved:{}", uuid::Uuid::new_v4());
|
||||
reporter.started(span_id.clone(), activity_kind).await;
|
||||
let result = operation.await;
|
||||
let outcome = match &result {
|
||||
Ok(result) if result.success => {
|
||||
codewhale_protocol::engine_owner::OwnerOperationOutcome::Succeeded
|
||||
}
|
||||
Ok(_) => codewhale_protocol::engine_owner::OwnerOperationOutcome::Failed,
|
||||
Err(ToolError::Cancelled { .. }) => {
|
||||
codewhale_protocol::engine_owner::OwnerOperationOutcome::Cancelled
|
||||
}
|
||||
Err(
|
||||
ToolError::InvalidInput { .. }
|
||||
| ToolError::MissingField { .. }
|
||||
| ToolError::PathEscape { .. }
|
||||
| ToolError::NotAvailable { .. }
|
||||
| ToolError::PermissionDenied { .. },
|
||||
) => codewhale_protocol::engine_owner::OwnerOperationOutcome::Denied,
|
||||
Err(ToolError::ExecutionFailed { .. } | ToolError::Timeout { .. }) => {
|
||||
codewhale_protocol::engine_owner::OwnerOperationOutcome::Failed
|
||||
}
|
||||
};
|
||||
reporter.completed(span_id, activity_kind, outcome).await;
|
||||
result
|
||||
}
|
||||
|
||||
fn file_read_snapshot(path: &Path) -> Result<FileReadSnapshot, ToolError> {
|
||||
let metadata = fs::metadata(path).map_err(|e| {
|
||||
ToolError::execution_failed(format!("Failed to inspect {}: {e}", path.display()))
|
||||
@@ -602,6 +657,9 @@ pub struct ToolExecutionState {
|
||||
/// with the originating transcript position.
|
||||
pub(crate) origin_tool_call_id: Option<String>,
|
||||
pub(crate) origin_turn_id: Option<String>,
|
||||
/// Set only for an Engine-dispatched wrapper call. It forwards typed
|
||||
/// activity from resolved nested operations without exposing arguments.
|
||||
pub(crate) operation_activity_reporter: Option<Arc<dyn OperationActivityReporter>>,
|
||||
/// Outer process authority cap installed by Fleet/headless dispatch.
|
||||
/// `None` for ordinary interactive/root sessions.
|
||||
pub(crate) tool_authority: Option<Arc<ToolAuthorityEnvelope>>,
|
||||
@@ -780,6 +838,7 @@ impl ToolContext {
|
||||
owner_agent_name: None,
|
||||
origin_tool_call_id: None,
|
||||
origin_turn_id: None,
|
||||
operation_activity_reporter: None,
|
||||
tool_authority,
|
||||
trust_mode,
|
||||
sandbox_policy: SandboxPolicy::None,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! removed in v0.9.3.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use codewhale_protocol::engine_owner::OwnerActivityKind;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::canonical_action::required_action;
|
||||
@@ -12,6 +13,7 @@ use super::dev_server_readiness::WaitForDevServerTool;
|
||||
use super::fetch_url::FetchUrlTool;
|
||||
use super::spec::{
|
||||
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
|
||||
with_operation_activity,
|
||||
};
|
||||
use super::web_search::WebSearchTool;
|
||||
|
||||
@@ -200,9 +202,24 @@ impl ToolSpec for WebTool {
|
||||
let input = self.strip_action(input)?;
|
||||
|
||||
match action.as_str() {
|
||||
"search" => WebSearchTool.execute(input, context).await,
|
||||
"fetch" => FetchUrlTool.execute(input, context).await,
|
||||
"wait" => WaitForDevServerTool.execute(input, context).await,
|
||||
"search" => with_operation_activity(
|
||||
context,
|
||||
OwnerActivityKind::Browsing,
|
||||
WebSearchTool.execute(input, context),
|
||||
)
|
||||
.await,
|
||||
"fetch" => with_operation_activity(
|
||||
context,
|
||||
OwnerActivityKind::Browsing,
|
||||
FetchUrlTool.execute(input, context),
|
||||
)
|
||||
.await,
|
||||
"wait" => with_operation_activity(
|
||||
context,
|
||||
OwnerActivityKind::Browsing,
|
||||
WaitForDevServerTool.execute(input, context),
|
||||
)
|
||||
.await,
|
||||
other => Err(ToolError::invalid_input(format!(
|
||||
"Unknown Web action \"{other}\"; nothing was run. Pass one of: {}.",
|
||||
Self::ACTIONS.join(", ")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Live view transport replacing the app-local QuickJS Worker. Only immutable
|
||||
//! projections cross back to Ratatui; network, raster and encoding stay here.
|
||||
use super::{graphics, owner};
|
||||
use codewhale_protocol::engine_owner::EngineOwnerProjection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
@@ -26,13 +27,7 @@ pub struct Pose {
|
||||
pub style: Style,
|
||||
pub state: Value,
|
||||
}
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
pub struct Activity {
|
||||
pub label: String,
|
||||
pub tool: Option<String>,
|
||||
pub observed: bool,
|
||||
pub parallel: usize,
|
||||
}
|
||||
pub type Activity = EngineOwnerProjection;
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Scene {
|
||||
@@ -71,6 +66,12 @@ impl Scene {
|
||||
.chain(&self.still.points)
|
||||
.flatten()
|
||||
.all(|p| p.is_finite() && p.abs() <= 8.0)
|
||||
&& self.activity.as_ref().is_none_or(|activity| {
|
||||
activity.is_valid()
|
||||
&& activity.cursor == self.cursor
|
||||
&& activity.session_id.as_deref()
|
||||
== (self.source != "unattached").then_some(self.source.as_str())
|
||||
})
|
||||
}
|
||||
}
|
||||
#[derive(Clone)]
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
//! Views of one durable local pet. Engine events are projected here once;
|
||||
//! the companion owns simulation, persistence and the sole audio output.
|
||||
use crate::core::{
|
||||
events::Event,
|
||||
protocol_parity::{ProtocolIds, event_to_protocol},
|
||||
};
|
||||
use crate::core::events::{Event, TurnOutcomeStatus};
|
||||
use crate::tui::{
|
||||
app::{App, StatusToastLevel},
|
||||
underwater::ShellPhase,
|
||||
@@ -46,6 +43,7 @@ pub enum Control {
|
||||
pub struct PetWatch {
|
||||
worker: Option<Worker>,
|
||||
session: Option<String>,
|
||||
active_turn_id: Option<String>,
|
||||
last_tick: Option<Instant>,
|
||||
failed: bool,
|
||||
/// The companion said it cannot be reached. Cleared by the next frame.
|
||||
@@ -88,6 +86,7 @@ impl PetWatch {
|
||||
fn reset(&mut self, session: Option<String>) {
|
||||
self.worker = None;
|
||||
self.session = session;
|
||||
self.active_turn_id = None;
|
||||
self.raster = None;
|
||||
self.failed = false;
|
||||
self.unavailable = false;
|
||||
@@ -123,12 +122,21 @@ impl PetWatch {
|
||||
}
|
||||
pub fn observe(&mut self, event: &Event, session: Option<&str>, _now: Instant) {
|
||||
if self.session.as_deref() != session {
|
||||
let keep_worker = self.worker.is_some() || self.enabled;
|
||||
self.reset(session.map(str::to_owned));
|
||||
return;
|
||||
if keep_worker {
|
||||
self.ensure(self.session.clone());
|
||||
}
|
||||
}
|
||||
if let Some(text) = metadata(event) {
|
||||
if let Event::TurnStarted { turn_id, .. } = event {
|
||||
self.active_turn_id = Some(turn_id.clone());
|
||||
}
|
||||
if let Some(text) = metadata(event, self.active_turn_id.as_deref()) {
|
||||
self.send(Command::Observe(text));
|
||||
}
|
||||
if matches!(event, Event::TurnComplete { .. }) {
|
||||
self.active_turn_id = None;
|
||||
}
|
||||
}
|
||||
fn send(&mut self, command: Command) {
|
||||
if self
|
||||
@@ -207,61 +215,59 @@ impl PetWatch {
|
||||
}
|
||||
/// Existing protocol projection defines the variant names. This allowlist
|
||||
/// removes payloads before the bounded worker queue; no model text escapes.
|
||||
fn metadata(event: &Event) -> Option<String> {
|
||||
if !matches!(
|
||||
event,
|
||||
Event::TurnStarted { .. }
|
||||
| Event::TurnComplete { .. }
|
||||
| Event::MessageStarted { .. }
|
||||
| Event::MessageDelta { .. }
|
||||
| Event::MessageComplete { .. }
|
||||
| Event::ThinkingStarted { .. }
|
||||
| Event::ThinkingDelta { .. }
|
||||
| Event::ThinkingComplete { .. }
|
||||
| Event::ToolCallStarted { .. }
|
||||
| Event::ToolCallHeartbeat
|
||||
| Event::ToolCallComplete { .. }
|
||||
| Event::AgentSpawned { .. }
|
||||
| Event::AgentProgress { .. }
|
||||
| Event::AgentComplete { .. }
|
||||
| Event::ApprovalRequired { .. }
|
||||
| Event::UserInputRequired { .. }
|
||||
| Event::Error { .. }
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let ids = ProtocolIds {
|
||||
thread_id: "foreground".to_owned().into(),
|
||||
session_id: "foreground".to_owned().into(),
|
||||
fn metadata(event: &Event, turn_id: Option<&str>) -> Option<String> {
|
||||
let value = match event {
|
||||
Event::TurnStarted { turn_id, .. } => json!({"event":"turn_started","turn_id":turn_id}),
|
||||
Event::TurnComplete { status, .. } => json!({
|
||||
"event":"turn_complete",
|
||||
"turn_id":turn_id,
|
||||
"turn_outcome":match status {
|
||||
TurnOutcomeStatus::Completed => "completed",
|
||||
TurnOutcomeStatus::Interrupted => "interrupted",
|
||||
TurnOutcomeStatus::Failed => "failed",
|
||||
}
|
||||
}),
|
||||
Event::MessageStarted { index } => json!({"event":"message_started","index":index}),
|
||||
Event::MessageDelta { index, .. } => json!({"event":"response_delta","index":index,"channel":"text"}),
|
||||
Event::MessageComplete { index } => json!({"event":"message_complete","index":index}),
|
||||
Event::ThinkingStarted { index } => json!({"event":"thinking_started","index":index}),
|
||||
Event::ThinkingDelta { index, .. } => json!({"event":"response_delta","index":index,"channel":"reasoning"}),
|
||||
Event::ThinkingComplete { index } => json!({"event":"thinking_complete","index":index}),
|
||||
Event::OperationActivityStarted { span_id, activity_kind } => json!({
|
||||
"event":"operation_activity_started",
|
||||
"span_id":span_id,
|
||||
"activity_kind":activity_kind
|
||||
}),
|
||||
Event::OperationActivityCompleted { span_id, activity_kind, outcome } => json!({
|
||||
"event":"operation_activity_completed",
|
||||
"span_id":span_id,
|
||||
"activity_kind":activity_kind,
|
||||
"outcome":outcome
|
||||
}),
|
||||
Event::ToolCallHeartbeat => json!({"event":"tool_call_heartbeat"}),
|
||||
// A human approval denial clears the authoritative waiting state. It
|
||||
// carries only the Engine call id and typed outcome; the tool name and
|
||||
// error text remain in the normal Engine transcript path.
|
||||
Event::ToolCallComplete { id, result: Err(crate::tools::spec::ToolError::PermissionDenied { .. }), .. } => json!({
|
||||
"event":"approval_resolved","id":id,"outcome":"denied"
|
||||
}),
|
||||
Event::ToolCallComplete { id, result: Err(crate::tools::spec::ToolError::Cancelled { .. }), .. } => json!({
|
||||
"event":"approval_resolved","id":id,"outcome":"cancelled"
|
||||
}),
|
||||
Event::AgentSpawned { id, .. } => json!({"event":"agent_spawned","id":id}),
|
||||
Event::AgentProgress { id, activity, .. } => json!({
|
||||
"event":"agent_progress",
|
||||
"id":id,
|
||||
"worker_status":crate::core::protocol_parity::worker_status_str(activity.worker_status)
|
||||
}),
|
||||
Event::AgentComplete { id, .. } => json!({"event":"agent_complete","id":id}),
|
||||
Event::ApprovalRequired { id, .. } | Event::UserInputRequired { id, .. } => json!({
|
||||
"event":if matches!(event, Event::ApprovalRequired { .. }) {"approval_required"} else {"user_input_required"},
|
||||
"id":id
|
||||
}),
|
||||
_ => return None,
|
||||
};
|
||||
let projected = serde_json::to_value(event_to_protocol(event, &ids)).ok()?;
|
||||
let mut out = serde_json::Map::new();
|
||||
for key in [
|
||||
"event",
|
||||
"index",
|
||||
"channel",
|
||||
"tool_call_id",
|
||||
"tool_name",
|
||||
"id",
|
||||
"worker_status",
|
||||
] {
|
||||
if let Some(value) = projected.get(key) {
|
||||
out.insert(key.to_owned(), value.clone());
|
||||
}
|
||||
}
|
||||
if let Some(status) = projected.pointer("/activity/worker_status") {
|
||||
out.insert("worker_status".into(), status.clone());
|
||||
}
|
||||
let failed = projected.get("status") == Some(&json!("failed"))
|
||||
|| projected.get("worker_status") == Some(&json!("failed"))
|
||||
|| projected.pointer("/activity/worker_status") == Some(&json!("failed"))
|
||||
|| projected.pointer("/result/outcome") == Some(&json!("err"))
|
||||
|| projected.pointer("/result/success") == Some(&Value::Bool(false));
|
||||
if failed {
|
||||
out.insert("failed".into(), Value::Bool(true));
|
||||
}
|
||||
let json = serde_json::to_string(&out).ok()?;
|
||||
// Producer data is bounded before crossing the queue, including tool names.
|
||||
let json = serde_json::to_string(&value).ok()?;
|
||||
(json.len() <= 16_384).then_some(json)
|
||||
}
|
||||
|
||||
@@ -321,9 +327,10 @@ pub fn observe(app: &mut App, event: &Event, now: Instant) {
|
||||
app.pet_watch.work_complete = false;
|
||||
app.pet_watch.result_scroll = 0;
|
||||
app.pet_watch.work_enter_pending = app.pet_watch.enabled;
|
||||
} else if matches!(event, Event::TurnComplete { .. }) {
|
||||
} else if let Event::TurnComplete { status, .. } = event {
|
||||
app.pet_watch.work_enter_pending = false;
|
||||
app.pet_watch.work_complete = app.view_stack.top_kind() == Some(ModalKind::PetHabitat);
|
||||
app.pet_watch.work_complete = *status == TurnOutcomeStatus::Completed
|
||||
&& app.view_stack.top_kind() == Some(ModalKind::PetHabitat);
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
}
|
||||
@@ -482,15 +489,14 @@ fn render_tank(frame: &mut Frame, area: Rect, app: &mut App) {
|
||||
);
|
||||
if let Some(activity) = &r.scene.activity
|
||||
&& activity.observed
|
||||
&& activity.freshness == codewhale_protocol::engine_owner::OwnerFreshness::Fresh
|
||||
&& r.frame_changed.elapsed().as_millis() < 800
|
||||
{
|
||||
text = format!(
|
||||
"{} · {}",
|
||||
activity.tool.as_deref().unwrap_or(&activity.label),
|
||||
text
|
||||
);
|
||||
if activity.parallel > 0 {
|
||||
text.push_str(&format!(" · ×{}", activity.parallel));
|
||||
if let Some(kind) = activity.activity_kind {
|
||||
text = format!("{} · {}", kind.as_str(), text);
|
||||
}
|
||||
if activity.parallel_agent_count > 0 {
|
||||
text.push_str(&format!(" · ×{}", activity.parallel_agent_count));
|
||||
}
|
||||
}
|
||||
text
|
||||
@@ -860,23 +866,69 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreground_projection_keeps_lifecycle_and_excludes_private_payloads() {
|
||||
fn only_completed_turns_set_the_pet_done_state() {
|
||||
let mut app = crate::test_support::test_app_with_options(
|
||||
crate::test_support::test_tui_options("."),
|
||||
);
|
||||
app.onboarding = crate::tui::app::OnboardingState::None;
|
||||
app.redaction_gate = false;
|
||||
app.pet_watch.detach_for_test();
|
||||
open_habitat(&mut app);
|
||||
|
||||
let finish = |status| Event::TurnComplete {
|
||||
usage: Default::default(),
|
||||
parent_route_usage: Default::default(),
|
||||
routed_usage_dropped_records: 0,
|
||||
status,
|
||||
error: None,
|
||||
tool_catalog: None,
|
||||
base_url: None,
|
||||
};
|
||||
for status in [TurnOutcomeStatus::Interrupted, TurnOutcomeStatus::Failed] {
|
||||
observe(
|
||||
&mut app,
|
||||
&Event::TurnStarted {
|
||||
turn_id: format!("turn-{:?}", status),
|
||||
created_at: chrono::Utc::now(),
|
||||
route: None,
|
||||
},
|
||||
Instant::now(),
|
||||
);
|
||||
observe(&mut app, &finish(status), Instant::now());
|
||||
assert!(!app.pet_watch.work_complete, "{status:?} cannot mark Done");
|
||||
}
|
||||
|
||||
observe(
|
||||
&mut app,
|
||||
&Event::TurnStarted {
|
||||
turn_id: "turn-completed".into(),
|
||||
created_at: chrono::Utc::now(),
|
||||
route: None,
|
||||
},
|
||||
Instant::now(),
|
||||
);
|
||||
observe(
|
||||
&mut app,
|
||||
&finish(TurnOutcomeStatus::Completed),
|
||||
Instant::now(),
|
||||
);
|
||||
assert!(app.pet_watch.work_complete);
|
||||
assert!(app.pet_watch.worker.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreground_projection_forwards_only_typed_owner_metadata() {
|
||||
let call = metadata(&Event::ToolCallStarted {
|
||||
id: "call-a".into(),
|
||||
name: "exec_command".into(),
|
||||
input: json!({"command":"PRIVATE TOOL INPUT"}),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&call).unwrap(),
|
||||
json!({
|
||||
"event":"tool_call_started", "tool_call_id":"call-a", "tool_name":"exec_command",
|
||||
})
|
||||
);
|
||||
}, Some("turn-a"));
|
||||
assert!(call.is_none(), "tool names and inputs are not activity facts");
|
||||
|
||||
let thought = metadata(&Event::ThinkingDelta {
|
||||
index: 2,
|
||||
content: "PRIVATE REASONING".into(),
|
||||
})
|
||||
}, None)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&thought).unwrap(),
|
||||
@@ -885,11 +937,71 @@ mod tests {
|
||||
let message = metadata(&Event::MessageDelta {
|
||||
index: 3,
|
||||
content: "PRIVATE MESSAGE".into(),
|
||||
})
|
||||
}, None)
|
||||
.unwrap();
|
||||
|
||||
let operation = metadata(
|
||||
&Event::OperationActivityStarted {
|
||||
span_id: "private-internal-span".into(),
|
||||
activity_kind: codewhale_protocol::engine_owner::OwnerActivityKind::Computer,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&operation).unwrap(),
|
||||
json!({
|
||||
"event":"operation_activity_started",
|
||||
"span_id":"private-internal-span",
|
||||
"activity_kind":"computer"
|
||||
})
|
||||
);
|
||||
let completed = metadata(
|
||||
&Event::OperationActivityCompleted {
|
||||
span_id: "private-internal-span".into(),
|
||||
activity_kind: codewhale_protocol::engine_owner::OwnerActivityKind::Editing,
|
||||
outcome: codewhale_protocol::engine_owner::OwnerOperationOutcome::Denied,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&completed).unwrap(),
|
||||
json!({
|
||||
"event":"operation_activity_completed",
|
||||
"span_id":"private-internal-span",
|
||||
"activity_kind":"editing",
|
||||
"outcome":"denied"
|
||||
})
|
||||
);
|
||||
|
||||
let turn = metadata(
|
||||
&Event::TurnComplete {
|
||||
usage: Default::default(),
|
||||
parent_route_usage: Default::default(),
|
||||
routed_usage_dropped_records: 0,
|
||||
status: TurnOutcomeStatus::Completed,
|
||||
error: None,
|
||||
tool_catalog: None,
|
||||
base_url: None,
|
||||
},
|
||||
Some("stable-turn-a"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&turn).unwrap(),
|
||||
json!({
|
||||
"event":"turn_complete",
|
||||
"turn_id":"stable-turn-a",
|
||||
"turn_outcome":"completed"
|
||||
})
|
||||
);
|
||||
|
||||
assert!(!message.contains("PRIVATE"));
|
||||
assert!(!call.contains("PRIVATE"));
|
||||
assert!(call.is_none());
|
||||
assert!(!thought.contains("PRIVATE"));
|
||||
assert!(!operation.contains("tool_name"));
|
||||
assert!(!operation.contains("PRIVATE"));
|
||||
}
|
||||
|
||||
/// The pet's multi-agent count is derived on the JS side from
|
||||
@@ -913,7 +1025,7 @@ mod tests {
|
||||
spawn_depth: 2,
|
||||
model: "PRIVATE CHILD MODEL".into(),
|
||||
route_source: Some("task.model".into()),
|
||||
})
|
||||
}, None)
|
||||
.expect("agent spawns are observed");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&spawned).unwrap(),
|
||||
@@ -934,7 +1046,7 @@ mod tests {
|
||||
},
|
||||
parent_run_id: Some("run-9".into()),
|
||||
spawn_depth: 2,
|
||||
})
|
||||
}, None)
|
||||
.expect("agent progress is observed");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&progress).unwrap(),
|
||||
@@ -950,7 +1062,7 @@ mod tests {
|
||||
spawn_depth: Some(2),
|
||||
continuable: Some(false),
|
||||
usage: None,
|
||||
})
|
||||
}, None)
|
||||
.expect("agent completions are observed");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&complete).unwrap(),
|
||||
|
||||
@@ -556,6 +556,17 @@ fn run_world(
|
||||
frame["cursor"] = json!(saved.cursor);
|
||||
frame["source"] = json!(saved.source);
|
||||
frame["sourceRevision"] = json!(saved.source_revision);
|
||||
if let Some(activity) = frame["activity"].as_object_mut() {
|
||||
activity.insert(
|
||||
"sessionId".into(),
|
||||
if saved.source == "unattached" {
|
||||
Value::Null
|
||||
} else {
|
||||
json!(saved.source)
|
||||
},
|
||||
);
|
||||
activity.insert("cursor".into(), json!(saved.cursor));
|
||||
}
|
||||
// Presentation material keeps missing coverage legible. The core
|
||||
// pigment, score, particle digest and recording are unchanged.
|
||||
for key in ["", "still"] {
|
||||
|
||||
+341
-90
@@ -1,139 +1,390 @@
|
||||
import { PET_MAX_SECONDS } from './pet-sim.js';
|
||||
import type { Category, WhaleEvent } from './model.js';
|
||||
import { toolActivity, toolCategory } from './codewhale.js';
|
||||
import { compilePetTelemetry, PET_BIN_MS, type PetBucket } from './pet-telemetry.js';
|
||||
|
||||
/** Read-only adapter for codewhale_protocol::EventMsg metadata. The foreground
|
||||
* Engine is the event owner. This replaces no turn loop: it only translates
|
||||
* lifecycle observations to event-v1 for the same pet bucketer used by imports.
|
||||
* Text, inputs and results are neither accepted nor retained. */
|
||||
// Engine emits a liveness pulse every 10 seconds for a running operation.
|
||||
// Keep a small scheduling margin so one delayed pulse does not erase a valid
|
||||
// long operation; after that, the projection becomes generic.
|
||||
export const ENGINE_OWNER_STALE_MS = 12_000;
|
||||
export type OwnerActivityKind = 'reading' | 'editing' | 'searching' | 'testing' | 'executing'
|
||||
| 'browsing' | 'computer' | 'memory' | 'tool' | 'thinking' | 'responding' | 'delegating';
|
||||
export type OwnerPresence = 'unknown' | 'working' | 'needs_you' | 'done' | 'idle';
|
||||
export type OwnerFreshness = 'missing' | 'fresh' | 'stale';
|
||||
export type OwnerTurnOutcome = 'completed' | 'interrupted' | 'failed';
|
||||
export type OwnerOperationOutcome = 'succeeded' | 'failed' | 'cancelled' | 'denied';
|
||||
|
||||
export interface EngineOwnerProjection {
|
||||
schemaVersion: 1;
|
||||
/** Added by the existing owner after selecting its session source. */
|
||||
sessionId: string | null;
|
||||
/** Added by the existing owner; monotonic for this session source. */
|
||||
cursor: number;
|
||||
observed: boolean;
|
||||
freshness: OwnerFreshness;
|
||||
authoritativePresence: OwnerPresence;
|
||||
activityKind: OwnerActivityKind | null;
|
||||
observedAtMs: number | null;
|
||||
parallelAgentCount: number;
|
||||
activeSpans: { activityKind: OwnerActivityKind; startedAtMs: number }[];
|
||||
turnId: string | null;
|
||||
turnOutcome: OwnerTurnOutcome | null;
|
||||
doneEffectId: string | null;
|
||||
failedToolAge: { activityKind: OwnerActivityKind; ageMs: number } | null;
|
||||
}
|
||||
|
||||
type SpanGroup = 'operation' | 'agent' | 'thinking' | 'responding';
|
||||
interface ActiveSpan {
|
||||
key: string;
|
||||
activityKind: OwnerActivityKind;
|
||||
startedAtMs: number;
|
||||
event: WhaleEvent;
|
||||
group: SpanGroup;
|
||||
}
|
||||
interface FailedTool {
|
||||
activityKind: OwnerActivityKind;
|
||||
failedAtMs: number;
|
||||
}
|
||||
|
||||
const ACTIVITY_KINDS: readonly OwnerActivityKind[] = [
|
||||
'reading', 'editing', 'searching', 'testing', 'executing', 'browsing', 'computer',
|
||||
'memory', 'tool', 'thinking', 'responding', 'delegating',
|
||||
];
|
||||
const TURN_OUTCOMES: readonly OwnerTurnOutcome[] = ['completed', 'interrupted', 'failed'];
|
||||
const OPERATION_OUTCOMES: readonly OwnerOperationOutcome[] = ['succeeded', 'failed', 'cancelled', 'denied'];
|
||||
const APPROVAL_OUTCOMES = ['approved', 'denied', 'cancelled'] as const;
|
||||
|
||||
function categoryFor(kind: OwnerActivityKind): Category {
|
||||
switch (kind) {
|
||||
case 'reading': case 'editing': case 'searching': return 'filesystem';
|
||||
case 'testing': case 'executing': return 'code';
|
||||
case 'browsing': return 'browser';
|
||||
case 'computer': case 'tool': return 'tool';
|
||||
case 'memory': return 'memory';
|
||||
case 'thinking': return 'reasoning';
|
||||
case 'responding': return 'communication';
|
||||
case 'delegating': return 'agent';
|
||||
}
|
||||
}
|
||||
|
||||
/** Read-only reducer for the Engine owner's typed metadata. It keeps using the
|
||||
* existing pet telemetry tape and physics owner; it neither recognizes tool
|
||||
* names nor accepts transcript text, arguments, commands, or results. */
|
||||
export class PetEngineTelemetry {
|
||||
private events: WhaleEvent[] = [];
|
||||
private active = new Map<string, WhaleEvent>();
|
||||
private active = new Map<string, ActiveSpan>();
|
||||
private waiting: WhaleEvent | undefined;
|
||||
private sequence = 0;
|
||||
private lastTime = 0;
|
||||
private lastObservedAt: number | undefined;
|
||||
private turnId: string | undefined;
|
||||
private turnOutcome: OwnerTurnOutcome | undefined;
|
||||
private terminalAt: number | undefined;
|
||||
private lastFailedTool: FailedTool | undefined;
|
||||
private completedSpans = new Set<string>();
|
||||
private completedTurns = new Set<string>();
|
||||
|
||||
/** Ephemeral receipts. Replay tapes retain measured categories, not tool
|
||||
* names. Restoring/disconnecting clears these captions. Never mutates world. */
|
||||
activity(at: number) {
|
||||
const fresh = (e: WhaleEvent) => at >= e.startTime && at - e.endTime <= PET_BIN_MS * 2;
|
||||
const spans = [...this.active].filter(([, e]) => fresh(e));
|
||||
const parallel = spans.filter(([key]) => key.startsWith('agent:')).length;
|
||||
const cue = ([key, e]: [string, WhaleEvent]) => ({
|
||||
...(key.startsWith('tool:') ? toolActivity(e.name) : key.startsWith('thinking:')
|
||||
? { kind: 'thinking', label: 'Thinking' } : key.startsWith('agent:')
|
||||
? { kind: 'delegating', label: 'Coordinating agents' } : { kind: 'responding', label: 'Writing the response' }),
|
||||
tool: key.startsWith('tool:') ? e.name.replace(/[^a-zA-Z0-9_.:-]/g, '').slice(0, 96) : null,
|
||||
sinceMs: e.startTime,
|
||||
});
|
||||
const active = spans.filter(([key]) => !key.startsWith('agent:')).slice(-4).reverse().map(cue);
|
||||
const error = [...this.events].reverse().find(e => e.category === 'error' && fresh(e));
|
||||
const primary = this.waiting && fresh(this.waiting)
|
||||
? { kind: 'waiting', label: 'Waiting for you', tool: null, sinceMs: this.waiting.startTime }
|
||||
: error ? { kind: 'error', label: 'An operation failed', tool: null, sinceMs: error.startTime }
|
||||
: active[0] ?? (parallel ? cue(spans.find(([key]) => key.startsWith('agent:'))!)
|
||||
: { kind: 'unknown', label: 'Activity unobserved', tool: null, sinceMs: at });
|
||||
return { ...primary, observed: primary.kind !== 'unknown', parallel, active };
|
||||
/** Ephemeral safe read projection. Span ids are retained only in this
|
||||
* reducer to correlate trusted lifecycle events and never leave the owner. */
|
||||
activity(at: number): EngineOwnerProjection {
|
||||
const observed = this.lastObservedAt !== undefined;
|
||||
const freshness: OwnerFreshness = !observed ? 'missing'
|
||||
: at - this.lastObservedAt! <= ENGINE_OWNER_STALE_MS ? 'fresh' : 'stale';
|
||||
const fresh = freshness === 'fresh';
|
||||
const active = fresh
|
||||
? [...this.active.values()]
|
||||
.filter(span => at >= span.startedAtMs && at - span.event.endTime <= ENGINE_OWNER_STALE_MS)
|
||||
.sort((a, b) => b.startedAtMs - a.startedAtMs || a.key.localeCompare(b.key))
|
||||
: [];
|
||||
const agents = active.filter(span => span.group === 'agent').length;
|
||||
const terminalFresh = fresh && this.terminalAt !== undefined
|
||||
&& at >= this.terminalAt && at - this.terminalAt <= ENGINE_OWNER_STALE_MS;
|
||||
let authoritativePresence: OwnerPresence = 'unknown';
|
||||
if (fresh) {
|
||||
if (this.waiting) authoritativePresence = 'needs_you';
|
||||
else if (terminalFresh && this.turnOutcome === 'completed' && this.turnId) authoritativePresence = 'done';
|
||||
else if (terminalFresh && this.turnOutcome) authoritativePresence = 'idle';
|
||||
else if (active.length > 0 || this.turnId) authoritativePresence = 'working';
|
||||
}
|
||||
const terminal = this.turnOutcome !== undefined && this.turnId !== undefined;
|
||||
const activityKind = fresh && authoritativePresence !== 'needs_you'
|
||||
&& authoritativePresence !== 'done' && authoritativePresence !== 'idle'
|
||||
? active[0]?.activityKind
|
||||
?? (this.lastFailedTool && at >= this.lastFailedTool.failedAtMs
|
||||
&& at - this.lastFailedTool.failedAtMs <= ENGINE_OWNER_STALE_MS
|
||||
? this.lastFailedTool.activityKind : null)
|
||||
: null;
|
||||
const failedAge = fresh && this.lastFailedTool
|
||||
&& at >= this.lastFailedTool.failedAtMs
|
||||
&& at - this.lastFailedTool.failedAtMs <= ENGINE_OWNER_STALE_MS
|
||||
? { activityKind: this.lastFailedTool.activityKind, ageMs: at - this.lastFailedTool.failedAtMs }
|
||||
: null;
|
||||
const doneEffectId = authoritativePresence === 'done' && terminalFresh
|
||||
&& this.turnOutcome === 'completed' && this.turnId ? this.turnId : null;
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
sessionId: null,
|
||||
cursor: 0,
|
||||
observed,
|
||||
freshness,
|
||||
authoritativePresence,
|
||||
activityKind: activityKind ?? null,
|
||||
observedAtMs: this.lastObservedAt ?? null,
|
||||
parallelAgentCount: fresh ? agents : 0,
|
||||
activeSpans: active.slice(0, 4).map(span => ({
|
||||
activityKind: span.activityKind,
|
||||
startedAtMs: span.startedAtMs,
|
||||
})),
|
||||
turnId: this.turnId ?? null,
|
||||
turnOutcome: this.turnOutcome ?? null,
|
||||
doneEffectId,
|
||||
failedToolAge: failedAge,
|
||||
};
|
||||
}
|
||||
|
||||
private add(name: string, category: Category, at: number, agentId = 'parent', continuation = false): WhaleEvent {
|
||||
if (this.events.length >= 8192) throw new Error('Pet Engine observation window is full.');
|
||||
const e: WhaleEvent = { schemaVersion: 1, id: `engine:${this.sequence++}`, traceId: 'foreground',
|
||||
startTime: at, endTime: at, name, category, agentId, status: 'running',
|
||||
attributes: continuation ? { 'whalesong.continuation': true } : {} };
|
||||
this.events.push(e); return e;
|
||||
const event: WhaleEvent = {
|
||||
schemaVersion: 1,
|
||||
id: `engine:${this.sequence++}`,
|
||||
traceId: 'foreground',
|
||||
startTime: at,
|
||||
endTime: at,
|
||||
name,
|
||||
category,
|
||||
agentId,
|
||||
status: 'running',
|
||||
attributes: continuation ? { 'whalesong.continuation': true } : {},
|
||||
};
|
||||
this.events.push(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
private start(key: string, kind: OwnerActivityKind, group: SpanGroup, at: number, agentId = 'parent'): void {
|
||||
if (this.active.has(key)) return;
|
||||
if (this.active.size >= 256) throw new Error('Too many active Engine pet spans.');
|
||||
const event = this.add(group === 'agent' ? 'agent' : group === 'thinking' ? 'thinking'
|
||||
: group === 'responding' ? 'assistant_message' : 'operation', categoryFor(kind), at, agentId);
|
||||
this.active.set(key, { key, activityKind: kind, startedAtMs: at, event, group });
|
||||
}
|
||||
|
||||
private pulse(key: string, at: number): void {
|
||||
const e = this.active.get(key);
|
||||
if (!e) return;
|
||||
// A resumed stream does not assert coverage across its silent interval.
|
||||
if (at - e.endTime > PET_BIN_MS * 2) {
|
||||
this.active.set(key, this.add(e.name, e.category, at, e.agentId, true));
|
||||
} else e.endTime = at;
|
||||
const span = this.active.get(key);
|
||||
if (!span) return;
|
||||
if (at - span.event.endTime > ENGINE_OWNER_STALE_MS) {
|
||||
const event = this.add(span.event.name, span.event.category, at, span.event.agentId, true);
|
||||
this.active.set(key, { ...span, startedAtMs: at, event });
|
||||
} else {
|
||||
span.event.endTime = at;
|
||||
}
|
||||
}
|
||||
|
||||
/** Transactional batch copy; failed validation cannot accept half a packet. */
|
||||
private finish(key: string, at: number, status: 'success' | 'error' | 'unknown' = 'success'): ActiveSpan | undefined {
|
||||
if (!this.active.has(key)) return undefined;
|
||||
this.pulse(key, at);
|
||||
const span = this.active.get(key);
|
||||
if (!span) return undefined;
|
||||
span.event.endTime = at;
|
||||
span.event.status = status;
|
||||
this.active.delete(key);
|
||||
return span;
|
||||
}
|
||||
|
||||
private addOnce(set: Set<string>, id: string, maximum: number): boolean {
|
||||
if (set.has(id)) return false;
|
||||
set.add(id);
|
||||
while (set.size > maximum) set.delete(set.values().next().value as string);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Transactional batch copy; validation errors cannot accept half a batch. */
|
||||
clone(): PetEngineTelemetry {
|
||||
const next = new PetEngineTelemetry();
|
||||
const copy = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
|
||||
next.events = copy(this.events);
|
||||
const spans = new Map(next.events.map(event => [event.id, event]));
|
||||
// Active and waiting spans must still reference their journal entry so a
|
||||
// later heartbeat extends the coverage consumed by bucket().
|
||||
const span = (event: WhaleEvent) => spans.get(event.id) ?? copy(event);
|
||||
next.active = new Map(Array.from(this.active, ([key, event]) => [key, span(event)]));
|
||||
next.waiting = this.waiting ? span(this.waiting) : undefined;
|
||||
next.sequence = this.sequence; next.lastTime = this.lastTime;
|
||||
next.active = new Map(Array.from(this.active, ([key, span]) => [key, {
|
||||
...span,
|
||||
event: spans.get(span.event.id) ?? copy(span.event),
|
||||
}]));
|
||||
next.waiting = this.waiting ? spans.get(this.waiting.id) ?? copy(this.waiting) : undefined;
|
||||
next.sequence = this.sequence;
|
||||
next.lastTime = this.lastTime;
|
||||
next.lastObservedAt = this.lastObservedAt;
|
||||
next.turnId = this.turnId;
|
||||
next.turnOutcome = this.turnOutcome;
|
||||
next.terminalAt = this.terminalAt;
|
||||
next.lastFailedTool = this.lastFailedTool ? { ...this.lastFailedTool } : undefined;
|
||||
next.completedSpans = new Set(this.completedSpans);
|
||||
next.completedTurns = new Set(this.completedTurns);
|
||||
return next;
|
||||
}
|
||||
|
||||
observe(value: unknown, at: number): void {
|
||||
if (!Number.isFinite(at) || at < this.lastTime || at > PET_MAX_SECONDS * 1000) throw new Error('Invalid Engine pet clock.');
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid Engine pet metadata.');
|
||||
const e = value as Record<string, unknown>;
|
||||
const allowed = ['event', 'index', 'channel', 'tool_call_id', 'tool_name', 'id', 'worker_status', 'failed'];
|
||||
if (Object.keys(e).some(k => !allowed.includes(k)) || typeof e.event !== 'string'
|
||||
|| Object.values(e).some(v => typeof v === 'string' && v.length > 4096)
|
||||
|| e.channel !== undefined && !['text', 'reasoning'].includes(e.channel as string)
|
||||
|| ['tool_call_id', 'tool_name', 'id', 'worker_status'].some(k => e[k] !== undefined && typeof e[k] !== 'string')
|
||||
|| e.failed !== undefined && typeof e.failed !== 'boolean'
|
||||
|| e.index !== undefined && (!Number.isSafeInteger(e.index) || (e.index as number) < 0))
|
||||
if (!Number.isFinite(at) || at < this.lastTime || at > PET_MAX_SECONDS * 1000)
|
||||
throw new Error('Invalid Engine pet clock.');
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||
throw new Error('Invalid Engine pet metadata.');
|
||||
const event = value as Record<string, unknown>;
|
||||
const allowed = ['event', 'index', 'channel', 'span_id', 'activity_kind', 'outcome', 'id', 'worker_status', 'turn_id', 'turn_outcome'];
|
||||
const stringFields = ['event', 'span_id', 'id', 'worker_status', 'turn_id', 'turn_outcome'];
|
||||
if (Object.keys(event).some(key => !allowed.includes(key)) || typeof event.event !== 'string'
|
||||
|| Object.values(event).some(v => typeof v === 'string' && v.length > 256)
|
||||
|| stringFields.some(key => event[key] !== undefined && typeof event[key] !== 'string')
|
||||
|| event.channel !== undefined && !['text', 'reasoning'].includes(event.channel as string)
|
||||
|| event.activity_kind !== undefined && !ACTIVITY_KINDS.includes(event.activity_kind as OwnerActivityKind)
|
||||
|| event.outcome !== undefined && (typeof event.outcome !== 'string'
|
||||
|| (event.event === 'approval_resolved'
|
||||
? !APPROVAL_OUTCOMES.includes(event.outcome as typeof APPROVAL_OUTCOMES[number])
|
||||
: event.event === 'operation_activity_completed'
|
||||
? !OPERATION_OUTCOMES.includes(event.outcome as OwnerOperationOutcome)
|
||||
: true))
|
||||
|| event.turn_outcome !== undefined && !TURN_OUTCOMES.includes(event.turn_outcome as OwnerTurnOutcome)
|
||||
|| event.index !== undefined && (!Number.isSafeInteger(event.index) || (event.index as number) < 0))
|
||||
throw new Error('Invalid Engine pet metadata fields.');
|
||||
|
||||
this.lastTime = at;
|
||||
this.events = this.events.filter(span => span.endTime >= at - 12_800);
|
||||
const id = (field: string) => { const s = e[field]; if (typeof s !== 'string' || !s) throw new Error(`Missing Engine ${field}.`); return s; };
|
||||
const index = () => { if (!Number.isSafeInteger(e.index)) throw new Error('Missing Engine index.'); return String(e.index); };
|
||||
const start = (key: string, name: string, category: Category, agentId?: string) => {
|
||||
if (this.active.size >= 256 && !this.active.has(key)) throw new Error('Too many active Engine pet spans.');
|
||||
this.active.set(key, this.add(name, category, at, agentId));
|
||||
this.lastObservedAt = at;
|
||||
this.events = this.events.filter(item => item.endTime >= at - 12_800);
|
||||
const required = (key: string): string => {
|
||||
const value = event[key];
|
||||
if (typeof value !== 'string' || !value) throw new Error(`Missing Engine ${key}.`);
|
||||
return value;
|
||||
};
|
||||
const finish = (key: string) => { this.pulse(key, at); this.active.delete(key); };
|
||||
switch (e.event) {
|
||||
case 'turn_started': this.active.clear(); this.waiting = undefined; break;
|
||||
case 'message_started': start(`message:${index()}`, 'assistant_message', 'communication'); this.waiting = undefined; break;
|
||||
case 'thinking_started': start(`thinking:${index()}`, 'thinking', 'reasoning'); this.waiting = undefined; break;
|
||||
const index = (): string => {
|
||||
if (!Number.isSafeInteger(event.index)) throw new Error('Missing Engine index.');
|
||||
return String(event.index);
|
||||
};
|
||||
const activityKind = (): OwnerActivityKind => {
|
||||
if (!ACTIVITY_KINDS.includes(event.activity_kind as OwnerActivityKind))
|
||||
throw new Error('Missing Engine activity kind.');
|
||||
return event.activity_kind as OwnerActivityKind;
|
||||
};
|
||||
const startMessage = (key: string, kind: OwnerActivityKind, group: SpanGroup): void => {
|
||||
this.start(key, kind, group, at);
|
||||
this.waiting = undefined;
|
||||
};
|
||||
|
||||
switch (event.event) {
|
||||
case 'turn_started': {
|
||||
const id = required('turn_id');
|
||||
if (this.turnId !== id) {
|
||||
this.active.clear();
|
||||
this.waiting = undefined;
|
||||
this.lastFailedTool = undefined;
|
||||
this.turnId = id;
|
||||
this.turnOutcome = undefined;
|
||||
this.terminalAt = undefined;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'message_started': startMessage(`message:${index()}`, 'responding', 'responding'); break;
|
||||
case 'thinking_started': startMessage(`thinking:${index()}`, 'thinking', 'thinking'); break;
|
||||
case 'response_delta': {
|
||||
const reasoning = e.channel === 'reasoning';
|
||||
const reasoning = event.channel === 'reasoning';
|
||||
const key = `${reasoning ? 'thinking' : 'message'}:${index()}`;
|
||||
if (!this.active.has(key)) start(key, reasoning ? 'thinking' : 'assistant_message', reasoning ? 'reasoning' : 'communication');
|
||||
if (!this.active.has(key)) this.start(key, reasoning ? 'thinking' : 'responding', reasoning ? 'thinking' : 'responding', at);
|
||||
else this.pulse(key, at);
|
||||
this.waiting = undefined; break;
|
||||
}
|
||||
case 'message_complete': finish(`message:${index()}`); break;
|
||||
case 'thinking_complete': finish(`thinking:${index()}`); break;
|
||||
case 'tool_call_started': start(`tool:${id('tool_call_id')}`, id('tool_name'), toolCategory(id('tool_name'))); this.waiting = undefined; break;
|
||||
case 'tool_call_heartbeat': for (const key of this.active.keys()) if (key.startsWith('tool:')) this.pulse(key, at); break;
|
||||
case 'tool_call_complete': finish(`tool:${id('tool_call_id')}`); this.waiting = undefined; break;
|
||||
case 'agent_spawned': start(`agent:${id('id')}`, 'agent', 'agent', id('id')); break;
|
||||
this.waiting = undefined;
|
||||
break;
|
||||
}
|
||||
case 'message_complete': this.finish(`message:${index()}`, at); break;
|
||||
case 'thinking_complete': this.finish(`thinking:${index()}`, at); break;
|
||||
case 'operation_activity_started': {
|
||||
const spanId = required('span_id');
|
||||
const kind = activityKind();
|
||||
if (!this.completedSpans.has(spanId)) this.start(`operation:${spanId}`, kind, 'operation', at);
|
||||
this.waiting = undefined;
|
||||
break;
|
||||
}
|
||||
case 'operation_activity_completed': {
|
||||
const spanId = required('span_id');
|
||||
const kind = activityKind();
|
||||
if (!OPERATION_OUTCOMES.includes(event.outcome as OwnerOperationOutcome))
|
||||
throw new Error('Missing Engine operation outcome.');
|
||||
const outcome = event.outcome as OwnerOperationOutcome;
|
||||
const completed = this.finish(`operation:${spanId}`, at,
|
||||
outcome === 'failed' ? 'error' : outcome === 'succeeded' ? 'success' : 'unknown');
|
||||
if (completed && this.addOnce(this.completedSpans, spanId, 4096)) {
|
||||
if (outcome === 'failed') {
|
||||
this.add('operation_failed', 'error', at).status = 'error';
|
||||
this.lastFailedTool = { activityKind: kind, failedAtMs: at };
|
||||
}
|
||||
if (outcome === 'denied' || outcome === 'cancelled') this.waiting = undefined;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'tool_call_heartbeat':
|
||||
for (const [key, span] of this.active) if (span.group === 'operation') this.pulse(key, at);
|
||||
break;
|
||||
case 'approval_resolved': {
|
||||
required('id');
|
||||
if (!APPROVAL_OUTCOMES.includes(event.outcome as typeof APPROVAL_OUTCOMES[number]))
|
||||
throw new Error('Invalid Engine approval outcome.');
|
||||
this.waiting = undefined;
|
||||
break;
|
||||
}
|
||||
case 'agent_spawned': {
|
||||
const id = required('id');
|
||||
this.start(`agent:${id}`, 'delegating', 'agent', at, id);
|
||||
break;
|
||||
}
|
||||
case 'agent_progress': {
|
||||
const key = `agent:${id('id')}`;
|
||||
if (['completed', 'failed', 'cancelled', 'interrupted', 'budget_exhausted'].includes(e.worker_status as string)) { finish(key); break; }
|
||||
if (!this.active.has(key)) start(key, 'agent', 'agent', id('id')); else this.pulse(key, at);
|
||||
const id = required('id');
|
||||
const key = `agent:${id}`;
|
||||
if (['completed', 'failed', 'cancelled', 'interrupted', 'budget_exhausted'].includes(event.worker_status as string)) {
|
||||
this.finish(key, at);
|
||||
break;
|
||||
}
|
||||
if (!this.active.has(key)) this.start(key, 'delegating', 'agent', at, id);
|
||||
else this.pulse(key, at);
|
||||
break;
|
||||
}
|
||||
case 'agent_complete': this.finish(`agent:${required('id')}`, at); break;
|
||||
case 'approval_required': case 'user_input_required': {
|
||||
required('id');
|
||||
if (!this.waiting) {
|
||||
this.waiting = this.add('human_request', 'human', at);
|
||||
this.waiting.status = 'pending';
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'turn_complete': {
|
||||
const outcome = event.turn_outcome;
|
||||
if (!TURN_OUTCOMES.includes(outcome as OwnerTurnOutcome))
|
||||
throw new Error('Missing Engine turn outcome.');
|
||||
const id = typeof event.turn_id === 'string' && event.turn_id.length ? event.turn_id : undefined;
|
||||
this.active.clear();
|
||||
this.waiting = undefined;
|
||||
this.turnId = id;
|
||||
this.turnOutcome = outcome as OwnerTurnOutcome;
|
||||
this.terminalAt = at;
|
||||
if (outcome === 'completed' && id && this.addOnce(this.completedTurns, id, 256))
|
||||
this.add('turn_completed', 'communication', at).status = 'success';
|
||||
break;
|
||||
}
|
||||
case 'agent_complete': finish(`agent:${id('id')}`); break;
|
||||
case 'approval_required': case 'user_input_required': this.waiting = this.add('human', 'human', at); this.waiting.status = 'pending'; break;
|
||||
case 'turn_complete': this.active.clear(); this.waiting = undefined; break;
|
||||
case 'error': break;
|
||||
default: throw new Error('Unsupported Engine pet event.');
|
||||
}
|
||||
// Receipt time is the error onset; never rewrite the operation's old start.
|
||||
if (e.event === 'error' || e.failed === true) this.add('error', 'error', at).status = 'error';
|
||||
}
|
||||
|
||||
/** Waiting coverage comes from the existing typed shell's current request.
|
||||
* It can extend a witnessed request, never invent one on a mid-turn attach. */
|
||||
/** The typed shell may extend a request already witnessed in Engine events,
|
||||
* but a mid-turn attach cannot invent a NeedsYou state. */
|
||||
confirmWaiting(at: number, waiting: boolean): void {
|
||||
if (!waiting) { this.waiting = undefined; return; }
|
||||
if (this.waiting && at >= this.waiting.endTime) this.waiting.endTime = at;
|
||||
if (!waiting) {
|
||||
this.waiting = undefined;
|
||||
return;
|
||||
}
|
||||
if (this.waiting && at >= this.waiting.endTime) {
|
||||
this.waiting.endTime = at;
|
||||
this.lastObservedAt = at;
|
||||
this.lastTime = Math.max(this.lastTime, at);
|
||||
}
|
||||
}
|
||||
|
||||
bucket(sequence: number): PetBucket {
|
||||
const end = (sequence + 1) * PET_BIN_MS;
|
||||
const input = this.events.filter(e => e.startTime < end && e.endTime >= end - 12_400)
|
||||
.map(e => ({ ...e, endTime: Math.min(e.endTime, end) }));
|
||||
const input = this.events.filter(event => event.startTime < end && event.endTime >= end - 12_400)
|
||||
.map(event => ({ ...event, endTime: Math.min(event.endTime, end) }));
|
||||
return compilePetTelemetry(input, end, sequence)[0];
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,7 @@ export function AdminClient({ drafts, posted, isZh, typeLabels }: Props) {
|
||||
<div key={key} className="hairline-t hairline-b hairline-l hairline-r bg-paper">
|
||||
<div className="bg-paper-deep text-ink px-4 py-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-xs uppercase tracking-wider text-indigo">
|
||||
<span className="text-xs text-indigo">
|
||||
{isZh ? label.zh : label.en}
|
||||
</span>
|
||||
{draft.targetNumber && (
|
||||
@@ -92,13 +92,13 @@ export function AdminClient({ drafts, posted, isZh, typeLabels }: Props) {
|
||||
<button
|
||||
onClick={() => handleAction(key, "post", editBody)}
|
||||
disabled={loading === key}
|
||||
className="px-4 py-2 bg-indigo text-paper font-mono text-xs uppercase tracking-wider hover:bg-indigo-deep transition-colors disabled:opacity-50"
|
||||
className="portal-button portal-button-primary disabled:opacity-50"
|
||||
>
|
||||
{isZh ? "确认发布" : "Post edited"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditing(null)}
|
||||
className="px-4 py-2 hairline-t hairline-b hairline-l hairline-r font-mono text-xs uppercase tracking-wider hover:bg-paper-deep transition-colors"
|
||||
className="portal-button portal-button-secondary"
|
||||
>
|
||||
{isZh ? "取消" : "Cancel"}
|
||||
</button>
|
||||
@@ -113,20 +113,20 @@ export function AdminClient({ drafts, posted, isZh, typeLabels }: Props) {
|
||||
<button
|
||||
onClick={() => handleAction(key, "post")}
|
||||
disabled={loading === key}
|
||||
className="px-4 py-2 bg-indigo text-paper font-mono text-xs uppercase tracking-wider hover:bg-indigo-deep transition-colors disabled:opacity-50"
|
||||
className="portal-button portal-button-primary disabled:opacity-50"
|
||||
>
|
||||
{isZh ? "发布评论" : "Post as comment"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => startEdit(draft)}
|
||||
className="px-4 py-2 hairline-t hairline-b hairline-l hairline-r font-mono text-xs uppercase tracking-wider hover:bg-paper-deep transition-colors"
|
||||
className="portal-button portal-button-secondary"
|
||||
>
|
||||
{isZh ? "编辑后发布" : "Edit & post"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAction(key, "discard")}
|
||||
disabled={loading === key}
|
||||
className="px-4 py-2 font-mono text-xs uppercase tracking-wider text-ink-mute hover:text-indigo transition-colors disabled:opacity-50"
|
||||
className="px-4 py-2 text-sm text-ink-mute hover:text-indigo transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isZh ? "丢弃" : "Discard"}
|
||||
</button>
|
||||
@@ -152,7 +152,7 @@ export function AdminClient({ drafts, posted, isZh, typeLabels }: Props) {
|
||||
return (
|
||||
<div key={key} className="hairline-t py-3 px-4 opacity-60">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="font-mono text-xs uppercase tracking-wider text-ink-mute">
|
||||
<span className="text-xs text-ink-mute">
|
||||
{isZh ? label.zh : label.en}
|
||||
</span>
|
||||
{draft.targetNumber && (
|
||||
|
||||
@@ -47,7 +47,7 @@ function LoginForm({ locale, error }: { locale: string; error: boolean }) {
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full px-5 py-3 bg-indigo text-paper font-mono text-sm uppercase tracking-wider hover:bg-indigo-deep transition-colors"
|
||||
className="w-full portal-button portal-button-primary"
|
||||
>
|
||||
{isZh ? "登录 →" : "Sign in →"}
|
||||
</button>
|
||||
@@ -128,7 +128,7 @@ export default async function AdminPage({
|
||||
<form method="POST" action={`/api/admin/logout?locale=${locale}`}>
|
||||
<button
|
||||
type="submit"
|
||||
className="font-mono text-xs text-ink-mute hover:text-indigo uppercase tracking-wider"
|
||||
className="text-xs text-ink-mute hover:text-indigo transition-colors"
|
||||
>
|
||||
{isZh ? "退出 →" : "Sign out →"}
|
||||
</button>
|
||||
|
||||
@@ -90,7 +90,7 @@ export default async function ConstitutionPage({ params }: { params: Promise<{ l
|
||||
<div className="grid md:grid-cols-3 gap-0 col-rule hairline-t hairline-b">
|
||||
{LAYERS.map((layer) => (
|
||||
<div key={layer.n} className="p-6">
|
||||
<div className="font-mono uppercase tracking-widest mb-2 text-[0.7rem] text-indigo">{layer.n}</div>
|
||||
<div className="mb-2 text-xs text-indigo">{layer.n}</div>
|
||||
<h2 className="font-display text-xl mb-1">{isZh ? layer.name.zh : layer.name.en}</h2>
|
||||
<div className="font-mono text-[0.68rem] text-ink-mute mb-3 break-all">
|
||||
{isZh ? layer.pathZh : layer.path}
|
||||
@@ -138,13 +138,13 @@ export default async function ConstitutionPage({ params }: { params: Promise<{ l
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Link
|
||||
href={p("/install")}
|
||||
className="px-5 py-3 bg-indigo text-paper font-mono text-sm uppercase tracking-wider hover:bg-indigo-deep transition-colors"
|
||||
className="portal-button portal-button-primary"
|
||||
>
|
||||
{isZh ? "安装 →" : "Install →"}
|
||||
</Link>
|
||||
<Link
|
||||
href="https://github.com/Hmbown/CodeWhale/blob/main/docs/CONFIGURATION.md#constitution-project-instructions-and-repo-authority"
|
||||
className="px-5 py-3 font-mono text-sm uppercase tracking-wider text-ink-mute hover:text-indigo transition-colors"
|
||||
className="px-5 py-3 text-sm text-ink-mute hover:text-indigo transition-colors"
|
||||
>
|
||||
{isZh ? "配置文档 ↗" : "Configuration ↗"}
|
||||
</Link>
|
||||
|
||||
@@ -36,7 +36,7 @@ export default async function ToolsPage({ params }: { params: Promise<{ locale:
|
||||
<section id="compatibility" className="scroll-mt-32">
|
||||
<h2 className="font-display text-2xl mb-1">{t(TOOLS_COPY.compatibilityTitle)}</h2>
|
||||
<p className="text-ink-soft mt-3 leading-relaxed">{t(TOOLS_COPY.compatibility)}</p>
|
||||
<Link href="https://github.com/Hmbown/CodeWhale/blob/main/docs/RUNTIME_SIMPLIFICATION_DESIGN.md" className="inline-block mt-3 font-mono text-xs uppercase tracking-wider text-indigo hover:underline">
|
||||
<Link href="https://github.com/Hmbown/CodeWhale/blob/main/docs/RUNTIME_SIMPLIFICATION_DESIGN.md" className="inline-block mt-3 text-xs text-indigo hover:underline">
|
||||
docs/RUNTIME_SIMPLIFICATION_DESIGN.md →
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
@@ -803,7 +803,7 @@ export default async function FaqPage({ params }: { params: Promise<{ locale: st
|
||||
</p>
|
||||
<a
|
||||
href="https://github.com/Hmbown/CodeWhale/issues/new/choose"
|
||||
className="inline-flex items-center gap-2 px-5 py-3 bg-indigo text-paper font-mono text-sm uppercase tracking-wider hover:bg-indigo-deep transition-colors"
|
||||
className="portal-button portal-button-primary gap-2"
|
||||
>
|
||||
{isZh ? "提交 Issue →" : "Open an issue →"}
|
||||
</a>
|
||||
|
||||
@@ -88,8 +88,8 @@ export default async function FeedPage({ params }: { params: Promise<{ locale: s
|
||||
<div className="lg:col-span-6">
|
||||
<div className="hairline-t hairline-b hairline-l hairline-r bg-paper">
|
||||
<div className="bg-indigo text-paper px-4 py-3 flex items-baseline justify-between">
|
||||
<h2 className="font-cjk text-base tracking-wider">合并请求 · Pull Requests</h2>
|
||||
<span className="font-mono text-[0.7rem] uppercase tabular tracking-widest">{pulls.length} 条</span>
|
||||
<h2 className="font-cjk text-base">合并请求 · Pull Requests</h2>
|
||||
<span className="text-xs tabular">{pulls.length} 条</span>
|
||||
</div>
|
||||
<div className="px-4">
|
||||
{pulls.length > 0 ? (
|
||||
@@ -104,8 +104,8 @@ export default async function FeedPage({ params }: { params: Promise<{ locale: s
|
||||
<div className="lg:col-span-6">
|
||||
<div className="hairline-t hairline-b hairline-l hairline-r bg-paper">
|
||||
<div className="bg-paper-deep text-ink px-4 py-3 flex items-baseline justify-between">
|
||||
<h2 className="font-cjk text-base tracking-wider">议题 · Issues</h2>
|
||||
<span className="font-mono text-[0.7rem] uppercase tabular tracking-widest">{issues.length} 条</span>
|
||||
<h2 className="font-cjk text-base">议题 · Issues</h2>
|
||||
<span className="text-xs tabular">{issues.length} 条</span>
|
||||
</div>
|
||||
<div className="px-4">
|
||||
{issues.length > 0 ? (
|
||||
@@ -156,8 +156,8 @@ export default async function FeedPage({ params }: { params: Promise<{ locale: s
|
||||
<div className="lg:col-span-6">
|
||||
<div className="hairline-t hairline-b hairline-l hairline-r bg-paper">
|
||||
<div className="bg-indigo text-paper px-4 py-3 flex items-baseline justify-between">
|
||||
<h2 className="font-cjk text-base tracking-wider">合并 · Pull Requests</h2>
|
||||
<span className="font-mono text-[0.7rem] uppercase tabular tracking-widest">{pulls.length} shown</span>
|
||||
<h2 className="font-cjk text-base">合并 · Pull Requests</h2>
|
||||
<span className="text-xs tabular">{pulls.length} shown</span>
|
||||
</div>
|
||||
<div className="px-4">
|
||||
{pulls.length > 0 ? (
|
||||
@@ -172,8 +172,8 @@ export default async function FeedPage({ params }: { params: Promise<{ locale: s
|
||||
<div className="lg:col-span-6">
|
||||
<div className="hairline-t hairline-b hairline-l hairline-r bg-paper">
|
||||
<div className="bg-paper-deep text-ink px-4 py-3 flex items-baseline justify-between">
|
||||
<h2 className="font-cjk text-base tracking-wider">议题 · Issues</h2>
|
||||
<span className="font-mono text-[0.7rem] uppercase tabular tracking-widest">{issues.length} shown</span>
|
||||
<h2 className="font-cjk text-base">议题 · Issues</h2>
|
||||
<span className="text-xs tabular">{issues.length} shown</span>
|
||||
</div>
|
||||
<div className="px-4">
|
||||
{issues.length > 0 ? (
|
||||
|
||||
@@ -3,7 +3,7 @@ import Image from "next/image";
|
||||
import { InstallCodeBlock } from "@/components/install-code-block";
|
||||
import { InstallBinary } from "@/components/install-binary";
|
||||
import { GETTING_STARTED_STEPS } from "@/lib/content/getting-started";
|
||||
import { INSTALL_COPY } from "@/lib/content/install";
|
||||
import { INSTALL_COMMANDS, INSTALL_COPY } from "@/lib/content/install";
|
||||
import type { LocalizedText } from "@/lib/content/vocabulary";
|
||||
import { fill, getComputerUse, getHome, pickText } from "@/lib/i18n/dictionaries";
|
||||
import { getFacts } from "@/lib/facts";
|
||||
@@ -21,8 +21,8 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
|
||||
});
|
||||
}
|
||||
|
||||
const SHELL_INSTALL = `curl -fsSL https://codewhale.net/install.sh | sh`;
|
||||
const NPM_INSTALL = `npm install -g codewhale`;
|
||||
const SHELL_INSTALL = INSTALL_COMMANDS.shell;
|
||||
const NPM_INSTALL = INSTALL_COMMANDS.npm;
|
||||
const CARGO_INSTALL = `cargo install codewhale-cli --locked`;
|
||||
const UPDATE = `codewhale update`;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Fragment } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { GettingStartedSteps } from "@/components/getting-started-steps";
|
||||
import { HeroInstall } from "@/components/hero-install";
|
||||
import { InstallCodeBlock } from "@/components/install-code-block";
|
||||
import { Strata } from "@/components/strata";
|
||||
import { getFacts } from "@/lib/facts";
|
||||
@@ -81,6 +82,7 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s
|
||||
{d.exploreProduct}
|
||||
</Link>
|
||||
</div>
|
||||
<HeroInstall ariaLabel={d.heroInstallAria} copyLabel={d.copy} copiedLabel={d.copied} />
|
||||
</div>
|
||||
|
||||
{/* Exact-build PTY capture of an empty session. No fabricated
|
||||
|
||||
+40
-10
@@ -80,6 +80,46 @@
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* The hero install: a segmented choice over the shared copy block. The
|
||||
selected segment takes the neutral selected fill, not the accent: the one
|
||||
primary action on the plate stays the Get Codewhale button. */
|
||||
.hero-install {
|
||||
max-width: 34rem;
|
||||
margin-top: 1.6rem;
|
||||
}
|
||||
|
||||
.hero-install-switch {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--paper-card);
|
||||
}
|
||||
|
||||
.hero-install-switch button {
|
||||
min-height: 2rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-control);
|
||||
transition: background-color var(--dur-state) var(--ease-spring), color var(--dur-state) var(--ease-spring);
|
||||
}
|
||||
|
||||
.hero-install-switch button:hover { background: var(--hover); color: var(--ink); }
|
||||
.hero-install-switch button[aria-pressed="true"] { background: var(--selected); color: var(--ink); }
|
||||
|
||||
/* The hero column is narrow; the command wraps rather than scrolling under
|
||||
the Copy button. Copy still writes the exact one-line command. */
|
||||
.hero-install pre.code-block {
|
||||
margin: 0;
|
||||
padding-right: 5rem;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.folio-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -885,12 +925,6 @@ pre.gs-step-commands {
|
||||
|
||||
.site-nav-actions {
|
||||
min-width: 0;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.site-nav-actions select {
|
||||
width: 6.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.paper-wordmark {
|
||||
@@ -904,10 +938,6 @@ pre.gs-step-commands {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.paper-install-cta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.folio-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -191,6 +191,7 @@
|
||||
.folio-hero-copy h1 { grid-column: 1; grid-row: 1 / 3; max-width: none; font-size: clamp(2.75rem, 5vw, 4.5rem); line-height: 1.06; text-wrap: balance; }
|
||||
.folio-hero-copy .folio-lede { grid-column: 2; grid-row: 1; margin-top: 0; align-self: end; }
|
||||
.folio-hero-copy .folio-actions { grid-column: 2; grid-row: 2; align-self: start; margin-top: 1.25rem; }
|
||||
.folio-hero-copy .hero-install { grid-column: 2; grid-row: 3; }
|
||||
.folio-shot { grid-column: 1; grid-row: 2; width: min(100%, 56rem); justify-self: center; margin-top: 0; }
|
||||
.folio-chapter { grid-column: 1; grid-row: 3; justify-self: start; }
|
||||
.product-install-grid > div, .product-composer, .product-composer > div { min-width: 0; }
|
||||
|
||||
+75
-43
@@ -83,13 +83,9 @@
|
||||
filter: brightness(0) invert(0.93);
|
||||
}
|
||||
|
||||
.paper-star-badge {
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.paper-install-cta {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 2.25rem;
|
||||
padding: 0.35rem 0.85rem;
|
||||
background: var(--indigo);
|
||||
@@ -106,68 +102,104 @@
|
||||
color: var(--paper);
|
||||
}
|
||||
|
||||
.paper-auth {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.paper-auth-signin,
|
||||
.paper-auth-register {
|
||||
/* The one identity door: a quiet text control beside the primary action. */
|
||||
.paper-auth-signin {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 2.25rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
color: var(--ink);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
transition: background-color var(--dur-state) var(--ease-spring), color var(--dur-state) var(--ease-spring), border-color var(--dur-state) var(--ease-spring);
|
||||
border-radius: var(--radius-control);
|
||||
transition: background-color var(--dur-state) var(--ease-spring);
|
||||
}
|
||||
|
||||
.paper-auth-signin,
|
||||
.paper-auth-register {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.paper-auth-signin:hover,
|
||||
.paper-auth-register:hover {
|
||||
color: var(--indigo);
|
||||
}
|
||||
.paper-auth-signin:hover { background: var(--hover); }
|
||||
|
||||
/* Actions sit 2px apart, as related rail controls in the GPUI client; the
|
||||
text door and primary action take a group gap before them. */
|
||||
.site-nav-actions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.site-nav-actions > * { flex-shrink: 0; }
|
||||
.site-nav-actions > .paper-auth-signin { margin-left: 0.5rem; }
|
||||
.site-nav-actions > .paper-install-cta { margin-left: 0.25rem; }
|
||||
|
||||
/* Native <select> sizes to the selected option (Safari) or the longest
|
||||
option (Chrome). Unbounded, "Bahasa Indonesia (sebagian)" / "Deutsch
|
||||
(teilweise)" push the compact controls past overflow-x: clip. */
|
||||
.site-nav-actions select {
|
||||
width: 6.75rem;
|
||||
max-width: 6.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.site-github-link {
|
||||
/* Quiet icon controls (theme, language, GitHub, menu): a 40px target around
|
||||
a 20px glyph, the GPUI rail metrics. Muted at rest, full ink on hover and
|
||||
while the control is expanded; the accessible name carries the state. */
|
||||
.nav-icon-button {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
min-height: 2.25rem;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
align-items: center;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border: 1px solid var(--hairline);
|
||||
justify-content: center;
|
||||
color: var(--ink-soft);
|
||||
border-radius: var(--radius-control);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--dur-state) var(--ease-spring), color var(--dur-state) var(--ease-spring);
|
||||
}
|
||||
|
||||
.site-github-link:hover { border-color: var(--ink); }
|
||||
.nav-icon-button:hover,
|
||||
.nav-icon-button[aria-expanded="true"] {
|
||||
background: var(--hover);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.brand-mark { width: 1rem; height: 1rem; display: block; }
|
||||
.nav-icon { display: block; width: 1.25rem; height: 1.25rem; }
|
||||
|
||||
.paper-star-badge .brand-mark { margin-right: 0.3rem; }
|
||||
/* The native <select> stays the control (keyboard, screen readers, the
|
||||
platform picker) but is transparent over the globe glyph, so the icon is
|
||||
the whole hit target. Its focus ring moves to the visible box. */
|
||||
.nav-locale select {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.nav-locale:has(select:focus-visible) {
|
||||
box-shadow: 0 0 0 var(--gpui-focus-width) var(--ring);
|
||||
}
|
||||
|
||||
/* Compact sheet rows: one label per destination, a chevron, a selected fill
|
||||
for the current page rather than a decorative bar. */
|
||||
.mm-list > li + li { border-top: 1px solid var(--hairline); }
|
||||
|
||||
.mm-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-height: 3.25rem;
|
||||
padding-inline: 0.5rem;
|
||||
color: var(--ink);
|
||||
font-size: 1.0625rem;
|
||||
border-radius: var(--radius-control);
|
||||
transition: background-color var(--dur-state) var(--ease-spring);
|
||||
}
|
||||
|
||||
.mm-link:hover { background: var(--hover); }
|
||||
.mm-link[aria-current="page"] { background: var(--selected); }
|
||||
.mm-link-chevron { margin-left: auto; color: var(--ink-mute); }
|
||||
|
||||
.mm-action {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 2.75rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.mm-action.paper-install-cta { margin-top: 1.5rem; }
|
||||
|
||||
.nav-link {
|
||||
font-family: var(--font-body);
|
||||
|
||||
@@ -145,7 +145,7 @@ export function FaqSearch({
|
||||
</div>
|
||||
{item.sources && item.sources.length > 0 && (
|
||||
<div className="mt-3 flex items-center gap-2 flex-wrap">
|
||||
<span className="font-mono text-[0.66rem] text-ink-mute uppercase tracking-wider">
|
||||
<span className="text-xs text-ink-mute">
|
||||
{isZh ? "来源" : "Sources"}:
|
||||
</span>
|
||||
{item.sources.map((s) => {
|
||||
|
||||
@@ -25,7 +25,7 @@ export function FeedCard({ item, dense = false }: { item: FeedItem; dense?: bool
|
||||
return (
|
||||
<article className={`hairline-b py-4 ${dense ? "" : "px-1"}`}>
|
||||
<div className="flex items-baseline gap-3 mb-1.5">
|
||||
<span className="font-mono text-[0.66rem] uppercase tracking-widest text-indigo">
|
||||
<span className="text-xs text-indigo">
|
||||
{k.label} <span className="font-cjk text-ink-mute normal-case tracking-normal ml-1">{k.cn}</span>
|
||||
</span>
|
||||
<span className="font-mono text-[0.7rem] text-ink-mute tabular">#{item.number}</span>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { INSTALL_COMMANDS } from "@/lib/content/install";
|
||||
import { InstallCodeBlock } from "./install-code-block";
|
||||
|
||||
/**
|
||||
* The hero's copyable install: one segmented choice between the checked
|
||||
* shell installer (macOS and Linux) and npm (any platform with Node 18+),
|
||||
* above the same copy block the install page uses. The option labels are
|
||||
* code-owned proper nouns; only the group's accessible name is translated.
|
||||
*/
|
||||
const OPTIONS = [
|
||||
{ id: "shell", label: "macOS · Linux", cmd: INSTALL_COMMANDS.shell },
|
||||
{ id: "npm", label: "npm", cmd: INSTALL_COMMANDS.npm },
|
||||
] as const;
|
||||
|
||||
export function HeroInstall({
|
||||
ariaLabel,
|
||||
copyLabel,
|
||||
copiedLabel,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
copyLabel: string;
|
||||
copiedLabel: string;
|
||||
}) {
|
||||
const [selected, setSelected] = useState<(typeof OPTIONS)[number]["id"]>("shell");
|
||||
const option = OPTIONS.find((o) => o.id === selected) ?? OPTIONS[0];
|
||||
|
||||
return (
|
||||
<div className="hero-install">
|
||||
<div className="hero-install-switch" role="group" aria-label={ariaLabel}>
|
||||
{OPTIONS.map((o) => (
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
onClick={() => setSelected(o.id)}
|
||||
aria-pressed={o.id === selected}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<InstallCodeBlock cmd={option.cmd} copyLabel={copyLabel} copiedLabel={copiedLabel} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,48 @@ const GLYPHS = {
|
||||
<path d="M9 18c-4.51 2-5-2-7-2" />
|
||||
</>
|
||||
),
|
||||
globe: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
|
||||
<path d="M2 12h20" />
|
||||
</>
|
||||
),
|
||||
monitor: (
|
||||
<>
|
||||
<rect width="20" height="14" x="2" y="3" rx="2" />
|
||||
<path d="M8 21h8" />
|
||||
<path d="M12 17v4" />
|
||||
</>
|
||||
),
|
||||
sun: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="m4.93 4.93 1.41 1.41" />
|
||||
<path d="m17.66 17.66 1.41 1.41" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="m6.34 17.66-1.41 1.41" />
|
||||
<path d="m19.07 4.93-1.41 1.41" />
|
||||
</>
|
||||
),
|
||||
moon: <path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />,
|
||||
menu: (
|
||||
<>
|
||||
<path d="M4 6h16" />
|
||||
<path d="M4 12h16" />
|
||||
<path d="M4 18h16" />
|
||||
</>
|
||||
),
|
||||
x: (
|
||||
<>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</>
|
||||
),
|
||||
"chevron-right": <path d="m9 18 6-6-6-6" />,
|
||||
} satisfies Record<string, JSX.Element>;
|
||||
|
||||
export type IconName = keyof typeof GLYPHS;
|
||||
|
||||
@@ -69,7 +69,7 @@ export function InstallBinary({ copyLabel, copiedLabel, verifyHeading = "Verify
|
||||
type="button"
|
||||
onClick={() => setArch(a)}
|
||||
aria-pressed={arch === a}
|
||||
className={`px-3 py-1.5 font-mono text-[0.7rem] tracking-wider transition-colors ${
|
||||
className={`px-3 py-1.5 text-xs transition-colors ${
|
||||
i > 0 ? "hairline-l" : ""
|
||||
} ${arch === a ? "bg-indigo text-paper" : "bg-paper hover:bg-paper-deep"}`}
|
||||
>
|
||||
|
||||
@@ -31,7 +31,7 @@ export function InstallCodeBlock({ cmd, copyLabel = "Copy", copiedLabel = "Copie
|
||||
onClick={copy}
|
||||
aria-label={copied ? copiedLabel : copyLabel}
|
||||
data-copied={copied}
|
||||
className="copy-btn absolute top-3 right-3 z-10 px-3 py-1 bg-paper hairline-t hairline-b hairline-l hairline-r font-mono text-[0.7rem] uppercase tracking-wider hover:bg-indigo hover:text-paper transition-colors"
|
||||
className="copy-btn absolute top-3 right-3 z-10 px-3 py-1 bg-paper hairline-t hairline-b hairline-l hairline-r rounded text-xs hover:bg-indigo hover:text-paper transition-colors"
|
||||
>
|
||||
{copied ? copiedLabel : copyLabel}
|
||||
</button>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter, usePathname } from "next/navigation";
|
||||
import { ALL_LOCALES } from "@/lib/i18n/config";
|
||||
import { fill, getChrome } from "@/lib/i18n/dictionaries";
|
||||
import { replacePathLocale } from "@/lib/i18n/path";
|
||||
import { Icon } from "./icon";
|
||||
|
||||
/** Labels for the dropdown. Keyed by locale code, displayed in native script. */
|
||||
const LOCALE_LABELS: Record<string, string> = {};
|
||||
@@ -34,29 +35,36 @@ export function LocaleSwitcher({ current }: { current: string }) {
|
||||
if (!other) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => switchLocale(other.code)}
|
||||
className="font-mono text-[0.72rem] uppercase text-ink-mute hover:text-indigo transition-colors px-2 py-1"
|
||||
className="nav-icon-button"
|
||||
aria-label={fill(chrome.switcherSwitchTo, { label: other.label })}
|
||||
title={other.label}
|
||||
>
|
||||
{other.label}
|
||||
<Icon name="globe" className="nav-icon" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// 3+ routed locales: show a dropdown. Partial packs carry a visible
|
||||
// badge so the incomplete scope is honest at the point of selection.
|
||||
// 3+ routed locales: a globe icon over a native <select>, which keeps the
|
||||
// platform picker, keyboard and screen-reader behavior. The select is
|
||||
// transparent and covers the icon, so the whole icon is the hit target.
|
||||
// Partial packs carry a visible badge in the list so the incomplete scope
|
||||
// is honest at the point of selection.
|
||||
return (
|
||||
<select
|
||||
value={current}
|
||||
onChange={(e) => switchLocale(e.target.value)}
|
||||
className="font-mono text-[0.72rem] uppercase text-ink-mute bg-transparent hairline-t hairline-b hairline-l hairline-r px-2 py-1 cursor-pointer hover:text-indigo transition-colors"
|
||||
aria-label={chrome.switcherLabel}
|
||||
>
|
||||
{ROUTED.map((l) => (
|
||||
<option key={l.code} value={l.code}>
|
||||
{l.status === "partial" ? `${l.label} ${chrome.partialBadge}` : l.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="nav-icon-button nav-locale">
|
||||
<Icon name="globe" className="nav-icon" />
|
||||
<select
|
||||
value={current}
|
||||
onChange={(e) => switchLocale(e.target.value)}
|
||||
aria-label={chrome.switcherLabel}
|
||||
>
|
||||
{ROUTED.map((l) => (
|
||||
<option key={l.code} value={l.code}>
|
||||
{l.status === "partial" ? `${l.label} ${chrome.partialBadge}` : l.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { usePathname } from "next/navigation";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { currentNavHref, type ChromeLink } from "@/lib/i18n/links";
|
||||
import { Icon } from "./icon";
|
||||
|
||||
export function MobileMenu({
|
||||
links,
|
||||
@@ -13,8 +14,6 @@ export function MobileMenu({
|
||||
installLabel,
|
||||
signInHref,
|
||||
signInLabel,
|
||||
registerHref,
|
||||
registerLabel,
|
||||
openLabel,
|
||||
closeLabel,
|
||||
navAria,
|
||||
@@ -26,8 +25,6 @@ export function MobileMenu({
|
||||
installLabel: string;
|
||||
signInHref: string;
|
||||
signInLabel: string;
|
||||
registerHref: string;
|
||||
registerLabel: string;
|
||||
openLabel: string;
|
||||
closeLabel: string;
|
||||
/** Accessible name for the dialog's navigation landmark. */
|
||||
@@ -128,10 +125,10 @@ export function MobileMenu({
|
||||
}
|
||||
};
|
||||
|
||||
// Tailwind's xl boundary hides the compact controls. Close immediately
|
||||
// Tailwind's lg boundary hides the compact controls. Close immediately
|
||||
// when a live resize crosses it so an invisible sheet cannot retain the
|
||||
// body's scroll lock.
|
||||
const desktop = window.matchMedia("(min-width: 1280px)");
|
||||
const desktop = window.matchMedia("(min-width: 1024px)");
|
||||
const onDesktop = (event: MediaQueryListEvent | MediaQueryList) => {
|
||||
if (event.matches) closeImmediately();
|
||||
};
|
||||
@@ -162,42 +159,26 @@ export function MobileMenu({
|
||||
ref={toggleRef}
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="xl:hidden inline-flex items-center justify-center w-9 h-9 hairline-t hairline-b hairline-l hairline-r hover:bg-paper-deep transition-colors"
|
||||
className="nav-icon-button lg:hidden"
|
||||
aria-label={open ? closeLabel : openLabel}
|
||||
aria-expanded={open}
|
||||
aria-controls="mobile-menu"
|
||||
>
|
||||
{open ? (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden>
|
||||
<path d="M2 2L12 12M12 2L2 12" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="16" height="12" viewBox="0 0 16 12" fill="none" aria-hidden>
|
||||
<path d="M0 1H16M0 6H16M0 11H16" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
)}
|
||||
<Icon name={open ? "x" : "menu"} className="nav-icon" />
|
||||
</button>
|
||||
|
||||
{open && typeof document !== "undefined" &&
|
||||
createPortal(<div
|
||||
ref={menuRef}
|
||||
id="mobile-menu"
|
||||
className={`mm-panel xl:hidden fixed inset-0 z-40 bg-paper overflow-y-auto${closing ? " mm-closing" : ""}`}
|
||||
className={`mm-panel lg:hidden fixed inset-0 z-40 bg-paper overflow-y-auto${closing ? " mm-closing" : ""}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={navAria}
|
||||
>
|
||||
<div className="flex min-h-[5.75rem] items-center justify-between px-6 hairline-b">
|
||||
<span className="font-display text-lg">{navAria}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
className="inline-flex h-9 w-9 items-center justify-center hairline-t hairline-b hairline-l hairline-r hover:bg-paper-deep transition-colors"
|
||||
aria-label={closeLabel}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden>
|
||||
<path d="M2 2L12 12M12 2L2 12" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
<div className="flex min-h-[3.85rem] items-center justify-end px-4 hairline-b">
|
||||
<button type="button" onClick={close} className="nav-icon-button" aria-label={closeLabel}>
|
||||
<Icon name="x" className="nav-icon" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Only one nav landmark is exposed at a time (the desktop nav is
|
||||
@@ -205,7 +186,7 @@ export function MobileMenu({
|
||||
landmark name and the inner nav stays unlabeled — two nested
|
||||
"Primary" landmarks would read as duplication. */}
|
||||
<nav className="px-6 py-4">
|
||||
<ul className="divide-y divide-[rgba(20,35,82,0.14)]">
|
||||
<ul className="mm-list">
|
||||
{[...links, ...moreLinks].map((l) => {
|
||||
const isActive = l.href === currentHref;
|
||||
return (
|
||||
@@ -213,14 +194,11 @@ export function MobileMenu({
|
||||
<Link
|
||||
href={l.href}
|
||||
onClick={() => setOpen(false)}
|
||||
className={`flex items-baseline gap-3 py-4 hover:text-indigo transition-colors ${isActive ? "text-indigo" : ""}`}
|
||||
className="mm-link"
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
>
|
||||
<span className="font-display text-lg">{l.label}</span>
|
||||
{l.secondary && (
|
||||
<span className="font-cjk text-sm text-ink-mute">{l.secondary}</span>
|
||||
)}
|
||||
<span className="ml-auto font-mono text-xs text-ink-mute">→</span>
|
||||
<span>{l.label}</span>
|
||||
<Icon name="chevron-right" className="nav-icon mm-link-chevron" />
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
@@ -230,28 +208,18 @@ export function MobileMenu({
|
||||
<Link
|
||||
href={installHref}
|
||||
onClick={() => setOpen(false)}
|
||||
className="mt-6 block w-full text-center px-5 py-3 bg-indigo text-paper font-mono text-sm uppercase tracking-wider hover:bg-indigo-deep transition-colors"
|
||||
className="mm-action paper-install-cta"
|
||||
>
|
||||
{installLabel}
|
||||
</Link>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3">
|
||||
<Link
|
||||
href={signInHref}
|
||||
data-usage="login"
|
||||
onClick={() => setOpen(false)}
|
||||
className="block text-center px-5 py-3 hairline-t hairline-b hairline-l hairline-r font-mono text-sm uppercase tracking-wider hover:bg-paper-deep transition-colors"
|
||||
>
|
||||
{signInLabel}
|
||||
</Link>
|
||||
<Link
|
||||
href={registerHref}
|
||||
data-usage="signup"
|
||||
onClick={() => setOpen(false)}
|
||||
className="block text-center px-5 py-3 hairline-t hairline-b hairline-l hairline-r font-mono text-sm uppercase tracking-wider text-indigo hover:bg-paper-deep transition-colors"
|
||||
>
|
||||
{registerLabel}
|
||||
</Link>
|
||||
</div>
|
||||
<Link
|
||||
href={signInHref}
|
||||
data-usage="login"
|
||||
onClick={() => setOpen(false)}
|
||||
className="mm-action paper-auth-signin"
|
||||
>
|
||||
{signInLabel}
|
||||
</Link>
|
||||
</nav>
|
||||
</div>, document.body)}
|
||||
</>
|
||||
|
||||
@@ -5,18 +5,14 @@ import { usePathname } from "next/navigation";
|
||||
import { currentNavHref, type ChromeLink } from "@/lib/i18n/links";
|
||||
|
||||
/**
|
||||
* Desktop primary navigation. Labels come from the locale's chrome
|
||||
* dictionary — no locale branch here, and no Han characters leaking into
|
||||
* locales that never asked for them.
|
||||
* Desktop primary navigation, shown from `lg`. Labels come from the locale's
|
||||
* chrome dictionary — no locale branch here and no companion labels: at 2xl,
|
||||
* de/pt-BR/id companions once collapsed the home wordmark to a zero-width
|
||||
* hit target (#5290).
|
||||
*
|
||||
* Companion labels stay on the compact sheet (mobile-menu.tsx). On the
|
||||
* 76rem desktop strip they do not fit: at 2xl, de/pt-BR/id secondaries
|
||||
* collapsed the home wordmark to a zero-width hit target (#5290).
|
||||
*
|
||||
* Wrapping is the strip's escape valve — the container is capped at 76rem,
|
||||
* so the six longest translated labels (de on a docs route, which also
|
||||
* carries the theme control) can still exceed it. The row gap is tight so a
|
||||
* wrapped second row does not double the height of the sticky header.
|
||||
* Wrapping is the strip's escape valve for a translated strip that outgrows
|
||||
* the row at the `lg` floor. The row gap is tight so a wrapped second row
|
||||
* does not double the height of the sticky header.
|
||||
*/
|
||||
export function NavLinks({
|
||||
links,
|
||||
@@ -30,7 +26,7 @@ export function NavLinks({
|
||||
const current = currentNavHref(links, pathname);
|
||||
|
||||
return (
|
||||
<nav className="hidden xl:flex min-w-0 shrink items-center gap-x-5 gap-y-1 flex-wrap" aria-label={primaryAria}>
|
||||
<nav className="hidden lg:flex min-w-0 shrink items-center gap-x-5 gap-y-1 flex-wrap" aria-label={primaryAria}>
|
||||
{links.map((l) => {
|
||||
const isActive = l.href === current;
|
||||
return (
|
||||
|
||||
+14
-33
@@ -2,32 +2,25 @@ import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { getChrome } from "@/lib/i18n/dictionaries";
|
||||
import { navLinks, secondaryNavLinks, REPO_URL, APP_LOGIN_URL, APP_SIGNUP_URL } from "@/lib/i18n/links";
|
||||
import { fetchRepoStats, formatStars } from "@/lib/github";
|
||||
import { getEnv } from "@/lib/kv";
|
||||
import { navLinks, secondaryNavLinks, REPO_URL, APP_LOGIN_URL } from "@/lib/i18n/links";
|
||||
import { Icon } from "./icon";
|
||||
import { LocaleSwitcher } from "./locale-switcher";
|
||||
import { MobileMenu } from "./mobile-menu";
|
||||
import { NavLinks } from "./nav-links";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
|
||||
/** Masthead + primary nav — the Tideline topbar on the web. */
|
||||
export async function Nav({ locale = "en" }: { locale?: Locale }) {
|
||||
/**
|
||||
* Masthead + primary nav. Like the GPUI titlebar it carries few visible
|
||||
* controls: the mark, four links, quiet icon controls with accessible names,
|
||||
* one identity door (Sign in; the sign-in page offers account creation) and
|
||||
* the one primary action, Install.
|
||||
*/
|
||||
export function Nav({ locale = "en" }: { locale?: Locale }) {
|
||||
const chrome = getChrome(locale);
|
||||
const links = navLinks(locale, chrome);
|
||||
const moreLinks = secondaryNavLinks(locale, chrome);
|
||||
const homeHref = `/${locale}`;
|
||||
|
||||
// Live star count — cached by fetchRepoStats. Falls back to a plain GitHub
|
||||
// label when the API is unreachable at build time.
|
||||
let stars = 0;
|
||||
try {
|
||||
const env = await getEnv();
|
||||
stars = (await fetchRepoStats(env.GITHUB_TOKEN)).stars;
|
||||
} catch {
|
||||
/* keep fallback label */
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="site-nav paper-nav">
|
||||
<div className="site-nav-inner paper-nav-inner">
|
||||
@@ -55,25 +48,15 @@ export async function Nav({ locale = "en" }: { locale?: Locale }) {
|
||||
titleLabel={chrome.themeTitle}
|
||||
/>
|
||||
<LocaleSwitcher current={locale} />
|
||||
<Link
|
||||
href={REPO_URL}
|
||||
className="site-github-link paper-star-badge"
|
||||
aria-label={chrome.starsAria}
|
||||
>
|
||||
<Icon name="github" className="brand-mark" />
|
||||
★ {stars > 0 ? formatStars(stars) : chrome.githubFallback}
|
||||
<Link href={REPO_URL} className="nav-icon-button site-github-link" aria-label="GitHub">
|
||||
<Icon name="github" className="nav-icon" />
|
||||
</Link>
|
||||
<Link href={APP_LOGIN_URL} className="paper-auth-signin hidden lg:inline-flex" data-usage="login">
|
||||
{chrome.authSignIn}
|
||||
</Link>
|
||||
<span className="paper-auth" role="group" aria-label={chrome.authGroupAria}>
|
||||
<Link href={APP_LOGIN_URL} className="paper-auth-signin hidden lg:inline-flex" data-usage="login">
|
||||
{chrome.authSignIn}
|
||||
</Link>
|
||||
<Link href={APP_SIGNUP_URL} className="paper-auth-register hidden lg:inline-flex" data-usage="signup">
|
||||
{chrome.authRegister}
|
||||
</Link>
|
||||
</span>
|
||||
<Link
|
||||
href={`/${locale}/install`}
|
||||
className="paper-install-cta hidden xl:inline-flex"
|
||||
className="paper-install-cta hidden lg:inline-flex"
|
||||
>
|
||||
{chrome.installCta}
|
||||
</Link>
|
||||
@@ -82,8 +65,6 @@ export async function Nav({ locale = "en" }: { locale?: Locale }) {
|
||||
installLabel={chrome.installCta}
|
||||
signInHref={APP_LOGIN_URL}
|
||||
signInLabel={chrome.authSignIn}
|
||||
registerHref={APP_SIGNUP_URL}
|
||||
registerLabel={chrome.authRegister}
|
||||
links={links}
|
||||
moreLinks={moreLinks}
|
||||
openLabel={chrome.menuOpen}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* <ThemeToggle> — a compact System / Light / Dark control in the site nav,
|
||||
* shown on every page.
|
||||
* <ThemeToggle> — an icon-only System / Light / Dark control in the site nav,
|
||||
* shown on every page. The current mode is in its accessible name.
|
||||
*
|
||||
* The whole site follows the OS appearance by default, the way the GPUI
|
||||
* client follows its `set_theme` light/dark pair: with no `data-theme` on
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { fill } from "@/lib/i18n/dictionaries";
|
||||
import { Icon, type IconName } from "./icon";
|
||||
|
||||
type Mode = "system" | "light" | "dark";
|
||||
const ORDER: Mode[] = ["system", "light", "dark"];
|
||||
@@ -79,22 +80,19 @@ export function ThemeToggle({
|
||||
light: lightLabel,
|
||||
dark: darkLabel,
|
||||
};
|
||||
const glyph: Record<Mode, string> = { system: "◐", light: "☀", dark: "☾" };
|
||||
const glyph: Record<Mode, IconName> = { system: "monitor", light: "sun", dark: "moon" };
|
||||
const shown = mounted ? mode : "system";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={cycle}
|
||||
className="inline-flex items-center gap-1.5 px-1.5 py-0.5 hairline-l hairline-r hairline-t hairline-b hover:text-indigo transition-colors"
|
||||
className="nav-icon-button"
|
||||
aria-label={fill(ariaTemplate, { mode: labels[shown] })}
|
||||
title={titleLabel}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<span aria-hidden>{glyph[shown]}</span>
|
||||
<span className="hidden 2xl:inline" suppressHydrationWarning>
|
||||
{labels[shown]}
|
||||
</span>
|
||||
<Icon name={glyph[shown]} className="nav-icon" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -80,7 +80,7 @@ export function ThinkingTrace({ locale = "en" }: { locale?: string }) {
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-jade inline-block" />
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-ochre inline-block" />
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-indigo inline-block" />
|
||||
<span className="ml-2.5 font-mono text-[0.66rem] uppercase tracking-widest text-ink-mute">
|
||||
<span className="ml-2.5 text-xs text-ink-mute">
|
||||
codewhale — thinking
|
||||
</span>
|
||||
</div>
|
||||
@@ -90,7 +90,7 @@ export function ThinkingTrace({ locale = "en" }: { locale?: string }) {
|
||||
</div>
|
||||
|
||||
{/* context */}
|
||||
<div className="px-4 pt-4 text-[0.66rem] font-mono uppercase tracking-wider text-ink-mute">
|
||||
<div className="px-4 pt-4 text-xs text-ink-mute">
|
||||
{isZh ? s.context.zh : s.context.en}
|
||||
</div>
|
||||
|
||||
@@ -103,7 +103,7 @@ export function ThinkingTrace({ locale = "en" }: { locale?: string }) {
|
||||
{/* cited authority */}
|
||||
<div className="px-4 pb-3 flex flex-wrap gap-1.5">
|
||||
{s.cites.map((c) => (
|
||||
<span key={c} className="pill text-[0.58rem] tracking-wider">
|
||||
<span key={c} className="pill text-[0.58rem]">
|
||||
{c}
|
||||
</span>
|
||||
))}
|
||||
|
||||
@@ -153,8 +153,8 @@ export function Ticker({ items, labels }: { items: FeedItem[]; labels: TickerLab
|
||||
<div className="site-container flex items-stretch">
|
||||
<div className="border-r border-paper-edge text-ink px-4 py-2 flex items-center shrink-0 gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-indigo rounded-full inline-block animate-pulse" />
|
||||
<span className="font-cjk text-sm font-semibold tracking-wider">{labels.liveLabel}</span>
|
||||
<span className="font-mono text-[0.55rem] uppercase tracking-widest text-ink-mute ml-1 self-end mb-0.5">
|
||||
<span className="font-cjk text-sm font-semibold">{labels.liveLabel}</span>
|
||||
<span className="text-xs text-ink-mute ml-1 self-end mb-0.5">
|
||||
{labels.liveTag}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
+3
-15
@@ -6,31 +6,18 @@
|
||||
"navFaq": "FAQ",
|
||||
"navCommunity": "Community",
|
||||
"navContribute": "Contribute",
|
||||
"navDocsSecondary": "文档",
|
||||
"navStartSecondary": "指引",
|
||||
"navInstallSecondary": "安装",
|
||||
"navFaqSecondary": "问答",
|
||||
"navCommunitySecondary": "社区",
|
||||
"navContributeSecondary": "贡献",
|
||||
"navProduct": "Product",
|
||||
"navModels": "Models",
|
||||
"navPlugins": "Plugins",
|
||||
"navProductSecondary": "产品",
|
||||
"navModelsSecondary": "模型",
|
||||
"navPluginsSecondary": "插件",
|
||||
"skipToContent": "Skip to main content",
|
||||
"navPrimaryAria": "Primary",
|
||||
"navHomeAria": "Codewhale home",
|
||||
"installCta": "Install →",
|
||||
"authSignIn": "Sign in",
|
||||
"authRegister": "Create account",
|
||||
"authGroupAria": "Account",
|
||||
"wordmarkSeal": "深",
|
||||
"wordmarkTag": "any model, on your machine",
|
||||
"issueLabel": "Issue {date}",
|
||||
"dateLocale": "en-US",
|
||||
"starsAria": "GitHub stars",
|
||||
"githubFallback": "GitHub",
|
||||
"tickerLiveLabel": "实 时",
|
||||
"tickerLiveTag": "LIVE",
|
||||
"tickerMerged": "merged",
|
||||
@@ -47,8 +34,8 @@
|
||||
"themeAuto": "auto",
|
||||
"themeLight": "light",
|
||||
"themeDark": "dark",
|
||||
"themeAria": "Docs theme: {mode} (click to cycle)",
|
||||
"themeTitle": "Docs theme · auto / light / dark",
|
||||
"themeAria": "Theme: {mode} (click to cycle)",
|
||||
"themeTitle": "Theme · auto / light / dark",
|
||||
"footerTagline": "Create what you want and automate everyday work with the models you choose.",
|
||||
"footerProduct": "Product",
|
||||
"footerProject": "Project",
|
||||
@@ -78,6 +65,7 @@
|
||||
"heroTitle": "Build and automate with the models you choose",
|
||||
"heroIntro": "{brand} is an open-source agent that reads files, edits code, runs commands, and checks its work. Use it in your terminal or local browser with a hosted or local model. You choose the tools and permissions; the session keeps the conversation and tool results.",
|
||||
"getCodewhale": "Get Codewhale",
|
||||
"heroInstallAria": "Install command",
|
||||
"exploreProduct": "Explore the product",
|
||||
"shotPreview": "Terminal preview",
|
||||
"shotBuild": "v{version} development build",
|
||||
|
||||
+3
-15
@@ -6,31 +6,18 @@
|
||||
"navFaq": "常见问题",
|
||||
"navCommunity": "社区",
|
||||
"navContribute": "贡献",
|
||||
"navDocsSecondary": "Docs",
|
||||
"navStartSecondary": "Start",
|
||||
"navInstallSecondary": "Install",
|
||||
"navFaqSecondary": "FAQ",
|
||||
"navCommunitySecondary": "Community",
|
||||
"navContributeSecondary": "Contribute",
|
||||
"navProduct": "产品",
|
||||
"navModels": "模型",
|
||||
"navPlugins": "插件",
|
||||
"navProductSecondary": "Product",
|
||||
"navModelsSecondary": "Models",
|
||||
"navPluginsSecondary": "Plugins",
|
||||
"skipToContent": "跳转到主要内容",
|
||||
"navPrimaryAria": "主导航",
|
||||
"navHomeAria": "Codewhale 首页",
|
||||
"installCta": "安装 →",
|
||||
"authSignIn": "登录",
|
||||
"authRegister": "创建账户",
|
||||
"authGroupAria": "账户",
|
||||
"wordmarkSeal": "深",
|
||||
"wordmarkTag": "任意模型,本机运行",
|
||||
"issueLabel": "第 {date} 期",
|
||||
"dateLocale": "zh-CN",
|
||||
"starsAria": "GitHub 星标数",
|
||||
"githubFallback": "GitHub",
|
||||
"tickerLiveLabel": "实 时",
|
||||
"tickerLiveTag": "LIVE",
|
||||
"tickerMerged": "已合并",
|
||||
@@ -47,8 +34,8 @@
|
||||
"themeAuto": "自动",
|
||||
"themeLight": "浅色",
|
||||
"themeDark": "深色",
|
||||
"themeAria": "文档主题:{mode}(点击切换)",
|
||||
"themeTitle": "文档主题 · 自动 / 浅色 / 深色",
|
||||
"themeAria": "主题:{mode}(点击切换)",
|
||||
"themeTitle": "主题 · 自动 / 浅色 / 深色",
|
||||
"footerTagline": "用你选择的模型构建想做的项目,并自动处理日常工作。",
|
||||
"footerProduct": "产品",
|
||||
"footerProject": "项目",
|
||||
@@ -78,6 +65,7 @@
|
||||
"heroTitle": "用你选择的模型,完成开发和自动化任务",
|
||||
"heroIntro": "{brand} 是一个开源智能体,能够读取文件、编辑代码、运行命令并检查结果。在终端或本地浏览器中使用它,连接你选择的云端或本地模型。工具和权限由你决定;对话与工具执行结果保存在会话中。",
|
||||
"getCodewhale": "获取 Codewhale",
|
||||
"heroInstallAria": "安装命令",
|
||||
"exploreProduct": "了解产品",
|
||||
"shotPreview": "终端预览",
|
||||
"shotBuild": "v{version} 开发版本",
|
||||
|
||||
@@ -96,21 +96,17 @@ describe("GPUI public-surface contract", () => {
|
||||
|
||||
expect(mobile).toMatch(/\.site-nav-inner\s*\{\s*gap:\s*0\.5rem/);
|
||||
expect(mobile).toMatch(/\.site-nav-actions\s*\{[\s\S]*?min-width:\s*0/);
|
||||
expect(mobile).toMatch(
|
||||
/\.site-nav-actions select\s*\{[\s\S]*?width:\s*6\.75rem;[\s\S]*?min-width:\s*0/,
|
||||
);
|
||||
expect(CSS).toMatch(/\.paper-wordmark-mark\s*\{[^}]*height:\s*22px;/);
|
||||
expect(CSS).toMatch(/\.paper-wordmark-logo\s*\{[^}]*height:\s*20px;/);
|
||||
expect(CSS).toMatch(/\.site-nav-actions\s*\{[\s\S]*?flex-shrink:\s*0/);
|
||||
expect(CSS).toMatch(/\.site-nav-actions\s*>\s*\*\s*\{\s*flex-shrink:\s*0/);
|
||||
expect(CSS).toMatch(/@media \(max-width: 900px\)[\s\S]*?\.site-github-link\s*\{\s*display:\s*none/);
|
||||
expect(mobile).not.toMatch(/body:has\(\.product-home\) \.site-nav-actions select/);
|
||||
// The locale <select> and the home wordmark must keep a usable hit
|
||||
// target on every viewport, not only below 520px. Long native option
|
||||
// labels and 2xl companion text used to collapse the wordmark to 0.
|
||||
expect(CSS).toMatch(
|
||||
/\.site-nav-actions select\s*\{\s*width:\s*6\.75rem;\s*max-width:\s*6\.75rem;\s*min-width:\s*0;/,
|
||||
);
|
||||
// labels and 2xl companion text used to collapse the wordmark to 0; the
|
||||
// select now sits inside the fixed icon box instead of sizing the row.
|
||||
expect(CSS).not.toMatch(/\.site-nav-actions select/);
|
||||
expect(CSS).toMatch(/\.nav-locale select\s*\{[^}]*position:\s*absolute;[^}]*inset:\s*0;/);
|
||||
// `min-width` is the floor that keeps the wordmark clickable; the shrink
|
||||
// factor stays at 1 so the compact controls are never the ones pushed
|
||||
// past `overflow-x: clip` when the row is over budget.
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
/**
|
||||
* The two one-line installs, shared by the install page and the homepage
|
||||
* hero. Code-owned shell, never translated.
|
||||
*/
|
||||
export const INSTALL_COMMANDS = {
|
||||
shell: "curl -fsSL https://codewhale.net/install.sh | sh",
|
||||
npm: "npm install -g codewhale",
|
||||
} as const;
|
||||
|
||||
export const INSTALL_COPY = {
|
||||
metaTitle: { en: "Install · Codewhale", zh: "安装 · Codewhale" },
|
||||
metaDescription: { en: "Install Codewhale, connect your model, and start your first task. Guides for macOS, Linux, Windows, package managers, and source builds.", zh: "安装 Codewhale、连接模型并开始第一项任务。提供 macOS、Linux、Windows、包管理器与源码编译指南。" },
|
||||
|
||||
+14
-14
@@ -139,8 +139,10 @@ describe("sitemap and hreflang preservation", () => {
|
||||
expect(entry).toContain("installLocally");
|
||||
expect(webText("app/[locale]/signin/page.tsx")).toContain('kind="sign-in"');
|
||||
expect(webText("app/[locale]/signup/page.tsx")).toContain('kind="sign-up"');
|
||||
// One identity door in the header; the sign-in page links onward to
|
||||
// account creation.
|
||||
expect(nav).toContain("APP_LOGIN_URL");
|
||||
expect(nav).toContain("APP_SIGNUP_URL");
|
||||
expect(nav).not.toContain("APP_SIGNUP_URL");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -155,21 +157,19 @@ describe("navigation parity and accessibility", () => {
|
||||
// One generator feeds both surfaces — no per-locale hardcoded arrays.
|
||||
expect(nav).toContain("navLinks(locale, chrome)");
|
||||
expect(nav).not.toMatch(/const (EN|ZH)_LINKS/);
|
||||
// The primary strip does not replace the compact menu until xl;
|
||||
// translated labels are wider than English and used to push real controls
|
||||
// beyond the clipped viewport at md widths.
|
||||
// The primary strip replaces the compact menu at lg, with icon-only
|
||||
// controls leaving room for translated labels (see nav-hit-target.test).
|
||||
// Wrapping is the escape valve for a translated strip that outgrows the
|
||||
// 76rem container; the row gap stays tight so a second row does not
|
||||
// double the sticky header's height.
|
||||
// row; the row gap stays tight so a second row does not double the
|
||||
// sticky header's height.
|
||||
expect(navLinks).toContain(
|
||||
'className="hidden xl:flex min-w-0 shrink items-center gap-x-5 gap-y-1 flex-wrap"',
|
||||
'className="hidden lg:flex min-w-0 shrink items-center gap-x-5 gap-y-1 flex-wrap"',
|
||||
);
|
||||
// Companion labels remain on the compact sheet. They must not return to
|
||||
// the 76rem desktop strip — at 2xl they zeroed the wordmark on de/pt-BR.
|
||||
expect(navLinks).not.toContain("nav-link-secondary");
|
||||
expect(mobileMenu).toContain("l.secondary");
|
||||
expect(mobileMenu).toContain("xl:hidden inline-flex");
|
||||
expect(nav).toContain("paper-install-cta hidden xl:inline-flex");
|
||||
// One label per destination: no companion labels on either surface.
|
||||
expect(navLinks).not.toContain("secondary");
|
||||
expect(mobileMenu).not.toContain("secondary");
|
||||
expect(mobileMenu).toContain('className="nav-icon-button lg:hidden"');
|
||||
expect(nav).toContain("paper-install-cta hidden lg:inline-flex");
|
||||
// A fixed descendant of the blurred sticky header uses the header as its
|
||||
// containing block and collapses. The open sheet must live at body scope.
|
||||
expect(mobileMenu).toContain('import { createPortal } from "react-dom"');
|
||||
@@ -177,7 +177,7 @@ describe("navigation parity and accessibility", () => {
|
||||
expect(mobileMenu).toContain("document.body");
|
||||
expect(mobileMenu).toContain("element.inert = true");
|
||||
expect(mobileMenu).toContain('if (e.key !== "Tab") return');
|
||||
expect(mobileMenu).toContain('window.matchMedia("(min-width: 1280px)")');
|
||||
expect(mobileMenu).toContain('window.matchMedia("(min-width: 1024px)")');
|
||||
expect(mobileMenu).toContain("if (event.matches) closeImmediately()");
|
||||
// Locale handlers are shared so a regional tag cannot nest
|
||||
// (`/ja/pt-BR/...`); the theme control is site-wide, never route-gated.
|
||||
|
||||
@@ -59,7 +59,6 @@ import type { ChromeDict, HomeDict } from "./dictionaries/types";
|
||||
const NON_PROSE_KEYS = new Set([
|
||||
"wordmarkSeal",
|
||||
"dateLocale",
|
||||
"githubFallback",
|
||||
"tickerLiveTag",
|
||||
]);
|
||||
|
||||
@@ -72,7 +71,6 @@ const CHROME_PROSE_KEYS = [
|
||||
"navPrimaryAria",
|
||||
"navHomeAria",
|
||||
"wordmarkTag",
|
||||
"starsAria",
|
||||
"traceLabel",
|
||||
"traceTabsAria",
|
||||
"menuOpen",
|
||||
@@ -116,6 +114,7 @@ const HOME_PROSE_KEYS = [
|
||||
"heroTitle",
|
||||
"heroIntro",
|
||||
"getCodewhale",
|
||||
"heroInstallAria",
|
||||
"exploreProduct",
|
||||
"shotPreview",
|
||||
"shotBuild",
|
||||
|
||||
@@ -20,19 +20,9 @@ export const chrome: ChromeDict = {
|
||||
navCommunity: "المجتمع",
|
||||
navContribute: "المساهمة",
|
||||
|
||||
navDocsSecondary: "Docs",
|
||||
navStartSecondary: "Start",
|
||||
navInstallSecondary: "Install",
|
||||
navFaqSecondary: "FAQ",
|
||||
navCommunitySecondary: "Community",
|
||||
navContributeSecondary: "Contribute",
|
||||
|
||||
navProduct: "المنتج",
|
||||
navModels: "النماذج",
|
||||
navPlugins: "الإضافات",
|
||||
navProductSecondary: "Product",
|
||||
navModelsSecondary: "Models",
|
||||
navPluginsSecondary: "Plugins",
|
||||
|
||||
skipToContent: "الانتقال إلى المحتوى الرئيسي",
|
||||
|
||||
@@ -42,8 +32,6 @@ export const chrome: ChromeDict = {
|
||||
installCta: "ثبّت ←",
|
||||
|
||||
authSignIn: "تسجيل الدخول",
|
||||
authRegister: "إنشاء حساب",
|
||||
authGroupAria: "الحساب",
|
||||
|
||||
wordmarkSeal: "深",
|
||||
wordmarkTag: "أي نموذج، على جهازك",
|
||||
@@ -51,9 +39,6 @@ export const chrome: ChromeDict = {
|
||||
issueLabel: "عدد {date}",
|
||||
dateLocale: "ar",
|
||||
|
||||
starsAria: "نجوم GitHub",
|
||||
githubFallback: "GitHub",
|
||||
|
||||
tickerLiveLabel: "مباشر",
|
||||
tickerLiveTag: "LIVE",
|
||||
tickerMerged: "دُمج",
|
||||
@@ -73,8 +58,8 @@ export const chrome: ChromeDict = {
|
||||
themeAuto: "تلقائي",
|
||||
themeLight: "فاتح",
|
||||
themeDark: "داكن",
|
||||
themeAria: "سمة التوثيق: {mode} (انقر للتبديل)",
|
||||
themeTitle: "سمة التوثيق · تلقائي / فاتح / داكن",
|
||||
themeAria: "السمة: {mode} (انقر للتبديل)",
|
||||
themeTitle: "السمة · تلقائي / فاتح / داكن",
|
||||
|
||||
footerTagline:
|
||||
"اصنع ما تريد وأتمت العمل اليومي باستخدام النماذج التي تختارها.",
|
||||
|
||||
@@ -16,6 +16,7 @@ export const home: HomeDict = {
|
||||
heroIntro:
|
||||
"يمنحك {brand} وكلاء يمكنهم بناء البرمجيات والعمل على ملفاتك وتحويل المهام المتكررة إلى مسارات عمل قابلة لإعادة الاستخدام. أخبرهم بما تريد إنجازه واختر النماذج المستضافة أو المحلية المناسبة للمهمة، مع حرية تبديل المزوّدين أثناء العمل.",
|
||||
getCodewhale: "احصل على Codewhale",
|
||||
heroInstallAria: "أمر التثبيت",
|
||||
exploreProduct: "استكشف المنتج",
|
||||
shotPreview: "معاينة الطرفية",
|
||||
shotBuild: "إصدار تطوير v{version}",
|
||||
|
||||
@@ -25,19 +25,9 @@ export const chrome: ChromeDict = {
|
||||
navCommunity: "Comunitat",
|
||||
navContribute: "Col·laborar",
|
||||
|
||||
navDocsSecondary: "Docs",
|
||||
navStartSecondary: "Start",
|
||||
navInstallSecondary: "Install",
|
||||
navFaqSecondary: "FAQ",
|
||||
navCommunitySecondary: "Community",
|
||||
navContributeSecondary: "Contribute",
|
||||
|
||||
navProduct: "Producte",
|
||||
navModels: "Models",
|
||||
navPlugins: "Plugins",
|
||||
navProductSecondary: "Product",
|
||||
navModelsSecondary: "Models",
|
||||
navPluginsSecondary: "Plugins",
|
||||
|
||||
skipToContent: "Salta al contingut principal",
|
||||
|
||||
@@ -47,8 +37,6 @@ export const chrome: ChromeDict = {
|
||||
installCta: "Instal·la →",
|
||||
|
||||
authSignIn: "Inicia la sessió",
|
||||
authRegister: "Registra't",
|
||||
authGroupAria: "Compte",
|
||||
|
||||
wordmarkSeal: "深",
|
||||
wordmarkTag: "qualsevol model, a la teva màquina",
|
||||
@@ -56,9 +44,6 @@ export const chrome: ChromeDict = {
|
||||
issueLabel: "Edició del {date}",
|
||||
dateLocale: "ca-ES",
|
||||
|
||||
starsAria: "Estrelles a GitHub",
|
||||
githubFallback: "GitHub",
|
||||
|
||||
tickerLiveLabel: "En directe",
|
||||
tickerLiveTag: "LIVE",
|
||||
tickerMerged: "fusionat",
|
||||
@@ -78,8 +63,8 @@ export const chrome: ChromeDict = {
|
||||
themeAuto: "auto",
|
||||
themeLight: "clar",
|
||||
themeDark: "fosc",
|
||||
themeAria: "Tema de la documentació: {mode} (fes clic per canviar)",
|
||||
themeTitle: "Tema de la documentació · auto / clar / fosc",
|
||||
themeAria: "Tema: {mode} (fes clic per canviar)",
|
||||
themeTitle: "Tema · auto / clar / fosc",
|
||||
|
||||
footerTagline:
|
||||
"Crea el que vulguis i automatitza la feina quotidiana amb els models que triïs.",
|
||||
|
||||
@@ -16,6 +16,7 @@ export const home: HomeDict = {
|
||||
heroIntro:
|
||||
"{brand} et proporciona agents que poden crear programari, treballar amb els teus fitxers i convertir les tasques repetitives en fluxos de treball reutilitzables. Digues-los què vols aconseguir i tria els models allotjats o locals adequats per a la feina, amb la llibertat de canviar de proveïdor sobre la marxa.",
|
||||
getCodewhale: "Obtenir Codewhale",
|
||||
heroInstallAria: "Ordre d'instal·lació",
|
||||
exploreProduct: "Explorar el producte",
|
||||
shotPreview: "Vista prèvia del terminal",
|
||||
shotBuild: "build de desenvolupament v{version}",
|
||||
|
||||
@@ -25,19 +25,9 @@ export const chrome: ChromeDict = {
|
||||
navCommunity: "Community",
|
||||
navContribute: "Mitwirken",
|
||||
|
||||
navDocsSecondary: "Docs",
|
||||
navStartSecondary: "Start",
|
||||
navInstallSecondary: "Install",
|
||||
navFaqSecondary: "FAQ",
|
||||
navCommunitySecondary: "Community",
|
||||
navContributeSecondary: "Contribute",
|
||||
|
||||
navProduct: "Produkt",
|
||||
navModels: "Modelle",
|
||||
navPlugins: "Plugins",
|
||||
navProductSecondary: "Product",
|
||||
navModelsSecondary: "Models",
|
||||
navPluginsSecondary: "Plugins",
|
||||
|
||||
skipToContent: "Zum Hauptinhalt springen",
|
||||
|
||||
@@ -47,8 +37,6 @@ export const chrome: ChromeDict = {
|
||||
installCta: "Installieren →",
|
||||
|
||||
authSignIn: "Anmelden",
|
||||
authRegister: "Registrieren",
|
||||
authGroupAria: "Konto",
|
||||
|
||||
wordmarkSeal: "深",
|
||||
wordmarkTag: "jedes Modell, auf deiner Maschine",
|
||||
@@ -56,9 +44,6 @@ export const chrome: ChromeDict = {
|
||||
issueLabel: "Ausgabe vom {date}",
|
||||
dateLocale: "de-DE",
|
||||
|
||||
starsAria: "GitHub-Sterne",
|
||||
githubFallback: "GitHub",
|
||||
|
||||
tickerLiveLabel: "Echtzeit",
|
||||
tickerLiveTag: "LIVE",
|
||||
tickerMerged: "gemerged",
|
||||
@@ -78,8 +63,8 @@ export const chrome: ChromeDict = {
|
||||
themeAuto: "auto",
|
||||
themeLight: "hell",
|
||||
themeDark: "dunkel",
|
||||
themeAria: "Dokumentations-Design: {mode} (Klick zum Wechseln)",
|
||||
themeTitle: "Dokumentations-Design · auto / hell / dunkel",
|
||||
themeAria: "Design: {mode} (Klick zum Wechseln)",
|
||||
themeTitle: "Design · auto / hell / dunkel",
|
||||
|
||||
footerTagline:
|
||||
"Erstelle, was du möchtest, und automatisiere alltägliche Arbeit mit den Modellen deiner Wahl.",
|
||||
|
||||
@@ -16,6 +16,7 @@ export const home: HomeDict = {
|
||||
heroIntro:
|
||||
"{brand} gibt dir Agenten, die Software entwickeln, mit deinen Dateien arbeiten und wiederkehrende Aufgaben in wiederverwendbare Workflows verwandeln können. Beschreibe, was du erreichen möchtest, und wähle gehostete oder lokale Modelle, die zur Aufgabe passen, wobei du den Anbieter im Laufe der Arbeit wechseln kannst.",
|
||||
getCodewhale: "Codewhale holen",
|
||||
heroInstallAria: "Installationsbefehl",
|
||||
exploreProduct: "Produkt ansehen",
|
||||
shotPreview: "Terminal-Vorschau",
|
||||
shotBuild: "Entwicklungsbuild v{version}",
|
||||
|
||||
@@ -3,11 +3,6 @@ import type { ChromeDict } from "../types";
|
||||
/**
|
||||
* English reference chrome dictionary. Every other locale must match these
|
||||
* keys exactly (`npm run check:locales`, `dictionaries.test.ts`).
|
||||
*
|
||||
* The Han secondary labels and the 深 wordmark seal are the English
|
||||
* edition's own editorial device — the newspaper masthead sets them beside
|
||||
* the Latin labels on purpose. Other locales pair their native label with a
|
||||
* short English one instead; nothing hardcodes Han at a call site.
|
||||
*/
|
||||
export const chrome: ChromeDict = {
|
||||
navDocs: "Docs",
|
||||
@@ -17,31 +12,18 @@ export const chrome: ChromeDict = {
|
||||
navCommunity: "Community",
|
||||
navContribute: "Contribute",
|
||||
|
||||
navDocsSecondary: "文档",
|
||||
navStartSecondary: "指引",
|
||||
navInstallSecondary: "安装",
|
||||
navFaqSecondary: "问答",
|
||||
navCommunitySecondary: "社区",
|
||||
navContributeSecondary: "贡献",
|
||||
|
||||
navProduct: "Product",
|
||||
navModels: "Models",
|
||||
navPlugins: "Plugins",
|
||||
navProductSecondary: "产品",
|
||||
navModelsSecondary: "模型",
|
||||
navPluginsSecondary: "插件",
|
||||
|
||||
skipToContent: "Skip to main content",
|
||||
|
||||
|
||||
navPrimaryAria: "Primary",
|
||||
navHomeAria: "Codewhale home",
|
||||
|
||||
installCta: "Install →",
|
||||
|
||||
authSignIn: "Sign in",
|
||||
authRegister: "Create account",
|
||||
authGroupAria: "Account",
|
||||
|
||||
wordmarkSeal: "深",
|
||||
wordmarkTag: "any model, on your machine",
|
||||
@@ -49,9 +31,6 @@ export const chrome: ChromeDict = {
|
||||
issueLabel: "Issue {date}",
|
||||
dateLocale: "en-US",
|
||||
|
||||
starsAria: "GitHub stars",
|
||||
githubFallback: "GitHub",
|
||||
|
||||
tickerLiveLabel: "实 时",
|
||||
tickerLiveTag: "LIVE",
|
||||
tickerMerged: "merged",
|
||||
@@ -71,8 +50,8 @@ export const chrome: ChromeDict = {
|
||||
themeAuto: "auto",
|
||||
themeLight: "light",
|
||||
themeDark: "dark",
|
||||
themeAria: "Docs theme: {mode} (click to cycle)",
|
||||
themeTitle: "Docs theme · auto / light / dark",
|
||||
themeAria: "Theme: {mode} (click to cycle)",
|
||||
themeTitle: "Theme · auto / light / dark",
|
||||
|
||||
footerTagline:
|
||||
"Create what you want and automate everyday work with the models you choose.",
|
||||
|
||||
@@ -19,6 +19,7 @@ export const home: HomeDict = {
|
||||
heroIntro:
|
||||
"{brand} is an open-source agent that reads files, edits code, runs commands, and checks its work. Use it in your terminal or local browser with a hosted or local model. You choose the tools and permissions; the session keeps the conversation and tool results.",
|
||||
getCodewhale: "Get Codewhale",
|
||||
heroInstallAria: "Install command",
|
||||
exploreProduct: "Explore the product",
|
||||
|
||||
shotPreview: "Terminal preview",
|
||||
|
||||
@@ -12,10 +12,6 @@ import type { ChromeDict } from "../types";
|
||||
* Auto-Review / Full Access) under "postura de permisos", `Runtime`,
|
||||
* `fleet`, and `Workflow` stay literal product nouns, and "receipt" is
|
||||
* "recibo".
|
||||
*
|
||||
* Secondary nav labels pair the Spanish primary with the short English
|
||||
* label, the pattern every non-English locale uses; the Han pair is the
|
||||
* English edition's own device.
|
||||
*/
|
||||
export const chrome: ChromeDict = {
|
||||
navDocs: "Documentación",
|
||||
@@ -25,31 +21,18 @@ export const chrome: ChromeDict = {
|
||||
navCommunity: "Comunidad",
|
||||
navContribute: "Contribuir",
|
||||
|
||||
navDocsSecondary: "Docs",
|
||||
navStartSecondary: "Start",
|
||||
navInstallSecondary: "Install",
|
||||
navFaqSecondary: "FAQ",
|
||||
navCommunitySecondary: "Community",
|
||||
navContributeSecondary: "Contribute",
|
||||
|
||||
navProduct: "Producto",
|
||||
navModels: "Modelos",
|
||||
navPlugins: "Plugins",
|
||||
navProductSecondary: "Product",
|
||||
navModelsSecondary: "Models",
|
||||
navPluginsSecondary: "Plugins",
|
||||
|
||||
skipToContent: "Saltar al contenido principal",
|
||||
|
||||
|
||||
navPrimaryAria: "Navegación principal",
|
||||
navHomeAria: "Inicio de Codewhale",
|
||||
|
||||
installCta: "Instalar →",
|
||||
|
||||
authSignIn: "Iniciar sesión",
|
||||
authRegister: "Crear cuenta",
|
||||
authGroupAria: "Cuenta",
|
||||
|
||||
wordmarkSeal: "深",
|
||||
wordmarkTag: "cualquier modelo, en tu máquina",
|
||||
@@ -57,9 +40,6 @@ export const chrome: ChromeDict = {
|
||||
issueLabel: "Edición {date}",
|
||||
dateLocale: "es-419",
|
||||
|
||||
starsAria: "Estrellas en GitHub",
|
||||
githubFallback: "GitHub",
|
||||
|
||||
tickerLiveLabel: "En vivo",
|
||||
tickerLiveTag: "LIVE",
|
||||
tickerMerged: "fusionado",
|
||||
@@ -79,8 +59,8 @@ export const chrome: ChromeDict = {
|
||||
themeAuto: "auto",
|
||||
themeLight: "claro",
|
||||
themeDark: "oscuro",
|
||||
themeAria: "Tema de la documentación: {mode} (clic para alternar)",
|
||||
themeTitle: "Tema de la documentación · auto / claro / oscuro",
|
||||
themeAria: "Tema: {mode} (clic para alternar)",
|
||||
themeTitle: "Tema · auto / claro / oscuro",
|
||||
|
||||
footerTagline:
|
||||
"Crea lo que quieras y automatiza el trabajo cotidiano con los modelos que elijas.",
|
||||
|
||||
@@ -16,6 +16,7 @@ export const home: HomeDict = {
|
||||
heroIntro:
|
||||
"{brand} te ofrece agentes que pueden crear software, trabajar con tus archivos y convertir las tareas repetitivas en flujos de trabajo reutilizables. Diles qué quieres conseguir y elige los modelos alojados o locales adecuados para el trabajo, con la libertad de cambiar de proveedor sobre la marcha.",
|
||||
getCodewhale: "Obtener Codewhale",
|
||||
heroInstallAria: "Comando de instalación",
|
||||
exploreProduct: "Explorar el producto",
|
||||
shotPreview: "Vista previa de la terminal",
|
||||
shotBuild: "build de desarrollo v{version}",
|
||||
|
||||
@@ -25,19 +25,9 @@ export const chrome: ChromeDict = {
|
||||
navCommunity: "Communauté",
|
||||
navContribute: "Contribuer",
|
||||
|
||||
navDocsSecondary: "Docs",
|
||||
navStartSecondary: "Start",
|
||||
navInstallSecondary: "Install",
|
||||
navFaqSecondary: "FAQ",
|
||||
navCommunitySecondary: "Community",
|
||||
navContributeSecondary: "Contribute",
|
||||
|
||||
navProduct: "Produit",
|
||||
navModels: "Modèles",
|
||||
navPlugins: "Plugins",
|
||||
navProductSecondary: "Product",
|
||||
navModelsSecondary: "Models",
|
||||
navPluginsSecondary: "Plugins",
|
||||
|
||||
skipToContent: "Aller au contenu principal",
|
||||
|
||||
@@ -47,8 +37,6 @@ export const chrome: ChromeDict = {
|
||||
installCta: "Installer →",
|
||||
|
||||
authSignIn: "Se connecter",
|
||||
authRegister: "Créer un compte",
|
||||
authGroupAria: "Compte",
|
||||
|
||||
wordmarkSeal: "深",
|
||||
wordmarkTag: "n’importe quel modèle, sur votre machine",
|
||||
@@ -56,9 +44,6 @@ export const chrome: ChromeDict = {
|
||||
issueLabel: "Édition du {date}",
|
||||
dateLocale: "fr-FR",
|
||||
|
||||
starsAria: "Étoiles GitHub",
|
||||
githubFallback: "GitHub",
|
||||
|
||||
tickerLiveLabel: "En direct",
|
||||
tickerLiveTag: "LIVE",
|
||||
tickerMerged: "fusionné",
|
||||
@@ -78,8 +63,8 @@ export const chrome: ChromeDict = {
|
||||
themeAuto: "auto",
|
||||
themeLight: "clair",
|
||||
themeDark: "sombre",
|
||||
themeAria: "Thème de la documentation : {mode} (cliquer pour changer)",
|
||||
themeTitle: "Thème de la documentation · auto / clair / sombre",
|
||||
themeAria: "Thème : {mode} (cliquer pour changer)",
|
||||
themeTitle: "Thème · auto / clair / sombre",
|
||||
|
||||
footerTagline:
|
||||
"Créez ce que vous voulez et automatisez le travail du quotidien avec les modèles de votre choix.",
|
||||
|
||||
@@ -16,6 +16,7 @@ export const home: HomeDict = {
|
||||
heroIntro:
|
||||
"{brand} vous donne des agents capables de créer des logiciels, de travailler sur vos fichiers et de transformer les tâches répétitives en workflows réutilisables. Dites-leur ce que vous voulez accomplir et choisissez les modèles hébergés ou locaux adaptés au travail, avec la liberté de changer de fournisseur en cours de route.",
|
||||
getCodewhale: "Obtenir Codewhale",
|
||||
heroInstallAria: "Commande d'installation",
|
||||
exploreProduct: "Découvrir le produit",
|
||||
shotPreview: "Aperçu du terminal",
|
||||
shotBuild: "build de développement v{version}",
|
||||
|
||||
@@ -23,19 +23,9 @@ export const chrome: ChromeDict = {
|
||||
navCommunity: "समुदाय",
|
||||
navContribute: "योगदान",
|
||||
|
||||
navDocsSecondary: "Docs",
|
||||
navStartSecondary: "Start",
|
||||
navInstallSecondary: "Install",
|
||||
navFaqSecondary: "FAQ",
|
||||
navCommunitySecondary: "Community",
|
||||
navContributeSecondary: "Contribute",
|
||||
|
||||
navProduct: "उत्पाद",
|
||||
navModels: "मॉडल",
|
||||
navPlugins: "प्लगइन",
|
||||
navProductSecondary: "Product",
|
||||
navModelsSecondary: "Models",
|
||||
navPluginsSecondary: "Plugins",
|
||||
|
||||
skipToContent: "मुख्य सामग्री पर जाएँ",
|
||||
|
||||
@@ -45,8 +35,6 @@ export const chrome: ChromeDict = {
|
||||
installCta: "इंस्टॉल करें →",
|
||||
|
||||
authSignIn: "साइन इन करें",
|
||||
authRegister: "रजिस्टर करें",
|
||||
authGroupAria: "खाता",
|
||||
|
||||
wordmarkSeal: "深",
|
||||
wordmarkTag: "कोई भी मॉडल, आपकी मशीन पर",
|
||||
@@ -54,9 +42,6 @@ export const chrome: ChromeDict = {
|
||||
issueLabel: "{date} का अंक",
|
||||
dateLocale: "hi-IN",
|
||||
|
||||
starsAria: "GitHub स्टार",
|
||||
githubFallback: "GitHub",
|
||||
|
||||
tickerLiveLabel: "लाइव",
|
||||
tickerLiveTag: "LIVE",
|
||||
tickerMerged: "मर्ज",
|
||||
@@ -76,8 +61,8 @@ export const chrome: ChromeDict = {
|
||||
themeAuto: "ऑटो",
|
||||
themeLight: "लाइट",
|
||||
themeDark: "डार्क",
|
||||
themeAria: "दस्तावेज़ीकरण थीम: {mode} (बदलने के लिए क्लिक करें)",
|
||||
themeTitle: "दस्तावेज़ीकरण थीम · ऑटो / लाइट / डार्क",
|
||||
themeAria: "थीम: {mode} (बदलने के लिए क्लिक करें)",
|
||||
themeTitle: "थीम · ऑटो / लाइट / डार्क",
|
||||
|
||||
footerTagline:
|
||||
"अपनी पसंद के मॉडल से जो चाहें बनाएँ और रोज़मर्रा के काम स्वचालित करें।",
|
||||
|
||||
@@ -16,6 +16,7 @@ export const home: HomeDict = {
|
||||
heroIntro:
|
||||
"{brand} आपको ऐसे एजेंट देता है जो सॉफ़्टवेयर बना सकते हैं, आपकी फ़ाइलों पर काम कर सकते हैं और दोहराए जाने वाले कामों को बार-बार इस्तेमाल होने वाले वर्कफ़्लो में बदल सकते हैं। उन्हें बताएँ कि आप क्या करना चाहते हैं और काम के लिए उपयुक्त होस्टेड या लोकल मॉडल चुनें, जिन्हें इस्तेमाल करते हुए आप अपनी ज़रूरत के अनुसार प्रदाता भी बदल सकते हैं।",
|
||||
getCodewhale: "Codewhale लें",
|
||||
heroInstallAria: "इंस्टॉल कमांड",
|
||||
exploreProduct: "उत्पाद देखें",
|
||||
shotPreview: "टर्मिनल पूर्वावलोकन",
|
||||
shotBuild: "v{version} डेवलपमेंट बिल्ड",
|
||||
|
||||
@@ -10,10 +10,6 @@ import type { ChromeDict } from "../types";
|
||||
* "penalaran" (reasoning), "repositori", "tanda terima" (receipt). The mode
|
||||
* names (Plan / Work / Operate) and permission postures (Ask / Auto-Review /
|
||||
* Full Access) stay literal there and stay literal here.
|
||||
*
|
||||
* Secondary nav labels pair the Indonesian primary with a short English
|
||||
* companion — the masthead's bilingual device, minus the Han seals, which
|
||||
* belong to the English edition.
|
||||
*/
|
||||
export const chrome: ChromeDict = {
|
||||
navDocs: "Dokumentasi",
|
||||
@@ -23,31 +19,18 @@ export const chrome: ChromeDict = {
|
||||
navCommunity: "Komunitas",
|
||||
navContribute: "Kontribusi",
|
||||
|
||||
navDocsSecondary: "Docs",
|
||||
navStartSecondary: "Start",
|
||||
navInstallSecondary: "Install",
|
||||
navFaqSecondary: "FAQ",
|
||||
navCommunitySecondary: "Community",
|
||||
navContributeSecondary: "Contribute",
|
||||
|
||||
navProduct: "Produk",
|
||||
navModels: "Model",
|
||||
navPlugins: "Plugin",
|
||||
navProductSecondary: "Product",
|
||||
navModelsSecondary: "Models",
|
||||
navPluginsSecondary: "Plugins",
|
||||
|
||||
skipToContent: "Lewati ke konten utama",
|
||||
|
||||
|
||||
navPrimaryAria: "Navigasi utama",
|
||||
navHomeAria: "Beranda Codewhale",
|
||||
|
||||
installCta: "Instal →",
|
||||
|
||||
authSignIn: "Masuk",
|
||||
authRegister: "Daftar",
|
||||
authGroupAria: "Akun",
|
||||
|
||||
wordmarkSeal: "深",
|
||||
wordmarkTag: "model apa pun, di mesin Anda",
|
||||
@@ -55,9 +38,6 @@ export const chrome: ChromeDict = {
|
||||
issueLabel: "Edisi {date}",
|
||||
dateLocale: "id-ID",
|
||||
|
||||
starsAria: "Bintang GitHub",
|
||||
githubFallback: "GitHub",
|
||||
|
||||
tickerLiveLabel: "Langsung",
|
||||
tickerLiveTag: "LIVE",
|
||||
tickerMerged: "digabungkan",
|
||||
@@ -77,8 +57,8 @@ export const chrome: ChromeDict = {
|
||||
themeAuto: "otomatis",
|
||||
themeLight: "terang",
|
||||
themeDark: "gelap",
|
||||
themeAria: "Tema dokumentasi: {mode} (klik untuk mengganti)",
|
||||
themeTitle: "Tema dokumentasi · otomatis / terang / gelap",
|
||||
themeAria: "Tema: {mode} (klik untuk mengganti)",
|
||||
themeTitle: "Tema · otomatis / terang / gelap",
|
||||
|
||||
footerTagline:
|
||||
"Ciptakan apa yang Anda inginkan dan otomatisasikan pekerjaan sehari-hari dengan model pilihan Anda.",
|
||||
|
||||
@@ -16,6 +16,7 @@ export const home: HomeDict = {
|
||||
heroIntro:
|
||||
"{brand} menyediakan agen yang dapat membangun perangkat lunak, mengelola berkas Anda, dan mengubah tugas berulang menjadi alur kerja yang dapat digunakan kembali. Sampaikan apa yang ingin Anda capai dan pilih model yang dihosting atau dijalankan secara lokal sesuai kebutuhan tugas, dengan kebebasan untuk berganti penyedia selama bekerja.",
|
||||
getCodewhale: "Dapatkan Codewhale",
|
||||
heroInstallAria: "Perintah instalasi",
|
||||
exploreProduct: "Jelajahi produk",
|
||||
shotPreview: "Pratinjau terminal",
|
||||
shotBuild: "build pengembangan v{version}",
|
||||
|
||||
@@ -23,19 +23,9 @@ export const chrome: ChromeDict = {
|
||||
navCommunity: "Comunità",
|
||||
navContribute: "Contribuisci",
|
||||
|
||||
navDocsSecondary: "Docs",
|
||||
navStartSecondary: "Start",
|
||||
navInstallSecondary: "Install",
|
||||
navFaqSecondary: "FAQ",
|
||||
navCommunitySecondary: "Community",
|
||||
navContributeSecondary: "Contribute",
|
||||
|
||||
navProduct: "Prodotto",
|
||||
navModels: "Modelli",
|
||||
navPlugins: "Plugin",
|
||||
navProductSecondary: "Product",
|
||||
navModelsSecondary: "Models",
|
||||
navPluginsSecondary: "Plugins",
|
||||
|
||||
skipToContent: "Vai al contenuto principale",
|
||||
|
||||
@@ -45,8 +35,6 @@ export const chrome: ChromeDict = {
|
||||
installCta: "Installa →",
|
||||
|
||||
authSignIn: "Accedi",
|
||||
authRegister: "Registrati",
|
||||
authGroupAria: "Account",
|
||||
|
||||
wordmarkSeal: "深",
|
||||
wordmarkTag: "qualsiasi modello, sulla tua macchina",
|
||||
@@ -54,9 +42,6 @@ export const chrome: ChromeDict = {
|
||||
issueLabel: "Edizione del {date}",
|
||||
dateLocale: "it-IT",
|
||||
|
||||
starsAria: "Stelle GitHub",
|
||||
githubFallback: "GitHub",
|
||||
|
||||
tickerLiveLabel: "In diretta",
|
||||
tickerLiveTag: "LIVE",
|
||||
tickerMerged: "unito",
|
||||
@@ -76,8 +61,8 @@ export const chrome: ChromeDict = {
|
||||
themeAuto: "auto",
|
||||
themeLight: "chiaro",
|
||||
themeDark: "scuro",
|
||||
themeAria: "Tema della documentazione: {mode} (clic per cambiare)",
|
||||
themeTitle: "Tema della documentazione · auto / chiaro / scuro",
|
||||
themeAria: "Tema: {mode} (clic per cambiare)",
|
||||
themeTitle: "Tema · auto / chiaro / scuro",
|
||||
|
||||
footerTagline:
|
||||
"Crea quello che vuoi e automatizza il lavoro quotidiano con i modelli che scegli.",
|
||||
|
||||
@@ -16,6 +16,7 @@ export const home: HomeDict = {
|
||||
heroIntro:
|
||||
"{brand} ti offre agenti che possono creare software, lavorare con i tuoi file e trasformare le attività ripetitive in flussi di lavoro riutilizzabili. Di’ loro cosa vuoi realizzare e scegli i modelli ospitati o locali adatti al lavoro, con la libertà di cambiare provider mentre procedi.",
|
||||
getCodewhale: "Ottieni Codewhale",
|
||||
heroInstallAria: "Comando di installazione",
|
||||
exploreProduct: "Esplora il prodotto",
|
||||
shotPreview: "Anteprima del terminale",
|
||||
shotBuild: "build di sviluppo v{version}",
|
||||
|
||||
@@ -11,10 +11,6 @@ import type { ChromeDict } from "../types";
|
||||
* Auto-Review / Full Access), 権限 is "permissions", 推論 is "reasoning",
|
||||
* レシート is "receipt". The pack renders "posture" as 姿勢/権限 rather than
|
||||
* the katakana calque ポスチャ, so the website matches it.
|
||||
*
|
||||
* The secondary nav labels pair the Japanese primary with a short English
|
||||
* companion — the Han pair (文档 / 指引 / …) is the English edition's own
|
||||
* editorial device and is not reused here.
|
||||
*/
|
||||
export const chrome: ChromeDict = {
|
||||
navDocs: "ドキュメント",
|
||||
@@ -24,31 +20,18 @@ export const chrome: ChromeDict = {
|
||||
navCommunity: "コミュニティ",
|
||||
navContribute: "貢献",
|
||||
|
||||
navDocsSecondary: "Docs",
|
||||
navStartSecondary: "Start",
|
||||
navInstallSecondary: "Install",
|
||||
navFaqSecondary: "FAQ",
|
||||
navCommunitySecondary: "Community",
|
||||
navContributeSecondary: "Contribute",
|
||||
|
||||
navProduct: "製品",
|
||||
navModels: "モデル",
|
||||
navPlugins: "プラグイン",
|
||||
navProductSecondary: "Product",
|
||||
navModelsSecondary: "Models",
|
||||
navPluginsSecondary: "Plugins",
|
||||
|
||||
skipToContent: "メインコンテンツへスキップ",
|
||||
|
||||
|
||||
navPrimaryAria: "メインナビゲーション",
|
||||
navHomeAria: "Codewhale ホーム",
|
||||
|
||||
installCta: "インストール →",
|
||||
|
||||
authSignIn: "ログイン",
|
||||
authRegister: "新規登録",
|
||||
authGroupAria: "アカウント",
|
||||
|
||||
wordmarkSeal: "深",
|
||||
wordmarkTag: "どんなモデルでも、あなたのマシンで",
|
||||
@@ -56,9 +39,6 @@ export const chrome: ChromeDict = {
|
||||
issueLabel: "{date} 号",
|
||||
dateLocale: "ja-JP",
|
||||
|
||||
starsAria: "GitHub のスター数",
|
||||
githubFallback: "GitHub",
|
||||
|
||||
tickerLiveLabel: "速 報",
|
||||
tickerLiveTag: "LIVE",
|
||||
tickerMerged: "マージ",
|
||||
@@ -78,8 +58,8 @@ export const chrome: ChromeDict = {
|
||||
themeAuto: "自動",
|
||||
themeLight: "ライト",
|
||||
themeDark: "ダーク",
|
||||
themeAria: "ドキュメントのテーマ:{mode}(クリックで切り替え)",
|
||||
themeTitle: "ドキュメントのテーマ · 自動 / ライト / ダーク",
|
||||
themeAria: "テーマ:{mode}(クリックで切り替え)",
|
||||
themeTitle: "テーマ · 自動 / ライト / ダーク",
|
||||
|
||||
footerTagline:
|
||||
"選んだモデルで作りたいものを形にし、日々の作業を自動化できます。",
|
||||
|
||||
@@ -16,6 +16,7 @@ export const home: HomeDict = {
|
||||
heroIntro:
|
||||
"{brand} のエージェントは、ソフトウェアを開発し、ファイルを扱い、繰り返し行う作業を再利用できるワークフローにまとめられます。達成したいことを伝えて仕事に合うホスト型またはローカルのモデルを選び、必要に応じてプロバイダーを自由に切り替えながら作業を進めてください。",
|
||||
getCodewhale: "Codewhale を入手",
|
||||
heroInstallAria: "インストールコマンド",
|
||||
exploreProduct: "製品を見る",
|
||||
shotPreview: "ターミナルのプレビュー",
|
||||
shotBuild: "v{version} 開発ビルド",
|
||||
|
||||
@@ -11,10 +11,6 @@ import type { ChromeDict } from "../types";
|
||||
* "repository", 추론 is "reasoning", 권한 is "permission". Commands, package
|
||||
* names, and GitHub are left as-is per docs/VOICE.md.
|
||||
*
|
||||
* The secondary nav labels pair the Korean primary with a short English
|
||||
* label — the Han pair (文档 / 指引 / …) is the English edition's own
|
||||
* editorial device and is never hardcoded at a call site.
|
||||
*
|
||||
* Nav labels are kept to two–four syllables: they sit in one horizontal
|
||||
* masthead row. "FAQ" therefore renders as 질의응답 rather than the longer
|
||||
* 자주 묻는 질문, in both the nav and the footer so the two agree.
|
||||
@@ -27,31 +23,18 @@ export const chrome: ChromeDict = {
|
||||
navCommunity: "커뮤니티",
|
||||
navContribute: "기여",
|
||||
|
||||
navDocsSecondary: "Docs",
|
||||
navStartSecondary: "Start",
|
||||
navInstallSecondary: "Install",
|
||||
navFaqSecondary: "FAQ",
|
||||
navCommunitySecondary: "Community",
|
||||
navContributeSecondary: "Contribute",
|
||||
|
||||
navProduct: "제품",
|
||||
navModels: "모델",
|
||||
navPlugins: "플러그인",
|
||||
navProductSecondary: "Product",
|
||||
navModelsSecondary: "Models",
|
||||
navPluginsSecondary: "Plugins",
|
||||
|
||||
skipToContent: "본문으로 건너뛰기",
|
||||
|
||||
|
||||
navPrimaryAria: "기본 탐색",
|
||||
navHomeAria: "Codewhale 홈",
|
||||
|
||||
installCta: "설치 →",
|
||||
|
||||
authSignIn: "로그인",
|
||||
authRegister: "회원가입",
|
||||
authGroupAria: "계정",
|
||||
|
||||
wordmarkSeal: "深",
|
||||
wordmarkTag: "어떤 모델이든, 당신의 머신에서",
|
||||
@@ -61,9 +44,6 @@ export const chrome: ChromeDict = {
|
||||
issueLabel: "{date}자",
|
||||
dateLocale: "ko-KR",
|
||||
|
||||
starsAria: "GitHub 스타 수",
|
||||
githubFallback: "GitHub",
|
||||
|
||||
tickerLiveLabel: "실시간",
|
||||
tickerLiveTag: "LIVE",
|
||||
tickerMerged: "병합됨",
|
||||
@@ -83,8 +63,8 @@ export const chrome: ChromeDict = {
|
||||
themeAuto: "자동",
|
||||
themeLight: "밝게",
|
||||
themeDark: "어둡게",
|
||||
themeAria: "문서 테마: {mode} (클릭하면 전환)",
|
||||
themeTitle: "문서 테마 · 자동 / 밝게 / 어둡게",
|
||||
themeAria: "테마: {mode} (클릭하면 전환)",
|
||||
themeTitle: "테마 · 자동 / 밝게 / 어둡게",
|
||||
|
||||
footerTagline:
|
||||
"원하는 모델로 만들고 싶은 것을 구현하고 일상적인 작업을 자동화하세요.",
|
||||
|
||||
@@ -16,6 +16,7 @@ export const home: HomeDict = {
|
||||
heroIntro:
|
||||
"{brand}은 소프트웨어를 개발하고 파일을 다루며 반복 작업을 재사용 가능한 워크플로로 바꿀 수 있는 에이전트를 제공합니다. 무엇을 이루고 싶은지 알려 주고 작업에 맞는 호스팅형 또는 로컬 모델을 선택하면, 작업을 진행하면서 제공업체도 자유롭게 바꿀 수 있습니다.",
|
||||
getCodewhale: "Codewhale 받기",
|
||||
heroInstallAria: "설치 명령",
|
||||
exploreProduct: "제품 살펴보기",
|
||||
shotPreview: "터미널 미리보기",
|
||||
shotBuild: "v{version} 개발 빌드",
|
||||
|
||||
@@ -22,19 +22,9 @@ export const chrome: ChromeDict = {
|
||||
navCommunity: "Społeczność",
|
||||
navContribute: "Współtwórz",
|
||||
|
||||
navDocsSecondary: "Docs",
|
||||
navStartSecondary: "Start",
|
||||
navInstallSecondary: "Install",
|
||||
navFaqSecondary: "FAQ",
|
||||
navCommunitySecondary: "Community",
|
||||
navContributeSecondary: "Contribute",
|
||||
|
||||
navProduct: "Produkt",
|
||||
navModels: "Modele",
|
||||
navPlugins: "Wtyczki",
|
||||
navProductSecondary: "Product",
|
||||
navModelsSecondary: "Models",
|
||||
navPluginsSecondary: "Plugins",
|
||||
|
||||
skipToContent: "Przejdź do treści głównej",
|
||||
|
||||
@@ -44,8 +34,6 @@ export const chrome: ChromeDict = {
|
||||
installCta: "Instaluj →",
|
||||
|
||||
authSignIn: "Zaloguj się",
|
||||
authRegister: "Zarejestruj się",
|
||||
authGroupAria: "Konto",
|
||||
|
||||
wordmarkSeal: "深",
|
||||
wordmarkTag: "dowolny model, na twojej maszynie",
|
||||
@@ -53,9 +41,6 @@ export const chrome: ChromeDict = {
|
||||
issueLabel: "Wydanie z {date}",
|
||||
dateLocale: "pl-PL",
|
||||
|
||||
starsAria: "Gwiazdki na GitHubie",
|
||||
githubFallback: "GitHub",
|
||||
|
||||
tickerLiveLabel: "Na żywo",
|
||||
tickerLiveTag: "LIVE",
|
||||
tickerMerged: "scalono",
|
||||
@@ -75,8 +60,8 @@ export const chrome: ChromeDict = {
|
||||
themeAuto: "auto",
|
||||
themeLight: "jasny",
|
||||
themeDark: "ciemny",
|
||||
themeAria: "Motyw dokumentacji: {mode} (kliknij, aby przełączyć)",
|
||||
themeTitle: "Motyw dokumentacji · auto / jasny / ciemny",
|
||||
themeAria: "Motyw: {mode} (kliknij, aby przełączyć)",
|
||||
themeTitle: "Motyw · auto / jasny / ciemny",
|
||||
|
||||
footerTagline:
|
||||
"Twórz to, co chcesz, i automatyzuj codzienną pracę z wybranymi przez siebie modelami.",
|
||||
|
||||
@@ -16,6 +16,7 @@ export const home: HomeDict = {
|
||||
heroIntro:
|
||||
"{brand} daje Ci agentów, którzy mogą tworzyć oprogramowanie, pracować z Twoimi plikami i zamieniać powtarzalne zadania w przepływy pracy, z których można korzystać wielokrotnie. Powiedz, co chcesz osiągnąć, i wybierz hostowane lub lokalne modele odpowiednie do zadania, z możliwością zmiany dostawców w trakcie pracy.",
|
||||
getCodewhale: "Pobierz Codewhale",
|
||||
heroInstallAria: "Polecenie instalacji",
|
||||
exploreProduct: "Poznaj produkt",
|
||||
shotPreview: "Podgląd terminala",
|
||||
shotBuild: "kompilacja deweloperska v{version}",
|
||||
|
||||
@@ -14,31 +14,18 @@ export const chrome: ChromeDict = {
|
||||
navCommunity: "Comunidade",
|
||||
navContribute: "Contribuir",
|
||||
|
||||
navDocsSecondary: "Docs",
|
||||
navStartSecondary: "Start",
|
||||
navInstallSecondary: "Install",
|
||||
navFaqSecondary: "FAQ",
|
||||
navCommunitySecondary: "Community",
|
||||
navContributeSecondary: "Contribute",
|
||||
|
||||
navProduct: "Produto",
|
||||
navModels: "Modelos",
|
||||
navPlugins: "Plugins",
|
||||
navProductSecondary: "Product",
|
||||
navModelsSecondary: "Models",
|
||||
navPluginsSecondary: "Plugins",
|
||||
|
||||
skipToContent: "Pular para o conteúdo principal",
|
||||
|
||||
|
||||
navPrimaryAria: "Navegação principal",
|
||||
navHomeAria: "Início do Codewhale",
|
||||
|
||||
installCta: "Instalar →",
|
||||
|
||||
authSignIn: "Entrar",
|
||||
authRegister: "Criar conta",
|
||||
authGroupAria: "Conta",
|
||||
|
||||
wordmarkSeal: "深",
|
||||
wordmarkTag: "qualquer modelo, na sua máquina",
|
||||
@@ -46,9 +33,6 @@ export const chrome: ChromeDict = {
|
||||
issueLabel: "Edição {date}",
|
||||
dateLocale: "pt-BR",
|
||||
|
||||
starsAria: "Estrelas no GitHub",
|
||||
githubFallback: "GitHub",
|
||||
|
||||
tickerLiveLabel: "Ao vivo",
|
||||
tickerLiveTag: "LIVE",
|
||||
tickerMerged: "mesclado",
|
||||
@@ -68,8 +52,8 @@ export const chrome: ChromeDict = {
|
||||
themeAuto: "auto",
|
||||
themeLight: "claro",
|
||||
themeDark: "escuro",
|
||||
themeAria: "Tema da documentação: {mode} (clique para alternar)",
|
||||
themeTitle: "Tema da documentação · auto / claro / escuro",
|
||||
themeAria: "Tema: {mode} (clique para alternar)",
|
||||
themeTitle: "Tema · auto / claro / escuro",
|
||||
|
||||
footerTagline:
|
||||
"Crie o que quiser e automatize o trabalho do dia a dia com os modelos que você escolher.",
|
||||
|
||||
@@ -16,6 +16,7 @@ export const home: HomeDict = {
|
||||
heroIntro:
|
||||
"{brand} oferece agentes que podem criar software, trabalhar com seus arquivos e transformar tarefas repetitivas em fluxos de trabalho reutilizáveis. Diga a eles o que você quer realizar e escolha os modelos hospedados ou locais adequados ao trabalho, com a liberdade de trocar de provedor ao longo do caminho.",
|
||||
getCodewhale: "Obter o Codewhale",
|
||||
heroInstallAria: "Comando de instalação",
|
||||
exploreProduct: "Explorar o produto",
|
||||
shotPreview: "Prévia do terminal",
|
||||
shotBuild: "build de desenvolvimento v{version}",
|
||||
|
||||
@@ -9,8 +9,7 @@ import type { ChromeDict } from "../types";
|
||||
* the modes Plan / Work / Operate and the permission postures
|
||||
* Ask / Auto-Review / Full Access stay Latin, wrapped in Russian prose
|
||||
* ("режим Operate", "режим разрешений"). The 深 seal is the masthead's mark,
|
||||
* not prose, and is shared across locales. Native nav labels pair with short
|
||||
* English secondaries, per the masthead convention.
|
||||
* not prose, and is shared across locales.
|
||||
*/
|
||||
export const chrome: ChromeDict = {
|
||||
navDocs: "Документация",
|
||||
@@ -20,31 +19,18 @@ export const chrome: ChromeDict = {
|
||||
navCommunity: "Сообщество",
|
||||
navContribute: "Участие",
|
||||
|
||||
navDocsSecondary: "Docs",
|
||||
navStartSecondary: "Start",
|
||||
navInstallSecondary: "Install",
|
||||
navFaqSecondary: "FAQ",
|
||||
navCommunitySecondary: "Community",
|
||||
navContributeSecondary: "Contribute",
|
||||
|
||||
navProduct: "Продукт",
|
||||
navModels: "Модели",
|
||||
navPlugins: "Плагины",
|
||||
navProductSecondary: "Product",
|
||||
navModelsSecondary: "Models",
|
||||
navPluginsSecondary: "Plugins",
|
||||
|
||||
skipToContent: "Перейти к основному содержимому",
|
||||
|
||||
|
||||
navPrimaryAria: "Основная навигация",
|
||||
navHomeAria: "Главная Codewhale",
|
||||
|
||||
installCta: "Установить →",
|
||||
|
||||
authSignIn: "Войти",
|
||||
authRegister: "Регистрация",
|
||||
authGroupAria: "Аккаунт",
|
||||
|
||||
wordmarkSeal: "深",
|
||||
wordmarkTag: "любая модель, на вашей машине",
|
||||
@@ -52,9 +38,6 @@ export const chrome: ChromeDict = {
|
||||
issueLabel: "Выпуск {date}",
|
||||
dateLocale: "ru-RU",
|
||||
|
||||
starsAria: "Звёзды на GitHub",
|
||||
githubFallback: "GitHub",
|
||||
|
||||
tickerLiveLabel: "Эфир",
|
||||
tickerLiveTag: "LIVE",
|
||||
tickerMerged: "влит",
|
||||
@@ -74,8 +57,8 @@ export const chrome: ChromeDict = {
|
||||
themeAuto: "авто",
|
||||
themeLight: "светлая",
|
||||
themeDark: "тёмная",
|
||||
themeAria: "Тема документации: {mode} (нажмите, чтобы переключить)",
|
||||
themeTitle: "Тема документации · авто / светлая / тёмная",
|
||||
themeAria: "Тема: {mode} (нажмите, чтобы переключить)",
|
||||
themeTitle: "Тема · авто / светлая / тёмная",
|
||||
|
||||
footerTagline:
|
||||
"Создавайте то, что хотите, и автоматизируйте повседневную работу с помощью выбранных вами моделей.",
|
||||
|
||||
@@ -16,6 +16,7 @@ export const home: HomeDict = {
|
||||
heroIntro:
|
||||
"{brand} даёт вам агентов, которые могут создавать программы, работать с вашими файлами и превращать повторяющиеся задачи в рабочие процессы, которые можно запускать повторно. Расскажите, чего хотите добиться, и выберите подходящие для задачи облачные или локальные модели, сохраняя возможность менять провайдеров по ходу работы.",
|
||||
getCodewhale: "Получить Codewhale",
|
||||
heroInstallAria: "Команда установки",
|
||||
exploreProduct: "Посмотреть продукт",
|
||||
shotPreview: "Предпросмотр терминала",
|
||||
shotBuild: "сборка для разработки v{version}",
|
||||
|
||||
@@ -22,19 +22,9 @@ export const chrome: ChromeDict = {
|
||||
navCommunity: "Topluluk",
|
||||
navContribute: "Katkı",
|
||||
|
||||
navDocsSecondary: "Docs",
|
||||
navStartSecondary: "Start",
|
||||
navInstallSecondary: "Install",
|
||||
navFaqSecondary: "FAQ",
|
||||
navCommunitySecondary: "Community",
|
||||
navContributeSecondary: "Contribute",
|
||||
|
||||
navProduct: "Ürün",
|
||||
navModels: "Modeller",
|
||||
navPlugins: "Eklentiler",
|
||||
navProductSecondary: "Product",
|
||||
navModelsSecondary: "Models",
|
||||
navPluginsSecondary: "Plugins",
|
||||
|
||||
skipToContent: "Ana içeriğe geç",
|
||||
|
||||
@@ -44,8 +34,6 @@ export const chrome: ChromeDict = {
|
||||
installCta: "Kur →",
|
||||
|
||||
authSignIn: "Giriş yap",
|
||||
authRegister: "Kayıt ol",
|
||||
authGroupAria: "Hesap",
|
||||
|
||||
wordmarkSeal: "深",
|
||||
wordmarkTag: "istediğin model, senin makinen",
|
||||
@@ -53,9 +41,6 @@ export const chrome: ChromeDict = {
|
||||
issueLabel: "{date} sayısı",
|
||||
dateLocale: "tr-TR",
|
||||
|
||||
starsAria: "GitHub yıldızları",
|
||||
githubFallback: "GitHub",
|
||||
|
||||
tickerLiveLabel: "Canlı",
|
||||
tickerLiveTag: "LIVE",
|
||||
tickerMerged: "birleştirildi",
|
||||
@@ -75,8 +60,8 @@ export const chrome: ChromeDict = {
|
||||
themeAuto: "otomatik",
|
||||
themeLight: "açık",
|
||||
themeDark: "koyu",
|
||||
themeAria: "Belge teması: {mode} (geçiş için tıkla)",
|
||||
themeTitle: "Belge teması · otomatik / açık / koyu",
|
||||
themeAria: "Tema: {mode} (geçiş için tıkla)",
|
||||
themeTitle: "Tema · otomatik / açık / koyu",
|
||||
|
||||
footerTagline:
|
||||
"Seçtiğin modellerle istediğini oluştur ve günlük işleri otomatikleştir.",
|
||||
|
||||
@@ -16,6 +16,7 @@ export const home: HomeDict = {
|
||||
heroIntro:
|
||||
"{brand}, yazılım geliştirebilen, dosyaların üzerinde çalışabilen ve tekrarlanan görevleri yeniden kullanılabilir iş akışlarına dönüştürebilen ajanlar sunar. Onlara ne yapmak istediğini anlat ve işe uygun barındırılan veya yerel modelleri seç; çalışırken sağlayıcı değiştirmekte özgürsün.",
|
||||
getCodewhale: "Codewhale'i edin",
|
||||
heroInstallAria: "Kurulum komutu",
|
||||
exploreProduct: "Ürünü keşfet",
|
||||
shotPreview: "Terminal önizlemesi",
|
||||
shotBuild: "v{version} geliştirme derlemesi",
|
||||
|
||||
@@ -30,23 +30,6 @@ export interface ChromeDict {
|
||||
navCommunity: string;
|
||||
navContribute: string;
|
||||
|
||||
/**
|
||||
* Bilingual secondary nav labels — the small companion label the
|
||||
* newspaper masthead sets beside each primary link.
|
||||
*
|
||||
* The English edition uses the Han seal pair (文档 / 指引 / …) as an
|
||||
* editorial device; every other locale supplies its OWN pairing (native
|
||||
* primary, short English secondary). Never hardcode Han characters at a
|
||||
* call site — a locale that wants no second label still needs a value
|
||||
* here, because empty strings are rejected by dictionaries.test.ts.
|
||||
*/
|
||||
navDocsSecondary: string;
|
||||
navStartSecondary: string;
|
||||
navInstallSecondary: string;
|
||||
navFaqSecondary: string;
|
||||
navCommunitySecondary: string;
|
||||
navContributeSecondary: string;
|
||||
|
||||
/**
|
||||
* The primary strip: Product / Models / Plugins / Docs. The
|
||||
* older six (Start, Install, FAQ, Community, Contribute) stay in the
|
||||
@@ -55,9 +38,6 @@ export interface ChromeDict {
|
||||
navProduct: string;
|
||||
navModels: string;
|
||||
navPlugins: string;
|
||||
navProductSecondary: string;
|
||||
navModelsSecondary: string;
|
||||
navPluginsSecondary: string;
|
||||
|
||||
/**
|
||||
* Skip-to-content link rendered before the nav in app/[locale]/layout.tsx.
|
||||
@@ -74,11 +54,11 @@ export interface ChromeDict {
|
||||
/** Mobile-menu and masthead call to action, e.g. "Install →". */
|
||||
installCta: string;
|
||||
|
||||
/** Header account links to the Codewhale app (app.codewhale.net). */
|
||||
/**
|
||||
* The header's one identity door to the Codewhale app (app.codewhale.net).
|
||||
* Account creation is offered on the sign-in page, not beside it.
|
||||
*/
|
||||
authSignIn: string;
|
||||
authRegister: string;
|
||||
/** aria-label for the header account link group. */
|
||||
authGroupAria: string;
|
||||
|
||||
/** Wordmark seal glyph beside the masthead brand (components/seal.tsx). */
|
||||
wordmarkSeal: string;
|
||||
@@ -94,11 +74,6 @@ export interface ChromeDict {
|
||||
*/
|
||||
dateLocale: string;
|
||||
|
||||
/** aria-label on the star-count link, e.g. "GitHub stars". */
|
||||
starsAria: string;
|
||||
/** Star-badge label when the live count is unavailable. */
|
||||
githubFallback: string;
|
||||
|
||||
/** Live-ticker seal label (components/ticker.tsx). */
|
||||
tickerLiveLabel: string;
|
||||
/** Live-ticker mono tag beside the seal label, e.g. "LIVE". */
|
||||
@@ -210,6 +185,8 @@ export interface HomeDict {
|
||||
heroIntro: string;
|
||||
/** Primary action → /install, e.g. "Get Codewhale". */
|
||||
getCodewhale: string;
|
||||
/** Accessible name for the hero install command and its platform choice. */
|
||||
heroInstallAria: string;
|
||||
/** Secondary action → /product, e.g. "Explore the product". */
|
||||
exploreProduct: string;
|
||||
|
||||
|
||||
@@ -7,9 +7,6 @@ import type { ChromeDict } from "../types";
|
||||
* режим дозволів for the permission posture, провайдер, репозиторій,
|
||||
* композер, міркування. Plan / Work / Operate and Ask / Auto-Review /
|
||||
* Full Access stay literal there and stay literal here.
|
||||
*
|
||||
* Secondary nav labels pair the Ukrainian primary with a short English
|
||||
* companion — the Han pair is the English edition's own device.
|
||||
*/
|
||||
export const chrome: ChromeDict = {
|
||||
navDocs: "Документація",
|
||||
@@ -19,31 +16,18 @@ export const chrome: ChromeDict = {
|
||||
navCommunity: "Спільнота",
|
||||
navContribute: "Участь",
|
||||
|
||||
navDocsSecondary: "Docs",
|
||||
navStartSecondary: "Start",
|
||||
navInstallSecondary: "Install",
|
||||
navFaqSecondary: "FAQ",
|
||||
navCommunitySecondary: "Community",
|
||||
navContributeSecondary: "Contribute",
|
||||
|
||||
navProduct: "Продукт",
|
||||
navModels: "Моделі",
|
||||
navPlugins: "Плагіни",
|
||||
navProductSecondary: "Product",
|
||||
navModelsSecondary: "Models",
|
||||
navPluginsSecondary: "Plugins",
|
||||
|
||||
skipToContent: "Перейти до основного вмісту",
|
||||
|
||||
|
||||
navPrimaryAria: "Основна навігація",
|
||||
navHomeAria: "Головна сторінка Codewhale",
|
||||
|
||||
installCta: "Встановити →",
|
||||
|
||||
authSignIn: "Увійти",
|
||||
authRegister: "Реєстрація",
|
||||
authGroupAria: "Обліковий запис",
|
||||
|
||||
wordmarkSeal: "深",
|
||||
wordmarkTag: "будь-яка модель, на вашій машині",
|
||||
@@ -51,9 +35,6 @@ export const chrome: ChromeDict = {
|
||||
issueLabel: "Випуск {date}",
|
||||
dateLocale: "uk-UA",
|
||||
|
||||
starsAria: "Зірки на GitHub",
|
||||
githubFallback: "GitHub",
|
||||
|
||||
tickerLiveLabel: "Наживо",
|
||||
tickerLiveTag: "LIVE",
|
||||
tickerMerged: "влито",
|
||||
@@ -73,8 +54,8 @@ export const chrome: ChromeDict = {
|
||||
themeAuto: "авто",
|
||||
themeLight: "світла",
|
||||
themeDark: "темна",
|
||||
themeAria: "Тема документації: {mode} (натисніть, щоб перемкнути)",
|
||||
themeTitle: "Тема документації · авто / світла / темна",
|
||||
themeAria: "Тема: {mode} (натисніть, щоб перемкнути)",
|
||||
themeTitle: "Тема · авто / світла / темна",
|
||||
|
||||
footerTagline:
|
||||
"Створюйте те, що хочете, й автоматизуйте повсякденну роботу за допомогою обраних вами моделей.",
|
||||
|
||||
@@ -16,6 +16,7 @@ export const home: HomeDict = {
|
||||
heroIntro:
|
||||
"{brand} дає вам агентів, які можуть створювати програми, працювати з вашими файлами й перетворювати повторювані завдання на робочі процеси, які можна використовувати знову. Розкажіть, чого хочете досягти, і виберіть хмарні або локальні моделі, які підходять для завдання, та за потреби змінюйте провайдерів під час роботи.",
|
||||
getCodewhale: "Отримати Codewhale",
|
||||
heroInstallAria: "Команда встановлення",
|
||||
exploreProduct: "Переглянути продукт",
|
||||
shotPreview: "Попередній перегляд термінала",
|
||||
shotBuild: "збірка для розробки v{version}",
|
||||
|
||||
@@ -9,10 +9,6 @@ import type { ChromeDict } from "../types";
|
||||
* (repository), mức quyền (permission posture), biên nhận (receipt). Product
|
||||
* terms — Codewhale, Plan / Work / Operate, Ask / Auto-Review / Full Access,
|
||||
* fleet, Workflow, Runtime — stay literal, as they do in the TUI.
|
||||
*
|
||||
* The masthead pairs a Vietnamese primary label with a short English
|
||||
* secondary one; the Han seals are the English edition's own device and are
|
||||
* not borrowed here (only the 深 wordmark glyph is shared).
|
||||
*/
|
||||
export const chrome: ChromeDict = {
|
||||
navDocs: "Tài liệu",
|
||||
@@ -24,31 +20,18 @@ export const chrome: ChromeDict = {
|
||||
navCommunity: "Cộng đồng",
|
||||
navContribute: "Đóng góp",
|
||||
|
||||
navDocsSecondary: "Docs",
|
||||
navStartSecondary: "Start",
|
||||
navInstallSecondary: "Install",
|
||||
navFaqSecondary: "FAQ",
|
||||
navCommunitySecondary: "Community",
|
||||
navContributeSecondary: "Contribute",
|
||||
|
||||
navProduct: "Sản phẩm",
|
||||
navModels: "Mô hình",
|
||||
navPlugins: "Plugin",
|
||||
navProductSecondary: "Product",
|
||||
navModelsSecondary: "Models",
|
||||
navPluginsSecondary: "Plugins",
|
||||
|
||||
skipToContent: "Bỏ qua tới nội dung chính",
|
||||
|
||||
|
||||
navPrimaryAria: "Điều hướng chính",
|
||||
navHomeAria: "Trang chủ Codewhale",
|
||||
|
||||
installCta: "Cài đặt →",
|
||||
|
||||
authSignIn: "Đăng nhập",
|
||||
authRegister: "Đăng ký",
|
||||
authGroupAria: "Tài khoản",
|
||||
|
||||
wordmarkSeal: "深",
|
||||
wordmarkTag: "mọi mô hình, trên máy của bạn",
|
||||
@@ -56,9 +39,6 @@ export const chrome: ChromeDict = {
|
||||
issueLabel: "Số ra {date}",
|
||||
dateLocale: "vi-VN",
|
||||
|
||||
starsAria: "Số sao trên GitHub",
|
||||
githubFallback: "GitHub",
|
||||
|
||||
tickerLiveLabel: "Trực tiếp",
|
||||
tickerLiveTag: "LIVE",
|
||||
tickerMerged: "đã hợp nhất",
|
||||
@@ -78,8 +58,8 @@ export const chrome: ChromeDict = {
|
||||
themeAuto: "tự động",
|
||||
themeLight: "sáng",
|
||||
themeDark: "tối",
|
||||
themeAria: "Giao diện tài liệu: {mode} (nhấn để đổi)",
|
||||
themeTitle: "Giao diện tài liệu · tự động / sáng / tối",
|
||||
themeAria: "Giao diện: {mode} (nhấn để đổi)",
|
||||
themeTitle: "Giao diện · tự động / sáng / tối",
|
||||
|
||||
footerTagline:
|
||||
"Tạo những gì bạn muốn và tự động hóa công việc hằng ngày bằng các mô hình bạn chọn.",
|
||||
|
||||
@@ -16,6 +16,7 @@ export const home: HomeDict = {
|
||||
heroIntro:
|
||||
"{brand} cung cấp các tác tử có thể xây dựng phần mềm, làm việc với tệp và biến những tác vụ lặp lại thành quy trình có thể tái sử dụng. Hãy cho chúng biết bạn muốn hoàn thành điều gì rồi chọn mô hình chạy trên máy chủ hoặc cục bộ phù hợp với công việc, đồng thời bạn có thể tự do chuyển đổi nhà cung cấp trong quá trình làm việc.",
|
||||
getCodewhale: "Tải Codewhale",
|
||||
heroInstallAria: "Lệnh cài đặt",
|
||||
exploreProduct: "Khám phá sản phẩm",
|
||||
shotPreview: "Xem trước terminal",
|
||||
shotBuild: "bản phát triển v{version}",
|
||||
|
||||
@@ -3,9 +3,7 @@ import type { ChromeDict } from "../types";
|
||||
/**
|
||||
* Simplified Chinese chrome — a native rewrite mirroring the current
|
||||
* English direction (the wordmark tag is "any model, on your machine";
|
||||
* the retired "local-first" framing is gone). Nav pairs each Chinese
|
||||
* primary label with a short English secondary — the inverse of the
|
||||
* English edition's Han companion labels.
|
||||
* the retired "local-first" framing is gone).
|
||||
*/
|
||||
export const chrome: ChromeDict = {
|
||||
navDocs: "文档",
|
||||
@@ -15,31 +13,18 @@ export const chrome: ChromeDict = {
|
||||
navCommunity: "社区",
|
||||
navContribute: "贡献",
|
||||
|
||||
navDocsSecondary: "Docs",
|
||||
navStartSecondary: "Start",
|
||||
navInstallSecondary: "Install",
|
||||
navFaqSecondary: "FAQ",
|
||||
navCommunitySecondary: "Community",
|
||||
navContributeSecondary: "Contribute",
|
||||
|
||||
navProduct: "产品",
|
||||
navModels: "模型",
|
||||
navPlugins: "插件",
|
||||
navProductSecondary: "Product",
|
||||
navModelsSecondary: "Models",
|
||||
navPluginsSecondary: "Plugins",
|
||||
|
||||
skipToContent: "跳转到主要内容",
|
||||
|
||||
|
||||
navPrimaryAria: "主导航",
|
||||
navHomeAria: "Codewhale 首页",
|
||||
|
||||
installCta: "安装 →",
|
||||
|
||||
authSignIn: "登录",
|
||||
authRegister: "创建账户",
|
||||
authGroupAria: "账户",
|
||||
|
||||
wordmarkSeal: "深",
|
||||
wordmarkTag: "任意模型,本机运行",
|
||||
@@ -47,9 +32,6 @@ export const chrome: ChromeDict = {
|
||||
issueLabel: "第 {date} 期",
|
||||
dateLocale: "zh-CN",
|
||||
|
||||
starsAria: "GitHub 星标数",
|
||||
githubFallback: "GitHub",
|
||||
|
||||
tickerLiveLabel: "实 时",
|
||||
tickerLiveTag: "LIVE",
|
||||
tickerMerged: "已合并",
|
||||
@@ -69,8 +51,8 @@ export const chrome: ChromeDict = {
|
||||
themeAuto: "自动",
|
||||
themeLight: "浅色",
|
||||
themeDark: "深色",
|
||||
themeAria: "文档主题:{mode}(点击切换)",
|
||||
themeTitle: "文档主题 · 自动 / 浅色 / 深色",
|
||||
themeAria: "主题:{mode}(点击切换)",
|
||||
themeTitle: "主题 · 自动 / 浅色 / 深色",
|
||||
|
||||
footerTagline:
|
||||
"用你选择的模型构建想做的项目,并自动处理日常工作。",
|
||||
|
||||
@@ -16,6 +16,7 @@ export const home: HomeDict = {
|
||||
heroIntro:
|
||||
"{brand} 是一个开源智能体,能够读取文件、编辑代码、运行命令并检查结果。在终端或本地浏览器中使用它,连接你选择的云端或本地模型。工具和权限由你决定;对话与工具执行结果保存在会话中。",
|
||||
getCodewhale: "获取 Codewhale",
|
||||
heroInstallAria: "安装命令",
|
||||
exploreProduct: "了解产品",
|
||||
shotPreview: "终端预览",
|
||||
shotBuild: "v{version} 开发版本",
|
||||
|
||||
+10
-35
@@ -22,11 +22,10 @@ export const APP_URL = "https://app.codewhale.net";
|
||||
export const APP_LOGIN_URL = `${APP_URL}/login`;
|
||||
export const APP_SIGNUP_URL = `${APP_URL}/signup`;
|
||||
|
||||
/** A chrome link. `secondary` is the small bilingual companion label. */
|
||||
/** A chrome link: one label per destination. */
|
||||
export interface ChromeLink {
|
||||
href: string;
|
||||
label: string;
|
||||
secondary?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,45 +36,21 @@ export interface ChromeLink {
|
||||
*/
|
||||
export function navLinks(locale: string, chrome: ChromeDict): ChromeLink[] {
|
||||
return [
|
||||
{
|
||||
href: `/${locale}/product`,
|
||||
label: chrome.navProduct,
|
||||
secondary: chrome.navProductSecondary,
|
||||
},
|
||||
{ href: `/${locale}/models`, label: chrome.navModels, secondary: chrome.navModelsSecondary },
|
||||
{
|
||||
href: `/${locale}/plugins`,
|
||||
label: chrome.navPlugins,
|
||||
secondary: chrome.navPluginsSecondary,
|
||||
},
|
||||
{ href: `/${locale}/docs`, label: chrome.navDocs, secondary: chrome.navDocsSecondary },
|
||||
{ href: `/${locale}/product`, label: chrome.navProduct },
|
||||
{ href: `/${locale}/models`, label: chrome.navModels },
|
||||
{ href: `/${locale}/plugins`, label: chrome.navPlugins },
|
||||
{ href: `/${locale}/docs`, label: chrome.navDocs },
|
||||
];
|
||||
}
|
||||
|
||||
/** The compact sheet's second group: getting started, install, and community. */
|
||||
export function secondaryNavLinks(locale: string, chrome: ChromeDict): ChromeLink[] {
|
||||
return [
|
||||
{
|
||||
href: `/${locale}/docs/guide`,
|
||||
label: chrome.navStart,
|
||||
secondary: chrome.navStartSecondary,
|
||||
},
|
||||
{
|
||||
href: `/${locale}/install`,
|
||||
label: chrome.navInstall,
|
||||
secondary: chrome.navInstallSecondary,
|
||||
},
|
||||
{ href: `/${locale}/faq`, label: chrome.navFaq, secondary: chrome.navFaqSecondary },
|
||||
{
|
||||
href: `/${locale}/community`,
|
||||
label: chrome.navCommunity,
|
||||
secondary: chrome.navCommunitySecondary,
|
||||
},
|
||||
{
|
||||
href: `/${locale}/contribute`,
|
||||
label: chrome.navContribute,
|
||||
secondary: chrome.navContributeSecondary,
|
||||
},
|
||||
{ href: `/${locale}/docs/guide`, label: chrome.navStart },
|
||||
{ href: `/${locale}/install`, label: chrome.navInstall },
|
||||
{ href: `/${locale}/faq`, label: chrome.navFaq },
|
||||
{ href: `/${locale}/community`, label: chrome.navCommunity },
|
||||
{ href: `/${locale}/contribute`, label: chrome.navContribute },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -38,68 +38,61 @@ function advance(text: string, fontRem: number): number {
|
||||
describe("localized chrome keeps a clickable home control", () => {
|
||||
const css = siteCss();
|
||||
const theme = webText("components/theme-toggle.tsx");
|
||||
const locale = webText("components/locale-switcher.tsx");
|
||||
|
||||
it("keeps the wordmark and locale switcher from shrinking to zero", () => {
|
||||
// Quiet icon controls are 2.5rem squares 2px apart (GPUI rail metrics).
|
||||
const icon = 2.5 * 16;
|
||||
const iconGap = 2;
|
||||
|
||||
it("keeps the wordmark from shrinking to zero and the controls icon-only", () => {
|
||||
// The floor is `min-width`, not a zero shrink factor: flexbox will not
|
||||
// take an item below its `min-width`, so the box can still give space
|
||||
// back when the row is over budget.
|
||||
expect(css).toMatch(/\.paper-wordmark\s*\{[\s\S]*?flex:\s*0 1 auto;/);
|
||||
expect(css).toMatch(/\.paper-wordmark\s*\{[\s\S]*?min-width:\s*8\.75rem;/);
|
||||
expect(css).toMatch(
|
||||
/\.site-nav-actions select\s*\{\s*width:\s*6\.75rem;\s*max-width:\s*6\.75rem;/,
|
||||
);
|
||||
expect(theme).toContain("hidden 2xl:inline");
|
||||
expect(theme).not.toContain("hidden sm:inline");
|
||||
expect(css).toMatch(/\.nav-icon-button\s*\{[\s\S]*?width:\s*2\.5rem;\s*height:\s*2\.5rem;/);
|
||||
expect(css).toMatch(/\.site-nav-actions\s*\{[\s\S]*?gap:\s*2px;/);
|
||||
// The theme and locale controls carry their state in the accessible
|
||||
// name, not in visible text, so no translated word can widen the strip.
|
||||
// The native <select> (an unbounded one once ate 124px, #5290) sits
|
||||
// transparent over the globe glyph and is clipped to the icon box.
|
||||
expect(theme).not.toMatch(/\{labels\[shown\]\}\s*</);
|
||||
expect(locale).toContain('className="nav-icon-button nav-locale"');
|
||||
expect(css).toMatch(/\.nav-locale select\s*\{[\s\S]*?inset:\s*0;[\s\S]*?opacity:\s*0;/);
|
||||
});
|
||||
|
||||
it("fits every locale's desktop strip inside the 76rem container", () => {
|
||||
// --container: min(100% - 2rem, 76rem). The desktop strip is the 76rem
|
||||
// cap (1216px). Wordmark 8.75rem, select 6.75rem, remaining actions
|
||||
// measured on the 1536 deployed pass, minus the 124px the unbounded
|
||||
// select used to steal and the theme word that no longer shows below 2xl.
|
||||
const container = 76 * 16;
|
||||
it("fits every locale's desktop strip at the lg floor", () => {
|
||||
// At lg (1024px) --container is min(100% - 32px, 76rem) → 992px. The
|
||||
// strip is wordmark + links + [theme, locale, GitHub, Sign in, Install].
|
||||
const container = 1024 - 32;
|
||||
const wordmarkMin = 8.75 * 16;
|
||||
const gaps = 1.5 * 16 * 2;
|
||||
const select = 6.75 * 16;
|
||||
const actionsBesidesSelect = 542 - 232;
|
||||
const actionBudget = select + actionsBesidesSelect;
|
||||
|
||||
for (const locale of locales) {
|
||||
const links = navLinks(locale, getChrome(locale));
|
||||
for (const code of locales) {
|
||||
const chrome = getChrome(code);
|
||||
const links = navLinks(code, chrome);
|
||||
const navWidth =
|
||||
links.reduce((sum, link) => sum + Math.max(advance(link.label, 0.78), 23), 0) +
|
||||
20 * Math.max(links.length - 1, 0);
|
||||
const used = wordmarkMin + navWidth + actionBudget + gaps;
|
||||
expect(used, `${locale} strip ${Math.round(used)}px`).toBeLessThanOrEqual(container);
|
||||
const signIn = 0.5 * 16 + 1.5 * 16 + advance(chrome.authSignIn, 0.8125);
|
||||
const install = 0.25 * 16 + 1.7 * 16 + advance(chrome.installCta, 0.8125);
|
||||
const actions = 3 * icon + 2 * iconGap + signIn + install;
|
||||
const used = wordmarkMin + navWidth + actions + gaps;
|
||||
expect(used, `${code} strip ${Math.round(used)}px`).toBeLessThanOrEqual(container);
|
||||
for (const link of links) {
|
||||
expect(link.href.startsWith(`/${locale}/`), `${locale} ${link.href}`).toBe(true);
|
||||
expect(link.href.startsWith(`/${code}/`), `${code} ${link.href}`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("fits the compact nav strip inside a 375px viewport", () => {
|
||||
// --container is min(100% - 2rem, 76rem) → 343px at 375. Below 520px the
|
||||
// strip is wordmark + [theme, select, menu]: the desktop nav is
|
||||
// display:none, the wordmark tag and install CTA are hidden, and the
|
||||
// GitHub/Discord links went at 900px. Widths are the CSS boxes — border
|
||||
// plus padding plus a 1em glyph for the theme control, which is
|
||||
// glyph-only below 2xl.
|
||||
// --container is min(100% - 2rem, 76rem) → 343px at 375. Below lg the
|
||||
// strip is wordmark + [theme, locale, menu]: the desktop nav, Sign in and
|
||||
// Install are display:none, and the GitHub icon goes at 900px.
|
||||
const container = 375 - 2 * 16;
|
||||
const innerGap = 0.5 * 16;
|
||||
const actionGap = 0.35 * 16;
|
||||
const themeToggle = 16 + 2 * 0.375 * 16 + 2;
|
||||
const select = 6.75 * 16;
|
||||
const menuToggle = 2.25 * 16;
|
||||
const actions = themeToggle + actionGap + select + actionGap + menuToggle;
|
||||
|
||||
// At the width its own content asks for, the row does not fit. Every
|
||||
// page carries the theme control, so every page is the tight case.
|
||||
expect(9.75 * 16 + innerGap + actions).toBeGreaterThan(container);
|
||||
|
||||
// It fits because the wordmark gives space back down to its floor. With
|
||||
// `flex-shrink: 0` the overflow landed on the menu toggle instead, and
|
||||
// `overflow-x: clip` on <body> then ate the edge of it.
|
||||
expect(8.75 * 16 + innerGap + actions).toBeLessThanOrEqual(container);
|
||||
const actions = 3 * icon + 2 * iconGap;
|
||||
// Even at the wordmark's phone cap the row fits without shrinking it.
|
||||
expect(9.75 * 16 + innerGap + actions).toBeLessThanOrEqual(container);
|
||||
expect(css).toMatch(/\.paper-wordmark\s*\{[\s\S]*?flex:\s*0 1 auto;/);
|
||||
expect(css).toMatch(
|
||||
/@media \(max-width: 520px\)[\s\S]*?\.paper-wordmark\s*\{\s*max-width:\s*9\.75rem;/,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readFileSync, statSync } from "node:fs";
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveWhale } from "./whale-tokens";
|
||||
import { siteCss } from "./site-css";
|
||||
@@ -25,6 +25,21 @@ describe("typography contract", () => {
|
||||
expect(TAILWIND).toMatch(/textTransform:\s*false/);
|
||||
});
|
||||
|
||||
it("leaves no caller on the ungenerated all-caps or wide-tracking utilities", () => {
|
||||
// Tailwind no longer emits these, so a surviving class is a silent no-op
|
||||
// that reads as intent. Labels are sentence case at normal tracking.
|
||||
const offenders: string[] = [];
|
||||
for (const dir of ["../app/", "../components/"]) {
|
||||
for (const entry of readdirSync(new URL(dir, import.meta.url), { recursive: true })) {
|
||||
const path = String(entry);
|
||||
if (!path.endsWith(".tsx")) continue;
|
||||
const source = readFileSync(new URL(dir + path, import.meta.url), "utf8");
|
||||
if (/\b(uppercase|tracking-wider|tracking-widest)\b/.test(source)) offenders.push(dir + path);
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("resolves every role to Shannon Sans and code to the system mono stack", () => {
|
||||
expect(rootVar("font-body")).toMatch(/^var\(--font-shannon-latin\), var\(--font-shannon-ext\),/);
|
||||
expect(rootVar("font-display")).toBe("var(--font-body)");
|
||||
|
||||
Reference in new issue
Block a user