refactor: archive GUI clients; keep minimal TUI with hooks/API
Move Qt/Tauri/full Web SPA under archive/, ship hermit as CLI/TUI plus libcstcore and cst-webui/1 HTTP+SSE (turn lifecycle hooks, capabilities). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
src-tauri/target/
|
||||
.DS_Store
|
||||
*.log
|
||||
|
||||
.layout-check.png
|
||||
.layout-dsh.png
|
||||
.layout-dsh-qa/
|
||||
@@ -0,0 +1,58 @@
|
||||
# Hermit Desktop (Tauri 2)
|
||||
|
||||
Cross-platform shell for Hermit. Visual layout follows DeepSeek Harness
|
||||
(session rail · chat · tool timeline · composer); the agent engine is the
|
||||
in-process C core (`libcstcore`), not a Cordis/plugin runtime.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Tauri 2** + Rust host
|
||||
- **React 19** + Vite + TypeScript UI
|
||||
- FFI to `libcstcore` (same embed path as the legacy Qt app)
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Platform | Needs |
|
||||
|----------|--------|
|
||||
| All | Rust (stable), Node 20+, `pnpm` or `npm` |
|
||||
| Linux | `webkit2gtk-4.1`, `libgtk-3`, `librsvg`, `patchelf`, pkg-config, curl |
|
||||
| Windows | WebView2 runtime |
|
||||
| macOS | Xcode CLT |
|
||||
|
||||
Nix example:
|
||||
|
||||
```bash
|
||||
nix-shell -p rustc cargo nodejs_22 pkg-config webkitgtk_4_1 gtk3 librsvg openssl curl
|
||||
```
|
||||
|
||||
## Develop
|
||||
|
||||
```bash
|
||||
cd archive/desktop-tauri
|
||||
npm install
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
Without a full engine link yet, the UI runs against the mock bridge
|
||||
(`HERMIT_DESKTOP_MOCK=1` or automatic when FFI is unavailable).
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm run tauri build
|
||||
```
|
||||
|
||||
Artifacts land in `src-tauri/target/release/bundle/`.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
archive/desktop-tauri/
|
||||
src/ React UI (DSH-like)
|
||||
src-tauri/ Rust + Tauri + FFI
|
||||
README.md
|
||||
```
|
||||
|
||||
|
||||
The legacy Qt front end under `/app` is frozen and will be archived once
|
||||
desktop feature parity lands.
|
||||
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" data-ds-dark-theme>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hermit</title>
|
||||
<style>
|
||||
/* Boot: match DSH bluish-950 before modules arrive; never flash light UA chrome */
|
||||
html, body, #root { width: 100%; height: 100%; margin: 0; background: rgb(21, 21, 23); color: rgb(247, 247, 248); }
|
||||
#root { display: flex; flex-direction: column; min-width: 0; }
|
||||
button { appearance: none; -webkit-appearance: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body data-ds-dark-theme>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3584
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "hermit-desktop",
|
||||
"private": true,
|
||||
"version": "1.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.1.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.2.0",
|
||||
"@tauri-apps/plugin-shell": "^2.2.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^9.0.3",
|
||||
"remark-gfm": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.1.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
}
|
||||
Executable
+116
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hermit Desktop — official local dev entry (engine preferred).
|
||||
#
|
||||
# Required for real turns:
|
||||
# - libcstcore.a at repo root (auto-enables --features engine), OR HERMIT_FEATURES=engine
|
||||
# - DEEPSEEK_API_KEY in the environment, OR set the key in Settings after launch
|
||||
#
|
||||
# Host-only (no engine): HERMIT_FEATURES=host ./run-dev.sh
|
||||
# Composer will refuse send until engine is available (no silent mock product path).
|
||||
# UI mock turns (browser/host demos only): localStorage hermit.allowMock=1 or VITE_HERMIT_ALLOW_MOCK=1
|
||||
#
|
||||
# Hand-check: new session → type → streaming assistant → Stop → reopen session has history
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# 补 PKG_CONFIG_PATH(勿整文件 source set-environment:会撞 set -u)
|
||||
if [[ -z "${PKG_CONFIG_PATH:-}" ]]; then
|
||||
if [[ -f /etc/set-environment ]]; then
|
||||
PKG_CONFIG_PATH="$(set +u; . /etc/set-environment >/dev/null; printf '%s' "${PKG_CONFIG_PATH:-}")"
|
||||
export PKG_CONFIG_PATH
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "${PKG_CONFIG_PATH:-}" ]]; then
|
||||
echo "hermit-dev: PKG_CONFIG_PATH 为空。请用 hermit-tauri-dev 或 nixos-rebuild 后重开终端" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! pkg-config --exists webkit2gtk-4.1 gdk-pixbuf-2.0 2>/dev/null; then
|
||||
echo "hermit-dev: 缺少 webkit/gdk-pixbuf .pc" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
unset WEBKIT_DISABLE_COMPOSITING_MODE || true
|
||||
|
||||
if [[ -f /proc/driver/nvidia/version ]]; then
|
||||
export __NV_DISABLE_EXPLICIT_SYNC="${__NV_DISABLE_EXPLICIT_SYNC:-1}"
|
||||
# X11/XWayland 上 WebKit DMABUF →「Failed to create GBM buffer」→ 空白灰窗
|
||||
# 必须关 DMABUF;Wayland 原生则可只靠 __NV_DISABLE_EXPLICIT_SYNC
|
||||
if [[ -n "${WAYLAND_DISPLAY:-}" || "${XDG_SESSION_TYPE:-}" == wayland ]]; then
|
||||
export GDK_BACKEND="${GDK_BACKEND:-x11}"
|
||||
fi
|
||||
if [[ "${GDK_BACKEND:-}" == x11 ]]; then
|
||||
export WEBKIT_DISABLE_DMABUF_RENDERER="${WEBKIT_DISABLE_DMABUF_RENDERER:-1}"
|
||||
else
|
||||
unset WEBKIT_DISABLE_DMABUF_RENDERER || true
|
||||
fi
|
||||
fi
|
||||
|
||||
export TAURI_DEV_HOST="${TAURI_DEV_HOST:-127.0.0.1}"
|
||||
|
||||
# Optional real engine (libcstcore). Example:
|
||||
# HERMIT_FEATURES=engine ./run-dev.sh
|
||||
# # or:
|
||||
# HERMIT_CORE_LIB=$PWD/../../libcstcore.a \
|
||||
# HERMIT_CORE_INCLUDE=$PWD/../../include \
|
||||
# npm run tauri -- dev -- --features engine
|
||||
TAURI_EXTRA=()
|
||||
ROOT="$(cd ../.. && pwd)"
|
||||
# Auto-enable engine when libcstcore.a exists, unless HERMIT_FEATURES=host
|
||||
if [[ "${HERMIT_FEATURES:-}" == *engine* ]] || {
|
||||
[[ "${HERMIT_FEATURES:-}" != *host* ]] && [[ -f "$ROOT/libcstcore.a" ]]
|
||||
}; then
|
||||
export HERMIT_CORE_LIB="${HERMIT_CORE_LIB:-$ROOT/libcstcore.a}"
|
||||
export HERMIT_CORE_INCLUDE="${HERMIT_CORE_INCLUDE:-$ROOT/include}"
|
||||
if [[ -f "$HERMIT_CORE_LIB" ]]; then
|
||||
# NixOS: -lcurl needs an explicit libdir (pkg-config or ldd).
|
||||
if [[ -z "${HERMIT_CURL_LIBDIR:-}" ]]; then
|
||||
if pkg-config --exists libcurl 2>/dev/null; then
|
||||
HERMIT_CURL_LIBDIR="$(pkg-config --variable=libdir libcurl 2>/dev/null || true)"
|
||||
fi
|
||||
if [[ -z "${HERMIT_CURL_LIBDIR:-}" ]] && command -v curl >/dev/null 2>&1; then
|
||||
curl_so="$(ldd "$(command -v curl)" 2>/dev/null | awk '/libcurl\.so/ { print $3; exit }' || true)"
|
||||
if [[ -n "${curl_so:-}" && -e "$curl_so" ]]; then
|
||||
HERMIT_CURL_LIBDIR="$(dirname "$curl_so")"
|
||||
fi
|
||||
fi
|
||||
if [[ -n "${HERMIT_CURL_LIBDIR:-}" ]]; then
|
||||
export HERMIT_CURL_LIBDIR
|
||||
export LD_LIBRARY_PATH="${HERMIT_CURL_LIBDIR}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
||||
fi
|
||||
fi
|
||||
echo "hermit-dev: engine feature HERMIT_CORE_LIB=$HERMIT_CORE_LIB curl=${HERMIT_CURL_LIBDIR:-default}" >&2
|
||||
TAURI_EXTRA+=(--features engine)
|
||||
else
|
||||
echo "hermit-dev: engine requested but missing $HERMIT_CORE_LIB (host mock)" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
for p in 1420 1421; do
|
||||
if command -v fuser >/dev/null 2>&1; then
|
||||
fuser -k "${p}/tcp" 2>/dev/null || true
|
||||
else
|
||||
mapfile -t pids < <(ss -ltnp 2>/dev/null | awk -v p=":$p" '$4 ~ p {
|
||||
if (match($0, /pid=[0-9]+/)) { print substr($0, RSTART+4, RLENGTH-4) }
|
||||
}' | sort -u)
|
||||
for pid in "${pids[@]:-}"; do
|
||||
[[ -n "${pid:-}" ]] && kill "$pid" 2>/dev/null || true
|
||||
done
|
||||
fi
|
||||
done
|
||||
sleep 0.3
|
||||
|
||||
if [[ -z "${DEEPSEEK_API_KEY:-}" && -z "${OPENAI_API_KEY:-}" ]]; then
|
||||
echo "hermit-dev: note — no DEEPSEEK_API_KEY/OPENAI_API_KEY in env; set one or paste key in Settings" >&2
|
||||
fi
|
||||
|
||||
if printf '%s\n' "${TAURI_EXTRA[@]:-}" | grep -qx -- '--features' && printf '%s\n' "${TAURI_EXTRA[@]:-}" | grep -qx engine; then
|
||||
echo "hermit-dev: mode=engine (libcstcore)" >&2
|
||||
elif printf '%s\n' "${TAURI_EXTRA[@]:-}" | grep -q engine; then
|
||||
echo "hermit-dev: mode=engine (libcstcore)" >&2
|
||||
else
|
||||
echo "hermit-dev: mode=host (Composer send disabled until engine)" >&2
|
||||
fi
|
||||
|
||||
echo "hermit-dev: GDK_BACKEND=${GDK_BACKEND:-none} DMABUF_OFF=${WEBKIT_DISABLE_DMABUF_RENDERER:-0} NV_SYNC=${__NV_DISABLE_EXPLICIT_SYNC:-0} host=${TAURI_DEV_HOST}" >&2
|
||||
exec npm run tauri -- dev "${TAURI_EXTRA[@]}" "$@"
|
||||
+4585
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "hermit-desktop"
|
||||
version = "1.1.0"
|
||||
description = "Hermit desktop shell (Tauri 2)"
|
||||
authors = ["WHC"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "hermit_desktop_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
cc = "1.2"
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "sync", "macros", "time"] }
|
||||
once_cell = "1"
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
# Link libcstcore from the repo (requires curl + built archive).
|
||||
engine = []
|
||||
@@ -0,0 +1,175 @@
|
||||
fn main() {
|
||||
// Optional: link a prebuilt libcstcore when HERMIT_CORE_LIB is set.
|
||||
// Example:
|
||||
// HERMIT_CORE_LIB=/path/to/libcstcore.a HERMIT_CORE_INCLUDE=/path/to/include
|
||||
// cargo build --features engine
|
||||
if std::env::var("CARGO_FEATURE_ENGINE").is_ok() {
|
||||
let manifest_dir = std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
|
||||
// src-tauri → desktop → apps → repo root
|
||||
let repo_root = manifest_dir.join("../../..");
|
||||
let include = std::env::var("HERMIT_CORE_INCLUDE").unwrap_or_else(|_| {
|
||||
repo_root
|
||||
.join("include")
|
||||
.canonicalize()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_else(|_| repo_root.join("include").display().to_string())
|
||||
});
|
||||
|
||||
let mut build = cc::Build::new();
|
||||
build
|
||||
.file("src/ffi_bridge.c")
|
||||
.include(&include)
|
||||
.include("src")
|
||||
.warnings(false);
|
||||
// libcstcore pulls C11 + POSIX.
|
||||
build.std("c11");
|
||||
build.compile("hermit_ffi_bridge");
|
||||
println!("cargo:rerun-if-changed=src/ffi_bridge.c");
|
||||
println!("cargo:rerun-if-changed=src/ffi_bridge.h");
|
||||
|
||||
let core = std::env::var("HERMIT_CORE_LIB")
|
||||
.ok()
|
||||
.map(std::path::PathBuf::from)
|
||||
.filter(|p| p.exists())
|
||||
.or_else(|| {
|
||||
let fallback = repo_root.join("libcstcore.a");
|
||||
fallback.exists().then_some(fallback)
|
||||
});
|
||||
|
||||
if let Some(path) = core {
|
||||
if let Some(dir) = path.parent() {
|
||||
println!("cargo:rustc-link-search=native={}", dir.display());
|
||||
}
|
||||
let stem = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("cstcore")
|
||||
.trim_start_matches("lib");
|
||||
println!("cargo:rustc-link-lib=static={}", stem);
|
||||
// libcstcore leaves sashidown symbols unresolved (same as Makefile LDLIBS).
|
||||
let sashidown_dir = repo_root.join("vendor/sashidown");
|
||||
let sashidown_a = sashidown_dir.join("libsashidown.a");
|
||||
if sashidown_a.exists() {
|
||||
println!(
|
||||
"cargo:rustc-link-search=native={}",
|
||||
sashidown_dir
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| sashidown_dir.clone())
|
||||
.display()
|
||||
);
|
||||
println!("cargo:rustc-link-lib=static=sashidown");
|
||||
println!("cargo:rerun-if-changed={}", sashidown_a.display());
|
||||
} else {
|
||||
println!(
|
||||
"cargo:warning=missing {} (make -C vendor/sashidown libsashidown.a)",
|
||||
sashidown_a.display()
|
||||
);
|
||||
}
|
||||
link_curl_and_sys();
|
||||
println!("cargo:rerun-if-changed={}", path.display());
|
||||
} else {
|
||||
println!(
|
||||
"cargo:warning=feature engine enabled but HERMIT_CORE_LIB unset and libcstcore.a missing"
|
||||
);
|
||||
}
|
||||
println!("cargo:rerun-if-env-changed=HERMIT_CORE_LIB");
|
||||
println!("cargo:rerun-if-env-changed=HERMIT_CORE_INCLUDE");
|
||||
println!("cargo:rerun-if-env-changed=HERMIT_CURL_LIBDIR");
|
||||
println!("cargo:rerun-if-env-changed=PKG_CONFIG_PATH");
|
||||
println!("cargo:rustc-env=HERMIT_CORE_INCLUDE={}", include);
|
||||
}
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
/// libcstcore needs libcurl (+ pthread/m/dl). On NixOS `-lcurl` alone fails
|
||||
/// because curl is not on the default linker path — resolve via pkg-config,
|
||||
/// HERMIT_CURL_LIBDIR, or `ldd $(command -v curl)`.
|
||||
fn link_curl_and_sys() {
|
||||
if link_curl_via_pkg_config() || link_curl_via_env() || link_curl_via_ldd() {
|
||||
// ok
|
||||
} else {
|
||||
println!("cargo:warning=libcurl path not found; falling back to -lcurl");
|
||||
println!("cargo:rustc-link-lib=curl");
|
||||
}
|
||||
println!("cargo:rustc-link-lib=pthread");
|
||||
println!("cargo:rustc-link-lib=m");
|
||||
println!("cargo:rustc-link-lib=dl");
|
||||
}
|
||||
|
||||
fn apply_pkg_libs(libs: &str) -> bool {
|
||||
let mut saw_curl = false;
|
||||
for tok in libs.split_whitespace() {
|
||||
if let Some(dir) = tok.strip_prefix("-L") {
|
||||
if !dir.is_empty() {
|
||||
println!("cargo:rustc-link-search=native={dir}");
|
||||
}
|
||||
} else if let Some(lib) = tok.strip_prefix("-l") {
|
||||
if lib == "curl" {
|
||||
saw_curl = true;
|
||||
}
|
||||
println!("cargo:rustc-link-lib={lib}");
|
||||
}
|
||||
}
|
||||
saw_curl
|
||||
}
|
||||
|
||||
fn link_curl_via_pkg_config() -> bool {
|
||||
let Ok(out) = std::process::Command::new("pkg-config")
|
||||
.args(["--libs", "libcurl"])
|
||||
.output()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if !out.status.success() {
|
||||
return false;
|
||||
}
|
||||
let libs = String::from_utf8_lossy(&out.stdout);
|
||||
apply_pkg_libs(libs.trim())
|
||||
}
|
||||
|
||||
fn link_curl_via_env() -> bool {
|
||||
let Ok(dir) = std::env::var("HERMIT_CURL_LIBDIR") else {
|
||||
return false;
|
||||
};
|
||||
if dir.is_empty() || !std::path::Path::new(&dir).is_dir() {
|
||||
return false;
|
||||
}
|
||||
println!("cargo:rustc-link-search=native={dir}");
|
||||
println!("cargo:rustc-link-lib=curl");
|
||||
true
|
||||
}
|
||||
|
||||
fn link_curl_via_ldd() -> bool {
|
||||
// Parse: libcurl.so.4 => /nix/store/.../lib/libcurl.so.4 (0x...)
|
||||
let script = r#"
|
||||
set -e
|
||||
bin=$(command -v curl 2>/dev/null || true)
|
||||
[ -n "$bin" ] || exit 1
|
||||
ldd "$bin" 2>/dev/null | awk '/libcurl\.so/ { print $3; exit }'
|
||||
"#;
|
||||
let Ok(out) = std::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(script)
|
||||
.output()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if !out.status.success() {
|
||||
return false;
|
||||
}
|
||||
let so = String::from_utf8_lossy(&out.stdout);
|
||||
let so = so.trim();
|
||||
if so.is_empty() || so == "not" {
|
||||
return false;
|
||||
}
|
||||
let path = std::path::Path::new(so);
|
||||
let Some(dir) = path.parent() else {
|
||||
return false;
|
||||
};
|
||||
if !dir.is_dir() {
|
||||
return false;
|
||||
}
|
||||
println!("cargo:rustc-link-search=native={}", dir.display());
|
||||
println!("cargo:rustc-link-lib=curl");
|
||||
true
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default Hermit desktop capabilities",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:allow-open",
|
||||
"dialog:default"
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default Hermit desktop capabilities","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","dialog:default"]}}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 682 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 233 B |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 8.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,780 @@
|
||||
//! FFI surface against `include/cst_agent.h` via `ffi_bridge.c`.
|
||||
//!
|
||||
//! Enable with:
|
||||
//! HERMIT_CORE_LIB=$PWD/../../libcstcore.a \
|
||||
//! HERMIT_CORE_INCLUDE=$PWD/../../include \
|
||||
//! cargo build --features engine
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::sync::{Condvar, Mutex};
|
||||
|
||||
#[cfg(not(feature = "engine"))]
|
||||
pub fn engine_linked() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(feature = "engine")]
|
||||
pub fn engine_linked() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Shared waiter: UI answers via `consent_answer` / `question_answer`.
|
||||
#[derive(Default)]
|
||||
pub struct ConsentGate {
|
||||
inner: Mutex<ConsentGateInner>,
|
||||
cv: Condvar,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ConsentGateInner {
|
||||
pending_id: Option<String>,
|
||||
pending_tool: String,
|
||||
pending_detail: String,
|
||||
answer: Option<String>,
|
||||
answer_text: String,
|
||||
cancel: bool,
|
||||
}
|
||||
|
||||
impl ConsentGate {
|
||||
pub fn request(&self, id: &str, tool: &str, detail: &str) -> i32 {
|
||||
let mut g = self.inner.lock().unwrap();
|
||||
g.pending_id = Some(id.to_string());
|
||||
g.pending_tool = tool.to_string();
|
||||
g.pending_detail = detail.to_string();
|
||||
g.answer = None;
|
||||
g.answer_text.clear();
|
||||
g.cancel = false;
|
||||
loop {
|
||||
if g.cancel {
|
||||
g.pending_id = None;
|
||||
return 0;
|
||||
}
|
||||
if let Some(ans) = g.answer.take() {
|
||||
g.pending_id = None;
|
||||
g.answer_text = ans.clone();
|
||||
return match ans.as_str() {
|
||||
"once" | "always_tool" | "always_prefix" => {
|
||||
if ans == "once" {
|
||||
1
|
||||
} else if ans == "always_tool" {
|
||||
2
|
||||
} else {
|
||||
3
|
||||
}
|
||||
}
|
||||
_ if !ans.is_empty() && ans != "deny" => 1,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
g = self.cv.wait(g).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn request_text(&self, id: &str, tool: &str, detail: &str) -> Option<String> {
|
||||
let code = self.request(id, tool, detail);
|
||||
if code == 0 {
|
||||
return None;
|
||||
}
|
||||
let g = self.inner.lock().unwrap();
|
||||
if g.answer_text.is_empty() || g.answer_text == "once" {
|
||||
Some(String::new())
|
||||
} else {
|
||||
Some(g.answer_text.clone())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn answer(&self, id: &str, answer: &str) -> bool {
|
||||
let mut g = self.inner.lock().unwrap();
|
||||
if g.pending_id.as_deref() != Some(id) {
|
||||
return false;
|
||||
}
|
||||
g.answer = Some(answer.to_string());
|
||||
g.answer_text = answer.to_string();
|
||||
self.cv.notify_all();
|
||||
true
|
||||
}
|
||||
|
||||
pub fn cancel(&self) {
|
||||
let mut g = self.inner.lock().unwrap();
|
||||
g.cancel = true;
|
||||
self.cv.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "engine")]
|
||||
mod live {
|
||||
use super::*;
|
||||
use std::ffi::{CStr, CString};
|
||||
use std::os::raw::{c_char, c_int, c_void};
|
||||
use std::ptr;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[repr(C)]
|
||||
struct HermitHooks {
|
||||
on_assistant: Option<extern "C" fn(*const c_char, usize, *mut c_void)>,
|
||||
on_thought: Option<extern "C" fn(*const c_char, usize, *mut c_void)>,
|
||||
on_tool: Option<
|
||||
extern "C" fn(
|
||||
*const c_char,
|
||||
*const c_char,
|
||||
*const c_char,
|
||||
*const c_char,
|
||||
*mut c_void,
|
||||
),
|
||||
>,
|
||||
on_tool_result: Option<
|
||||
extern "C" fn(*const c_char, *const c_char, *const c_char, usize, c_int, *mut c_void),
|
||||
>,
|
||||
on_todo: Option<extern "C" fn(*const c_char, *mut c_void)>,
|
||||
on_status: Option<extern "C" fn(*const c_char, *mut c_void)>,
|
||||
on_perm: Option<
|
||||
extern "C" fn(*const c_char, *const c_char, *const c_char, *mut c_void) -> c_int,
|
||||
>,
|
||||
on_ask: Option<
|
||||
extern "C" fn(
|
||||
*const c_char,
|
||||
*const c_char,
|
||||
*mut c_char,
|
||||
usize,
|
||||
*mut c_void,
|
||||
) -> c_int,
|
||||
>,
|
||||
ud: *mut c_void,
|
||||
}
|
||||
|
||||
extern "C" {
|
||||
fn hermit_agent_open(
|
||||
workspace: *const c_char,
|
||||
provider: *const c_char,
|
||||
model: *const c_char,
|
||||
approval: *const c_char,
|
||||
api_key: *const c_char,
|
||||
effort: *const c_char,
|
||||
err: *mut c_char,
|
||||
err_sz: usize,
|
||||
) -> *mut c_void;
|
||||
fn hermit_agent_close(agent: *mut c_void);
|
||||
fn hermit_agent_turn(
|
||||
agent: *mut c_void,
|
||||
user_text: *const c_char,
|
||||
hooks: *const HermitHooks,
|
||||
err: *mut c_char,
|
||||
err_sz: usize,
|
||||
) -> c_int;
|
||||
fn hermit_agent_cancel(agent: *mut c_void);
|
||||
fn hermit_agent_set_workspace(
|
||||
agent: *mut c_void,
|
||||
path: *const c_char,
|
||||
err: *mut c_char,
|
||||
err_sz: usize,
|
||||
) -> c_int;
|
||||
fn hermit_agent_new_session(
|
||||
agent: *mut c_void,
|
||||
id_out: *mut c_char,
|
||||
id_sz: usize,
|
||||
) -> c_int;
|
||||
fn hermit_agent_switch_session(agent: *mut c_void, id: *const c_char) -> c_int;
|
||||
fn hermit_agent_session_id(
|
||||
agent: *mut c_void,
|
||||
out: *mut c_char,
|
||||
out_sz: usize,
|
||||
) -> c_int;
|
||||
fn hermit_agent_set_plan_mode(agent: *mut c_void, on: c_int) -> c_int;
|
||||
fn hermit_agent_set_approval(agent: *mut c_void, approval: *const c_char) -> c_int;
|
||||
fn hermit_agent_set_effort(agent: *mut c_void, effort: *const c_char) -> c_int;
|
||||
fn hermit_agent_attach_file(
|
||||
agent: *mut c_void,
|
||||
path: *const c_char,
|
||||
err: *mut c_char,
|
||||
err_sz: usize,
|
||||
) -> c_int;
|
||||
fn hermit_agent_pending_clear(agent: *mut c_void);
|
||||
fn hermit_agent_compact(
|
||||
agent: *mut c_void,
|
||||
err: *mut c_char,
|
||||
err_sz: usize,
|
||||
) -> c_int;
|
||||
fn hermit_agent_usage(
|
||||
agent: *mut c_void,
|
||||
prompt: *mut i64,
|
||||
out_tokens: *mut i64,
|
||||
cost: *mut f64,
|
||||
priced: *mut c_int,
|
||||
);
|
||||
fn hermit_agent_history_json(agent: *mut c_void) -> *mut c_char;
|
||||
fn hermit_agent_todo(agent: *mut c_void) -> *mut c_char;
|
||||
fn hermit_agent_export_md(agent: *mut c_void, title: *const c_char) -> *mut c_char;
|
||||
fn hermit_creds_has(provider: *const c_char) -> c_int;
|
||||
fn free(p: *mut c_void);
|
||||
fn hermit_store_list(
|
||||
workspace: *const c_char,
|
||||
ids: *mut [c_char; 128],
|
||||
max_ids: c_int,
|
||||
) -> c_int;
|
||||
fn hermit_store_info(
|
||||
workspace: *const c_char,
|
||||
id: *const c_char,
|
||||
title: *mut c_char,
|
||||
title_sz: usize,
|
||||
mtime_ms: *mut i64,
|
||||
) -> c_int;
|
||||
fn hermit_store_title_set(
|
||||
workspace: *const c_char,
|
||||
id: *const c_char,
|
||||
title: *const c_char,
|
||||
) -> c_int;
|
||||
fn hermit_store_delete(workspace: *const c_char, id: *const c_char) -> c_int;
|
||||
fn hermit_creds_save(provider: *const c_char, key: *const c_char) -> c_int;
|
||||
}
|
||||
|
||||
pub struct LiveAgent {
|
||||
ptr: *mut c_void,
|
||||
}
|
||||
|
||||
unsafe impl Send for LiveAgent {}
|
||||
|
||||
impl Drop for LiveAgent {
|
||||
fn drop(&mut self) {
|
||||
if !self.ptr.is_null() {
|
||||
unsafe { hermit_agent_close(self.ptr) };
|
||||
self.ptr = ptr::null_mut();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cstr_lossy(buf: &[u8]) -> String {
|
||||
CStr::from_bytes_until_nul(buf)
|
||||
.ok()
|
||||
.map(|c| c.to_string_lossy().into_owned())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn open(
|
||||
workspace: &str,
|
||||
provider: &str,
|
||||
model: &str,
|
||||
approval: &str,
|
||||
api_key: &str,
|
||||
effort: &str,
|
||||
) -> Result<LiveAgent, String> {
|
||||
let ws = CString::new(workspace).map_err(|e| e.to_string())?;
|
||||
let prov = CString::new(provider).map_err(|e| e.to_string())?;
|
||||
let modl = CString::new(model).map_err(|e| e.to_string())?;
|
||||
let appr = CString::new(approval).map_err(|e| e.to_string())?;
|
||||
let key = CString::new(api_key).map_err(|e| e.to_string())?;
|
||||
let eff = CString::new(effort).map_err(|e| e.to_string())?;
|
||||
let mut err = vec![0u8; 512];
|
||||
let ptr = unsafe {
|
||||
hermit_agent_open(
|
||||
ws.as_ptr(),
|
||||
prov.as_ptr(),
|
||||
modl.as_ptr(),
|
||||
appr.as_ptr(),
|
||||
key.as_ptr(),
|
||||
eff.as_ptr(),
|
||||
err.as_mut_ptr() as *mut c_char,
|
||||
err.len(),
|
||||
)
|
||||
};
|
||||
if ptr.is_null() {
|
||||
return Err(cstr_lossy(&err));
|
||||
}
|
||||
Ok(LiveAgent { ptr })
|
||||
}
|
||||
|
||||
pub fn cancel(agent: &LiveAgent) {
|
||||
unsafe { hermit_agent_cancel(agent.ptr) };
|
||||
}
|
||||
|
||||
pub fn set_workspace(agent: &LiveAgent, path: &str) -> Result<(), String> {
|
||||
let p = CString::new(path).map_err(|e| e.to_string())?;
|
||||
let mut err = vec![0u8; 512];
|
||||
let rc = unsafe {
|
||||
hermit_agent_set_workspace(
|
||||
agent.ptr,
|
||||
p.as_ptr(),
|
||||
err.as_mut_ptr() as *mut c_char,
|
||||
err.len(),
|
||||
)
|
||||
};
|
||||
if rc != 0 {
|
||||
return Err(cstr_lossy(&err));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_plan_mode(agent: &LiveAgent, on: bool) -> Result<(), String> {
|
||||
let rc = unsafe { hermit_agent_set_plan_mode(agent.ptr, if on { 1 } else { 0 }) };
|
||||
if rc != 0 {
|
||||
return Err("set_plan_mode failed".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_approval(agent: &LiveAgent, approval: &str) -> Result<(), String> {
|
||||
let a = CString::new(approval).map_err(|e| e.to_string())?;
|
||||
let rc = unsafe { hermit_agent_set_approval(agent.ptr, a.as_ptr()) };
|
||||
if rc != 0 {
|
||||
return Err("set_approval failed".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_effort(agent: &LiveAgent, effort: &str) -> Result<(), String> {
|
||||
let e = CString::new(effort).map_err(|e| e.to_string())?;
|
||||
let rc = unsafe { hermit_agent_set_effort(agent.ptr, e.as_ptr()) };
|
||||
if rc != 0 {
|
||||
return Err("set_effort failed".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn attach_file(agent: &LiveAgent, path: &str) -> Result<(), String> {
|
||||
let p = CString::new(path).map_err(|e| e.to_string())?;
|
||||
let mut err = vec![0u8; 512];
|
||||
let rc = unsafe {
|
||||
hermit_agent_attach_file(
|
||||
agent.ptr,
|
||||
p.as_ptr(),
|
||||
err.as_mut_ptr() as *mut c_char,
|
||||
err.len(),
|
||||
)
|
||||
};
|
||||
if rc != 0 {
|
||||
return Err(cstr_lossy(&err));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn pending_clear(agent: &LiveAgent) {
|
||||
unsafe { hermit_agent_pending_clear(agent.ptr) };
|
||||
}
|
||||
|
||||
pub fn compact(agent: &LiveAgent) -> Result<(), String> {
|
||||
let mut err = vec![0u8; 512];
|
||||
let rc = unsafe {
|
||||
hermit_agent_compact(agent.ptr, err.as_mut_ptr() as *mut c_char, err.len())
|
||||
};
|
||||
if rc != 0 {
|
||||
return Err(cstr_lossy(&err));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct UsageSnap {
|
||||
pub prompt: i64,
|
||||
pub out: i64,
|
||||
pub cost: f64,
|
||||
pub priced: bool,
|
||||
pub pct: f64,
|
||||
}
|
||||
|
||||
pub fn usage(agent: &LiveAgent) -> Result<UsageSnap, String> {
|
||||
let mut prompt = 0i64;
|
||||
let mut out = 0i64;
|
||||
let mut cost = 0f64;
|
||||
let mut priced = 0i32;
|
||||
unsafe {
|
||||
hermit_agent_usage(agent.ptr, &mut prompt, &mut out, &mut cost, &mut priced);
|
||||
}
|
||||
let pct = ((prompt + out) as f64 / 128_000.0 * 100.0).min(99.0);
|
||||
Ok(UsageSnap {
|
||||
prompt,
|
||||
out,
|
||||
cost,
|
||||
priced: priced != 0,
|
||||
pct,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new_session(agent: &LiveAgent) -> Result<String, String> {
|
||||
let mut buf = vec![0u8; 64];
|
||||
let rc = unsafe {
|
||||
hermit_agent_new_session(agent.ptr, buf.as_mut_ptr() as *mut c_char, buf.len())
|
||||
};
|
||||
if rc != 0 {
|
||||
return Err("new_session failed".into());
|
||||
}
|
||||
Ok(cstr_lossy(&buf))
|
||||
}
|
||||
|
||||
pub fn switch_session(agent: &LiveAgent, id: &str) -> Result<(), String> {
|
||||
let i = CString::new(id).map_err(|e| e.to_string())?;
|
||||
let rc = unsafe { hermit_agent_switch_session(agent.ptr, i.as_ptr()) };
|
||||
if rc != 0 {
|
||||
return Err("switch_session failed".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn session_history_json(agent: &LiveAgent) -> Result<String, String> {
|
||||
let ptr = unsafe { hermit_agent_history_json(agent.ptr) };
|
||||
if ptr.is_null() {
|
||||
return Ok("[]".into());
|
||||
}
|
||||
let json = unsafe { CStr::from_ptr(ptr) }
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
unsafe { free(ptr as *mut c_void) };
|
||||
Ok(json)
|
||||
}
|
||||
|
||||
pub fn todo_text(agent: &LiveAgent) -> String {
|
||||
let ptr = unsafe { hermit_agent_todo(agent.ptr) };
|
||||
if ptr.is_null() {
|
||||
return String::new();
|
||||
}
|
||||
let s = unsafe { CStr::from_ptr(ptr) }
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
unsafe { free(ptr as *mut c_void) };
|
||||
s
|
||||
}
|
||||
|
||||
pub fn export_md(agent: &LiveAgent, title: &str) -> Result<String, String> {
|
||||
let t = CString::new(title).map_err(|e| e.to_string())?;
|
||||
let ptr = unsafe { hermit_agent_export_md(agent.ptr, t.as_ptr()) };
|
||||
if ptr.is_null() {
|
||||
return Err("export failed".into());
|
||||
}
|
||||
let s = unsafe { CStr::from_ptr(ptr) }
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
unsafe { free(ptr as *mut c_void) };
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
pub fn creds_has(provider: &str) -> bool {
|
||||
let Ok(p) = CString::new(provider) else {
|
||||
return false;
|
||||
};
|
||||
unsafe { hermit_creds_has(p.as_ptr()) != 0 }
|
||||
}
|
||||
|
||||
pub struct TurnCallbacks {
|
||||
pub on_assistant: Box<dyn FnMut(&str) + Send>,
|
||||
pub on_thought: Box<dyn FnMut(&str) + Send>,
|
||||
pub on_tool: Box<dyn FnMut(&str, &str, &str, &str) + Send>,
|
||||
pub on_tool_result: Box<dyn FnMut(&str, &str, &str, i32) + Send>,
|
||||
pub on_todo: Box<dyn FnMut(&str) + Send>,
|
||||
pub on_status: Box<dyn FnMut(&str) + Send>,
|
||||
pub gate: Arc<ConsentGate>,
|
||||
pub question_gate: Arc<ConsentGate>,
|
||||
pub emit_consent: Box<dyn FnMut(String, String, String, Option<String>) + Send>,
|
||||
pub emit_question: Box<dyn FnMut(String, String, Vec<String>) + Send>,
|
||||
}
|
||||
|
||||
struct TurnUd {
|
||||
cb: *mut TurnCallbacks,
|
||||
}
|
||||
|
||||
unsafe fn cstr(p: *const c_char) -> String {
|
||||
if p.is_null() {
|
||||
return String::new();
|
||||
}
|
||||
CStr::from_ptr(p).to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
extern "C" fn c_assistant(text: *const c_char, len: usize, ud: *mut c_void) {
|
||||
if text.is_null() || ud.is_null() {
|
||||
return;
|
||||
}
|
||||
let s = unsafe { std::slice::from_raw_parts(text as *const u8, len) };
|
||||
let t = String::from_utf8_lossy(s);
|
||||
let turn = unsafe { &mut *(ud as *mut TurnUd) };
|
||||
let cb = unsafe { &mut *turn.cb };
|
||||
(cb.on_assistant)(&t);
|
||||
}
|
||||
|
||||
extern "C" fn c_thought(text: *const c_char, len: usize, ud: *mut c_void) {
|
||||
if text.is_null() || ud.is_null() {
|
||||
return;
|
||||
}
|
||||
let s = unsafe { std::slice::from_raw_parts(text as *const u8, len) };
|
||||
let t = String::from_utf8_lossy(s);
|
||||
let turn = unsafe { &mut *(ud as *mut TurnUd) };
|
||||
let cb = unsafe { &mut *turn.cb };
|
||||
(cb.on_thought)(&t);
|
||||
}
|
||||
|
||||
extern "C" fn c_tool(
|
||||
phase: *const c_char,
|
||||
id: *const c_char,
|
||||
name: *const c_char,
|
||||
detail: *const c_char,
|
||||
ud: *mut c_void,
|
||||
) {
|
||||
if ud.is_null() {
|
||||
return;
|
||||
}
|
||||
let phase = unsafe { cstr(phase) };
|
||||
let id = unsafe { cstr(id) };
|
||||
let name = unsafe { cstr(name) };
|
||||
let detail = unsafe { cstr(detail) };
|
||||
let turn = unsafe { &mut *(ud as *mut TurnUd) };
|
||||
let cb = unsafe { &mut *turn.cb };
|
||||
(cb.on_tool)(&phase, &id, &name, &detail);
|
||||
}
|
||||
|
||||
extern "C" fn c_tool_result(
|
||||
id: *const c_char,
|
||||
name: *const c_char,
|
||||
out: *const c_char,
|
||||
len: usize,
|
||||
exit: c_int,
|
||||
ud: *mut c_void,
|
||||
) {
|
||||
if ud.is_null() {
|
||||
return;
|
||||
}
|
||||
let id = unsafe { cstr(id) };
|
||||
let name = unsafe { cstr(name) };
|
||||
let out = if out.is_null() {
|
||||
String::new()
|
||||
} else {
|
||||
let s = unsafe { std::slice::from_raw_parts(out as *const u8, len) };
|
||||
String::from_utf8_lossy(s).into_owned()
|
||||
};
|
||||
let turn = unsafe { &mut *(ud as *mut TurnUd) };
|
||||
let cb = unsafe { &mut *turn.cb };
|
||||
(cb.on_tool_result)(&id, &name, &out, exit);
|
||||
}
|
||||
|
||||
extern "C" fn c_todo(text: *const c_char, ud: *mut c_void) {
|
||||
if ud.is_null() {
|
||||
return;
|
||||
}
|
||||
let t = unsafe { cstr(text) };
|
||||
let turn = unsafe { &mut *(ud as *mut TurnUd) };
|
||||
let cb = unsafe { &mut *turn.cb };
|
||||
(cb.on_todo)(&t);
|
||||
}
|
||||
|
||||
extern "C" fn c_status(text: *const c_char, ud: *mut c_void) {
|
||||
if ud.is_null() {
|
||||
return;
|
||||
}
|
||||
let t = unsafe { cstr(text) };
|
||||
let turn = unsafe { &mut *(ud as *mut TurnUd) };
|
||||
let cb = unsafe { &mut *turn.cb };
|
||||
(cb.on_status)(&t);
|
||||
}
|
||||
|
||||
extern "C" fn c_perm(
|
||||
tool: *const c_char,
|
||||
detail: *const c_char,
|
||||
preview: *const c_char,
|
||||
ud: *mut c_void,
|
||||
) -> c_int {
|
||||
if ud.is_null() {
|
||||
return 0;
|
||||
}
|
||||
let tool = unsafe { cstr(tool) };
|
||||
let detail = unsafe { cstr(detail) };
|
||||
let preview = if preview.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(unsafe { cstr(preview) })
|
||||
};
|
||||
let turn = unsafe { &mut *(ud as *mut TurnUd) };
|
||||
let cb = unsafe { &mut *turn.cb };
|
||||
let id = format!(
|
||||
"c{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0)
|
||||
);
|
||||
(cb.emit_consent)(id.clone(), tool.clone(), detail.clone(), preview);
|
||||
cb.gate.request(&id, &tool, &detail)
|
||||
}
|
||||
|
||||
extern "C" fn c_ask(
|
||||
question: *const c_char,
|
||||
options: *const c_char,
|
||||
ans: *mut c_char,
|
||||
ans_sz: usize,
|
||||
ud: *mut c_void,
|
||||
) -> c_int {
|
||||
if ud.is_null() || ans.is_null() || ans_sz == 0 {
|
||||
return -1;
|
||||
}
|
||||
let question = unsafe { cstr(question) };
|
||||
let options_raw = unsafe { cstr(options) };
|
||||
let opts: Vec<String> = if options_raw.is_empty() {
|
||||
Vec::new()
|
||||
} else if options_raw.trim_start().starts_with('[') {
|
||||
serde_json::from_str(&options_raw).unwrap_or_default()
|
||||
} else {
|
||||
options_raw
|
||||
.split(|c| c == '\n' || c == '|')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
};
|
||||
let turn = unsafe { &mut *(ud as *mut TurnUd) };
|
||||
let cb = unsafe { &mut *turn.cb };
|
||||
let id = format!(
|
||||
"q{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0)
|
||||
);
|
||||
(cb.emit_question)(id.clone(), question.clone(), opts);
|
||||
match cb.question_gate.request_text(&id, "ask_user", &question) {
|
||||
None => -1,
|
||||
Some(text) => {
|
||||
let bytes = text.as_bytes();
|
||||
let n = bytes.len().min(ans_sz - 1);
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(bytes.as_ptr(), ans as *mut u8, n);
|
||||
*ans.add(n) = 0;
|
||||
}
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_turn(agent: &LiveAgent, text: &str, mut cb: TurnCallbacks) -> Result<(), String> {
|
||||
let prompt = CString::new(text).map_err(|e| e.to_string())?;
|
||||
let mut err = vec![0u8; 1024];
|
||||
let mut turn_ud = TurnUd {
|
||||
cb: &mut cb as *mut TurnCallbacks,
|
||||
};
|
||||
let hooks = HermitHooks {
|
||||
on_assistant: Some(c_assistant),
|
||||
on_thought: Some(c_thought),
|
||||
on_tool: Some(c_tool),
|
||||
on_tool_result: Some(c_tool_result),
|
||||
on_todo: Some(c_todo),
|
||||
on_status: Some(c_status),
|
||||
on_perm: Some(c_perm),
|
||||
on_ask: Some(c_ask),
|
||||
ud: &mut turn_ud as *mut TurnUd as *mut c_void,
|
||||
};
|
||||
let rc = unsafe {
|
||||
hermit_agent_turn(
|
||||
agent.ptr,
|
||||
prompt.as_ptr(),
|
||||
&hooks,
|
||||
err.as_mut_ptr() as *mut c_char,
|
||||
err.len(),
|
||||
)
|
||||
};
|
||||
if rc != 0 {
|
||||
return Err(cstr_lossy(&err));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn store_list(workspace: &str) -> Vec<(String, String, i64)> {
|
||||
let Ok(ws) = CString::new(workspace) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut ids = [[0i8; 128]; 200];
|
||||
let n = unsafe { hermit_store_list(ws.as_ptr(), ids.as_mut_ptr(), 200) };
|
||||
if n <= 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for i in 0..(n as usize) {
|
||||
let id = unsafe { CStr::from_ptr(ids[i].as_ptr()) }
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
if id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(id_c) = CString::new(id.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let mut title_buf = vec![0u8; 128];
|
||||
let mut mtime: i64 = 0;
|
||||
let rc = unsafe {
|
||||
hermit_store_info(
|
||||
ws.as_ptr(),
|
||||
id_c.as_ptr(),
|
||||
title_buf.as_mut_ptr() as *mut c_char,
|
||||
title_buf.len(),
|
||||
&mut mtime,
|
||||
)
|
||||
};
|
||||
let title = if rc == 0 {
|
||||
cstr_lossy(&title_buf)
|
||||
} else {
|
||||
id.clone()
|
||||
};
|
||||
out.push((id, title, mtime));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn store_title_set(workspace: &str, id: &str, title: &str) -> bool {
|
||||
let Ok(ws) = CString::new(workspace) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(i) = CString::new(id) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(t) = CString::new(title) else {
|
||||
return false;
|
||||
};
|
||||
unsafe { hermit_store_title_set(ws.as_ptr(), i.as_ptr(), t.as_ptr()) == 0 }
|
||||
}
|
||||
|
||||
pub fn store_delete(workspace: &str, id: &str) -> bool {
|
||||
let Ok(ws) = CString::new(workspace) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(i) = CString::new(id) else {
|
||||
return false;
|
||||
};
|
||||
unsafe { hermit_store_delete(ws.as_ptr(), i.as_ptr()) == 0 }
|
||||
}
|
||||
|
||||
pub fn creds_save(provider: &str, key: &str) -> bool {
|
||||
let Ok(p) = CString::new(provider) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(k) = CString::new(key) else {
|
||||
return false;
|
||||
};
|
||||
unsafe { hermit_creds_save(p.as_ptr(), k.as_ptr()) == 0 }
|
||||
}
|
||||
|
||||
pub struct ProviderSnap {
|
||||
pub name: String,
|
||||
pub model: String,
|
||||
pub has_key: bool,
|
||||
pub key_from: Option<String>,
|
||||
}
|
||||
|
||||
pub fn providers_list() -> Result<Vec<ProviderSnap>, String> {
|
||||
let presets = [
|
||||
("deepseek", "deepseek-flash"),
|
||||
("openai", "gpt-4.1"),
|
||||
("anthropic", "claude-sonnet-4"),
|
||||
];
|
||||
Ok(presets
|
||||
.into_iter()
|
||||
.map(|(name, model)| {
|
||||
let has = creds_has(name)
|
||||
|| std::env::var(format!("{}_API_KEY", name.to_ascii_uppercase())).is_ok()
|
||||
|| (name == "openai" && std::env::var("OPENAI_API_KEY").is_ok())
|
||||
|| (name == "deepseek"
|
||||
&& (std::env::var("DEEPSEEK_API_KEY").is_ok()
|
||||
|| std::env::var("OPENAI_API_KEY").is_ok()));
|
||||
ProviderSnap {
|
||||
name: name.into(),
|
||||
model: model.into(),
|
||||
has_key: has,
|
||||
key_from: if has { Some("creds/env".into()) } else { None },
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "engine")]
|
||||
pub use live::*;
|
||||
@@ -0,0 +1,517 @@
|
||||
#include "ffi_bridge.h"
|
||||
|
||||
#include "cst_agent.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
typedef struct {
|
||||
const hermit_hooks_t *h;
|
||||
} bridge_ud_t;
|
||||
|
||||
static void hook_assistant(const char *text, size_t len, void *ud) {
|
||||
bridge_ud_t *b = (bridge_ud_t *)ud;
|
||||
if (b && b->h && b->h->on_assistant)
|
||||
b->h->on_assistant(text, len, b->h->ud);
|
||||
}
|
||||
|
||||
static void hook_think(const char *text, size_t len, void *ud) {
|
||||
bridge_ud_t *b = (bridge_ud_t *)ud;
|
||||
if (b && b->h && b->h->on_thought)
|
||||
b->h->on_thought(text, len, b->h->ud);
|
||||
}
|
||||
|
||||
static void hook_tool(const char *phase, const ctl_tool_call_t *call,
|
||||
const char *detail, void *ud) {
|
||||
bridge_ud_t *b = (bridge_ud_t *)ud;
|
||||
if (!b || !b->h || !b->h->on_tool || !call)
|
||||
return;
|
||||
b->h->on_tool(phase ? phase : "", call->id, call->name,
|
||||
detail ? detail : "", b->h->ud);
|
||||
}
|
||||
|
||||
static void hook_tool_result(const ctl_tool_call_t *call, const char *out,
|
||||
size_t len, int exit_code, void *ud) {
|
||||
bridge_ud_t *b = (bridge_ud_t *)ud;
|
||||
if (!b || !b->h || !b->h->on_tool_result || !call)
|
||||
return;
|
||||
b->h->on_tool_result(call->id, call->name, out ? out : "", len, exit_code,
|
||||
b->h->ud);
|
||||
}
|
||||
|
||||
static void hook_todo(const char *text, void *ud) {
|
||||
bridge_ud_t *b = (bridge_ud_t *)ud;
|
||||
if (b && b->h && b->h->on_todo)
|
||||
b->h->on_todo(text ? text : "", b->h->ud);
|
||||
}
|
||||
|
||||
static void hook_status(const char *msg, void *ud) {
|
||||
bridge_ud_t *b = (bridge_ud_t *)ud;
|
||||
if (b && b->h && b->h->on_status)
|
||||
b->h->on_status(msg ? msg : "", b->h->ud);
|
||||
}
|
||||
|
||||
static int hook_perm(const char *tool_name, const char *detail,
|
||||
const char *preview, void *ud) {
|
||||
bridge_ud_t *b = (bridge_ud_t *)ud;
|
||||
if (!b || !b->h || !b->h->on_perm)
|
||||
return CST_PERM_DENY;
|
||||
return b->h->on_perm(tool_name ? tool_name : "", detail ? detail : "",
|
||||
preview, b->h->ud);
|
||||
}
|
||||
|
||||
static int hook_ask(const char *question, const char *options, char *ans,
|
||||
size_t ans_sz, void *ud) {
|
||||
bridge_ud_t *b = (bridge_ud_t *)ud;
|
||||
if (!b || !b->h || !b->h->on_ask)
|
||||
return -1;
|
||||
return b->h->on_ask(question ? question : "", options ? options : "", ans,
|
||||
ans_sz, b->h->ud);
|
||||
}
|
||||
|
||||
static void hook_turn_begin(const char *user_text, void *ud) {
|
||||
bridge_ud_t *b = (bridge_ud_t *)ud;
|
||||
if (b && b->h && b->h->on_turn_begin)
|
||||
b->h->on_turn_begin(user_text ? user_text : "", b->h->ud);
|
||||
}
|
||||
|
||||
static void hook_turn_end(int rc, const char *err, void *ud) {
|
||||
bridge_ud_t *b = (bridge_ud_t *)ud;
|
||||
if (b && b->h && b->h->on_turn_end)
|
||||
b->h->on_turn_end(rc, err, b->h->ud);
|
||||
}
|
||||
|
||||
static void hook_error(const char *kind, const char *message, void *ud) {
|
||||
bridge_ud_t *b = (bridge_ud_t *)ud;
|
||||
if (b && b->h && b->h->on_error)
|
||||
b->h->on_error(kind ? kind : "", message ? message : "", b->h->ud);
|
||||
}
|
||||
|
||||
void *hermit_agent_open(const char *workspace, const char *provider,
|
||||
const char *model, const char *approval,
|
||||
const char *api_key, const char *effort, char *err,
|
||||
size_t err_sz) {
|
||||
if (err && err_sz)
|
||||
err[0] = '\0';
|
||||
if (!workspace || !workspace[0]) {
|
||||
if (err && err_sz)
|
||||
snprintf(err, err_sz, "workspace required");
|
||||
return NULL;
|
||||
}
|
||||
if (cst_agent_store_ensure(workspace) != 0) {
|
||||
if (err && err_sz)
|
||||
snprintf(err, err_sz, "store ensure failed");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cst_agent_config_t cfg;
|
||||
memset(&cfg, 0, sizeof(cfg));
|
||||
snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", workspace);
|
||||
if (provider && provider[0])
|
||||
snprintf(cfg.provider, sizeof(cfg.provider), "%s", provider);
|
||||
else
|
||||
snprintf(cfg.provider, sizeof(cfg.provider), "%s", "deepseek");
|
||||
if (model && model[0])
|
||||
snprintf(cfg.model, sizeof(cfg.model), "%s", model);
|
||||
else
|
||||
snprintf(cfg.model, sizeof(cfg.model), "%s", "deepseek-flash");
|
||||
snprintf(cfg.mode, sizeof(cfg.mode), "%s", CST_DEFAULT_MODE);
|
||||
cfg.protocol = CST_PROTO_NATIVE_EOS;
|
||||
cfg.max_steps = CST_DEFAULT_MAX_STEPS;
|
||||
cfg.tool_out_cap = CST_DEFAULT_TOOL_OUT_CAP;
|
||||
cfg.approval = CST_DEFAULT_APPROVAL;
|
||||
if (approval && approval[0]) {
|
||||
int a = CST_DEFAULT_APPROVAL;
|
||||
if (cst_approval_parse(approval, &a) == 0)
|
||||
cfg.approval = a;
|
||||
}
|
||||
if (effort && effort[0] && cst_effort_valid(effort))
|
||||
snprintf(cfg.effort, sizeof(cfg.effort), "%s", effort);
|
||||
|
||||
char id[64];
|
||||
if (cst_agent_store_resolve_default(cfg.workspace_root, cfg.session_path,
|
||||
sizeof(cfg.session_path), id,
|
||||
sizeof(id)) != 0) {
|
||||
if (cst_agent_store_new_id(id, sizeof(id)) != 0 ||
|
||||
cst_agent_store_path_for_id(cfg.workspace_root, id, cfg.session_path,
|
||||
sizeof(cfg.session_path)) != 0) {
|
||||
if (err && err_sz)
|
||||
snprintf(err, err_sz, "session path failed");
|
||||
return NULL;
|
||||
}
|
||||
cst_agent_store_set_current(cfg.workspace_root, id);
|
||||
}
|
||||
|
||||
if (api_key && api_key[0]) {
|
||||
snprintf(cfg.api_key, sizeof(cfg.api_key), "%s", api_key);
|
||||
} else {
|
||||
char key[512];
|
||||
const char *from = NULL;
|
||||
if (cst_creds_resolve(cfg.provider, key, sizeof(key), &from) == 0)
|
||||
snprintf(cfg.api_key, sizeof(cfg.api_key), "%s", key);
|
||||
}
|
||||
|
||||
cst_agent_t *a = cst_agent_create(&cfg);
|
||||
if (!a && err && err_sz)
|
||||
snprintf(err, err_sz, "cst_agent_create failed");
|
||||
return a;
|
||||
}
|
||||
|
||||
void hermit_agent_close(void *agent) {
|
||||
if (agent)
|
||||
cst_agent_destroy((cst_agent_t *)agent);
|
||||
}
|
||||
|
||||
int hermit_agent_turn(void *agent, const char *user_text,
|
||||
const hermit_hooks_t *hooks, char *err, size_t err_sz) {
|
||||
if (!agent || !user_text)
|
||||
return -1;
|
||||
bridge_ud_t bud = {.h = hooks};
|
||||
cst_agent_hooks_t h;
|
||||
memset(&h, 0, sizeof(h));
|
||||
h.on_assistant_delta = hook_assistant;
|
||||
h.on_think_delta = hook_think;
|
||||
h.on_tool_event = hook_tool;
|
||||
h.on_tool_result = hook_tool_result;
|
||||
h.on_todo = hook_todo;
|
||||
h.on_status = hook_status;
|
||||
h.on_permission = hook_perm;
|
||||
h.on_ask_user = hook_ask;
|
||||
h.on_turn_begin = hook_turn_begin;
|
||||
h.on_turn_end = hook_turn_end;
|
||||
h.on_error = hook_error;
|
||||
h.userdata = &bud;
|
||||
return cst_agent_run_turn_ex((cst_agent_t *)agent, user_text, &h, err,
|
||||
err_sz);
|
||||
}
|
||||
|
||||
void hermit_agent_cancel(void *agent) {
|
||||
if (agent)
|
||||
cst_agent_request_cancel((cst_agent_t *)agent);
|
||||
}
|
||||
|
||||
int hermit_agent_set_workspace(void *agent, const char *path, char *err,
|
||||
size_t err_sz) {
|
||||
if (!agent || !path)
|
||||
return -1;
|
||||
return cst_agent_set_workspace((cst_agent_t *)agent, path, err, err_sz);
|
||||
}
|
||||
|
||||
int hermit_agent_new_session(void *agent, char *id_out, size_t id_sz) {
|
||||
if (!agent)
|
||||
return -1;
|
||||
return cst_agent_new_session((cst_agent_t *)agent, id_out, id_sz);
|
||||
}
|
||||
|
||||
int hermit_agent_switch_session(void *agent, const char *id) {
|
||||
if (!agent || !id)
|
||||
return -1;
|
||||
return cst_agent_switch_session((cst_agent_t *)agent, id);
|
||||
}
|
||||
|
||||
int hermit_agent_session_id(void *agent, char *out, size_t out_sz) {
|
||||
if (!agent || !out || out_sz == 0)
|
||||
return -1;
|
||||
cst_agent_session_id((cst_agent_t *)agent, out, out_sz);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int hermit_agent_set_plan_mode(void *agent, int on) {
|
||||
if (!agent)
|
||||
return -1;
|
||||
cst_agent_config_t *cfg = cst_agent_config_mut((cst_agent_t *)agent);
|
||||
if (!cfg)
|
||||
return -1;
|
||||
cfg->plan_mode = on ? 1 : 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int hermit_agent_set_approval(void *agent, const char *approval) {
|
||||
if (!agent || !approval)
|
||||
return -1;
|
||||
cst_agent_config_t *cfg = cst_agent_config_mut((cst_agent_t *)agent);
|
||||
if (!cfg)
|
||||
return -1;
|
||||
int a = cfg->approval;
|
||||
if (cst_approval_parse(approval, &a) == 0)
|
||||
cfg->approval = a;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int hermit_agent_set_effort(void *agent, const char *effort) {
|
||||
if (!agent)
|
||||
return -1;
|
||||
cst_agent_config_t *cfg = cst_agent_config_mut((cst_agent_t *)agent);
|
||||
if (!cfg)
|
||||
return -1;
|
||||
if (!effort || !effort[0]) {
|
||||
cfg->effort[0] = '\0';
|
||||
return 0;
|
||||
}
|
||||
if (!cst_effort_valid(effort))
|
||||
return -1;
|
||||
snprintf(cfg->effort, sizeof(cfg->effort), "%s", effort);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int hermit_agent_attach_file(void *agent, const char *path, char *err,
|
||||
size_t err_sz) {
|
||||
if (!agent || !path)
|
||||
return -1;
|
||||
return cst_agent_attach_file((cst_agent_t *)agent, path, err, err_sz);
|
||||
}
|
||||
|
||||
void hermit_agent_pending_clear(void *agent) {
|
||||
if (agent)
|
||||
cst_agent_pending_clear((cst_agent_t *)agent);
|
||||
}
|
||||
|
||||
int hermit_agent_compact(void *agent, char *err, size_t err_sz) {
|
||||
if (!agent)
|
||||
return -1;
|
||||
return cst_agent_compact((cst_agent_t *)agent, err, err_sz);
|
||||
}
|
||||
|
||||
void hermit_agent_usage(void *agent, long *prompt, long *out_tokens,
|
||||
double *cost, int *priced) {
|
||||
if (!agent)
|
||||
return;
|
||||
cst_usage_t s, t;
|
||||
memset(&s, 0, sizeof(s));
|
||||
memset(&t, 0, sizeof(t));
|
||||
cst_agent_usage((cst_agent_t *)agent, &s, &t);
|
||||
if (prompt)
|
||||
*prompt = s.prompt;
|
||||
if (out_tokens)
|
||||
*out_tokens = s.out;
|
||||
if (cost)
|
||||
*cost = s.cost;
|
||||
if (priced)
|
||||
*priced = s.priced;
|
||||
}
|
||||
|
||||
static void json_escape_append(char **buf, size_t *len, size_t *cap,
|
||||
const char *s) {
|
||||
if (!s)
|
||||
s = "";
|
||||
for (const unsigned char *p = (const unsigned char *)s; *p; p++) {
|
||||
char tmp[8];
|
||||
const char *chunk;
|
||||
size_t n;
|
||||
if (*p == '"' || *p == '\\') {
|
||||
tmp[0] = '\\';
|
||||
tmp[1] = (char)*p;
|
||||
tmp[2] = 0;
|
||||
chunk = tmp;
|
||||
n = 2;
|
||||
} else if (*p < 0x20) {
|
||||
n = (size_t)snprintf(tmp, sizeof(tmp), "\\u%04x", *p);
|
||||
chunk = tmp;
|
||||
} else {
|
||||
tmp[0] = (char)*p;
|
||||
tmp[1] = 0;
|
||||
chunk = tmp;
|
||||
n = 1;
|
||||
}
|
||||
if (*len + n + 1 >= *cap) {
|
||||
*cap = (*cap + n + 1) * 2;
|
||||
char *nb = (char *)realloc(*buf, *cap);
|
||||
if (!nb)
|
||||
return;
|
||||
*buf = nb;
|
||||
}
|
||||
memcpy(*buf + *len, chunk, n);
|
||||
*len += n;
|
||||
(*buf)[*len] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
static int hist_append_obj(char **buf, size_t *len, size_t *cap, int *first,
|
||||
const char *head, const char *body) {
|
||||
if (!*first) {
|
||||
if (*len + 2 >= *cap) {
|
||||
*cap *= 2;
|
||||
char *nb = (char *)realloc(*buf, *cap);
|
||||
if (!nb)
|
||||
return -1;
|
||||
*buf = nb;
|
||||
}
|
||||
(*buf)[(*len)++] = ',';
|
||||
(*buf)[*len] = 0;
|
||||
}
|
||||
*first = 0;
|
||||
size_t hn = strlen(head);
|
||||
if (*len + hn + 8 >= *cap) {
|
||||
*cap = (*cap + hn) * 2;
|
||||
char *nb = (char *)realloc(*buf, *cap);
|
||||
if (!nb)
|
||||
return -1;
|
||||
*buf = nb;
|
||||
}
|
||||
memcpy(*buf + *len, head, hn);
|
||||
*len += hn;
|
||||
(*buf)[*len] = 0;
|
||||
json_escape_append(buf, len, cap, body ? body : "");
|
||||
if (*len + 3 >= *cap) {
|
||||
*cap *= 2;
|
||||
char *nb = (char *)realloc(*buf, *cap);
|
||||
if (!nb)
|
||||
return -1;
|
||||
*buf = nb;
|
||||
}
|
||||
(*buf)[(*len)++] = '"';
|
||||
(*buf)[(*len)++] = '}';
|
||||
(*buf)[*len] = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
char *hermit_agent_history_json(void *agent) {
|
||||
if (!agent)
|
||||
return NULL;
|
||||
cst_session_t *sess = cst_agent_session((cst_agent_t *)agent);
|
||||
if (!sess)
|
||||
return NULL;
|
||||
cst_session_lock(sess);
|
||||
int n = cst_session_count(sess);
|
||||
size_t cap = 4096, len = 0;
|
||||
char *buf = (char *)malloc(cap);
|
||||
if (!buf) {
|
||||
cst_session_unlock(sess);
|
||||
return NULL;
|
||||
}
|
||||
buf[0] = '[';
|
||||
len = 1;
|
||||
int first = 1;
|
||||
for (int i = 0; i < n; i++) {
|
||||
const cst_message_t *m = cst_session_get(sess, i);
|
||||
if (!m)
|
||||
continue;
|
||||
if (m->role == CST_ROLE_SYSTEM)
|
||||
continue;
|
||||
char idbuf[64], head[288];
|
||||
snprintf(idbuf, sizeof(idbuf), "h%d", i);
|
||||
|
||||
if (m->role == CST_ROLE_ASSISTANT && m->reasoning && m->reasoning[0]) {
|
||||
snprintf(head, sizeof(head),
|
||||
"{\"kind\":\"thought\",\"id\":\"%s-r\",\"text\":\"", idbuf);
|
||||
if (hist_append_obj(&buf, &len, &cap, &first, head, m->reasoning) != 0)
|
||||
break;
|
||||
}
|
||||
|
||||
if (m->role == CST_ROLE_TOOL) {
|
||||
const char *tname =
|
||||
(m->tool_name[0] && !strpbrk(m->tool_name, "\\\"\n\r"))
|
||||
? m->tool_name
|
||||
: "tool";
|
||||
snprintf(head, sizeof(head),
|
||||
"{\"kind\":\"tool\",\"id\":\"%s\",\"name\":\"%s\",\"status\":"
|
||||
"\"ok\",\"detail\":\"%s\",\"output\":\"",
|
||||
idbuf, tname, tname);
|
||||
if (hist_append_obj(&buf, &len, &cap, &first, head,
|
||||
m->content ? m->content : "") != 0)
|
||||
break;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (m->role == CST_ROLE_USER) {
|
||||
if (!m->content || !m->content[0])
|
||||
continue;
|
||||
snprintf(head, sizeof(head),
|
||||
"{\"kind\":\"user\",\"id\":\"%s\",\"text\":\"", idbuf);
|
||||
if (hist_append_obj(&buf, &len, &cap, &first, head, m->content) != 0)
|
||||
break;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (m->role == CST_ROLE_ASSISTANT) {
|
||||
if (!m->content || !m->content[0])
|
||||
continue;
|
||||
snprintf(head, sizeof(head),
|
||||
"{\"kind\":\"assistant\",\"id\":\"%s\",\"text\":\"", idbuf);
|
||||
if (hist_append_obj(&buf, &len, &cap, &first, head, m->content) != 0)
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (len + 2 >= cap) {
|
||||
char *nb = (char *)realloc(buf, len + 2);
|
||||
if (nb)
|
||||
buf = nb;
|
||||
}
|
||||
buf[len++] = ']';
|
||||
buf[len] = 0;
|
||||
cst_session_unlock(sess);
|
||||
return buf;
|
||||
}
|
||||
|
||||
char *hermit_agent_todo(void *agent) {
|
||||
if (!agent)
|
||||
return NULL;
|
||||
const char *t = cst_agent_todo((cst_agent_t *)agent);
|
||||
if (!t || !t[0])
|
||||
return NULL;
|
||||
size_t n = strlen(t);
|
||||
char *out = (char *)malloc(n + 1);
|
||||
if (!out)
|
||||
return NULL;
|
||||
memcpy(out, t, n + 1);
|
||||
return out;
|
||||
}
|
||||
|
||||
char *hermit_agent_export_md(void *agent, const char *title) {
|
||||
if (!agent)
|
||||
return NULL;
|
||||
cst_session_t *sess = cst_agent_session((cst_agent_t *)agent);
|
||||
if (!sess)
|
||||
return NULL;
|
||||
char sid[128];
|
||||
cst_agent_session_id((cst_agent_t *)agent, sid, sizeof(sid));
|
||||
const cst_agent_config_t *cfg = cst_agent_config((cst_agent_t *)agent);
|
||||
const char *model = cfg && cfg->model[0] ? cfg->model : "";
|
||||
return cst_session_export_md(sess, title ? title : "Session", sid, model);
|
||||
}
|
||||
|
||||
int hermit_creds_has(const char *provider) {
|
||||
char key[512];
|
||||
const char *from = NULL;
|
||||
if (!provider || !provider[0])
|
||||
return 0;
|
||||
return cst_creds_resolve(provider, key, sizeof(key), &from) == 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
int hermit_store_list(const char *workspace, char ids[][128], int max_ids) {
|
||||
return cst_agent_store_list(workspace, ids, max_ids);
|
||||
}
|
||||
|
||||
int hermit_store_info(const char *workspace, const char *id, char *title,
|
||||
size_t title_sz, long long *mtime_ms) {
|
||||
cst_session_info_t info;
|
||||
memset(&info, 0, sizeof(info));
|
||||
if (cst_agent_store_info(workspace, id, &info) != 0)
|
||||
return -1;
|
||||
if (title && title_sz) {
|
||||
if (info.title[0])
|
||||
snprintf(title, title_sz, "%s", info.title);
|
||||
else if (info.first_user[0])
|
||||
snprintf(title, title_sz, "%s", info.first_user);
|
||||
else
|
||||
snprintf(title, title_sz, "%s", id);
|
||||
}
|
||||
/* cst_session_info_t.mtime is seconds since epoch (os_lstat); JS Date wants ms. */
|
||||
if (mtime_ms)
|
||||
*mtime_ms = info.mtime > 0 ? info.mtime * 1000LL : 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int hermit_store_title_set(const char *workspace, const char *id,
|
||||
const char *title) {
|
||||
return cst_agent_store_title_set(workspace, id, title);
|
||||
}
|
||||
|
||||
int hermit_store_delete(const char *workspace, const char *id) {
|
||||
return cst_agent_store_delete(workspace, id);
|
||||
}
|
||||
|
||||
int hermit_creds_save(const char *provider, const char *key) {
|
||||
return cst_creds_save(provider, key);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/* Thin C façade over cst_agent.h for the Tauri desktop host. */
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef void (*hermit_on_text_fn)(const char *text, size_t len, void *ud);
|
||||
typedef void (*hermit_on_tool_fn)(const char *phase, const char *id,
|
||||
const char *name, const char *detail,
|
||||
void *ud);
|
||||
typedef void (*hermit_on_tool_result_fn)(const char *id, const char *name,
|
||||
const char *out, size_t out_len,
|
||||
int exit_code, void *ud);
|
||||
typedef void (*hermit_on_todo_fn)(const char *text, void *ud);
|
||||
typedef void (*hermit_on_status_fn)(const char *msg, void *ud);
|
||||
/* Return CST_PERM_* . preview may be NULL. */
|
||||
typedef int (*hermit_on_perm_fn)(const char *tool, const char *detail,
|
||||
const char *preview, void *ud);
|
||||
/* Fill ans; return 0 ok, -1 dismiss. options is CSV or JSON-ish string. */
|
||||
typedef int (*hermit_on_ask_fn)(const char *question, const char *options,
|
||||
char *ans, size_t ans_sz, void *ud);
|
||||
typedef void (*hermit_on_turn_begin_fn)(const char *user_text, void *ud);
|
||||
typedef void (*hermit_on_turn_end_fn)(int rc, const char *err, void *ud);
|
||||
typedef void (*hermit_on_error_fn)(const char *kind, const char *message,
|
||||
void *ud);
|
||||
|
||||
typedef struct {
|
||||
hermit_on_text_fn on_assistant;
|
||||
hermit_on_text_fn on_thought;
|
||||
hermit_on_tool_fn on_tool;
|
||||
hermit_on_tool_result_fn on_tool_result;
|
||||
hermit_on_todo_fn on_todo;
|
||||
hermit_on_status_fn on_status;
|
||||
hermit_on_perm_fn on_perm;
|
||||
hermit_on_ask_fn on_ask;
|
||||
hermit_on_turn_begin_fn on_turn_begin;
|
||||
hermit_on_turn_end_fn on_turn_end;
|
||||
hermit_on_error_fn on_error;
|
||||
void *ud;
|
||||
} hermit_hooks_t;
|
||||
|
||||
void *hermit_agent_open(const char *workspace, const char *provider,
|
||||
const char *model, const char *approval,
|
||||
const char *api_key, const char *effort, char *err,
|
||||
size_t err_sz);
|
||||
void hermit_agent_close(void *agent);
|
||||
int hermit_agent_turn(void *agent, const char *user_text,
|
||||
const hermit_hooks_t *hooks, char *err, size_t err_sz);
|
||||
void hermit_agent_cancel(void *agent);
|
||||
|
||||
int hermit_agent_set_workspace(void *agent, const char *path, char *err,
|
||||
size_t err_sz);
|
||||
int hermit_agent_new_session(void *agent, char *id_out, size_t id_sz);
|
||||
int hermit_agent_switch_session(void *agent, const char *id);
|
||||
int hermit_agent_session_id(void *agent, char *out, size_t out_sz);
|
||||
int hermit_agent_set_plan_mode(void *agent, int on);
|
||||
int hermit_agent_set_approval(void *agent, const char *approval);
|
||||
int hermit_agent_set_effort(void *agent, const char *effort);
|
||||
int hermit_agent_attach_file(void *agent, const char *path, char *err,
|
||||
size_t err_sz);
|
||||
void hermit_agent_pending_clear(void *agent);
|
||||
int hermit_agent_compact(void *agent, char *err, size_t err_sz);
|
||||
void hermit_agent_usage(void *agent, long *prompt, long *out_tokens,
|
||||
double *cost, int *priced);
|
||||
|
||||
/* Malloc'd JSON array of chat items; caller frees. NULL on failure. */
|
||||
char *hermit_agent_history_json(void *agent);
|
||||
/* Malloc'd todo text or NULL. */
|
||||
char *hermit_agent_todo(void *agent);
|
||||
/* Malloc'd markdown export; caller frees. */
|
||||
char *hermit_agent_export_md(void *agent, const char *title);
|
||||
/* 1 if a saved/env key exists for provider. */
|
||||
int hermit_creds_has(const char *provider);
|
||||
|
||||
int hermit_store_list(const char *workspace, char ids[][128], int max_ids);
|
||||
int hermit_store_info(const char *workspace, const char *id, char *title,
|
||||
size_t title_sz, long long *mtime_ms);
|
||||
int hermit_store_title_set(const char *workspace, const char *id,
|
||||
const char *title);
|
||||
int hermit_store_delete(const char *workspace, const char *id);
|
||||
int hermit_creds_save(const char *provider, const char *key);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,14 @@
|
||||
/* Placeholder for future cbindgen / manual FFI against include/cst_agent.h.
|
||||
* Build with --features engine and HERMIT_CORE_LIB pointing at libcstcore.a.
|
||||
*/
|
||||
#pragma once
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Mirrors the subset AgentCore used; real symbols come from libcstcore. */
|
||||
typedef struct cst_agent cst_agent_t;
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,291 @@
|
||||
mod engine;
|
||||
mod ffi;
|
||||
|
||||
use engine::{SharedEngine, EngineState};
|
||||
use std::sync::Mutex;
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem, PredefinedMenuItem, Submenu},
|
||||
Emitter, Manager,
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
fn session_list(state: tauri::State<'_, SharedEngine>) -> Vec<engine::SessionInfo> {
|
||||
engine::list_sessions(&state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn session_new(state: tauri::State<'_, SharedEngine>) -> engine::SessionInfo {
|
||||
engine::new_session(&state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn session_open(
|
||||
state: tauri::State<'_, SharedEngine>,
|
||||
id: String,
|
||||
) -> Vec<engine::ChatItem> {
|
||||
engine::open_session(&state, &id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn session_rename(
|
||||
state: tauri::State<'_, SharedEngine>,
|
||||
id: String,
|
||||
title: String,
|
||||
) -> bool {
|
||||
engine::rename_session(&state, &id, &title)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn session_delete(state: tauri::State<'_, SharedEngine>, id: String) -> bool {
|
||||
engine::delete_session(&state, &id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn session_pin(state: tauri::State<'_, SharedEngine>, id: String) -> bool {
|
||||
engine::pin_session(&state, &id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn session_set_folder(
|
||||
state: tauri::State<'_, SharedEngine>,
|
||||
id: String,
|
||||
folder: Option<String>,
|
||||
) -> bool {
|
||||
engine::session_set_folder(&state, &id, folder)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn session_compact(state: tauri::State<'_, SharedEngine>) -> String {
|
||||
engine::session_compact(&state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn session_export(state: tauri::State<'_, SharedEngine>) -> String {
|
||||
engine::session_export(&state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn settings_get(state: tauri::State<'_, SharedEngine>) -> engine::Settings {
|
||||
engine::settings_get(&state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn settings_set(
|
||||
state: tauri::State<'_, SharedEngine>,
|
||||
settings: engine::Settings,
|
||||
) -> engine::Settings {
|
||||
engine::settings_set(&state, settings)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn attach_list(state: tauri::State<'_, SharedEngine>) -> Vec<engine::AttachmentInfo> {
|
||||
engine::attach_list(&state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn attach_file(
|
||||
state: tauri::State<'_, SharedEngine>,
|
||||
path: String,
|
||||
) -> Result<engine::AttachmentInfo, String> {
|
||||
engine::attach_file(&state, path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn attach_clear(state: tauri::State<'_, SharedEngine>) {
|
||||
engine::attach_clear(&state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn attach_remove(state: tauri::State<'_, SharedEngine>, id: String) {
|
||||
engine::attach_remove(&state, &id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn providers_list(state: tauri::State<'_, SharedEngine>) -> Vec<engine::ProviderInfo> {
|
||||
engine::providers_list(&state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn providers_use(
|
||||
state: tauri::State<'_, SharedEngine>,
|
||||
provider: String,
|
||||
model: String,
|
||||
key: String,
|
||||
) -> Result<String, String> {
|
||||
engine::providers_use(&state, provider, model, key)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn usage_get(state: tauri::State<'_, SharedEngine>) -> engine::UsageInfo {
|
||||
engine::usage_get(&state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn todo_get(state: tauri::State<'_, SharedEngine>) -> String {
|
||||
engine::todo_get(&state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn bash_list(state: tauri::State<'_, SharedEngine>) -> Vec<engine::BashRun> {
|
||||
engine::bash_list(&state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn workspace_files(
|
||||
state: tauri::State<'_, SharedEngine>,
|
||||
query: String,
|
||||
) -> Vec<String> {
|
||||
engine::workspace_files(&state, query)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn engine_status() -> String {
|
||||
engine::engine_status()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn send_turn(
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, SharedEngine>,
|
||||
text: String,
|
||||
) -> Result<(), String> {
|
||||
engine::send_turn(app, &state, text).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cancel_turn(state: tauri::State<'_, SharedEngine>) {
|
||||
engine::cancel_turn(&state);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn consent_answer(state: tauri::State<'_, SharedEngine>, id: String, answer: String) {
|
||||
engine::consent_answer(&state, &id, &answer);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn question_answer(state: tauri::State<'_, SharedEngine>, id: String, answer: String) {
|
||||
engine::question_answer(&state, &id, &answer);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn apply_linux_webview_workarounds() {
|
||||
let nvidia = std::path::Path::new("/proc/driver/nvidia/version").exists();
|
||||
let wayland = std::env::var_os("WAYLAND_DISPLAY").is_some()
|
||||
|| std::env::var("XDG_SESSION_TYPE").ok().as_deref() == Some("wayland");
|
||||
unsafe {
|
||||
if nvidia && std::env::var_os("__NV_DISABLE_EXPLICIT_SYNC").is_none() {
|
||||
std::env::set_var("__NV_DISABLE_EXPLICIT_SYNC", "1");
|
||||
}
|
||||
if nvidia && wayland && std::env::var_os("GDK_BACKEND").is_none() {
|
||||
std::env::set_var("GDK_BACKEND", "x11");
|
||||
}
|
||||
let backend = std::env::var("GDK_BACKEND").unwrap_or_default();
|
||||
let on_x11 = backend == "x11" || (!wayland && std::env::var_os("DISPLAY").is_some());
|
||||
if nvidia && on_x11 && std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
|
||||
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_menu(app: &tauri::App) -> tauri::Result<Menu<tauri::Wry>> {
|
||||
let new_session = MenuItem::with_id(app, "new_session", "New Session", true, Some("CmdOrCtrl+N"))?;
|
||||
let open_ws = MenuItem::with_id(app, "open_workspace", "Open Workspace…", true, Some("CmdOrCtrl+O"))?;
|
||||
let settings = MenuItem::with_id(app, "settings", "Settings…", true, Some("CmdOrCtrl+Comma"))?;
|
||||
let quit = PredefinedMenuItem::quit(app, Some("Quit Hermit"))?;
|
||||
|
||||
let file = Submenu::with_items(
|
||||
app,
|
||||
"File",
|
||||
true,
|
||||
&[&new_session, &open_ws, &PredefinedMenuItem::separator(app)?, &settings, &quit],
|
||||
)?;
|
||||
|
||||
let edit = Submenu::with_items(
|
||||
app,
|
||||
"Edit",
|
||||
true,
|
||||
&[
|
||||
&PredefinedMenuItem::undo(app, None)?,
|
||||
&PredefinedMenuItem::redo(app, None)?,
|
||||
&PredefinedMenuItem::separator(app)?,
|
||||
&PredefinedMenuItem::cut(app, None)?,
|
||||
&PredefinedMenuItem::copy(app, None)?,
|
||||
&PredefinedMenuItem::paste(app, None)?,
|
||||
&PredefinedMenuItem::select_all(app, None)?,
|
||||
],
|
||||
)?;
|
||||
|
||||
let view_plan = MenuItem::with_id(app, "view_plan", "Toggle Plan", true, None::<&str>)?;
|
||||
let view_bash = MenuItem::with_id(app, "view_bash", "Toggle Background Bash", true, Some("CmdOrCtrl+`"))?;
|
||||
let view = Submenu::with_items(app, "View", true, &[&view_plan, &view_bash])?;
|
||||
|
||||
let about = MenuItem::with_id(app, "about", "About Hermit", true, None::<&str>)?;
|
||||
let help = Submenu::with_items(app, "Help", true, &[&about])?;
|
||||
|
||||
Menu::with_items(app, &[&file, &edit, &view, &help])
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
#[cfg(target_os = "linux")]
|
||||
apply_linux_webview_workarounds();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(Mutex::new(EngineState::default()))
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
session_list,
|
||||
session_new,
|
||||
session_open,
|
||||
session_rename,
|
||||
session_delete,
|
||||
session_pin,
|
||||
session_set_folder,
|
||||
session_compact,
|
||||
session_export,
|
||||
settings_get,
|
||||
settings_set,
|
||||
attach_list,
|
||||
attach_file,
|
||||
attach_clear,
|
||||
attach_remove,
|
||||
providers_list,
|
||||
providers_use,
|
||||
usage_get,
|
||||
todo_get,
|
||||
bash_list,
|
||||
workspace_files,
|
||||
engine_status,
|
||||
send_turn,
|
||||
cancel_turn,
|
||||
consent_answer,
|
||||
question_answer
|
||||
])
|
||||
.setup(|app| {
|
||||
if let Ok(menu) = build_menu(app) {
|
||||
let _ = app.set_menu(menu);
|
||||
}
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
let _ = w.set_title("Hermit");
|
||||
let _ = w.set_size(tauri::Size::Logical(tauri::LogicalSize {
|
||||
width: 1280.0,
|
||||
height: 840.0,
|
||||
}));
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
let _ = w.navigate(
|
||||
tauri::Url::parse("http://127.0.0.1:1420/").expect("dev url"),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.on_menu_event(|app, event| {
|
||||
let id = event.id().as_ref();
|
||||
let _ = app.emit("hermit://menu", id);
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Hermit desktop");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
hermit_desktop_lib::run();
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Hermit",
|
||||
"version": "1.1.0",
|
||||
"identifier": "xyz.whcself.hermit",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://127.0.0.1:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Hermit",
|
||||
"width": 1280,
|
||||
"height": 840,
|
||||
"minWidth": 880,
|
||||
"minHeight": 560,
|
||||
"resizable": true,
|
||||
"fullscreen": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"linux": {
|
||||
"deb": {
|
||||
"depends": []
|
||||
}
|
||||
},
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "12.0"
|
||||
},
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"installMode": "currentUser"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,789 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Rail } from "@/components/Rail";
|
||||
import { Transcript } from "@/components/Transcript";
|
||||
import { Composer, type MainView, type SlashCommand } from "@/components/Composer";
|
||||
import { Hero } from "@/components/Hero";
|
||||
import { WorkspaceChip } from "@/components/WorkspaceChip";
|
||||
import { ApprovalCard } from "@/components/ApprovalCard";
|
||||
import { QuestionCard } from "@/components/QuestionCard";
|
||||
import { SessionHeader } from "@/components/SessionHeader";
|
||||
import { SettingsPanel } from "@/components/SettingsPanel";
|
||||
import { PlanPanel } from "@/components/PlanPanel";
|
||||
import { BashPanel } from "@/components/BashPanel";
|
||||
import type { HermitMood } from "@/components/Icons";
|
||||
import {
|
||||
answerConsent,
|
||||
answerQuestion,
|
||||
attachFile,
|
||||
cancelTurn,
|
||||
clearPending,
|
||||
compactSession,
|
||||
deleteSession,
|
||||
engineStatus,
|
||||
exportSession,
|
||||
getSettings,
|
||||
getTodo,
|
||||
getUsage,
|
||||
isShellTool,
|
||||
listBashRuns,
|
||||
listPending,
|
||||
listProviders,
|
||||
listSessions,
|
||||
listWorkspaceFiles,
|
||||
mockTurnsAllowed,
|
||||
newSession,
|
||||
onEngineEvent,
|
||||
onMenuEvent,
|
||||
openSession,
|
||||
pickFiles,
|
||||
pickWorkspace,
|
||||
pinSession,
|
||||
removePending,
|
||||
renameSession,
|
||||
sendTurn,
|
||||
setSessionFolder,
|
||||
setSettings,
|
||||
useProvider,
|
||||
type AttachmentInfo,
|
||||
type BashRun,
|
||||
type ChatItem,
|
||||
type ConsentRequest,
|
||||
type ProviderInfo,
|
||||
type QuestionRequest,
|
||||
type SessionInfo,
|
||||
type Settings,
|
||||
type UsageInfo,
|
||||
} from "@/lib/bridge";
|
||||
import frame from "@/dsh/layout/AppFrame.module.css";
|
||||
import conv from "@/dsh/conversation/ConversationRoot.module.css";
|
||||
|
||||
const SIDEBAR_WIDTH = 280;
|
||||
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
function displaySessionTitle(title?: string, id?: string): string {
|
||||
const t = (title || "").trim();
|
||||
if (!t || UUID_RE.test(t) || t === id) return "New session";
|
||||
return t;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [items, setItems] = useState<ChatItem[]>([]);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [settings, setSettingsState] = useState<Settings>({
|
||||
provider: "deepseek",
|
||||
model: "deepseek-flash",
|
||||
approval: "auto",
|
||||
workspace: ".",
|
||||
plan_mode: false,
|
||||
reasoning_effort: "high",
|
||||
font_size: "m",
|
||||
language: "zh",
|
||||
});
|
||||
const [consent, setConsent] = useState<ConsentRequest | null>(null);
|
||||
const [question, setQuestion] = useState<QuestionRequest | null>(null);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [status, setStatus] = useState("");
|
||||
const [todo, setTodo] = useState("");
|
||||
const [bashRuns, setBashRuns] = useState<BashRun[]>([]);
|
||||
const [view, setView] = useState<MainView>("chat");
|
||||
const [attachments, setAttachments] = useState<AttachmentInfo[]>([]);
|
||||
const [providers, setProviders] = useState<ProviderInfo[]>([]);
|
||||
const [usage, setUsage] = useState<UsageInfo>({
|
||||
prompt: 0,
|
||||
out: 0,
|
||||
cost: 0,
|
||||
priced: false,
|
||||
pct: 0,
|
||||
});
|
||||
const [fileSuggestions, setFileSuggestions] = useState<string[]>([]);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [engine, setEngine] = useState("host");
|
||||
const convColRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const hero = items.length === 0 && !busy && view === "chat";
|
||||
|
||||
useEffect(() => {
|
||||
const el = convColRef.current;
|
||||
if (!el) return;
|
||||
const sync = () => {
|
||||
el.style.setProperty(
|
||||
"--dsh-conversation-column-width",
|
||||
`${el.clientWidth}px`,
|
||||
);
|
||||
};
|
||||
sync();
|
||||
const ro = new ResizeObserver(sync);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
const agentMood: HermitMood = busy
|
||||
? consent || question
|
||||
? "thinking"
|
||||
: "coding"
|
||||
: "idle";
|
||||
|
||||
const engineReady = engine === "engine";
|
||||
const sendBlocked = !engineReady && !mockTurnsAllowed();
|
||||
const sendBlockedReason = sendBlocked
|
||||
? engine === "browser"
|
||||
? "当前在浏览器预览(无 Tauri)。请用 apps/desktop/run-dev.sh 启动真引擎。"
|
||||
: "当前为 host(未链接 libcstcore)。请用 run-dev.sh(自动 --features engine),并在环境或设置中配置 DEEPSEEK_API_KEY。"
|
||||
: engineReady
|
||||
? undefined
|
||||
: "演示 mock 已开启(hermit.allowMock)— 非真实引擎。";
|
||||
|
||||
const refreshSessions = useCallback(async () => {
|
||||
const list = await listSessions();
|
||||
list.sort(
|
||||
(a, b) =>
|
||||
Number(!!b.pinned) - Number(!!a.pinned) || b.updated_at - a.updated_at,
|
||||
);
|
||||
setSessions(list);
|
||||
}, []);
|
||||
|
||||
const refreshPending = useCallback(async () => {
|
||||
setAttachments(await listPending());
|
||||
}, []);
|
||||
|
||||
const refreshBash = useCallback(async () => {
|
||||
setBashRuns(await listBashRuns());
|
||||
}, []);
|
||||
|
||||
const applySettings = useCallback(async (next: Settings) => {
|
||||
const saved = await setSettings(next);
|
||||
setSettingsState(saved);
|
||||
document.documentElement.dataset.font = saved.font_size || "m";
|
||||
document.documentElement.dataset.lang = saved.language || "zh";
|
||||
return saved;
|
||||
}, []);
|
||||
|
||||
const onPickWorkspace = useCallback(async () => {
|
||||
const path = await pickWorkspace();
|
||||
if (!path) return;
|
||||
await applySettings({ ...settings, workspace: path });
|
||||
await refreshSessions();
|
||||
}, [applySettings, refreshSessions, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const s = await getSettings();
|
||||
setSettingsState(s);
|
||||
document.documentElement.dataset.font = s.font_size || "m";
|
||||
document.documentElement.dataset.lang = s.language || "zh";
|
||||
const st = await engineStatus();
|
||||
setEngine(st);
|
||||
setStatus(st === "engine" ? "engine ready" : st);
|
||||
setProviders(await listProviders());
|
||||
setUsage(await getUsage());
|
||||
const list = await listSessions();
|
||||
list.sort(
|
||||
(a, b) =>
|
||||
Number(!!b.pinned) - Number(!!a.pinned) || b.updated_at - a.updated_at,
|
||||
);
|
||||
setSessions(list);
|
||||
await refreshPending();
|
||||
// Never block first paint on ensure_live/open_session (can freeze GTK).
|
||||
// Start on empty hero; open the latest session after a tick.
|
||||
const forceHero =
|
||||
typeof location !== "undefined" &&
|
||||
new URLSearchParams(location.search).get("hero") === "1";
|
||||
setActiveId(null);
|
||||
setItems([]);
|
||||
if (forceHero) return;
|
||||
const first = list[0];
|
||||
if (!first) return;
|
||||
window.setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
setActiveId(first.id);
|
||||
setItems(await openSession(first.id));
|
||||
setTodo(await getTodo());
|
||||
setBashRuns(await listBashRuns());
|
||||
} catch (e) {
|
||||
console.error("openSession deferred failed", e);
|
||||
setActiveId(null);
|
||||
setItems([]);
|
||||
}
|
||||
})();
|
||||
}, 0);
|
||||
})();
|
||||
}, [refreshPending]);
|
||||
|
||||
useEffect(() => {
|
||||
let un: (() => void) | undefined;
|
||||
void onEngineEvent((ev) => {
|
||||
if (ev.type === "assistant_delta") {
|
||||
setItems((prev) => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last && last.kind === "assistant") {
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{ ...last, text: last.text + ev.text },
|
||||
];
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
kind: "assistant",
|
||||
id: `a${Date.now()}`,
|
||||
text: ev.text,
|
||||
ts: Date.now(),
|
||||
},
|
||||
];
|
||||
});
|
||||
} else if (ev.type === "thought_delta") {
|
||||
setItems((prev) => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last && last.kind === "thought") {
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{ ...last, text: last.text + "\n" + ev.text },
|
||||
];
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
kind: "thought",
|
||||
id: `th${Date.now()}`,
|
||||
text: ev.text,
|
||||
ts: Date.now(),
|
||||
},
|
||||
];
|
||||
});
|
||||
} else if (ev.type === "tool_start") {
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
kind: "tool",
|
||||
id: ev.id,
|
||||
name: ev.name,
|
||||
status: "running",
|
||||
detail: ev.detail,
|
||||
ts: Date.now(),
|
||||
},
|
||||
]);
|
||||
if (isShellTool(ev.name)) {
|
||||
setBashRuns((prev) => {
|
||||
if (prev.some((b) => b.id === ev.id)) return prev;
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: ev.id,
|
||||
command: ev.detail || ev.name,
|
||||
status: "running",
|
||||
started_at: Date.now(),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
} else if (ev.type === "tool_end") {
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.kind === "tool" && it.id === ev.id
|
||||
? {
|
||||
...it,
|
||||
status: ev.status,
|
||||
detail: ev.detail || it.detail,
|
||||
output: ev.output ?? it.output,
|
||||
exit_code: ev.exit_code ?? it.exit_code,
|
||||
}
|
||||
: it,
|
||||
),
|
||||
);
|
||||
setBashRuns((prev) =>
|
||||
prev.map((b) =>
|
||||
b.id === ev.id
|
||||
? {
|
||||
...b,
|
||||
status: ev.status,
|
||||
output: ev.output ?? b.output,
|
||||
exit_code: ev.exit_code ?? b.exit_code,
|
||||
ended_at: Date.now(),
|
||||
}
|
||||
: b,
|
||||
),
|
||||
);
|
||||
} else if (ev.type === "consent") {
|
||||
setConsent(ev.req);
|
||||
} else if (ev.type === "question") {
|
||||
setQuestion(ev.req);
|
||||
} else if (ev.type === "todo") {
|
||||
setTodo(ev.text);
|
||||
} else if (ev.type === "status") {
|
||||
setStatus(ev.message);
|
||||
} else if (ev.type === "turn_end") {
|
||||
setBusy(false);
|
||||
setConsent(null);
|
||||
setQuestion(null);
|
||||
void refreshSessions();
|
||||
void refreshPending();
|
||||
void getUsage().then(setUsage);
|
||||
void getTodo().then(setTodo);
|
||||
void refreshBash();
|
||||
setActiveId((id) => {
|
||||
if (id && ev.ok) {
|
||||
void openSession(id).then(setItems);
|
||||
}
|
||||
return id;
|
||||
});
|
||||
} else if (ev.type === "error") {
|
||||
setBusy(false);
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
kind: "assistant",
|
||||
id: `err${Date.now()}`,
|
||||
text: `Error: ${ev.message}`,
|
||||
ts: Date.now(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
}).then((u) => {
|
||||
un = u;
|
||||
});
|
||||
return () => un?.();
|
||||
}, [refreshBash, refreshPending, refreshSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
let un: (() => void) | undefined;
|
||||
void onMenuEvent((id) => {
|
||||
if (id === "new_session") void onNew();
|
||||
else if (id === "open_workspace") void onPickWorkspace();
|
||||
else if (id === "settings") setShowSettings(true);
|
||||
else if (id === "view_plan") setView((v) => (v === "plan" ? "chat" : "plan"));
|
||||
else if (id === "view_bash") setView((v) => (v === "bash" ? "chat" : "bash"));
|
||||
else if (id === "about") setShowSettings(true);
|
||||
}).then((u) => {
|
||||
un = u;
|
||||
});
|
||||
return () => un?.();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [onPickWorkspace]);
|
||||
|
||||
const open = async (id: string) => {
|
||||
setActiveId(id);
|
||||
setItems(await openSession(id));
|
||||
setTodo(await getTodo());
|
||||
setBashRuns(await listBashRuns());
|
||||
setConsent(null);
|
||||
setQuestion(null);
|
||||
setView("chat");
|
||||
};
|
||||
|
||||
const onNew = async () => {
|
||||
const s = await newSession();
|
||||
await refreshSessions();
|
||||
setActiveId(s.id);
|
||||
setItems([]);
|
||||
setConsent(null);
|
||||
setQuestion(null);
|
||||
setTodo("");
|
||||
setBashRuns([]);
|
||||
setView("chat");
|
||||
};
|
||||
|
||||
const onAttach = async () => {
|
||||
const paths = await pickFiles();
|
||||
for (const p of paths) await attachFile(p);
|
||||
await refreshPending();
|
||||
};
|
||||
|
||||
const onSend = async () => {
|
||||
const text = draft.trim();
|
||||
if ((!text && attachments.length === 0) || busy || consent || question) return;
|
||||
if (sendBlocked) return;
|
||||
setDraft("");
|
||||
setBusy(true);
|
||||
setView("chat");
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
kind: "user",
|
||||
id: `u${Date.now()}`,
|
||||
text: text || "(attachments)",
|
||||
ts: Date.now(),
|
||||
},
|
||||
]);
|
||||
if (!activeId) {
|
||||
const s = await newSession();
|
||||
setActiveId(s.id);
|
||||
await refreshSessions();
|
||||
} else {
|
||||
setSessions((prev) =>
|
||||
prev.map((s) =>
|
||||
s.id === activeId &&
|
||||
(s.title === "New session" || s.title === "Welcome")
|
||||
? {
|
||||
...s,
|
||||
title: (text || "Attachments").slice(0, 48),
|
||||
updated_at: Date.now(),
|
||||
}
|
||||
: s,
|
||||
),
|
||||
);
|
||||
}
|
||||
await sendTurn(text || "Please look at the attached files.");
|
||||
};
|
||||
|
||||
const models = useMemo(() => {
|
||||
const base = ["deepseek-flash", "deepseek-chat", "deepseek-reasoner"];
|
||||
if (!base.includes(settings.model)) return [settings.model, ...base];
|
||||
return base;
|
||||
}, [settings.model]);
|
||||
|
||||
const slashCommands: SlashCommand[] = useMemo(
|
||||
() => [
|
||||
{ cmd: "/new", desc: "Start a new session", run: () => void onNew() },
|
||||
{
|
||||
cmd: "/model",
|
||||
desc: "Cycle model",
|
||||
run: () => {
|
||||
const i = models.indexOf(settings.model);
|
||||
const next = models[(i + 1) % models.length];
|
||||
void applySettings({ ...settings, model: next });
|
||||
},
|
||||
},
|
||||
{
|
||||
cmd: "/approval",
|
||||
desc: "Cycle approval mode",
|
||||
run: () => {
|
||||
const order: Settings["approval"][] = ["ask", "auto", "yolo"];
|
||||
const i = order.indexOf(settings.approval);
|
||||
void applySettings({
|
||||
...settings,
|
||||
approval: order[(i + 1) % order.length],
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
cmd: "/plan",
|
||||
desc: "Toggle plan panel",
|
||||
run: () => setView((v) => (v === "plan" ? "chat" : "plan")),
|
||||
},
|
||||
{
|
||||
cmd: "/bash",
|
||||
desc: "Toggle bash panel",
|
||||
run: () => setView((v) => (v === "bash" ? "chat" : "bash")),
|
||||
},
|
||||
{
|
||||
cmd: "/compact",
|
||||
desc: "Compact session context",
|
||||
run: async () => {
|
||||
const msg = await compactSession();
|
||||
const hist = activeId ? await openSession(activeId) : [];
|
||||
setItems([
|
||||
...hist,
|
||||
{
|
||||
kind: "assistant",
|
||||
id: `c${Date.now()}`,
|
||||
text: msg,
|
||||
ts: Date.now(),
|
||||
},
|
||||
]);
|
||||
setTodo(await getTodo());
|
||||
},
|
||||
},
|
||||
{
|
||||
cmd: "/todo",
|
||||
desc: "Refresh todo checklist",
|
||||
run: async () => {
|
||||
const t = await getTodo();
|
||||
setTodo(t);
|
||||
setView("plan");
|
||||
},
|
||||
},
|
||||
{
|
||||
cmd: "/provider",
|
||||
desc: "Cycle provider",
|
||||
run: async () => {
|
||||
const list =
|
||||
providers.length > 0 ? providers : await listProviders();
|
||||
if (list.length === 0) return;
|
||||
const i = list.findIndex((p) => p.name === settings.provider);
|
||||
const next = list[(i + 1) % list.length];
|
||||
await useProvider(next.name, next.model);
|
||||
setProviders(await listProviders());
|
||||
setSettingsState(await getSettings());
|
||||
setStatus(`provider · ${next.name}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
cmd: "/clear",
|
||||
desc: "Clear pending attachments",
|
||||
run: async () => {
|
||||
await clearPending();
|
||||
await refreshPending();
|
||||
},
|
||||
},
|
||||
{
|
||||
cmd: "/rename",
|
||||
desc: "Rename current session",
|
||||
run: () => {
|
||||
if (!activeId) return;
|
||||
const cur = sessions.find((s) => s.id === activeId)?.title ?? "";
|
||||
const title = window.prompt("Rename session", cur);
|
||||
if (title?.trim()) {
|
||||
void renameSession(activeId, title.trim()).then(refreshSessions);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
cmd: "/export",
|
||||
desc: "Export transcript as Markdown",
|
||||
run: async () => {
|
||||
const md = await exportSession();
|
||||
await navigator.clipboard?.writeText(md).catch(() => undefined);
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
kind: "assistant",
|
||||
id: `e${Date.now()}`,
|
||||
text: "Exported Markdown copied to clipboard.",
|
||||
ts: Date.now(),
|
||||
},
|
||||
]);
|
||||
},
|
||||
},
|
||||
{
|
||||
cmd: "/settings",
|
||||
desc: "Open settings",
|
||||
run: () => setShowSettings(true),
|
||||
},
|
||||
],
|
||||
[
|
||||
activeId,
|
||||
applySettings,
|
||||
models,
|
||||
providers,
|
||||
refreshPending,
|
||||
refreshSessions,
|
||||
sessions,
|
||||
settings,
|
||||
],
|
||||
);
|
||||
|
||||
const active = sessions.find((s) => s.id === activeId);
|
||||
const sessionTitle = displaySessionTitle(active?.title, active?.id);
|
||||
const wsLabel =
|
||||
settings.workspace === "." || !settings.workspace
|
||||
? "Choose workspace"
|
||||
: settings.workspace.split(/[/\\]/).filter(Boolean).pop() ||
|
||||
settings.workspace;
|
||||
|
||||
const inputBar = consent ? (
|
||||
<ApprovalCard
|
||||
req={consent}
|
||||
onAnswer={(a) => {
|
||||
void answerConsent(consent.id, a);
|
||||
setConsent(null);
|
||||
}}
|
||||
/>
|
||||
) : question ? (
|
||||
<QuestionCard
|
||||
req={question}
|
||||
onAnswer={(a) => {
|
||||
void answerQuestion(question.id, a);
|
||||
setQuestion(null);
|
||||
}}
|
||||
onSkip={() => {
|
||||
void answerQuestion(question.id, "");
|
||||
setQuestion(null);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Composer
|
||||
value={draft}
|
||||
busy={busy}
|
||||
settings={settings}
|
||||
models={models}
|
||||
attachments={attachments}
|
||||
usage={usage}
|
||||
slashCommands={slashCommands}
|
||||
fileSuggestions={fileSuggestions}
|
||||
view={view}
|
||||
hero={hero}
|
||||
dragging={dragging}
|
||||
sendBlocked={sendBlocked}
|
||||
sendBlockedReason={sendBlockedReason}
|
||||
onChange={setDraft}
|
||||
onSend={() => void onSend()}
|
||||
onStop={() => void cancelTurn()}
|
||||
onSettingsPatch={(patch) => {
|
||||
void applySettings({ ...settings, ...patch });
|
||||
}}
|
||||
onAttach={() => void onAttach()}
|
||||
onRemoveAttach={(id) => {
|
||||
void removePending(id).then(refreshPending);
|
||||
}}
|
||||
onQueryFiles={(q) => {
|
||||
void listWorkspaceFiles(q).then(setFileSuggestions);
|
||||
}}
|
||||
onInsert={() => undefined}
|
||||
onView={setView}
|
||||
/>
|
||||
);
|
||||
|
||||
/** DSH ConversationContent composerBar */
|
||||
const composerBar = (
|
||||
<div
|
||||
className={`${conv.composerStack}${hero ? ` ${conv.composerHero}` : ""}`}
|
||||
>
|
||||
{hero ? <Hero mood={agentMood} /> : null}
|
||||
{hero ? (
|
||||
<div className={conv.heroWorkspaceRow}>
|
||||
<WorkspaceChip
|
||||
workspace={settings.workspace}
|
||||
onPick={() => void onPickWorkspace()}
|
||||
/>
|
||||
<span className="hermit-engine-chip" data-mode={engine}>
|
||||
{engine}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{inputBar}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${frame.frame} app-frame-fill`}
|
||||
style={{
|
||||
gridTemplateColumns: `${SIDEBAR_WIDTH}px minmax(0, 1fr) 0px`,
|
||||
}}
|
||||
data-rightbar-collapsed=""
|
||||
data-font={settings.font_size || "m"}
|
||||
onDragEnter={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
const files = [...e.dataTransfer.files];
|
||||
void (async () => {
|
||||
for (const f of files) {
|
||||
const path = (f as File & { path?: string }).path;
|
||||
if (path) await attachFile(path);
|
||||
}
|
||||
await refreshPending();
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<div className={frame.sidebarCol}>
|
||||
<Rail
|
||||
sessions={sessions}
|
||||
activeId={activeId}
|
||||
workspace={settings.workspace}
|
||||
query={query}
|
||||
engine={engine}
|
||||
onQuery={setQuery}
|
||||
onNew={() => void onNew()}
|
||||
onOpen={(id) => void open(id)}
|
||||
onRename={(id, title) => {
|
||||
void renameSession(id, title).then(refreshSessions);
|
||||
}}
|
||||
onDelete={(id) => {
|
||||
void (async () => {
|
||||
await deleteSession(id);
|
||||
await refreshSessions();
|
||||
if (activeId === id) {
|
||||
setActiveId(null);
|
||||
setItems([]);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
onPin={(id) => {
|
||||
void pinSession(id).then(refreshSessions);
|
||||
}}
|
||||
onSetFolder={(id, folder) => {
|
||||
void setSessionFolder(id, folder).then(refreshSessions);
|
||||
}}
|
||||
onSettings={() => setShowSettings(true)}
|
||||
onPickWorkspace={() => void onPickWorkspace()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={frame.centerCol}>
|
||||
<div className={frame.mainPanel}>
|
||||
<div
|
||||
ref={convColRef}
|
||||
className={`${conv.root} hermit-conversation-body`}
|
||||
data-phase={hero ? "hero" : "active"}
|
||||
>
|
||||
{!hero ? (
|
||||
<SessionHeader
|
||||
workspaceLabel={wsLabel}
|
||||
folder={active?.folder}
|
||||
title={sessionTitle}
|
||||
mood={agentMood}
|
||||
status={status && status !== engine ? status : undefined}
|
||||
engine={engine}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className={conv.body}>
|
||||
<div className={conv.scrollBody} data-conversation-scroll="">
|
||||
{!hero ? (
|
||||
<div className={conv.viewArea}>
|
||||
{view === "plan" ? (
|
||||
<PlanPanel
|
||||
text={todo}
|
||||
planMode={!!settings.plan_mode}
|
||||
onClose={() => setView("chat")}
|
||||
onTogglePlanMode={() =>
|
||||
void applySettings({
|
||||
...settings,
|
||||
plan_mode: !settings.plan_mode,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : view === "bash" ? (
|
||||
<BashPanel
|
||||
runs={bashRuns}
|
||||
onClose={() => setView("chat")}
|
||||
/>
|
||||
) : (
|
||||
<Transcript items={items} busy={busy} />
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={conv.composerSeat} data-composer-seat="">
|
||||
{composerBar}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* rightbar fixed closed — 0 track */}
|
||||
<div className={frame.rightbarCol} aria-hidden />
|
||||
|
||||
{showSettings ? (
|
||||
<SettingsPanel
|
||||
settings={settings}
|
||||
providers={providers}
|
||||
engine={engine}
|
||||
onSave={(s) => {
|
||||
void applySettings(s);
|
||||
}}
|
||||
onUseProvider={(provider, model, key) => {
|
||||
void useProvider(provider, model, key).then(async () => {
|
||||
setProviders(await listProviders());
|
||||
setSettingsState(await getSettings());
|
||||
});
|
||||
}}
|
||||
onClose={() => setShowSettings(false)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { ConsentRequest } from "@/lib/bridge";
|
||||
import appr from "@/dsh/approval/ApprovalPanel.module.css";
|
||||
|
||||
type Answer = "deny" | "once" | "always_tool" | "always_prefix";
|
||||
|
||||
type Props = {
|
||||
req: ConsentRequest;
|
||||
onAnswer: (a: Answer) => void;
|
||||
};
|
||||
|
||||
export function ApprovalCard({ req, onAnswer }: Props) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
rootRef.current?.focus();
|
||||
}, [req.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
if (e.isComposing || e.keyCode === 229) return;
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onAnswer("once");
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onAnswer("deny");
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onAnswer, req.id]);
|
||||
|
||||
return (
|
||||
<div className={appr.root}>
|
||||
<div
|
||||
className={appr.card}
|
||||
ref={rootRef}
|
||||
tabIndex={-1}
|
||||
role="group"
|
||||
aria-label={`Allow ${req.tool}?`}
|
||||
>
|
||||
<div className={appr.strip}>Waiting for approval</div>
|
||||
<div className={appr.body}>
|
||||
<div className={appr.headline}>Allow {req.tool}?</div>
|
||||
<pre className={appr.command}>{req.detail}</pre>
|
||||
{req.preview ? <pre className={appr.command}>{req.preview}</pre> : null}
|
||||
</div>
|
||||
<div className={appr.actionRow}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${appr.reject || ""} hermit-toolbar-btn`}
|
||||
onClick={() => onAnswer("deny")}
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="hermit-toolbar-btn"
|
||||
onClick={() => onAnswer("always_prefix")}
|
||||
>
|
||||
Always prefix
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="hermit-toolbar-btn"
|
||||
onClick={() => onAnswer("always_tool")}
|
||||
>
|
||||
Always tool
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="hermit-send"
|
||||
style={{ width: "auto", padding: "0 14px", borderRadius: 8 }}
|
||||
onClick={() => onAnswer("once")}
|
||||
>
|
||||
Allow once
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { BashRun } from "@/lib/bridge";
|
||||
|
||||
type Filter = "recent" | "running" | "done" | "all";
|
||||
|
||||
type Props = {
|
||||
runs: BashRun[];
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function fmtDur(r: BashRun): string {
|
||||
const end = r.ended_at ?? Date.now();
|
||||
const ms = Math.max(0, end - r.started_at);
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60_000) return `${Math.round(ms / 1000)}秒`;
|
||||
return `${Math.floor(ms / 60_000)}分${Math.round((ms % 60_000) / 1000)}秒`;
|
||||
}
|
||||
|
||||
export function BashPanel({ runs, onClose }: Props) {
|
||||
const [filter, setFilter] = useState<Filter>("recent");
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
|
||||
const list = useMemo(() => {
|
||||
const sorted = [...runs].sort((a, b) => b.started_at - a.started_at);
|
||||
if (filter === "all") return sorted;
|
||||
if (filter === "running") return sorted.filter((r) => r.status === "running");
|
||||
if (filter === "done")
|
||||
return sorted.filter((r) => r.status !== "running");
|
||||
return sorted.slice(0, 20);
|
||||
}, [runs, filter]);
|
||||
|
||||
const running = runs.filter((r) => r.status === "running").length;
|
||||
|
||||
return (
|
||||
<div className="work-panel bash-panel">
|
||||
<div className="work-panel-head">
|
||||
<strong>后台 Bash · {running} 运行中</strong>
|
||||
<button type="button" onClick={onClose}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<div className="bash-filters">
|
||||
{(
|
||||
[
|
||||
["recent", "最近"],
|
||||
["running", "进行中"],
|
||||
["done", "已完成"],
|
||||
["all", "全部"],
|
||||
] as const
|
||||
).map(([k, label]) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
className={filter === k ? "active" : ""}
|
||||
onClick={() => setFilter(k)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{list.length === 0 ? (
|
||||
<div className="work-empty">还没有 shell 工具记录。</div>
|
||||
) : (
|
||||
<ul className="bash-list">
|
||||
{list.map((r) => (
|
||||
<li key={r.id} className={`bash-row ${r.status}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="bash-row-main"
|
||||
onClick={() => setOpenId(openId === r.id ? null : r.id)}
|
||||
>
|
||||
<span className="bash-status" aria-hidden>
|
||||
{r.status === "ok"
|
||||
? "✓"
|
||||
: r.status === "running"
|
||||
? "…"
|
||||
: "✕"}
|
||||
</span>
|
||||
<code className="bash-cmd">$ {r.command}</code>
|
||||
<span className="bash-dur">{fmtDur(r)}</span>
|
||||
<span className="bash-chev">{openId === r.id ? "▾" : "▸"}</span>
|
||||
</button>
|
||||
{openId === r.id && r.output ? (
|
||||
<pre className="bash-out">{r.output}</pre>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { AttachmentInfo, Settings, UsageInfo } from "@/lib/bridge";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconPlus,
|
||||
IconSend,
|
||||
IconSparkle,
|
||||
IconStop,
|
||||
} from "@/components/Icons";
|
||||
import bar from "@/dsh/conversation/InputBar.module.css";
|
||||
|
||||
const APPROVALS: {
|
||||
value: Settings["approval"];
|
||||
label: string;
|
||||
desc: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "ask",
|
||||
label: "始终询问",
|
||||
desc: "只读自动;其余操作需确认",
|
||||
},
|
||||
{
|
||||
value: "auto",
|
||||
label: "必要时询问",
|
||||
desc: "常规修改/命令自动;高风险再问",
|
||||
},
|
||||
{
|
||||
value: "yolo",
|
||||
label: "完全自动",
|
||||
desc: "不打断;全部自行完成",
|
||||
},
|
||||
];
|
||||
|
||||
const EFFORTS: { value: string; label: string }[] = [
|
||||
{ value: "", label: "Off" },
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "high", label: "High" },
|
||||
{ value: "max", label: "Max" },
|
||||
];
|
||||
|
||||
export type SlashCommand = {
|
||||
cmd: string;
|
||||
desc: string;
|
||||
run: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
export type MainView = "chat" | "plan" | "bash";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
busy: boolean;
|
||||
settings: Settings;
|
||||
models: string[];
|
||||
attachments: AttachmentInfo[];
|
||||
usage: UsageInfo;
|
||||
slashCommands: SlashCommand[];
|
||||
fileSuggestions: string[];
|
||||
view: MainView;
|
||||
hero?: boolean;
|
||||
sendBlocked?: boolean;
|
||||
sendBlockedReason?: string;
|
||||
onChange: (v: string) => void;
|
||||
onSend: () => void;
|
||||
onStop: () => void;
|
||||
onSettingsPatch: (patch: Partial<Settings>) => void;
|
||||
onAttach: () => void;
|
||||
onRemoveAttach: (id: string) => void;
|
||||
onQueryFiles: (q: string) => void;
|
||||
onInsert: (snippet: string) => void;
|
||||
onView: (v: MainView) => void;
|
||||
dragging?: boolean;
|
||||
};
|
||||
|
||||
export function Composer({
|
||||
value,
|
||||
busy,
|
||||
settings,
|
||||
models,
|
||||
attachments,
|
||||
usage,
|
||||
slashCommands,
|
||||
fileSuggestions,
|
||||
view: _view,
|
||||
hero,
|
||||
sendBlocked,
|
||||
sendBlockedReason,
|
||||
onChange,
|
||||
onSend,
|
||||
onStop,
|
||||
onSettingsPatch,
|
||||
onAttach,
|
||||
onRemoveAttach,
|
||||
onQueryFiles,
|
||||
onInsert,
|
||||
onView: _onView,
|
||||
dragging,
|
||||
}: Props) {
|
||||
void _view;
|
||||
void _onView;
|
||||
const isHero = !!hero;
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
const [menu, setMenu] = useState<"model" | "approval" | null>(null);
|
||||
const [palIdx, setPalIdx] = useState(0);
|
||||
|
||||
const slash = useMemo(() => {
|
||||
const m = value.match(/(?:^|\n)\/([^\s]*)$/);
|
||||
if (!m) return null;
|
||||
const q = m[1].toLowerCase();
|
||||
return slashCommands.filter(
|
||||
(c) => c.cmd.slice(1).startsWith(q) || c.cmd.includes(q),
|
||||
);
|
||||
}, [value, slashCommands]);
|
||||
|
||||
const atQuery = useMemo(() => {
|
||||
const m = value.match(/@([^\s]*)$/);
|
||||
return m ? m[1] : null;
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (atQuery !== null) onQueryFiles(atQuery);
|
||||
}, [atQuery, onQueryFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
setPalIdx(0);
|
||||
}, [slash, atQuery, fileSuggestions]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
const floor = isHero ? 52 : 36;
|
||||
const next = Math.min(220, Math.max(floor, el.scrollHeight + 2));
|
||||
el.style.height = `${next}px`;
|
||||
}, [value, isHero]);
|
||||
|
||||
const replaceTrigger = (re: RegExp, insert: string) => {
|
||||
const next = value.replace(re, insert);
|
||||
onChange(next);
|
||||
onInsert(insert);
|
||||
};
|
||||
|
||||
const approvalLabel =
|
||||
APPROVALS.find((a) => a.value === settings.approval)?.label ??
|
||||
settings.approval;
|
||||
const effort = settings.reasoning_effort ?? "";
|
||||
const effortLabel =
|
||||
EFFORTS.find((e) => e.value === effort)?.label ?? (effort || "Off");
|
||||
|
||||
const blocked = !!sendBlocked && !busy;
|
||||
const canSend =
|
||||
!blocked && !busy && (!!value.trim() || attachments.length > 0);
|
||||
|
||||
return (
|
||||
<div className={`${bar.root}${isHero ? ` ${bar.hero}` : ""}`}>
|
||||
{sendBlockedReason ? (
|
||||
<div className={bar.notice}>{sendBlockedReason}</div>
|
||||
) : null}
|
||||
|
||||
<div className={bar.card}>
|
||||
{dragging ? (
|
||||
<div className="hermit-drop-overlay">Drop files to attach</div>
|
||||
) : null}
|
||||
|
||||
<div className={bar.overlayAnchor}>
|
||||
{slash && slash.length > 0 ? (
|
||||
<div className="hermit-palette" role="listbox">
|
||||
{slash.map((c, i) => (
|
||||
<button
|
||||
key={c.cmd}
|
||||
type="button"
|
||||
className={i === palIdx ? "active" : ""}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
void c.run();
|
||||
onChange(
|
||||
value.replace(/(?:^|\n)\/[^\s]*$/, "").trimStart(),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<span className="pal-cmd">{c.cmd}</span>
|
||||
<span className="pal-desc">{c.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{atQuery !== null && fileSuggestions.length > 0 ? (
|
||||
<div className="hermit-palette" role="listbox">
|
||||
{fileSuggestions.map((p, i) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
className={i === palIdx ? "active" : ""}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
replaceTrigger(/@[^\s]*$/, `\`${p}\` `);
|
||||
}}
|
||||
>
|
||||
<span className="pal-cmd">{p}</span>
|
||||
<span className="pal-desc">Insert path</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{attachments.length > 0 ? (
|
||||
<div className="hermit-attach-rail">
|
||||
{attachments.map((a) => (
|
||||
<div key={a.id} className="hermit-attach-chip" title={a.path}>
|
||||
<span>{a.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove"
|
||||
onClick={() => onRemoveAttach(a.id)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={bar.scroll}>
|
||||
<textarea
|
||||
ref={ref}
|
||||
className="hermit-textarea"
|
||||
rows={2}
|
||||
placeholder={
|
||||
blocked
|
||||
? "Engine required to send…"
|
||||
: isHero
|
||||
? "Ask Hermit to build, fix, or explore…"
|
||||
: "Describe a task, / commands, @ files…"
|
||||
}
|
||||
value={value}
|
||||
disabled={blocked}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
const items =
|
||||
slash && slash.length > 0
|
||||
? slash
|
||||
: atQuery !== null
|
||||
? fileSuggestions
|
||||
: null;
|
||||
if (items && items.length > 0) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setPalIdx((i) => (i + 1) % items.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setPalIdx((i) => (i - 1 + items.length) % items.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
if (slash && slash.length > 0) {
|
||||
const c = slash[palIdx];
|
||||
void c.run();
|
||||
onChange(
|
||||
value.replace(/(?:^|\n)\/[^\s]*$/, "").trimStart(),
|
||||
);
|
||||
} else if (atQuery !== null) {
|
||||
const p = fileSuggestions[palIdx];
|
||||
if (p) replaceTrigger(/@[^\s]*$/, `\`${p}\` `);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onChange(
|
||||
value
|
||||
.replace(/(?:^|\n)\/[^\s]*$/, "")
|
||||
.replace(/@[^\s]*$/, ""),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
!e.shiftKey &&
|
||||
!e.nativeEvent.isComposing
|
||||
) {
|
||||
e.preventDefault();
|
||||
if (canSend) onSend();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={bar.row}>
|
||||
<div className={bar.tools}>
|
||||
<button
|
||||
type="button"
|
||||
className={bar.add}
|
||||
title="Attach"
|
||||
onClick={onAttach}
|
||||
disabled={blocked}
|
||||
>
|
||||
<IconPlus size={14} />
|
||||
</button>
|
||||
<div className={bar.modes}>
|
||||
<div style={{ position: "relative" }}>
|
||||
<button
|
||||
type="button"
|
||||
className="hermit-toolbar-btn"
|
||||
disabled={blocked}
|
||||
onClick={() =>
|
||||
setMenu(menu === "approval" ? null : "approval")
|
||||
}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 99,
|
||||
background: "var(--dsw-alias-state-business-primary)",
|
||||
}}
|
||||
/>
|
||||
{approvalLabel}
|
||||
<IconChevronDown size={12} />
|
||||
</button>
|
||||
{menu === "approval" ? (
|
||||
<div className="hermit-chip-menu">
|
||||
<div className="menu-section">Autonomy</div>
|
||||
{APPROVALS.map((a) => (
|
||||
<button
|
||||
key={a.value}
|
||||
type="button"
|
||||
className={
|
||||
a.value === settings.approval ? "active" : ""
|
||||
}
|
||||
onClick={() => {
|
||||
onSettingsPatch({ approval: a.value });
|
||||
setMenu(null);
|
||||
}}
|
||||
>
|
||||
<strong>{a.label}</strong>
|
||||
<span className="menu-desc">{a.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="hermit-toolbar-btn"
|
||||
disabled={blocked}
|
||||
onClick={() =>
|
||||
onSettingsPatch({ plan_mode: !settings.plan_mode })
|
||||
}
|
||||
>
|
||||
Plan{settings.plan_mode ? " · on" : ""}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={bar.trailing}>
|
||||
<div style={{ position: "relative" }}>
|
||||
<button
|
||||
type="button"
|
||||
className="hermit-toolbar-btn"
|
||||
disabled={blocked}
|
||||
onClick={() => setMenu(menu === "model" ? null : "model")}
|
||||
>
|
||||
<IconSparkle size={12} />
|
||||
<strong>{settings.model}</strong>
|
||||
<span>· {effortLabel}</span>
|
||||
<IconChevronDown size={12} />
|
||||
</button>
|
||||
{menu === "model" ? (
|
||||
<div className="hermit-chip-menu">
|
||||
<div className="menu-section">Models</div>
|
||||
{models.map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
className={m === settings.model ? "active" : ""}
|
||||
onClick={() => onSettingsPatch({ model: m })}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
<div className="menu-section">Thinking</div>
|
||||
<div className="hermit-effort-row">
|
||||
{EFFORTS.map((e) => (
|
||||
<button
|
||||
key={e.label}
|
||||
type="button"
|
||||
className={
|
||||
(settings.reasoning_effort ?? "") === e.value
|
||||
? "active"
|
||||
: ""
|
||||
}
|
||||
onClick={() => {
|
||||
onSettingsPatch({ reasoning_effort: e.value });
|
||||
setMenu(null);
|
||||
}}
|
||||
>
|
||||
{e.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<span
|
||||
className="hermit-toolbar-btn"
|
||||
title="Context usage"
|
||||
style={{ cursor: "default" }}
|
||||
>
|
||||
{usage.pct > 0 ? `${Math.round(usage.pct)}%` : "ctx"}
|
||||
</span>
|
||||
|
||||
{busy ? (
|
||||
<button type="button" className="hermit-send stop" onClick={onStop}>
|
||||
<IconStop size={12} />
|
||||
Stop
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={bar.primary}
|
||||
disabled={!canSend}
|
||||
onClick={onSend}
|
||||
aria-label="Send"
|
||||
>
|
||||
<IconSend size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { HermitMark, type HermitMood } from "@/components/Icons";
|
||||
import hero from "@/dsh/conversation/HeroShell.module.css";
|
||||
|
||||
type Props = {
|
||||
mood?: HermitMood;
|
||||
};
|
||||
|
||||
/**
|
||||
* DSH EmptyHero parity: headline only (mark 34 + title + preview badge).
|
||||
* Workspace chip + composer live on InputBar / ConversationRoot, not here.
|
||||
*/
|
||||
export function Hero({ mood = "idle" }: Props) {
|
||||
return (
|
||||
<div className={hero.root}>
|
||||
<div className={hero.stack}>
|
||||
<div className={hero.headline}>
|
||||
<span className={hero.fishHitbox}>
|
||||
<HermitMark size={34} mood={mood} className={`${hero.fish} hermit-hero-mark`} />
|
||||
</span>
|
||||
<div className={hero.titleGroup}>
|
||||
<span>Meet Hermit</span>
|
||||
<span className={hero.previewBadge}>Preview</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={hero.body} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import type { ReactNode } from "react";
|
||||
import idleShell from "@/assets/hermit/idle-shell.svg?raw";
|
||||
import thinkingShell from "@/assets/hermit/thinking-shell.svg?raw";
|
||||
import codingShell from "@/assets/hermit/coding-shell.svg?raw";
|
||||
import successShell from "@/assets/hermit/success-shell.svg?raw";
|
||||
import errorShell from "@/assets/hermit/error-shell.svg?raw";
|
||||
|
||||
type IconProps = {
|
||||
size?: number;
|
||||
className?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export type HermitMood = "idle" | "thinking" | "coding" | "success" | "error";
|
||||
|
||||
const MOOD_SVG: Record<HermitMood, string> = {
|
||||
idle: idleShell,
|
||||
thinking: thinkingShell,
|
||||
coding: codingShell,
|
||||
success: successShell,
|
||||
error: errorShell,
|
||||
};
|
||||
|
||||
function Svg({
|
||||
size = 16,
|
||||
className,
|
||||
title,
|
||||
children,
|
||||
}: IconProps & { children: ReactNode }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
aria-hidden={title ? undefined : true}
|
||||
role={title ? "img" : undefined}
|
||||
>
|
||||
{title ? <title>{title}</title> : null}
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Designed agent-motion hermit crab (shell + body + eyes). */
|
||||
export function HermitMark({
|
||||
size = 24,
|
||||
className,
|
||||
mood = "idle",
|
||||
}: IconProps & { mood?: HermitMood }) {
|
||||
const h = Math.round(size * (80 / 96));
|
||||
return (
|
||||
<span
|
||||
className={`hermit-mark${className ? ` ${className}` : ""}`}
|
||||
style={{ width: size, height: h, display: "inline-flex" }}
|
||||
aria-hidden
|
||||
dangerouslySetInnerHTML={{ __html: scaleSvg(MOOD_SVG[mood], size, h) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function scaleSvg(raw: string, w: number, h: number): string {
|
||||
// WebKitGTK + NVIDIA: infinite CSS animations inside injected SVGs freeze the
|
||||
// compositor ("没有响应"). Strip <style> so marks stay static glyphs.
|
||||
const stripped = raw.replace(/<style[\s\S]*?<\/style>/gi, "");
|
||||
return stripped.replace(
|
||||
/<svg\b([^>]*)>/,
|
||||
(_m, attrs: string) => {
|
||||
let next = String(attrs)
|
||||
.replace(/\swidth="[^"]*"/g, "")
|
||||
.replace(/\sheight="[^"]*"/g, "");
|
||||
return `<svg${next} width="${w}" height="${h}">`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function IconPlus(p: IconProps) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<path
|
||||
d="M8 3.2v9.6M3.2 8h9.6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSearch(p: IconProps) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<circle cx="7" cy="7" r="4.2" stroke="currentColor" strokeWidth="1.4" />
|
||||
<path
|
||||
d="M10.2 10.2L13.2 13.2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.4"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconFolder(p: IconProps) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<path
|
||||
d="M2.5 5.5h4l1.2 1.5H13.5a1 1 0 011 1v5a1 1 0 01-1 1h-11a1 1 0 01-1-1v-6.5a1 1 0 011-1z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconChevronDown(p: IconProps) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<path
|
||||
d="M4 6.5L8 10.5L12 6.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSend(p: IconProps) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<path
|
||||
d="M8 12.5V3.5M8 3.5L4.5 7M8 3.5L11.5 7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconGear(p: IconProps) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<circle cx="8" cy="8" r="2.2" stroke="currentColor" strokeWidth="1.3" />
|
||||
<path
|
||||
d="M8 2.2v1.4M8 12.4v1.4M2.2 8h1.4M12.4 8h1.4M3.9 3.9l1 1M11.1 11.1l1 1M12.1 3.9l-1 1M4.9 11.1l-1 1"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconPencil(p: IconProps) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<path
|
||||
d="M10.5 2.8l2.7 2.7L5.8 13H3.1v-2.7L10.5 2.8z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconTrash(p: IconProps) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<path
|
||||
d="M3.5 5h9M6 5V3.5h4V5M5 5l.5 8h5l.5-8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconPin(p: IconProps) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<path
|
||||
d="M9.8 2.8l3.4 3.4-2.2.6-2.8 2.8-.8 2.8-2.8-.8 2.8-2.8.6-2.2zM6.2 9.8L3.5 12.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconStop(p: IconProps) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<rect
|
||||
x="4"
|
||||
y="4"
|
||||
width="8"
|
||||
height="8"
|
||||
rx="1.2"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSparkle(p: IconProps) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<path
|
||||
d="M8 2.5l1.1 3.4L12.5 7 9.1 8.1 8 11.5 6.9 8.1 3.5 7l3.4-1.1L8 2.5z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
export type PlanStep = {
|
||||
text: string;
|
||||
status: "pending" | "done" | "running";
|
||||
};
|
||||
|
||||
export function parseTodo(text: string): PlanStep[] {
|
||||
if (!text.trim()) return [];
|
||||
return text
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const m = line.match(/^[-*]\s*\[([ xX~])\]\s*(.*)$/);
|
||||
if (m) {
|
||||
const mark = m[1].toLowerCase();
|
||||
return {
|
||||
text: m[2] || line,
|
||||
status: mark === "x" ? "done" : mark === "~" ? "running" : "pending",
|
||||
} as PlanStep;
|
||||
}
|
||||
if (/^(done|✓|✔)/i.test(line)) {
|
||||
return { text: line.replace(/^(done|✓|✔)\s*/i, ""), status: "done" as const };
|
||||
}
|
||||
if (/^(wip|running|\.\.\.)/i.test(line)) {
|
||||
return {
|
||||
text: line.replace(/^(wip|running|\.\.\.)\s*/i, ""),
|
||||
status: "running" as const,
|
||||
};
|
||||
}
|
||||
return { text: line.replace(/^[-*]\s*/, ""), status: "pending" as const };
|
||||
});
|
||||
}
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
planMode: boolean;
|
||||
onClose: () => void;
|
||||
onTogglePlanMode: () => void;
|
||||
};
|
||||
|
||||
export function PlanPanel({ text, planMode, onClose, onTogglePlanMode }: Props) {
|
||||
const steps = parseTodo(text);
|
||||
return (
|
||||
<div className="work-panel plan-panel">
|
||||
<div className="work-panel-head">
|
||||
<strong>计划</strong>
|
||||
<div className="work-panel-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={planMode ? "active" : ""}
|
||||
onClick={onTogglePlanMode}
|
||||
>
|
||||
Plan mode {planMode ? "on" : "off"}
|
||||
</button>
|
||||
<button type="button" onClick={onClose}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{steps.length === 0 ? (
|
||||
<div className="work-empty">暂无计划步骤。Agent 写入 todo 后会出现在这里。</div>
|
||||
) : (
|
||||
<ol className="plan-steps">
|
||||
{steps.map((s, i) => (
|
||||
<li key={`${i}-${s.text}`} className={`plan-step ${s.status}`}>
|
||||
<span className="plan-mark" aria-hidden>
|
||||
{s.status === "done" ? "✓" : s.status === "running" ? "…" : "○"}
|
||||
</span>
|
||||
<span className="plan-text">{s.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { QuestionRequest } from "@/lib/bridge";
|
||||
import appr from "@/dsh/approval/ApprovalPanel.module.css";
|
||||
|
||||
type Props = {
|
||||
req: QuestionRequest;
|
||||
onAnswer: (answer: string) => void;
|
||||
onSkip: () => void;
|
||||
};
|
||||
|
||||
export function QuestionCard({ req, onAnswer, onSkip }: Props) {
|
||||
const [text, setText] = useState("");
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
rootRef.current?.focus();
|
||||
}, [req.id]);
|
||||
|
||||
return (
|
||||
<div className={appr.root}>
|
||||
<div
|
||||
className={appr.card}
|
||||
ref={rootRef}
|
||||
tabIndex={-1}
|
||||
role="group"
|
||||
aria-label="Question"
|
||||
>
|
||||
<div className={appr.strip}>Waiting for your answer</div>
|
||||
<div className={appr.body}>
|
||||
<div className={appr.headline}>{req.question}</div>
|
||||
{req.options.length > 0 ? (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
|
||||
{req.options.map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
className="hermit-toolbar-btn"
|
||||
onClick={() => onAnswer(opt)}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<input
|
||||
className="question-input"
|
||||
style={{
|
||||
marginTop: 8,
|
||||
height: 32,
|
||||
width: "100%",
|
||||
boxSizing: "border-box",
|
||||
padding: "0 10px",
|
||||
borderRadius: "var(--dsw-radius-sm)",
|
||||
border: "0.5px solid var(--dsw-alias-border-l3)",
|
||||
background: "var(--dsw-alias-bg-layer-1)",
|
||||
color: "var(--dsw-alias-label-primary)",
|
||||
font: "inherit",
|
||||
}}
|
||||
value={text}
|
||||
placeholder={
|
||||
req.options.length > 0
|
||||
? "Or type a custom answer…"
|
||||
: "Type your answer…"
|
||||
}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && text.trim()) onAnswer(text.trim());
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={appr.actionRow}>
|
||||
<button type="button" className="hermit-toolbar-btn" onClick={onSkip}>
|
||||
Skip
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="hermit-send"
|
||||
style={{ width: "auto", padding: "0 14px", borderRadius: 8 }}
|
||||
disabled={!text.trim() && req.options.length === 0}
|
||||
onClick={() => onAnswer(text.trim())}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { SessionInfo } from "@/lib/bridge";
|
||||
import {
|
||||
HermitMark,
|
||||
IconFolder,
|
||||
IconGear,
|
||||
IconPencil,
|
||||
IconPin,
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
IconTrash,
|
||||
} from "@/components/Icons";
|
||||
import browser from "@/dsh/workspace/WorkspaceBrowser.module.css";
|
||||
import rows from "@/dsh/workspace/Rows.module.css";
|
||||
import sidebar from "@/dsh/workspace/SidebarRoot.module.css";
|
||||
|
||||
type Props = {
|
||||
sessions: SessionInfo[];
|
||||
activeId: string | null;
|
||||
workspace: string;
|
||||
query: string;
|
||||
engine: string;
|
||||
onQuery: (q: string) => void;
|
||||
onNew: () => void;
|
||||
onOpen: (id: string) => void;
|
||||
onRename: (id: string, title: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onPin?: (id: string) => void;
|
||||
onSetFolder?: (id: string, folder: string | null) => void;
|
||||
onSettings: () => void;
|
||||
onPickWorkspace: () => void;
|
||||
};
|
||||
|
||||
function normalizeTs(ts: number): number {
|
||||
if (ts > 0 && ts < 1e12) return ts * 1000;
|
||||
return ts;
|
||||
}
|
||||
|
||||
function relTime(ts: number): string {
|
||||
const t = normalizeTs(ts);
|
||||
const d = Date.now() - t;
|
||||
if (d < 0 || Number.isNaN(d)) return "";
|
||||
if (d < 60_000) return "just now";
|
||||
if (d < 3600_000) return `${Math.floor(d / 60_000)}m`;
|
||||
if (d < 86400_000) return `${Math.floor(d / 3600_000)}h`;
|
||||
if (d < 7 * 86400_000) return `${Math.floor(d / 86400_000)}d`;
|
||||
return new Date(t).toLocaleDateString();
|
||||
}
|
||||
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
function sessionLabel(s: SessionInfo): string {
|
||||
const t = (s.title || "").trim();
|
||||
if (!t || UUID_RE.test(t) || t === s.id) return "New session";
|
||||
return t;
|
||||
}
|
||||
|
||||
export function Rail({
|
||||
sessions,
|
||||
activeId,
|
||||
workspace,
|
||||
query,
|
||||
engine,
|
||||
onQuery,
|
||||
onNew,
|
||||
onOpen,
|
||||
onRename,
|
||||
onDelete,
|
||||
onPin,
|
||||
onSetFolder,
|
||||
onSettings,
|
||||
onPickWorkspace,
|
||||
}: Props) {
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const filtered = sessions.filter(
|
||||
(s) =>
|
||||
!query ||
|
||||
sessionLabel(s).toLowerCase().includes(query.toLowerCase()) ||
|
||||
s.title.toLowerCase().includes(query.toLowerCase()),
|
||||
);
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, SessionInfo[]>();
|
||||
for (const s of filtered) {
|
||||
const key = s.folder?.trim() || "";
|
||||
const list = map.get(key) ?? [];
|
||||
list.push(s);
|
||||
map.set(key, list);
|
||||
}
|
||||
return [...map.entries()].sort(([a], [b]) => {
|
||||
if (!a) return 1;
|
||||
if (!b) return -1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}, [filtered]);
|
||||
|
||||
const wsLabel =
|
||||
workspace === "." || !workspace
|
||||
? "workspace"
|
||||
: workspace.split(/[/\\]/).filter(Boolean).pop() || workspace;
|
||||
|
||||
useEffect(() => {
|
||||
if (searchOpen) searchRef.current?.focus();
|
||||
}, [searchOpen]);
|
||||
|
||||
return (
|
||||
<aside className={sidebar.root}>
|
||||
<div className={sidebar.logoRow}>
|
||||
<button type="button" className={sidebar.brand} onClick={onNew}>
|
||||
<span className={sidebar.brandIdentity}>
|
||||
<span className={sidebar.brandMark}>
|
||||
<HermitMark size={22} />
|
||||
</span>
|
||||
<span className={sidebar.brandName}>
|
||||
<span className={sidebar.fallbackBrandName}>Hermit</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={sidebar.iconButton}
|
||||
aria-label="Settings"
|
||||
title={`engine · ${engine}`}
|
||||
onClick={onSettings}
|
||||
>
|
||||
<IconGear size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button type="button" className={sidebar.newSession} onClick={onNew}>
|
||||
<span className={sidebar.newSessionContent}>
|
||||
<IconPlus size={16} />
|
||||
<span className={sidebar.newSessionLabel}>New session</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className={browser.root}>
|
||||
<div className={browser.sectionHeader}>
|
||||
<button
|
||||
type="button"
|
||||
className={browser.iconButton}
|
||||
title={workspace}
|
||||
onClick={onPickWorkspace}
|
||||
>
|
||||
<IconFolder size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={browser.iconButton}
|
||||
aria-label="Search"
|
||||
onClick={() => setSearchOpen((v) => !v)}
|
||||
>
|
||||
<IconSearch size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{searchOpen ? (
|
||||
<input
|
||||
ref={searchRef}
|
||||
className={browser.searchInput}
|
||||
value={query}
|
||||
placeholder="Search sessions"
|
||||
onChange={(e) => onQuery(e.target.value)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={rows.projectRow}
|
||||
title={workspace}
|
||||
onClick={onPickWorkspace}
|
||||
>
|
||||
<span className={rows.slot}>
|
||||
<IconFolder size={16} />
|
||||
</span>
|
||||
<span className={rows.title}>{wsLabel}</span>
|
||||
<span className={rows.time}>{engine}</span>
|
||||
</button>
|
||||
|
||||
<div className={browser.listArea}>
|
||||
<div className={browser.list}>
|
||||
{groups.map(([folder, list]) => {
|
||||
const key = folder || "__ungrouped";
|
||||
const isCollapsed = !!collapsed[key];
|
||||
return (
|
||||
<div key={key} className={browser.groupSection}>
|
||||
{folder ? (
|
||||
<button
|
||||
type="button"
|
||||
className={rows.projectRow}
|
||||
onClick={() =>
|
||||
setCollapsed((c) => ({ ...c, [key]: !c[key] }))
|
||||
}
|
||||
>
|
||||
<span className={rows.title}>{folder}</span>
|
||||
<span className={rows.time}>
|
||||
{isCollapsed ? "▸" : "▾"}
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
{!isCollapsed
|
||||
? list.map((s) => {
|
||||
const selected = s.id === activeId;
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
className={`${rows.sessionRow}${selected ? ` ${rows.selected}` : ""}`}
|
||||
onClick={() => onOpen(s.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onOpen(s.id);
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className={rows.title}>{sessionLabel(s)}</span>
|
||||
<span className={rows.rowResting}>
|
||||
{s.pinned ? (
|
||||
<span className={rows.pinIndicator}>📌</span>
|
||||
) : null}
|
||||
<span className={rows.time}>
|
||||
{relTime(s.updated_at)}
|
||||
</span>
|
||||
</span>
|
||||
<div
|
||||
className={`${rows.rowActions} hermit-row-actions`}
|
||||
>
|
||||
{onPin ? (
|
||||
<button
|
||||
type="button"
|
||||
className={browser.iconButton}
|
||||
title="Pin"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPin(s.id);
|
||||
}}
|
||||
>
|
||||
<IconPin size={14} />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={browser.iconButton}
|
||||
title="Rename"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const title = window.prompt(
|
||||
"Rename",
|
||||
sessionLabel(s),
|
||||
);
|
||||
if (title?.trim())
|
||||
onRename(s.id, title.trim());
|
||||
}}
|
||||
>
|
||||
<IconPencil size={14} />
|
||||
</button>
|
||||
{onSetFolder ? (
|
||||
<button
|
||||
type="button"
|
||||
className={browser.iconButton}
|
||||
title="Folder"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const next = window.prompt(
|
||||
"Folder (empty to clear)",
|
||||
s.folder ?? "",
|
||||
);
|
||||
if (next === null) return;
|
||||
onSetFolder(s.id, next.trim() || null);
|
||||
}}
|
||||
>
|
||||
<IconFolder size={14} />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={browser.iconButton}
|
||||
title="Delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(s.id);
|
||||
}}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { HermitMark, type HermitMood } from "@/components/Icons";
|
||||
import conv from "@/dsh/conversation/ConversationRoot.module.css";
|
||||
|
||||
type Props = {
|
||||
workspaceLabel: string;
|
||||
folder?: string | null;
|
||||
title: string;
|
||||
mood: HermitMood;
|
||||
status?: string;
|
||||
engine: string;
|
||||
};
|
||||
|
||||
export function SessionHeader({
|
||||
workspaceLabel,
|
||||
folder,
|
||||
title,
|
||||
mood,
|
||||
status,
|
||||
engine,
|
||||
}: Props) {
|
||||
return (
|
||||
<header className={conv.header}>
|
||||
<div className={conv.titleRow}>
|
||||
<HermitMark size={18} mood={mood} />
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
color: "var(--dsw-alias-label-primary)",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
<span className="hermit-engine-chip" data-mode={engine}>
|
||||
{engine}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--dsw-alias-label-tertiary)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{workspaceLabel}
|
||||
{folder ? ` / ${folder}` : ""}
|
||||
{status ? ` · ${status}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { useState } from "react";
|
||||
import type { ProviderInfo, Settings } from "@/lib/bridge";
|
||||
|
||||
type Tab = "general" | "model" | "agent" | "workspace" | "about";
|
||||
|
||||
type Props = {
|
||||
settings: Settings;
|
||||
providers: ProviderInfo[];
|
||||
engine: string;
|
||||
onSave: (s: Settings) => void;
|
||||
onUseProvider: (provider: string, model: string, key?: string) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function SettingsPanel({
|
||||
settings,
|
||||
providers,
|
||||
engine,
|
||||
onSave,
|
||||
onUseProvider,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const [tab, setTab] = useState<Tab>("general");
|
||||
const [draft, setDraft] = useState<Settings>({
|
||||
reasoning_effort: "high",
|
||||
font_size: "m",
|
||||
language: "zh",
|
||||
native_titlebar: false,
|
||||
...settings,
|
||||
});
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: "general", label: "通用" },
|
||||
{ id: "model", label: "模型" },
|
||||
{ id: "agent", label: "智能体" },
|
||||
{ id: "workspace", label: "工作区" },
|
||||
{ id: "about", label: "关于" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="settings-scrim" onClick={onClose}>
|
||||
<div
|
||||
className="settings-card"
|
||||
style={{ width: "min(720px, calc(100% - 48px))", display: "flex", gap: 16 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="settings-nav">
|
||||
<h3>设置</h3>
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
className={tab === t.id ? "active" : ""}
|
||||
onClick={() => setTab(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="settings-body">
|
||||
{tab === "general" ? (
|
||||
<>
|
||||
<label className="field">
|
||||
<span>字号</span>
|
||||
<div className="seg">
|
||||
{(["s", "m", "l", "xl"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
className={draft.font_size === f ? "active" : ""}
|
||||
onClick={() => setDraft({ ...draft, font_size: f })}
|
||||
>
|
||||
{f.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>语言</span>
|
||||
<div className="seg">
|
||||
<button
|
||||
type="button"
|
||||
className={draft.language === "zh" ? "active" : ""}
|
||||
onClick={() => setDraft({ ...draft, language: "zh" })}
|
||||
>
|
||||
简体中文
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={draft.language === "en" ? "active" : ""}
|
||||
onClick={() => setDraft({ ...draft, language: "en" })}
|
||||
>
|
||||
English
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<label className="field field-row">
|
||||
<span>使用系统标题栏</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!draft.native_titlebar}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, native_titlebar: e.target.checked })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{tab === "model" ? (
|
||||
<>
|
||||
<label className="field">
|
||||
<span>Provider</span>
|
||||
<select
|
||||
value={draft.provider}
|
||||
onChange={(e) => setDraft({ ...draft, provider: e.target.value })}
|
||||
>
|
||||
{providers.map((p) => (
|
||||
<option key={p.name} value={p.name}>
|
||||
{p.name}
|
||||
{p.has_key ? "" : " (no key)"}
|
||||
</option>
|
||||
))}
|
||||
{!providers.find((p) => p.name === draft.provider) ? (
|
||||
<option value={draft.provider}>{draft.provider}</option>
|
||||
) : null}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Model</span>
|
||||
<input
|
||||
value={draft.model}
|
||||
onChange={(e) => setDraft({ ...draft, model: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Thinking / effort</span>
|
||||
<select
|
||||
value={draft.reasoning_effort ?? ""}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, reasoning_effort: e.target.value })
|
||||
}
|
||||
>
|
||||
<option value="">Off</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="max">Max</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>API key</span>
|
||||
<input
|
||||
type="password"
|
||||
value={draft.api_key ?? ""}
|
||||
placeholder="sk-…"
|
||||
onChange={(e) => setDraft({ ...draft, api_key: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{tab === "agent" ? (
|
||||
<>
|
||||
<label className="field">
|
||||
<span>Approval</span>
|
||||
<select
|
||||
value={draft.approval}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
approval: e.target.value as Settings["approval"],
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="ask">始终询问 (ask)</option>
|
||||
<option value="auto">必要时询问 (auto)</option>
|
||||
<option value="yolo">完全自动 (yolo)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field field-row">
|
||||
<span>Plan mode 默认</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!draft.plan_mode}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, plan_mode: e.target.checked })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{tab === "workspace" ? (
|
||||
<label className="field">
|
||||
<span>Workspace</span>
|
||||
<input
|
||||
value={draft.workspace}
|
||||
onChange={(e) => setDraft({ ...draft, workspace: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{tab === "about" ? (
|
||||
<div className="about-block">
|
||||
<p>
|
||||
<strong>Hermit</strong> desktop 1.1.0
|
||||
</p>
|
||||
<p>
|
||||
Engine: <code>{engine}</code>
|
||||
</p>
|
||||
<p className="muted">暗色色卡 · 本地 agent 工作台</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="modal-actions">
|
||||
<button type="button" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
{tab !== "about" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="primary"
|
||||
onClick={() => {
|
||||
onUseProvider(draft.provider, draft.model, draft.api_key);
|
||||
onSave(draft);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="primary" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ChatItem } from "@/lib/bridge";
|
||||
import { isShellTool } from "@/lib/bridge";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import chat from "@/dsh/chat/ChatView.module.css";
|
||||
import msg from "@/dsh/chat/MessageItem.module.css";
|
||||
import md from "@/dsh/chat/AssistantMarkdown.module.css";
|
||||
import reason from "@/dsh/chat/ReasoningRow.module.css";
|
||||
import tool from "@/dsh/chat/GenericCommandCard.module.css";
|
||||
|
||||
type Props = { items: ChatItem[]; busy: boolean };
|
||||
|
||||
function fmtTs(ts?: number): string {
|
||||
if (!ts) return "";
|
||||
const d = new Date(ts);
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
export function Transcript({ items, busy }: Props) {
|
||||
const scroller = useRef<HTMLDivElement>(null);
|
||||
const [follow, setFollow] = useState(true);
|
||||
const [showJump, setShowJump] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!follow) return;
|
||||
const el = scroller.current;
|
||||
if (!el) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}, [items, busy, follow]);
|
||||
|
||||
return (
|
||||
<div className={chat.frame}>
|
||||
<div className={chat.root} data-chat-following-tail={follow || undefined}>
|
||||
<div
|
||||
className={chat.scroll}
|
||||
ref={scroller}
|
||||
onScroll={() => {
|
||||
const el = scroller.current;
|
||||
if (!el) return;
|
||||
const atBottom =
|
||||
el.scrollHeight - el.scrollTop - el.clientHeight < 48;
|
||||
setFollow(atBottom);
|
||||
setShowJump(!atBottom);
|
||||
}}
|
||||
>
|
||||
<div className={chat.column}>
|
||||
{items.map((it) => {
|
||||
if (it.kind === "user") {
|
||||
return (
|
||||
<div className={msg.userRow} key={it.id}>
|
||||
<div className={msg.userStack}>
|
||||
<div className="hermit-user-bubble">{it.text}</div>
|
||||
{it.ts ? (
|
||||
<div className={msg.referenceSummary}>{fmtTs(it.ts)}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (it.kind === "assistant") {
|
||||
return (
|
||||
<div className={md.root} key={it.id}>
|
||||
<div className={md.body}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{it.text}
|
||||
</ReactMarkdown>
|
||||
{busy && items[items.length - 1]?.id === it.id ? (
|
||||
<span className="hermit-cursor" aria-hidden />
|
||||
) : null}
|
||||
</div>
|
||||
<div className={md.actions}>
|
||||
{it.ts ? <span>{fmtTs(it.ts)}</span> : null}
|
||||
<button
|
||||
type="button"
|
||||
className="hermit-toolbar-btn"
|
||||
onClick={() =>
|
||||
void navigator.clipboard?.writeText(it.text)
|
||||
}
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (it.kind === "thought") {
|
||||
return (
|
||||
<details
|
||||
className={reason.root}
|
||||
key={it.id}
|
||||
open={busy}
|
||||
data-expanded={busy || undefined}
|
||||
data-preview
|
||||
>
|
||||
<summary className={reason.row}>
|
||||
<span className={reason.leading}>✦</span>
|
||||
<span className={reason.title}>Thought</span>
|
||||
<span className={reason.separator} />
|
||||
<span className={reason.summary}>
|
||||
<span className={reason.summaryText}>
|
||||
{firstLine(it.text)}
|
||||
</span>
|
||||
</span>
|
||||
</summary>
|
||||
<div className={reason.thinkBody}>
|
||||
<pre style={{ margin: 0, whiteSpace: "pre-wrap" }}>
|
||||
{it.text}
|
||||
</pre>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
return <ToolCard key={it.id} item={it} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showJump ? (
|
||||
<button
|
||||
type="button"
|
||||
className="hermit-toolbar-btn"
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 16,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
background: "var(--dsw-alias-bg-layer-2)",
|
||||
boxShadow: "var(--dsw-elevation-soft)",
|
||||
}}
|
||||
onClick={() => {
|
||||
setFollow(true);
|
||||
setShowJump(false);
|
||||
const el = scroller.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}}
|
||||
>
|
||||
Jump to latest
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function firstLine(s: string) {
|
||||
return s.split("\n").find((l) => l.trim())?.slice(0, 80) ?? "";
|
||||
}
|
||||
|
||||
function ToolCard({ item }: { item: Extract<ChatItem, { kind: "tool" }> }) {
|
||||
const shell = isShellTool(item.name);
|
||||
return (
|
||||
<details
|
||||
className={tool.root}
|
||||
open={item.status === "running"}
|
||||
data-error={
|
||||
item.status === "error" || item.status === "denied" || undefined
|
||||
}
|
||||
>
|
||||
<summary
|
||||
style={{ display: "flex", alignItems: "center", cursor: "pointer" }}
|
||||
>
|
||||
<span className={tool.leading}>{shell ? "$" : "⚙"}</span>
|
||||
<span className={tool.title}>{item.name}</span>
|
||||
<span className={tool.separator} />
|
||||
<span
|
||||
className={tool.summary}
|
||||
data-error={
|
||||
item.status === "error" || item.status === "denied" || undefined
|
||||
}
|
||||
>
|
||||
{item.detail || item.status}
|
||||
</span>
|
||||
</summary>
|
||||
{(item.output || item.detail) && (
|
||||
<pre
|
||||
className={tool.body}
|
||||
data-error={
|
||||
item.status === "error" || item.status === "denied" || undefined
|
||||
}
|
||||
>
|
||||
{item.output || item.detail}
|
||||
</pre>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconFolder,
|
||||
} from "@/components/Icons";
|
||||
import hero from "@/dsh/conversation/HeroShell.module.css";
|
||||
|
||||
type Props = {
|
||||
workspace: string;
|
||||
onPick: () => void;
|
||||
};
|
||||
|
||||
/** DSH EmptyHero WorkspaceChip — folder + label + chevron. */
|
||||
export function WorkspaceChip({ workspace, onPick }: Props) {
|
||||
const has =
|
||||
workspace !== "." && workspace !== "" && workspace.trim().length > 0;
|
||||
const label = has
|
||||
? workspace.split(/[/\\]/).filter(Boolean).pop() || workspace
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={hero.workspace}
|
||||
title={has ? workspace : undefined}
|
||||
aria-label="Choose workspace"
|
||||
onClick={onPick}
|
||||
>
|
||||
<IconFolder size={16} className={hero.folder} />
|
||||
<span className={hero.workspaceLabel}>
|
||||
{label ?? "Choose workspace"}
|
||||
</span>
|
||||
<IconChevronDown size={12} className={hero.chevron} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 8px calc(var(--dsh-composer-side-clearance) + 16px) 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
max-width: var(--dsh-chat-content-width);
|
||||
border: 1px solid var(--dsw-alias-state-warn-secondary);
|
||||
border-radius: var(--dsw-radius-xl);
|
||||
background: var(--dsw-specific-input-major);
|
||||
box-shadow: var(--dsw-shadow-lv2);
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
.strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: var(--dsw-alias-state-warn-tertiary);
|
||||
color: var(--dsw-alias-state-warn-primary);
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
box-sizing: border-box;
|
||||
max-height: var(--dsh-composer-text-max-height);
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px 0;
|
||||
}
|
||||
|
||||
.headline {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.command {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.actionRow {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.reject:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover-danger);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
--dsh-button-outline-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.subject {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.subject img {
|
||||
flex-shrink: 0;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.withSubject .body { text-align: left; }
|
||||
.withSubject .actionRow { flex-wrap: wrap; }
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,78 @@
|
||||
/* Assistant flow body: full-width narration (figma 16/28 scaled 0.875 → 14/24
|
||||
with the markdown token ladder), block gap 16. The Settings font-size
|
||||
preference rides the body-published axis: size follows
|
||||
--dsh-content-font-size and the line height keeps the 10px leading via the
|
||||
shared px delta. IconActions sit below the body with an explicit 16px top
|
||||
margin (figma 43:32997) — separate from the body's internal gap so the
|
||||
footer spacing stays fixed when the body is a single block. */
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: var(--dsh-content-font-size, 14px);
|
||||
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Wide markdown tables (the renderer's ≥4-column md-table-wide hook) span
|
||||
the whole transcript width instead of clipping at the 748px message
|
||||
column, while the table content keeps starting at its normal x (the lead
|
||||
padding compensates the negative margin) — deepsuite chat TableWrapper
|
||||
parity, with 100cqw (the ChatView scroll container) standing in for its
|
||||
JS-measured --dsl-virtual-list-width. `--dsh-table-spare` clamps to zero
|
||||
when the transcript is narrower than the message column, so narrow
|
||||
viewports keep the plain in-column scroll with no sideways shift.
|
||||
Percentages resolve against the wrapper's containing block, so a table
|
||||
indented inside a list still reaches the same transcript edges. */
|
||||
.body :global(.md-table-wide) {
|
||||
--dsh-table-spare: max(0px, calc((100cqw - var(--dsh-chat-content-width)) / 2));
|
||||
--dsh-table-lead: calc(var(--dsh-table-spare) + min(var(--dsh-chat-content-width), 100cqw) - 100%);
|
||||
box-sizing: border-box;
|
||||
width: calc(100% + var(--dsh-table-lead) + var(--dsh-table-spare));
|
||||
/* The base .tableScroll caps at the column (max-width: 100%); the breakout
|
||||
is exactly the case that must exceed it. */
|
||||
max-width: none;
|
||||
margin-left: calc(-1 * var(--dsh-table-lead));
|
||||
padding-left: var(--dsh-table-lead);
|
||||
}
|
||||
|
||||
/* Only the painted table outranks Conversation's z-index 0 width handle. The
|
||||
full-width scroll wrapper and its transparent alignment padding stay below,
|
||||
so a fitting four-column table does not disable the surrounding gutter. */
|
||||
.body :global(.md-table-wide) > table {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* hidden="until-found" keeps a zero-height reasoning box in flex layout.
|
||||
Cancel the one gap that box would otherwise leave before the visible reply;
|
||||
visible Assistant blocks retain the ordinary 16px rhythm. */
|
||||
.body > [data-turn-process-inline][hidden] {
|
||||
margin-bottom: -16px;
|
||||
}
|
||||
|
||||
/* Interrupted-turn terminal marker: quiet inline tag, no animation. Fixed
|
||||
size like the small/code token variants — 11px is dense secondary text
|
||||
that would fall to an illegible 9px at the 12px floor. */
|
||||
.stopped {
|
||||
align-self: flex-start;
|
||||
padding: 0 6px;
|
||||
border-radius: var(--dsw-radius-sm);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
/* Finalized footer offset (figma 43:32997); chrome lives in MessageIconActions. */
|
||||
.actions {
|
||||
margin-top: 16px;
|
||||
/* Optical align with the 28px action hit targets. */
|
||||
margin-left: -6px;
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
.frame {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
/* Floating controls query this frame outside the clipped transcript. */
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
|
||||
overflow-x: visible;
|
||||
overflow-y: clip;
|
||||
}
|
||||
|
||||
.scroll {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
/* Never expose horizontal scrollable overflow: the absolutely positioned
|
||||
text-interaction surface adds it transiently, and the changing scroll range
|
||||
makes the transcript jitter while the tail is pinned. */
|
||||
overflow-x: clip;
|
||||
overflow-y: auto;
|
||||
/* Sides = composer clearance + 16px: on narrow viewports the transcript
|
||||
stays exactly 32px narrower than the input card (the shared width rule). */
|
||||
padding: 16px calc(var(--dsh-composer-side-clearance) + 16px);
|
||||
/* Inline-size query container: wide markdown tables size their breakout
|
||||
against this box's content width (100cqw in AssistantMarkdown's
|
||||
md-table-wide rule) — the CSS stand-in for deepsuite chat's JS-measured
|
||||
--dsl-virtual-list-width. */
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.root[data-chat-following-tail] .scroll,
|
||||
:global([data-conversation-scroll]):has(.root[data-chat-following-tail]) {
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
:global([data-conversation-scroll]) .frame,
|
||||
:global([data-conversation-scroll]) .root {
|
||||
flex: 0 0 auto;
|
||||
min-height: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
:global([data-conversation-scroll]) .scroll {
|
||||
/* A host scrollport still owns vertical overflow; horizontal overflow is
|
||||
clipped for the same reason as the self-scrolled case above. */
|
||||
overflow-x: clip;
|
||||
overflow-y: visible;
|
||||
flex: 0 0 auto;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
/* Message column: shared chat width (ConversationRoot --dsh-chat-content-width),
|
||||
centered on the same axis as the input box (which caps at chat + 16px); the
|
||||
scroller itself stays full-bleed. */
|
||||
.column {
|
||||
position: relative;
|
||||
max-width: var(--dsh-chat-content-width);
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* The flow slot's display:contents anchor preserves layout but not selector ancestry.
|
||||
`hidden="until-found"` retains a searchable zero-height box; hidden and empty
|
||||
Seats do not contribute spacing. The older-history control precedes the anchor;
|
||||
an ancestor :has() here also invalidates the history on unrelated descendant edits. */
|
||||
:is(.column, :global([data-slot="conversation.chat.flow"])) > :not([hidden]):not(.textInteraction):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty)))
|
||||
~ :not([hidden]):not(.textInteraction):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))),
|
||||
.column > .older + :global([data-slot="conversation.chat.flow"])
|
||||
> :not([hidden]):not(.textInteraction):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))) {
|
||||
margin-top: var(--dsh-chat-flow-gap, 4px);
|
||||
}
|
||||
|
||||
/* Process rows sit 4px apart; an Assistant response is separated from its
|
||||
neighbours by 12px. */
|
||||
.flowItem[data-chat-group-part="response"] {
|
||||
--dsh-chat-flow-gap: 12px;
|
||||
}
|
||||
|
||||
:is(.column, :global([data-slot="conversation.chat.flow"])) > [data-chat-group-part="response"]:not([hidden]):not(:empty)
|
||||
+ :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))):not(.running):not(:has(> [data-slot="conversation.chat.node"] > [data-chat-steering-batch])) {
|
||||
--dsh-chat-flow-gap: 12px;
|
||||
}
|
||||
|
||||
/* Settled-flow identity boundary. It is neutral until it becomes the natural
|
||||
measurement/mount unit for a virtualizer without changing the column gap. */
|
||||
.flowItem {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Bottom room: sized by the viewport owner while rows above the reader close,
|
||||
given back only as later content grows into it. */
|
||||
.turnSpacer {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* While a Turn folds, spacing changes share the closing rows' timing. */
|
||||
.column[data-chat-motion] > :not([data-chat-motion]),
|
||||
.column[data-chat-motion] > :global([data-slot="conversation.chat.flow"]) > :not([data-chat-motion]) {
|
||||
transition: margin-top 160ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.column[data-input-fold] > :not([data-chat-motion]),
|
||||
.column[data-input-fold] > :global([data-slot="conversation.chat.flow"]) > :not([data-chat-motion]) {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* A Turn's duration control renders empty until the Turn closes; its gap appears with it in the same
|
||||
frame, so the layout below changes once instead of sliding after the control's own height. */
|
||||
.column[data-chat-motion] > [data-chat-flow-kind="turn-process"],
|
||||
.column[data-chat-motion] > :global([data-slot="conversation.chat.flow"]) > [data-chat-flow-kind="turn-process"] {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.column[data-chat-motion] > :not([data-chat-motion]),
|
||||
.column[data-chat-motion] > :global([data-slot="conversation.chat.flow"]) > :not([data-chat-motion]) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* The Turn header has 16px clearance from its input and following content. A
|
||||
running Turn's control renders empty until the Turn closes; an echo after it
|
||||
is still an opening input of that Turn, so it keeps the input gap. */
|
||||
.flowItem[data-chat-flow-kind="turn-process"],
|
||||
:is(.column, :global([data-slot="conversation.chat.flow"])) > [data-chat-flow-kind="turn-process"]:not(:empty, :has(> [data-slot="conversation.chat.node"]:empty)) + :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))):not(.running):not(:has(> [data-slot="conversation.chat.node"] > [data-chat-steering-batch])),
|
||||
:is(.column, :global([data-slot="conversation.chat.flow"])) > [data-chat-flow-kind="turn-process"] + :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))):not(.running):not([data-submission-echo], [data-pending-steering]):not(:has(> [data-slot="conversation.chat.node"] > [data-chat-steering-batch])) {
|
||||
--dsh-chat-flow-gap: 16px;
|
||||
}
|
||||
|
||||
/* Tail slot: the running status and a completed Turn's actions row both take
|
||||
a 16px offset plus a 28px row. The steering renderer marks the first
|
||||
steering row of each claim that Chat orders after the Turn's process
|
||||
control; that row keeps the slot above it, the way a completed Turn's
|
||||
actions row precedes the next user message, while rows claimed together
|
||||
stay 4px apart and opening inputs keep ordinary spacing. The first waiting
|
||||
echo sits directly below the running status, so admission swaps the two
|
||||
without moving any bubble. Neighbour gap rules exclude the batch start so
|
||||
this slot always applies. */
|
||||
:is(.column, :global([data-slot="conversation.chat.flow"])) > .flowItem:has(> [data-slot="conversation.chat.node"] > [data-chat-steering-batch="start"]) {
|
||||
--dsh-chat-flow-gap: calc(44px + var(--dsh-content-font-delta, 0px));
|
||||
}
|
||||
|
||||
.running + [data-pending-steering] {
|
||||
--dsh-chat-flow-gap: 0px;
|
||||
}
|
||||
|
||||
/* A completed Turn's actions row owns the tail slot; the status can trail it
|
||||
for one frame before the Session stops running, or between queued Turns. */
|
||||
.flowItem[data-chat-flow-kind="turn-tail"] + .running {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Steering rows keep their full layout from insertion. Only the status moves:
|
||||
it enters its new slot below them from one slot higher, independent of how
|
||||
many admitted rows it passes or how tall they are. The start therefore sits
|
||||
below the status's previous position by the admitted rows' height; the
|
||||
fade-in covers that difference, so this entrance approximates the move. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
:where(.column > :global([data-slot="conversation.chat.flow"]) > .flowItem[data-chat-flow-kind="steering"]) + .running {
|
||||
animation: running-enters-slot 140ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
/* An input fold runs the entrance at zero duration. The switch only changes
|
||||
the duration of an entrance its structural change already created, as if
|
||||
the entrance had it from its start: lifting the switch never replays an
|
||||
entrance older than its duration, and setting it completes a running one.
|
||||
The structural rule is wrapped in :where() so this rule outranks it. */
|
||||
.column[data-input-fold] .running {
|
||||
animation-duration: 0s;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes running-enters-slot {
|
||||
from {
|
||||
opacity: 0;
|
||||
translate: 0 calc(-44px - var(--dsh-content-font-delta, 0px));
|
||||
}
|
||||
40% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Empty Seats, including retained empty slot anchors, preserve Turn lookup
|
||||
without contributing height or the sibling gap. */
|
||||
.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty)) {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.callRow {
|
||||
border-radius: var(--dsw-radius-sm);
|
||||
}
|
||||
|
||||
/* Selection still sets data-selected for details linkage; no outline —
|
||||
tool rows match Think chrome (no selected ring). */
|
||||
|
||||
.hint {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: var(--dsh-content-font-size-secondary, 13px);
|
||||
line-height: calc(18px + var(--dsh-content-font-delta-secondary, 0px));
|
||||
}
|
||||
|
||||
.running {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
--dsh-chat-flow-gap: 12px;
|
||||
--dsw-alias-label-shimmer: var(--dsw-alias-label-deep-diving-shimmer);
|
||||
color: var(--dsw-alias-label-deep-diving);
|
||||
font-size: calc(var(--dsh-content-font-size, 14px) - 2px);
|
||||
line-height: calc(22px + var(--dsh-content-font-delta, 0px));
|
||||
}
|
||||
|
||||
/* After the Session stops running and before the Turn's actions row arrives, the status keeps its slot
|
||||
unseen so the tail does not shrink for that interval. */
|
||||
.running[data-holding] {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* The last visible row before the status must contain output, not an input.
|
||||
Hidden and empty seats do not separate a user message from the status, and
|
||||
the steering/Inbox echoes after the status are outside the count. */
|
||||
:is(.column, :global([data-slot="conversation.chat.flow"])) > :nth-last-child(2 of :not([hidden]):not([data-pending-steering]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty)))):is(
|
||||
.flowItem:not([data-chat-flow-kind="user"]):not([data-chat-flow-kind="steering"]):not([data-chat-flow-kind="turn-trigger"]),
|
||||
[data-chat-group-key]
|
||||
) ~ .running > .runningDivider {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Drawn inside the 16px offset above the status row, so showing or hiding it
|
||||
never changes the tail slot's height. */
|
||||
.runningDivider {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 0.5px;
|
||||
background: color-mix(in srgb, var(--dsw-alias-border-l1) 75%, var(--dsw-alias-border-l2));
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.runningDivider {
|
||||
transition: opacity 160ms ease;
|
||||
}
|
||||
}
|
||||
|
||||
/* Same offset and height as the completed Turn's tail actions row. */
|
||||
.runningContent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
height: calc(28px + var(--dsh-content-font-delta, 0px));
|
||||
}
|
||||
|
||||
.runningText {
|
||||
min-width: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.runningIcon {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
width: calc(14px + var(--dsh-content-font-delta, 0px));
|
||||
height: calc(14px + var(--dsh-content-font-delta, 0px));
|
||||
/* The fixed footprint contains the animation's layout and paint. */
|
||||
contain: strict;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.runningWhaleAnimated {
|
||||
display: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.runningWhaleStill {
|
||||
display: initial;
|
||||
}
|
||||
|
||||
@supports (mask-mode: alpha) and (mask-image: url('')) {
|
||||
@media (prefers-reduced-motion: no-preference) and (forced-colors: none) {
|
||||
.runningWhaleAnimated {
|
||||
display: block;
|
||||
background: currentColor;
|
||||
mask: url('../assets/running-whale@2x.png') center / 100% 100% no-repeat;
|
||||
mask-mode: alpha;
|
||||
/* Keep animated-mask repaints on an icon-sized composited layer. */
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.runningWhaleStill {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.openError {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
font-size: var(--dsh-content-font-size-secondary, 13px);
|
||||
line-height: calc(18px + var(--dsh-content-font-delta-secondary, 0px));
|
||||
}
|
||||
|
||||
.older {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.older button {
|
||||
border: none;
|
||||
border-radius: var(--dsw-radius-sm);
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
background: var(--dsw-alias-interactive-bg-hover-solid);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.older button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Floating controls sit outside the clipped transcript. A standalone Chat
|
||||
anchors them to its frame; the shared host supplies the sticky scrollport. */
|
||||
.toBottomSlot {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 16px;
|
||||
left: 0;
|
||||
/* Above the sticky composer (z-index 7) so the control stays clickable and
|
||||
visible over the input card. Exception: while an @/slash menu is open the
|
||||
seat lifts to 9 (ui-conversation ConversationRoot) so the menu is never
|
||||
covered by this control. */
|
||||
z-index: 8;
|
||||
height: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-right: max(
|
||||
calc(var(--dsh-composer-side-clearance) + 16px),
|
||||
calc((100% - var(--dsh-chat-content-width)) / 2)
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:global([data-conversation-scroll]) .toBottomSlot {
|
||||
position: sticky;
|
||||
/* Clears the sticky composer stack (docks + input card + stats): the live
|
||||
height rides --dsh-composer-height (ConversationRoot's seat observer) so
|
||||
the control follows a growing textarea; the fallback covers the first
|
||||
paint before the observer fires. */
|
||||
bottom: calc(var(--dsh-composer-height, 152px) + 16px);
|
||||
}
|
||||
|
||||
.toBottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
margin-top: -34px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
--dsw-elevation-stroke-color: var(--dsw-alias-border-l3);
|
||||
border-radius: 100px;
|
||||
corner-shape: round;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-button-floating-fill);
|
||||
box-shadow: var(--dsw-elevation-panel);
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.toBottom:hover {
|
||||
background: var(--dsw-alias-button-floating-hover);
|
||||
}
|
||||
}
|
||||
|
||||
/* Host open-path refusal: same dialog family as the workspace folder error. */
|
||||
.modalAction {
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
/* Local surfaces do not contribute flex spacing or selected message text. */
|
||||
.column > .textInteraction {
|
||||
position: absolute;
|
||||
/* Remain below the sticky composer while scroll placement catches up. */
|
||||
z-index: 6;
|
||||
visibility: hidden;
|
||||
margin: 0;
|
||||
width: max-content;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.leading {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.separator {
|
||||
flex: none;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
margin: 0 8px;
|
||||
border-radius: 1px;
|
||||
background: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.summary {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
flex: 1 1 auto;
|
||||
font-size: var(--dsh-content-font-size-secondary, 13px);
|
||||
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.summary[data-error],
|
||||
.body[data-error] {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.body {
|
||||
max-height: 260px;
|
||||
margin: 4px 0 4px 4px;
|
||||
padding: 12px 16px;
|
||||
overflow: auto;
|
||||
border: 0.5px solid var(--dsw-alias-border-l1);
|
||||
border-radius: var(--dsw-radius-lg);
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: var(--dsw-font-markdown-code-block-small);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
/* User messages align their bubble and action row to the right. */
|
||||
|
||||
.userRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.userStack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
/* The figma 525px cap held as a share of the width axis (525/748 ≈ 0.702):
|
||||
resolves to the original 525px on the default column and widens with a
|
||||
dragged or adaptive column. The 748px fallback covers mounts outside the
|
||||
conversation column, where the axis variable is absent. */
|
||||
max-width: min(calc(var(--dsh-chat-content-width, 748px) * 0.702), 82%);
|
||||
}
|
||||
.referenceSummary {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: var(--dsh-content-font-size-secondary, 13px);
|
||||
line-height: calc(18px + var(--dsh-content-font-delta-secondary, 0px));
|
||||
}
|
||||
.contextRow {
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
/* Compaction marker: one 24px row with a context icon at rest and a
|
||||
hover/focus disclosure for the summary body. */
|
||||
.compactionRow {
|
||||
/* The toggle's line, and the band a pinned toggle occupies. The button's
|
||||
height and the offset that holds a summary's code banner below that band
|
||||
are the same measurement, so both read it from here. */
|
||||
--dsh-compaction-header-height: calc(24px + var(--dsh-content-font-delta, 0px));
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.compactionButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: var(--dsh-compaction-header-height);
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--dsw-radius-sm);
|
||||
background: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
transition: color 100ms ease;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* The summary body flows uncapped with the page, so a long checkpoint summary
|
||||
carries this toggle off the top of the viewport. Pin it to the conversation
|
||||
scroll container's top while the row is open (the body is a sibling rendered
|
||||
only when expanded); the base background masks the prose scrolling under it.
|
||||
The square corners apply for as long as the row is open, pinned or not: the
|
||||
fill is what masks the prose, and rounded corners would leave that prose visible
|
||||
in the corners. z-index outranks the markdown code block's own sticky banner
|
||||
(z-index 6 in CodeBlock.module.css); that banner itself stops below this
|
||||
toggle (rule below), so a summary's fenced code block never puts a control
|
||||
inside the toggle's band. */
|
||||
.compactionRow:has(.compactionBody) .compactionButton {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 7;
|
||||
border-radius: 0;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
/* A fenced code block in the summary pins its banner at the scrollport top,
|
||||
where this pinned toggle already sits, and the toggle's band covers the
|
||||
banner's Copy control. Stop the banner below the toggle instead, on the same
|
||||
measurement the toggle's own height uses. This rule beats CodeBlock's
|
||||
`.bannerWrap { top: 0 }` by specificity (0,2,0 against 0,1,0), not by
|
||||
declaration order: the two sheets load in their own packages. */
|
||||
.compactionBody :has(> [data-code-block-banner]) {
|
||||
top: var(--dsh-compaction-header-height);
|
||||
}
|
||||
|
||||
/* Pinned, the toggle needs an opaque hover fill: the default translucent hover
|
||||
token below would let the scrolling prose show through the moment the pointer
|
||||
lands on the toggle to collapse it. */
|
||||
@media (hover: hover) {
|
||||
.compactionRow:has(.compactionBody) .compactionButton:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover-solid);
|
||||
}
|
||||
}
|
||||
|
||||
.compactionButton:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.compactionButton:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.compactionButton:not(:disabled):hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.compactionLeading {
|
||||
flex: none;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: calc(16px + var(--dsh-content-font-delta, 0px));
|
||||
height: calc(16px + var(--dsh-content-font-delta, 0px));
|
||||
margin-right: 6px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Same glyph-scaling rule as DisclosureRow's leading: the CSS edge overrides
|
||||
the svg attributes so the 14px glyphs follow the font-size delta. */
|
||||
.compactionLeading svg {
|
||||
width: calc(14px + var(--dsh-content-font-delta, 0px));
|
||||
height: calc(14px + var(--dsh-content-font-delta, 0px));
|
||||
}
|
||||
|
||||
.compactionContextIcon,
|
||||
.compactionDisclosureIcon {
|
||||
display: inline-flex;
|
||||
grid-area: 1 / 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.compactionDisclosureIcon {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.compactionButton:not(:disabled):focus-visible .compactionContextIcon {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.compactionButton:not(:disabled):hover .compactionContextIcon {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.compactionButton:not(:disabled):focus-visible .compactionDisclosureIcon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.compactionButton:not(:disabled):hover .compactionDisclosureIcon {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.compactionTitle {
|
||||
flex: none;
|
||||
font-size: var(--dsh-content-font-size-secondary, 13px);
|
||||
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.compactionSep {
|
||||
flex: none;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
margin: 0 8px;
|
||||
border-radius: 1px;
|
||||
background: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.compactionSummary {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
font-size: var(--dsh-content-font-size-secondary, 13px);
|
||||
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.compactionBody {
|
||||
/* Indent under the title, which starts at leading (16 + delta) + gap 6. */
|
||||
padding: 4px 0 4px calc(22px + var(--dsh-content-font-delta, 0px));
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: var(--dsh-content-font-size-secondary, 13px);
|
||||
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
|
||||
}
|
||||
|
||||
.retryRow {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: var(--dsh-content-font-size-secondary, 13px);
|
||||
line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
|
||||
}
|
||||
|
||||
.retrySummary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
padding: 2px 0;
|
||||
gap: 7px;
|
||||
border-radius: var(--dsw-radius-sm);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.retrySummary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.retrySummary::after {
|
||||
flex: none;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin-right: 2px;
|
||||
border-right: 1.5px solid currentcolor;
|
||||
border-bottom: 1.5px solid currentcolor;
|
||||
content: '';
|
||||
transform: rotate(-45deg);
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.retrySummary:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.compactionButton {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.retrySummary:focus-visible {
|
||||
outline: 1.5px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.retryText {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.retryRow[open] .retrySummary::after {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.retryDetails {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
margin-top: 3px;
|
||||
padding-left: 14px;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: var(--dsh-content-font-size-secondary, 13px);
|
||||
line-height: calc(18px + var(--dsh-content-font-delta-secondary, 0px));
|
||||
}
|
||||
|
||||
.retryDetailLabel {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.turnErrorRow {
|
||||
display: grid;
|
||||
grid-template-columns: 10px minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
padding: 2px 0;
|
||||
font-size: var(--dsh-content-font-size-secondary, 13px);
|
||||
line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
|
||||
}
|
||||
|
||||
.turnErrorDot {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.turnErrorCopy {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.turnErrorTitle {
|
||||
margin-right: 6px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.turnErrorMessage {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.turnErrorCode {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font: var(--dsw-font-markdown-code-block-small);
|
||||
}
|
||||
|
||||
.maxTokensTitle {
|
||||
margin-right: 6px;
|
||||
color: var(--dsw-alias-state-warn-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.attachmentRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
max-width: 100%;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fileCard {
|
||||
display: inline-flex;
|
||||
flex: 0 0 240px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 240px;
|
||||
min-height: 64px;
|
||||
padding: 8px 12px;
|
||||
border: 0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12));
|
||||
border-radius: var(--dsw-radius-lg);
|
||||
background: var(--dsw-specific-input-major, transparent);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.fileIcon {
|
||||
flex: none;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.fileContent {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fileName {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.fileMeta {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45));
|
||||
font-size: 12px;
|
||||
line-height: 15px;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.root:not([data-expanded]) {
|
||||
contain: size layout;
|
||||
height: calc(24px + var(--dsh-content-font-delta, 0px));
|
||||
}
|
||||
|
||||
.row {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The uncapped body scrolls with the conversation; keep its collapse toggle
|
||||
reachable. Compact Markdown code banners stay in flow below this header. */
|
||||
.root[data-expanded] [data-open] [data-disclosure-header] {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
.leading {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.separator {
|
||||
flex: none;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
margin: 0 8px;
|
||||
border-radius: 1px;
|
||||
background: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
/* Think text reads the secondary tier — one step under the body size
|
||||
(setting −1 at ≤14, setting −2 above), shared with every flow-row title
|
||||
and summary. */
|
||||
.summary {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
flex: 1 1 auto;
|
||||
font-size: var(--dsh-content-font-size-secondary, 13px);
|
||||
line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.summaryText {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.summary[data-streaming] {
|
||||
mask-image: linear-gradient(to right, black calc(100% - 48px), transparent);
|
||||
}
|
||||
|
||||
.summary[data-streaming] .summaryText {
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.root:not([data-preview]) .separator,
|
||||
.root:not([data-preview]) .summary {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.thinkBody {
|
||||
/* Indent under the title, which starts at leading (16 + delta) + gap 6. */
|
||||
padding: 4px 0 4px calc(22px + var(--dsh-content-font-delta, 0px));
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/* Context-occupancy control below the composer plus its click-open breakdown
|
||||
panel (menu surface: elevation-prominent). */
|
||||
|
||||
.root {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
padding: 1px 8px;
|
||||
border: none;
|
||||
border-radius: var(--dsw-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-family: inherit;
|
||||
font-size: var(--dsh-content-font-size-secondary, 13px);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.trigger[aria-expanded='true'] {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.trigger:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.track {
|
||||
fill: none;
|
||||
stroke: var(--dsw-alias-border-l3);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.fill {
|
||||
fill: none;
|
||||
stroke: var(--dsw-alias-label-tertiary);
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: fixed;
|
||||
z-index: 1100;
|
||||
box-sizing: border-box;
|
||||
width: min(264px, calc(100vw - 24px));
|
||||
padding: 12px;
|
||||
border: 0;
|
||||
border-radius: var(--dsw-radius-lg);
|
||||
background: var(--dsw-specific-menu);
|
||||
backdrop-filter: var(--dsw-menu-backdrop-filter);
|
||||
--dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
|
||||
box-shadow: var(--dsw-elevation-prominent);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.figures {
|
||||
margin-left: auto;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.percent {
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.headline {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* The headline brackets the reading, so the side a locale leaves empty must
|
||||
drop out of the flex row rather than spend a gap. */
|
||||
.headline:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
margin: 10px 0 12px;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
corner-shape: round;
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.segment {
|
||||
flex: none;
|
||||
min-width: 2px;
|
||||
height: 100%;
|
||||
border-radius: 1px;
|
||||
background: var(--meter-tint, var(--dsw-alias-label-tertiary));
|
||||
}
|
||||
|
||||
.swatch {
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
background: var(--meter-tint);
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.colorSystem {
|
||||
--meter-tint: var(--dsw-static-neutral-bluish-400);
|
||||
}
|
||||
|
||||
.colorTools {
|
||||
/* The design platform ships no purple static token; violet-400 literal. */
|
||||
--meter-tint: rgb(167, 139, 250);
|
||||
}
|
||||
|
||||
.colorMessages {
|
||||
--meter-tint: var(--dsw-static-blue-450);
|
||||
}
|
||||
|
||||
.rows {
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.row dt {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.row dd {
|
||||
margin: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
.root {
|
||||
position: relative; /* positioning context for slot-owned absolute chrome */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
/* 76px with its rule: the height of the Sidebar's tab strip and header row
|
||||
(ui-dockkit `.tabStrip` 38px + the pane header's 38px), so the two rules
|
||||
meet at the column edge. The rows add up to it — 10px top inset, the 30px
|
||||
title row, the tab strip's 10px margin, and a 25px view row. When the views
|
||||
and the More launcher do not fit on one row, the row wraps and the header
|
||||
grows past 76px. */
|
||||
.header {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
flex: none;
|
||||
box-sizing: border-box;
|
||||
min-height: 76px;
|
||||
padding: 10px 28px 0 20px;
|
||||
border-bottom: 0.5px solid var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
.header:where(:not(:has(.tabs))) {
|
||||
min-height: 0;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Blank Sessions retain navigation controls without conversation title or tabs. */
|
||||
.headerBlank {
|
||||
min-height: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.headerBlank .headerCorner {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Only the macOS titlebar needs reserved drag space before a Session exists. */
|
||||
:global(html:not([data-platform='darwin'])) .headerSessionless {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
:global(html:not([data-platform='darwin'])) .headerSessionless .titleRow {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* macOS hiddenInset titlebar: the header block owns the window drag over its
|
||||
own box — title row plus the view tab strip, so the draggable geometry is the
|
||||
header's geometry and no frame band has to match its height. The header marks
|
||||
itself `data-window-drag` (ConversationHeader); below it the title-row clusters
|
||||
subtract their whole boxes, and individual controls are covered by the base.css
|
||||
interactive-element rule. A covering overlay lives beside #root
|
||||
(ui-settings-general), which puts it after this row in document order. */
|
||||
:global([data-platform='darwin']) .headerLeading,
|
||||
:global([data-platform='darwin']) .headerActions,
|
||||
:global([data-platform='darwin']) .headerUtilities,
|
||||
:global([data-platform='darwin']) .headerCorner {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
grid-column: 2;
|
||||
/* Anonymous inline-size container (CSS modules hash container-names per
|
||||
module, so occupants match the nearest container): header occupants such
|
||||
as the preset label and slot actions shed their text at row-width
|
||||
breakpoints. The minmax(0, 1fr) column sizes the row, so inline-size
|
||||
containment cannot collapse it. */
|
||||
container-type: inline-size;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
min-height: 30px;
|
||||
/* Clears the frame's window-chrome band (traffic lights + shell.leading
|
||||
controls, ui-layout AppFrame) when it is published; the header already
|
||||
pads 20px of it. Zero everywhere else (the fallback makes the max
|
||||
negative). */
|
||||
padding-inline-start: max(0px, calc(var(--dsh-frame-leading-clearance, 0px) - 20px));
|
||||
}
|
||||
|
||||
/* Header actions do not shrink, so on a narrow row they overflow the cluster.
|
||||
The cluster clips them, hit testing included, so the next control keeps
|
||||
every click. The trailing padding spans the gap before that control and
|
||||
holds the fade: content that fits ends before it, and only overflowing
|
||||
content reaches it. The 4px block and start padding, offset by negative
|
||||
margins, keeps focus rings inside the clip. `clip` rather than `hidden`
|
||||
keeps the cluster from becoming a scroll container that focus could scroll. */
|
||||
.titleCluster {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
--title-cluster-fade: 20px;
|
||||
margin: -4px calc(-1 * var(--title-cluster-fade)) -4px -4px;
|
||||
padding: 4px var(--title-cluster-fade) 4px 4px;
|
||||
overflow: clip;
|
||||
mask-image: linear-gradient(to left, transparent calc(0.3 * var(--title-cluster-fade)), #000 var(--title-cluster-fade));
|
||||
}
|
||||
|
||||
/* Without utilities the corner's 8px margin is the gap the fade spans. */
|
||||
.titleCluster:has(+ .headerUtilities:empty) {
|
||||
--title-cluster-fade: 8px;
|
||||
}
|
||||
|
||||
/* The auto grid column collapses with an empty or CSS-hidden occupant.
|
||||
The occupant owns its trailing gap so hidden controls reserve no space. */
|
||||
.headerLeading {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.crumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.crumbSeg {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.crumbSep {
|
||||
/* figma: "/" separators are 14px caption gray (75:7903), one tint lighter than crumb text. */
|
||||
color: var(--dsw-alias-label-caption);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.crumb {
|
||||
display: inline-block;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
border-radius: var(--dsw-radius-md);
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.crumbSubagent {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
button.crumb:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.crumbCurrent {
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.headerUtilities {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.headerUtilities:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The far-right corner seat reaches 16px into the header's 28px right padding,
|
||||
so its control sits past the utilities' edge; it is laid out only while its
|
||||
occupant renders something. Its left margin equals the utilities' 8px gap so
|
||||
the header's trailing controls space evenly. */
|
||||
.headerCorner {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
margin-left: 8px;
|
||||
margin-right: -16px;
|
||||
}
|
||||
|
||||
.headerCorner:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The tab row sits above the header paint so the active bar covers its rule. */
|
||||
.tabs {
|
||||
grid-column: 1 / -1;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
gap: 16px 20px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 10px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
:global(html:not([data-platform])) .tabs {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.viewTabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 32px;
|
||||
min-width: 0;
|
||||
min-height: 25px;
|
||||
}
|
||||
|
||||
/* The 16px text line retains 9px below it. The press target extends 10px
|
||||
above the text into the strip's top margin and 10px to each side, matching
|
||||
the inline padding of the Sidebar tab chips (ui-dockkit `.tab`); negative
|
||||
margins keep the layout box at its 25px row and text width. The active bar
|
||||
spans the text and reaches 1px past the tab box to sit over the header
|
||||
rule. */
|
||||
.tab {
|
||||
--dsh-tab-hit-inline: 10px;
|
||||
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
min-height: 35px;
|
||||
margin: -10px calc(-1 * var(--dsh-tab-hit-inline)) 0;
|
||||
padding: 10px var(--dsh-tab-hit-inline) 9px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: var(--dsh-tab-hit-inline);
|
||||
bottom: -1px;
|
||||
left: var(--dsh-tab-hit-inline);
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Selected tab is blue, not ink: brand-primary resolves to neutral black in
|
||||
this token sheet, so the selected state rides the business blue — the
|
||||
nearest semantic token that stays blue in both themes. */
|
||||
.tabActive {
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.tabActive::after {
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
/* The More launcher centers its 22px box on the tab's 16px text line. */
|
||||
.viewsMoreAnchor {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.viewsMore {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-top: -3px;
|
||||
padding: 0;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.viewsMoreActive { color: var(--dsw-alias-state-business-primary); }
|
||||
|
||||
.tabLabel { display: block; max-width: 88px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.viewsMore[aria-expanded='true'] {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.viewsMoreMenu {
|
||||
width: 288px;
|
||||
max-width: calc(100vw - 24px);
|
||||
}
|
||||
|
||||
.viewArea {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Width handles: 10px col-resize strips beside the transcript, absolute in
|
||||
.body (NOT the scrollport: an absolute strip there would extend the
|
||||
scrollable range; NOT .root: a strip from the column top would paint its
|
||||
glow through the header's transparent background). The inner edge sits
|
||||
24px outside the content column and the strip extends 10px outward; the
|
||||
outer edge is clamped to keep a 24px safe zone against the
|
||||
column edges (sidebar side and scrollbar side stay drag-free). When the
|
||||
margin cannot fit inset + strip + safe zone the computed width goes
|
||||
negative and the strip resolves to zero — no mispositioned hit area on
|
||||
narrow columns. Known half-scrollbar (~4px) centering offset vs the
|
||||
gutter-reserved content box is accepted: it consumes part of the 10px
|
||||
strip but remains within it. */
|
||||
.widthHandle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
/* Concrete overflow content establishes z-index 1, while the sticky
|
||||
composer remains above both layers. */
|
||||
z-index: 0;
|
||||
width: min(
|
||||
10px,
|
||||
calc((100% - var(--dsh-chat-content-width)) / 2 - 24px - 24px)
|
||||
);
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.widthHandle[data-side='left'] {
|
||||
right: calc(50% + var(--dsh-chat-content-width) / 2 + 24px);
|
||||
}
|
||||
|
||||
.widthHandle[data-side='right'] {
|
||||
left: calc(50% + var(--dsh-chat-content-width) / 2 + 24px);
|
||||
}
|
||||
|
||||
/* The glow: a short 2px bar following the captured pointer's Y, centered in the strip
|
||||
(29px from the content column), not a full-height rule — a 16px solid core
|
||||
centered on the pointer
|
||||
(published during drag as --dsh-width-handle-pointer-y), fading to
|
||||
transparent over 28px each side (72px total). A solid CORE, not a single
|
||||
solid stop: a lone peak washes out over the fade. The resting scrollbar
|
||||
tint keeps this secondary affordance quiet in both themes. Before the first
|
||||
drag move the fallback centers the glow at 50% height; hover leaves its position unchanged. */
|
||||
.widthHandle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent calc(var(--dsh-width-handle-pointer-y, 50%) - 36px),
|
||||
var(--dsw-alias-scrollbar-bg-l1) calc(var(--dsh-width-handle-pointer-y, 50%) - 8px),
|
||||
var(--dsw-alias-scrollbar-bg-l1) calc(var(--dsh-width-handle-pointer-y, 50%) + 8px),
|
||||
transparent calc(var(--dsh-width-handle-pointer-y, 50%) + 36px)
|
||||
);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.widthHandle[data-side='left']::after {
|
||||
right: 4px;
|
||||
}
|
||||
|
||||
.widthHandle[data-side='right']::after {
|
||||
left: 4px;
|
||||
}
|
||||
|
||||
.widthHandle[data-dragging]::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.widthHandle:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* A drag starts above the composer band. Pointer capture keeps it active when
|
||||
crossing the footer; lift only the active handle so its indicator remains
|
||||
visible without making the resting footer gutter draggable. */
|
||||
.widthHandle[data-dragging] {
|
||||
z-index: 8;
|
||||
}
|
||||
|
||||
/* Views that elect a composer overlay (trajectory etc.) own their full-bleed
|
||||
scrollers; the strips would sit over their content and steal clicks. */
|
||||
.root:has([data-conversation-composer-overlay]) .widthHandle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Composer context stack (Figma 9:937): standalone dock cards share one
|
||||
rhythm above the input card. */
|
||||
.composerStack {
|
||||
/* Horizontal geometry (card width, clearance, dock inset) rides the shared
|
||||
.root variables above so takeover siblings match the stack. */
|
||||
--dsh-composer-stack-gap: 6px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--dsh-composer-stack-gap);
|
||||
}
|
||||
|
||||
/* Common seat for the composer chain (fallback + elected overlay siblings). */
|
||||
.composerSeat {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
/* One cap for every scrolling text region a composer seat can hold: the
|
||||
InputBar draft (figma Input 75:8208 max 14 lines × 24px line) and the
|
||||
takeover panels' bodies top out at the same height, so electing a
|
||||
takeover never grows the footer past the card it replaces. Declared on
|
||||
the seat because it is the chain's only shared ancestor — fallback and
|
||||
elected overlay are siblings — and custom properties inherit down to
|
||||
whichever entry is mounted. */
|
||||
--dsh-composer-text-max-height: 336px;
|
||||
}
|
||||
|
||||
/* Active phase: header is ordinary column chrome above the scrollport (not
|
||||
sticky). The scroll body holds the transcript and the sticky composer seat
|
||||
so wheel over the footer moves the flow. */
|
||||
.root[data-phase='active'] {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.root[data-phase='active'] .header {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* Band below the header: the scrollport plus the width handles. Anchoring the
|
||||
handles here keeps their full-height strips and glow under the header's
|
||||
bottom edge at every header height. */
|
||||
.body {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
|
||||
/* Shared width axis for the transcript, dock cards, composer, and takeover
|
||||
cards. Main width controls publish the live body width and optional user
|
||||
preference; embedded occurrences override this adaptive axis below. */
|
||||
--dsh-chat-content-width: var(
|
||||
--dsh-chat-user-width,
|
||||
clamp(680px, calc(var(--dsh-conversation-column-width, 0px) * 0.64), 920px)
|
||||
);
|
||||
--dsh-composer-card-max-width: calc(var(--dsh-chat-content-width) + 32px);
|
||||
--dsh-composer-side-clearance: 16px;
|
||||
--dsh-composer-dock-inset: 8px;
|
||||
}
|
||||
|
||||
.embeddedBody {
|
||||
overflow: hidden;
|
||||
--dsh-chat-content-width: min(calc(100% - 32px), 920px);
|
||||
--dsh-composer-card-max-width: min(calc(100% - 16px), 952px);
|
||||
--dsh-composer-side-clearance: 8px;
|
||||
--dsh-composer-dock-inset: 8px;
|
||||
}
|
||||
|
||||
.scrollBody {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
/* The product-wide 2px scrollbar offset (ui-workspace WorkspaceBrowser
|
||||
`.list`, ui-sidebar-files FilesBody `.body`): the bar sits 2px clear of
|
||||
the column's edge instead of flush against it. */
|
||||
margin-right: 2px;
|
||||
overflow-y: auto;
|
||||
/* Reserved unconditionally: the composer seat rides this box's content box in
|
||||
Chat and its padding box under a view's composer overlay, so an `auto`
|
||||
gutter moves the input card sideways by the bar's width whenever the two
|
||||
differ ([decision](../../../../../../.agents/notes/archived/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). */
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
/* The bar also stops 2px short of the scroller's ends, matching its 2px edge
|
||||
offset. WebKit-only: the Firefox path has no track to inset. */
|
||||
.scrollBody::-webkit-scrollbar-track {
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.root[data-phase='active'] .viewArea,
|
||||
.embeddedBody[data-content-phase='active'] .viewArea {
|
||||
flex: 1 0 auto;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.root[data-phase='active'] .composerSeat,
|
||||
.embeddedBody[data-content-phase='active'] .composerSeat {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
/* Above markdown CodeBlock sticky banners (z-index 6) so the footer never
|
||||
paints under a sticking code header while scrolling. The pinned compaction
|
||||
header (MessageItem.module.css, ui-chat) and the turn-navigation rail slot
|
||||
(TurnNavigator.module.css, ui-chat) also take 7 to clear the same banners.
|
||||
Among elements of equal rank the later one in DOM order wins, so the
|
||||
composer stays above the pinned header on the extreme small-viewport
|
||||
overlap and the pinned header stays above the rail. */
|
||||
z-index: 7;
|
||||
/* Input mask (figma 1205:27463): transcript fades out under a FIXED 36px
|
||||
band at the seat's top (the figma 24% of the resting ~150px composer),
|
||||
solid below — px stops, not %, so a growing draft only widens the solid
|
||||
region and the fade band never stretches. The 0px stop is bg-base at
|
||||
zero alpha (not white, which the figma export hardcodes) so both themes
|
||||
fade from their own base. */
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--dsw-alias-bg-base) 0%, transparent) 0px,
|
||||
var(--dsw-alias-bg-base) 36px
|
||||
);
|
||||
}
|
||||
|
||||
/* An open @/slash menu (data-trigger-menu, ui-input-trigger) renders inside
|
||||
the input card, so the seat's stacking context caps its z-index; lift the
|
||||
seat above the back-to-bottom control (z-index 8, ui-chat ChatView) while
|
||||
the menu is open. The steady state stays 7 so the control keeps painting
|
||||
over the card. */
|
||||
.root[data-phase='active'] .composerSeat:has([data-trigger-menu]) {
|
||||
z-index: 9;
|
||||
}
|
||||
|
||||
/* Views may opt into a composer overlay while ConversationRoot retains
|
||||
ownership of the seat geometry and its active-phase precedence. */
|
||||
.scrollBody:has([data-conversation-composer-overlay]) {
|
||||
position: relative;
|
||||
/* A clipping box nothing scrolls out of, stated as a scroll container on both
|
||||
axes rather than `overflow: hidden`: a single-axis scroller computes the
|
||||
other axis to `auto`, and the seat's absolute positioning below needs the
|
||||
box to stay a containing block in every engine. */
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
/* The view owns its own scrollers (the trajectory ledger scrolls inside the
|
||||
view), so this box never scrolls here; reserving a gutter would only
|
||||
narrow the view's content by the bar's width. Chat keeps the reservation
|
||||
on the same element, so the seat below compensates with the bar's width
|
||||
([decision](../../../../../../.agents/notes/archived/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). */
|
||||
scrollbar-gutter: auto;
|
||||
}
|
||||
|
||||
.scrollBody:has([data-conversation-composer-overlay]) > :global([data-slot='conversation.session']) > .viewArea {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scrollBody:has([data-conversation-composer-overlay]) > .composerSeat {
|
||||
position: absolute;
|
||||
/* Chat's seat rides this box's content box, which its own scrolling shortens
|
||||
by the bar's width; the overlay seat is against the padding box, so it
|
||||
must give back the same width to keep the input card in place across
|
||||
tabs. Reads ui-theme's --dsh-scrollbar-width, which mirrors the WebKit
|
||||
bar's layout width. Chat reserves the engine-resolved width, so on an
|
||||
engine where the resolved width differs from this fixed 8px the card
|
||||
drifts by half the difference — accepted residual risk, recorded in
|
||||
([decision](../../../../../../.agents/notes/archived/bug-fix/2026-08-12-composer-overlay-seat-width-compensation.md)). */
|
||||
right: var(--dsh-scrollbar-width);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* Hero phase: the composer stack (hero chrome + workspace row + card) is
|
||||
flex-centered in the column; composer phase docks it at the bottom. Flex,
|
||||
NOT absolute+transform: a transform would make this box the containing
|
||||
block for position:fixed descendants (pickers/modals), shrinking them. */
|
||||
.composerHero {
|
||||
align-self: center;
|
||||
/* figma 75:8208 drew 12 between all three rows; the workspace row now sits
|
||||
8 above the card (its margin-top restores 12 under the hero chrome). */
|
||||
gap: 8px;
|
||||
/* Foot inside the centered box floats the stack a bit above true center. */
|
||||
padding-bottom: 32px;
|
||||
/* Card cap + both clearances: the hero input card lands at exactly the same
|
||||
width as the docked composer at every viewport. */
|
||||
width: min(calc(var(--dsh-composer-card-max-width) + 2 * var(--dsh-composer-side-clearance)), 100%);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.heroWorkspaceRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
margin-top: 4px;
|
||||
/* figma drew px 8; left nudged +12 so the chip's folder glyph lines up
|
||||
closer to the card's inner controls below; right keeps the trailing
|
||||
preset seat off the card edge. */
|
||||
padding: 0 16px 0 20px;
|
||||
}
|
||||
|
||||
/* Plugin options share the row, pushed to its right edge. */
|
||||
.heroOptions {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.heroOptions:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hero: the composer sits inside the session scroll body; center there so
|
||||
the tree seat matches active (sticky footer) without a Root remount. */
|
||||
.root[data-phase='hero'] .scrollBody,
|
||||
.embeddedBody[data-content-phase='hero'] .scrollBody {
|
||||
justify-content: center;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Settling (session replaying, hero/docked unknown): keep the composer seat
|
||||
mounted but invisible so no wrong layout flashes before the phase lands. */
|
||||
.root[data-phase='settling'] .composerSeat,
|
||||
.embeddedBody[data-content-phase='settling'] .composerSeat {
|
||||
visibility: hidden;
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
/* NEW SESSION hero (figma Input_Bottom 75:8208): fish + title, workspace chip
|
||||
above the shared InputBar card. The input itself is InputBar — only stack
|
||||
geometry and the chip live here. */
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
/* Cap matches the InputBar card. Glow may paint past the sides. */
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
/* figma 75:8208: 12 between title block / workspace / card. */
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
max-width: var(--dsh-composer-card-max-width);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt500. The preview
|
||||
badge is a product addition outside that source: a mono superscript pill
|
||||
riding the title's top-right. */
|
||||
.headline {
|
||||
display: flex;
|
||||
/* Overflow-driven: the fish and the title group are two wrap units sharing
|
||||
one centered line while they fit; squeezed, the fish takes its own line
|
||||
above the group. No breakpoint. */
|
||||
flex-wrap: wrap;
|
||||
column-gap: 10px;
|
||||
/* Wrapped, the fish sits on its own line; more air than the 10px side gap
|
||||
keeps it reading as a mark above the title, not a squeezed inline glyph. */
|
||||
row-gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 26px;
|
||||
line-height: 32px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Title text + badge as one wrap unit so the title never breaks mid-text with
|
||||
the badge stranded beside a fragment. When the badge no longer fits beside
|
||||
the title it wraps to its own centered line below; while both fit, the group
|
||||
is content-sized and centering is a no-op, keeping the superscript snug. */
|
||||
.titleGroup {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
/* Superscript sits 7 from the title. */
|
||||
column-gap: 7px;
|
||||
row-gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.previewBadge {
|
||||
align-self: flex-start;
|
||||
margin-top: 2px;
|
||||
padding: 1px 7px 0;
|
||||
border: 0.5px solid var(--dsw-alias-interactive-bg-hover);
|
||||
border-radius: 999px;
|
||||
corner-shape: round;
|
||||
background: var(--dsw-alias-state-business-tertiary);
|
||||
color: var(--dsw-alias-label-primary-bluish);
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Keep hover detection on a stationary box while the mark moves within it;
|
||||
hover rules target the hitbox so a slot-occupant mark swims too. */
|
||||
.fishHitbox {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* The swim morph's curves poke slightly past the viewBox edges, so clipping
|
||||
would slice the tail mid-wag. The hero mark stays in the same primary ink
|
||||
as its headline. */
|
||||
.fish {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
transform-origin: 50% 60%;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Hover swim: a slow continuous sway about the body center — the head
|
||||
(left) lifts as the whale rises, then settles back with a small
|
||||
counter-tilt, like treading water. Negative rotation = head up (the head
|
||||
is the left half; origin 50% 60%). */
|
||||
@keyframes hero-fish-swim {
|
||||
0%,
|
||||
100% {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
35% {
|
||||
transform: rotate(-4deg) translate(-0.4px, -0.9px);
|
||||
}
|
||||
|
||||
70% {
|
||||
transform: rotate(1.6deg) translate(0.3px, 0.2px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: hover) and (prefers-reduced-motion: no-preference) {
|
||||
.fishHitbox:hover .fish {
|
||||
animation: hero-fish-swim 1.6s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
/* Workspace row sits 12px above the input card (figma y80 → y112). */
|
||||
.body {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.body > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Must beat `.body > :not(.glow)` specificity so the open Menu (and its
|
||||
right-hand submenu) paints above the InputBar card. */
|
||||
.body > .workspaceRow {
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
/* figma 75:8208 workspace row: px 8 above the card. */
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
/* Folder + label + chevron — transparent at rest; fill only on hover / open.
|
||||
Overflow-driven degradation, no breakpoint: the label shrinks with an
|
||||
ellipsis down to nothing, so folder and chevron always sit snug against
|
||||
whatever text remains — full text, ellipsized text, or none. */
|
||||
.workspace {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: min(100%, 360px);
|
||||
min-height: 28px;
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
border-radius: var(--dsw-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.workspace[aria-expanded='true'] {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.workspace:not(:disabled):hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
/* Locked form (bound guidance state): a static echo — no hover feedback, no
|
||||
pointer affordance; label keeps full contrast. */
|
||||
.workspace:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.folder {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.workspaceLabel {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
/* Dialog field: 44 tall on the modal's 332 content column, hairline
|
||||
border, pad 14/7, 14/22 wt400 primary text, caption placeholder. Focus
|
||||
keeps the resting border (design shows no focus ring). */
|
||||
.modalInput {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 7px 14px;
|
||||
border: 0.5px solid var(--dsw-alias-border-l4);
|
||||
border-radius: var(--dsw-radius-lg);
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.modalInput::placeholder {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.modalInput:disabled {
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
.modalAction {
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
.modalError {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
/* Side pads ride the shared clearance (figma Input_Bottom drew L32/R32/B8;
|
||||
the sides narrow with the shared width axis); the bottom gradient mask
|
||||
is owned by the chat scroller. No top pad: the composer stack's gap owns
|
||||
the space above; the status strip still carries its own margin. */
|
||||
padding: 0 var(--dsh-composer-side-clearance) 4px;
|
||||
}
|
||||
|
||||
.dock {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
max-width: 100%;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.hero .dock:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hero {
|
||||
/* No bottom pad in the centered hero, but the side clearance must survive:
|
||||
the hero wrapper is full-width on narrow viewports, so this padding is
|
||||
the only thing keeping the card off the edges there. */
|
||||
padding: 0 var(--dsh-composer-side-clearance);
|
||||
}
|
||||
|
||||
.notice {
|
||||
width: 100%;
|
||||
max-width: var(--dsh-composer-card-max-width);
|
||||
margin-bottom: 6px;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--dsw-radius-md);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.card {
|
||||
box-sizing: border-box;
|
||||
position: relative; /* overlay anchor positioning context */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* figma Input 75:8208: 12px between the text area and the button row; the
|
||||
card's top pad before .InputText tightened from the drawn 10 to 8. */
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
max-width: var(--dsh-composer-card-max-width);
|
||||
padding-top: 8px;
|
||||
/* Input stroke: the light neutral (l2, one step above the menus' l1),
|
||||
drawn as the elevation hairline so it costs no layout; the shadow is the
|
||||
soft elevation tier. Fill: the input surface token (elevated in dark). */
|
||||
border: 0;
|
||||
--dsw-elevation-stroke-color: var(--dsw-alias-border-l2);
|
||||
border-radius: var(--dsw-radius-panel);
|
||||
background: var(--dsw-specific-input-major);
|
||||
box-shadow: var(--dsw-elevation-soft);
|
||||
/* The draft text follows the Settings font-size preference (the textarea,
|
||||
mirror, and backdrop all inherit from here, so the three layers stay in
|
||||
step); the line height keeps the default 10px leading via the shared px
|
||||
delta. */
|
||||
font-size: var(--dsh-content-font-size, 14px);
|
||||
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
|
||||
/* Elevated surface in dark, same as the menus: the draft scrollport inside
|
||||
scrolls once the composer hits its height cap, so the thumb takes the l2
|
||||
pair. Declared on the card because the elevation belongs to the surface,
|
||||
and the custom properties inherit down to the box that actually scrolls
|
||||
(see ui-theme styles/scrollbar.css for the rebinding contract). */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.card {
|
||||
font-size: max(16px, var(--dsh-content-font-size, 14px));
|
||||
}
|
||||
}
|
||||
|
||||
/* No-workspace trigger state: an inset 1px dashed outline marks the card as a
|
||||
workspace picker rather than a live composer. Keeping the complete stroke
|
||||
inside the rounded box prevents clipped corner pixels from reading heavier. */
|
||||
.cardWorkspaceTrigger {
|
||||
--dsw-elevation-stroke-color: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cardWorkspaceTrigger::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
box-sizing: border-box;
|
||||
border: 1px dashed currentColor;
|
||||
border-radius: var(--dsw-radius-panel);
|
||||
color: var(--dsw-alias-border-l4);
|
||||
transition: color 100ms ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Disabled toolbar controls neither receive nor swallow clicks in the trigger
|
||||
state: pointer events fall through to the card's own click handler, making
|
||||
the whole card one pick target. */
|
||||
.cardWorkspaceTrigger :disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.cardWorkspaceTrigger:hover::after {
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.accessory {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px 0;
|
||||
}
|
||||
|
||||
/* Floating overlay anchor (menu / popupSelect shell): entries position
|
||||
themselves against the card (bottom: 100% + gap); closed entries render null. */
|
||||
.overlayAnchor {
|
||||
position: absolute;
|
||||
inset: 0 0 auto;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* The draft's scrollport, and the ONLY scrolling box in the composer. The
|
||||
contenteditable grows with its content inside; the 14-line cap lives here
|
||||
because this is the box the cap describes. */
|
||||
.scroll {
|
||||
max-height: var(--dsh-composer-text-max-height);
|
||||
overflow-y: auto;
|
||||
/* Keeps the bar off the card's right border; .input's right pad gives the
|
||||
4px back so the text column is unchanged. */
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* The card's corner arc still overlaps the scrollport's top (8px top
|
||||
pad reaches only y=8): start the thumb's travel below the arc so it never
|
||||
pokes outside the capsule. WebKit-path only; Firefox's thin bar has no
|
||||
track margin, an accepted remainder. */
|
||||
.scroll::-webkit-scrollbar-track {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Auto-grow anchor: the contenteditable is in normal flow and sets the
|
||||
draft's height; the placeholder rides it absolutely. */
|
||||
.grow {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Machine pending dot (adjudicating / submitting). */
|
||||
.pending {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
corner-shape: round;
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
animation: input-pending 1s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes input-pending {
|
||||
from { opacity: 0.35; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* The contenteditable draft surface (grows with its content; .scroll caps
|
||||
and scrolls it). figma .InputText 34:10434 drew pl 16 / pr 12 / pt 4; the
|
||||
left pad tightened to 14, and the right pad is 8 here plus .scroll's 4px
|
||||
bar inset. */
|
||||
.input {
|
||||
box-sizing: border-box;
|
||||
/* Docked floor: one 24px line + 4px top pad = 28 content height; 36 leaves
|
||||
an 8px breath below the line (the hero keeps its own 52px floor below). */
|
||||
min-height: 36px;
|
||||
padding: 4px 8px 0 14px;
|
||||
font-family: var(--dsw-font-family);
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
outline: none;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
/* Business blue, not brand-primary: that token resolves to ink in this sheet. */
|
||||
caret-color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
/* Lexical paragraphs are <p> blocks: strip the UA margins so the surface
|
||||
keeps the textarea's line rhythm. */
|
||||
.input p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Claim ghost hint as generated content after the last paragraph: the bar
|
||||
sets --dsh-composer-hint (a quoted string) while the claim's args are
|
||||
blank; without the variable the declaration is invalid and nothing shows. */
|
||||
.input p:last-child::after {
|
||||
content: var(--dsh-composer-hint);
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.input[data-composer-composing] p:last-child::after {
|
||||
content: none;
|
||||
}
|
||||
|
||||
/* A selected command chip stands in for the caret until the next key. */
|
||||
.input[data-composer-chip-selected] {
|
||||
caret-color: transparent;
|
||||
}
|
||||
|
||||
.input[data-composer-composing] + .placeholder {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* figma 34:10434: #ADB2B8 light / #81858C dark — the caption pair exactly.
|
||||
Insets mirror .input's pads (the other right 4px sits on .scroll). One
|
||||
ellipsized line: a wrapped second line would clip against the docked
|
||||
36px floor. */
|
||||
.placeholder {
|
||||
position: absolute;
|
||||
inset: 4px 8px auto 14px;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.inputDisabled {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.input[aria-haspopup='menu'] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Hero (centered empty-state) keeps the 2-line floor (figma min-h 52 = ~2 × 24
|
||||
line + 4pt); the docked composer collapses to the content height. */
|
||||
.hero .input {
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
/* The hero's 52px floor fits two lines, so its placeholder clamps at two
|
||||
instead of the docked single line. */
|
||||
.hero .placeholder {
|
||||
white-space: normal;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
}
|
||||
|
||||
/* Toolbar: attach + Plan + Read-only on the left; model + send on the right
|
||||
(figma Input_Bottom chrome). */
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
/* 2px moved from the bottom pad to the top: the whole control row sits 2px
|
||||
lower in the card (it read too high against the textarea) while the
|
||||
controls' own centering stays untouched. */
|
||||
padding: 2px 8px 6px;
|
||||
min-width: 0;
|
||||
/* Size container so the chips inside can collapse to icon-only when the
|
||||
card runs out of row width (PermissionSelect @container rule). Anonymous
|
||||
on purpose: CSS modules hash container-name per module, so a name declared
|
||||
here can never match a query in another module's sheet. */
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.row[data-model-compact] {
|
||||
--dsh-composer-model-text-display: none;
|
||||
--dsh-composer-model-icon-display: block;
|
||||
}
|
||||
|
||||
.tools,
|
||||
.modes,
|
||||
.trailing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* figma 75:8208 drew 16 between + and the mode chips and 4 between Plan /
|
||||
Read-only; both gaps settled on 12 so the pill chips read as separate
|
||||
controls without drifting from the + control. */
|
||||
.tools {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modes {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.trailing {
|
||||
flex: none;
|
||||
/* Wrap keeps the left mode chips and the right controls apart when the card
|
||||
runs out of row width: the trailing group (model + send) moves to its own
|
||||
line instead of the left group shrinking until its chip overlaps the
|
||||
model trigger (external:107). The auto margin re-anchors it right on the
|
||||
wrapped line; on a single line space-between already pins it right. */
|
||||
margin-left: auto;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Compact spacing preserves room for the fixed-width controls in narrow cards. */
|
||||
@container (max-width: 560px) {
|
||||
.tools,
|
||||
.modes,
|
||||
.trailing {
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Attach circle (figma + control): 28px, selector fill, primary glyph. */
|
||||
.add {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
corner-shape: round;
|
||||
background: var(--dsw-specific-selector);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.add:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover-solid);
|
||||
}
|
||||
}
|
||||
|
||||
.add:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Plan / Read-only / model — native <select>, chip-like closed chrome
|
||||
(figma ToggleButton: 13/20 medium secondary, 12px chevron). */
|
||||
.select {
|
||||
max-width: 220px;
|
||||
height: 28px;
|
||||
padding: 0 20px 0 8px;
|
||||
border: none;
|
||||
border-radius: var(--dsw-radius-sm);
|
||||
outline: none;
|
||||
background-color: transparent;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5' stroke='%2381858C' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 4px center;
|
||||
background-size: 12px 12px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.select:hover:not(:disabled) {
|
||||
background-color: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Primary send (figma IconButton 34:10465): 34px circle, #3964FE light /
|
||||
#679EFE dark — the info-fill pair (500→400), NOT button-primary (ink);
|
||||
white glyph; empty text = 0.4 opacity. */
|
||||
.primary {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
corner-shape: round;
|
||||
background: var(--dsw-alias-button-info-fill);
|
||||
/* Static white, not the foreground token: the arrow stays white on the blue
|
||||
fill in both themes (design 34:10465). */
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: background-color 100ms ease;
|
||||
/* Opts out of the row's 2px downward shift (.row top pad): the send circle
|
||||
keeps its original seat while the smaller chips sit lower. Transform, not
|
||||
margin, so flex centering math is untouched. */
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.primary:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-button-info-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.primary:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Sending: the message waits for the Host to accept it. The circle keeps its
|
||||
full fill rather than the disabled fade, and an arc turns where the arrow was. */
|
||||
.primary[data-sending]:disabled {
|
||||
opacity: 1;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.sendingSpinner {
|
||||
animation: input-sending 800ms linear infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sendingSpinner {
|
||||
animation-duration: 2400ms;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes input-sending {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.retry {
|
||||
margin-left: 8px;
|
||||
padding: 1px 8px;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: var(--dsw-radius-sm);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.standardControls { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||
.tools[hidden], .standardControls[hidden] { display: none; }
|
||||
.trailingActive { flex: 1; margin-left: 0; }
|
||||
.activity { display: flex; align-items: center; flex: none; }
|
||||
.activity:empty { display: none; }
|
||||
.activityExpanded { flex: 1; min-width: 0; }
|
||||
@@ -0,0 +1,123 @@
|
||||
.root {
|
||||
box-sizing: border-box;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
margin: 0 auto;
|
||||
width: calc(
|
||||
100% -
|
||||
var(--dsh-composer-side-clearance) -
|
||||
var(--dsh-composer-side-clearance) -
|
||||
var(--dsh-composer-dock-inset) -
|
||||
var(--dsh-composer-dock-inset) -
|
||||
var(--dsh-composer-dock-inset) -
|
||||
var(--dsh-composer-dock-inset)
|
||||
);
|
||||
max-width: calc(
|
||||
var(--dsh-composer-card-max-width) -
|
||||
var(--dsh-composer-dock-inset) -
|
||||
var(--dsh-composer-dock-inset) -
|
||||
var(--dsh-composer-dock-inset) -
|
||||
var(--dsh-composer-dock-inset)
|
||||
);
|
||||
border: 0;
|
||||
--dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
|
||||
border-radius: var(--dsw-radius-lg);
|
||||
background: var(--dsw-specific-menu);
|
||||
backdrop-filter: var(--dsw-menu-backdrop-filter);
|
||||
box-shadow: var(--dsw-elevation-panel);
|
||||
/* The menu surface and the scrolling `.list` use the l2 scrollbar colors.
|
||||
Declared here because the custom properties inherit into `.list` (see
|
||||
ui-theme styles/scrollbar.css for the rebinding contract). */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lead {
|
||||
display: grid;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
font-size: 13px;
|
||||
line-height: 24px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.progress {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 400;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
display: grid;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.glyph {
|
||||
display: grid;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* Figma strip is single-line; long items ellipsize with no inline expand. */
|
||||
.content {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/* Vendored DeepSeek Harness design tokens + surfaces (Phase 1).
|
||||
* NOTE: do NOT import corner-shape.css — `*,*::before,*::after{corner-shape}`
|
||||
* freezes WebKitGTK (Tauri) with continuous layout thrash. */
|
||||
@import "./styles/base.css";
|
||||
@import "./styles/design-platform.css";
|
||||
@import "./styles/scrollbar.css";
|
||||
@import "./styles/focus.css";
|
||||
@import "./styles/gradient-shadow-text.css";
|
||||
@import "./shell-vars.css";
|
||||
@@ -0,0 +1,388 @@
|
||||
.frame {
|
||||
position: relative; /* anchors the drag handles, which straddle column borders */
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
/* Only discrete collapse/expand and fold changes ease the tracks (the TSX holds
|
||||
data-animating across the toggle on the deepsuite sider curve). Steady-state
|
||||
tracks move only with the viewport, and those updates must land instantly:
|
||||
an eased track chases the live window edge and the centre column visibly
|
||||
rubber-bands while the user resizes the window. */
|
||||
.frame[data-animating] {
|
||||
transition: grid-template-columns var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
/* Dragging writes widths at pointer cadence; easing them would detach the
|
||||
column from the handle. */
|
||||
.frame[data-dragging] {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* The Windows caption row remains outside every conversation and file panel. */
|
||||
:global([data-windows-titlebar]) .frame {
|
||||
--dsh-windows-content-radius: 16px;
|
||||
box-sizing: border-box;
|
||||
padding-top: var(--dsh-windows-titlebar-height);
|
||||
background: var(--dsw-specific-sidebar-fill);
|
||||
}
|
||||
|
||||
:global([data-windows-titlebar]) .centerCol {
|
||||
background: var(--dsw-alias-bg-base);
|
||||
border-radius: var(--dsh-windows-content-radius) 0 0 0;
|
||||
corner-shape: round;
|
||||
}
|
||||
|
||||
:global([data-windows-titlebar]) .frame::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 0 auto;
|
||||
height: var(--dsh-windows-titlebar-height);
|
||||
background: var(--dsw-specific-sidebar-fill);
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
:global([data-windows-titlebar]) .sidebarCol {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.frame[data-animating] {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebarCol {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
background: var(--dsw-specific-sidebar-fill);
|
||||
border-right: 0.5px solid var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
.centerCol {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mainPanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* macOS desktop: the frame goes transparent so window vibrancy reaches the
|
||||
sidebar column, which keeps only a translucent tint; the centre paints the
|
||||
base background itself to stay opaque. The sidebar/centre separator moves
|
||||
to the opaque centre: on the translucent sidebar the alpha border mixes
|
||||
with the wallpaper and reads as a dark seam. */
|
||||
:global([data-platform='darwin']) .frame {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* The constant step below the window's top strip (traffic lights, sidebar
|
||||
toggle, shell.leading controls), the same in both sidebar states. Entry
|
||||
pages in the main panel (plugin manager, settings-like pages — not the
|
||||
conversation, which owns its header) pad their top by it so their leading
|
||||
content clears the strip, and overlay primitives add their own safe inset.
|
||||
Declared on the root element rather than the frame so
|
||||
overlays portalled to document.body inherit it too. */
|
||||
:global(html[data-platform='darwin']) {
|
||||
--dsh-frame-top-clearance: 48px;
|
||||
}
|
||||
|
||||
/* In-frame Windows content already clears the caption through .frame padding. */
|
||||
:global(html[data-windows-titlebar]) {
|
||||
--dsh-frame-top-clearance: var(--dsh-windows-titlebar-height);
|
||||
--dsh-frame-chrome-top: var(--dsh-windows-titlebar-height);
|
||||
}
|
||||
|
||||
/* Portalled overlays share a viewport inset without adding space to entry pages. */
|
||||
:global(html[data-platform='darwin']),
|
||||
:global(html[data-windows-titlebar]) {
|
||||
--dsh-frame-overlay-top: calc(var(--dsh-frame-top-clearance) + 20px);
|
||||
}
|
||||
|
||||
:global(html[data-platform='darwin'][data-fullscreen]),
|
||||
:global(html[data-windows-titlebar][data-fullscreen]) {
|
||||
--dsh-frame-overlay-top: 20px;
|
||||
--dsh-frame-chrome-top: 0px;
|
||||
}
|
||||
|
||||
:global([data-platform='darwin']) .frame:not([data-sidebar-drawer]) .sidebarCol {
|
||||
/* A blue wash over the top of the translucent tint, gone by a third of the
|
||||
column; a faint blue-grey-violet rises through the bottom third; and a
|
||||
faint blue cast is mixed into the tint itself. Every layer stays
|
||||
transparent so vibrancy shows. */
|
||||
background:
|
||||
linear-gradient(
|
||||
to bottom,
|
||||
rgb(122 155 240 / 0.1),
|
||||
rgb(122 155 240 / 0) 35%,
|
||||
rgb(143 137 184 / 0) 68%,
|
||||
rgb(143 137 184 / 0.09)
|
||||
),
|
||||
color-mix(in srgb, color-mix(in srgb, var(--dsw-specific-sidebar-fill) 97%, rgb(122 155 240)) 40%, transparent);
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* Dark drops the blue cast (the light-mode wash reads purple over dark fills)
|
||||
and thins the fill so more wallpaper shows; the washes stay. */
|
||||
:global([data-platform='darwin']) :global([data-ds-dark-theme]) .frame:not([data-sidebar-drawer]) .sidebarCol {
|
||||
background:
|
||||
linear-gradient(
|
||||
to bottom,
|
||||
rgb(122 155 240 / 0.08),
|
||||
rgb(122 155 240 / 0) 35%,
|
||||
rgb(143 137 184 / 0) 68%,
|
||||
rgb(143 137 184 / 0.07)
|
||||
),
|
||||
color-mix(in srgb, var(--dsw-specific-sidebar-fill) 50%, transparent);
|
||||
}
|
||||
|
||||
/* macOS Reduce Transparency: the OS flattens the window's vibrancy layer to a
|
||||
solid grey, so the thin tint and washes read washed-out over it. Replace
|
||||
them with a 90%-opaque fill of the same token in both themes (the dark
|
||||
selector repeats so this outranks its thinner fill). */
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
:global([data-platform='darwin']) .frame:not([data-sidebar-drawer]) .sidebarCol,
|
||||
:global([data-platform='darwin']) :global([data-ds-dark-theme]) .frame:not([data-sidebar-drawer]) .sidebarCol {
|
||||
background: color-mix(in srgb, var(--dsw-specific-sidebar-fill) 90%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
:global([data-platform='darwin']) .centerCol {
|
||||
background: var(--dsw-alias-bg-base);
|
||||
border-left: 0.5px solid var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
/* The right track paints the base ground on the transparent frame: while the
|
||||
track and the occupant's translateX slide ride the same curve, the grid
|
||||
eases in layout and the transform on the compositor, so frames where they
|
||||
diverge would otherwise show vibrancy glass between the centre's right edge
|
||||
and the incoming panel. The occupant's opaque panel covers this at rest. */
|
||||
:global([data-platform='darwin']) .rightbarCol {
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
/* The darwin closed sidebar has no rail: the centre reaches the window edge,
|
||||
where a separator would draw a seam against nothing. */
|
||||
:global([data-platform='darwin']) [data-sidebar-collapsed] .centerCol {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
/* Published while the shell.leading seat is mounted (same condition as the
|
||||
TSX mount: darwin with the sidebar collapsed — the collapse that hides the
|
||||
column entirely): the inline band the window chrome occupies, measured from
|
||||
the frame's left edge — traffic lights to x 68, controls at x 88
|
||||
(28 + 8 + 28), then 8px of air. Main panels whose content reaches the
|
||||
top-left corner pad by this. */
|
||||
:global([data-platform='darwin']) .frame[data-sidebar-collapsed] {
|
||||
--dsh-frame-leading-clearance: 160px;
|
||||
}
|
||||
|
||||
/* Fullscreen hides the traffic lights (preload mirrors the window state onto
|
||||
html[data-fullscreen]): the seat takes their band and the clearance keeps
|
||||
only the controls (12 + 28 + 8 + 28 + 8). The 12px start matches the
|
||||
expanded sidebar strip's inline padding (ui-sidebar .topStrip), so
|
||||
collapsing never shifts the toggle. */
|
||||
:global([data-platform='darwin'][data-fullscreen]) .frame[data-sidebar-collapsed] {
|
||||
--dsh-frame-leading-clearance: 84px;
|
||||
}
|
||||
|
||||
:global([data-platform='darwin'][data-fullscreen]) .leadingSeat {
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
/* The frame declares no window drag of its own: every chrome row owns the drag
|
||||
over its own box (ui-web base.css holds the one rule; the ui-theme app-region
|
||||
gate lists the rows). The ordering rule that replaces the old "band first"
|
||||
invariant: a surface that covers the window and must stay clickable portals
|
||||
beside #root, which puts it after every drag row in document order. The Windows
|
||||
caption row above is the other platform's chrome and stays. */
|
||||
|
||||
/* The seat itself: the two 28px controls centered on the conversation title
|
||||
row (10px header inset + 30px row → centre y 25) and level with the traffic
|
||||
lights (centre y ≈ 24). Sits above column content, below frame overlays
|
||||
(20) and the fullscreen right panel (40). The box subtracts itself from any
|
||||
window drag row beneath (Electron app-regions are geometry, not stacking). */
|
||||
.leadingSeat {
|
||||
position: absolute;
|
||||
top: 11px;
|
||||
left: 88px;
|
||||
z-index: 15;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* Drag handles are frame children (columns clip overflow): an 8px hit strip
|
||||
centered on the column border via inline left, above column content. No
|
||||
handle draws a visible pill: the details column was the only one that did,
|
||||
and the right column never had one. The right handle sits above the right
|
||||
panel (which states z-index 10) so it stays grabbable when the panel hangs
|
||||
over the centre. */
|
||||
.handle {
|
||||
position: absolute;
|
||||
grid-area: 1 / 1 / 2 / -1;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 8px;
|
||||
margin-left: -4px;
|
||||
cursor: col-resize;
|
||||
z-index: 11;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* Rides the same curve as the tracks so the pill stays on the moving border
|
||||
during collapse/expand; instant at steady state (viewport-driven moves)
|
||||
and while dragging. */
|
||||
.frame[data-animating] .handle {
|
||||
transition: left var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.frame[data-dragging] .handle {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* Fullscreen covers the frame while its underlying columns enter or exit. */
|
||||
.frame[data-rightbar-fullscreen],
|
||||
.frame[data-rightbar-fullscreen] .handle,
|
||||
.frame[data-rightbar-instant],
|
||||
.frame[data-rightbar-instant] .handle {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.frame[data-animating] .handle {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* The right column never clips: its occupant anchors a fixed-width panel to the
|
||||
* column's right edge (the frame's edge, which never moves) and lets the track
|
||||
* decide whether the centre makes room. With no track the panel hangs over the
|
||||
* centre from a zero-width column, and while the track animates the panel stays
|
||||
* where it is. The occupant draws its own left border.
|
||||
*/
|
||||
.rightbarCol {
|
||||
grid-column: 3;
|
||||
grid-row: 1;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.frame[data-sidebar-drawer] {
|
||||
--dsh-frame-leading-clearance: 84px;
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
:global(html:not([data-platform])) .frame[data-sidebar-drawer] {
|
||||
/* 12px seat offset + 28px opener + 8px gap; Desktop keeps room for two controls. */
|
||||
--dsh-frame-leading-clearance: 48px;
|
||||
}
|
||||
|
||||
.frame[data-sidebar-drawer] .mainPanel[data-global-panel] {
|
||||
padding-top: max(0px, calc(48px - var(--dsh-frame-top-clearance, 0px)));
|
||||
}
|
||||
|
||||
.frame[data-sidebar-drawer] .sidebarCol {
|
||||
position: absolute;
|
||||
grid-area: auto;
|
||||
inset: 0 auto 0 0;
|
||||
z-index: 71;
|
||||
box-sizing: border-box;
|
||||
border-right: none;
|
||||
transform: translateX(0);
|
||||
visibility: visible;
|
||||
transition: transform var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.frame[data-sidebar-drawer][data-sidebar-collapsed] .sidebarCol {
|
||||
transform: translateX(-100%);
|
||||
visibility: hidden;
|
||||
transition:
|
||||
transform var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
visibility 0s linear var(--ds-transition-duration-slow);
|
||||
}
|
||||
|
||||
.frame[data-sidebar-drawer][data-sidebar-instant] .sidebarCol {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.drawerMask {
|
||||
position: absolute;
|
||||
inset: var(--dsh-frame-chrome-top, 0px) 0 0;
|
||||
z-index: 70;
|
||||
background: var(--dsw-alias-bg-mask-1);
|
||||
backdrop-filter: var(--dsw-mask-blur);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.frame[data-sidebar-drawer]:not([data-sidebar-collapsed]) .drawerMask {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.frame[data-sidebar-drawer] .leadingSeat {
|
||||
top: 11px;
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
:global([data-platform='darwin']) .frame[data-sidebar-drawer] {
|
||||
--dsh-frame-leading-clearance: 160px;
|
||||
}
|
||||
|
||||
:global([data-platform='darwin']) .frame[data-sidebar-drawer] .leadingSeat {
|
||||
left: 88px;
|
||||
}
|
||||
|
||||
:global([data-platform='darwin'][data-fullscreen]) .frame[data-sidebar-drawer] {
|
||||
--dsh-frame-leading-clearance: 84px;
|
||||
}
|
||||
|
||||
:global([data-platform='darwin'][data-fullscreen]) .frame[data-sidebar-drawer] .leadingSeat {
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.frame[data-sidebar-drawer] .sidebarCol,
|
||||
.frame[data-sidebar-drawer][data-sidebar-collapsed] .sidebarCol,
|
||||
.drawerMask {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.bottomRow {
|
||||
grid-column: 1 / -1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
.overlayLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.overlayLayer > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/* Layout vars published by DSH shells that we don't mount wholesale. */
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--dsw-font-family);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-bg-base);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
html[data-ds-dark-theme],
|
||||
body[data-ds-dark-theme] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* WebKitGTK: component sheets still set corner-shape on pills; neutralize. */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
corner-shape: initial !important;
|
||||
}
|
||||
|
||||
/* Ensure dark aliases win even if a sheet only keyed body without attribute specificity. */
|
||||
html[data-ds-dark-theme] body,
|
||||
body[data-ds-dark-theme] {
|
||||
background-color: var(--dsw-alias-bg-base);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Sidebar row metrics (normally on SidebarRoot). */
|
||||
.hermit-sidebar {
|
||||
--dsh-sidebar-inline-padding: 8px;
|
||||
--dsh-sidebar-row-padding: 12px;
|
||||
--dsh-sidebar-label-gap: 6px;
|
||||
--dsh-sidebar-paint-inset: 3px;
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 6px var(--dsh-sidebar-inline-padding);
|
||||
background: var(--dsw-specific-sidebar-fill);
|
||||
}
|
||||
|
||||
/* Conversation width axis fallback when column width isn't measured. */
|
||||
.hermit-conversation-body {
|
||||
--dsh-conversation-column-width: 100%;
|
||||
--dsh-chat-content-width: clamp(680px, 64vw, 920px);
|
||||
--dsh-composer-card-max-width: calc(var(--dsh-chat-content-width) + 32px);
|
||||
--dsh-composer-side-clearance: 16px;
|
||||
--dsh-composer-dock-inset: 8px;
|
||||
--dsh-composer-text-max-height: 336px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hermit-conversation-body {
|
||||
--dsh-chat-content-width: min(calc(100% - 32px), 920px);
|
||||
--dsh-composer-card-max-width: min(calc(100% - 16px), 952px);
|
||||
--dsh-composer-side-clearance: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Engine / host status chip in conversation header */
|
||||
.hermit-engine-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: 8px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
letter-spacing: 0.02em;
|
||||
border: 0.5px solid var(--dsw-alias-border-l3);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hermit-engine-chip[data-mode="engine"] {
|
||||
color: var(--dsw-alias-state-success-primary, #3dd68c);
|
||||
border-color: color-mix(in srgb, var(--dsw-alias-state-success-primary, #3dd68c) 40%, transparent);
|
||||
}
|
||||
|
||||
.hermit-engine-chip[data-mode="host"],
|
||||
.hermit-engine-chip[data-mode="browser"] {
|
||||
color: var(--dsw-alias-state-warn-primary);
|
||||
border-color: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 40%, transparent);
|
||||
}
|
||||
|
||||
.hermit-engine-banner {
|
||||
width: 100%;
|
||||
max-width: var(--dsh-composer-card-max-width);
|
||||
margin: 0 auto 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--dsw-radius-md);
|
||||
background: var(--dsw-alias-state-warn-tertiary);
|
||||
color: var(--dsw-alias-state-warn-primary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.hermit-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 4px 14px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.01em;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/* Base variables referenced by the token sheets and component CSS but defined
|
||||
* upstream (deepsuite theme/global.css) — supplied here so the composite
|
||||
* --dsw-font-* variables resolve and motion rides the upstream curve. Code
|
||||
* font stack deliberately omits a bare `monospace` tail (Windows CJK falls
|
||||
* back to SimSun otherwise). */
|
||||
:root {
|
||||
--dsh-font-family-text-default: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
|
||||
'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
--dsw-font-family: var(--dsh-font-family-text-default);
|
||||
--dsw-font-family-brand: 'Montserrat', var(--dsh-font-family-text-default);
|
||||
--dsh-font-family-code-default: 'SF Mono', 'JetBrains Mono', 'Fira Code', Consolas,
|
||||
'Liberation Mono', Menlo, Courier, 'PingFang SC', 'Microsoft YaHei';
|
||||
--ds-font-family-code: var(--dsh-font-family-code-default);
|
||||
--ds-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--ds-transition-duration: 0.2s;
|
||||
--ds-transition-duration-fast: 0.1s;
|
||||
--ds-transition-duration-slow: 0.3s;
|
||||
--dsw-radius-xs: 4px;
|
||||
--dsw-radius-sm: 8px;
|
||||
--dsw-radius-md: 12px;
|
||||
--dsw-radius-lg: 16px;
|
||||
--dsw-radius-xl: 20px;
|
||||
--dsw-radius-panel: 28px;
|
||||
}
|
||||
|
||||
/* User font lists (--dsh-font-family-text/-code, written on body by the theme
|
||||
* boot script and ThemePresenter) lead the built-in stacks. An unset list
|
||||
* falls back to the built-in stack itself; the repeated families are inert. */
|
||||
body {
|
||||
--dsw-font-family: var(--dsh-font-family-text, var(--dsh-font-family-text-default)), var(--dsh-font-family-text-default);
|
||||
--ds-font-family-code: var(--dsh-font-family-code, var(--dsh-font-family-code-default)), var(--dsh-font-family-code-default);
|
||||
--dsw-alias-settings-card-fill: var(--dsw-alias-bg-layer-2);
|
||||
--dsw-alias-settings-card-stroke: var(--dsw-alias-border-l4);
|
||||
}
|
||||
|
||||
[data-dsh-automatic-focus]:focus,
|
||||
[data-dsh-automatic-focus]:focus:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/* Smooth corner curvature for every rounded surface. Inside the support
|
||||
* guard, all corners curve along superellipse(1.5) — between a circular arc
|
||||
* (round = superellipse(1)) and a squircle (superellipse(2)) — instead of the
|
||||
* default arc; engines without corner-shape keep circular corners because the
|
||||
* token and the rule exist only inside the guard.
|
||||
*
|
||||
* corner-shape does not inherit, so the universal selector applies the token
|
||||
* to elements and their ::before/::after; other pseudo-elements (for example
|
||||
* the ::-webkit-scrollbar parts) stay circular.
|
||||
*
|
||||
* Full-round shapes opt out where they are declared: a superellipse deforms a
|
||||
* circle into a squircle and squares off capsule ends, so every effectively
|
||||
* uncapped radius (50%, 100%, or a pill radius far above the box size) pairs
|
||||
* `corner-shape: round` with its `border-radius` in the owning component
|
||||
* sheet. The corner-shape stylesheet spec enforces that pairing. */
|
||||
@supports (corner-shape: superellipse(1.5)) {
|
||||
:root {
|
||||
--dsw-corner-shape: superellipse(1.5);
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
corner-shape: var(--dsw-corner-shape);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
/* Figma font-weight 510 (an SF Pro variable-font weight) always renders as
|
||||
font-weight: 500 in this UI — non-variable webfonts snap intermediate
|
||||
weights unpredictably across platforms. */
|
||||
body {
|
||||
--dsw-static-amber-100: rgb(254, 245, 231);
|
||||
--dsw-static-amber-400: rgb(247, 173, 49);
|
||||
--dsw-static-amber-500: rgb(245, 158, 11);
|
||||
--dsw-static-amber-600: rgb(221, 134, 41);
|
||||
--dsw-static-amber-900: rgb(39, 36, 31);
|
||||
--dsw-static-blue-100: rgb(219, 234, 254);
|
||||
--dsw-static-blue-300: rgb(147, 197, 253);
|
||||
--dsw-static-blue-400: rgb(96, 165, 250);
|
||||
--dsw-static-blue-450: rgb(77, 147, 248);
|
||||
--dsw-static-blue-500: rgb(59, 130, 246);
|
||||
--dsw-static-blue-50: rgb(239, 246, 255);
|
||||
--dsw-static-blue-50p: rgb(234, 243, 255);
|
||||
--dsw-static-blue-600: rgb(37, 99, 235);
|
||||
--dsw-static-blue-75: rgb(229, 240, 255);
|
||||
--dsw-static-blue-800: rgb(30, 64, 175);
|
||||
--dsw-static-blue-900: rgb(14, 48, 116);
|
||||
--dsw-static-blue-950: rgb(23, 37, 84);
|
||||
--dsw-static-deepseek-100: rgb(228, 237, 253);
|
||||
--dsw-static-deepseek-200: rgb(211, 226, 255);
|
||||
--dsw-static-deepseek-300: rgb(183, 200, 254);
|
||||
/* Brightened from the platform rgb(103, 158, 254) at the designer's request: legibility on the dark toast surface. */
|
||||
--dsw-static-deepseek-400: rgb(122, 170, 255);
|
||||
--dsw-static-deepseek-450: rgb(86, 134, 254);
|
||||
--dsw-static-deepseek-500: rgb(65, 118, 230);
|
||||
--dsw-static-deepseek-50: rgb(237, 243, 254);
|
||||
--dsw-static-deepseek-600: rgb(72, 104, 178);
|
||||
--dsw-static-deepseek-700-delete: rgb(47, 76, 143);
|
||||
--dsw-static-deepseek-800: rgb(52, 65, 91);
|
||||
--dsw-static-deepseek-900: rgb(40, 49, 66);
|
||||
--dsw-static-green-100: rgb(230, 250, 237);
|
||||
--dsw-static-green-400: rgb(78, 209, 126);
|
||||
--dsw-static-green-500: rgb(34, 197, 94);
|
||||
--dsw-static-green-500-a08: rgb(34 197 94 / 8%);
|
||||
--dsw-static-green-500-a12: rgb(34 197 94 / 12%);
|
||||
--dsw-static-green-900: rgb(35, 60, 44);
|
||||
--dsw-static-neutral-00: rgb(255, 255, 255);
|
||||
--dsw-static-neutral-1000: rgb(0, 0, 0);
|
||||
--dsw-static-neutral-100: rgb(245, 245, 245);
|
||||
--dsw-static-neutral-150: rgb(237, 237, 237);
|
||||
--dsw-static-neutral-200: rgb(229, 229, 229);
|
||||
--dsw-static-neutral-250: rgb(220, 220, 220);
|
||||
--dsw-static-neutral-300: rgb(212, 212, 212);
|
||||
--dsw-static-neutral-400: rgb(162, 164, 166);
|
||||
--dsw-static-neutral-500: rgb(127, 130, 135);
|
||||
--dsw-static-neutral-50: rgb(250, 250, 250);
|
||||
--dsw-static-neutral-550: rgb(101, 103, 107);
|
||||
--dsw-static-neutral-600: rgb(84, 85, 87);
|
||||
--dsw-static-neutral-700: rgb(60, 60, 61);
|
||||
--dsw-static-neutral-800: rgb(41, 41, 41);
|
||||
--dsw-static-neutral-850: rgb(33, 33, 35);
|
||||
--dsw-static-neutral-900: rgb(15, 15, 15);
|
||||
--dsw-static-neutral-bluish-00: rgb(255, 255, 255);
|
||||
--dsw-static-neutral-bluish-1000: rgb(15, 17, 21);
|
||||
--dsw-static-neutral-bluish-100: rgb(235, 238, 242);
|
||||
--dsw-static-neutral-bluish-150: rgb(233, 236, 242);
|
||||
--dsw-static-neutral-bluish-200: rgb(225, 229, 238);
|
||||
--dsw-static-neutral-bluish-300: rgb(207, 211, 214);
|
||||
--dsw-static-neutral-bluish-310: rgb(202, 210, 219);
|
||||
--dsw-static-neutral-bluish-400: rgb(173, 178, 184);
|
||||
--dsw-static-neutral-bluish-500: rgb(151, 157, 166);
|
||||
--dsw-static-neutral-bluish-50: rgb(249, 250, 251);
|
||||
--dsw-static-neutral-bluish-55: rgb(245, 247, 250);
|
||||
--dsw-static-neutral-bluish-600: rgb(129, 133, 140);
|
||||
--dsw-static-neutral-bluish-60: rgb(245, 246, 247);
|
||||
--dsw-static-neutral-bluish-700: rgb(97, 102, 107);
|
||||
--dsw-static-neutral-bluish-750: rgb(67, 69, 74);
|
||||
--dsw-static-neutral-bluish-75: rgb(241, 243, 245);
|
||||
--dsw-static-neutral-bluish-800: rgb(53, 54, 56);
|
||||
--dsw-static-neutral-bluish-850: rgb(44, 44, 46);
|
||||
--dsw-static-neutral-bluish-875: rgb(35, 35, 36);
|
||||
--dsw-static-neutral-bluish-900: rgb(27, 27, 28);
|
||||
--dsw-static-neutral-bluish-90: rgb(237, 240, 244);
|
||||
--dsw-static-neutral-bluish-950: rgb(21, 21, 23);
|
||||
--dsw-static-red-100: rgb(254, 226, 226);
|
||||
--dsw-static-red-400: rgb(242, 90, 90);
|
||||
--dsw-static-red-400-a12: rgb(242 90 90 / 12%);
|
||||
--dsw-static-red-500: rgb(239, 68, 68);
|
||||
--dsw-static-red-50: rgb(254, 242, 242);
|
||||
--dsw-static-red-600: rgb(236, 19, 19);
|
||||
--dsw-static-red-600-a08: rgb(236 19 19 / 8%);
|
||||
--dsw-static-red-900: rgb(87, 12, 12);
|
||||
--dsw-static-violet-400: rgb(167, 139, 250);
|
||||
--dsw-static-violet-600: rgb(124, 58, 237);
|
||||
}
|
||||
|
||||
body[data-ds-dark-theme] {
|
||||
--dsw-static-amber-100: rgb(254, 245, 231);
|
||||
--dsw-static-amber-400: rgb(247, 173, 49);
|
||||
--dsw-static-amber-500: rgb(245, 158, 11);
|
||||
--dsw-static-amber-600: rgb(221, 134, 41);
|
||||
--dsw-static-amber-900: rgb(39, 36, 31);
|
||||
--dsw-static-blue-100: rgb(219, 234, 254);
|
||||
--dsw-static-blue-300: rgb(147, 197, 253);
|
||||
--dsw-static-blue-400: rgb(96, 165, 250);
|
||||
--dsw-static-blue-450: rgb(77, 147, 248);
|
||||
--dsw-static-blue-500: rgb(59, 130, 246);
|
||||
--dsw-static-blue-50: rgb(239, 246, 255);
|
||||
--dsw-static-blue-50p: rgb(234, 243, 255);
|
||||
--dsw-static-blue-600: rgb(37, 99, 235);
|
||||
--dsw-static-blue-75: rgb(229, 240, 255);
|
||||
--dsw-static-blue-800: rgb(30, 64, 175);
|
||||
--dsw-static-blue-900: rgb(14, 48, 116);
|
||||
--dsw-static-blue-950: rgb(23, 37, 84);
|
||||
--dsw-static-deepseek-100: rgb(228, 237, 253);
|
||||
--dsw-static-deepseek-200: rgb(211, 226, 255);
|
||||
--dsw-static-deepseek-300: rgb(183, 200, 254);
|
||||
/* Brightened from the platform rgb(103, 158, 254) at the designer's request: legibility on the dark toast surface. */
|
||||
--dsw-static-deepseek-400: rgb(122, 170, 255);
|
||||
--dsw-static-deepseek-450: rgb(86, 134, 254);
|
||||
--dsw-static-deepseek-500: rgb(65, 118, 230);
|
||||
--dsw-static-deepseek-50: rgb(237, 243, 254);
|
||||
--dsw-static-deepseek-600: rgb(72, 104, 178);
|
||||
--dsw-static-deepseek-700-delete: rgb(47, 76, 143);
|
||||
--dsw-static-deepseek-800: rgb(52, 65, 91);
|
||||
--dsw-static-deepseek-900: rgb(40, 49, 66);
|
||||
--dsw-static-green-100: rgb(230, 250, 237);
|
||||
--dsw-static-green-400: rgb(78, 209, 126);
|
||||
--dsw-static-green-500: rgb(34, 197, 94);
|
||||
--dsw-static-green-500-a08: rgb(34 197 94 / 8%);
|
||||
--dsw-static-green-500-a12: rgb(34 197 94 / 12%);
|
||||
--dsw-static-green-900: rgb(35, 60, 44);
|
||||
--dsw-static-neutral-00: rgb(255, 255, 255);
|
||||
--dsw-static-neutral-1000: rgb(0, 0, 0);
|
||||
--dsw-static-neutral-100: rgb(245, 245, 245);
|
||||
--dsw-static-neutral-150: rgb(237, 237, 237);
|
||||
--dsw-static-neutral-200: rgb(229, 229, 229);
|
||||
--dsw-static-neutral-250: rgb(220, 220, 220);
|
||||
--dsw-static-neutral-300: rgb(212, 212, 212);
|
||||
--dsw-static-neutral-400: rgb(162, 164, 166);
|
||||
--dsw-static-neutral-500: rgb(127, 130, 135);
|
||||
--dsw-static-neutral-50: rgb(250, 250, 250);
|
||||
--dsw-static-neutral-550: rgb(101, 103, 107);
|
||||
--dsw-static-neutral-600: rgb(84, 85, 87);
|
||||
--dsw-static-neutral-700: rgb(60, 60, 61);
|
||||
--dsw-static-neutral-800: rgb(41, 41, 41);
|
||||
--dsw-static-neutral-850: rgb(33, 33, 35);
|
||||
--dsw-static-neutral-900: rgb(15, 15, 15);
|
||||
--dsw-static-neutral-bluish-00: rgb(255, 255, 255);
|
||||
--dsw-static-neutral-bluish-1000: rgb(15, 17, 21);
|
||||
--dsw-static-neutral-bluish-100: rgb(235, 238, 242);
|
||||
--dsw-static-neutral-bluish-150: rgb(233, 236, 242);
|
||||
--dsw-static-neutral-bluish-200: rgb(225, 229, 238);
|
||||
--dsw-static-neutral-bluish-300: rgb(207, 211, 214);
|
||||
--dsw-static-neutral-bluish-310: rgb(202, 210, 219);
|
||||
--dsw-static-neutral-bluish-400: rgb(173, 178, 184);
|
||||
--dsw-static-neutral-bluish-500: rgb(151, 157, 166);
|
||||
--dsw-static-neutral-bluish-50: rgb(249, 250, 251);
|
||||
--dsw-static-neutral-bluish-55: rgb(245, 247, 250);
|
||||
--dsw-static-neutral-bluish-600: rgb(129, 133, 140);
|
||||
--dsw-static-neutral-bluish-60: rgb(249, 250, 251);
|
||||
--dsw-static-neutral-bluish-700: rgb(97, 102, 107);
|
||||
--dsw-static-neutral-bluish-750: rgb(67, 69, 74);
|
||||
--dsw-static-neutral-bluish-75: rgb(241, 243, 245);
|
||||
--dsw-static-neutral-bluish-800: rgb(53, 54, 56);
|
||||
--dsw-static-neutral-bluish-850: rgb(44, 44, 46);
|
||||
--dsw-static-neutral-bluish-875: rgb(35, 35, 36);
|
||||
--dsw-static-neutral-bluish-900: rgb(27, 27, 28);
|
||||
--dsw-static-neutral-bluish-90: rgb(237, 240, 244);
|
||||
--dsw-static-neutral-bluish-950: rgb(21, 21, 23);
|
||||
--dsw-static-red-100: rgb(254, 226, 226);
|
||||
--dsw-static-red-400: rgb(242, 90, 90);
|
||||
--dsw-static-red-400-a12: rgb(242 90 90 / 12%);
|
||||
--dsw-static-red-500: rgb(239, 68, 68);
|
||||
--dsw-static-red-50: rgb(254, 242, 242);
|
||||
--dsw-static-red-600: rgb(236, 19, 19);
|
||||
--dsw-static-red-600-a08: rgb(236 19 19 / 8%);
|
||||
--dsw-static-red-900: rgb(87, 12, 12);
|
||||
--dsw-static-violet-400: rgb(167, 139, 250);
|
||||
--dsw-static-violet-600: rgb(124, 58, 237);
|
||||
}
|
||||
|
||||
body {
|
||||
--dsw-alias-bg-base: var(--dsw-static-neutral-bluish-00);
|
||||
--dsw-alias-bg-document-preview: var(--dsw-static-neutral-bluish-100);
|
||||
--dsw-alias-bg-document-selection: color-mix(in srgb, var(--dsw-static-blue-500) 40%, transparent);
|
||||
--dsw-alias-label-document-preview: var(--dsw-static-neutral-bluish-700);
|
||||
--dsw-alias-bg-layer-1: var(--dsw-static-neutral-bluish-00);
|
||||
--dsw-alias-bg-layer-2: var(--dsw-static-neutral-bluish-00);
|
||||
--dsw-alias-bg-layer-3: var(--dsw-static-neutral-bluish-00);
|
||||
--dsw-alias-bg-mask-1: rgba(0, 0, 0, 0.24);
|
||||
--dsw-alias-bg-mask-2: rgba(0, 0, 0, 0.12);
|
||||
--dsw-alias-bg-mask-3: rgba(0, 0, 0, 0.48);
|
||||
--dsw-alias-bg-mask-photo: rgba(0, 0, 0, 0.88);
|
||||
--dsw-alias-bg-mask-drop: rgba(255, 255, 255, 0.7);
|
||||
--dsw-alias-bg-module-platform: var(--dsw-static-neutral-bluish-60);
|
||||
--dsw-alias-bg-multi-select: var(--dsw-static-neutral-bluish-60);
|
||||
--dsw-alias-bg-overlay: var(--dsw-static-neutral-bluish-150);
|
||||
--dsw-alias-bg-skeleton: rgba(0, 0, 0, 0.04);
|
||||
--dsw-alias-border-inverted2: rgba(0, 0, 0, 0);
|
||||
--dsw-alias-border-inverted: rgba(0, 0, 0, 0);
|
||||
--dsw-alias-border-l1: rgba(0, 0, 0, 0.04);
|
||||
--dsw-alias-border-l2-darkmode-thin: rgba(0, 0, 0, 0.1);
|
||||
--dsw-alias-border-l2: rgba(0, 0, 0, 0.1);
|
||||
--dsw-alias-border-l3: rgba(0, 0, 0, 0.12);
|
||||
--dsw-alias-border-l4: rgba(0, 0, 0, 0.16);
|
||||
--dsw-alias-brand-primary-invert: var(--dsw-static-neutral-bluish-1000);
|
||||
--dsw-alias-brand-primary-new-colorprimary-new-color: rgb(65, 118, 230);
|
||||
--dsw-alias-brand-primary: var(--dsw-static-neutral-bluish-1000);
|
||||
--dsw-alias-brand-text: var(--dsw-static-neutral-bluish-1000);
|
||||
--dsw-alias-button-contrast-fill: var(--dsw-static-neutral-bluish-700);
|
||||
--dsw-alias-button-elevated-fill: var(--dsw-static-neutral-bluish-00);
|
||||
--dsw-alias-button-floating-fill: var(--dsw-static-neutral-bluish-00);
|
||||
--dsw-alias-button-floating-hover: var(--dsw-static-neutral-bluish-75);
|
||||
--dsw-alias-button-ghost-active-border: var(--dsw-static-neutral-bluish-500);
|
||||
--dsw-alias-button-ghost-active-fill: var(--dsw-static-neutral-bluish-100);
|
||||
--dsw-alias-button-ghost-active-hover: var(--dsw-static-neutral-bluish-150);
|
||||
--dsw-alias-button-info-fill: var(--dsw-static-deepseek-500);
|
||||
--dsw-alias-button-info-hover: var(--dsw-static-deepseek-400);
|
||||
--dsw-alias-button-primary-dimmed: var(--dsw-static-neutral-bluish-100);
|
||||
--dsw-alias-button-primary-fill: var(--dsw-alias-brand-primary);
|
||||
--dsw-alias-button-primary-hover: var(--dsw-static-neutral-bluish-750);
|
||||
--dsw-alias-button-tool-bar-fill-invisible: rgba(31, 31, 31, 0.36);
|
||||
--dsw-alias-button-tool-bar-fill: rgba(84, 85, 87, 0.5);
|
||||
--dsw-alias-button-tool-bar-hover: rgba(84, 85, 87, 0.6);
|
||||
--dsw-alias-illustration-background: var(--dsw-static-neutral-bluish-55);
|
||||
--dsw-alias-illustration-backing: var(--dsw-static-neutral-bluish-310);
|
||||
--dsw-alias-illustration-detail: var(--dsw-static-neutral-bluish-310);
|
||||
--dsw-alias-illustration-inset: var(--dsw-alias-illustration-surface);
|
||||
--dsw-alias-illustration-shadow: var(--dsw-alias-illustration-surface);
|
||||
--dsw-alias-illustration-surface: var(--dsw-static-neutral-bluish-90);
|
||||
--dsw-alias-interactive-bg-active: rgba(38, 49, 72, 0.1);
|
||||
--dsw-alias-interactive-bg-hover-accent: rgba(38, 49, 72, 0.14);
|
||||
--dsw-alias-interactive-bg-hover-danger: rgba(236, 19, 19, 0.05);
|
||||
--dsw-alias-interactive-bg-hover-solid: var(--dsw-static-neutral-bluish-75);
|
||||
--dsw-alias-interactive-bg-hover: rgba(38, 49, 72, 0.06);
|
||||
--dsw-alias-label-caption: var(--dsw-static-neutral-bluish-400);
|
||||
--dsw-alias-label-deep-diving: color-mix(in srgb, var(--dsw-static-deepseek-500) 70%, var(--dsw-static-blue-950));
|
||||
--dsw-alias-label-deep-diving-shimmer: color-mix(in srgb, var(--dsw-static-deepseek-500) 30%, var(--dsw-static-blue-950));
|
||||
--dsw-alias-label-dimmed: var(--dsw-static-neutral-bluish-200);
|
||||
--dsw-alias-label-primary-bluish: var(--dsw-static-blue-900);
|
||||
--dsw-alias-label-primary-dimmed: var(--dsw-static-neutral-bluish-950);
|
||||
--dsw-alias-label-primary-foreground: var(--dsw-static-neutral-bluish-00);
|
||||
--dsw-alias-label-primary-inverted: var(--dsw-static-neutral-bluish-00);
|
||||
--dsw-alias-label-primary: var(--dsw-static-neutral-bluish-1000);
|
||||
--dsw-alias-label-secondary: var(--dsw-static-neutral-bluish-700);
|
||||
--dsw-alias-label-shimmer: color-mix(in srgb, var(--dsw-static-neutral-1000) 30%, transparent);
|
||||
--dsw-alias-label-tertiary: var(--dsw-static-neutral-bluish-600);
|
||||
--dsw-alias-menu-icon: var(--dsw-static-neutral-bluish-800);
|
||||
--dsw-alias-link: var(--dsw-static-deepseek-500);
|
||||
--dsw-alias-markdown-citation: var(--dsw-static-neutral-bluish-100);
|
||||
--dsw-alias-markdown-code-block-banner: var(--dsw-static-neutral-bluish-50);
|
||||
--dsw-alias-markdown-code-block: var(--dsw-static-neutral-bluish-50);
|
||||
--dsw-alias-markdown-code-segment-selected: var(--dsw-static-neutral-bluish-00);
|
||||
--dsw-alias-markdown-code-segment-unselected: var(--dsw-static-neutral-bluish-75);
|
||||
--dsw-alias-markdown-inline-code: var(--dsw-static-neutral-50);
|
||||
--dsw-alias-markdown-placeholder: var(--dsw-static-neutral-bluish-60);
|
||||
--dsw-alias-markdown-tag: var(--dsw-static-neutral-bluish-75);
|
||||
--dsw-alias-scrollbar-bg-l1: var(--dsw-static-neutral-200);
|
||||
--dsw-alias-scrollbar-bg-l2: var(--dsw-static-neutral-200);
|
||||
--dsw-alias-scrollbar-hover-l1: var(--dsw-static-neutral-300);
|
||||
--dsw-alias-scrollbar-hover-l2: var(--dsw-static-neutral-300);
|
||||
--dsw-alias-state-business-primary: var(--dsw-static-deepseek-500);
|
||||
--dsw-alias-state-business-tertiary: var(--dsw-static-deepseek-100);
|
||||
--dsw-alias-code-diff-added: var(--dsw-static-green-500-a08);
|
||||
--dsw-alias-code-diff-deleted: var(--dsw-static-red-600-a08);
|
||||
--dsw-alias-file-diff-added-bg: rgb(230, 244, 231);
|
||||
--dsw-alias-file-diff-added-gutter: rgb(237, 247, 237);
|
||||
--dsw-alias-file-diff-added-marker: rgb(1, 162, 65);
|
||||
--dsw-alias-file-diff-deleted-bg: rgb(252, 230, 226);
|
||||
--dsw-alias-file-diff-deleted-gutter: rgb(253, 236, 233);
|
||||
--dsw-alias-file-diff-deleted-marker: rgb(186, 39, 35);
|
||||
--dsw-alias-state-done-primary: var(--dsw-static-violet-600);
|
||||
--dsw-alias-state-error-primary: var(--dsw-static-red-600);
|
||||
--dsw-alias-state-error-secondary: var(--dsw-static-red-400);
|
||||
--dsw-alias-state-idle-primary: var(--dsw-static-neutral-300);
|
||||
--dsw-alias-state-success-primary: var(--dsw-static-green-500);
|
||||
--dsw-alias-state-success-secondary: var(--dsw-static-green-400);
|
||||
--dsw-alias-state-success-tertiary: var(--dsw-static-green-100);
|
||||
--dsw-alias-state-warn-label: var(--dsw-static-amber-600);
|
||||
--dsw-alias-state-warn-primary: var(--dsw-static-amber-500);
|
||||
--dsw-alias-state-warn-secondary: var(--dsw-static-amber-400);
|
||||
--dsw-alias-state-warn-tertiary: var(--dsw-static-amber-100);
|
||||
--dsw-alias-switch-thumb: var(--dsw-static-neutral-bluish-00);
|
||||
--dsw-alias-toast-bg: var(--dsw-static-neutral-bluish-800);
|
||||
--dsw-alias-toast-label: var(--dsw-static-neutral-bluish-00);
|
||||
--dsw-alias-tooltip-bg: var(--dsw-static-neutral-bluish-850);
|
||||
--dsw-alias-tooltip-key-bg: color-mix(in srgb, var(--dsw-alias-tooltip-bg), white 18%);
|
||||
--dsw-alias-turn-trigger-bg: var(--dsw-alias-markdown-code-block);
|
||||
--dsw-alias-turn-trigger-bg-hover: var(--dsw-alias-interactive-bg-hover);
|
||||
--dsw-specific-bubble-highlight: var(--dsw-static-deepseek-200);
|
||||
--dsw-specific-bubble: var(--dsw-static-deepseek-50);
|
||||
--dsw-specific-input-major: var(--dsw-static-neutral-bluish-00);
|
||||
--dsw-specific-login-input: var(--dsw-static-neutral-bluish-50);
|
||||
--dsw-menu-surface-fill: rgba(252, 253, 254, 0.7);
|
||||
--dsw-alias-menu-group-header-fill: rgba(248, 249, 250, 0.94);
|
||||
--dsw-specific-menu: var(--dsw-menu-surface-fill);
|
||||
--dsw-specific-selector: var(--dsw-static-neutral-bluish-60);
|
||||
--dsw-specific-sidebar-fill: var(--dsw-static-neutral-bluish-50);
|
||||
--dsw-specific-sidebar-nav-item-active-accent: var(--dsw-static-deepseek-100);
|
||||
--dsw-specific-sidebar-nav-item-active: var(--dsw-static-neutral-bluish-100);
|
||||
--dsw-specific-tip: var(--dsw-static-neutral-bluish-60);
|
||||
|
||||
background-color: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
body[data-ds-dark-theme] {
|
||||
--dsw-alias-bg-base: var(--dsw-static-neutral-bluish-950);
|
||||
--dsw-alias-bg-document-preview: var(--dsw-static-neutral-bluish-950);
|
||||
--dsw-alias-bg-document-selection: color-mix(in srgb, var(--dsw-static-blue-500) 40%, transparent);
|
||||
--dsw-alias-label-document-preview: var(--dsw-static-neutral-bluish-300);
|
||||
--dsw-alias-bg-layer-1: var(--dsw-static-neutral-bluish-875);
|
||||
--dsw-alias-bg-layer-2: var(--dsw-static-neutral-bluish-850);
|
||||
--dsw-alias-bg-layer-3: var(--dsw-static-neutral-bluish-800);
|
||||
--dsw-alias-bg-mask-1: rgba(0, 0, 0, 0.5);
|
||||
--dsw-alias-bg-mask-2: rgba(0, 0, 0, 0.2);
|
||||
--dsw-alias-bg-mask-3: rgba(0, 0, 0, 0.48);
|
||||
--dsw-alias-bg-mask-photo: rgba(0, 0, 0, 0.88);
|
||||
--dsw-alias-bg-mask-drop: rgba(39, 39, 48, 0.7);
|
||||
--dsw-alias-bg-module-platform: var(--dsw-static-neutral-bluish-800);
|
||||
--dsw-alias-bg-multi-select: var(--dsw-static-neutral-850);
|
||||
--dsw-alias-bg-overlay: var(--dsw-static-neutral-bluish-700);
|
||||
--dsw-alias-bg-skeleton: rgba(255, 255, 255, 0.08);
|
||||
--dsw-alias-border-inverted2: rgba(255, 255, 255, 0.08);
|
||||
--dsw-alias-border-inverted: rgba(255, 255, 255, 0.06);
|
||||
--dsw-alias-border-l1: rgba(255, 255, 255, 0.06);
|
||||
--dsw-alias-border-l2-darkmode-thin: rgba(255, 255, 255, 0.06);
|
||||
--dsw-alias-border-l2: rgba(255, 255, 255, 0.12);
|
||||
--dsw-alias-border-l3: rgba(255, 255, 255, 0.16);
|
||||
--dsw-alias-border-l4: rgba(255, 255, 255, 0.2);
|
||||
--dsw-alias-brand-primary-invert: var(--dsw-static-neutral-bluish-50);
|
||||
--dsw-alias-brand-primary-new-colorprimary-new-color: var(--dsw-static-deepseek-450);
|
||||
--dsw-alias-brand-primary: var(--dsw-static-neutral-bluish-50);
|
||||
--dsw-alias-brand-text: var(--dsw-static-neutral-bluish-50);
|
||||
--dsw-alias-button-contrast-fill: var(--dsw-static-neutral-bluish-50);
|
||||
--dsw-alias-button-elevated-fill: var(--dsw-static-neutral-bluish-750);
|
||||
--dsw-alias-button-floating-fill: var(--dsw-static-neutral-bluish-850);
|
||||
--dsw-alias-button-floating-hover: var(--dsw-static-neutral-bluish-800);
|
||||
--dsw-alias-button-ghost-active-border: var(--dsw-static-neutral-bluish-600);
|
||||
--dsw-alias-button-ghost-active-fill: var(--dsw-static-neutral-bluish-750);
|
||||
--dsw-alias-button-ghost-active-hover: var(--dsw-static-neutral-bluish-700);
|
||||
--dsw-alias-button-info-fill: var(--dsw-static-deepseek-400);
|
||||
--dsw-alias-button-info-hover: var(--dsw-static-deepseek-500);
|
||||
--dsw-alias-button-primary-dimmed: var(--dsw-static-neutral-bluish-750);
|
||||
--dsw-alias-button-primary-fill: var(--dsw-alias-brand-primary);
|
||||
--dsw-alias-button-primary-hover: var(--dsw-static-neutral-bluish-100);
|
||||
--dsw-alias-button-tool-bar-fill-invisible: rgba(31, 31, 31, 0.36);
|
||||
--dsw-alias-button-tool-bar-fill: rgba(84, 85, 87, 0.5);
|
||||
--dsw-alias-button-tool-bar-hover: rgba(84, 85, 87, 0.6);
|
||||
--dsw-alias-illustration-background: var(--dsw-static-neutral-bluish-850);
|
||||
--dsw-alias-illustration-backing: var(--dsw-static-neutral-bluish-750);
|
||||
--dsw-alias-illustration-detail: var(--dsw-static-neutral-bluish-600);
|
||||
--dsw-alias-illustration-inset: var(--dsw-alias-illustration-backing);
|
||||
--dsw-alias-illustration-shadow: var(--dsw-alias-illustration-backing);
|
||||
--dsw-alias-illustration-surface: var(--dsw-static-neutral-bluish-700);
|
||||
--dsw-alias-interactive-bg-active: rgba(255, 255, 255, 0.14);
|
||||
--dsw-alias-interactive-bg-hover-accent: rgba(255, 255, 255, 0.24);
|
||||
--dsw-alias-interactive-bg-hover-danger: rgba(242, 90, 90, 0.15);
|
||||
--dsw-alias-interactive-bg-hover-solid: var(--dsw-static-neutral-bluish-800);
|
||||
--dsw-alias-interactive-bg-hover: rgba(255, 255, 255, 0.08);
|
||||
--dsw-alias-label-caption: var(--dsw-static-neutral-bluish-600);
|
||||
--dsw-alias-label-deep-diving: color-mix(in srgb, var(--dsw-static-deepseek-450) 55%, var(--dsw-static-neutral-bluish-400));
|
||||
--dsw-alias-label-deep-diving-shimmer: color-mix(in srgb, var(--dsw-static-blue-300) 65%, var(--dsw-static-deepseek-400));
|
||||
--dsw-alias-label-dimmed: var(--dsw-static-neutral-bluish-750);
|
||||
--dsw-alias-label-primary-bluish: var(--dsw-static-neutral-bluish-50);
|
||||
--dsw-alias-label-primary-dimmed: var(--dsw-static-neutral-bluish-100);
|
||||
--dsw-alias-label-primary-foreground: var(--dsw-static-neutral-bluish-1000);
|
||||
--dsw-alias-label-primary-inverted: var(--dsw-static-neutral-bluish-800);
|
||||
--dsw-alias-label-primary: var(--dsw-static-neutral-bluish-50);
|
||||
--dsw-alias-label-secondary: var(--dsw-static-neutral-bluish-300);
|
||||
--dsw-alias-label-shimmer: color-mix(in srgb, var(--dsw-static-neutral-00) 45%, transparent);
|
||||
--dsw-alias-label-tertiary: var(--dsw-static-neutral-bluish-400);
|
||||
--dsw-alias-menu-icon: var(--dsw-alias-label-primary-dimmed);
|
||||
--dsw-alias-link: var(--dsw-static-deepseek-400);
|
||||
--dsw-alias-markdown-citation: var(--dsw-static-neutral-bluish-800);
|
||||
--dsw-alias-markdown-code-block-banner: var(--dsw-static-neutral-bluish-850);
|
||||
--dsw-alias-markdown-code-block: var(--dsw-static-neutral-bluish-900);
|
||||
--dsw-alias-markdown-code-segment-selected: var(--dsw-static-neutral-bluish-800);
|
||||
--dsw-alias-markdown-code-segment-unselected: var(--dsw-static-neutral-bluish-900);
|
||||
--dsw-alias-markdown-inline-code: var(--dsw-static-neutral-800);
|
||||
--dsw-alias-markdown-placeholder: var(--dsw-static-neutral-bluish-850);
|
||||
--dsw-alias-markdown-tag: var(--dsw-static-neutral-bluish-850);
|
||||
--dsw-alias-scrollbar-bg-l1: var(--dsw-static-neutral-700);
|
||||
--dsw-alias-scrollbar-bg-l2: var(--dsw-static-neutral-600);
|
||||
--dsw-alias-scrollbar-hover-l1: var(--dsw-static-neutral-600);
|
||||
--dsw-alias-scrollbar-hover-l2: var(--dsw-static-neutral-550);
|
||||
--dsw-alias-state-business-primary: var(--dsw-static-deepseek-400);
|
||||
--dsw-alias-state-business-tertiary: var(--dsw-static-deepseek-800);
|
||||
--dsw-alias-code-diff-added: var(--dsw-static-green-500-a12);
|
||||
--dsw-alias-code-diff-deleted: var(--dsw-static-red-400-a12);
|
||||
--dsw-alias-file-diff-added-bg: rgb(31, 49, 36);
|
||||
--dsw-alias-file-diff-added-gutter: rgb(19, 32, 22);
|
||||
--dsw-alias-file-diff-added-marker: rgb(65, 201, 119);
|
||||
--dsw-alias-file-diff-deleted-bg: rgb(60, 31, 27);
|
||||
--dsw-alias-file-diff-deleted-gutter: rgb(40, 19, 14);
|
||||
--dsw-alias-file-diff-deleted-marker: rgb(250, 66, 62);
|
||||
--dsw-alias-state-done-primary: var(--dsw-static-violet-400);
|
||||
--dsw-alias-state-error-primary: var(--dsw-static-red-400);
|
||||
--dsw-alias-state-error-secondary: var(--dsw-static-red-400);
|
||||
--dsw-alias-state-idle-primary: var(--dsw-static-neutral-600);
|
||||
--dsw-alias-state-success-primary: var(--dsw-static-green-500);
|
||||
--dsw-alias-state-success-secondary: var(--dsw-static-green-400);
|
||||
--dsw-alias-state-success-tertiary: var(--dsw-static-green-900);
|
||||
--dsw-alias-state-warn-label: var(--dsw-static-amber-600);
|
||||
--dsw-alias-state-warn-primary: var(--dsw-static-amber-500);
|
||||
--dsw-alias-state-warn-secondary: var(--dsw-static-amber-400);
|
||||
--dsw-alias-state-warn-tertiary: var(--dsw-static-amber-900);
|
||||
--dsw-alias-switch-thumb: var(--dsw-static-neutral-bluish-400);
|
||||
--dsw-alias-toast-bg: var(--dsw-static-neutral-bluish-750);
|
||||
--dsw-alias-toast-label: var(--dsw-static-neutral-bluish-00);
|
||||
--dsw-alias-turn-trigger-bg: var(--dsw-alias-interactive-bg-hover);
|
||||
--dsw-alias-turn-trigger-bg-hover: var(--dsw-alias-interactive-bg-active);
|
||||
--dsw-alias-tooltip-bg: var(--dsw-static-neutral-bluish-750);
|
||||
--dsw-alias-tooltip-key-bg: color-mix(in srgb, var(--dsw-alias-tooltip-bg), white 18%);
|
||||
--dsw-specific-bubble-highlight: var(--dsw-static-neutral-bluish-750);
|
||||
--dsw-specific-bubble: var(--dsw-static-neutral-bluish-850);
|
||||
--dsw-specific-input-major: var(--dsw-static-neutral-bluish-850);
|
||||
--dsw-specific-login-input: var(--dsw-static-neutral-bluish-900);
|
||||
--dsw-menu-surface-fill: rgba(48, 49, 54, 0.64);
|
||||
--dsw-alias-menu-group-header-fill: rgba(48, 49, 54, 0.94);
|
||||
--dsw-specific-menu: var(--dsw-menu-surface-fill);
|
||||
--dsw-specific-selector: var(--dsw-static-neutral-bluish-800);
|
||||
--dsw-specific-sidebar-fill: var(--dsw-static-neutral-bluish-900);
|
||||
--dsw-specific-sidebar-nav-item-active-accent: var(--dsw-static-neutral-bluish-800);
|
||||
--dsw-specific-sidebar-nav-item-active: var(--dsw-static-neutral-bluish-750);
|
||||
--dsw-specific-tip: var(--dsw-static-neutral-bluish-800);
|
||||
}
|
||||
|
||||
/* Overlays without MenuSurface's opaque backing cannot blur page content over
|
||||
* native vibrancy reliably, so their fill stays nearly opaque on macOS. */
|
||||
html[data-platform='darwin'] body {
|
||||
--dsw-specific-menu: rgba(248, 249, 250, 0.94);
|
||||
}
|
||||
|
||||
html[data-platform='darwin'] body[data-ds-dark-theme] {
|
||||
--dsw-specific-menu: rgba(48, 49, 54, 0.94);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/* Components own offsets; dense tables and toolbars may keep a 1px ring. */
|
||||
:root {
|
||||
--dsw-focus-ring-width: 2px;
|
||||
}
|
||||
|
||||
/* The fallback names the colour and the standard width, never the style: a control that
|
||||
* disables its outline keeps `none` and paints nothing. Without the width, Chromium's
|
||||
* unnamed ring stayed at `auto 1px` while every declared ring used the standard geometry.
|
||||
* Declared rings also read this color, including rings on descendants and pseudo-elements. */
|
||||
:focus-visible {
|
||||
outline-color: var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-width: var(--dsw-focus-ring-width);
|
||||
}
|
||||
|
||||
/* ui-primitives/input-modality publishes the attribute. Editable controls keep
|
||||
* their focus feedback on click; other controls reveal rings on keyboard
|
||||
* navigation. Only ring paint is suppressed, not elevation or selected borders. */
|
||||
html[data-input-modality='pointer'] body :focus-visible:not(:read-write) {
|
||||
--dsw-focus-ring-color: transparent;
|
||||
outline-color: transparent;
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
body {
|
||||
--dsw-linear-gradient-think: linear-gradient(180deg, #fff 20.19%, rgba(255, 255, 255, 0) 100%);
|
||||
--dsw-linear-think-select: linear-gradient(180deg, #f5f6f7 20.19%, rgba(245, 246, 247, 0) 100%);
|
||||
/* 阴影 */
|
||||
--dsw-shadow-lv1: 0 2px 4px 0 rgba(0, 0, 0, 0.05);
|
||||
--dsw-shadow-lv1-blur: 0 4px 12px 0 rgba(0, 0, 0, 0.02);
|
||||
--dsw-shadow-lv2: 0 4px 12px 0 rgba(0, 0, 0, 0.02), 0 2px 8px 0 rgba(0, 0, 0, 0.04);
|
||||
--dsw-shadow-lv3:
|
||||
0 0 1px 0 rgba(0, 0, 0, 0.2), 0 0 4px 0 rgba(0, 0, 0, 0.02), 0 12px 32px 0 rgba(0, 0, 0, 0.08);
|
||||
/* Elevation:浮层/面板/面型按钮的描边+投影三件套。0.5px 发丝描边画在
|
||||
box-shadow 里(组件配 border: 0,不占布局),叠两层极淡柔光;深色主题下
|
||||
柔光几乎不可见,由描边承担分离。描边颜色经 --dsw-elevation-stroke-color
|
||||
间接,组件可重绑(菜单面重绑最浅的 l1、输入框重绑 l2,输入框的
|
||||
workspace-trigger 态置 transparent 只保留柔光)。soft 供输入框:更大
|
||||
模糊、更低透明度的柔光。默认色只声明在 body 上,让表面的重绑沿继承
|
||||
传给真正消费投影的后代。 */
|
||||
--dsw-elevation-stroke-color: var(--dsw-alias-border-l4);
|
||||
/* Blocking masks dim the page; menu backgrounds retain their blur. */
|
||||
--dsw-mask-blur: none;
|
||||
--dsw-menu-backdrop-filter: blur(40px) saturate(150%);
|
||||
}
|
||||
|
||||
body[data-ds-dark-theme] [data-menu-material] {
|
||||
--dsw-elevation-stroke-color: var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
/* 派生 elevation 值逐元素声明而非从 body 继承:继承传下来的是在 body 上就
|
||||
已替换完 var() 的值,组件重绑 --dsw-elevation-stroke-color 将无法进入
|
||||
var(--dsw-elevation-*);逐元素声明让每个元素按自己看到的描边色重新替换
|
||||
(同 scrollbar.css 对 --dsh-scrollbar-thumb 的处理)。 */
|
||||
body,
|
||||
body * {
|
||||
--dsw-elevation-stroke: 0 0 0 0.5px var(--dsw-elevation-stroke-color);
|
||||
--dsw-elevation-panel:
|
||||
var(--dsw-elevation-stroke), 0 3px 8px 0 rgba(0, 0, 0, 0.03), 0 0 16px 0 rgba(0, 0, 0, 0.02);
|
||||
--dsw-elevation-prominent:
|
||||
var(--dsw-elevation-stroke), 0 3px 8px 0 rgba(0, 0, 0, 0.04), 0 0 20px 0 rgba(0, 0, 0, 0.05);
|
||||
--dsw-elevation-soft:
|
||||
var(--dsw-elevation-stroke), 0 4px 16px 0 rgba(0, 0, 0, 0.03), 0 0 24px 0 rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
body[data-ds-dark-theme] {
|
||||
--dsw-linear-gradient-think: linear-gradient(180deg, #151517 20.19%, rgba(21, 21, 23, 0) 100%);
|
||||
--dsw-linear-think-select: linear-gradient(180deg, #232325 20.19%, rgba(35, 35, 37, 0) 100%);
|
||||
}
|
||||
|
||||
/* ---------- 字体 ---------- */
|
||||
/* 由 @deepseek-figma-plugin/custom-variable-name 插件导出 */
|
||||
/* Markdown 阶梯整体按 0.875(正文 16 → 14)等比缩放,四舍五入到整数像素;
|
||||
重新从 Figma 导出时需要保留这一缩放。 */
|
||||
/* Bootstrap and ThemePresenter set the integer content size on body (10–22px,
|
||||
default 14px). Headings and body variants share its pixel delta. Secondary
|
||||
text uses size − 1 through 14px, then size − 2: 10→9, 14→13, 15→13, 22→20.
|
||||
max(13px, ...) selects the delta, not a font-size floor. Small typography keeps a
|
||||
fixed size; code typography follows the separate code size setting. */
|
||||
body {
|
||||
--dsh-content-font-delta: calc(var(--dsh-content-font-size, 14px) - 14px);
|
||||
--dsh-content-font-size-secondary: min(calc(var(--dsh-content-font-size, 14px) - 1px), max(13px, calc(var(--dsh-content-font-size, 14px) - 2px)));
|
||||
--dsh-content-font-delta-secondary: calc(var(--dsh-content-font-size-secondary) - 13px);
|
||||
/* Code size setting (--dsh-code-font-size, integer px 10–16, default 11)
|
||||
is the code-block size; inline code and the small tool variant move by
|
||||
the same increment. */
|
||||
--dsh-code-font-delta: calc(var(--dsh-code-font-size, 11px) - 11px);
|
||||
|
||||
--dsw-font-markdown-h1: 700 calc(21px + var(--dsh-content-font-delta)) / calc(30px + var(--dsh-content-font-delta)) var(--dsw-font-family);
|
||||
--dsw-font-markdown-h1-font-family: var(--dsw-font-family);
|
||||
--dsw-font-markdown-h1-font-weight: 700;
|
||||
--dsw-font-markdown-h1-line-height: calc(30px + var(--dsh-content-font-delta));
|
||||
--dsw-font-markdown-h1-font-size: calc(21px + var(--dsh-content-font-delta));
|
||||
--dsw-font-markdown-h1-font-style: normal;
|
||||
|
||||
--dsw-font-markdown-h2: 700 calc(19px + var(--dsh-content-font-delta)) / calc(28px + var(--dsh-content-font-delta)) var(--dsw-font-family);
|
||||
--dsw-font-markdown-h2-font-family: var(--dsw-font-family);
|
||||
--dsw-font-markdown-h2-font-weight: 700;
|
||||
--dsw-font-markdown-h2-line-height: calc(28px + var(--dsh-content-font-delta));
|
||||
--dsw-font-markdown-h2-font-size: calc(19px + var(--dsh-content-font-delta));
|
||||
--dsw-font-markdown-h2-font-style: normal;
|
||||
|
||||
--dsw-font-markdown-h3: 700 calc(18px + var(--dsh-content-font-delta)) / calc(26px + var(--dsh-content-font-delta)) var(--dsw-font-family);
|
||||
--dsw-font-markdown-h3-font-family: var(--dsw-font-family);
|
||||
--dsw-font-markdown-h3-font-weight: 700;
|
||||
--dsw-font-markdown-h3-line-height: calc(26px + var(--dsh-content-font-delta));
|
||||
--dsw-font-markdown-h3-font-size: calc(18px + var(--dsh-content-font-delta));
|
||||
--dsw-font-markdown-h3-font-style: normal;
|
||||
|
||||
--dsw-font-markdown-h4: 600 var(--dsh-content-font-size, 14px) / calc(24px + var(--dsh-content-font-delta)) var(--dsw-font-family);
|
||||
--dsw-font-markdown-h4-font-family: var(--dsw-font-family);
|
||||
--dsw-font-markdown-h4-font-weight: 600;
|
||||
--dsw-font-markdown-h4-line-height: calc(24px + var(--dsh-content-font-delta));
|
||||
--dsw-font-markdown-h4-font-size: var(--dsh-content-font-size, 14px);
|
||||
--dsw-font-markdown-h4-font-style: normal;
|
||||
|
||||
--dsw-font-markdown-base: var(--dsh-content-font-size, 14px) / calc(24px + var(--dsh-content-font-delta)) var(--dsw-font-family);
|
||||
--dsw-font-markdown-base-font-family: var(--dsw-font-family);
|
||||
--dsw-font-markdown-base-font-weight: 400;
|
||||
--dsw-font-markdown-base-line-height: calc(24px + var(--dsh-content-font-delta));
|
||||
--dsw-font-markdown-base-font-size: var(--dsh-content-font-size, 14px);
|
||||
--dsw-font-markdown-base-font-style: normal;
|
||||
|
||||
--dsw-font-markdown-base-strong: 600 var(--dsh-content-font-size, 14px) / calc(24px + var(--dsh-content-font-delta)) var(--dsw-font-family);
|
||||
--dsw-font-markdown-base-strong-font-family: var(--dsw-font-family);
|
||||
--dsw-font-markdown-base-strong-font-weight: 600;
|
||||
--dsw-font-markdown-base-strong-line-height: calc(24px + var(--dsh-content-font-delta));
|
||||
--dsw-font-markdown-base-strong-font-size: var(--dsh-content-font-size, 14px);
|
||||
--dsw-font-markdown-base-strong-font-style: normal;
|
||||
|
||||
--dsw-font-markdown-base-italic: italic var(--dsh-content-font-size, 14px) / calc(24px + var(--dsh-content-font-delta)) var(--dsw-font-family);
|
||||
--dsw-font-markdown-base-italic-font-family: var(--dsw-font-family);
|
||||
--dsw-font-markdown-base-italic-font-weight: 400;
|
||||
--dsw-font-markdown-base-italic-line-height: calc(24px + var(--dsh-content-font-delta));
|
||||
--dsw-font-markdown-base-italic-font-size: var(--dsh-content-font-size, 14px);
|
||||
--dsw-font-markdown-base-italic-font-style: italic;
|
||||
|
||||
--dsw-font-markdown-base-strong-italic: italic 600 var(--dsh-content-font-size, 14px) / calc(24px + var(--dsh-content-font-delta)) var(--dsw-font-family);
|
||||
--dsw-font-markdown-base-strong-italic-font-family: var(--dsw-font-family);
|
||||
--dsw-font-markdown-base-strong-italic-font-weight: 600;
|
||||
--dsw-font-markdown-base-strong-italic-line-height: calc(24px + var(--dsh-content-font-delta));
|
||||
--dsw-font-markdown-base-strong-italic-font-size: var(--dsh-content-font-size, 14px);
|
||||
--dsw-font-markdown-base-strong-italic-font-style: italic;
|
||||
|
||||
--dsw-font-markdown-table: var(--dsh-content-font-size-secondary, 13px)/calc(22px + var(--dsh-content-font-delta-secondary, 0px)) var(--dsw-font-family);
|
||||
--dsw-font-markdown-table-font-family: var(--dsw-font-family);
|
||||
--dsw-font-markdown-table-font-weight: 400;
|
||||
--dsw-font-markdown-table-line-height: calc(22px + var(--dsh-content-font-delta-secondary, 0px));
|
||||
--dsw-font-markdown-table-font-size: var(--dsh-content-font-size-secondary, 13px);
|
||||
--dsw-font-markdown-table-font-style: normal;
|
||||
|
||||
--dsw-font-markdown-table-head: 500 var(--dsh-content-font-size-secondary, 13px)/calc(22px + var(--dsh-content-font-delta-secondary, 0px)) var(--dsw-font-family);
|
||||
--dsw-font-markdown-table-head-font-family: var(--dsw-font-family);
|
||||
--dsw-font-markdown-table-head-font-weight: 500;
|
||||
--dsw-font-markdown-table-head-line-height: calc(22px + var(--dsh-content-font-delta-secondary, 0px));
|
||||
--dsw-font-markdown-table-head-font-size: var(--dsh-content-font-size-secondary, 13px);
|
||||
--dsw-font-markdown-table-head-font-style: normal;
|
||||
|
||||
--dsw-font-markdown-small: 12px/20px var(--dsw-font-family);
|
||||
--dsw-font-markdown-small-font-family: var(--dsw-font-family);
|
||||
--dsw-font-markdown-small-font-weight: 400;
|
||||
--dsw-font-markdown-small-line-height: 20px;
|
||||
--dsw-font-markdown-small-font-size: 12px;
|
||||
--dsw-font-markdown-small-font-style: normal;
|
||||
|
||||
--dsw-font-markdown-small-strong: 600 12px/20px var(--dsw-font-family);
|
||||
--dsw-font-markdown-small-strong-font-family: var(--dsw-font-family);
|
||||
--dsw-font-markdown-small-strong-font-weight: 600;
|
||||
--dsw-font-markdown-small-strong-line-height: 20px;
|
||||
--dsw-font-markdown-small-strong-font-size: 12px;
|
||||
--dsw-font-markdown-small-strong-font-style: normal;
|
||||
|
||||
--dsw-font-markdown-small-italic: italic 12px/20px var(--dsw-font-family);
|
||||
--dsw-font-markdown-small-italic-font-family: var(--dsw-font-family);
|
||||
--dsw-font-markdown-small-italic-font-weight: 400;
|
||||
--dsw-font-markdown-small-italic-line-height: 20px;
|
||||
--dsw-font-markdown-small-italic-font-size: 12px;
|
||||
--dsw-font-markdown-small-italic-font-style: italic;
|
||||
|
||||
--dsw-font-markdown-small-strong-italic: italic 600 12px/20px var(--dsw-font-family);
|
||||
--dsw-font-markdown-small-strong-italic-font-family: var(--dsw-font-family);
|
||||
--dsw-font-markdown-small-strong-italic-font-weight: 600;
|
||||
--dsw-font-markdown-small-strong-italic-line-height: 20px;
|
||||
--dsw-font-markdown-small-strong-italic-font-size: 12px;
|
||||
--dsw-font-markdown-small-strong-italic-font-style: italic;
|
||||
|
||||
--dsw-font-markdown-code: calc(12px + var(--dsh-code-font-delta)) / calc(19px + var(--dsh-code-font-delta)) var(--ds-font-family-code);
|
||||
--dsw-font-markdown-code-font-family: var(--ds-font-family-code);
|
||||
--dsw-font-markdown-code-font-weight: 400;
|
||||
--dsw-font-markdown-code-line-height: calc(19px + var(--dsh-code-font-delta));
|
||||
--dsw-font-markdown-code-font-size: calc(12px + var(--dsh-code-font-delta));
|
||||
--dsw-font-markdown-code-font-style: normal;
|
||||
|
||||
--dsw-font-markdown-code-block: var(--dsh-code-font-size, 11px) / calc(19px + var(--dsh-code-font-delta)) var(--ds-font-family-code);
|
||||
--dsw-font-markdown-code-block-font-family: var(--ds-font-family-code);
|
||||
--dsw-font-markdown-code-block-font-weight: 400;
|
||||
--dsw-font-markdown-code-block-line-height: calc(19px + var(--dsh-code-font-delta));
|
||||
--dsw-font-markdown-code-block-font-size: var(--dsh-code-font-size, 11px);
|
||||
--dsw-font-markdown-code-block-font-style: normal;
|
||||
|
||||
/* 手工补充(非插件导出):tool row 展开卡片内的小号 code 字体。 */
|
||||
--dsw-font-markdown-code-block-small: var(--dsh-code-font-size, 11px) / calc(16px + var(--dsh-code-font-delta)) var(--ds-font-family-code);
|
||||
--dsw-font-markdown-code-block-small-font-family: var(--ds-font-family-code);
|
||||
--dsw-font-markdown-code-block-small-font-weight: 400;
|
||||
--dsw-font-markdown-code-block-small-line-height: calc(16px + var(--dsh-code-font-delta));
|
||||
--dsw-font-markdown-code-block-small-font-size: var(--dsh-code-font-size, 11px);
|
||||
--dsw-font-markdown-code-block-small-font-style: normal;
|
||||
|
||||
--dsw-font-xl-24: 600 24px/32px var(--dsw-font-family);
|
||||
--dsw-font-xl-24-font-family: var(--dsw-font-family);
|
||||
--dsw-font-xl-24-font-weight: 600;
|
||||
--dsw-font-xl-24-line-height: 32px;
|
||||
--dsw-font-xl-24-font-size: 24px;
|
||||
--dsw-font-xl-24-font-style: normal;
|
||||
|
||||
--dsw-font-l-20: 500 20px/28px var(--dsw-font-family);
|
||||
--dsw-font-l-20-font-family: var(--dsw-font-family);
|
||||
--dsw-font-l-20-font-weight: 500;
|
||||
--dsw-font-l-20-line-height: 28px;
|
||||
--dsw-font-l-20-font-size: 20px;
|
||||
--dsw-font-l-20-font-style: normal;
|
||||
|
||||
--dsw-font-m-18: 500 16px/28px var(--dsw-font-family);
|
||||
--dsw-font-m-18-font-family: var(--dsw-font-family);
|
||||
--dsw-font-m-18-font-weight: 500;
|
||||
--dsw-font-m-18-line-height: 28px;
|
||||
--dsw-font-m-18-font-size: 16px;
|
||||
--dsw-font-m-18-font-style: normal;
|
||||
|
||||
--dsw-font-base-16: 16px/24px var(--dsw-font-family);
|
||||
--dsw-font-base-16-font-family: var(--dsw-font-family);
|
||||
--dsw-font-base-16-font-weight: 400;
|
||||
--dsw-font-base-16-line-height: 24px;
|
||||
--dsw-font-base-16-font-size: 16px;
|
||||
--dsw-font-base-16-font-style: normal;
|
||||
|
||||
--dsw-font-base-strong-16: 500 16px/24px var(--dsw-font-family);
|
||||
--dsw-font-base-strong-16-font-family: var(--dsw-font-family);
|
||||
--dsw-font-base-strong-16-font-weight: 500;
|
||||
--dsw-font-base-strong-16-line-height: 24px;
|
||||
--dsw-font-base-strong-16-font-size: 16px;
|
||||
--dsw-font-base-strong-16-font-style: normal;
|
||||
|
||||
--dsw-font-s-14: 14px/22px var(--dsw-font-family);
|
||||
--dsw-font-s-14-font-family: var(--dsw-font-family);
|
||||
--dsw-font-s-14-font-weight: 400;
|
||||
--dsw-font-s-14-line-height: 22px;
|
||||
--dsw-font-s-14-font-size: 14px;
|
||||
--dsw-font-s-14-font-style: normal;
|
||||
|
||||
--dsw-font-s-strong-14: 500 14px/22px var(--dsw-font-family);
|
||||
--dsw-font-s-strong-14-font-family: var(--dsw-font-family);
|
||||
--dsw-font-s-strong-14-font-weight: 500;
|
||||
--dsw-font-s-strong-14-line-height: 22px;
|
||||
--dsw-font-s-strong-14-font-size: 14px;
|
||||
--dsw-font-s-strong-14-font-style: normal;
|
||||
|
||||
--dsw-font-xs-13: 13px/20px var(--dsw-font-family);
|
||||
--dsw-font-xs-13-font-family: var(--dsw-font-family);
|
||||
--dsw-font-xs-13-font-weight: 400;
|
||||
--dsw-font-xs-13-line-height: 20px;
|
||||
--dsw-font-xs-13-font-size: 13px;
|
||||
--dsw-font-xs-13-font-style: normal;
|
||||
|
||||
--dsw-font-xs-strong-13: 500 13px/20px var(--dsw-font-family);
|
||||
--dsw-font-xs-strong-13-font-family: var(--dsw-font-family);
|
||||
--dsw-font-xs-strong-13-font-weight: 500;
|
||||
--dsw-font-xs-strong-13-line-height: 20px;
|
||||
--dsw-font-xs-strong-13-font-size: 13px;
|
||||
--dsw-font-xs-strong-13-font-style: normal;
|
||||
|
||||
--dsw-font-xxs-12: 12px/18px var(--dsw-font-family);
|
||||
--dsw-font-xxs-12-font-family: var(--dsw-font-family);
|
||||
--dsw-font-xxs-12-font-weight: 400;
|
||||
--dsw-font-xxs-12-line-height: 18px;
|
||||
--dsw-font-xxs-12-font-size: 12px;
|
||||
--dsw-font-xxs-12-font-style: normal;
|
||||
|
||||
--dsw-font-xxs-strong-12: 500 12px/18px var(--dsw-font-family);
|
||||
--dsw-font-xxs-strong-12-font-family: var(--dsw-font-family);
|
||||
--dsw-font-xxs-strong-12-font-weight: 500;
|
||||
--dsw-font-xxs-strong-12-line-height: 18px;
|
||||
--dsw-font-xxs-strong-12-font-size: 12px;
|
||||
--dsw-font-xxs-strong-12-font-style: normal;
|
||||
|
||||
--dsw-font-xxxs-11: 11px/14px var(--dsw-font-family);
|
||||
--dsw-font-xxxs-11-font-family: var(--dsw-font-family);
|
||||
--dsw-font-xxxs-11-font-weight: 400;
|
||||
--dsw-font-xxxs-11-line-height: 14px;
|
||||
--dsw-font-xxxs-11-font-size: 11px;
|
||||
--dsw-font-xxxs-11-font-style: normal;
|
||||
|
||||
--dsw-font-xxxs-strong-11: 500 11px/14px var(--dsw-font-family);
|
||||
--dsw-font-xxxs-strong-11-font-family: var(--dsw-font-family);
|
||||
--dsw-font-xxxs-strong-11-font-weight: 500;
|
||||
--dsw-font-xxxs-strong-11-line-height: 14px;
|
||||
--dsw-font-xxxs-strong-11-font-size: 11px;
|
||||
--dsw-font-xxxs-strong-11-font-style: normal;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/* Scrollbar skin: the sole consumer of the four --dsw-alias-scrollbar-*
|
||||
* tokens. Without it every scrolling region renders the UA scrollbar, which
|
||||
* ignores the theme — a light native bar over the dark palette.
|
||||
*
|
||||
* The rules sit on `body`, not `html`: design-platform.css declares the
|
||||
* --dsw-alias-* tokens on `body` (and the dark overrides on
|
||||
* `body[data-ds-dark-theme]`), and custom properties only inherit downward,
|
||||
* so an `html` rule resolves them to the guaranteed-invalid value and
|
||||
* `scrollbar-color` falls back to `auto`.
|
||||
*
|
||||
* Surfaces pick their elevation by rebinding --dsh-scrollbar-thumb{,-hover}:
|
||||
* the l1 pair here is the base-surface default, and an elevated surface
|
||||
* (menu, popover, dialog) rebinds to the l2 pair on its own container. A
|
||||
* scroll container may also rebind the three geometry variables below. */
|
||||
|
||||
body {
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l1);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l1);
|
||||
--dsh-scrollbar-thumb-border: 0px;
|
||||
--dsh-scrollbar-track-margin: 0px;
|
||||
/* The WebKit bar's layout width, mirrored by the ::-webkit-scrollbar rule
|
||||
below. A surface that must align itself beside a space-consuming bar
|
||||
(ConversationRoot's overlay composer seat) reads this instead of
|
||||
hardcoding the number. */
|
||||
--dsh-scrollbar-width: 5px;
|
||||
}
|
||||
|
||||
/* The two paths are mutually exclusive, and the gate is load-bearing rather
|
||||
than defensive. A non-`auto` `scrollbar-width` or `scrollbar-color` makes
|
||||
Chromium and Safari drop every `::-webkit-scrollbar*` rule for that
|
||||
element, including `::-webkit-scrollbar-thumb:hover` — measured in chromium
|
||||
as a `::-webkit-scrollbar` width taking effect on its own and being
|
||||
ignored as soon as `scrollbar-width: thin` is added. Declaring both
|
||||
unconditionally therefore leaves the hover tokens with no rendering at all,
|
||||
because the engines that implement the hover pseudo-element are exactly the
|
||||
ones the standard properties silence, and Firefox has no hover
|
||||
pseudo-element to fall back on.
|
||||
|
||||
`not selector(::-webkit-scrollbar)` is true only where the pseudo-element
|
||||
is unimplemented, so Firefox takes the standard path and WebKit-based
|
||||
engines take the pseudo-element path. An engine too old for the
|
||||
`selector()` function makes the condition invalid, which evaluates false
|
||||
and selects the pseudo-element path — the correct side for the pre-16.4
|
||||
Safari that is the realistic case. */
|
||||
@supports not selector(::-webkit-scrollbar) {
|
||||
/* Declared on every element rather than inherited from `body`. Inheriting
|
||||
would pass down the COLOUR already substituted at `body`, so a descendant
|
||||
rebinding --dsh-scrollbar-thumb could not change it; re-declaring makes
|
||||
each element substitute the variable as it sees it, which is what gives
|
||||
an elevated surface a working rebind. `scrollbar-width` is not an
|
||||
inherited property at all, so it needs the per-element declaration
|
||||
regardless.
|
||||
|
||||
No hover counterpart exists on this path: `scrollbar-color` states one
|
||||
thumb colour and the engine derives its own hover treatment. */
|
||||
body,
|
||||
body * {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--dsh-scrollbar-thumb) transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* Not gated in turn: an engine that does not implement these pseudo-elements
|
||||
drops the rules as unknown selectors, so the gate would only restate what
|
||||
selector matching already does. Not inherited either, hence the unscoped
|
||||
selectors. */
|
||||
::-webkit-scrollbar {
|
||||
width: var(--dsh-scrollbar-width);
|
||||
height: var(--dsh-scrollbar-width);
|
||||
}
|
||||
|
||||
/* Track stays transparent so the thumb reads against whatever surface scrolls
|
||||
under it; only the thumb carries a token colour. */
|
||||
::-webkit-scrollbar-track {
|
||||
margin-block: var(--dsh-scrollbar-track-margin);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border: var(--dsh-scrollbar-thumb-border) solid transparent;
|
||||
border-radius: 999px;
|
||||
corner-shape: round;
|
||||
background: var(--dsh-scrollbar-thumb);
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--dsh-scrollbar-thumb-hover);
|
||||
}
|
||||
}
|
||||
|
||||
/* Both scrollbars meeting in a corner: no separate token, so the corner
|
||||
matches the transparent track rather than the UA's opaque default. */
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
.projectRow,
|
||||
.sessionRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--dsh-sidebar-label-gap);
|
||||
border-radius: var(--dsw-radius-md);
|
||||
border-inline: var(--dsh-sidebar-paint-inset) solid transparent;
|
||||
background-clip: padding-box;
|
||||
box-sizing: border-box;
|
||||
padding: 0 8px;
|
||||
padding-inline-start: calc(var(--dsh-sidebar-row-padding) - var(--dsh-sidebar-paint-inset) + var(--dsh-workspace-indent, 0px));
|
||||
cursor: pointer;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.projectRow:hover {
|
||||
background-color: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.sessionRow.selected {
|
||||
background-color: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.dragPreview {
|
||||
background: var(--dsw-specific-sidebar-fill);
|
||||
}
|
||||
|
||||
.searchResultRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
box-sizing: border-box;
|
||||
border: none;
|
||||
border-radius: var(--dsw-radius-lg);
|
||||
padding: 4px calc(var(--dsh-sidebar-row-padding) - var(--dsh-sidebar-paint-inset));
|
||||
border-inline: var(--dsh-sidebar-paint-inset) solid transparent;
|
||||
background: transparent;
|
||||
background-clip: padding-box;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.searchResultRow:hover {
|
||||
background-color: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.searchResultRow.selected {
|
||||
background-color: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.searchResultHeading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.searchResultTitle {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
margin: 0 var(--dsh-sidebar-label-gap);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.searchResultMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
margin-left: calc(16px + var(--dsh-sidebar-label-gap));
|
||||
}
|
||||
|
||||
.searchResultWorkspace,
|
||||
.searchResultSnippet {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
}
|
||||
|
||||
.searchResultWorkspace {
|
||||
flex: none;
|
||||
max-width: 40%;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* The 40% share only protects a snippet; a snippetless match keeps the row. */
|
||||
.searchResultWorkspace:only-child {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.searchResultSnippet {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Compact one-line Workspace row after removing the session-count subtitle. */
|
||||
.projectRow {
|
||||
height: 34px;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.projectRow .rowActions {
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Session titles share the workspace icon slot and compact label gap. */
|
||||
.sessionRow {
|
||||
height: 32px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.sessionRow .title {
|
||||
margin: 0 6px 0 var(--dsh-sidebar-label-gap);
|
||||
}
|
||||
|
||||
.slot {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.visuallyHidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.folderActive {
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
|
||||
/* Project leading slot: folder by default, expand arrow on row hover. */
|
||||
.projectRow .chevron { display: none; }
|
||||
@media (hover: hover) {
|
||||
.projectRow:hover .chevron { display: inline-flex; }
|
||||
|
||||
.projectRow:hover .folder { display: none; }
|
||||
}
|
||||
|
||||
/* Expand arrow (filled triangle): points right closed, rotates to point down open. */
|
||||
.arrow {
|
||||
transition: transform 150ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.arrowOpen {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.projectText {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.renameInput {
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
padding: 0 2px;
|
||||
border: 0.5px solid var(--dsw-alias-border-l4);
|
||||
border-radius: var(--dsw-radius-sm);
|
||||
background: var(--dsw-alias-button-elevated-fill);
|
||||
color: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Hovering a session row marquees a title wider than its cell: Rows.tsx crawls
|
||||
it at a constant speed to its far edge while the pointer rests on the row,
|
||||
holds it there, and returns it to the start in one step when the pointer
|
||||
leaves. The motion is scripted frame by frame (reduced motion jumps), so no
|
||||
scroll-behavior applies here. */
|
||||
.sessionRow .title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* While the marquee travels, both clipping edges fade in over 12px instead of
|
||||
hard-cutting a character: the left once the title has moved off its start
|
||||
(data-scrolled), the right while text still remains beyond the cell
|
||||
(data-clipped) — at the far edge the right fade lifts so the final character
|
||||
reads at full strength. The mask rides the title span itself, so the status
|
||||
slot and every other leading cell keep their full color. */
|
||||
.sessionRow .title[data-scrolled] {
|
||||
mask-image: linear-gradient(to right, transparent, #000 12px);
|
||||
}
|
||||
|
||||
.sessionRow .title[data-clipped] {
|
||||
mask-image: linear-gradient(to left, transparent, #000 12px);
|
||||
}
|
||||
|
||||
.sessionRow .title[data-scrolled][data-clipped] {
|
||||
mask-image: linear-gradient(to right, transparent, #000 12px, #000 calc(100% - 12px), transparent);
|
||||
}
|
||||
|
||||
.meta {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.time {
|
||||
flex: none;
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
margin-inline-end: -1px;
|
||||
}
|
||||
|
||||
.pinIndicator {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 20px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 6px;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
/* Archived rows stay in place but read as inactive: titles drop to
|
||||
the caption label step (one step above dimmed, which reads as too faint
|
||||
over the sidebar fill). Archived search rows drop their meta line to the
|
||||
same step so both lines read as one unit. */
|
||||
.sessionRow.archived .title,
|
||||
.searchResultRow.archived .searchResultTitle,
|
||||
.searchResultRow.archived .searchResultWorkspace,
|
||||
.searchResultRow.archived .searchResultSnippet {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.dot {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* The trailing strip keeps a shared gap between its fixed-size control boxes. */
|
||||
.rowActions {
|
||||
flex: none;
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.projectRow:hover .rowActions,
|
||||
.searchResultRow:hover .rowActions {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
.projectRow.menuOpen .rowActions {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.rowQuickActions {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* The search heading has no trailing time cell, so the unarchive action
|
||||
right-aligns itself. */
|
||||
.searchResultHeading .rowActions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.rowResting {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.rowResting .pinIndicator {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* The unclipped hover state drops the ellipsis, which would otherwise cover
|
||||
the characters the marquee reached. Guarded because a touch tap
|
||||
latches `:hover` on the row after the title has already returned: an
|
||||
unguarded rule would strand that row without its ellipsis. */
|
||||
@media (hover: hover) {
|
||||
.sessionRow:hover {
|
||||
background-color: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.sessionRow:hover .rowActions,
|
||||
.sessionRow.menuOpen .rowActions,
|
||||
:global(html[data-input-modality='keyboard']) .sessionRow:focus-within .rowActions {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.sessionRow:hover .rowResting,
|
||||
.sessionRow.menuOpen .rowResting,
|
||||
:global(html[data-input-modality='keyboard']) .sessionRow:focus-within .rowResting {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sessionRow:hover .title,
|
||||
.sessionRow.menuOpen .title {
|
||||
text-overflow: clip;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.projectRow .rowActions,
|
||||
.searchResultRow .rowActions,
|
||||
.sessionRow .rowActions {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* Only update timestamps disappear; pending-interaction labels remain visible. */
|
||||
.sessionRow .timestamp,
|
||||
.sessionRow:has(.pinnedAction) .pinIndicator,
|
||||
.sessionRow .rowQuickActions > :global([data-slot]) > :not(.pinnedAction),
|
||||
.projectRow[aria-expanded='false'] .rowActions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sessionRow .pinnedAction {
|
||||
/* Keep More aligned across rows. Tab order remains More, then Unpin. */
|
||||
order: -1;
|
||||
}
|
||||
}
|
||||
|
||||
/* An open row menu pins the hover affordances (figma: the row keeps its
|
||||
hover fill while its dropdown is up). */
|
||||
.projectRow.menuOpen,
|
||||
.sessionRow.menuOpen {
|
||||
background-color: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Session drag insert marker: a leading chevron and 2px rule between rows,
|
||||
absolutely positioned so it neither resembles a row border nor changes layout. */
|
||||
.sessionRow.dropBefore,
|
||||
.sessionRow.dropAfter {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sessionRow.dropBefore::before,
|
||||
.sessionRow.dropAfter::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
left: calc(-1 * var(--dsh-sidebar-paint-inset));
|
||||
right: calc(4px - var(--dsh-sidebar-paint-inset));
|
||||
height: 12px;
|
||||
background:
|
||||
linear-gradient(
|
||||
55deg,
|
||||
transparent calc(50% - 1px),
|
||||
var(--dsw-alias-state-business-primary) calc(50% - 1px) calc(50% + 1px),
|
||||
transparent calc(50% + 1px)
|
||||
) 0 0 / 5px 7px no-repeat,
|
||||
linear-gradient(
|
||||
125deg,
|
||||
transparent calc(50% - 1px),
|
||||
var(--dsw-alias-state-business-primary) calc(50% - 1px) calc(50% + 1px),
|
||||
transparent calc(50% + 1px)
|
||||
) 0 5px / 5px 7px no-repeat,
|
||||
linear-gradient(
|
||||
var(--dsw-alias-state-business-primary) 0 0
|
||||
) 4px 5px / calc(100% - 4px) 2px no-repeat;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sessionRow.dropBefore::before {
|
||||
top: -7px;
|
||||
}
|
||||
|
||||
.sessionRow.dropAfter::after {
|
||||
bottom: -7px;
|
||||
}
|
||||
|
||||
/* Hover-card body (figma 169:16903): dark surface, fixed colors both themes. */
|
||||
.hoverContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hoverTitle {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: #FFFFFF;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.hoverPath {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: #CFD3D6;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.hoverTime {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: #CFD3D6;
|
||||
}
|
||||
|
||||
.hoverStatus {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: #ADB2B8;
|
||||
}
|
||||
|
||||
/* Archive glyph rides the status row's text color; slight inset aligns it with the 6px status dots. */
|
||||
.hoverArchived svg {
|
||||
flex-shrink: 0;
|
||||
margin: 0 -4px;
|
||||
}
|
||||
|
||||
/* Compensated margins keep the 24px target on the compact icon's center. */
|
||||
.iconButton {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin: -4px;
|
||||
border: none;
|
||||
border-radius: var(--dsw-radius-xs);
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* Keep the focus ring on its original compact outline. */
|
||||
.iconButton:focus-visible {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.iconButton:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
}
|
||||
|
||||
/* Chevrons ride the caption grey (#ADB2B8); the folder glyph stays one step
|
||||
darker (tertiary, #81858C) per the cell spec. */
|
||||
.chevron {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.arrow {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,686 @@
|
||||
/* Sidebar column: vertical stack, padding 8/6, sidebar
|
||||
fill and desktop divider painted by the layout column. Collapse is a
|
||||
slide + crossfade, not a morph: the content holds its frozen expanded
|
||||
layout (inline width set by the component) and fades in place (.fading)
|
||||
while the sliding column (AppFrame grid tracks) clips it; the rail layout
|
||||
(.collapsed) only applies after the fade settles, so nothing reflows
|
||||
mid-slide. */
|
||||
|
||||
.root {
|
||||
--dsh-sidebar-inline-padding: 8px;
|
||||
--dsh-sidebar-row-padding: 12px;
|
||||
--dsh-sidebar-label-gap: 6px;
|
||||
--dsh-sidebar-paint-inset: 3px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 6px var(--dsh-sidebar-inline-padding);
|
||||
box-sizing: border-box;
|
||||
background: var(--dsw-specific-sidebar-fill);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 14px;
|
||||
/* The sidebar is elevated above the conversation surface, so a revealed
|
||||
scrollbar uses the l2 pair. .quietBars hides it without changing layout. */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
:global([data-animating]:not([data-dragging])) .root {
|
||||
transition: width var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
/* macOS desktop: the AppFrame column owns the translucent sidebar tint; an
|
||||
opaque fill here would sit on top of it and block the window vibrancy. */
|
||||
:global([data-platform='darwin']) .root {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Rail geometry (figma rail spec): 36x36 control boxes centered in the 56px
|
||||
rail (10px side padding), 12px vertical rhythm, 18px from the rail top to
|
||||
the whale's box (24px to the 24-wide whale glyph itself). */
|
||||
.root.collapsed {
|
||||
padding: 18px 10px 6px;
|
||||
}
|
||||
|
||||
/* The brand stays in the sidebar; fixed caption controls survive zero-width collapse. */
|
||||
:global([data-windows-titlebar]) .root .logoRow {
|
||||
height: 40px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:global([data-windows-titlebar]) .toggle {
|
||||
position: fixed;
|
||||
top: calc((var(--dsh-windows-titlebar-height) - 28px) / 2);
|
||||
left: 12px;
|
||||
z-index: 30;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
:global([data-windows-titlebar]) .root.collapsed .logoRow {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
:global([data-windows-titlebar]) .root:not(.collapsed) .brand {
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
:global([data-windows-titlebar]) .brandIdentity {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
:global([data-windows-titlebar]) .brandMark {
|
||||
transform: translateX(1px);
|
||||
}
|
||||
|
||||
:global([data-windows-titlebar]) .brandName {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
:global([data-windows-titlebar]) .root.collapsed {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:global([data-windows-titlebar]) .root:not(.collapsed) .newSession {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
:global([data-windows-titlebar]) .collapsed .panelList,
|
||||
:global([data-windows-titlebar]) .collapsed .regionArea,
|
||||
:global([data-windows-titlebar]) .collapsed .footArea {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:global(html[data-windows-titlebar]:has([data-sidebar-collapsed='true'])) {
|
||||
--dsh-windows-menu-start: 84px;
|
||||
}
|
||||
|
||||
:global([data-windows-titlebar]) .collapsed .newSession {
|
||||
position: fixed;
|
||||
top: calc((var(--dsh-windows-titlebar-height) - 28px) / 2);
|
||||
left: 48px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
z-index: 30;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
:global([data-windows-titlebar]) .railIn .iconButton,
|
||||
:global([data-windows-titlebar]) .railIn .newSession {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
:global([data-windows-titlebar]) .collapsed .toggle,
|
||||
:global([data-windows-titlebar]) .collapsed .newSession {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--dsw-radius-sm);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
:global([data-windows-titlebar]) .collapsed .toggle .panelIcon {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Scrollbars in the column are a pointer affordance: the shell adds this
|
||||
class whenever the pointer is not inside (SidebarRoot.tsx owns the linger),
|
||||
and rebinding ui-theme's indirection pair to `transparent` takes the thumb
|
||||
out of every scroll region nested under it — the workspace browser's
|
||||
session list. `transparent` rather than `display: none` on the bar:
|
||||
the reservation (`scrollbar-gutter: stable` on the list) stays in force, so
|
||||
revealing the thumb never reflows a row. Rebinding contract and the two
|
||||
rendering paths it reaches: ui-theme's README. */
|
||||
.root.quietBars {
|
||||
--dsh-scrollbar-thumb: transparent;
|
||||
--dsh-scrollbar-thumb-hover: transparent;
|
||||
}
|
||||
|
||||
/* Collapse phase 1: the whole frozen-width content fades out in place over
|
||||
150ms; at settle the children unmount/snap to the rail layout. */
|
||||
.fading > * {
|
||||
opacity: 0;
|
||||
transition: opacity 150ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
/* Wide-only content fades back in on expand remount. */
|
||||
.wide {
|
||||
animation: wide-in 200ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
@keyframes wide-in {
|
||||
from { opacity: 0; }
|
||||
}
|
||||
|
||||
/* At the 150ms rail settle, the upper controls enter from the former
|
||||
rail right edge over the remaining 150ms of the AppFrame track transition.
|
||||
The bottom-pinned settings seat shares their opacity timeline but stays
|
||||
horizontally fixed. Only a live collapse gets .railIn; a cold collapsed
|
||||
render stays static. */
|
||||
.railIn .iconButton,
|
||||
.railIn .newSession,
|
||||
.railIn .panelList,
|
||||
.railIn .regionArea {
|
||||
animation: rail-in 150ms var(--ds-ease-in-out) backwards;
|
||||
}
|
||||
|
||||
.railIn .footArea {
|
||||
animation: rail-fade-in 150ms var(--ds-ease-in-out) backwards;
|
||||
}
|
||||
|
||||
@keyframes rail-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(49px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rail-fade-in {
|
||||
from { opacity: 0; }
|
||||
}
|
||||
|
||||
/* macOS hiddenInset titlebar: the strip spans the column's top edge (negative
|
||||
margins cancel the root padding) so it clears the traffic lights at
|
||||
(16, 18). The strip marks itself `data-window-drag` in markup (SidebarRoot), so
|
||||
its own run is the column's draggable geometry and no frame band has to match
|
||||
the strip's height; the toggle inside subtracts via the base.css rule. */
|
||||
.topStrip {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
height: 52px;
|
||||
box-sizing: border-box;
|
||||
margin: -6px calc(-1 * var(--dsh-sidebar-inline-padding)) 0;
|
||||
/* The 2px foot centres the 28px toggle at y 25, level with the collapsed
|
||||
seat (ui-layout .leadingSeat, top 11) and the conversation title row, so
|
||||
collapse/expand never shifts it vertically. */
|
||||
padding: 0 12px 2px;
|
||||
}
|
||||
|
||||
:global(html[data-platform='darwin']:not([data-fullscreen])) .root:not(.collapsed) > .topStrip > .toggle {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
/* Window fullscreen hides the traffic lights (html[data-fullscreen], desktop
|
||||
preload): the toggle takes their spot at the strip's left edge. */
|
||||
:global([data-fullscreen]) .topStrip {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* The strip's full 52px foot leaves the wordmark too low; the content column
|
||||
tucks 12px back under it, still clear of the toggle (28px control centered
|
||||
in the strip ends at y 40). */
|
||||
.topStrip + .logoRow {
|
||||
margin-top: -12px;
|
||||
}
|
||||
|
||||
/* Logo row: 8px vertical and leading padding, with no trailing padding or
|
||||
bottom margin. Expanded keeps the panel toggle at the
|
||||
right edge; the rail gives every control the same base left anchor before
|
||||
the shared entry translation is applied. macOS hiddenInset titlebar: the row
|
||||
is marked `data-window-drag` in markup (SidebarRoot), so ui-web base.css's
|
||||
single darwin rule carries the drag over its own box — below the top strip
|
||||
(40–100px: the strip's 52px foot with the row's -12px tuck). The brand rides
|
||||
that box (SidebarRoot renders the wordmark as a plain span there, so the
|
||||
global interactive no-drag rule never subtracts it; only the local-build
|
||||
fallbacks opt out through .copyVersion); the dedicated New
|
||||
Session button below keeps the pointer and keyboard paths. Safe as a drag
|
||||
source: it precedes the sidebar's overlay slots in document order, so their
|
||||
no-drag still wins. */
|
||||
.logoRow {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
height: 60px;
|
||||
padding: 8px 0 8px 8px;
|
||||
margin-bottom: 0;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.collapsed .logoRow {
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
margin-bottom: 12px;
|
||||
justify-content: flex-start;
|
||||
/* The settled rail's 36px toggle is wider than this 35px row; the base row's clip would cut
|
||||
the control's right edge, so the rail stops clipping once the slide has settled. */
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Brand group (figma I133:7632): the full wordmark rides the text ink
|
||||
(figma flows: the main-screen instance is black; blue is brand
|
||||
emphasis only). A button only in behavior (New Session shortcut): the
|
||||
pointer cursor is the sole affordance — no hover chrome on the mark. */
|
||||
.brand {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* On macOS the brand is a window-drag surface, not a New Session button;
|
||||
a pointer cursor would promise a click that drags the window instead. */
|
||||
:global([data-platform='darwin']) .brand {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Mark and name are independent slots; the shell owns their shared baseline,
|
||||
spacing, and height so either occupant can vary in width. */
|
||||
.brandIdentity {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brandMark {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.brandName {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
height: 24px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.fallbackBrandName {
|
||||
font-size: 17px;
|
||||
letter-spacing: 0px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.localBuildBrand {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
height: 24px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Local-build fallbacks copy the version, also on macOS, where the
|
||||
surrounding brand is a window-drag surface. */
|
||||
.copyVersion {
|
||||
display: inline-flex;
|
||||
cursor: pointer;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.localBuildTitle {
|
||||
font-size: 12px;
|
||||
line-height: 13px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
position: relative;
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: var(--dsw-radius-sm);
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.iconButton:hover {
|
||||
background-color: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
/* The toggle is flush with .logoRow, which clips its own overflow, so an outward ring loses
|
||||
the pixels that leave the row. Draw this control's ring inside its own box instead. */
|
||||
.iconButton.toggle:focus-visible {
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.collapsed .iconButton {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
/* Rail hovers share the panel rows' and New Session's 12px rounding. */
|
||||
border-radius: var(--dsw-radius-md);
|
||||
}
|
||||
|
||||
/* Rail logo swap: collapsed, the toggle rests as the whale mark (brand ink,
|
||||
no hover circle) and hovering reveals the panel icon — the expand
|
||||
affordance (figma sidebar-hover flow). Expanded it is a plain panel icon. */
|
||||
.collapsed .toggle .panelIcon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.collapsed .toggle:hover .panelIcon {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.collapsed .toggle:hover .railMark {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.railMark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Rail icons ride the primary ink (figma rail spec); expanded keeps the
|
||||
secondary icon-button ink. */
|
||||
.collapsed .iconButton {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.buildVersion {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 10px;
|
||||
padding: 0 3px;
|
||||
border-radius: 2px;
|
||||
color: var(--dsw-alias-label-primary-inverted);
|
||||
background: var(--dsw-alias-label-primary);
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 6px;
|
||||
font-weight: 500;
|
||||
line-height: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* New Session: 38px bar, 12px radius (figma 133:7634 geometry, squared-off
|
||||
corners); collapsed it renders as the rail's plain icon control. */
|
||||
.newSession {
|
||||
container-type: inline-size;
|
||||
position: relative;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
height: 38px;
|
||||
padding: 8px 12px;
|
||||
margin: 0 calc(var(--dsh-sidebar-paint-inset) + 1px) 10px;
|
||||
box-sizing: border-box;
|
||||
border: 0.5px solid var(--dsw-alias-border-l3);
|
||||
border-radius: var(--dsw-radius-md);
|
||||
background: var(--dsw-alias-button-elevated-fill);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 22px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.newSession:hover {
|
||||
background: var(--dsw-alias-button-floating-hover);
|
||||
}
|
||||
}
|
||||
|
||||
/* macOS: the themed elevated fill reads as a foreign slab on the vibrancy
|
||||
sidebar; a white wash sits in the glass like native macOS sidebar
|
||||
controls. Dark keeps the same white ink at lower alpha. */
|
||||
:global([data-platform='darwin']) :where(.root:not([role='dialog'])) .newSession {
|
||||
background: rgb(255 255 255 / 0.55);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
:global([data-platform='darwin']) :where(.root:not([role='dialog'])) .newSession:hover {
|
||||
background: rgb(255 255 255 / 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
:global([data-platform='darwin']) :global([data-ds-dark-theme]) :where(.root:not([role='dialog'])) .newSession {
|
||||
background: rgb(255 255 255 / 0.15);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
:global([data-platform='darwin']) :global([data-ds-dark-theme]) :where(.root:not([role='dialog'])) .newSession:hover {
|
||||
background: rgb(255 255 255 / 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.collapsed .newSession {
|
||||
align-self: flex-start;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
margin: 0 0 12px;
|
||||
gap: 0;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.collapsed .newSession:hover {
|
||||
background-color: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.newSessionLabel {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.collapsed .newSessionLabel {
|
||||
max-width: 0;
|
||||
}
|
||||
|
||||
.newSessionLabelMask { flex: 1; min-width: 0; }
|
||||
|
||||
.newSession:focus-visible .newSessionLabelMask:has(+ .newSessionShortcut) {
|
||||
overflow: hidden;
|
||||
mask-image: linear-gradient(to right, black calc(100% - 16px), transparent);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.newSession:hover .newSessionLabelMask:has(+ .newSessionShortcut) {
|
||||
overflow: hidden;
|
||||
mask-image: linear-gradient(to right, black calc(100% - 16px), transparent);
|
||||
}
|
||||
}
|
||||
.newSessionContent { display: flex; width: 100cqw; align-items: center; justify-content: center; gap: 6px; }
|
||||
.newSessionContent > svg { flex: none; }
|
||||
|
||||
.newSessionShortcut {
|
||||
opacity: 0;
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
pointer-events: none;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.newSession:focus-visible .newSessionShortcut {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.newSession:hover .newSessionShortcut {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.panelList {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Panel entries use a 3px paint inset, the New Session bar's 12px radius,
|
||||
the session rows' hover/selected fill, and the primary ink. */
|
||||
.panelRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--dsh-sidebar-label-gap);
|
||||
margin: 0;
|
||||
min-height: 36px;
|
||||
padding: 7px calc(var(--dsh-sidebar-row-padding) - var(--dsh-sidebar-paint-inset));
|
||||
box-sizing: border-box;
|
||||
border: none;
|
||||
border-inline: var(--dsh-sidebar-paint-inset) solid transparent;
|
||||
border-radius: var(--dsw-radius-md);
|
||||
background: transparent;
|
||||
background-clip: padding-box;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: inherit;
|
||||
line-height: 22px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.panelRow:hover {
|
||||
background-color: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.panelRow.panelActive {
|
||||
background-color: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.panelRow:focus-visible {
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* The More launcher keeps the panel rows' geometry inside the menu's anchor wrapper. */
|
||||
.moreAnchor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panelRow[aria-expanded='true'] {
|
||||
background-color: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.moreMenu {
|
||||
width: 288px;
|
||||
max-width: calc(100vw - 24px);
|
||||
}
|
||||
|
||||
.panelGlyph {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.panelTitle {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.collapsed .panelList {
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.collapsed .panelRow {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
border-inline: 0;
|
||||
background-clip: border-box;
|
||||
padding: 0;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Region seat: always mounted so the foot never moves. Its trailing margin
|
||||
cancels the wide shell inset so the nested scrollbar can sit at the sidebar
|
||||
edge; the browser restores that inset inside its own rows. */
|
||||
.regionArea {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-left: -4px;
|
||||
margin-right: calc(-1 * var(--dsh-sidebar-inline-padding));
|
||||
padding-left: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.collapsed .regionArea {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
/* Footer seats: additive actions stack above Settings. Each occupant owns its
|
||||
button geometry and hover chrome. */
|
||||
.footArea {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settingsArea,
|
||||
.footerActions {
|
||||
flex: none;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footerActions {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.collapsed .footArea {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.collapsed .settingsArea,
|
||||
.collapsed .footerActions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:global([data-animating]:not([data-dragging])) .root,
|
||||
.wide,
|
||||
.fading > *,
|
||||
.railIn .iconButton,
|
||||
.railIn .newSession,
|
||||
.railIn .panelList,
|
||||
.railIn .footArea,
|
||||
.railIn .regionArea {
|
||||
transition: none;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,662 @@
|
||||
.root {
|
||||
--dsh-session-list-edge-inset: var(--dsh-sidebar-inline-padding);
|
||||
--dsh-session-list-scrollbar-width: 5px;
|
||||
--dsh-session-list-scrollbar-offset: 2px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
padding-right: var(--dsh-session-list-edge-inset);
|
||||
}
|
||||
|
||||
.root.rail {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: var(--dsw-radius-sm);
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* The action cluster animates its width and the search pill rounds its corners,
|
||||
* so both clip an outer ring; these controls keep theirs inside instead. */
|
||||
.iconButton:focus-visible,
|
||||
.searchButton:focus-visible,
|
||||
.clearButton:focus-visible {
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.iconButton:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
/* View-options dropdown card: labels are short, so a content-sized card reads
|
||||
cramped and its width shifts with the locale — hold a stable floor. */
|
||||
.viewOptionsMenu {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
/* Section header: title, an inline search control, and the two trailing
|
||||
actions. Expanding search collapses the action cluster and takes its room. */
|
||||
.sectionHeader {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
height: 36px;
|
||||
padding-left: var(--dsh-sidebar-row-padding);
|
||||
margin-bottom: 4px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--dsw-radius-md);
|
||||
overflow: hidden;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.root:not(.rail) .sectionHeader {
|
||||
margin-top: 2px;
|
||||
margin-right: -4px;
|
||||
}
|
||||
|
||||
.root:not(.rail) .sectionHeader.searchSpacingMotion {
|
||||
transition:
|
||||
margin-right 180ms var(--ds-ease-in-out),
|
||||
padding-inline 180ms var(--ds-ease-in-out),
|
||||
gap 180ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.root:not(.rail) .sectionHeader:not(:has(.searchExpanded)) {
|
||||
padding-right: 1px;
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
position: relative;
|
||||
left: -1px;
|
||||
flex: none;
|
||||
max-width: 45%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
line-height: 20px;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transition:
|
||||
max-width 180ms var(--ds-ease-in-out),
|
||||
margin-right 180ms var(--ds-ease-in-out),
|
||||
opacity 120ms var(--ds-ease-in-out),
|
||||
transform 180ms var(--ds-ease-in-out),
|
||||
visibility 0s linear;
|
||||
}
|
||||
|
||||
.sectionLabelHidden {
|
||||
max-width: 0;
|
||||
margin-right: -4px;
|
||||
opacity: 0;
|
||||
transform: translateX(-4px);
|
||||
visibility: hidden;
|
||||
transition-delay: 0s, 0s, 0s, 0s, 180ms;
|
||||
}
|
||||
|
||||
.searchSlot {
|
||||
flex: 1;
|
||||
max-width: 28px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
padding-left: 0;
|
||||
box-sizing: border-box;
|
||||
transition:
|
||||
max-width 180ms var(--ds-ease-in-out),
|
||||
padding-left 180ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.searchSlotExpanded {
|
||||
max-width: 100%;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 60px;
|
||||
opacity: 1;
|
||||
overflow: hidden;
|
||||
visibility: visible;
|
||||
transition:
|
||||
max-width 180ms var(--ds-ease-in-out),
|
||||
opacity 120ms var(--ds-ease-in-out),
|
||||
transform 180ms var(--ds-ease-in-out),
|
||||
visibility 0s linear;
|
||||
}
|
||||
|
||||
.headerActionsHidden {
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition-delay: 0s, 0s, 0s, 180ms;
|
||||
}
|
||||
|
||||
/* Inline search always fills the room between the title and trailing actions;
|
||||
it grows farther right when the action cluster collapses. */
|
||||
.search {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
border: none;
|
||||
border-radius: var(--dsw-radius-sm);
|
||||
background: transparent;
|
||||
cursor: text;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
overflow: hidden;
|
||||
transition:
|
||||
width 180ms var(--ds-ease-in-out),
|
||||
padding 180ms var(--ds-ease-in-out),
|
||||
border-color 180ms var(--ds-ease-in-out),
|
||||
background-color 180ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.root:not(.rail) .sectionHeader:has(.searchExpanded) {
|
||||
margin-right: 0;
|
||||
padding-inline: calc(var(--dsh-sidebar-paint-inset) + 1px);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.sectionHeader:has(.searchExpanded) .sectionLabelHidden {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.searchExpanded {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
margin-inline: 0;
|
||||
padding: 0 4px 0 0;
|
||||
border: 0.5px solid var(--dsw-alias-border-l4);
|
||||
border-radius: var(--dsw-radius-md);
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.searchButton {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: var(--dsw-radius-sm);
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.searchExpanded .searchButton {
|
||||
width: 28px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.searchButton:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.searchExpanded .searchButton:hover {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
flex: 1;
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
transition: opacity 120ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.searchExpanded .searchInput {
|
||||
margin-left: -2px;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.searchInput::placeholder {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.clearButton {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: var(--dsw-radius-sm);
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.clearButton:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
/* Rail variant (own .rail class from the wide owner prop — the region never
|
||||
reads the shell's class names): the two icon controls stack as 36x36
|
||||
circles matching the shell's rail rhythm. Both use the rail's shared base
|
||||
left anchor so the outer shell can translate the whole region uniformly. */
|
||||
.rail .sectionHeader {
|
||||
gap: 0;
|
||||
padding-left: 0;
|
||||
margin-bottom: 12px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.rail .headerActions {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.rail .iconButton {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
/* Rail hovers share the shell rail's 12px rounding. */
|
||||
border-radius: var(--dsw-radius-md);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.rail .search {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
margin: 0 0 12px;
|
||||
gap: 0;
|
||||
/* The collapsed search frame clips its button's hover, so it must carry the
|
||||
rail's 12px rounding itself. */
|
||||
border-radius: var(--dsw-radius-md);
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.rail .searchButton {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--dsw-radius-md);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.rail .searchButton:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
/* List seat: always mounted so the shell foot never moves. */
|
||||
.listArea {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-left: -4px;
|
||||
margin-right: calc(-1 * var(--dsh-session-list-edge-inset));
|
||||
padding-left: 4px;
|
||||
/* The list remains the scroll clip. This seat stays visible so the
|
||||
absolutely positioned first-boundary marker can occupy the header gap. */
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.rail .listArea {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
/* Relative for the bottom fade overlay. */
|
||||
.treeBody {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Bottom fade: compact overlay pinned to the visible bottom,
|
||||
transparent -> sidebar fill so it tracks the theme. */
|
||||
.fade {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: var(--dsh-session-list-edge-inset);
|
||||
bottom: 0;
|
||||
height: 24px;
|
||||
background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* macOS desktop: the sidebar is translucent over window vibrancy, so the
|
||||
opaque fill gradient would paint a solid smear instead of a fade. */
|
||||
:global([data-platform='darwin']) .fade {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Wide-only content fades back in on expand remount (mirrors the shell). */
|
||||
.wide {
|
||||
animation: wide-in 200ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
@keyframes wide-in {
|
||||
from { opacity: 0; }
|
||||
}
|
||||
|
||||
/* List: the only scrolling region. Block children keep their design heights
|
||||
under content overflow. The 2px edge offset, stable 5px themed scrollbar,
|
||||
and remaining padding equal the shell's right inset, with or without
|
||||
overflow, so moving the bar does not move the rows. */
|
||||
.list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
margin-left: -4px;
|
||||
margin-right: var(--dsh-session-list-scrollbar-offset);
|
||||
padding-left: 4px;
|
||||
padding-right: calc(
|
||||
var(--dsh-session-list-edge-inset)
|
||||
- var(--dsh-session-list-scrollbar-width)
|
||||
- var(--dsh-session-list-scrollbar-offset)
|
||||
);
|
||||
/* Clears the compact bottom fade overlay: at scroll end the last row sits
|
||||
above the gradient instead of under it. */
|
||||
padding-bottom: 16px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.flatList > * + *,
|
||||
.searchTree > [role='treeitem'] + [role='treeitem'],
|
||||
.groupSection > * + * {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.searchStatus {
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* Pending content search: skeleton rows on searchResultRow metrics — a round
|
||||
leading glyph beside a title bar and a wider snippet bar, breathing through
|
||||
opacity (same pulse as the input-trigger menu skeleton). */
|
||||
.skeletonRow {
|
||||
display: flex;
|
||||
/* Top alignment lines the 16px dot up with the equally tall title bar;
|
||||
the paddings center the 35px content in the 48px row. */
|
||||
align-items: flex-start;
|
||||
gap: var(--dsh-sidebar-label-gap);
|
||||
box-sizing: border-box;
|
||||
min-height: 48px;
|
||||
padding: 6px var(--dsh-sidebar-row-padding) 7px;
|
||||
}
|
||||
|
||||
.skeletonDot {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
corner-shape: round;
|
||||
}
|
||||
|
||||
.skeletonBars {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.skeletonDot,
|
||||
.skeletonBar {
|
||||
background: var(--dsw-alias-bg-skeleton);
|
||||
animation: search-skeleton 2s cubic-bezier(0.36, 0, 0.64, 1) infinite;
|
||||
}
|
||||
|
||||
.skeletonBar {
|
||||
width: 65%;
|
||||
height: 16px;
|
||||
border-radius: var(--dsw-radius-xs);
|
||||
}
|
||||
|
||||
.skeletonBarWide {
|
||||
width: 90%;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
@keyframes search-skeleton {
|
||||
0% { opacity: 1; }
|
||||
40% { opacity: 0.6; }
|
||||
80%, 100% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* One workspace section: header row + a compact expanded session run. */
|
||||
.groupSection {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.groupSection + .groupSection {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.listTopDropIndicator,
|
||||
.workspaceDropBefore::before,
|
||||
.workspaceDropAfter::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 12px;
|
||||
background:
|
||||
linear-gradient(
|
||||
55deg,
|
||||
transparent calc(50% - 1px),
|
||||
var(--dsw-alias-state-business-primary) calc(50% - 1px) calc(50% + 1px),
|
||||
transparent calc(50% + 1px)
|
||||
) 0 0 / 5px 7px no-repeat,
|
||||
linear-gradient(
|
||||
125deg,
|
||||
transparent calc(50% - 1px),
|
||||
var(--dsw-alias-state-business-primary) calc(50% - 1px) calc(50% + 1px),
|
||||
transparent calc(50% + 1px)
|
||||
) 0 5px / 5px 7px no-repeat,
|
||||
linear-gradient(
|
||||
var(--dsw-alias-state-business-primary) 0 0
|
||||
) 4px 5px / calc(100% - 4px) 2px no-repeat;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* The first insertion boundary keeps the same -8px coordinate as every
|
||||
Workspace boundary, but lives outside the scrolling clip. */
|
||||
.listTopDropIndicator {
|
||||
top: -8px;
|
||||
left: 0;
|
||||
right: var(--dsh-session-list-edge-inset);
|
||||
}
|
||||
|
||||
.listTopDropActive > .workspaceDropBefore:first-child::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workspaceDropBefore::before {
|
||||
top: -8px;
|
||||
}
|
||||
|
||||
.workspaceDropAfter::after {
|
||||
bottom: -8px;
|
||||
}
|
||||
|
||||
.sessionOverflowButton {
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: var(--dsw-radius-sm);
|
||||
padding: 0 var(--dsh-sidebar-row-padding) 0 calc(var(--dsh-sidebar-row-padding) + 16px + var(--dsh-sidebar-label-gap) + var(--dsh-workspace-indent, 0px));
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.groupSection > .sessionOverflowButton {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.sessionOverflowButton:hover {
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 16px 12px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 80px;
|
||||
padding: 0 12px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.emptyState > svg {
|
||||
margin-bottom: 4px;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.emptyAction {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-link);
|
||||
}
|
||||
|
||||
/* Rename dialog form (same figma dialog family as the create modals). */
|
||||
.renameInput {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 7px 14px;
|
||||
border: 0.5px solid var(--dsw-alias-border-l4);
|
||||
border-radius: var(--dsw-radius-lg);
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.renameInput:disabled {
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
.renameError {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.deleteAction:not(:disabled) {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.deleteStatus {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* The stop-and-archive dialog's list of work that will be stopped. */
|
||||
.archiveActivity {
|
||||
margin: 0 0 8px;
|
||||
padding-left: 18px;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.archiveActivity li {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.searchInput,
|
||||
.renameInput {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wide {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.skeletonDot,
|
||||
.skeletonBar {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.root:not(.rail) .sectionHeader.searchSpacingMotion,
|
||||
.search,
|
||||
.sectionLabel,
|
||||
.searchSlot,
|
||||
.searchInput,
|
||||
.headerActions {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
/** IPC / mock bridge for Hermit desktop. */
|
||||
|
||||
export type SessionInfo = {
|
||||
id: string;
|
||||
title: string;
|
||||
updated_at: number;
|
||||
pinned?: boolean;
|
||||
folder?: string | null;
|
||||
};
|
||||
|
||||
export type ConsentRequest = {
|
||||
id: string;
|
||||
tool: string;
|
||||
detail: string;
|
||||
preview?: string;
|
||||
};
|
||||
|
||||
export type QuestionRequest = {
|
||||
id: string;
|
||||
question: string;
|
||||
options: string[];
|
||||
};
|
||||
|
||||
export type AttachmentInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type ProviderInfo = {
|
||||
name: string;
|
||||
model: string;
|
||||
has_key: boolean;
|
||||
key_from?: string;
|
||||
};
|
||||
|
||||
export type UsageInfo = {
|
||||
prompt: number;
|
||||
out: number;
|
||||
cost: number;
|
||||
priced: boolean;
|
||||
pct: number;
|
||||
};
|
||||
|
||||
export type BashRun = {
|
||||
id: string;
|
||||
command: string;
|
||||
status: string;
|
||||
output?: string;
|
||||
exit_code?: number;
|
||||
started_at: number;
|
||||
ended_at?: number;
|
||||
};
|
||||
|
||||
export type ChatItem =
|
||||
| { kind: "user"; id: string; text: string; ts?: number }
|
||||
| { kind: "assistant"; id: string; text: string; ts?: number }
|
||||
| { kind: "thought"; id: string; text: string; ts?: number }
|
||||
| {
|
||||
kind: "tool";
|
||||
id: string;
|
||||
name: string;
|
||||
status: "running" | "ok" | "denied" | "error";
|
||||
detail: string;
|
||||
output?: string;
|
||||
exit_code?: number;
|
||||
ts?: number;
|
||||
};
|
||||
|
||||
export type EngineEvent =
|
||||
| { type: "assistant_delta"; text: string }
|
||||
| { type: "thought_delta"; text: string }
|
||||
| { type: "tool_start"; id: string; name: string; detail: string }
|
||||
| {
|
||||
type: "tool_end";
|
||||
id: string;
|
||||
status: "ok" | "denied" | "error";
|
||||
detail: string;
|
||||
output?: string;
|
||||
exit_code?: number;
|
||||
}
|
||||
| { type: "consent"; req: ConsentRequest }
|
||||
| { type: "question"; req: QuestionRequest }
|
||||
| { type: "todo"; text: string }
|
||||
| { type: "status"; message: string }
|
||||
| { type: "turn_end"; ok: boolean }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
export type Settings = {
|
||||
provider: string;
|
||||
model: string;
|
||||
approval: "ask" | "auto" | "yolo";
|
||||
workspace: string;
|
||||
plan_mode?: boolean;
|
||||
api_key?: string;
|
||||
/** "" | low | medium | high | max */
|
||||
reasoning_effort?: string;
|
||||
font_size?: "s" | "m" | "l" | "xl";
|
||||
language?: "zh" | "en";
|
||||
native_titlebar?: boolean;
|
||||
};
|
||||
|
||||
type Unlisten = () => void;
|
||||
|
||||
async function tryInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T | null> {
|
||||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
return await invoke<T>(cmd, args);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const mockSessions: SessionInfo[] = [
|
||||
{
|
||||
id: "s1",
|
||||
title: "Fix home TUI layout",
|
||||
updated_at: Date.now() - 3600_000,
|
||||
pinned: true,
|
||||
folder: "desktop",
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
title: "Ship hermit-update",
|
||||
updated_at: Date.now() - 86400_000,
|
||||
folder: "desktop",
|
||||
},
|
||||
];
|
||||
|
||||
const mockTranscripts = new Map<string, ChatItem[]>([
|
||||
[
|
||||
"s1",
|
||||
[
|
||||
{ kind: "user", id: "u1", text: "右上角布局不对,帮我看看。", ts: Date.now() - 3600_000 },
|
||||
{
|
||||
kind: "thought",
|
||||
id: "t1",
|
||||
text: "Sidebar starts at row 0 while topbar only covers the main pane…",
|
||||
ts: Date.now() - 3590_000,
|
||||
},
|
||||
{
|
||||
kind: "tool",
|
||||
id: "tool1",
|
||||
name: "read_file",
|
||||
status: "ok",
|
||||
detail: "src/tui.c · draw_side / layout",
|
||||
output: "…layout code…",
|
||||
exit_code: 0,
|
||||
ts: Date.now() - 3580_000,
|
||||
},
|
||||
{
|
||||
kind: "assistant",
|
||||
id: "a1",
|
||||
text: "顶栏只盖主区、侧栏从第 0 行铺满,右上角会空出一块。已把 topbar 拉满并把 seat deck 对齐到顶栏下方。",
|
||||
ts: Date.now() - 3570_000,
|
||||
},
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
let mockBusy = false;
|
||||
let mockSettings: Settings = {
|
||||
provider: "deepseek",
|
||||
model: "deepseek-flash",
|
||||
approval: "auto",
|
||||
workspace: "~/hermit",
|
||||
plan_mode: false,
|
||||
api_key: "",
|
||||
reasoning_effort: "high",
|
||||
font_size: "m",
|
||||
language: "zh",
|
||||
native_titlebar: false,
|
||||
};
|
||||
let mockPending: AttachmentInfo[] = [];
|
||||
let mockTodo = "";
|
||||
let mockBash: BashRun[] = [];
|
||||
const mockListeners = new Set<(e: EngineEvent) => void>();
|
||||
let mockConsentResolve: ((n: number) => void) | null = null;
|
||||
let mockQuestionResolve: ((s: string) => void) | null = null;
|
||||
|
||||
function emitMock(e: EngineEvent) {
|
||||
mockListeners.forEach((fn) => fn(e));
|
||||
}
|
||||
|
||||
function normalizeSession(s: SessionInfo): SessionInfo {
|
||||
let updated_at = s.updated_at;
|
||||
if (updated_at > 0 && updated_at < 1e12) updated_at *= 1000;
|
||||
return { ...s, updated_at };
|
||||
}
|
||||
|
||||
export async function listSessions(): Promise<SessionInfo[]> {
|
||||
const real = await tryInvoke<SessionInfo[]>("session_list");
|
||||
const list = real ?? mockSessions;
|
||||
return list.map(normalizeSession);
|
||||
}
|
||||
|
||||
export async function newSession(): Promise<SessionInfo> {
|
||||
const real = await tryInvoke<SessionInfo>("session_new");
|
||||
if (real) return real;
|
||||
const s: SessionInfo = {
|
||||
id: `s${Date.now()}`,
|
||||
title: "New session",
|
||||
updated_at: Date.now(),
|
||||
};
|
||||
mockSessions.unshift(s);
|
||||
mockTranscripts.set(s.id, []);
|
||||
mockBash = [];
|
||||
return s;
|
||||
}
|
||||
|
||||
export async function openSession(id: string): Promise<ChatItem[]> {
|
||||
const real = await tryInvoke<ChatItem[]>("session_open", { id });
|
||||
if (real) return real;
|
||||
return mockTranscripts.get(id) ?? [];
|
||||
}
|
||||
|
||||
export async function renameSession(id: string, title: string): Promise<boolean> {
|
||||
const real = await tryInvoke<boolean>("session_rename", { id, title });
|
||||
if (real !== null) return real;
|
||||
const s = mockSessions.find((x) => x.id === id);
|
||||
if (!s) return false;
|
||||
s.title = title;
|
||||
s.updated_at = Date.now();
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function deleteSession(id: string): Promise<boolean> {
|
||||
const real = await tryInvoke<boolean>("session_delete", { id });
|
||||
if (real !== null) return real;
|
||||
const i = mockSessions.findIndex((x) => x.id === id);
|
||||
if (i < 0) return false;
|
||||
mockSessions.splice(i, 1);
|
||||
mockTranscripts.delete(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function pinSession(id: string): Promise<boolean> {
|
||||
const real = await tryInvoke<boolean>("session_pin", { id });
|
||||
if (real !== null) return real;
|
||||
const s = mockSessions.find((x) => x.id === id);
|
||||
if (!s) return false;
|
||||
s.pinned = !s.pinned;
|
||||
mockSessions.sort((a, b) => Number(!!b.pinned) - Number(!!a.pinned));
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function setSessionFolder(
|
||||
id: string,
|
||||
folder: string | null,
|
||||
): Promise<boolean> {
|
||||
const real = await tryInvoke<boolean>("session_set_folder", { id, folder });
|
||||
if (real !== null) return real;
|
||||
const s = mockSessions.find((x) => x.id === id);
|
||||
if (!s) return false;
|
||||
s.folder = folder;
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function getSettings(): Promise<Settings> {
|
||||
const real = await tryInvoke<Settings>("settings_get");
|
||||
return real ?? { ...mockSettings };
|
||||
}
|
||||
|
||||
export async function setSettings(settings: Settings): Promise<Settings> {
|
||||
const real = await tryInvoke<Settings>("settings_set", { settings });
|
||||
if (real) {
|
||||
mockSettings = real;
|
||||
return real;
|
||||
}
|
||||
mockSettings = { ...settings };
|
||||
return mockSettings;
|
||||
}
|
||||
|
||||
export async function pickWorkspace(): Promise<string | null> {
|
||||
try {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: "Choose workspace",
|
||||
});
|
||||
if (typeof selected === "string") return selected;
|
||||
return null;
|
||||
} catch {
|
||||
const next = window.prompt("Workspace path", mockSettings.workspace);
|
||||
return next && next.trim() ? next.trim() : null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function pickFiles(): Promise<string[]> {
|
||||
try {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const selected = await open({
|
||||
multiple: true,
|
||||
title: "Attach files",
|
||||
});
|
||||
if (!selected) return [];
|
||||
return Array.isArray(selected) ? selected : [selected];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function listPending(): Promise<AttachmentInfo[]> {
|
||||
const real = await tryInvoke<AttachmentInfo[]>("attach_list");
|
||||
return real ?? mockPending;
|
||||
}
|
||||
|
||||
export async function attachFile(path: string): Promise<AttachmentInfo | null> {
|
||||
const real = await tryInvoke<AttachmentInfo>("attach_file", { path });
|
||||
if (real) return real;
|
||||
const name = path.split(/[/\\]/).pop() || path;
|
||||
const info: AttachmentInfo = { id: `a${Date.now()}`, name, path };
|
||||
mockPending.push(info);
|
||||
return info;
|
||||
}
|
||||
|
||||
export async function clearPending(): Promise<void> {
|
||||
await tryInvoke("attach_clear");
|
||||
mockPending = [];
|
||||
}
|
||||
|
||||
export async function removePending(id: string): Promise<void> {
|
||||
await tryInvoke("attach_remove", { id });
|
||||
mockPending = mockPending.filter((a) => a.id !== id);
|
||||
}
|
||||
|
||||
export async function listProviders(): Promise<ProviderInfo[]> {
|
||||
const real = await tryInvoke<ProviderInfo[]>("providers_list");
|
||||
return (
|
||||
real ?? [
|
||||
{ name: "deepseek", model: "deepseek-flash", has_key: true, key_from: "env" },
|
||||
{ name: "openai", model: "gpt-4.1", has_key: false },
|
||||
{ name: "anthropic", model: "claude-sonnet-4", has_key: false },
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
export async function useProvider(
|
||||
provider: string,
|
||||
model: string,
|
||||
key?: string,
|
||||
): Promise<string> {
|
||||
const real = await tryInvoke<string>("providers_use", { provider, model, key: key ?? "" });
|
||||
if (real !== null) return real;
|
||||
mockSettings = { ...mockSettings, provider, model, api_key: key ?? mockSettings.api_key };
|
||||
return "";
|
||||
}
|
||||
|
||||
export async function getUsage(): Promise<UsageInfo> {
|
||||
const real = await tryInvoke<UsageInfo>("usage_get");
|
||||
return real ?? { prompt: 0, out: 0, cost: 0, priced: false, pct: 0 };
|
||||
}
|
||||
|
||||
export async function listWorkspaceFiles(query: string): Promise<string[]> {
|
||||
const real = await tryInvoke<string[]>("workspace_files", { query });
|
||||
if (real) return real;
|
||||
const sample = [
|
||||
"src/main.c",
|
||||
"src/tui.c",
|
||||
"apps/desktop/src/App.tsx",
|
||||
"README.md",
|
||||
"include/cst_agent.h",
|
||||
];
|
||||
const q = query.toLowerCase();
|
||||
return sample.filter((p) => !q || p.toLowerCase().includes(q)).slice(0, 20);
|
||||
}
|
||||
|
||||
export async function compactSession(): Promise<string> {
|
||||
const real = await tryInvoke<string>("session_compact");
|
||||
return real ?? "Compacted (mock).";
|
||||
}
|
||||
|
||||
export async function exportSession(): Promise<string> {
|
||||
const real = await tryInvoke<string>("session_export");
|
||||
return real ?? "# Session export (mock)\n";
|
||||
}
|
||||
|
||||
export async function engineStatus(): Promise<string> {
|
||||
return (await tryInvoke<string>("engine_status")) ?? "browser";
|
||||
}
|
||||
|
||||
export async function getTodo(): Promise<string> {
|
||||
const real = await tryInvoke<string>("todo_get");
|
||||
if (real !== null) {
|
||||
mockTodo = real;
|
||||
return real;
|
||||
}
|
||||
return mockTodo;
|
||||
}
|
||||
|
||||
export async function listBashRuns(): Promise<BashRun[]> {
|
||||
const real = await tryInvoke<BashRun[]>("bash_list");
|
||||
return real ?? mockBash;
|
||||
}
|
||||
|
||||
export function isShellTool(name: string): boolean {
|
||||
const n = name.toLowerCase();
|
||||
return n.includes("shell") || n === "bash" || n === "sh" || n === "run_terminal";
|
||||
}
|
||||
|
||||
/** Opt-in browser/host mock turns. Default: refuse so UI never silents as product. */
|
||||
export function mockTurnsAllowed(): boolean {
|
||||
return (
|
||||
import.meta.env.VITE_HERMIT_ALLOW_MOCK === "1" ||
|
||||
(typeof localStorage !== "undefined" &&
|
||||
localStorage.getItem("hermit.allowMock") === "1")
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendTurn(text: string): Promise<void> {
|
||||
const real = await tryInvoke<void>("send_turn", { text });
|
||||
if (real !== null) return;
|
||||
|
||||
if (!mockTurnsAllowed()) {
|
||||
emitMock({
|
||||
type: "error",
|
||||
message:
|
||||
"Engine unavailable (host/browser). Start with apps/desktop/run-dev.sh (needs libcstcore + DEEPSEEK_API_KEY or Settings key). Set hermit.allowMock=1 only for UI demos.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (mockBusy) return;
|
||||
mockBusy = true;
|
||||
emitMock({ type: "status", message: "mock demo (not engine)" });
|
||||
emitMock({ type: "thought_delta", text: "Planning a short reply…" });
|
||||
await delay(300);
|
||||
const tid = `m${Date.now()}`;
|
||||
mockBash.push({
|
||||
id: tid,
|
||||
command: "hermit --version",
|
||||
status: "running",
|
||||
started_at: Date.now(),
|
||||
});
|
||||
emitMock({
|
||||
type: "tool_start",
|
||||
id: tid,
|
||||
name: "shell",
|
||||
detail: "hermit --version",
|
||||
});
|
||||
if (mockSettings.approval === "ask") {
|
||||
const id = `c${Date.now()}`;
|
||||
const ans = await new Promise<number>((resolve) => {
|
||||
mockConsentResolve = resolve;
|
||||
emitMock({
|
||||
type: "consent",
|
||||
req: {
|
||||
id,
|
||||
tool: "shell",
|
||||
detail: "hermit --version",
|
||||
preview: "$ hermit --version\n",
|
||||
},
|
||||
});
|
||||
});
|
||||
mockConsentResolve = null;
|
||||
if (ans === 0) {
|
||||
emitMock({
|
||||
type: "tool_end",
|
||||
id: tid,
|
||||
status: "denied",
|
||||
detail: "rejected",
|
||||
});
|
||||
const b = mockBash.find((x) => x.id === tid);
|
||||
if (b) {
|
||||
b.status = "denied";
|
||||
b.ended_at = Date.now();
|
||||
}
|
||||
emitMock({ type: "turn_end", ok: false });
|
||||
mockBusy = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
await delay(400);
|
||||
const b = mockBash.find((x) => x.id === tid);
|
||||
if (b) {
|
||||
b.status = "ok";
|
||||
b.output = "hermit 1.1.0\n";
|
||||
b.exit_code = 0;
|
||||
b.ended_at = Date.now();
|
||||
}
|
||||
emitMock({
|
||||
type: "tool_end",
|
||||
id: tid,
|
||||
status: "ok",
|
||||
detail: "hermit 1.1.0",
|
||||
output: "hermit 1.1.0\n",
|
||||
exit_code: 0,
|
||||
});
|
||||
mockTodo = "- [x] Inspect layout\n- [ ] Verify send path\n- [ ] Ship desktop polish";
|
||||
emitMock({ type: "todo", text: mockTodo });
|
||||
if (text.toLowerCase().includes("ask")) {
|
||||
const qid = `q${Date.now()}`;
|
||||
const answer = await new Promise<string>((resolve) => {
|
||||
mockQuestionResolve = resolve;
|
||||
emitMock({
|
||||
type: "question",
|
||||
req: {
|
||||
id: qid,
|
||||
question: "Which approach should we take?",
|
||||
options: ["Polish first", "Features first", "Skip"],
|
||||
},
|
||||
});
|
||||
});
|
||||
mockQuestionResolve = null;
|
||||
emitMock({ type: "status", message: `User answered: ${answer}` });
|
||||
}
|
||||
const reply =
|
||||
"[mock] UI demo only. Use run-dev.sh with engine + API key for real turns.";
|
||||
for (const ch of reply) {
|
||||
emitMock({ type: "assistant_delta", text: ch });
|
||||
await delay(8);
|
||||
}
|
||||
mockPending = [];
|
||||
emitMock({ type: "turn_end", ok: true });
|
||||
mockBusy = false;
|
||||
}
|
||||
|
||||
export async function cancelTurn(): Promise<void> {
|
||||
await tryInvoke("cancel_turn");
|
||||
mockBusy = false;
|
||||
if (mockConsentResolve) {
|
||||
mockConsentResolve(0);
|
||||
mockConsentResolve = null;
|
||||
}
|
||||
if (mockQuestionResolve) {
|
||||
mockQuestionResolve("");
|
||||
mockQuestionResolve = null;
|
||||
}
|
||||
emitMock({ type: "turn_end", ok: false });
|
||||
}
|
||||
|
||||
export async function answerConsent(
|
||||
id: string,
|
||||
answer: "deny" | "once" | "always_tool" | "always_prefix",
|
||||
): Promise<void> {
|
||||
await tryInvoke("consent_answer", { id, answer });
|
||||
if (mockConsentResolve) {
|
||||
mockConsentResolve(answer === "deny" ? 0 : 1);
|
||||
mockConsentResolve = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function answerQuestion(id: string, answer: string): Promise<void> {
|
||||
await tryInvoke("question_answer", { id, answer });
|
||||
if (mockQuestionResolve) {
|
||||
mockQuestionResolve(answer);
|
||||
mockQuestionResolve = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getMockTodo(): string {
|
||||
return mockTodo;
|
||||
}
|
||||
|
||||
export async function onEngineEvent(fn: (e: EngineEvent) => void): Promise<Unlisten> {
|
||||
try {
|
||||
const { listen } = await import("@tauri-apps/api/event");
|
||||
const un = await listen<EngineEvent>("hermit://event", (ev) => {
|
||||
if (ev.payload.type === "todo") mockTodo = ev.payload.text;
|
||||
fn(ev.payload);
|
||||
});
|
||||
return () => {
|
||||
un();
|
||||
};
|
||||
} catch {
|
||||
mockListeners.add(fn);
|
||||
return () => {
|
||||
mockListeners.delete(fn);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function onMenuEvent(fn: (id: string) => void): Promise<Unlisten> {
|
||||
try {
|
||||
const { listen } = await import("@tauri-apps/api/event");
|
||||
const un = await listen<string>("hermit://menu", (ev) => fn(ev.payload));
|
||||
return () => {
|
||||
un();
|
||||
};
|
||||
} catch {
|
||||
return () => undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "@/dsh/index.css";
|
||||
import "@/styles/app.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,400 @@
|
||||
/* Minimal Hermit glue — DSH modules own surfaces. Bypass old theme color card. */
|
||||
|
||||
.app-frame-fill {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.hermit-palette {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: calc(100% + 8px);
|
||||
z-index: 20;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
border-radius: var(--dsw-radius-lg);
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
box-shadow: var(--dsw-elevation-panel);
|
||||
border: 0;
|
||||
--dsw-elevation-stroke-color: var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.hermit-palette button {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.hermit-palette button.active,
|
||||
.hermit-palette button:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.hermit-palette .pal-cmd {
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 12px;
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.hermit-palette .pal-desc {
|
||||
font-size: 12px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.hermit-chip-menu {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(100% + 6px);
|
||||
z-index: 30;
|
||||
min-width: 220px;
|
||||
padding: 6px;
|
||||
border-radius: var(--dsw-radius-lg);
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
box-shadow: var(--dsw-elevation-panel);
|
||||
}
|
||||
|
||||
.hermit-chip-menu .menu-section {
|
||||
padding: 6px 8px 4px;
|
||||
font-size: 11px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.hermit-chip-menu button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
gap: 2px;
|
||||
padding: 8px;
|
||||
border: none;
|
||||
border-radius: var(--dsw-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.hermit-chip-menu button.active,
|
||||
.hermit-chip-menu button:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.hermit-chip-menu .menu-desc {
|
||||
font-size: 12px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.hermit-effort-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.hermit-effort-row button {
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
padding: 6px 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hermit-attach-rail {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.hermit-attach-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
font-size: 12px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.hermit-attach-chip button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.hermit-toolbar-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
border-radius: var(--dsw-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hermit-toolbar-btn:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.hermit-toolbar-btn strong {
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.hermit-send {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hermit-send:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.hermit-send.stop {
|
||||
width: auto;
|
||||
padding: 0 12px;
|
||||
gap: 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.hermit-textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
resize: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-family: var(--dsw-font-family);
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
caret-color: var(--dsw-alias-state-business-primary);
|
||||
padding: 4px 8px 0 14px;
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
.hermit-textarea:disabled {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.hermit-drop-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--dsw-radius-panel);
|
||||
background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 12%, transparent);
|
||||
border: 1px dashed var(--dsw-alias-state-business-primary);
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
font-size: 13px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Settings overlay kept simple */
|
||||
.settings-scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--dsw-alias-bg-mask-1);
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
width: min(520px, calc(100% - 48px));
|
||||
max-height: calc(100% - 48px);
|
||||
overflow: auto;
|
||||
padding: 20px;
|
||||
border-radius: var(--dsw-radius-xl);
|
||||
background: var(--dsw-specific-input-major);
|
||||
box-shadow: var(--dsw-elevation-panel);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.settings-card h2 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.settings-card label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.settings-card input,
|
||||
.settings-card select {
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
border-radius: var(--dsw-radius-sm);
|
||||
border: 0.5px solid var(--dsw-alias-border-l3);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.settings-actions button {
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
border: none;
|
||||
border-radius: var(--dsw-radius-sm);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.settings-actions .primary {
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hermit-user-bubble {
|
||||
box-sizing: border-box;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--dsw-radius-xl);
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: var(--dsh-content-font-size, 14px);
|
||||
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.hermit-cursor {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 14px;
|
||||
margin-left: 2px;
|
||||
vertical-align: text-bottom;
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
animation: hermit-blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes hermit-blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Soften leftover Hermit theme tokens if any sheet still references them */
|
||||
:root {
|
||||
--hermit-accent: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
/* WebKitGTK paints native light buttons; kill UA chrome inside DSH shells. */
|
||||
.hermit-conversation-body button,
|
||||
aside button {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
aside [class*="projectRow"],
|
||||
aside [class*="sessionRow"] {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Hero mark: DSH fish slot is currentColor silhouette; keep crab readable. */
|
||||
.hermit-hero-mark {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
flex: none;
|
||||
}
|
||||
.hermit-hero-mark svg,
|
||||
.hermit-mark svg {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
animation: none !important;
|
||||
}
|
||||
.hermit-mark svg *,
|
||||
.hermit-hero-mark svg * {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
/* Kill DSH hover swim / pending pulses that thrash WebKitGTK compositing. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
[class*="fishHitbox"]:hover [class*="fish"],
|
||||
[class*="pending"],
|
||||
[class*="sendingSpinner"] {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Session row quick actions: hover-only (override DSH coarse-pointer always-on). */
|
||||
.hermit-row-actions {
|
||||
display: none !important;
|
||||
}
|
||||
@media (hover: hover) {
|
||||
[class*="sessionRow"]:hover .hermit-row-actions {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
}
|
||||
|
||||
.hermit-row-actions button {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Plan/Bash panels — DSH surface tokens */
|
||||
.plan-panel,
|
||||
.bash-panel {
|
||||
margin: 16px auto;
|
||||
width: min(100%, var(--dsh-chat-content-width));
|
||||
padding: 16px;
|
||||
border-radius: var(--dsw-radius-xl);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
border: 0.5px solid var(--dsw-alias-border-l3);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/* DSH dark surfaces + Hermit brand accents */
|
||||
:root {
|
||||
/* Brand from color card + agent-motion eye */
|
||||
--brand: #009cc6;
|
||||
--brand-bright: #70cde5;
|
||||
--brand-dim: #007a9c;
|
||||
--accent: var(--brand);
|
||||
--accent-bright: var(--brand-bright);
|
||||
--crab: #ffa939;
|
||||
--crab-dim: #e8922a;
|
||||
--cream: #f9f3ee;
|
||||
--eye: #9957ad;
|
||||
--send: var(--crab);
|
||||
--send-hover: var(--crab-dim);
|
||||
--ok: #22c55e;
|
||||
--warn: #f59e0b;
|
||||
--danger: #f25a5a;
|
||||
|
||||
/* DSH dark bluish neutrals (design-platform.css) */
|
||||
--bg: #151517; /* bluish-950 */
|
||||
--bg-elev: #1b1b1c; /* bluish-900 sidebar */
|
||||
--bg-layer: #232325; /* bluish-875 */
|
||||
--bg-card: #2c2c2e; /* bluish-850 input */
|
||||
--bg-hover: rgba(255, 255, 255, 0.08);
|
||||
--bg-active: rgba(255, 255, 255, 0.14);
|
||||
--border-l1: rgba(255, 255, 255, 0.06);
|
||||
--border-l2: rgba(255, 255, 255, 0.12);
|
||||
--border-l3: rgba(255, 255, 255, 0.16);
|
||||
--border-l4: rgba(255, 255, 255, 0.2);
|
||||
--border: var(--border-l1);
|
||||
--border-2: var(--border-l2);
|
||||
--border-3: var(--border-l3);
|
||||
--border-4: var(--border-l4);
|
||||
--text: #f9fafb;
|
||||
--text-dim: #ebf0f4;
|
||||
--text-muted: #cfd3d6;
|
||||
--text-faint: #adb2b8;
|
||||
--text-caption: #81858c;
|
||||
--bubble-user: #2c2c2e;
|
||||
|
||||
--radius-xs: 4px;
|
||||
--radius-sm: 8px;
|
||||
--radius: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 20px;
|
||||
--radius-panel: 28px;
|
||||
|
||||
--rail-w: 280px;
|
||||
--rail-collapsed: 56px;
|
||||
--chat-w: 820px;
|
||||
--composer-w: 852px; /* chat + 32 */
|
||||
--composer-side: 16px;
|
||||
--header-h: 52px;
|
||||
--new-session-h: 38px;
|
||||
|
||||
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||
"Noto Sans SC", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
--mono: "SF Mono", "JetBrains Mono", "Fira Code", Consolas, Menlo, monospace;
|
||||
--ease: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--elevation-stroke: 0 0 0 0.5px var(--border-l2);
|
||||
/* DSH soft: hairline + very light lift (not heavy drop shadow) */
|
||||
--elevation-soft: var(--elevation-stroke), 0 4px 16px rgba(0, 0, 0, 0.03),
|
||||
0 0 24px rgba(0, 0, 0, 0.03);
|
||||
--sidebar-wash: linear-gradient(
|
||||
to bottom,
|
||||
rgb(0 156 198 / 0.1),
|
||||
rgb(112 205 229 / 0) 38%,
|
||||
rgb(153 87 173 / 0) 68%,
|
||||
rgb(153 87 173 / 0.07)
|
||||
);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent-bright);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.svg?raw" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["src/*"] }
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/ApprovalCard.tsx","./src/components/BashPanel.tsx","./src/components/Composer.tsx","./src/components/Hero.tsx","./src/components/Icons.tsx","./src/components/PlanPanel.tsx","./src/components/QuestionCard.tsx","./src/components/Rail.tsx","./src/components/SessionHeader.tsx","./src/components/SettingsPanel.tsx","./src/components/Transcript.tsx","./src/lib/bridge.ts"],"version":"5.9.3"}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "node:path";
|
||||
|
||||
// 固定 IPv4:WebKitGTK 访问 localhost 常走 ::1,Vite 默认只听 127.0.0.1 → 空白灰窗
|
||||
const host = process.env.TAURI_DEV_HOST || "127.0.0.1";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: { "@": path.resolve(__dirname, "src") },
|
||||
},
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host,
|
||||
hmr: { protocol: "ws", host, port: 1421 },
|
||||
watch: { ignored: ["**/src-tauri/**"] },
|
||||
},
|
||||
envPrefix: ["VITE_", "TAURI_"],
|
||||
build: {
|
||||
target: process.env.TAURI_ENV_PLATFORM === "windows" ? "chrome105" : "safari13",
|
||||
minify: !process.env.TAURI_ENV_DEBUG ? "esbuild" : false,
|
||||
sourcemap: !!process.env.TAURI_ENV_DEBUG,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user