OR-225: localize dashboard in Chinese and Persian (#258)

* feat(ui): localize dashboard in Chinese and Persian

* fix(ui): refine localized directionality
This commit is contained in:
Daniel Kim
2026-08-27 21:43:28 -07:00
committed by GitHub
parent 5c365c6e07
commit 39a15958ce
68 changed files with 6178 additions and 2579 deletions
+19
View File
@@ -24,9 +24,28 @@ jobs:
with:
node-version: 22
- name: Localization catalogs
run: node ui/scripts/check-i18n.mjs
- name: Dev-slot helper tests
run: node --test scripts/dev-slot.test.mjs
- uses: pnpm/action-setup@v4
with:
version: 10
- name: Install UI dependencies
working-directory: ui
run: pnpm install --frozen-lockfile
- name: Generate localized messages
working-directory: ui
run: pnpm exec paraglide-js compile --silent --emit-ts-declarations
- name: UI typecheck
working-directory: ui
run: pnpm exec tsc --noEmit
- name: UI unit tests
run: node --test --experimental-strip-types ui/tests/*.test.mjs
Generated
+1 -1
View File
@@ -1047,7 +1047,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "openresearch-cli"
version = "0.1.116"
version = "0.1.117"
dependencies = [
"anyhow",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "openresearch-cli"
version = "0.1.116"
version = "0.1.117"
edition = "2021"
description = "OpenResearch CLI (orx) — Rust port"
repository = "https://github.com/alphaXiv/openresearch-cli"
+1051
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1041
View File
File diff suppressed because one or more lines are too long
+26 -3
View File
@@ -20,19 +20,42 @@
document.documentElement.dataset.theme = "light";
}
})();
// Resolve language before first paint; keep supported in sync with project.inlang/settings.json.
(function () {
var saved = null;
try { saved = localStorage.getItem("orx:locale"); } catch (e) {}
var browserPreferences = navigator.languages || [navigator.language];
var supported = ["en", "zh-CN", "fa"];
var match = function (tag) {
if (!tag) return null;
var normalized = String(tag).toLowerCase();
for (var j = 0; j < supported.length; j += 1) {
if (supported[j].toLowerCase() === normalized) return supported[j];
}
return null;
};
var locale = match(saved);
for (var i = 0; !locale && i < browserPreferences.length; i += 1) {
var preference = browserPreferences[i];
locale = match(preference) || match(String(preference).split("-")[0]);
}
locale = locale || "en";
document.documentElement.lang = locale;
document.documentElement.dir = "ltr";
})();
</script>
<style>
/* Pre-CSS background; keep in sync with --base in src/tailwind.css. */
html { background: #ffffff; }
html[data-theme="dark"] { background: #0e0c0c; }
</style>
<script type="module" crossorigin src="/assets/index-DfoIjp19.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dc0I88Kl.css">
<script type="module" crossorigin src="/assets/index-3pBlNq7W.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CECCiDmL.css">
</head>
<body>
<div
id="root"
class="[&_:where([data-tip])]:relative [&_:where([data-tip])::after]:content-[attr(data-tip)] [&_:where([data-tip])::after]:absolute [&_:where([data-tip])::after]:top-[calc(100%_+_6px)] [&_:where([data-tip])::after]:left-1/2 [&_:where([data-tip])::after]:-translate-x-1/2 [&_:where([data-tip])::after]:w-max [&_:where([data-tip])::after]:max-w-none [&_:where([data-tip])::after]:bg-text [&_:where([data-tip])::after]:text-background [&_:where([data-tip])::after]:text-xs [&_:where([data-tip])::after]:font-medium [&_:where([data-tip])::after]:leading-none [&_:where([data-tip])::after]:py-[5px] [&_:where([data-tip])::after]:px-2 [&_:where([data-tip])::after]:rounded-sm [&_:where([data-tip])::after]:whitespace-nowrap [&_:where([data-tip])::after]:opacity-0 [&_:where([data-tip])::after]:invisible [&_:where([data-tip])::after]:pointer-events-none [&_:where([data-tip])::after]:z-[9999] [&_:where([data-tip]):is(:hover,:focus-visible)::after]:opacity-100 [&_:where([data-tip]):is(:hover,:focus-visible)::after]:visible [&_:where([data-tip-align='start'])::after]:left-0 [&_:where([data-tip-align='start'])::after]:translate-none [&_:where([data-tip-align='end'])::after]:right-0 [&_:where([data-tip-align='end'])::after]:left-auto [&_:where([data-tip-align='end'])::after]:translate-none"
class="[&_:where([data-tip])]:relative [&_:where([data-tip])::after]:content-[attr(data-tip)] [&_:where([data-tip])::after]:absolute [&_:where([data-tip])::after]:top-[calc(100%_+_6px)] [&_:where([data-tip])::after]:left-1/2 [&_:where([data-tip])::after]:-translate-x-1/2 [&_:where([data-tip])::after]:w-max [&_:where([data-tip])::after]:max-w-none [&_:where([data-tip])::after]:bg-text [&_:where([data-tip])::after]:text-background [&_:where([data-tip])::after]:text-xs [&_:where([data-tip])::after]:font-medium [&_:where([data-tip])::after]:leading-none [&_:where([data-tip])::after]:py-[5px] [&_:where([data-tip])::after]:px-2 [&_:where([data-tip])::after]:rounded-sm [&_:where([data-tip])::after]:whitespace-nowrap [&_:where([data-tip])::after]:opacity-0 [&_:where([data-tip])::after]:invisible [&_:where([data-tip])::after]:pointer-events-none [&_:where([data-tip])::after]:z-[9999] [&_:where([data-tip]):is(:hover,:focus-visible)::after]:opacity-100 [&_:where([data-tip]):is(:hover,:focus-visible)::after]:visible [&_[data-tip-align='start']::after]:start-0 [&_[data-tip-align='start']::after]:translate-none [&_[data-tip-align='end']::after]:end-0 [&_[data-tip-align='end']::after]:start-auto [&_[data-tip-align='end']::after]:translate-none"
></div>
</body>
</html>
+24 -1
View File
@@ -20,6 +20,29 @@
document.documentElement.dataset.theme = "light";
}
})();
// Resolve language before first paint; keep supported in sync with project.inlang/settings.json.
(function () {
var saved = null;
try { saved = localStorage.getItem("orx:locale"); } catch (e) {}
var browserPreferences = navigator.languages || [navigator.language];
var supported = ["en", "zh-CN", "fa"];
var match = function (tag) {
if (!tag) return null;
var normalized = String(tag).toLowerCase();
for (var j = 0; j < supported.length; j += 1) {
if (supported[j].toLowerCase() === normalized) return supported[j];
}
return null;
};
var locale = match(saved);
for (var i = 0; !locale && i < browserPreferences.length; i += 1) {
var preference = browserPreferences[i];
locale = match(preference) || match(String(preference).split("-")[0]);
}
locale = locale || "en";
document.documentElement.lang = locale;
document.documentElement.dir = "ltr";
})();
</script>
<style>
/* Pre-CSS background; keep in sync with --base in src/tailwind.css. */
@@ -30,7 +53,7 @@
<body>
<div
id="root"
class="[&_:where([data-tip])]:relative [&_:where([data-tip])::after]:content-[attr(data-tip)] [&_:where([data-tip])::after]:absolute [&_:where([data-tip])::after]:top-[calc(100%_+_6px)] [&_:where([data-tip])::after]:left-1/2 [&_:where([data-tip])::after]:-translate-x-1/2 [&_:where([data-tip])::after]:w-max [&_:where([data-tip])::after]:max-w-none [&_:where([data-tip])::after]:bg-text [&_:where([data-tip])::after]:text-background [&_:where([data-tip])::after]:text-xs [&_:where([data-tip])::after]:font-medium [&_:where([data-tip])::after]:leading-none [&_:where([data-tip])::after]:py-[5px] [&_:where([data-tip])::after]:px-2 [&_:where([data-tip])::after]:rounded-sm [&_:where([data-tip])::after]:whitespace-nowrap [&_:where([data-tip])::after]:opacity-0 [&_:where([data-tip])::after]:invisible [&_:where([data-tip])::after]:pointer-events-none [&_:where([data-tip])::after]:z-[9999] [&_:where([data-tip]):is(:hover,:focus-visible)::after]:opacity-100 [&_:where([data-tip]):is(:hover,:focus-visible)::after]:visible [&_:where([data-tip-align='start'])::after]:left-0 [&_:where([data-tip-align='start'])::after]:translate-none [&_:where([data-tip-align='end'])::after]:right-0 [&_:where([data-tip-align='end'])::after]:left-auto [&_:where([data-tip-align='end'])::after]:translate-none"
class="[&_:where([data-tip])]:relative [&_:where([data-tip])::after]:content-[attr(data-tip)] [&_:where([data-tip])::after]:absolute [&_:where([data-tip])::after]:top-[calc(100%_+_6px)] [&_:where([data-tip])::after]:left-1/2 [&_:where([data-tip])::after]:-translate-x-1/2 [&_:where([data-tip])::after]:w-max [&_:where([data-tip])::after]:max-w-none [&_:where([data-tip])::after]:bg-text [&_:where([data-tip])::after]:text-background [&_:where([data-tip])::after]:text-xs [&_:where([data-tip])::after]:font-medium [&_:where([data-tip])::after]:leading-none [&_:where([data-tip])::after]:py-[5px] [&_:where([data-tip])::after]:px-2 [&_:where([data-tip])::after]:rounded-sm [&_:where([data-tip])::after]:whitespace-nowrap [&_:where([data-tip])::after]:opacity-0 [&_:where([data-tip])::after]:invisible [&_:where([data-tip])::after]:pointer-events-none [&_:where([data-tip])::after]:z-[9999] [&_:where([data-tip]):is(:hover,:focus-visible)::after]:opacity-100 [&_:where([data-tip]):is(:hover,:focus-visible)::after]:visible [&_[data-tip-align='start']::after]:start-0 [&_[data-tip-align='start']::after]:translate-none [&_[data-tip-align='end']::after]:end-0 [&_[data-tip-align='end']::after]:start-auto [&_[data-tip-align='end']::after]:translate-none"
></div>
<script type="module" src="/src/main.tsx"></script>
</body>
+1053
View File
File diff suppressed because it is too large Load Diff
+1053
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -5,12 +5,14 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"build": "pnpm lint:i18n && paraglide-js compile --silent --emit-ts-declarations && tsc --noEmit && vite build",
"lint:i18n": "node scripts/check-i18n.mjs",
"test": "node --test --experimental-strip-types tests/*.test.mjs",
"preview": "vite preview"
},
"dependencies": {
"@clo/react-markdown": "jsr:1.1.1",
"@inlang/paraglide-js": "^2.25.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"@xyflow/react": "^12.6.0",
+204
View File
@@ -11,6 +11,9 @@ importers:
'@clo/react-markdown':
specifier: jsr:1.1.1
version: '@jsr/clo__react-markdown@1.1.1'
'@inlang/paraglide-js':
specifier: ^2.25.0
version: 2.25.0(typescript@5.9.3)(vite@6.4.3(jiti@2.7.0)(lightningcss@1.32.0))
'@xterm/addon-fit':
specifier: ^0.10.0
version: 0.10.0(@xterm/xterm@5.5.0)
@@ -323,6 +326,25 @@ packages:
cpu: [x64]
os: [win32]
'@inlang/paraglide-js@2.25.0':
resolution: {integrity: sha512-a3Gw3md1S3WyJK1rFAP/FwDCsFKnu1b98rJ3k5bUl6nc3cmbglaSYpuK9qq9aLLlUlZhuCtB/Pb1LeKDUaLycg==}
hasBin: true
peerDependencies:
typescript: '>=5.6'
vite: '>=5.0.0'
peerDependenciesMeta:
typescript:
optional: true
vite:
optional: true
'@inlang/recommend-sherlock@0.2.1':
resolution: {integrity: sha512-ckv8HvHy/iTqaVAEKrr+gnl+p3XFNwe5D2+6w6wJk2ORV2XkcRkKOJ/XsTUJbPSiyi4PI+p+T3bqbmNx/rDUlg==}
'@inlang/sdk@3.0.2':
resolution: {integrity: sha512-qBAzkOLRQ42hZBl2/AZFZD+/JkdjBAvwxWphdTs9UbikMsuLathOBOIl+rC/mzaq48ylD3zE8Je2nrx7tbm0pw==}
engines: {node: '>=20.0.0'}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -345,6 +367,29 @@ packages:
'@jsr/clo__react-markdown@1.1.1':
resolution: {integrity: sha512-no2p+01aol5OiHGUwzYGkAKDWWPGpNAlumZezytP+zidXMGSTFChHs7k7j3ILM8SYaTK0HyVg1to4yumyuX97A==, tarball: https://npm.jsr.io/~/11/@jsr/clo__react-markdown/1.1.1.tgz}
'@lix-js/sdk-darwin-arm64@0.12.3':
resolution: {integrity: sha512-YeMJyYGk+QwvQe8O4AAcBMgfNm68IjAGDvuvZHr1U1rIb3GC2kYFStajqf4mT4BGc+cN65/P/2kkqEo+Igu/sA==}
cpu: [arm64]
os: [darwin]
'@lix-js/sdk-linux-arm64@0.12.3':
resolution: {integrity: sha512-jtmfNg6jLW61l1QvsU3W184l3hGtYUyPGzZrQyekk0vsMF1hxsP++7ONEGNI19PE7ldwSnGCB8Q6OzRKX87abQ==}
cpu: [arm64]
os: [linux]
'@lix-js/sdk-linux-x64@0.12.3':
resolution: {integrity: sha512-PiQL4h1FJXbxnZx/PgenI5grgpFtYlS/ZtPtTYh638YHVu+6J0cqGY/pLVqP8CKv1dWAKsZ2YgftLd+1j2+WKA==}
cpu: [x64]
os: [linux]
'@lix-js/sdk-win32-x64@0.12.3':
resolution: {integrity: sha512-hnxMI7kPS4ngXs0ZVnOve5CmtC4+vaNIyNDi/yWU0rSvxj7mqw/1RCegTrCB7LjxlX4DG+eg9HruXurk9NDjGg==}
cpu: [x64]
os: [win32]
'@lix-js/sdk@0.12.3':
resolution: {integrity: sha512-T2194yoZsM2S9kyRmI8UtEJL0fgXYAkeY9jZ/PVyC0K78/wmBvrqOSt9SdvgYxM5qnZ57re3NLOjVlV+X5hWxQ==}
'@rolldown/pluginutils@1.0.0-beta.27':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
@@ -473,6 +518,13 @@ packages:
cpu: [x64]
os: [win32]
'@sinclair/typebox@0.31.30':
resolution: {integrity: sha512-MGsM7bVmHg3sUKCphlu3SGQ+T+5JTbygZWYArfKQCW5anJeACHeqLhcnf+R7XlCWZ+QR3C8JTBx3kCKJTN69ow==}
'@sqlite.org/sqlite-wasm@3.48.0-build4':
resolution: {integrity: sha512-hI6twvUkzOmyGZhQMza1gpfqErZxXRw6JEsiVjUbo7tFanVD+8Oil0Ih3l2nGzHdxPI41zFmfUQG7GHqhciKZQ==}
hasBin: true
'@tailwindcss/node@4.3.3':
resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==}
@@ -664,6 +716,14 @@ packages:
'@xyflow/system@0.0.78':
resolution: {integrity: sha512-lY0z2qP33fUhTva9Vaxrk0lqZta2pkbxB1trHAx1omnJqRtPvDlAQYV2r5fhS6AdpkulYmbNW0svy+A4/t4B/g==}
acorn@8.18.0:
resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==}
engines: {node: '>=0.4.0'}
hasBin: true
array-timsort@1.0.3:
resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==}
bail@2.0.2:
resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
@@ -704,10 +764,22 @@ packages:
comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
commander@11.1.0:
resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==}
engines: {node: '>=16'}
commander@8.3.0:
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
engines: {node: '>= 12'}
comment-json@4.6.2:
resolution: {integrity: sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==}
engines: {node: '>= 6'}
consola@3.4.0:
resolution: {integrity: sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==}
engines: {node: ^14.18.0 || >=16.10.0}
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@@ -802,6 +874,11 @@ packages:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'}
esprima@4.0.1:
resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
engines: {node: '>=4'}
hasBin: true
estree-util-is-identifier-name@3.0.0:
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
@@ -817,6 +894,9 @@ packages:
picomatch:
optional: true
fflate@0.8.3:
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -905,6 +985,10 @@ packages:
resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==}
hasBin: true
kysely@0.28.17:
resolution: {integrity: sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==}
engines: {node: '>=20.0.0'}
lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'}
@@ -1236,6 +1320,11 @@ packages:
space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
sqlite-wasm-kysely@0.3.0:
resolution: {integrity: sha512-TzjBNv7KwRw6E3pdKdlRyZiTmUIE0UttT/Sl56MVwVARl/u5gp978KepazCJZewFUnlWHz9i3NQd4kOtP/Afdg==}
peerDependencies:
kysely: '*'
stringify-entities@4.0.4:
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
@@ -1291,17 +1380,36 @@ packages:
unist-util-visit@5.1.0:
resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==}
unplugin@2.3.11:
resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==}
engines: {node: '>=18.12.0'}
update-browserslist-db@1.2.3:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true
peerDependencies:
browserslist: '>= 4.21.0'
urlpattern-polyfill@10.1.0:
resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==}
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
uuid@14.0.2:
resolution: {integrity: sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==}
hasBin: true
valibot@1.4.2:
resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==}
peerDependencies:
typescript: '>=5'
peerDependenciesMeta:
typescript:
optional: true
vfile-location@5.0.3:
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
@@ -1357,6 +1465,9 @@ packages:
web-namespaces@2.0.1:
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -1570,6 +1681,33 @@ snapshots:
'@esbuild/win32-x64@0.25.12':
optional: true
'@inlang/paraglide-js@2.25.0(typescript@5.9.3)(vite@6.4.3(jiti@2.7.0)(lightningcss@1.32.0))':
dependencies:
'@inlang/recommend-sherlock': 0.2.1
'@inlang/sdk': 3.0.2
commander: 11.1.0
consola: 3.4.0
jiti: 2.7.0
json5: 2.2.3
unplugin: 2.3.11
urlpattern-polyfill: 10.1.0
valibot: 1.4.2(typescript@5.9.3)
optionalDependencies:
typescript: 5.9.3
vite: 6.4.3(jiti@2.7.0)(lightningcss@1.32.0)
'@inlang/recommend-sherlock@0.2.1':
dependencies:
comment-json: 4.6.2
'@inlang/sdk@3.0.2':
dependencies:
'@lix-js/sdk': 0.12.3
'@sinclair/typebox': 0.31.30
kysely: 0.28.17
sqlite-wasm-kysely: 0.3.0(kysely@0.28.17)
uuid: 14.0.2
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -1605,6 +1743,27 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@lix-js/sdk-darwin-arm64@0.12.3':
optional: true
'@lix-js/sdk-linux-arm64@0.12.3':
optional: true
'@lix-js/sdk-linux-x64@0.12.3':
optional: true
'@lix-js/sdk-win32-x64@0.12.3':
optional: true
'@lix-js/sdk@0.12.3':
dependencies:
fflate: 0.8.3
optionalDependencies:
'@lix-js/sdk-darwin-arm64': 0.12.3
'@lix-js/sdk-linux-arm64': 0.12.3
'@lix-js/sdk-linux-x64': 0.12.3
'@lix-js/sdk-win32-x64': 0.12.3
'@rolldown/pluginutils@1.0.0-beta.27': {}
'@rollup/rollup-android-arm-eabi@4.62.2':
@@ -1682,6 +1841,10 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.62.2':
optional: true
'@sinclair/typebox@0.31.30': {}
'@sqlite.org/sqlite-wasm@3.48.0-build4': {}
'@tailwindcss/node@4.3.3':
dependencies:
'@jridgewell/remapping': 2.3.5
@@ -1873,6 +2036,10 @@ snapshots:
d3-selection: 3.0.0
d3-zoom: 3.0.0
acorn@8.18.0: {}
array-timsort@1.0.3: {}
bail@2.0.2: {}
baseline-browser-mapping@2.10.42: {}
@@ -1903,8 +2070,17 @@ snapshots:
comma-separated-tokens@2.0.3: {}
commander@11.1.0: {}
commander@8.3.0: {}
comment-json@4.6.2:
dependencies:
array-timsort: 1.0.3
esprima: 4.0.1
consola@3.4.0: {}
convert-source-map@2.0.0: {}
csstype@3.2.3: {}
@@ -2005,6 +2181,8 @@ snapshots:
escape-string-regexp@5.0.0: {}
esprima@4.0.1: {}
estree-util-is-identifier-name@3.0.0: {}
extend@3.0.2: {}
@@ -2013,6 +2191,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.5
fflate@0.8.3: {}
fsevents@2.3.3:
optional: true
@@ -2131,6 +2311,8 @@ snapshots:
dependencies:
commander: 8.3.0
kysely@0.28.17: {}
lightningcss-android-arm64@1.32.0:
optional: true
@@ -2746,6 +2928,11 @@ snapshots:
space-separated-tokens@2.0.2: {}
sqlite-wasm-kysely@0.3.0(kysely@0.28.17):
dependencies:
'@sqlite.org/sqlite-wasm': 3.48.0-build4
kysely: 0.28.17
stringify-entities@4.0.4:
dependencies:
character-entities-html4: 2.1.0
@@ -2817,16 +3004,31 @@ snapshots:
unist-util-is: 6.0.1
unist-util-visit-parents: 6.0.2
unplugin@2.3.11:
dependencies:
'@jridgewell/remapping': 2.3.5
acorn: 8.18.0
picomatch: 4.0.5
webpack-virtual-modules: 0.6.2
update-browserslist-db@1.2.3(browserslist@4.28.4):
dependencies:
browserslist: 4.28.4
escalade: 3.2.0
picocolors: 1.1.1
urlpattern-polyfill@10.1.0: {}
use-sync-external-store@1.6.0(react@19.2.7):
dependencies:
react: 19.2.7
uuid@14.0.2: {}
valibot@1.4.2(typescript@5.9.3):
optionalDependencies:
typescript: 5.9.3
vfile-location@5.0.3:
dependencies:
'@types/unist': 3.0.3
@@ -2861,6 +3063,8 @@ snapshots:
web-namespaces@2.0.1: {}
webpack-virtual-modules@0.6.2: {}
yallist@3.1.1: {}
zustand@4.5.7(@types/react@19.2.17)(react@19.2.7):
+12
View File
@@ -0,0 +1,12 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"baseLocale": "en",
"locales": ["en", "zh-CN", "fa"],
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js"
],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.json"
}
}
+71
View File
@@ -0,0 +1,71 @@
import { readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const uiDir = dirname(dirname(fileURLToPath(import.meta.url)));
const settings = await readJson(join(uiDir, "project.inlang/settings.json"));
const catalogs = new Map();
const errors = [];
for (const locale of settings.locales) {
const file = join(uiDir, "messages", `${locale}.json`);
try {
catalogs.set(locale, await readJson(file));
} catch (error) {
errors.push(`${locale}: ${error instanceof Error ? error.message : String(error)}`);
}
}
const base = catalogs.get(settings.baseLocale);
if (!base) {
errors.push(`Base locale catalog is unavailable: ${settings.baseLocale}`);
} else {
const baseKeys = messageKeys(base);
for (const [locale, catalog] of catalogs) {
const keys = messageKeys(catalog);
const missing = baseKeys.filter((key) => !keys.includes(key));
const unexpected = keys.filter((key) => !baseKeys.includes(key));
const invalid = keys.filter(
(key) => typeof catalog[key] !== "string" || catalog[key].trim() === "",
);
const placeholderMismatch = keys.filter(
(key) =>
typeof catalog[key] === "string" &&
placeholders(catalog[key]).join(",") !== placeholders(base[key]).join(","),
);
const entities = keys.filter(
(key) => typeof catalog[key] === "string" && /&(?:apos|quot|amp|lt|gt);/.test(catalog[key]),
);
if (missing.length) errors.push(`${locale}: missing keys: ${missing.join(", ")}`);
if (unexpected.length) errors.push(`${locale}: unexpected keys: ${unexpected.join(", ")}`);
if (invalid.length) errors.push(`${locale}: empty or non-string values: ${invalid.join(", ")}`);
if (placeholderMismatch.length) errors.push(`${locale}: placeholder mismatch: ${placeholderMismatch.join(", ")}`);
if (entities.length) errors.push(`${locale}: HTML entities must be decoded: ${entities.join(", ")}`);
}
}
if (errors.length) {
console.error(`Localization catalog lint failed:\n${errors.map((error) => `- ${error}`).join("\n")}`);
process.exitCode = 1;
}
async function readJson(file) {
try {
return JSON.parse(await readFile(file, "utf8"));
} catch (error) {
throw new Error(`${file}: ${error instanceof Error ? error.message : String(error)}`);
}
}
function messageKeys(catalog) {
return Object.keys(catalog)
.filter((key) => !key.startsWith("$"))
.sort();
}
function placeholders(message) {
if (typeof message !== "string") return [];
return [...message.matchAll(/\{([A-Za-z][A-Za-z0-9_]*)\}/g)]
.map((match) => match[1])
.sort();
}
+34 -32
View File
@@ -1,3 +1,5 @@
import { m } from "./paraglide/messages.js";
import { getLocale } from "./paraglide/runtime.js";
import {
ChartSpline,
Check,
@@ -408,7 +410,7 @@ const PANEL_MIN_WIDTH = 360;
const PANEL_MARGIN = 10;
// Space the rest of the layout needs beside the panel: the 272px rail, the
// chat column's minimum, and the gutters/margins between the three columns
// (app-body padding 14×2, rail inner margin 14, right-pane inner margin 14).
// (app-body padding 14×2, rail inner margin 14, end-pane inner margin 14).
const RAIL_WIDTH = 272;
const CHAT_MIN_SPACE = 380;
const LAYOUT_CHROME = RAIL_WIDTH + 14 * 4;
@@ -830,16 +832,16 @@ export default function App() {
: projectsResult.value[0]?.id ?? null,
);
} else {
errors.push("projects");
errors.push(m.app_projects());
}
if (uiStateResult.status === "fulfilled") {
persistedPreferredAgent.current = uiStateResult.value.preferredAgent;
setUiState(uiStateResult.value);
} else {
errors.push("settings");
errors.push(m.app_settings());
}
if (errors.length > 0) {
setStartupError(`Couldn't load OpenResearch ${errors.join(" and ")}.`);
setStartupError(m.app_startup_load_failed({ items: new Intl.ListFormat(getLocale()).format(errors) }));
}
});
}, []);
@@ -1060,14 +1062,14 @@ export default function App() {
);
const nextExperimentNames = useMemo(
() => new Map(experiments.map((experiment) => [experiment.id, experiment.title?.trim() || experiment.slug || "Experiment"])),
() => new Map(experiments.map((experiment) => [experiment.id, experiment.title?.trim() || experiment.slug || m.tree_experiment()])),
[experiments],
);
const experimentNames = useStableStringMap(nextExperimentNames);
const nextRunExperimentNames = useMemo(() => {
const names = new Map<string, string>();
for (const run of runs) {
names.set(run.id, experimentNames.get(run.experimentId) ?? "Experiment");
names.set(run.id, experimentNames.get(run.experimentId) ?? m.tree_experiment());
}
return names;
}, [experimentNames, runs]);
@@ -1609,7 +1611,7 @@ export default function App() {
<ClosableTab
key={rightTabKey(tab)}
active={planTab !== null && planTab.promptId === tab.promptId}
label="Plan"
label={m.chat_plan()}
icon={<ScrollText size={12} style={{ flexShrink: 0 }} />}
preview={isPreviewTab(tab)}
onSelect={() => selectRightTab(tab)}
@@ -1623,7 +1625,7 @@ export default function App() {
<ClosableTab
key={rightTabKey(tab)}
active={subagentTab !== null && subagentTab.spawnPartId === tab.spawnPartId}
label={spawnMeta[tab.spawnPartId]?.label ?? tab.label ?? "Sub-agent"}
label={spawnMeta[tab.spawnPartId]?.label ?? tab.label ?? m.app_subagent()}
shimmer={spawnMeta[tab.spawnPartId]?.running ?? false}
icon={<Users size={12} style={{ flexShrink: 0 }} />}
preview={isPreviewTab(tab)}
@@ -1653,7 +1655,7 @@ export default function App() {
<div className="app flex flex-col h-full">
<div className={EMPTY_STATE_CLASS_NAME}>
<p>{startupError}</p>
<button className={PRIMARY_BUTTON_CLASS_NAME} onClick={loadInitialState}>Retry</button>
<button className={PRIMARY_BUTTON_CLASS_NAME} onClick={loadInitialState}>{m.app_retry()}</button>
</div>
</div>
);
@@ -1778,22 +1780,22 @@ export default function App() {
)}
{mainView === "chat" && panelOpen && (
<aside
className={`right-pane relative shrink-0 min-w-0 flex flex-col mt-5 mr-0 mb-5 ml-3.5 bg-canvas [&.max]:fixed [&.max]:inset-2.5 [&.max]:m-0 [&.max]:z-60 [&.max]:shadow-[0_12px_40px_color-mix(in_oklab,_var(--text)_22%,_transparent)] floating-panel border border-border rounded-lg overflow-hidden ${ELEVATED_SURFACE_SHADOW_CLASS_NAME} ${panelMax ? "max" : ""}`}
className={`right-pane relative shrink-0 min-w-0 flex flex-col mt-5 me-0 mb-5 ms-3.5 bg-canvas [&.max]:fixed [&.max]:inset-2.5 [&.max]:m-0 [&.max]:z-60 [&.max]:shadow-[0_12px_40px_color-mix(in_oklab,_var(--text)_22%,_transparent)] floating-panel border border-border rounded-lg overflow-hidden ${ELEVATED_SURFACE_SHADOW_CLASS_NAME} ${panelMax ? "max" : ""}`}
style={panelMax ? undefined : { width: panelWidth }}
data-onboarding="experiments"
>
<div
className={`panel-resizer absolute left-0 top-0 bottom-0 w-1.5 z-30 [&:hover]:bg-[color-mix(in_oklab,_var(--text)_12%,_transparent)] [&:active]:bg-[color-mix(in_oklab,_var(--text)_12%,_transparent)] ${panelMax ? "cursor-e-resize" : "cursor-col-resize"}`}
title={panelMax ? "Drag right to restore panel" : "Drag to resize panel"}
className={`panel-resizer absolute start-0 top-0 bottom-0 w-1.5 z-30 [&:hover]:bg-[color-mix(in_oklab,_var(--text)_12%,_transparent)] [&:active]:bg-[color-mix(in_oklab,_var(--text)_12%,_transparent)] ${panelMax ? "cursor-e-resize" : "cursor-col-resize"}`}
title={panelMax ? m.app_drag_to_restore_panel() : m.app_drag_to_resize_panel()}
onPointerDown={resizePanel}
/>
<div className="tabs flex items-end gap-0 pt-1 pr-1.5 pb-0 pl-2 h-10 border-b border-b-border bg-background shrink-0">
<div className="tabs flex items-end gap-0 pt-1 pe-1.5 pb-0 ps-2 h-10 border-b border-b-border bg-background shrink-0">
<div className="tab-strip flex items-end gap-0.5 flex-1 min-w-0 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{leadingFileTabs.map(renderFileTab)}
{filesTabOpen && (
<ClosableTab
active={rightTab === "files"}
label="Files"
label={m.app_files()}
icon={<FolderOpen size={12} style={{ flexShrink: 0 }} />}
onSelect={() => selectRightTab("files")}
onClose={() => closeHomeTab("files")}
@@ -1802,7 +1804,7 @@ export default function App() {
{artifactsTabOpen && (
<ClosableTab
active={rightTab === "artifacts"}
label="Artifacts"
label={m.app_artifacts()}
icon={<Package size={12} style={{ flexShrink: 0 }} />}
onSelect={() => selectRightTab("artifacts")}
onClose={() => closeHomeTab("artifacts")}
@@ -1811,7 +1813,7 @@ export default function App() {
{experimentsTabOpen && (
<ClosableTab
active={rightTab === "experiments"}
label="Experiments"
label={m.app_experiments()}
icon={<FlaskConical size={12} style={{ flexShrink: 0 }} />}
onSelect={() => selectRightTab("experiments")}
onClose={() => closeHomeTab("experiments")}
@@ -1822,16 +1824,16 @@ export default function App() {
<div className="panel-controls inline-flex items-center gap-0.5 self-center py-0 px-1.5 shrink-0">
<button
className={ICON_BUTTON_CLASS_NAME}
title={panelMax ? "Restore panel" : "Expand panel"}
aria-label={panelMax ? "Restore panel" : "Expand panel"}
title={panelMax ? m.app_restore_panel() : m.app_expand_panel()}
aria-label={panelMax ? m.app_restore_panel() : m.app_expand_panel()}
onClick={() => setPanelMax((m) => !m)}
>
{panelMax ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
</button>
<button
className={ICON_BUTTON_CLASS_NAME}
title="Close panel"
aria-label="Close panel"
title={m.app_close_panel()}
aria-label={m.app_close_panel()}
onClick={() => {
pendingExperimentsAutoOpenRef.current = false;
setPanelOpen(false);
@@ -1864,24 +1866,24 @@ export default function App() {
<button
ref={scopeTriggerRef}
className={`${ICON_BUTTON_BASE_CLASS_NAME} experiment-scope-trigger w-6.5 h-6.5 rounded-sm${effectiveScope === "agent" ? " active" : ""}`}
title={`Experiment filter: ${effectiveScope === "agent" ? "Current task" : "Entire project"}`}
aria-label="Filter experiments"
title={m.app_experiment_filter({ scope: effectiveScope === "agent" ? m.app_current_task() : m.app_entire_project() })}
aria-label={m.app_filter_experiments()}
aria-expanded={scopeMenuOpen}
onClick={() => setScopeMenuOpen((open) => !open)}
>
<Filter size={16} strokeWidth={2.5} />
</button>
{scopeMenuOpen && (
<div className="option-menu absolute bottom-[calc(100%_+_8px)] left-0 max-h-95 flex flex-col bg-background border border-border rounded-lg shadow-[0_12px_32px_rgba(0,_0,_0,_0.18)] z-50 overflow-hidden min-w-47.5 p-1.5 [&.align-right]:left-auto [&.align-right]:right-0 [&.drop-down]:bottom-auto [&.drop-down]:top-[calc(100%_+_4px)] [&.session-menu]:left-auto [&.session-menu]:right-1.5 [&.session-menu]:top-[calc(100%_-_2px)] [&.session-menu]:min-w-35 drop-down align-right experiment-scope-menu [&_.model-item]:whitespace-nowrap [&_.model-item:disabled]:text-muted [&_.model-item:disabled]:cursor-default [&_.model-item:disabled:hover]:bg-transparent">
<div className="option-menu absolute bottom-[calc(100%_+_8px)] start-0 max-h-95 flex flex-col bg-background border border-border rounded-lg shadow-[0_12px_32px_rgba(0,_0,_0,_0.18)] z-50 overflow-hidden min-w-47.5 p-1.5 [&.align-right]:start-auto [&.align-right]:end-0 [&.drop-down]:bottom-auto [&.drop-down]:top-[calc(100%_+_4px)] [&.session-menu]:start-auto [&.session-menu]:end-1.5 [&.session-menu]:top-[calc(100%_-_2px)] [&.session-menu]:min-w-35 drop-down align-right experiment-scope-menu [&_.model-item]:whitespace-nowrap [&_.model-item:disabled]:text-muted [&_.model-item:disabled]:cursor-default [&_.model-item:disabled:hover]:bg-transparent">
<button
className={MODEL_ITEM_CLASS_NAME}
aria-pressed={effectiveScope === "agent"}
disabled={!activeSessionId || !allExperimentsAttributed}
title={
!activeSessionId
? "Open a task to filter to its experiments"
? m.app_open_task_to_filter()
: !allExperimentsAttributed
? "Current task filtering is unavailable for unattributed experiments"
? m.app_filter_unavailable_unattributed()
: undefined
}
onClick={() => {
@@ -1889,7 +1891,7 @@ export default function App() {
setScopeMenuOpen(false);
}}
>
<span>Current task</span>
<span>{m.app_current_task()}</span>
{effectiveScope === "agent" && <Check size={13} />}
</button>
<button
@@ -1900,7 +1902,7 @@ export default function App() {
setScopeMenuOpen(false);
}}
>
<span>Entire project</span>
<span>{m.app_entire_project()}</span>
{effectiveScope === "project" && <Check size={13} />}
</button>
</div>
@@ -1909,21 +1911,21 @@ export default function App() {
<div
className="seg inline-flex items-center gap-0.5 rounded-md bg-[color-mix(in_oklab,_var(--text)_10%,_transparent)] [&_button]:font-semibold [&_button]:text-text [&_button]:rounded-sm [&_button:not(:disabled):hover]:text-text [&_button.active]:bg-background [&_button.active]:shadow-[0_1px_3px_color-mix(in_oklab,_var(--text)_25%,_transparent)] [&_button:disabled]:text-muted [&_button:disabled]:cursor-default experiments-view-toggle p-0.5 [&_button]:py-0.5 [&_button]:px-2 [&_button]:text-sm"
role="group"
aria-label="Experiment view"
aria-label={m.app_experiment_view()}
>
<button
className={view === "table" ? "active" : ""}
aria-pressed={view === "table"}
onClick={() => setView("table")}
>
Table
{m.app_table()}
</button>
<button
className={view === "tree" ? "active" : ""}
aria-pressed={view === "tree"}
onClick={() => setView("tree")}
>
Tree
{m.app_tree()}
</button>
</div>
</div>
@@ -1946,7 +1948,7 @@ export default function App() {
runs={scopedRuns}
emptyHint={
effectiveScope === "agent" && experiments.length > 0
? "No experiments from the current task yet. Switch to Entire project to see all experiments."
? m.app_no_task_experiments()
: undefined
}
experiments={scopedExperiments}
@@ -2000,7 +2002,7 @@ export default function App() {
<div className={CODE_TAB_BODY_CLASS_NAME}>
<div className="wt-empty flex flex-col items-center gap-2.5 py-12 px-6 text-center text-muted [&_>_svg]:text-subtext [&_p]:m-0 [&_p]:max-w-80 [&_p]:text-sm">
<FolderGit2 size={22} />
<p>Select a project to browse its files.</p>
<p>{m.app_select_a_project_to_browse_its_files()}</p>
</div>
</div>
</div>
+26 -23
View File
@@ -1,5 +1,11 @@
// Typed client for the orx up local HTTP API (/api/*). All wire JSON is camelCase.
import { m } from "./paraglide/messages.js";
import { getLocale } from "./paraglide/runtime.js";
import { fmtNumber } from "./i18n";
export { fmtNumber } from "./i18n";
export const DEMO_PROJECT_ID = "demo_nanochat_v1";
// Bundled demo snapshots reserve this prefix so future demos inherit demo-only UI.
export const isDemoProjectId = (id: string) => id.startsWith("demo_");
@@ -1213,7 +1219,7 @@ export function serviceTiersFor(
const tiers = harness.models.find((model) => model.id === modelId)?.serviceTiers;
if (!tiers?.length) return [];
return [
{ id: SERVICE_TIER_DEFAULT_ID, label: "Standard", description: "Default speed" },
{ id: SERVICE_TIER_DEFAULT_ID, label: m.service_tier_standard(), description: m.service_tier_default_speed() },
...tiers,
];
}
@@ -1721,23 +1727,24 @@ export function statusColor(status: string): string {
export function timeAgo(ms: number): string {
const s = Math.max(0, Math.floor((Date.now() - ms) / 1000));
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
const format = new Intl.RelativeTimeFormat(getLocale(), { numeric: "always", style: "narrow" });
if (s < 60) return format.format(-s, "second");
const minutes = Math.floor(s / 60);
if (minutes < 60) return format.format(-minutes, "minute");
const h = Math.floor(minutes / 60);
if (h < 24) return format.format(-h, "hour");
return format.format(-Math.floor(h / 24), "day");
}
/** "42s" / "18m" / "2h 28m" / "1d 4h" — an elapsed duration, not a timestamp. */
export function fmtDuration(ms: number): string {
const s = Math.max(0, Math.floor(ms / 1000));
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ${m % 60}m`;
return `${Math.floor(h / 24)}d ${h % 24}h`;
if (s < 60) return m.duration_seconds({ value: fmtNumber(s) });
const minutes = Math.floor(s / 60);
if (minutes < 60) return m.duration_minutes({ value: fmtNumber(minutes) });
const hours = Math.floor(minutes / 60);
if (hours < 24) return m.duration_hours_minutes({ hours: fmtNumber(hours), minutes: fmtNumber(minutes % 60) });
return m.duration_days_hours({ days: fmtNumber(Math.floor(hours / 24)), hours: fmtNumber(hours % 24) });
}
/** Compact byte size, e.g. "512 B", "2.0 KB", "5.3 MB". Mirrors the backend's
@@ -1750,20 +1757,16 @@ export function fmtBytes(n: number): string {
v /= 1024;
u += 1;
}
return u === 0 ? `${n} B` : `${v.toFixed(1)} ${units[u]}`;
const number = new Intl.NumberFormat(getLocale(), { maximumFractionDigits: u === 0 ? 0 : 1, minimumFractionDigits: u === 0 ? 0 : 1 });
return `${number.format(u === 0 ? n : v)} ${units[u]}`;
}
/** Compact token count, e.g. 62300 → "62k", 1_200_000 → "1.2M", 940 → "940". */
export function fmtTokens(n: number): string {
if (n < 1000) return `${Math.round(n)}`;
// One decimal, dropped when it's .0 ("31.4k", "200k", "1M"). The k branch
// stops where toFixed(1) would round to "1000.0" (e.g. 999_960 → "1M").
if (n < 999_950) return `${trimZero((n / 1000).toFixed(1))}k`;
return `${trimZero((n / 1_000_000).toFixed(1))}M`;
}
function trimZero(s: string): string {
return s.endsWith(".0") ? s.slice(0, -2) : s;
const number = new Intl.NumberFormat(getLocale(), { maximumFractionDigits: 1 });
if (n < 1000) return number.format(Math.round(n));
if (n < 999_950) return `${number.format(n / 1000)}k`;
return `${number.format(n / 1_000_000)}M`;
}
export function shortId(id: string): string {
+19 -12
View File
@@ -1,4 +1,8 @@
import type { TurnOptions } from "./api";
import { m } from "./paraglide/messages.js";
import { getLocale } from "./paraglide/runtime.js";
const fmtNumber = (value: number) => new Intl.NumberFormat(getLocale()).format(value);
export interface RetryMetadata {
retryOwner?: "native" | "orx";
@@ -12,23 +16,26 @@ export function retryStatusLabel(input: RetryMetadata, now: number): string {
? Math.max(0, Math.ceil((input.nextRetryAt - now) / 1000))
: null;
if (input.retryOwner === "native" && input.maximum == null && seconds == null) {
return "CLI is retrying…";
return m.retry_cli();
}
return [
"Retrying",
typeof input.attempt === "number"
? typeof input.maximum === "number"
? `attempt ${input.attempt}/${input.maximum}`
: `attempt ${input.attempt}`
: null,
seconds == null ? null : `next attempt in ${seconds}s`,
].filter(Boolean).join(" · ");
if (typeof input.attempt === "number" && typeof input.maximum === "number" && seconds != null) {
return m.retry_attempt_max_seconds({ attempt: fmtNumber(input.attempt), maximum: fmtNumber(input.maximum), seconds: fmtNumber(seconds) });
}
if (typeof input.attempt === "number" && typeof input.maximum === "number") {
return m.retry_attempt_max({ attempt: fmtNumber(input.attempt), maximum: fmtNumber(input.maximum) });
}
if (typeof input.attempt === "number" && seconds != null) {
return m.retry_attempt_seconds({ attempt: fmtNumber(input.attempt), seconds: fmtNumber(seconds) });
}
if (typeof input.attempt === "number") return m.retry_attempt({ attempt: fmtNumber(input.attempt) });
if (seconds != null) return m.retry_seconds({ seconds: fmtNumber(seconds) });
return m.retrying();
}
export function queuedRetryLabel(nextRetryAt: number | null | undefined, now: number): string {
if (typeof nextRetryAt !== "number") return "Sending again…";
if (typeof nextRetryAt !== "number") return m.retry_sending_again();
const seconds = Math.max(0, Math.ceil((nextRetryAt - now) / 1000));
return `Sending again in ${seconds}s…`;
return m.retry_sending_again_seconds({ seconds: fmtNumber(seconds) });
}
export function recoveryAction(
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -6,6 +6,7 @@ import { backendDetail, backendKind, type Run } from "../api";
import slurmLogo from "../assets/slurm-logo.svg";
import thinkingMachinesLogo from "../assets/thinking-machines.png";
import { MONO_CLASS_NAME } from "../styleClasses";
import { m } from "../paraglide/messages.js";
/** Human name for a backend kind, used as the logo's alt/label. */
function backendName(kind: string): string {
@@ -25,7 +26,7 @@ function backendName(kind: string): string {
case "openresearch_job":
return "OpenResearch";
case "local_job":
return "This machine";
return m.compute_target_local();
case "tinker_job":
return "Tinker";
default:
+6 -4
View File
@@ -1,3 +1,5 @@
import { m } from "../paraglide/messages.js";
import { ltr } from "../i18n";
import { useEffect, useState } from "react";
import { getExperimentDiff, type DiffPayload, type Experiment } from "../api";
import { GitDiffExplorer, TruncatedDiffNotice } from "./GitDiff";
@@ -38,14 +40,14 @@ export function BranchChanges({
return (
<div className={`${CODE_TAB_BODY_CLASS_NAME} branch-changes [&_>_.changes-note]:my-3.5 [&_>_.changes-note]:mx-4 [&_>_.openresearch-diff]:mt-3.5 [&_>_.openresearch-diff]:mx-4 [&_>_.openresearch-diff]:mb-0 [&_>_.truncated-notice]:mt-3.5 [&_>_.truncated-notice]:mx-4 [&_>_.truncated-notice]:mb-0 [&_>_.diff-explorer]:mt-3.5 [&_>_.diff-explorer]:mx-4 [&_>_.diff-explorer]:mb-0`}>
{error ? (
<div className={CODE_TAB_NOTE_CLASS_NAME}>Failed to load changes: {error}</div>
<div className={CODE_TAB_NOTE_CLASS_NAME}>{m.branch_changes_failed_to_load_changes()} {ltr(error)}</div>
) : !diff ? (
<div className={CODE_TAB_NOTE_CLASS_NAME}>Loading changes…</div>
<div className={CODE_TAB_NOTE_CLASS_NAME}>{m.branch_changes_loading_changes()}</div>
) : !diff.diff.trim() ? (
<div className="changes-note text-sm text-muted">
{experiment.parentExperimentId
? "No committed changes from the parent branch."
: "This is the baseline branch, so there is no parent comparison."}
? m.branch_no_committed_changes()
: m.branch_baseline_no_parent()}
</div>
) : (
<>
+3 -1
View File
@@ -1,4 +1,6 @@
import { githubBranchUrl } from "../api";
import { ltr } from "../i18n";
import { m } from "../paraglide/messages.js";
import { GitHubMark } from "./BackendLogos";
const FILES_PILL_CLASS_NAME = [
@@ -29,7 +31,7 @@ export function BranchPill({
href={githubBranchUrl(owner, repo, branch)}
target="_blank"
rel="noopener noreferrer"
title={`Open ${branch} on GitHub`}
title={m.a11y_open_on_github({ name: ltr(branch) })}
>
<code>{branch}</code>
<GitHubMark size={12} />
File diff suppressed because it is too large Load Diff
+6 -5
View File
@@ -1,8 +1,9 @@
import { m } from "../paraglide/messages.js";
import { X } from "lucide-react";
// Italic glyphs lean past the label's clip box, so pad the visible span and the
// invisible width reserve alike — `italic` itself inherits into the ::after.
const PREVIEW_CLASS_NAME = "italic [&_.tab-label_>_span]:pr-1 [&_.tab-label::after]:pr-1";
const PREVIEW_CLASS_NAME = "italic [&_.tab-label_>_span]:pe-1 [&_.tab-label::after]:pe-1";
/** A closable tab in the right panel's tab strip (open experiments / files).
* The close "x" is a span, not a button — it can't nest inside the tab button. */
@@ -31,13 +32,13 @@ export function ClosableTab({
}) {
return (
<button
className={`tab [&.closable]:max-w-60 [&.closable]:pr-0.5 [&_.tab-label]:grid [&_.tab-label]:grid-cols-[minmax(0,_1fr)] [&_.tab-label]:min-w-0 [&_.tab-label]:overflow-hidden [&_.tab-label_>_span]:[grid-area:1_/_1] [&_.tab-label_>_span]:overflow-hidden [&_.tab-label_>_span]:text-ellipsis [&_.tab-label_>_span]:whitespace-nowrap [&_.tab-label::after]:[grid-area:1_/_1] [&_.tab-label::after]:overflow-hidden [&_.tab-label::after]:text-ellipsis [&_.tab-label::after]:whitespace-nowrap [&_.tab-label::after]:content-[attr(data-label)] [&_.tab-label::after]:invisible [&_.tab-label::after]:font-medium [&_.tab-close]:inline-flex [&_.tab-close]:items-center [&_.tab-close]:justify-center [&_.tab-close]:w-3.5 [&_.tab-close]:h-3.5 [&_.tab-close]:rounded-xs [&_.tab-close]:text-muted [&_.tab-close]:shrink-0 [&_.tab-close:hover]:bg-[color-mix(in_oklab,_var(--text)_15%,_transparent)] [&_.tab-close:hover]:text-text relative inline-flex items-center gap-[5px] h-8 py-0 px-2 border border-transparent border-b-0 rounded-[var(--radius-md)_var(--radius-md)_0_0] text-sm font-normal text-subtext whitespace-nowrap select-none min-w-24 [&:hover]:bg-surface [&:hover]:text-text [&:not(.active)_+_.tab:not(.active)::before]:content-[''] [&:not(.active)_+_.tab:not(.active)::before]:absolute [&:not(.active)_+_.tab:not(.active)::before]:top-2.5 [&:not(.active)_+_.tab:not(.active)::before]:bottom-2.5 [&:not(.active)_+_.tab:not(.active)::before]:-left-px [&:not(.active)_+_.tab:not(.active)::before]:w-px [&:not(.active)_+_.tab:not(.active)::before]:bg-border [&.active]:border-border [&.active]:bg-background [&.active]:text-text [&.active]:font-medium [&.active::after]:content-[''] [&.active::after]:absolute [&.active::after]:right-0 [&.active::after]:-bottom-px [&.active::after]:left-0 [&.active::after]:h-px [&.active::after]:bg-background closable ${active ? "active" : ""} ${preview ? PREVIEW_CLASS_NAME : ""}`}
className={`tab [&.closable]:max-w-60 [&.closable]:pe-0.5 [&_.tab-label]:grid [&_.tab-label]:grid-cols-[minmax(0,_1fr)] [&_.tab-label]:min-w-0 [&_.tab-label]:overflow-hidden [&_.tab-label_>_span]:[grid-area:1_/_1] [&_.tab-label_>_span]:overflow-hidden [&_.tab-label_>_span]:text-ellipsis [&_.tab-label_>_span]:whitespace-nowrap [&_.tab-label::after]:[grid-area:1_/_1] [&_.tab-label::after]:overflow-hidden [&_.tab-label::after]:text-ellipsis [&_.tab-label::after]:whitespace-nowrap [&_.tab-label::after]:content-[attr(data-label)] [&_.tab-label::after]:invisible [&_.tab-label::after]:font-medium [&_.tab-close]:inline-flex [&_.tab-close]:items-center [&_.tab-close]:justify-center [&_.tab-close]:w-3.5 [&_.tab-close]:h-3.5 [&_.tab-close]:rounded-xs [&_.tab-close]:text-muted [&_.tab-close]:shrink-0 [&_.tab-close:hover]:bg-[color-mix(in_oklab,_var(--text)_15%,_transparent)] [&_.tab-close:hover]:text-text relative inline-flex items-center gap-[5px] h-8 py-0 px-2 border border-transparent border-b-0 rounded-[var(--radius-md)_var(--radius-md)_0_0] text-sm font-normal text-subtext whitespace-nowrap select-none min-w-24 [&:hover]:bg-surface [&:hover]:text-text [&:not(.active)_+_.tab:not(.active)::before]:content-[''] [&:not(.active)_+_.tab:not(.active)::before]:absolute [&:not(.active)_+_.tab:not(.active)::before]:top-2.5 [&:not(.active)_+_.tab:not(.active)::before]:bottom-2.5 [&:not(.active)_+_.tab:not(.active)::before]:-start-px [&:not(.active)_+_.tab:not(.active)::before]:w-px [&:not(.active)_+_.tab:not(.active)::before]:bg-border [&.active]:border-border [&.active]:bg-background [&.active]:text-text [&.active]:font-medium [&.active::after]:content-[''] [&.active::after]:absolute [&.active::after]:end-0 [&.active::after]:-bottom-px [&.active::after]:start-0 [&.active::after]:h-px [&.active::after]:bg-background closable ${active ? "active" : ""} ${preview ? PREVIEW_CLASS_NAME : ""}`}
onClick={onSelect}
onDoubleClick={onPromote}
title={preview ? `${label} (double-click or ⌘/Ctrl+K Enter to keep open)` : label}
title={preview ? m.tab_preview_title({ label }) : label}
aria-label={
preview
? `${label} (preview; double-click or Command/Control K, then Enter to keep open)`
? m.tab_preview_aria({ label })
: label
}
>
@@ -48,7 +49,7 @@ export function ClosableTab({
<span
role="button"
className="tab-close"
title="Close tab"
title={m.closable_tab_close_tab()}
onClick={(e) => {
e.stopPropagation();
onClose();
+5 -4
View File
@@ -1,3 +1,4 @@
import { m } from "../paraglide/messages.js";
import { GitBranch, RotateCw } from "lucide-react";
import { GitHubMark } from "./BackendLogos";
import { ICON_BUTTON_CLASS_NAME, SPINNER_CLASS_NAME } from "../styleClasses";
@@ -28,14 +29,14 @@ export function CodeBrowserHeader({
return (
<div className="code-tab-header flex items-center gap-2 py-1.5 px-3 border-b border-b-border-variant shrink-0 [&_>_.seg]:p-0.5 [&_>_.seg]:rounded-sm [&_>_.seg_button]:py-0.5 [&_>_.seg_button]:px-2 [&_>_.seg_button]:text-sm [&_>_.seg_button]:font-medium">
{showViewToggle && (
<div className="seg inline-flex items-center gap-0.5 p-[3px] rounded-md bg-[color-mix(in_oklab,_var(--text)_10%,_transparent)] [&_button]:py-[3px] [&_button]:px-3 [&_button]:text-md [&_button]:font-semibold [&_button]:text-text [&_button]:rounded-sm [&_button:not(:disabled):hover]:text-text [&_button.active]:bg-background [&_button.active]:shadow-[0_1px_3px_color-mix(in_oklab,_var(--text)_25%,_transparent)] [&_button:disabled]:text-muted [&_button:disabled]:cursor-default" role="group" aria-label="Code browser view">
<div className="seg inline-flex items-center gap-0.5 p-[3px] rounded-md bg-[color-mix(in_oklab,_var(--text)_10%,_transparent)] [&_button]:py-[3px] [&_button]:px-3 [&_button]:text-md [&_button]:font-semibold [&_button]:text-text [&_button]:rounded-sm [&_button:not(:disabled):hover]:text-text [&_button.active]:bg-background [&_button.active]:shadow-[0_1px_3px_color-mix(in_oklab,_var(--text)_25%,_transparent)] [&_button:disabled]:text-muted [&_button:disabled]:cursor-default" role="group" aria-label={m.code_browser_header_code_browser_view()}>
<button
type="button"
className={view === "files" ? "active" : ""}
aria-pressed={view === "files"}
onClick={() => onViewChange("files")}
>
Files
{m.code_browser_header_files()}
</button>
<button
type="button"
@@ -43,7 +44,7 @@ export function CodeBrowserHeader({
aria-pressed={view === "changes"}
onClick={() => onViewChange("changes")}
>
Changes
{m.code_browser_header_changes()}
</button>
</div>
)}
@@ -66,7 +67,7 @@ export function CodeBrowserHeader({
</a>
)}
<span style={{ flex: 1 }} />
<button className={ICON_BUTTON_CLASS_NAME} title="Refresh" aria-label="Refresh" onClick={onRefresh}>
<button className={ICON_BUTTON_CLASS_NAME} title={m.code_browser_header_refresh()} aria-label={m.code_browser_header_refresh()} onClick={onRefresh}>
{refreshing ? <span className={SPINNER_CLASS_NAME} /> : <RotateCw size={13} />}
</button>
</div>
+5 -5
View File
@@ -102,12 +102,12 @@ export function CodeEditor({
// Both layers must reserve the scrollbar, or the textarea is narrower than the
// overlay on classic-scrollbar platforms and the two wrap at different columns.
const layerClassName = `absolute inset-0 m-0 py-3.5 pr-4 ${CODE_TEXT_CLASS_NAME} ${CODE_WRAP_CLASS_NAME} [scrollbar-gutter:stable]`;
const layerClassName = `absolute inset-0 m-0 py-3.5 pe-4 ${CODE_TEXT_CLASS_NAME} ${CODE_WRAP_CLASS_NAME} [scrollbar-gutter:stable]`;
return (
<div className={`file-view-editwrap relative h-full min-h-0 ${CODE_TEXT_CLASS_NAME}`}>
<div
className="absolute left-0 top-0 bottom-0 border-r border-r-border-variant pointer-events-none"
className="absolute start-0 top-0 bottom-0 border-e border-e-border-variant pointer-events-none"
style={{ width: `${ruleCh}ch` }}
aria-hidden="true"
/>
@@ -122,10 +122,10 @@ export function CodeEditor({
/* The scroll target for `file:line` — measured, so it's on the row. */
data-line={i + 1}
className="relative"
style={{ paddingLeft: `${codeCh}ch` }}
style={{ paddingInlineStart: `${codeCh}ch` }}
>
<span
className={`${CODE_GUTTER_CLASS_NAME} absolute left-0 pr-[1ch]`}
className={`${CODE_GUTTER_CLASS_NAME} absolute start-0 pe-[1ch]`}
style={{ width: `${ruleCh}ch` }}
>
{i + 1}
@@ -138,7 +138,7 @@ export function CodeEditor({
<textarea
ref={taRef}
className={`file-view-editarea ${layerClassName} overflow-y-auto overflow-x-hidden resize-none border-0 bg-transparent text-transparent caret-[var(--text)] outline-none`}
style={{ paddingLeft: `${codeCh}ch` }}
style={{ paddingInlineStart: `${codeCh}ch` }}
value={value}
onChange={(e) => onChange(e.target.value)}
onScroll={syncScroll}
+7 -5
View File
@@ -1,3 +1,5 @@
import { m } from "../paraglide/messages.js";
import { ltr } from "../i18n";
// Files and committed changes for one experiment branch. The opening
// experiment fixes the Git source; users only switch between Files/Changes.
@@ -143,7 +145,7 @@ export function CodeTab({
? githubBranchUrl(project.githubOwner, project.githubRepo, branch)
: undefined
}
githubTitle={`Open ${branch} on GitHub`}
githubTitle={m.a11y_open_branch_github({ branch: ltr(branch) })}
refreshing={refreshing}
onRefresh={() =>
view === "files" ? load() : setChangesRefreshKey((current) => current + 1)
@@ -158,15 +160,15 @@ export function CodeTab({
/>
) : (
<>
{data?.truncated && <div className={CODE_TAB_NOTE_CLASS_NAME}>listing truncated</div>}
{error && tree && <div className={CODE_TAB_NOTE_CLASS_NAME}>Refresh failed: {error}</div>}
{data?.truncated && <div className={CODE_TAB_NOTE_CLASS_NAME}>{m.code_tab_listing_truncated()}</div>}
{error && tree && <div className={CODE_TAB_NOTE_CLASS_NAME}>{m.code_tab_refresh_failed()} {ltr(error)}</div>}
<div className={CODE_TAB_BODY_CLASS_NAME}>
{!tree ? (
<div className={CODE_TAB_NOTE_CLASS_NAME}>
{error ? `Failed to load: ${error}` : "Loading…"}
{error ? m.common_failed_to_load({ error: ltr(error) }) : m.common_loading()}
</div>
) : tree.dirs.size === 0 && tree.files.length === 0 ? (
<div className={CODE_TAB_NOTE_CLASS_NAME}>No files.</div>
<div className={CODE_TAB_NOTE_CLASS_NAME}>{m.code_tab_no_files()}</div>
) : (
<div className="file-tree py-1.5 px-0 text-md">
<TreeLevel
+3 -3
View File
@@ -75,12 +75,12 @@ export function CodeView({
code never picks it up. */}
<span
data-line={i + 1}
className={`${CODE_GUTTER_CLASS_NAME} before:content-[attr(data-line)] shrink-0 pr-[1ch]`}
className={`${CODE_GUTTER_CLASS_NAME} before:content-[attr(data-line)] shrink-0 pe-[1ch]`}
style={{ width: `${ruleCh}ch` }}
aria-hidden="true"
/>
<code
className={`file-view-code flex-1 min-w-0 pl-[2ch] pr-4 ${CODE_TEXT_CLASS_NAME} ${CODE_WRAP_CLASS_NAME}`}
className={`file-view-code flex-1 min-w-0 ps-[2ch] pe-4 ${CODE_TEXT_CLASS_NAME} ${CODE_WRAP_CLASS_NAME}`}
>
{/* An empty <code> serializes to nothing, dropping blank lines
from a copied selection. */}
@@ -95,7 +95,7 @@ export function CodeView({
<div className={`file-view-codewrap relative py-3.5 ${CODE_TEXT_CLASS_NAME}`}>
{lines.length > 0 && (
<div
className="absolute left-0 top-0 bottom-0 border-r border-r-border-variant pointer-events-none"
className="absolute start-0 top-0 bottom-0 border-e border-e-border-variant pointer-events-none"
style={{ width: `${ruleCh}ch` }}
aria-hidden="true"
/>
+11 -5
View File
@@ -1,3 +1,6 @@
import { m } from "../paraglide/messages.js";
import { ltr } from "../i18n";
import { getLocale } from "../paraglide/runtime.js";
import { fmtTokens, type ContextUsage } from "../api";
import {
COMPOSER_CONTROL_CLASS_NAME,
@@ -33,13 +36,16 @@ function VisibleContextMeter({ usage }: { usage: ContextUsage }) {
? Math.min(100, Math.round((usedTokens / contextWindow) * 100))
: null;
const fill = pct === null ? "var(--accent)" : tone(pct);
const percent = pct === null
? ""
: new Intl.NumberFormat(getLocale(), { style: "percent" }).format(pct / 100);
return (
<div className="option-picker relative inline-flex shrink-0" ref={ref}>
<button
type="button"
className={`${pct === null ? `${COMPOSER_CONTROL_CLASS_NAME} px-1` : COMPOSER_ICON_CONTROL_CLASS_NAME} composer-bare context-ring text-md text-text`}
title="Context window used"
title={m.context_meter_context_window_used()}
onClick={() => setOpen((v) => !v)}
>
{pct === null ? (
@@ -69,13 +75,13 @@ function VisibleContextMeter({ usage }: { usage: ContextUsage }) {
)}
</button>
{open && (
<div className="option-menu absolute bottom-[calc(100%_+_8px)] left-0 max-h-95 flex flex-col bg-background border border-border rounded-lg shadow-[0_12px_32px_rgba(0,_0,_0,_0.18)] z-50 overflow-hidden min-w-47.5 [&.align-right]:left-auto [&.align-right]:right-0 [&.drop-down]:bottom-auto [&.drop-down]:top-[calc(100%_+_4px)] [&.session-menu]:left-auto [&.session-menu]:right-1.5 [&.session-menu]:top-[calc(100%_-_2px)] [&.session-menu]:min-w-35 align-right context-meter-menu w-70 pt-2.5 px-3 pb-3 [&_.progress]:mt-2 [&_.progress]:mx-0 [&_.progress]:mb-0 [&_.progress-track]:h-[5px] [&_.progress-track]:border-0 [&_.progress-track]:bg-border">
<div className="option-menu absolute bottom-[calc(100%_+_8px)] start-0 max-h-95 flex flex-col bg-background border border-border rounded-lg shadow-[0_12px_32px_rgba(0,_0,_0,_0.18)] z-50 overflow-hidden min-w-47.5 [&.align-right]:start-auto [&.align-right]:end-0 [&.drop-down]:bottom-auto [&.drop-down]:top-[calc(100%_+_4px)] [&.session-menu]:start-auto [&.session-menu]:end-1.5 [&.session-menu]:top-[calc(100%_-_2px)] [&.session-menu]:min-w-35 align-right context-meter-menu w-70 pt-2.5 px-3 pb-3 [&_.progress]:mt-2 [&_.progress]:mx-0 [&_.progress]:mb-0 [&_.progress-track]:h-[5px] [&_.progress-track]:border-0 [&_.progress-track]:bg-border">
<div className="context-meter-head flex justify-between items-baseline gap-3 text-sm text-muted">
<span>Context window</span>
<span>{m.context_meter_context_window()}</span>
<span className="context-meter-value text-text tabular-nums">
{pct === null
? `${fmtTokens(usedTokens)} tokens`
: `${fmtTokens(usedTokens)} / ${fmtTokens(contextWindow!)} (${pct}%)`}
? m.context_meter_tokens({ value: ltr(fmtTokens(usedTokens)) })
: m.context_meter_usage({ used: ltr(fmtTokens(usedTokens)), total: ltr(fmtTokens(contextWindow!)), percent: ltr(percent) })}
</span>
</div>
{pct !== null && (
+11 -10
View File
@@ -1,3 +1,4 @@
import { m } from "../paraglide/messages.js";
import { ChevronDown, CircleStop } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import {
@@ -17,7 +18,7 @@ import type { TabOpenIntent } from "../tabPreview";
export type ExperimentView = "overview" | "terminal";
/** An experiment's detail view, rendered as right-pane tab content. Mount it
/** An experiment's detail view, rendered as end-pane tab content. Mount it
* keyed by `${experiment.id}:${view}` so per-view state resets on switch. */
export function DetailDrawer({
experiment,
@@ -151,34 +152,34 @@ function TerminalView({
{live && (
<button className={`${SMALL_BUTTON_CLASS_NAME} ghost`} disabled={cancelling} onClick={() => void stop()}>
<CircleStop size={13} />
{cancelling ? "Cancelling…" : "Stop"}
{cancelling ? m.common_cancelling() : m.common_stop()}
</button>
)}
{expRuns.length > 0 && selectedRun && (
<div className="run-history relative shrink-0" ref={historyRef}>
<button
className="run-picker inline-flex items-center gap-2 pt-1 pr-1.5 pb-1 pl-2.5 border border-border rounded-md bg-background text-text [&:hover]:bg-surface [&_.run-label]:text-sm [&_.run-label]:font-semibold"
title="Switch run"
className="run-picker inline-flex items-center gap-2 pt-1 pe-1.5 pb-1 ps-2.5 border border-border rounded-md bg-background text-text [&:hover]:bg-surface [&_.run-label]:text-sm [&_.run-label]:font-semibold"
title={m.detail_drawer_switch_run()}
onClick={() => setHistoryOpen((v) => !v)}
>
<span className="run-label">Run {runNumber(selectedRun.id)}</span>
<span className="run-label">{m.detail_drawer_run()} {runNumber(selectedRun.id)}</span>
<StatusBadge
status={cancelling ? "cancelling" : runDisplayStatus(selectedRun)}
/>
<ChevronDown size={14} className="run-picker-chev text-muted shrink-0" />
</button>
{historyOpen && (
<div className="history-menu absolute top-[calc(100%_+_6px)] right-0 min-w-57.5 max-h-80 overflow-y-auto bg-background border border-border rounded-lg shadow-[0_12px_32px_rgba(0,_0,_0,_0.18)] p-[5px] z-50">
<div className="history-menu absolute top-[calc(100%_+_6px)] end-0 min-w-57.5 max-h-80 overflow-y-auto bg-background border border-border rounded-lg shadow-[0_12px_32px_rgba(0,_0,_0,_0.18)] p-[5px] z-50">
{expRuns.map((r) => (
<button
key={r.id}
className={`history-item flex items-center gap-2 w-full text-left py-1.5 px-2 text-sm rounded-sm [&:hover]:bg-surface [&.active]:bg-surface [&_.run-label]:font-semibold [&_.when]:ml-auto [&_.when]:text-xs [&_.when]:text-muted ${r.id === selectedRun?.id ? "active" : ""}`}
className={`history-item flex items-center gap-2 w-full text-start py-1.5 px-2 text-sm rounded-sm [&:hover]:bg-surface [&.active]:bg-surface [&_.run-label]:font-semibold [&_.when]:ms-auto [&_.when]:text-xs [&_.when]:text-muted ${r.id === selectedRun?.id ? "active" : ""}`}
onClick={() => {
onSelectRun(r.id);
setHistoryOpen(false);
}}
>
<span className="run-label">Run {runNumber(r.id)}</span>
<span className="run-label">{m.detail_drawer_run()} {runNumber(r.id)}</span>
<StatusBadge status={runDisplayStatus(r)} />
<span className="when">{timeAgo(r.createdAt)}</span>
</button>
@@ -189,13 +190,13 @@ function TerminalView({
)}
</div>
<div className="term-fill flex-1 min-h-0 bg-[var(--term-bg)] pt-1 pr-0 pb-1 pl-1.5">
<div className="term-fill flex-1 min-h-0 bg-[var(--term-bg)] pt-1 pe-0 pb-1 ps-1.5">
{selectedRun ? (
// Key by run id so switching runs in the history dropdown remounts
// the terminal with the selected run's output.
<LogTerminal key={selectedRun.id} runId={selectedRun.id} />
) : (
<div className="term-empty h-full flex items-center justify-center p-6 text-center text-md text-muted">No runs yet — ask the agent to launch one.</div>
<div className="term-empty h-full flex items-center justify-center p-6 text-center text-md text-muted">{m.detail_drawer_no_runs_yet_ask_the_agent_to_launch()}</div>
)}
</div>
</div>
+22 -14
View File
@@ -1,3 +1,6 @@
import { m } from "../paraglide/messages.js";
import { getLocale } from "../paraglide/runtime.js";
import { ltr } from "../i18n";
// Floating detail card shown while hovering an experiment node in the tree.
// Rendered through a portal at a fixed viewport position (never inside the
// ReactFlow node: the canvas transform would scale it with zoom, and growing
@@ -13,6 +16,7 @@ import { parseDiff, type FileData } from "react-diff-view";
import {
backendKind,
fmtDuration,
fmtNumber,
getRunDiff,
runDisplayStatus,
timeAgo,
@@ -110,7 +114,7 @@ function fmtCreated(ms: number): string {
d.getFullYear() === new Date().getFullYear()
? { month: "short", day: "numeric" }
: { month: "short", day: "numeric", year: "numeric" };
return d.toLocaleDateString(undefined, opts);
return d.toLocaleDateString(getLocale(), opts);
}
export function ExpHoverCard({
@@ -265,7 +269,7 @@ export function ExpHoverCard({
{...tabOpenGestureHandlers<HTMLButtonElement>(onOpenLogs)}
>
<Terminal size={13} />
Logs
{m.exp_hover_card_logs()}
</button>
)}
<button
@@ -273,7 +277,7 @@ export function ExpHoverCard({
{...tabOpenGestureHandlers<HTMLButtonElement>(onOpenCode)}
>
<FolderTree size={13} />
Code
{m.exp_hover_card_code()}
</button>
</div>
{body && (
@@ -283,17 +287,19 @@ export function ExpHoverCard({
)}
{body && (clamped || expanded) && (
<button type="button" className="hc-toggle" onClick={() => setExpanded((v) => !v)}>
{expanded ? "Show less" : "Show more"}
{expanded ? m.common_show_less() : m.common_show_more()}
</button>
)}
{failureNote && <div className="hc-failure">{failureNote}</div>}
<div className="hc-stats">
<span>
{runs.length === 1 ? "1 run" : `${runs.length} runs`}
{counts.done > 0 && ` · ${counts.done} done`}
{counts.failed > 0 && ` · ${counts.failed} failed`}
{counts.cancelled > 0 && ` · ${counts.cancelled} cancelled`}
{counts.live > 0 && ` · ${counts.live} live`}
{new Intl.ListFormat(getLocale(), { style: "short" }).format([
runs.length === 1 ? m.hover_one_run() : m.hover_run_count({ count: fmtNumber(runs.length) }),
...(counts.done > 0 ? [m.hover_done_count({ count: fmtNumber(counts.done) })] : []),
...(counts.failed > 0 ? [m.hover_failed_count({ count: fmtNumber(counts.failed) })] : []),
...(counts.cancelled > 0 ? [m.hover_cancelled_count({ count: fmtNumber(counts.cancelled) })] : []),
...(counts.live > 0 ? [m.hover_live_count({ count: fmtNumber(counts.live) })] : []),
])}
</span>
{latestRun && backendKind(latestRun.backend) && <BackendBadge backend={latestRun.backend} />}
{duration && <span>{duration}</span>}
@@ -307,14 +313,16 @@ export function ExpHoverCard({
</span>
{parentSlug && (
<span>
from <span className="hc-mono">{parentSlug}</span>
{m.exp_hover_card_from()} <span className="hc-mono">{parentSlug}</span>
</span>
)}
</div>
{diffStat && diffStat.fileCount > 0 && (
<div
className="hc-git-row"
title={`Committed changes vs ${parentSlug ?? "parent"}${diffStat.truncated ? " (diff truncated — counts are lower bounds)" : ""}`}
title={diffStat.truncated
? m.a11y_committed_changes_truncated({ parent: ltr(parentSlug ?? "parent") })
: m.a11y_committed_changes({ parent: ltr(parentSlug ?? "parent") })}
>
<span>
{diffStat.truncated && "≥ "}
@@ -322,15 +330,15 @@ export function ExpHoverCard({
<span className="diff-stat-del text-accent-red">−{diffStat.deletions}</span>
{" · "}
{diffStat.fileCount === 1 && !diffStat.truncated
? "1 file"
: `${diffStat.fileCount}${diffStat.truncated ? "+" : ""} files`}
? m.hover_one_file()
: diffStat.truncated ? m.hover_files_at_least({ count: fmtNumber(diffStat.fileCount) }) : m.hover_file_count({ count: fmtNumber(diffStat.fileCount) })}
</span>
</div>
)}
</div>
<div className="hc-foot">
<span className="hc-mono">$ {exp.runCommand}</span>
<span>created {fmtCreated(exp.createdAt)}</span>
<span>{m.exp_hover_card_created()} {fmtCreated(exp.createdAt)}</span>
</div>
</div>,
document.body,
+16 -14
View File
@@ -1,3 +1,5 @@
import { m } from "../paraglide/messages.js";
import { getLocale } from "../paraglide/runtime.js";
import {
CalendarDays,
Clock3,
@@ -41,7 +43,7 @@ const EXPERIMENT_OVERVIEW_COMMAND_CLASS_NAME = [
].join(" ");
function fmtDate(ms: number): string {
return new Date(ms).toLocaleString(undefined, {
return new Date(ms).toLocaleString(getLocale(), {
month: "short",
day: "numeric",
year: "numeric",
@@ -102,7 +104,7 @@ export function ExperimentOverview({
)}
>
<Terminal size={15} />
Logs
{m.experiment_overview_logs()}
</button>
)}
<button
@@ -110,34 +112,34 @@ export function ExperimentOverview({
{...tabOpenGestureHandlers<HTMLButtonElement>(onOpenCode)}
>
<FolderTree size={15} />
Code
{m.experiment_overview_code()}
</button>
</div>
{experiment.description && (
<section className="experiment-overview-section mt-5.5 pt-4.5 border-t border-t-border-variant [&_h2]:mt-0 [&_h2]:mx-0 [&_h2]:mb-3.5 [&_h2]:text-text [&_h2]:text-md [&_h2]:font-semibold overview-description [&_.md]:text-text [&_.md]:leading-[1.65]">
<h2>Description</h2>
<h2>{m.experiment_overview_description()}</h2>
<Md text={experiment.description} />
</section>
)}
<section className={EXPERIMENT_OVERVIEW_SECTION_CLASS_NAME}>
<h2>{latestRun ? "Latest run" : "Runs"}</h2>
<h2>{latestRun ? m.experiment_latest_run() : m.experiment_runs()}</h2>
{latestRun && (
<>
<div className="experiment-overview-meta flex items-center flex-wrap gap-y-2.5 gap-x-4.5 text-text text-sm [&_svg]:text-muted [&_.backend-badge]:text-text [&_.status-badge]:text-text [&_>_span]:inline-flex [&_>_span]:items-center [&_>_span]:gap-[5px] [&_code]:text-text [&_code]:text-xs">
<StatusBadge status={runDisplayStatus(latestRun)} />
<BackendBadge backend={latestRun.backend} />
<span title="Started">
<span title={m.experiment_overview_started()}>
<CalendarDays size={13} />
{fmtDate(latestRun.createdAt)}
</span>
<span title="Duration">
<span title={m.experiment_overview_duration()}>
<Clock3 size={13} />
{runDuration(latestRun, now)}
</span>
{latestRun.commitSha && (
<span title="Commit">
<span title={m.experiment_overview_commit()}>
<GitCommitHorizontal size={14} />
<code>{latestRun.commitSha.slice(0, 7)}</code>
</span>
@@ -145,7 +147,7 @@ export function ExperimentOverview({
{latestRun.exitCode !== null &&
latestRun.exitCode !== undefined &&
latestRun.exitCode !== 0 && (
<span>exit {latestRun.exitCode}</span>
<span>{m.experiment_overview_exit()} {latestRun.exitCode}</span>
)}
</div>
{latestRun.command && (
@@ -172,11 +174,11 @@ export function ExperimentOverview({
/>
{parentExperiment && (
<span>
from <code>{parentExperiment.slug}</code>
{m.experiment_overview_from()} <code>{parentExperiment.slug}</code>
</span>
)}
<span title={fmtDate(experiment.createdAt)}>
created {timeAgo(experiment.createdAt)}
{m.experiment_overview_created()} {timeAgo(experiment.createdAt)}
</span>
</div>
{experiment.runCommand !== latestRun?.command && (
@@ -186,8 +188,8 @@ export function ExperimentOverview({
{runs.length > 0 && (
<section className={EXPERIMENT_OVERVIEW_SECTION_CLASS_NAME}>
<h2>Run history</h2>
<div className="experiment-run-history border-t border-t-border-variant [&_button]:w-full [&_button]:grid [&_button]:grid-cols-[minmax(72px,_0.7fr)_minmax(100px,_1fr)_minmax(70px,_0.7fr)_60px_16px] [&_button]:items-center [&_button]:gap-3.5 [&_button]:py-[11px] [&_button]:px-0.5 [&_button]:border-b [&_button]:border-b-border-variant [&_button]:text-text [&_button]:text-left [&_button]:text-sm [&_button:hover]:bg-surface [@media((max-width:_720px))]:[&_button]:grid-cols-[65px_1fr_60px_16px] [@media((max-width:_720px))]:[&_button_>_:nth-child(3)]:hidden">
<h2>{m.experiment_overview_run_history()}</h2>
<div className="experiment-run-history border-t border-t-border-variant [&_button]:w-full [&_button]:grid [&_button]:grid-cols-[minmax(72px,_0.7fr)_minmax(100px,_1fr)_minmax(70px,_0.7fr)_60px_16px] [&_button]:items-center [&_button]:gap-3.5 [&_button]:py-[11px] [&_button]:px-0.5 [&_button]:border-b [&_button]:border-b-border-variant [&_button]:text-text [&_button]:text-start [&_button]:text-sm [&_button:hover]:bg-surface [@media((max-width:_720px))]:[&_button]:grid-cols-[65px_1fr_60px_16px] [@media((max-width:_720px))]:[&_button_>_:nth-child(3)]:hidden">
{runs.map((run, index) => (
<button
key={run.id}
@@ -195,7 +197,7 @@ export function ExperimentOverview({
onOpenLogs(run.id, intent),
)}
>
<span className="experiment-run-number font-mono text-xs font-semibold">Run {runs.length - index}</span>
<span className="experiment-run-number font-mono text-xs font-semibold">{m.experiment_overview_run()} {runs.length - index}</span>
<StatusBadge status={runDisplayStatus(run)} />
<span>{timeAgo(run.createdAt)}</span>
<span>{runDuration(run, now)}</span>
+18 -18
View File
@@ -1,6 +1,8 @@
import { m } from "../paraglide/messages.js";
import { ltr } from "../i18n";
import { CircleStop, FolderTree, GitBranch, Terminal } from "lucide-react";
import { useState } from "react";
import { runDisplayStatus, timeAgo, type Experiment, type Run } from "../api";
import { fmtNumber, runDisplayStatus, timeAgo, type Experiment, type Run } from "../api";
import { StatusBadge } from "./StatusBadge";
import { tabOpenGestureHandlers, type TabOpenIntent } from "../tabPreview";
@@ -15,7 +17,7 @@ const EXPERIMENT_TABLE_ACTION_CLASS_NAME = [
"[&.danger]:bg-[color-mix(in_oklab,_var(--accent-red)_6%,_var(--base))]",
"[&.danger]:text-accent-red [&.danger:hover:not(:disabled)]:border-accent-red",
"[&.danger:hover:not(:disabled)]:bg-[color-mix(in_oklab,_var(--accent-red)_10%,_var(--base))]",
"[@container((max-width:_560px))]:[&.danger]:ml-auto",
"[@container((max-width:_560px))]:[&.danger]:ms-auto",
].join(" ");
export function ExperimentsTable({
@@ -56,7 +58,7 @@ export function ExperimentsTable({
if (sortedExperiments.length === 0) {
return (
<div className="empty-state absolute inset-0 flex flex-col items-center justify-center gap-2.5 p-6 text-center text-subtext [&_p]:max-w-[46ch] [&_p]:m-0 [&_p]:leading-normal [&_p]:text-balance [&_p.empty-state-title]:text-2xl [&_p.empty-state-title]:font-normal [&_p.empty-state-title]:text-text [&_p.empty-state-hint]:text-lg [&_p.empty-state-hint]:text-subtext experiments-empty-state [&_p]:text-2xl">
<p>{emptyHint ?? "No experiments yet."}</p>
<p>{emptyHint ?? m.experiments_none_yet()}</p>
</div>
);
}
@@ -80,10 +82,10 @@ export function ExperimentsTable({
<div className="experiments-table-wrap absolute inset-0 overflow-auto bg-background @container">
{cancelError && (
<div className="experiments-table-error py-2 px-3 text-accent-red text-sm border-b border-b-border" role="alert">
Stop failed: {cancelError}
{m.experiments_table_stop_failed()} {cancelError}
</div>
)}
<div className="experiments-table w-full text-md bg-background" role="list" aria-label="Experiments">
<div className="experiments-table w-full text-md bg-background" role="list" aria-label={m.experiments_table_experiments()}>
{sortedExperiments.map((experiment) => {
const experimentRuns = runsByExperiment.get(experiment.id) ?? [];
const latestRun = experimentRuns[0] ?? null;
@@ -118,7 +120,7 @@ export function ExperimentsTable({
<div className="experiment-table-name [grid-area:name] self-start min-w-0">
<button
type="button"
className="experiment-table-title block w-full overflow-hidden text-text font-semibold text-left text-ellipsis whitespace-nowrap"
className="experiment-table-title block w-full overflow-hidden text-text font-semibold text-start text-ellipsis whitespace-nowrap"
{...tabOpenGestureHandlers<HTMLButtonElement>((intent) =>
onOpen(experiment, intent),
{ stopPropagation: true })}
@@ -135,18 +137,16 @@ export function ExperimentsTable({
<StatusBadge status={status} />
</div>
<div className="experiment-run-summary flex items-center min-w-0 gap-2 text-subtext text-xs font-medium">
<span>
{experimentRuns.length} {experimentRuns.length === 1 ? "run" : "runs"}
</span>
<span>{experimentRuns.length === 1 ? m.experiments_one_run() : m.experiments_run_count({ count: fmtNumber(experimentRuns.length) })}</span>
</div>
<div className="experiment-table-latest flex items-center gap-1.5 min-w-0 text-subtext text-xs font-medium whitespace-nowrap">
<span>{latestRun ? timeAgo(latestRun.createdAt) : "Not run yet"}</span>
<span>{latestRun ? timeAgo(latestRun.createdAt) : m.experiments_not_run_yet()}</span>
</div>
</div>
<div
className="experiment-table-actions [grid-area:actions] flex flex-wrap items-center justify-start gap-2 mt-3"
role="group"
aria-label={`Actions for ${experiment.title || experiment.slug}`}
aria-label={m.a11y_actions_for({ name: experiment.title || experiment.slug })}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onAuxClick={(event) => event.stopPropagation()}
@@ -154,33 +154,33 @@ export function ExperimentsTable({
<button
className={EXPERIMENT_TABLE_ACTION_CLASS_NAME}
disabled={!logsRun}
title={logsRun ? "Open logs" : "No runs yet"}
title={logsRun ? m.experiments_open_logs() : m.experiments_no_runs_yet()}
{...tabOpenGestureHandlers<HTMLButtonElement>((intent) => {
if (logsRun) onOpenLogs(experiment.id, logsRun.id, intent);
}, { stopPropagation: true })}
>
<Terminal size={15} />
Logs
{m.experiments_table_logs()}
</button>
<button
className={EXPERIMENT_TABLE_ACTION_CLASS_NAME}
title={`Browse code on ${experiment.branchName}`}
title={m.a11y_browse_code_on({ branch: ltr(experiment.branchName) })}
{...tabOpenGestureHandlers<HTMLButtonElement>((intent) =>
onOpenCode(experiment.id, intent),
{ stopPropagation: true })}
>
<FolderTree size={15} />
Code
{m.experiments_table_code()}
</button>
{liveRun && (
<button
className="experiment-table-action inline-flex items-center gap-1.5 py-1.5 px-2.5 border border-border rounded-md bg-background text-text text-sm font-medium leading-none [&:hover:not(:disabled)]:bg-surface [&:hover:not(:disabled)]:border-border-strong [&:disabled]:text-muted [&:disabled]:cursor-default [&:disabled]:opacity-50 [&.danger]:border-[color-mix(in_oklab,_var(--accent-red)_42%,_var(--border))] [&.danger]:bg-[color-mix(in_oklab,_var(--accent-red)_6%,_var(--base))] [&.danger]:text-accent-red [&.danger:hover:not(:disabled)]:border-accent-red [&.danger:hover:not(:disabled)]:bg-[color-mix(in_oklab,_var(--accent-red)_10%,_var(--base))] [@container((max-width:_560px))]:[&.danger]:ml-auto danger"
className="experiment-table-action inline-flex items-center gap-1.5 py-1.5 px-2.5 border border-border rounded-md bg-background text-text text-sm font-medium leading-none [&:hover:not(:disabled)]:bg-surface [&:hover:not(:disabled)]:border-border-strong [&:disabled]:text-muted [&:disabled]:cursor-default [&:disabled]:opacity-50 [&.danger]:border-[color-mix(in_oklab,_var(--accent-red)_42%,_var(--border))] [&.danger]:bg-[color-mix(in_oklab,_var(--accent-red)_6%,_var(--base))] [&.danger]:text-accent-red [&.danger:hover:not(:disabled)]:border-accent-red [&.danger:hover:not(:disabled)]:bg-[color-mix(in_oklab,_var(--accent-red)_10%,_var(--base))] [@container((max-width:_560px))]:[&.danger]:ms-auto danger"
disabled={cancelling}
title={cancelling ? "Stop requested" : "Stop run"}
title={cancelling ? m.experiments_stop_requested() : m.experiments_stop_run()}
onClick={() => void requestCancel(liveRun.id)}
>
<CircleStop size={15} />
{cancelling ? "Stopping…" : "Stop"}
{cancelling ? m.common_stopping() : m.common_stop()}
</button>
)}
</div>
+54 -57
View File
@@ -1,3 +1,5 @@
import { m } from "../paraglide/messages.js";
import { ltr } from "../i18n";
// Mirror of openresearch.sh's AgentFileView: one file from the project —
// a branch's committed copy when the tab carries a ref, else the chat
// session's worktree, else the hub clone, else the project's artifacts, with
@@ -101,12 +103,12 @@ function CopyableCommand({ command }: { command: string }) {
className={ICON_BUTTON_CLASS_NAME}
data-tip={
state === "copied"
? "Copied"
? m.common_copied()
: state === "select"
? "Selected — press ⌘C"
: "Copy command"
? m.file_viewer_selected_copy_shortcut()
: m.file_viewer_copy_command()
}
aria-label="Copy install command"
aria-label={m.file_viewer_copy_install_command()}
onClick={() => void copy()}
>
{state === "copied" ? <Check size={13} /> : <Copy size={13} />}
@@ -309,14 +311,14 @@ export function FileViewer({
if (overleafConflicts > 0) setShowOverleaf(true);
}, [overleafConflicts]);
const overleafTip = overleaf.error
? "Overleaf sync failed"
? m.overleaf_sync_failed()
: overleafConflicts > 0
? "Changed here and on Overleaf — choose which copy to keep"
? m.overleaf_conflict_tip()
: overleaf.blocked
? "Save this file to sync it with Overleaf"
? m.overleaf_save_to_sync()
: overleaf.link
? "In step with Overleaf"
: "Send this paper to Overleaf";
? m.overleaf_in_sync()
: m.overleaf_send_paper();
const overleafColor =
overleaf.error || overleafConflicts > 0
? "text-accent-red"
@@ -459,16 +461,14 @@ export function FileViewer({
}, [data]);
const notFoundCopy = (d: LoadedFile) => {
if (d.source === "absolute") return "File not found on disk.";
if (d.source === "absolute") return m.file_viewer_not_found_on_disk();
if (isArtifacts)
return `File not found in the project's artifacts or the ${
sessionId ? "session's worktree" : "project clone"
}.`;
if (gitRef) return `File not found on branch ${gitRef}.`;
return m.file_viewer_not_found_artifacts_or_root({ root: sessionId ? m.file_viewer_session_worktree() : m.file_viewer_project_clone() });
if (gitRef) return m.file_viewer_not_found_on_branch({ branch: ltr(gitRef) });
if (sessionId && d.source === "checkout" && d.file.root === "clone")
return "This session's worktree isn't available, and the file isn't in the project clone or its artifacts.";
return m.file_viewer_worktree_unavailable_not_found();
const root = d.source === "checkout" ? d.file.root : d.checkoutRoot;
return `File not found in the ${root === "worktree" ? "session's worktree" : "project clone"} or the project's artifacts.`;
return m.file_viewer_not_found_root_or_artifacts({ root: root === "worktree" ? m.file_viewer_session_worktree() : m.file_viewer_project_clone() });
};
return (
@@ -479,7 +479,7 @@ export function FileViewer({
{filePath}
</code>
{branchLabel && (
<span className="file-view-branch inline-flex items-center gap-1 min-w-0 font-mono text-xs text-muted border border-border-variant rounded-sm py-px px-1.5 max-w-65 overflow-hidden text-ellipsis whitespace-nowrap shrink-0 [&_svg]:flex-none" title={`Branch: ${branchLabel}`}>
<span className="file-view-branch inline-flex items-center gap-1 min-w-0 font-mono text-xs text-muted border border-border-variant rounded-sm py-px px-1.5 max-w-65 overflow-hidden text-ellipsis whitespace-nowrap shrink-0 [&_svg]:flex-none" title={m.a11y_branch({ branch: ltr(branchLabel) })}>
<GitBranch size={11} />
{branchLabel}
</span>
@@ -487,16 +487,16 @@ export function FileViewer({
{showingEditor && (saving || dirty || saveError) && (
<span
className={`file-view-save-status inline-flex items-center gap-1 text-xs shrink-0 ${saveError ? "text-accent-red" : "text-muted"}`}
title={saveError ?? (saving ? "Saving…" : "Unsaved — ⌘S or click away to save")}
title={saveError ?? (saving ? m.common_saving() : m.file_viewer_unsaved_tip())}
>
{saving ? (
<>
<span className={SPINNER_CLASS_NAME} /> Saving…
<span className={SPINNER_CLASS_NAME} /> {m.file_viewer_saving()}
</>
) : saveError ? (
"Save failed"
m.file_viewer_save_failed()
) : (
"Unsaved"
m.file_viewer_unsaved()
)}
</span>
)}
@@ -505,13 +505,13 @@ export function FileViewer({
className={`${ICON_BUTTON_CLASS_NAME} ${!latex.showPdf ? "active" : ""}`}
data-tip={
latex.stale && latex.showPdf
? "Compiled PDF is out of date"
? m.file_viewer_pdf_out_of_date()
: latex.showPdf
? "View source"
: "Show compiled PDF"
? m.common_view_source()
: m.file_viewer_show_compiled_pdf()
}
data-tip-align="end"
aria-label={latex.showPdf ? "View source" : "Show compiled PDF"}
aria-label={latex.showPdf ? m.common_view_source() : m.file_viewer_show_compiled_pdf()}
onClick={() => latex.setShowPdf(!latex.showPdf)}
>
{latex.showPdf ? (
@@ -526,11 +526,11 @@ export function FileViewer({
className={ICON_BUTTON_CLASS_NAME}
data-tip={
latex.stale
? `Download ${compiledPdfName} (out of date — recompile first)`
: `Download ${compiledPdfName}`
? m.file_viewer_download_stale_pdf({ name: ltr(compiledPdfName) })
: m.a11y_download_file({ name: ltr(compiledPdfName) })
}
data-tip-align="end"
aria-label={`Download ${compiledPdfName}`}
aria-label={m.a11y_download_file({ name: ltr(compiledPdfName) })}
href={compiledPdfUrl}
download={compiledPdfName}
>
@@ -542,7 +542,7 @@ export function FileViewer({
className={`${ICON_BUTTON_CLASS_NAME} ${showOverleaf ? "active" : ""}`}
data-tip={overleafTip}
data-tip-align="end"
aria-label={`Overleaf — ${overleafTip.toLowerCase()}`}
aria-label={m.a11y_overleaf_status({ status: overleafTip })}
aria-expanded={showOverleaf}
onClick={() => setShowOverleaf((open) => !open)}
>
@@ -556,9 +556,9 @@ export function FileViewer({
{isLatex && onDisk && (
<button
className={ICON_BUTTON_CLASS_NAME}
data-tip={latex.compiled ? "Recompile PDF" : "Compile PDF"}
data-tip={latex.compiled ? m.file_viewer_recompile_pdf() : m.file_viewer_compile_pdf()}
data-tip-align="end"
aria-label={latex.compiled ? "Recompile PDF" : "Compile PDF"}
aria-label={latex.compiled ? m.file_viewer_recompile_pdf() : m.file_viewer_compile_pdf()}
disabled={latex.compiling || !latex.engine}
onClick={() => void compileFromDisk()}
>
@@ -568,9 +568,9 @@ export function FileViewer({
{isMarkdown && (
<button
className={`${ICON_BUTTON_CLASS_NAME} ${showSource ? "active" : ""}`}
data-tip={showSource ? "Rendered view" : "View source"}
data-tip={showSource ? m.common_rendered_view() : m.common_view_source()}
data-tip-align="end"
aria-label={showSource ? "Rendered view" : "View source"}
aria-label={showSource ? m.common_rendered_view() : m.common_view_source()}
onClick={() => setShowSource((s) => !s)}
>
<Code size={13} />
@@ -579,9 +579,9 @@ export function FileViewer({
{onDisk && (
<button
className={ICON_BUTTON_CLASS_NAME}
data-tip={editorError ?? "Open in default editor"}
data-tip={editorError ?? m.file_viewer_open_in_default_editor()}
data-tip-align="end"
aria-label="Open in default editor"
aria-label={m.file_viewer_open_in_default_editor()}
disabled={openingEditor}
onClick={() => void openInEditor()}
>
@@ -590,9 +590,9 @@ export function FileViewer({
)}
<button
className={ICON_BUTTON_CLASS_NAME}
data-tip="Reload file"
data-tip={m.file_viewer_reload_file()}
data-tip-align="end"
aria-label="Reload file"
aria-label={m.file_viewer_reload_file()}
onClick={() => setNonce((n) => n + 1)}
>
{loading ? <span className={SPINNER_CLASS_NAME} /> : <RotateCw size={13} />}
@@ -602,8 +602,7 @@ export function FileViewer({
editable, and the editor's `h-full` would push it out of view. */}
{!error && viaCheckout && loaded?.source === "checkout" && (
<div className="file-view-note py-2.5 px-4 text-sm text-muted border-b border-b-border-variant shrink-0">
Not in the project&apos;s artifacts — showing the copy from the{" "}
{loaded.file.root === "worktree" ? "session's worktree" : "project clone"}.
{m.file_viewer_not_in_artifacts_showing_root({ root: loaded.file.root === "worktree" ? m.file_viewer_session_worktree() : m.file_viewer_project_clone() })}
</div>
)}
{(latex.error || latex.log) && (
@@ -616,14 +615,14 @@ export function FileViewer({
>
{latex.error ??
(latex.builtWithErrors
? "Compiled, but the engine reported errors — check the output below."
: "Compile failed")}
? m.file_viewer_compiled_with_errors()
: m.file_viewer_compile_failed())}
</span>
<button
className={ICON_BUTTON_CLASS_NAME}
data-tip="Dismiss"
data-tip={m.file_viewer_dismiss()}
data-tip-align="end"
aria-label="Dismiss compile message"
aria-label={m.file_viewer_dismiss_compile_message()}
onClick={latex.dismiss}
>
<X size={13} />
@@ -639,8 +638,7 @@ export function FileViewer({
{liveTex && overleaf.staleOnDisk && (
<div className="file-view-note shrink-0 border-b border-b-border-variant py-2.5 px-4 flex items-center flex-wrap gap-2 text-sm text-accent-amber">
<span className="flex-1 min-w-0">
Overleaf&apos;s copy of this file was pulled while you had unsaved edits, so what you
see is no longer what is on disk. Saving now sends this draft to Overleaf instead.
{m.file_viewer_overleaf_apos_s_copy_of_this_file_was()}
</span>
<button
className={BUTTON_CLASS_NAME}
@@ -649,7 +647,7 @@ export function FileViewer({
setNonce((n) => n + 1);
}}
>
Discard my edits and reload
{m.file_viewer_discard_my_edits_and_reload()}
</button>
</div>
)}
@@ -660,9 +658,9 @@ export function FileViewer({
</span>
<button
className={ICON_BUTTON_CLASS_NAME}
data-tip="Dismiss"
data-tip={m.file_viewer_dismiss()}
data-tip-align="end"
aria-label="Dismiss Overleaf message"
aria-label={m.file_viewer_dismiss_overleaf_message()}
onClick={overleaf.dismiss}
>
<X size={13} />
@@ -687,7 +685,7 @@ export function FileViewer({
)}
{showingPdf && latex.stale && (
<div className="file-view-note shrink-0 border-b border-b-border-variant py-2 px-4 text-sm text-subtext">
This PDF was compiled from an earlier version of the source — recompile to update it.
{m.file_viewer_this_pdf_was_compiled_from_an_earlier_version()}
</div>
)}
<div
@@ -704,22 +702,21 @@ export function FileViewer({
>
{!showingEditor && !error && !isArtifacts && loaded?.source === "checkout" && !loaded.file.notFound && !gitRef && sessionId && loaded.file.root === "clone" && (
<div className="file-view-note py-2.5 px-4 text-sm text-muted">
This session&apos;s worktree isn&apos;t available — showing the project clone&apos;s copy.
{m.file_viewer_this_session_apos_s_worktree_isn_apos_t()}
</div>
)}
{!showingEditor && !error && loaded?.source === "artifact" && !loaded.file.notFound && viaArtifacts && (
<div className="file-view-note py-2.5 px-4 text-sm text-muted">
Not in the {loaded.checkoutRoot === "worktree" ? "session's worktree" : "project clone"} —
showing the copy from the project&apos;s artifacts.
{m.file_viewer_artifact_fallback({ root: loaded.checkoutRoot === "worktree" ? m.file_viewer_session_worktree() : m.file_viewer_project_clone() })}
</div>
)}
{error ? (
<div className="file-view-note py-2.5 px-4 text-sm text-muted">Failed to load file: {error}</div>
<div className="file-view-note py-2.5 px-4 text-sm text-muted">{m.file_viewer_failed_to_load_file()} {ltr(error)}</div>
) : data === null ? (
<div className="file-view-note py-2.5 px-4 text-sm text-muted">Loading…</div>
<div className="file-view-note py-2.5 px-4 text-sm text-muted">{m.file_viewer_loading()}</div>
) : data.notFound ? (
<div className="file-view-note py-2.5 px-4 text-sm text-muted">
{loaded ? notFoundCopy(loaded) : "File not found."}
{loaded ? notFoundCopy(loaded) : m.file_viewer_not_found()}
</div>
) : mediaKind ? (
<MediaPreview
@@ -729,7 +726,7 @@ export function FileViewer({
/>
) : data.binary ? (
<div className="file-view-note py-2.5 px-4 text-sm text-muted">
Binary file — no inline preview. <a href={rawUrl} download={path.split("/").pop() ?? path}>Download</a>
{m.file_viewer_binary_file_no_inline_preview()} <a href={rawUrl} download={path.split("/").pop() ?? path}>{m.file_viewer_download()}</a>
</div>
) : showingPdf && pdfPaneUrl && compiledPdfName ? (
<MediaPreview
@@ -786,7 +783,7 @@ export function FileViewer({
onScrollRequestHandled={onLineScrollRequestHandled}
/>
{data.truncated && (
<div className="file-view-note py-2.5 px-4 text-sm text-muted">File truncated — showing the first 512 KB.</div>
<div className="file-view-note py-2.5 px-4 text-sm text-muted">{m.file_viewer_file_truncated_showing_the_first_512_kb()}</div>
)}
</>
)}
+20 -23
View File
@@ -1,3 +1,5 @@
import { m } from "../paraglide/messages.js";
import { ltr } from "../i18n";
// Mirror of openresearch.sh's GitDiff: per-file collapsible cards over
// react-diff-view's unified view, with refractor syntax highlighting.
@@ -15,6 +17,7 @@ import {
} from "react-diff-view";
import { refractor } from "refractor";
import { detectSyntaxLanguageFromFilePath } from "../syntaxLanguage";
import { fmtBytes, fmtNumber } from "../api";
const DIFF_CLASS_NAME = [
"openresearch-diff flex flex-col gap-4",
"[&_.openresearch-diff-file]:[--openresearch-diff-selection-background-color:color-mix(_in_oklab,_var(--surface)_76%,_var(--primary)_)]",
@@ -57,14 +60,14 @@ const DIFF_CLASS_NAME = [
"[&_.openresearch-diff-file.diff-unified_col.diff-gutter-col:nth-child(2)]:w-[1%]",
"[&_.openresearch-diff-file.diff-unified_.diff-line_>_td:first-child]:hidden",
"[&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:sticky",
"[&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:left-0",
"[&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:start-0",
"[&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:z-1",
"[&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:w-[1%]",
"[&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:pt-0 [&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:pr-2.5 [&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:pb-0 [&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:pl-3.5",
"[&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:pt-0 [&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:pe-2.5 [&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:pb-0 [&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:ps-3.5",
"[&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:whitespace-nowrap",
"[&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:text-right",
"[&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:text-end",
"[&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:text-[var(--openresearch-diff-unified-gutter-text-color)]",
"[&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:border-r [&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:border-r-[var(--openresearch-diff-divider-color)]",
"[&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:border-e [&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:border-e-[var(--openresearch-diff-divider-color)]",
"[&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:select-none",
"[&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:cursor-default",
"[&_.openresearch-diff-file_.diff-line]:leading-[1.55]",
@@ -163,11 +166,6 @@ const renderUnifiedGutter: RenderGutter = ({ change, side }) => {
return getUnifiedLineNumber(change);
};
function formatBytes(bytes: number): string {
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / 1024).toFixed(1)} KB`;
}
export function TruncatedDiffNotice({
bytesRead,
byteLimit,
@@ -177,10 +175,9 @@ export function TruncatedDiffNotice({
}) {
return (
<div className="truncated-notice border border-accent-amber rounded-md bg-accent-amber-subtle py-3 px-3.5 text-md [&_h4]:mt-0 [&_h4]:mx-0 [&_h4]:mb-1 [&_h4]:text-md [&_h4]:text-accent-amber [&_p]:m-0 [&_p]:text-subtext">
<h4>Diff preview truncated</h4>
<h4>{m.git_diff_diff_preview_truncated()}</h4>
<p>
Showing the first {formatBytes(byteLimit)} ({formatBytes(bytesRead)} read). View the complete
diff locally with git.
{m.git_diff_truncated_detail({ limit: ltr(fmtBytes(byteLimit)), read: ltr(fmtBytes(bytesRead)) })}
</p>
</div>
);
@@ -208,7 +205,7 @@ function DiffFileCard({
return (
<section className={`diff-file-card overflow-hidden border border-border rounded-md bg-background [&.expanded_.diff-file-header]:border-b [&.expanded_.diff-file-header]:border-b-border ${expanded ? "expanded" : ""}`}>
<button
className="diff-file-header sticky top-0 z-10 flex items-center justify-between gap-3 w-full text-left py-2 px-3 bg-canvas cursor-pointer [&_.chev]:text-muted [&_.chev]:text-2xs [&_.chev]:shrink-0 [&_.chev]:w-3 [&_.path]:flex [&_.path]:items-center [&_.path]:gap-2 [&_.path]:min-w-0 [&_.path]:flex-1 [&_.path_code]:min-w-0 [&_.path_code]:flex-1 [&_.path_code]:overflow-hidden [&_.path_code]:text-ellipsis [&_.path_code]:whitespace-nowrap [&_.path_code]:font-mono [&_.path_code]:text-xs [&_.path_code]:font-semibold [&_.path_code]:text-text [&_.stats]:flex [&_.stats]:items-center [&_.stats]:gap-2 [&_.stats]:shrink-0 [&_.stats]:font-mono [&_.stats]:text-2xs [&_.stats]:font-medium [&_.stats]:tabular-nums"
className="diff-file-header sticky top-0 z-10 flex items-center justify-between gap-3 w-full text-start py-2 px-3 bg-canvas cursor-pointer [&_.chev]:text-muted [&_.chev]:text-2xs [&_.chev]:shrink-0 [&_.chev]:w-3 [&_.path]:flex [&_.path]:items-center [&_.path]:gap-2 [&_.path]:min-w-0 [&_.path]:flex-1 [&_.path_code]:min-w-0 [&_.path_code]:flex-1 [&_.path_code]:overflow-hidden [&_.path_code]:text-ellipsis [&_.path_code]:whitespace-nowrap [&_.path_code]:font-mono [&_.path_code]:text-xs [&_.path_code]:font-semibold [&_.path_code]:text-text [&_.stats]:flex [&_.stats]:items-center [&_.stats]:gap-2 [&_.stats]:shrink-0 [&_.stats]:font-mono [&_.stats]:text-2xs [&_.stats]:font-medium [&_.stats]:tabular-nums"
aria-expanded={expanded}
onClick={() => setExpanded((e) => !e)}
>
@@ -225,7 +222,7 @@ function DiffFileCard({
</button>
{expanded &&
(file.hunks.length === 0 ? (
<div className="diff-empty py-2 px-3 text-muted text-md">No textual diff for this file.</div>
<div className="diff-empty py-2 px-3 text-muted text-md">{m.git_diff_no_textual_diff_for_this_file()}</div>
) : (
<div className="diff-file-body overflow-x-auto bg-background">
<Diff
@@ -259,8 +256,8 @@ function DiffFiles({ files, className }: { files: FileData[]; className?: string
export function GitDiff({ diff, className }: { diff: string; className?: string }) {
const parsed = useMemo(() => parseDiffFiles(diff, false), [diff]);
if (parsed.failed) return <div className="diff-empty py-2 px-3 text-muted text-md">Unable to parse this diff.</div>;
if (parsed.files.length === 0) return <div className="diff-empty py-2 px-3 text-muted text-md">No changes.</div>;
if (parsed.failed) return <div className="diff-empty py-2 px-3 text-muted text-md">{m.git_diff_unable_to_parse_this_diff()}</div>;
if (parsed.files.length === 0) return <div className="diff-empty py-2 px-3 text-muted text-md">{m.git_diff_no_changes()}</div>;
return <DiffFiles files={parsed.files} className={className} />;
}
@@ -302,25 +299,25 @@ export function GitDiffExplorer({ diff, partial = false }: { diff: string; parti
if (parsed.failed) {
return (
<div className="diff-empty py-2 px-3 text-muted text-md">
{partial ? "No complete file preview was available before the cutoff." : "Unable to parse this diff."}
{partial ? m.git_diff_no_complete_preview() : m.git_diff_parse_failed()}
</div>
);
}
if (items.length === 0) return <div className="diff-empty py-2 px-3 text-muted text-md">No changes.</div>;
if (items.length === 0) return <div className="diff-empty py-2 px-3 text-muted text-md">{m.git_diff_no_changes()}</div>;
return (
<div className="diff-explorer @container">
<div className="diff-explorer-toolbar flex items-center justify-between gap-3 mb-2.5 text-sm [&_button]:py-0.5 [&_button]:px-0 [&_button]:text-muted [&_button]:text-xs [&_button]:font-medium [&_button:hover]:text-text [&_button:hover]:underline [&_button:hover]:underline-offset-2">
<strong>
{partial
? `${items.length} ${items.length === 1 ? "file" : "files"} shown (partial)`
? items.length === 1 ? m.git_diff_one_file_partial() : m.git_diff_files_partial({ count: fmtNumber(items.length) })
: items.length === 1
? "1 changed file"
: `${items.length} changed files`}
? m.git_diff_one_changed_file()
: m.git_diff_changed_file_count({ count: fmtNumber(items.length) })}
</strong>
{!partial && (
<button type="button" onClick={() => setShowFullDiff((current) => !current)}>
{showingFullDiff ? "Back to preview" : "View full diff"}
{showingFullDiff ? m.git_diff_back_to_preview() : m.git_diff_view_full()}
</button>
)}
</div>
@@ -328,7 +325,7 @@ export function GitDiffExplorer({ diff, partial = false }: { diff: string; parti
<DiffFiles files={files} />
) : (
<div className="diff-explorer-layout grid grid-cols-[minmax(180px,_260px)_minmax(0,_1fr)] items-start gap-3.5 [@container((max-width:_960px))]:grid-cols-1">
<div className="diff-explorer-files sticky top-0 max-h-[min(70vh,_720px)] overflow-auto border border-border rounded-md bg-background [&_button]:grid [&_button]:grid-cols-[18px_minmax(0,_1fr)_auto_auto] [&_button]:items-center [&_button]:gap-[7px] [&_button]:w-full [&_button]:py-2 [&_button]:px-[9px] [&_button]:border-b [&_button]:border-b-border-variant [&_button]:text-text [&_button]:text-left [&_button:last-child]:border-b-0 [&_button:hover]:bg-surface [&_button.active]:bg-surface [&_button.active]:shadow-[inset_2px_0_0_var(--text)] [&_code]:overflow-hidden [&_code]:text-ellipsis [&_code]:whitespace-nowrap [&_code]:text-xs [@container((max-width:_960px))]:static [@container((max-width:_960px))]:max-h-55" aria-label="Changed files">
<div className="diff-explorer-files sticky top-0 max-h-[min(70vh,_720px)] overflow-auto border border-border rounded-md bg-background [&_button]:grid [&_button]:grid-cols-[18px_minmax(0,_1fr)_auto_auto] [&_button]:items-center [&_button]:gap-[7px] [&_button]:w-full [&_button]:py-2 [&_button]:px-[9px] [&_button]:border-b [&_button]:border-b-border-variant [&_button]:text-text [&_button]:text-start [&_button:last-child]:border-b-0 [&_button:hover]:bg-surface [&_button.active]:bg-surface [&_button.active]:shadow-[inset_2px_0_0_var(--text)] [&_code]:overflow-hidden [&_code]:text-ellipsis [&_code]:whitespace-nowrap [&_code]:text-xs [@container((max-width:_960px))]:static [@container((max-width:_960px))]:max-h-55" aria-label={m.git_diff_changed_files()}>
{items.map((item) => (
<button
type="button"
+3 -2
View File
@@ -1,3 +1,4 @@
import { m } from "../paraglide/messages.js";
import { useState } from "react";
import { BUTTON_CLASS_NAME } from "../styleClasses";
@@ -42,10 +43,10 @@ export function TokenForm<T>({
autoComplete="off"
/>
<button type="submit" className={BUTTON_CLASS_NAME} disabled={saving || !token.trim()}>
{saving ? "Saving…" : "Save"}
{saving ? m.common_saving() : m.common_save()}
</button>
<a href={createHref} target="_blank" rel="noreferrer">
Create a token ↗
{m.git_token_form_create_a_token()}
</a>
{error && <div className="error">{error}</div>}
</form>
+10 -9
View File
@@ -1,3 +1,4 @@
import { m } from "../paraglide/messages.js";
import {
ArrowLeft,
ChevronDown,
@@ -44,10 +45,10 @@ export function RailHeader({
}, [open]);
return (
<div className="rail-brand flex items-center gap-1 h-16 p-2 border-b border-b-border shrink-0 [&_.project-switcher]:relative [&_.project-switcher]:flex-1 [&_.project-switcher]:self-stretch [&_.project-switcher]:min-w-0 [&_.project-back]:shrink-0 [&_.brand]:flex [&_.brand]:items-center [&_.brand]:justify-between [&_.brand]:gap-2 [&_.brand]:w-full [&_.brand]:h-full [&_.brand]:min-w-0 [&_.brand]:font-semibold [&_.brand]:text-base [&_.brand]:text-text [&_.brand]:py-1 [&_.brand]:px-1.5 [&_.brand]:border [&_.brand]:border-transparent [&_.brand]:rounded-sm [&_.brand:hover]:bg-surface [&_.brand:hover]:border-border [&_.brand.open]:bg-surface [&_.brand.open]:border-border [&_.brand_svg]:shrink-0 [&_.brand-project-copy]:flex [&_.brand-project-copy]:flex-col [&_.brand-project-copy]:gap-[3px] [&_.brand-project-copy]:min-w-0 [&_.brand-project-copy]:leading-[1.15] [&_.brand-project-copy]:text-left [&_.brand-project-label]:text-muted [&_.brand-project-label]:text-2xs [&_.brand-project-label]:font-medium [&_.brand-project-label]:tracking-[0.04em] [&_.brand-project-label]:uppercase [&_.brand_.brand-project]:min-w-0 [&_.brand_.brand-project]:overflow-hidden [&_.brand_.brand-project]:text-ellipsis [&_.brand_.brand-project]:whitespace-nowrap [&_.brand_.brand-project]:text-2xl [&_.project-chevron]:text-muted [&_.project-chevron]:opacity-0 [&_.project-chevron]:transition-transform [&_.project-chevron]:duration-120 [&_.project-chevron]:ease-standard [&_.brand:hover_.project-chevron]:opacity-100 [&_.brand.open_.project-chevron]:opacity-100 [&_.brand.open_.project-chevron]:rotate-180 [&_.project-menu]:left-0 [&_.project-menu]:w-52.5 [&_.project-menu]:z-70">
<div className="rail-brand flex items-center gap-1 h-16 p-2 border-b border-b-border shrink-0 [&_.project-switcher]:relative [&_.project-switcher]:flex-1 [&_.project-switcher]:self-stretch [&_.project-switcher]:min-w-0 [&_.project-back]:shrink-0 [&_.brand]:flex [&_.brand]:items-center [&_.brand]:justify-between [&_.brand]:gap-2 [&_.brand]:w-full [&_.brand]:h-full [&_.brand]:min-w-0 [&_.brand]:font-semibold [&_.brand]:text-base [&_.brand]:text-text [&_.brand]:py-1 [&_.brand]:px-1.5 [&_.brand]:border [&_.brand]:border-transparent [&_.brand]:rounded-sm [&_.brand:hover]:bg-surface [&_.brand:hover]:border-border [&_.brand.open]:bg-surface [&_.brand.open]:border-border [&_.brand_svg]:shrink-0 [&_.brand-project-copy]:flex [&_.brand-project-copy]:flex-col [&_.brand-project-copy]:gap-[3px] [&_.brand-project-copy]:min-w-0 [&_.brand-project-copy]:leading-[1.15] [&_.brand-project-copy]:text-start [&_.brand-project-label]:text-muted [&_.brand-project-label]:text-2xs [&_.brand-project-label]:font-medium [&_.brand-project-label]:tracking-[0.04em] [&_.brand-project-label]:uppercase [&_.brand_.brand-project]:min-w-0 [&_.brand_.brand-project]:overflow-hidden [&_.brand_.brand-project]:text-ellipsis [&_.brand_.brand-project]:whitespace-nowrap [&_.brand_.brand-project]:text-2xl [&_.project-chevron]:text-muted [&_.project-chevron]:opacity-0 [&_.project-chevron]:transition-transform [&_.project-chevron]:duration-120 [&_.project-chevron]:ease-standard [&_.brand:hover_.project-chevron]:opacity-100 [&_.brand.open_.project-chevron]:opacity-100 [&_.brand.open_.project-chevron]:rotate-180 [&_.project-menu]:start-0 [&_.project-menu]:w-52.5 [&_.project-menu]:z-70">
<button
className={`${ICON_BUTTON_CLASS_NAME} project-back !text-text`}
aria-label="All projects"
aria-label={m.header_all_projects()}
onClick={onHome}
>
<ArrowLeft size={18} />
@@ -60,13 +61,13 @@ export function RailHeader({
aria-expanded={open}
>
<span className="brand-project-copy">
<span className="brand-project-label">Project</span>
<span className="brand-project-label">{m.header_project()}</span>
<span className="brand-project">{projectName}</span>
</span>
<ChevronDown className="project-chevron" size={14} />
</button>
{open && (
<div className="option-menu absolute bottom-[calc(100%_+_8px)] left-0 max-h-95 flex flex-col bg-background border border-border rounded-lg shadow-[0_12px_32px_rgba(0,_0,_0,_0.18)] z-50 overflow-hidden min-w-47.5 p-1.5 [&.align-right]:left-auto [&.align-right]:right-0 [&.drop-down]:bottom-auto [&.drop-down]:top-[calc(100%_+_4px)] [&.session-menu]:left-auto [&.session-menu]:right-1.5 [&.session-menu]:top-[calc(100%_-_2px)] [&.session-menu]:min-w-35 drop-down project-menu">
<div className="option-menu absolute bottom-[calc(100%_+_8px)] start-0 max-h-95 flex flex-col bg-background border border-border rounded-lg shadow-[0_12px_32px_rgba(0,_0,_0,_0.18)] z-50 overflow-hidden min-w-47.5 p-1.5 [&.align-right]:start-auto [&.align-right]:end-0 [&.drop-down]:bottom-auto [&.drop-down]:top-[calc(100%_+_4px)] [&.session-menu]:start-auto [&.session-menu]:end-1.5 [&.session-menu]:top-[calc(100%_-_2px)] [&.session-menu]:min-w-35 drop-down project-menu">
<button
className={MODEL_ITEM_CLASS_NAME}
onClick={() => {
@@ -75,7 +76,7 @@ export function RailHeader({
}}
>
<span className={PROJECT_MENU_LABEL_CLASS_NAME}>
<FolderGit2 size={14} />Configure Repository
<FolderGit2 size={14} />{m.header_configure_repository()}
</span>
</button>
<button
@@ -85,7 +86,7 @@ export function RailHeader({
onHome();
}}
>
<span className={PROJECT_MENU_LABEL_CLASS_NAME}><History size={14} />All projects</span>
<span className={PROJECT_MENU_LABEL_CLASS_NAME}><History size={14} />{m.header_all_projects()}</span>
</button>
<button
className={MODEL_ITEM_CLASS_NAME}
@@ -95,7 +96,7 @@ export function RailHeader({
onNewProject();
}}
>
<span className={PROJECT_MENU_LABEL_CLASS_NAME}><FolderPlus size={14} />Create a new project</span>
<span className={PROJECT_MENU_LABEL_CLASS_NAME}><FolderPlus size={14} />{m.header_create_a_new_project()}</span>
</button>
</div>
)}
@@ -103,9 +104,9 @@ export function RailHeader({
{onCollapse && (
<button
className={ICON_BUTTON_CLASS_NAME}
data-tip="Hide sidebar"
data-tip={m.header_hide_sidebar()}
data-tip-align="end"
aria-label="Hide sidebar"
aria-label={m.header_hide_sidebar()}
onClick={onCollapse}
>
<PanelLeft size={15} />
+2 -1
View File
@@ -1,3 +1,4 @@
import { m } from "../paraglide/messages.js";
// Literature-source toggles shown inline in the composer chat-settings panel:
// which sources discovery and paper reading may use. State lives in settings.json
// (same `/api/settings/lit-sources` endpoint the CLI enforces).
@@ -38,7 +39,7 @@ export function LitSourcesList() {
.finally(() => setSaving(false));
};
if (!settings) return <div className="py-1.5 px-2 text-muted text-sm">Loading…</div>;
if (!settings) return <div className="py-1.5 px-2 text-muted text-sm">{m.lit_sources_picker_loading()}</div>;
return (
<div className="flex flex-col">
File diff suppressed because one or more lines are too long
+6 -4
View File
@@ -1,3 +1,5 @@
import { m } from "../paraglide/messages.js";
import { ltr } from "../i18n";
import { useEffect, useState } from "react";
import type { FilePresentation } from "../api";
@@ -20,8 +22,8 @@ export function mediaPreviewKind(
function DownloadFallback({ url, name }: { url: string; name: string }) {
return (
<div className="file-view-note py-2.5 px-4 text-sm text-muted">
This browser can’t preview this media format.{" "}
<a href={url} download={name}>Download {name}</a>
{m.media_preview_this_browser_can_t_preview_this_media_format()}{" "}
<a href={url} download={name}>{m.media_preview_download()} {ltr(name)}</a>
</div>
);
}
@@ -96,8 +98,8 @@ export function MediaPreview({
<div className="flex h-full min-h-0 flex-col">
{preview}
{downloadBar && (
<div className="shrink-0 border-t border-border-variant py-1.5 px-3 text-right text-xs">
<a href={url} download={name}>Download {name}</a>
<div className="shrink-0 border-t border-border-variant py-1.5 px-3 text-end text-xs">
<a href={url} download={name}>{m.media_preview_download()} {name}</a>
</div>
)}
</div>
+32 -30
View File
@@ -1,7 +1,10 @@
import { m } from "../paraglide/messages.js";
import { ltr } from "../i18n";
import { Check, ChevronDown, ChevronLeft, ChevronRight, Lock, Zap } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
import {
getHarnesses,
fmtNumber,
harnessModelLabel,
modelLabel,
reasoningFor,
@@ -212,13 +215,13 @@ export function ModelPicker({
? selected
? harnessModelLabel(selected)
: modelLabel(value.model)
: "Default model"
: "Model";
: m.model_picker_default_model()
: m.model_picker_model();
const effectiveReasoningId = value?.reasoningLevel ?? defaultReasoningId ?? reasoningChoices[0]?.id;
const reasoningLabel = reasoningChoices.find((choice) => choice.id === effectiveReasoningId)?.label;
const effectivePermissionId = value?.permissionMode ?? defaultPermissionId ?? permissionChoices[0]?.id;
const permissionLabel = permissionChoices.find((choice) => choice.id === effectivePermissionId)?.label;
const reasoningAxisLabel = value?.harness === "opencode" ? "Variant" : "Effort";
const reasoningAxisLabel = value?.harness === "opencode" ? m.model_picker_variant() : m.model_picker_effort();
const selectedHarness = harnesses.find((harness) => harness.id === value?.harness);
const speedChoices = serviceTiersFor(selectedHarness, value?.model);
const effectiveServiceTier = reconcileServiceTier(
@@ -250,7 +253,7 @@ export function ModelPicker({
) => (
<button
type="button"
className="model-root-row flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-md text-text hover:bg-surface"
className="model-root-row flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-start text-md text-text hover:bg-surface"
aria-haspopup="menu"
onClick={() => setPage(next)}
>
@@ -264,7 +267,7 @@ export function ModelPicker({
<button
ref={submenuHeaderRef}
type="button"
className="model-submenu-header flex w-full items-center gap-2 border-0 border-b border-solid border-b-border-variant bg-transparent px-2 py-2 text-left text-sm font-medium text-text hover:bg-surface"
className="model-submenu-header flex w-full items-center gap-2 border-0 border-b border-solid border-b-border-variant bg-transparent px-2 py-2 text-start text-sm font-medium text-text hover:bg-surface"
onClick={() => {
setPage("root");
setFilter("");
@@ -288,7 +291,7 @@ export function ModelPicker({
<span>
{choice.label}
{choice.id === defaultId && (
<span className="font-normal text-muted"> · Default</span>
<span className="font-normal text-muted"> {m.model_picker_default()}</span>
)}
</span>
{choice.description && (
@@ -309,7 +312,7 @@ export function ModelPicker({
ref={triggerRef}
type="button"
className={`${COMPOSER_CONTROL_CLASS_NAME} composer-pill min-w-0 max-w-full gap-[5px] px-2 text-md text-text whitespace-nowrap`}
title={`Harness + model for this chat: ${label}${reasoningLabel ? ` · ${reasoningLabel}` : ""}${speedLabel ? ` · ${speedLabel}` : ""}`}
title={m.a11y_chat_model({ label: `${label}${reasoningLabel ? ` · ${reasoningLabel}` : ""}${speedLabel ? ` · ${speedLabel}` : ""}` })}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => {
@@ -325,36 +328,36 @@ export function ModelPicker({
) : value?.harness ? (
<HarnessLogo harness={value.harness} size={14} />
) : null}
{effectiveServiceTier === "priority" && <span className="sr-only">Fast speed · </span>}
{effectiveServiceTier === "priority" && <span className="sr-only">{m.model_picker_fast_speed()} </span>}
<span className="model-picker-label min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">
{label}
{reasoningLabel && <span className="model-picker-reasoning ml-1 text-muted">{reasoningLabel}</span>}
{reasoningLabel && <span className="model-picker-reasoning ms-1 text-muted">{reasoningLabel}</span>}
</span>
<ChevronDown size={14} className="shrink-0 text-muted" />
</button>
{open && (
<div className="model-menu absolute bottom-[calc(100%_+_8px)] left-0 max-h-100 flex flex-col bg-background border border-border rounded-md shadow-[0_10px_26px_rgba(0,_0,_0,_0.16)] z-50 overflow-hidden w-72 [&.align-right]:left-auto [&.align-right]:right-0 [&_input]:rounded-none [&_input]:border-0 [&_input]:border-b [&_input]:border-b-border-variant [&_input]:bg-none [&_input]:bg-transparent [&_input]:py-2 [&_input]:px-2.5 [&_input]:text-sm [&_input]:outline-none align-right">
<div className="model-menu absolute bottom-[calc(100%_+_8px)] start-0 max-h-100 flex flex-col bg-background border border-border rounded-md shadow-[0_10px_26px_rgba(0,_0,_0,_0.16)] z-50 overflow-hidden w-72 [&.align-right]:start-auto [&.align-right]:end-0 [&_input]:rounded-none [&_input]:border-0 [&_input]:border-b [&_input]:border-b-border-variant [&_input]:bg-none [&_input]:bg-transparent [&_input]:py-2 [&_input]:px-2.5 [&_input]:text-sm [&_input]:outline-none align-right">
{page === "root" && (
<div className="model-root-menu p-1">
{menuRow("Model", label, "models")}
{menuRow(m.model_picker_model(), label, "models")}
{reasoningChoices.length > 0 && menuRow(reasoningAxisLabel, reasoningLabel, "reasoning")}
{speedChoices.length > 0 && menuRow("Speed", speedLabel, "speed")}
{permissionChoices.length > 0 && menuRow("Mode", permissionLabel, "permissions")}
{speedChoices.length > 0 && menuRow(m.model_picker_speed(), speedLabel, "speed")}
{permissionChoices.length > 0 && menuRow(m.model_picker_mode(), permissionLabel, "permissions")}
</div>
)}
{page === "models" && (
<>
{submenuHeader("Model")}
{submenuHeader(m.model_picker_model())}
<input
autoFocus
type="text"
placeholder="Search models…"
placeholder={m.model_picker_search_models()}
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<div className="model-menu-list overflow-y-auto p-1.5">
{groups.map(({ harness, models, hidden }) => (
<div key={harness.id} className="[&_.model-item]:pl-6">
<div key={harness.id} className="[&_.model-item]:ps-6">
<div className={MODEL_GROUP_CLASS_NAME}>
<span className="inline-flex items-center gap-1.5">
<HarnessLogo harness={harness.id} size={14} />
@@ -362,13 +365,13 @@ export function ModelPicker({
</span>
{!harness.agentReady && (
<span className="model-group-status inline-flex items-center gap-1 text-accent-amber font-normal">
<Lock size={10} /> Unavailable
<Lock size={10} /> {m.model_picker_unavailable()}
</span>
)}
</div>
{!harness.agentReady ? (
<div className="model-more [&_code]:font-mono [&_code]:text-xs [&_code]:bg-panel [&_code]:border [&_code]:border-border-variant [&_code]:rounded-xs [&_code]:py-px [&_code]:px-[5px] [&_code]:whitespace-nowrap pt-1 px-2 pb-2 text-xs text-muted model-unavailable leading-normal border-b border-b-border-variant">
{harness.agentNote ? renderNote(harness.agentNote) : "Not available"}
{harness.agentNote ? renderNote(harness.agentNote) : m.model_picker_not_available()}
</div>
) : (
<>
@@ -380,8 +383,8 @@ export function ModelPicker({
{harness.models.length === 0 && (
<button className={MODEL_ITEM_CLASS_NAME} onClick={() => pick(harness, null)}>
<span>
Default model
<span className="model-id">CLI configuration</span>
{m.model_picker_default_model()}
<span className="model-id">{m.model_picker_cli_configuration()}</span>
</span>
{value?.harness === harness.id && value?.model === null && (
<Check size={13} />
@@ -402,7 +405,7 @@ export function ModelPicker({
</button>
))}
{hidden > 0 && (
<div className={MODEL_MORE_CLASS_NAME}>{hidden} more — search to find</div>
<div className={MODEL_MORE_CLASS_NAME}>{m.model_picker_more({ count: fmtNumber(hidden) })}</div>
)}
{/* Free-form escape hatch: the catalogs are curated menus,
not the set of ids the CLIs accept — `--model
@@ -415,8 +418,7 @@ export function ModelPicker({
onClick={() => pick(harness, filter.trim())}
>
<span>
Use “{filter.trim()}”
<span className="model-id">pass as the model id</span>
{m.model_picker_use_id({ id: ltr(filter.trim()) })}
</span>
</button>
)}
@@ -424,12 +426,12 @@ export function ModelPicker({
)}
</div>
))}
{harnesses.length === 0 && <div className={MODEL_MORE_CLASS_NAME}>Detecting harnesses…</div>}
{harnesses.length === 0 && <div className={MODEL_MORE_CLASS_NAME}>{m.model_picker_detecting_harnesses()}</div>}
</div>
{lockHarness && value && harnesses.length > 1 && (
<div className="model-locked-note flex items-center gap-1.5 py-[7px] px-3 text-xs text-muted border-t border-t-border-variant [&_svg]:shrink-0">
<Lock size={11} />
Sessions keep their harness — new chat to switch
{m.model_picker_sessions_keep_their_harness_new_chat_to_switch()}
</div>
)}
</>
@@ -442,13 +444,13 @@ export function ModelPicker({
)}
{page === "permissions" && (
<>
{submenuHeader("Mode")}
{submenuHeader(m.model_picker_mode())}
{choiceList(permissionChoices, effectivePermissionId, defaultPermissionId, choosePermission)}
</>
)}
{page === "speed" && (
<>
{submenuHeader("Speed")}
{submenuHeader(m.model_picker_speed())}
{choiceList(speedChoices, effectiveServiceTier ?? undefined, "default", chooseSpeed)}
</>
)}
@@ -539,7 +541,7 @@ export function OptionPicker({
<ChevronDown size={12} />
</button>
{open && (
<div className={`option-menu absolute bottom-[calc(100%_+_8px)] left-0 max-h-95 flex flex-col bg-background border border-border rounded-lg shadow-[0_12px_32px_rgba(0,_0,_0,_0.18)] z-50 overflow-hidden min-w-47.5 p-1.5 [&.align-right]:left-auto [&.align-right]:right-0 [&.drop-down]:bottom-auto [&.drop-down]:top-[calc(100%_+_4px)] [&.session-menu]:left-auto [&.session-menu]:right-1.5 [&.session-menu]:top-[calc(100%_-_2px)] [&.session-menu]:min-w-35 ${choices.some((choice) => choice.description) ? "min-w-80" : ""} ${variant === "field" ? "min-w-full" : ""} ${align === "right" ? "align-right" : ""} ${dropDown ? "drop-down" : ""}`}>
<div className={`option-menu absolute bottom-[calc(100%_+_8px)] start-0 max-h-95 flex flex-col bg-background border border-border rounded-lg shadow-[0_12px_32px_rgba(0,_0,_0,_0.18)] z-50 overflow-hidden min-w-47.5 p-1.5 [&.align-right]:start-auto [&.align-right]:end-0 [&.drop-down]:bottom-auto [&.drop-down]:top-[calc(100%_+_4px)] [&.session-menu]:start-auto [&.session-menu]:end-1.5 [&.session-menu]:top-[calc(100%_-_2px)] [&.session-menu]:min-w-35 ${choices.some((choice) => choice.description) ? "min-w-80" : ""} ${variant === "field" ? "min-w-full" : ""} ${align === "right" ? "align-right" : ""} ${dropDown ? "drop-down" : ""}`}>
{header && <div className={MODEL_GROUP_CLASS_NAME}>{header}</div>}
{pinned && (
<>
@@ -553,7 +555,7 @@ export function OptionPicker({
the behavior comes from instead. A named one ("Adaptive")
gets the standard marker. */}
<span className="option-default text-muted font-normal">
{pinned.label === "Default" ? " · CLI configuration" : " · Default"}
{m.model_picker_cli_configuration()}
</span>
</span>
</span>
@@ -573,7 +575,7 @@ export function OptionPicker({
the marker — it's one of the tiers, not a separate kind of
choice like the pinned sentinel above. */}
{!pinned && c.id === defaultId && (
<span className="option-default text-muted font-normal"> · Default</span>
<span className="option-default text-muted font-normal"> {m.model_picker_default()}</span>
)}
</span>
{c.description && (
File diff suppressed because one or more lines are too long
+73 -86
View File
@@ -1,3 +1,5 @@
import { m } from "../paraglide/messages.js";
import { ltr } from "../i18n";
import { ArrowLeft, ArrowRight, Check, RefreshCw, X } from "lucide-react";
import { Wordmark } from "./Wordmark";
import { useEffect, useRef, useState } from "react";
@@ -19,7 +21,6 @@ import {
} from "../api";
import { renderNote } from "./agentNote";
import { HarnessLogo } from "./HarnessLogo";
import { LIT_SOURCE_NAME, LitSourceLogo, type LitSource } from "./LitSourceLogo";
import { onHarnessAuth } from "../events";
import { GHOST_BUTTON_CLASS_NAME, MONO_CLASS_NAME, PAPER_TITLE_CLASS_NAME, PRIMARY_BUTTON_CLASS_NAME, SPINNER_CLASS_NAME, STATUS_BADGE_CLASS_NAME } from "../styleClasses";
@@ -50,8 +51,12 @@ const FINISH_ERROR_CLASS_NAME = [
"text-text",
].join(" ");
const RETRY_COPY = "Couldn't reach orx. Check it's still running, then re-check.";
const RESEARCH_AREAS = ["AI/ML", "Biology", "Physics", "Other"];
const RESEARCH_AREAS = [
{ id: "AI/ML", label: m.onboarding_area_ai_ml },
{ id: "Biology", label: m.onboarding_area_biology },
{ id: "Physics", label: m.onboarding_area_physics },
{ id: "Other", label: m.onboarding_area_other },
];
/** First-run walkthrough: choose a local coding agent, verify Git, add a
* research profile, then install and open the demo project. The local tool
@@ -246,7 +251,7 @@ export function Onboarding({
<Wordmark />
</div>
<h2 className="onb-title mt-0 mx-0 text-[2.5rem] leading-[1.08] tracking-[-0.035em]">
A workspace for your research agents
{m.onboarding_a_workspace_for_your_research_agents()}
</h2>
</div>
<div className="onb-intro-features relative min-[1120px]:col-start-2 min-[1120px]:row-start-1 min-[1120px]:self-end">
@@ -258,33 +263,30 @@ export function Onboarding({
<li className="rounded-2xl border border-border bg-background p-6 shadow-[0_14px_36px_color-mix(in_oklab,_var(--text)_6%,_transparent)]">
<span>
<strong className="mb-1.5 block text-2xl tracking-[-0.015em]">
Consolidate your research
{m.onboarding_consolidate_your_research()}
</strong>
<span className="block text-lg leading-[1.55] text-text">
Track experiments, artifacts, compute, skills, and code all in one place.
{m.onboarding_track_experiments_artifacts_compute_skills_and_code_all()}
</span>
</span>
</li>
<li className="rounded-2xl border border-border bg-background p-6 shadow-[0_14px_36px_color-mix(in_oklab,_var(--text)_6%,_transparent)]">
<span>
<strong className="mb-1.5 block text-2xl tracking-[-0.015em]">
Ground your agents
{m.onboarding_ground_your_agents()}
</strong>
<span className="block text-lg leading-[1.55] text-text">
Connected to <InlineLitSource source="alphaxiv" />,{" "}
<InlineLitSource source="biorxiv" />, and{" "}
<InlineLitSource source="openalex" />{" "}
to ground your agents in the latest research.
{m.onboarding_sources_description()}
</span>
</span>
</li>
<li className="rounded-2xl border border-border bg-background p-6 shadow-[0_14px_36px_color-mix(in_oklab,_var(--text)_6%,_transparent)]">
<span>
<strong className="mb-1.5 block text-2xl tracking-[-0.015em]">
Everything stays local
{m.onboarding_everything_stays_local()}
</strong>
<span className="block text-lg leading-[1.55] text-text">
Your code, data, and experiment history stay on your machine.
{m.onboarding_your_code_data_and_experiment_history_stay_on()}
</span>
</span>
</li>
@@ -295,25 +297,25 @@ export function Onboarding({
className={`${PRIMARY_BUTTON_CLASS_NAME} !py-3.5 !px-7 !text-xl !rounded-lg`}
onClick={() => setStep(1)}
>
Continue <ArrowRight size={20} />
{m.onboarding_continue()} <ArrowRight size={20} />
</button>
</div>
</div>
) : step === 1 ? (
<>
<div className="onb-eyebrow text-xl font-semibold text-muted mb-4.5">
<Wordmark /> · Step 1 of 2
<Wordmark /> {m.onboarding_step_1_of_2()}
</div>
<h2 className="onb-title mt-0 mx-0 mb-1.5 text-3xl tracking-[-0.01em]">Choose a coding agent</h2>
<p className="onb-sub text-text text-base leading-[1.55] mt-0 mx-0 mb-5.5 max-w-120">OpenResearch uses a coding agent already installed on this machine.</p>
<h2 className="onb-title mt-0 mx-0 mb-1.5 text-3xl tracking-[-0.01em]">{m.onboarding_choose_a_coding_agent()}</h2>
<p className="onb-sub text-text text-base leading-[1.55] mt-0 mx-0 mb-5.5 max-w-120">{m.onboarding_open_research_uses_a_coding_agent_already_installed()}</p>
{harnesses !== null && !anyAgentReady && (
<p className={ONB_GATE_HINT_CLASS_NAME}>
Sign in to at least one agent to continue.
{m.onboarding_sign_in_to_at_least_one_agent_to()}
</p>
)}
{harnesses !== null && anyAgentReady && preferredHarness === null && (
<p className={ONB_GATE_HINT_CLASS_NAME}>
Choose a coding agent to continue.
{m.onboarding_choose_a_coding_agent_to_continue()}
</p>
)}
<div className="onb-cards flex flex-col gap-3.5">
@@ -328,10 +330,10 @@ export function Onboarding({
))
) : harnessError ? (
// Never a spinner next to an error — detection isn't running.
<div className={ONB_CARD_META_CLASS_NAME}>{RETRY_COPY}</div>
<div className={ONB_CARD_META_CLASS_NAME}>{m.onboarding_retry_connection()}</div>
) : (
<div className="onb-loading flex items-center gap-2 text-subtext text-md py-2 px-0">
<span className={SPINNER_CLASS_NAME} /> Detecting Claude Code, Codex, OpenCode…
<span className={SPINNER_CLASS_NAME} /> {m.onboarding_detecting_claude_code_codex_open_code()}
</div>
)}
</div>
@@ -339,24 +341,24 @@ export function Onboarding({
<div className="onb-git-check mt-7" role="status" aria-live="polite">
<LocalGitCard gitVersion={gitVersion} error={gitError} />
{gitError ? (
<p className={GIT_RETRY_HINT_CLASS_NAME}>{RETRY_COPY}</p>
<p className={GIT_RETRY_HINT_CLASS_NAME}>{m.onboarding_retry_connection()}</p>
) : (
<p className={GIT_RETRY_HINT_CLASS_NAME}>
Git is required for local experiments. Install Git, then re-check.
{m.onboarding_git_is_required_for_local_experiments_install_git()}
</p>
)}
</div>
)}
<div className="onb-actions flex items-center gap-2.5 mt-5.5">
<button className={GHOST_BUTTON_CLASS_NAME} onClick={() => setStep(0)}>
<ArrowLeft size={12} /> Back
<ArrowLeft size={12} /> {m.onboarding_back()}
</button>
{(harnessError ||
gitError ||
gitVersion === null ||
(harnesses !== null && !anyAgentReady)) && (
<button className={GHOST_BUTTON_CLASS_NAME} onClick={() => load(true, true)} disabled={checking}>
<RefreshCw size={12} className={checking ? "spin animate-[settings-spin_0.9s_linear_infinite]" : ""} /> Re-check
<RefreshCw size={12} className={checking ? "spin animate-[settings-spin_0.9s_linear_infinite]" : ""} /> {m.onboarding_re_check()}
</button>
)}
<div style={{ flex: 1 }} />
@@ -366,45 +368,45 @@ export function Onboarding({
disabled={checking || !anyAgentReady || preferredHarness === null || !gitReady}
title={
checking
? "Waiting for the local tool checks"
? m.onboarding_waiting_tool_checks()
: !anyAgentReady
? "Sign in to at least one coding agent to continue"
? m.onboarding_sign_in_agent_to_continue()
: preferredHarness === null
? "Choose your preferred coding agent"
? m.onboarding_choose_preferred_agent()
: gitError
? "Re-check Git before continuing"
? m.onboarding_recheck_git_to_continue()
: gitVersion === undefined
? "Waiting for the Git check"
? m.onboarding_waiting_git_check()
: gitVersion === null
? "Install Git to continue"
? m.onboarding_install_git_to_continue()
: undefined
}
>
Continue <ArrowRight size={13} />
{m.onboarding_continue()} <ArrowRight size={13} />
</button>
</div>
</>
) : (
<>
<div className="onb-eyebrow text-xl font-semibold text-muted mb-4.5">
<Wordmark /> · Step 2 of 2
<Wordmark /> {m.onboarding_step_2_of_2()}
</div>
<h2 className="onb-title mt-0 mx-0 mb-1.5 text-3xl tracking-[-0.01em] onb-profile-title mb-5.5">Tell us about your research</h2>
<h2 className="onb-title mt-0 mx-0 mb-1.5 text-3xl tracking-[-0.01em] onb-profile-title mb-5.5">{m.onboarding_tell_us_about_your_research()}</h2>
<div className="onb-cards flex flex-col gap-2.5">
<div className={ONB_CARD_CLASS_NAME}>
<fieldset className="onb-fieldset border-0 mt-0 mx-0 mb-4.5 p-0 [&_legend]:text-base [&_legend]:font-semibold [&_legend]:mb-1.5">
<legend>What areas are you interested in?</legend>
<p className="onb-field-hint text-muted text-sm leading-[1.4] mt-0 mx-0 mb-2">Choose one or more.</p>
<legend>{m.onboarding_what_areas_are_you_interested_in()}</legend>
<p className="onb-field-hint text-muted text-sm leading-[1.4] mt-0 mx-0 mb-2">{m.onboarding_choose_one_or_more()}</p>
<div className="onb-area-options grid grid-cols-[repeat(2,_minmax(0,_1fr))] gap-2">
{RESEARCH_AREAS.map((area) => (
<label key={area} className="onb-area-option flex items-center gap-2 border border-border rounded-md cursor-pointer py-[9px] px-2.5 [&:has(input:checked)]:border-accent [&:has(input:checked)]:bg-primary-subtle [&_input]:m-0">
<label key={area.id} className="onb-area-option flex items-center gap-2 border border-border rounded-md cursor-pointer py-[9px] px-2.5 [&:has(input:checked)]:border-accent [&:has(input:checked)]:bg-primary-subtle [&_input]:m-0">
<input
type="checkbox"
checked={researchAreas.includes(area)}
onChange={() => toggleResearchArea(area)}
checked={researchAreas.includes(area.id)}
onChange={() => toggleResearchArea(area.id)}
disabled={finishing}
/>
<span>{area}</span>
<span>{area.label()}</span>
</label>
))}
</div>
@@ -414,13 +416,13 @@ export function Onboarding({
value={otherArea}
onChange={(event) => setOtherArea(event.target.value)}
disabled={finishing}
placeholder="Tell us your other research area"
aria-label="Other research area"
placeholder={m.onboarding_tell_us_your_other_research_area()}
aria-label={m.onboarding_other_research_area()}
/>
)}
</fieldset>
<label className="onb-field-label text-base font-semibold mb-1.5" htmlFor="onb-background">
Research background
{m.onboarding_research_background()}
</label>
<textarea
id="onb-background"
@@ -429,14 +431,13 @@ export function Onboarding({
onChange={(e) => setBackground(e.target.value)}
disabled={finishing}
rows={4}
placeholder="e.g. I work on sample-efficient RL for LLM post-training, focused on reward-model-free methods."
placeholder={m.onboarding_e_g_i_work_on_sample_efficient_rl()}
/>
<label className="onb-field-label text-base font-semibold mb-1.5" htmlFor="onb-paper-search">
Representative papers
{m.onboarding_representative_papers()}
</label>
<p className="onb-field-hint text-muted text-sm leading-[1.4] mt-0 mx-0 mb-2">
Add papers that represent your research interests, including papers by other
authors.
{m.onboarding_add_papers_that_represent_your_research_interests_including()}
</p>
<div className="onb-paper-search flex flex-col gap-1.5 mt-3 [&_input]:w-full">
<input
@@ -444,12 +445,12 @@ export function Onboarding({
value={paperQuery}
onChange={(e) => setPaperQuery(e.target.value)}
disabled={finishing}
placeholder="Search alphaXiv by title to link a paper…"
placeholder={m.onboarding_search_alpha_xiv_by_title_to_link_a()}
/>
{searchingPapers ? (
<div className={ONB_CARD_META_CLASS_NAME}>Searching alphaXiv…</div>
<div className={ONB_CARD_META_CLASS_NAME}>{m.onboarding_searching_alpha_xiv()}</div>
) : paperHits.length > 0 ? (
<div className="onb-paper-results flex flex-col border border-border rounded-md max-h-50 overflow-y-auto [&_button]:flex [&_button]:flex-col [&_button]:items-start [&_button]:gap-0.5 [&_button]:py-2 [&_button]:px-2.5 [&_button]:bg-none [&_button]:bg-transparent [&_button]:border-0 [&_button]:border-b [&_button]:border-b-border-variant [&_button]:text-left [&_button]:[font:inherit] [&_button]:text-text [&_button]:cursor-pointer [&_button:last-child]:border-b-0 [&_button:hover]:bg-surface [&_.title]:text-md [&_.title]:font-medium [&_.id]:font-mono [&_.id]:text-xs [&_.id]:text-muted">
<div className="onb-paper-results flex flex-col border border-border rounded-md max-h-50 overflow-y-auto [&_button]:flex [&_button]:flex-col [&_button]:items-start [&_button]:gap-0.5 [&_button]:py-2 [&_button]:px-2.5 [&_button]:bg-none [&_button]:bg-transparent [&_button]:border-0 [&_button]:border-b [&_button]:border-b-border-variant [&_button]:text-start [&_button]:[font:inherit] [&_button]:text-text [&_button]:cursor-pointer [&_button:last-child]:border-b-0 [&_button:hover]:bg-surface [&_.title]:text-md [&_.title]:font-medium [&_.id]:font-mono [&_.id]:text-xs [&_.id]:text-muted">
{paperHits.map((h) => (
<button
key={h.paperId}
@@ -467,12 +468,12 @@ export function Onboarding({
{papers.length > 0 && (
<div className="onb-paper-chips flex flex-wrap gap-1.5 mt-2.5">
{papers.map((p) => (
<span key={p.paperId} className="onb-paper-chip inline-flex items-center gap-1.5 pt-1 pr-1 pb-1 pl-2.5 border border-border rounded-sm bg-surface text-sm max-w-full [&_.title]:font-medium [&_.title]:overflow-hidden [&_.title]:text-ellipsis [&_.title]:whitespace-nowrap [&_.title]:max-w-60 [&_.id]:font-mono [&_.id]:text-xs [&_.id]:text-muted [&_button]:inline-flex [&_button]:items-center [&_button]:justify-center [&_button]:p-0.5 [&_button]:border-0 [&_button]:bg-none [&_button]:bg-transparent [&_button]:text-muted [&_button]:cursor-pointer [&_button]:rounded-xs [&_button:hover]:text-text [&_button:hover]:bg-panel">
<span key={p.paperId} className="onb-paper-chip inline-flex items-center gap-1.5 pt-1 pe-1 pb-1 ps-2.5 border border-border rounded-sm bg-surface text-sm max-w-full [&_.title]:font-medium [&_.title]:overflow-hidden [&_.title]:text-ellipsis [&_.title]:whitespace-nowrap [&_.title]:max-w-60 [&_.id]:font-mono [&_.id]:text-xs [&_.id]:text-muted [&_button]:inline-flex [&_button]:items-center [&_button]:justify-center [&_button]:p-0.5 [&_button]:border-0 [&_button]:bg-none [&_button]:bg-transparent [&_button]:text-muted [&_button]:cursor-pointer [&_button]:rounded-xs [&_button:hover]:text-text [&_button:hover]:bg-panel">
<span className={PAPER_TITLE_CLASS_NAME}>{p.title || p.paperId}</span>
<span className="id">{p.paperId}</span>
<button
type="button"
aria-label={`Remove ${p.paperId}`}
aria-label={m.a11y_remove_item({ name: ltr(p.paperId) })}
onClick={() => removePaper(p.paperId)}
disabled={finishing}
>
@@ -487,13 +488,13 @@ export function Onboarding({
{!researchProfileValid && (
<p className="onb-profile-hint text-accent-red text-sm mt-2 mx-0 mb-0">
{researchAreas.length === 0
? "Choose at least one research area to continue."
: "Describe your research area to continue."}
? m.onboarding_choose_area_to_continue()
: m.onboarding_describe_area_to_continue()}
</p>
)}
<div className="onb-actions flex items-center gap-2.5 mt-5.5">
<button className={GHOST_BUTTON_CLASS_NAME} onClick={() => setStep(1)} disabled={finishing}>
<ArrowLeft size={12} /> Back
<ArrowLeft size={12} /> {m.onboarding_back()}
</button>
<div style={{ flex: 1 }} />
<button
@@ -503,18 +504,18 @@ export function Onboarding({
>
{finishing ? (
<>
<span className={SPINNER_CLASS_NAME} /> Setting things up…
<span className={SPINNER_CLASS_NAME} /> {m.onboarding_setting_things_up()}
</>
) : (
<>
Get started <ArrowRight size={13} />
{m.onboarding_get_started()} <ArrowRight size={13} />
</>
)}
</button>
</div>
{preferredHarness === null && (
<p className={FINISH_ERROR_CLASS_NAME}>
Your selected agent is no longer ready. Go back to Step 1 and choose another.
{m.onboarding_your_selected_agent_is_no_longer_ready_go()}
</p>
)}
{finishError && <p className={FINISH_ERROR_CLASS_NAME}>{finishError}</p>}
@@ -525,20 +526,6 @@ export function Onboarding({
);
}
function InlineLitSource({ source }: { source: LitSource }) {
return (
<span className="whitespace-nowrap">
<LitSourceLogo
source={source}
size={18}
decorative
className="mr-1 inline-flex align-[-0.15em] !rounded-none !bg-transparent !p-0 !shadow-none"
/>
{LIT_SOURCE_NAME[source]}
</span>
);
}
/** Fast-search titles carry scrape cruft: "[1706.03762] Title - arXiv".
* Kept in sync with NewProjectForm's cleanTitle. */
function cleanPaperTitle(title: string): string {
@@ -548,13 +535,13 @@ function cleanPaperTitle(title: string): string {
/** Agent notes carry the command to run in backticks (`claude auth login`) —
* render those spans as code so they read as something to type, not prose. */
function agentBadge(h: Harness): { cls: string; label: string } {
if (h.agentReady) return { cls: "st-done", label: "Signed in" };
if (!h.installed) return { cls: "st-idle", label: "Not detected" };
if (h.installBroken) return { cls: "st-starting", label: "Install broken" };
if (h.authState === "unknown") return { cls: "st-starting", label: "Unable to verify" };
if (h.authState === "unsupported") return { cls: "st-starting", label: "Update required" };
if (h.installed) return { cls: "st-starting", label: "Not signed in" };
return { cls: "st-idle", label: "Not detected" };
if (h.agentReady) return { cls: "st-done", label: m.onboarding_signed_in() };
if (!h.installed) return { cls: "st-idle", label: m.onboarding_not_detected() };
if (h.installBroken) return { cls: "st-starting", label: m.onboarding_install_broken() };
if (h.authState === "unknown") return { cls: "st-starting", label: m.onboarding_unable_to_verify() };
if (h.authState === "unsupported") return { cls: "st-starting", label: m.onboarding_update_required() };
if (h.installed) return { cls: "st-starting", label: m.onboarding_not_signed_in() };
return { cls: "st-idle", label: m.onboarding_not_detected() };
}
function selectionFor(harness: Harness): AgentSelection {
@@ -581,7 +568,7 @@ function AgentCard({
onSelect: () => void;
}) {
const badge = agentBadge(h);
const visibleBadge = selected ? { cls: "st-done", label: "Selected" } : badge;
const visibleBadge = selected ? { cls: "st-done", label: m.onboarding_selected() } : badge;
const version = h.version?.replace(/\s*\(.*\)$/, "");
const meta = [
version,
@@ -609,7 +596,7 @@ function AgentCard({
// disabled button, so the copy button on its `agentNote` command stays live.
if (!h.agentReady) {
return (
<div className="onb-card flex flex-col gap-2.5 bg-background border border-border rounded-lg py-5.5 px-6 onb-agent-choice w-full text-inherit [font:inherit] text-left transition-[border-color,box-shadow] duration-120 ease-standard [button&]:cursor-pointer [button&:hover]:border-muted [&.selected]:border-accent [&.selected]:shadow-[0_0_0_1px_var(--accent)]">
<div className="onb-card flex flex-col gap-2.5 bg-background border border-border rounded-lg py-5.5 px-6 onb-agent-choice w-full text-inherit [font:inherit] text-start transition-[border-color,box-shadow] duration-120 ease-standard [button&]:cursor-pointer [button&:hover]:border-muted [&.selected]:border-accent [&.selected]:shadow-[0_0_0_1px_var(--accent)]">
{head}
<div className={ONB_CARD_META_CLASS_NAME}>{renderNote(h.agentNote)}</div>
</div>
@@ -618,13 +605,13 @@ function AgentCard({
return (
<button
type="button"
className={`onb-card flex flex-col gap-2.5 bg-background border border-border rounded-lg py-5.5 px-6 onb-agent-choice w-full text-inherit [font:inherit] text-left transition-[border-color,box-shadow] duration-120 ease-standard [button&]:cursor-pointer [button&:hover]:border-muted [&.selected]:border-accent [&.selected]:shadow-[0_0_0_1px_var(--accent)]${selected ? " selected" : ""}`}
className={`onb-card flex flex-col gap-2.5 bg-background border border-border rounded-lg py-5.5 px-6 onb-agent-choice w-full text-inherit [font:inherit] text-start transition-[border-color,box-shadow] duration-120 ease-standard [button&]:cursor-pointer [button&:hover]:border-muted [&.selected]:border-accent [&.selected]:shadow-[0_0_0_1px_var(--accent)]${selected ? " selected" : ""}`}
aria-pressed={selected}
onClick={onSelect}
>
{head}
<div className={`onb-card-detail ${MONO_CLASS_NAME}`}>
{h.account ?? "API key"}
{h.account ?? m.onboarding_api_key()}
{h.plan ? ` · ${h.plan}` : ""}
</div>
<div
@@ -647,16 +634,16 @@ function LocalGitCard({
return (
<div className={ONB_CARD_CLASS_NAME}>
<div className="onb-card-head flex items-center justify-between gap-3">
<span className="onb-card-name font-semibold text-base">Local Git</span>
<span className="onb-card-name font-semibold text-base">{m.onboarding_local_git()}</span>
<span
className={`${STATUS_BADGE_CLASS_NAME} ${gitVersion ? "st-done" : error || gitVersion === null ? "st-failed" : "st-starting"}`}
>
{gitVersion ? <Check size={12} strokeWidth={3} /> : <span className="dot" />}
{gitVersion ? "Ready" : error ? "Check failed" : gitVersion === null ? "Not found" : "Checking"}
{gitVersion ? m.onboarding_ready() : error ? m.onboarding_check_failed() : gitVersion === null ? m.onboarding_not_found() : m.onboarding_checking()}
</span>
</div>
{(gitVersion || (!error && gitVersion === undefined)) && (
<div className={ONB_CARD_META_CLASS_NAME}>{gitVersion ?? "Checking Git…"}</div>
<div className={ONB_CARD_META_CLASS_NAME}>{gitVersion ?? m.onboarding_checking_git()}</div>
)}
</div>
);
+28 -28
View File
@@ -1,3 +1,4 @@
import { m } from "../paraglide/messages.js";
// Overleaf controls for a .tex tab, shown as a note strip under the file header
// like the compile log and the install hint beside it.
//
@@ -10,31 +11,31 @@
import { useState } from "react";
import { ExternalLink } from "lucide-react";
import type { OverleafSync } from "../useOverleafSync";
import { getLocale } from "../paraglide/runtime.js";
import { autoDir, ltr } from "../i18n";
import { BUTTON_CLASS_NAME, GHOST_BUTTON_CLASS_NAME, SPINNER_CLASS_NAME } from "../styleClasses";
const list = (paths: string[]) => paths.join(", ");
const list = (paths: string[]) => autoDir(new Intl.ListFormat(getLocale()).format(paths.map(ltr)));
/** What the last sync did. A failure outranks everything below it, so the line
* never reads "in step" above a red one saying otherwise; conflicts carry their
* own rows. */
function status(overleaf: OverleafSync): string {
if (overleaf.error) return "The last sync did not finish.";
if (overleaf.syncing) return "Syncing with Overleaf…";
if (overleaf.blocked) return "Save this file to sync it with Overleaf.";
if (overleaf.error) return m.overleaf_last_sync_failed();
if (overleaf.syncing) return m.overleaf_syncing();
if (overleaf.blocked) return m.overleaf_save_to_sync();
const last = overleaf.last;
if (!last) return "This paper stays in step with Overleaf.";
const moved: string[] = [];
if (last.pulled.length) moved.push(`pulled ${list(last.pulled)}`);
if (last.pushed.length) moved.push(`pushed ${list(last.pushed)}`);
if (!moved.length) return last.conflicts.length ? "Nothing could be synced." : "In step with Overleaf.";
const sentence = moved.join(", ");
return `${sentence[0].toUpperCase()}${sentence.slice(1)}.`;
if (!last) return m.overleaf_paper_stays_in_sync();
if (last.pulled.length && last.pushed.length) return m.overleaf_pulled_and_pushed({ pulled: list(last.pulled), pushed: list(last.pushed) });
if (last.pulled.length) return m.overleaf_pulled({ paths: list(last.pulled) });
if (last.pushed.length) return m.overleaf_pushed({ paths: list(last.pushed) });
return last.conflicts.length ? m.overleaf_nothing_synced() : m.overleaf_in_sync();
}
function UploadLink({ href }: { href: string }) {
return (
<a className="text-sm text-subtext whitespace-nowrap" href={href} target="_blank" rel="noreferrer">
Upload a copy as a new project ↗
{m.overleaf_panel_upload_a_copy_as_a_new_project()}
</a>
);
}
@@ -85,15 +86,15 @@ export function OverleafPanel({ overleaf }: { overleaf: OverleafSync }) {
target="_blank"
rel="noreferrer"
>
Open in Overleaf <ExternalLink size={11} />
{m.overleaf_panel_open_in_overleaf()} <ExternalLink size={11} />
</a>
<button
className={BUTTON_CLASS_NAME}
disabled={overleaf.syncing || overleaf.blocked}
data-tip={overleaf.blocked ? "Save the file first" : undefined}
data-tip={overleaf.blocked ? m.overleaf_save_first() : undefined}
onClick={() => overleaf.sync()}
>
Sync now
{m.overleaf_panel_sync_now()}
</button>
<button
className={GHOST_BUTTON_CLASS_NAME}
@@ -104,28 +105,27 @@ export function OverleafPanel({ overleaf }: { overleaf: OverleafSync }) {
})
}
>
Unlink
{m.overleaf_panel_unlink()}
</button>
</div>
{conflicts.map((path) => (
<div key={path} className="flex items-center flex-wrap gap-2 text-sm text-accent-red">
<span className="flex-1 min-w-0">
<code className="font-mono">{path}</code> changed here and on Overleaf. Both copies
are untouched — choose which one to keep.
<code className="font-mono">{path}</code> {m.overleaf_panel_changed_here_and_on_overleaf_both_copies_are()}
</span>
<button
className={BUTTON_CLASS_NAME}
disabled={overleaf.syncing || overleaf.blocked}
onClick={() => overleaf.sync({ [path]: "keep-local" })}
>
Keep this copy
{m.overleaf_panel_keep_this_copy()}
</button>
<button
className={BUTTON_CLASS_NAME}
disabled={overleaf.syncing || overleaf.blocked}
onClick={() => overleaf.sync({ [path]: "take-overleaf" })}
>
Use Overleaf&apos;s
{m.overleaf_panel_use_overleaf_apos_s()}
</button>
</div>
))}
@@ -138,7 +138,7 @@ export function OverleafPanel({ overleaf }: { overleaf: OverleafSync }) {
<div className="flex items-center flex-wrap gap-3">
<UploadLink href={overleaf.uploadUrl} />
<button type="button" className={GHOST_BUTTON_CLASS_NAME} onClick={replaceToken}>
Replace the Overleaf token
{m.overleaf_panel_replace_the_overleaf_token()}
</button>
</div>
</div>
@@ -149,8 +149,8 @@ export function OverleafPanel({ overleaf }: { overleaf: OverleafSync }) {
<form className="flex flex-col gap-1.5" onSubmit={submit}>
<div className="text-sm text-subtext">
{needsToken
? "Paste an Overleaf Git authentication token to keep this paper in step with an Overleaf project. Create one in Overleaf under Account Settings — Git integration comes with a paid Overleaf plan."
: "Paste the URL of the Overleaf project this paper belongs to. Overleaf cannot create one over Git, so open or create the project there first."}
? m.overleaf_token_instructions()
: m.overleaf_url_instructions()}
</div>
<div className="flex items-center flex-wrap gap-2">
<input
@@ -158,11 +158,11 @@ export function OverleafPanel({ overleaf }: { overleaf: OverleafSync }) {
type={needsToken ? "password" : "text"}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={needsToken ? "Overleaf Git token" : "https://www.overleaf.com/project/…"}
placeholder={needsToken ? m.overleaf_git_token() : "https://www.overleaf.com/project/…"}
autoComplete="off"
/>
<button type="submit" className={BUTTON_CLASS_NAME} disabled={busy || !value.trim()}>
{busy ? (needsToken ? "Saving…" : "Checking…") : needsToken ? "Save token" : "Link and sync"}
{busy ? (needsToken ? m.common_saving() : m.common_checking()) : needsToken ? m.overleaf_save_token() : m.overleaf_link_and_sync()}
</button>
<a
className="text-sm text-subtext whitespace-nowrap"
@@ -174,7 +174,7 @@ export function OverleafPanel({ overleaf }: { overleaf: OverleafSync }) {
target="_blank"
rel="noreferrer"
>
{needsToken ? "Create a token ↗" : "My projects ↗"}
{needsToken ? m.overleaf_create_token() : m.overleaf_my_projects()}
</a>
</div>
{formError && <div className="text-sm text-accent-red whitespace-pre-wrap">{formError}</div>}
@@ -186,14 +186,14 @@ export function OverleafPanel({ overleaf }: { overleaf: OverleafSync }) {
className={GHOST_BUTTON_CLASS_NAME}
onClick={() => setReplacingToken(false)}
>
Cancel
{m.overleaf_panel_cancel()}
</button>
) : (
// A token can be rejected before this paper is ever linked, so the
// way to replace it has to live in this state too.
overleaf.hasToken && (
<button type="button" className={GHOST_BUTTON_CLASS_NAME} onClick={replaceToken}>
Replace the Overleaf token
{m.overleaf_panel_replace_the_overleaf_token()}
</button>
)
)}
+23 -18
View File
@@ -1,3 +1,5 @@
import { m } from "../paraglide/messages.js";
import { ltr } from "../i18n";
import { ChevronDown, CornerDownLeft, ScrollText } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { tabOpenGestureHandlers, type TabOpenIntent } from "../tabPreview";
@@ -28,10 +30,10 @@ const PROMPT_ACTIONS_CLASS_NAME = [
"[&_.plan-strip-primary:hover:not(:disabled)]:bg-[color-mix(in_oklab,_var(--text)_85%,_var(--base))]",
"[&_.plan-strip-primary:hover:not(:disabled)]:border-text",
"[&_.plan-strip-primary:hover:not(:disabled)]:text-background",
"[&_.plan-strip-caret]:rounded-tl-none [&_.plan-strip-caret]:rounded-bl-none",
"[&_.plan-strip-caret]:rounded-ss-none [&_.plan-strip-caret]:rounded-es-none",
"[&_.plan-strip-caret]:py-0 [&_.plan-strip-caret]:px-1.5 [&_.plan-strip-caret]:flex",
"[&_.plan-strip-caret]:items-center",
"[&_.plan-strip-caret]:border-l [&_.plan-strip-caret]:border-l-[color-mix(in_oklab,_var(--base)_35%,_var(--text))]",
"[&_.plan-strip-caret]:border-s [&_.plan-strip-caret]:border-s-[color-mix(in_oklab,_var(--base)_35%,_var(--text))]",
].join(" ");
/** Docked strip above the composer while a plan awaits the user's decision.
@@ -48,7 +50,7 @@ const PROMPT_ACTIONS_CLASS_NAME = [
* default accept action. The caret menu holds Accept and bypass all
* (skip every gate, not just Auto's). No plain Accept edits tier here —
* the app has no story for partial (edits-only) approval.
* - Open plan: link in the title row → the right-pane plan tab. */
* - Open plan: link in the title row → the end-pane plan tab. */
export function PlanStrip({
synthesized,
agentLabel,
@@ -105,25 +107,28 @@ export function PlanStrip({
};
return (
<div className="plan-strip relative w-full mt-0 mx-0 mb-2.5 py-[11px] px-[13px] flex flex-col items-stretch gap-2.5 border border-border border-l-[3px] border-l-accent-blue rounded-md bg-surface shadow-[0_2px_10px_rgb(0_0_0_/_6%)]">
<div className="plan-strip relative w-full mt-0 mx-0 mb-2.5 py-[11px] px-[13px] flex flex-col items-stretch gap-2.5 border border-border border-s-[3px] border-s-accent-blue rounded-md bg-surface shadow-[0_2px_10px_rgb(0_0_0_/_6%)]">
<div className="plan-strip-info flex items-baseline gap-2 min-w-0">
<ScrollText size={14} className="plan-strip-icon text-accent-blue shrink-0 self-center" />
<span className="plan-strip-title text-md font-semibold whitespace-nowrap">
{synthesized ? `${agentLabel} is ready to proceed` : `${agentLabel} proposed a plan`}
<span dir="auto" className="plan-strip-title text-md font-semibold whitespace-nowrap">
{synthesized
? m.plan_strip_agent_ready({ agent: ltr(agentLabel) })
: m.plan_strip_agent_proposed({ agent: ltr(agentLabel) })}
</span>
<button
className="plan-strip-open ml-auto p-0 border-0 bg-none bg-transparent text-accent-blue text-md cursor-pointer whitespace-nowrap shrink-0 [&:hover]:underline"
className="plan-strip-open ms-auto p-0 border-0 bg-none bg-transparent text-accent-blue text-md cursor-pointer whitespace-nowrap shrink-0 [&:hover]:underline"
{...tabOpenGestureHandlers<HTMLButtonElement>(onView)}
>
Open plan
{m.plan_strip_open_plan()}
</button>
</div>
{revising ? (
<>
<textarea
dir="auto"
ref={textareaRef}
className="plan-strip-revise-input w-full resize-none border border-border rounded-md py-[9px] px-[11px] text-md font-[inherit] bg-background text-text [&:focus]:border-accent-blue"
placeholder="What should change? (optional)"
placeholder={m.plan_strip_what_should_change_optional()}
rows={2}
value={note}
onChange={(e) => setNote(e.target.value)}
@@ -146,11 +151,11 @@ export function PlanStrip({
setRevising(false);
}}
>
Back
{m.plan_strip_back()}
</button>
<span className="plan-strip-spacer flex-1" />
<button className="btn-primary plan-strip-primary" onClick={submitRevision}>
Revise
{m.plan_strip_revise()}
<CornerDownLeft size={13} />
</button>
</div>
@@ -158,40 +163,40 @@ export function PlanStrip({
) : (
<div className={PROMPT_ACTIONS_CLASS_NAME}>
<button className="btn-ghost" onClick={onReject}>
Reject
{m.plan_strip_reject()}
</button>
<button className="btn-ghost" onClick={() => setRevising(true)}>
Revise…
{m.plan_strip_revise_05bacc9()}
</button>
<span className="plan-strip-spacer flex-1" />
{showResumeModes ? (
<div className="plan-strip-approve relative flex [&_.btn-primary:first-child]:rounded-tr-none [&_.btn-primary:first-child]:rounded-br-none" ref={menuRef}>
<button className="btn-primary plan-strip-primary" onClick={() => onApprove("auto")}>
Accept and auto mode
{m.plan_strip_accept_and_auto_mode()}
</button>
<button
className="btn-primary plan-strip-primary plan-strip-caret"
aria-label="More approval options"
aria-label={m.plan_strip_more_approval_options()}
onClick={() => setMenuOpen((o) => !o)}
>
<ChevronDown size={13} />
</button>
{menuOpen && (
<div className="plan-strip-menu absolute right-0 bottom-[calc(100%_+_4px)] flex flex-col min-w-47.5 p-1 border border-border rounded-md bg-surface shadow-[0_6px_20px_rgb(0_0_0_/_12%)] z-6 [&_button]:text-left [&_button]:py-[7px] [&_button]:px-[9px] [&_button]:border-0 [&_button]:rounded-sm [&_button]:bg-transparent [&_button]:text-text [&_button]:text-md [&_button]:cursor-pointer [&_button:hover]:bg-[var(--surface-2,_rgb(0_0_0_/_5%))]">
<div className="plan-strip-menu absolute end-0 bottom-[calc(100%_+_4px)] flex flex-col min-w-47.5 p-1 border border-border rounded-md bg-surface shadow-[0_6px_20px_rgb(0_0_0_/_12%)] z-6 [&_button]:text-start [&_button]:py-[7px] [&_button]:px-[9px] [&_button]:border-0 [&_button]:rounded-sm [&_button]:bg-transparent [&_button]:text-text [&_button]:text-md [&_button]:cursor-pointer [&_button:hover]:bg-[var(--surface-2,_rgb(0_0_0_/_5%))]">
<button
onClick={() => {
setMenuOpen(false);
onApprove("bypassPermissions");
}}
>
Accept and bypass all
{m.plan_strip_accept_and_bypass_all()}
</button>
</div>
)}
</div>
) : (
<button className="btn-primary plan-strip-primary" onClick={() => onApprove()}>
Accept plan
{m.plan_strip_accept_plan()}
</button>
)}
</div>
+36 -34
View File
@@ -1,8 +1,11 @@
import { m } from "../paraglide/messages.js";
import { autoDir, ltr } from "../i18n";
import { Plus, Trash2 } from "lucide-react";
import { GitHubMark } from "./BackendLogos";
import { useEffect, useRef, useState } from "react";
import {
deleteProject,
fmtNumber,
listProjectActivity,
timeAgo,
type Project,
@@ -90,7 +93,7 @@ export function NewProjectDialog({
aria-labelledby="new-project-dialog-title"
tabIndex={-1}
>
<h2 id="new-project-dialog-title">New project</h2>
<h2 id="new-project-dialog-title">{m.projects_home_new_project()}</h2>
<NewProjectForm onCancel={onClose} onCreated={onCreated} />
</div>
</div>
@@ -175,21 +178,20 @@ function DeleteProjectDialog({
aria-describedby="delete-project-dialog-description"
tabIndex={-1}
>
<h2 id="delete-project-dialog-title" className="mt-0 mb-3 text-xl">Delete project?</h2>
<h2 id="delete-project-dialog-title" className="mt-0 mb-3 text-xl">{m.projects_home_delete_project()}</h2>
<div id="delete-project-dialog-description" className="flex flex-col gap-2 text-md leading-normal text-subtext">
<p className="m-0">
Delete <strong className="font-semibold text-text">{project.name}</strong> from OpenResearch?
Its experiments, runs, and chats will be permanently removed.
{m.projects_delete_from_app({ name: autoDir(project.name) })}
</p>
<p className="m-0">
The local folder{hasSyncedRepository ? " and linked GitHub repository are" : " is"} kept.
{hasSyncedRepository ? m.projects_home_local_and_github_kept() : m.projects_home_local_folder_kept()}
</p>
{error && <p className="m-0 text-accent-red" role="alert">{error}</p>}
</div>
<div className="mt-5 flex justify-end gap-2">
<button className={BUTTON_CLASS_NAME} disabled={deleting} onClick={onClose}>Cancel</button>
<button className={BUTTON_CLASS_NAME} disabled={deleting} onClick={onClose}>{m.projects_home_cancel()}</button>
<button className={`${BUTTON_CLASS_NAME} danger`} disabled={deleting} onClick={onConfirm}>
{deleting ? "Deleting…" : "Delete project"}
{deleting ? m.projects_home_deleting() : m.projects_home_delete_project_action()}
</button>
</div>
</div>
@@ -271,24 +273,24 @@ export function ProjectsHome({
<div className="home flex-1 min-h-0 overflow-y-auto [scrollbar-gutter:stable_both-edges] bg-canvas">
<div className="home-inner max-w-290 my-0 mx-auto pt-12 px-6 pb-16 [@media((max-width:_960px))]:pt-6 [@media((max-width:_960px))]:px-4">
<div className="home-head flex items-center justify-between gap-3 mb-4.5 [&_h2]:m-0 [&_h2]:text-4xl [&_h2]:tracking-[-0.02em] [@media((max-width:_520px))]:items-start [@media((max-width:_520px))]:flex-col">
<h2>Projects</h2>
<h2>{m.projects_home_projects()}</h2>
<button
className={BUTTON_CLASS_NAME}
onClick={() => setModalOpen(true)}
>
<Plus size={15} /> New project
<Plus size={15} /> {m.projects_home_new_project()}
</button>
</div>
<div className="home-list overflow-hidden rounded-lg border border-border bg-background">
<div>
<div className="grid grid-cols-[minmax(0,1fr)_9rem_9rem_minmax(18rem,max-content)] items-center gap-3 border-b border-border bg-background py-2.5 pl-4 pr-2 text-2xs font-medium tracking-[0.06em] text-text uppercase [@media((max-width:_960px))]:hidden">
<span>Project</span>
<span>Agents</span>
<span>Experiments</span>
<span>Repository</span>
<div className="grid grid-cols-[minmax(0,1fr)_9rem_9rem_minmax(18rem,max-content)] items-center gap-3 border-b border-border bg-background py-2.5 ps-4 pe-2 text-2xs font-medium tracking-[0.06em] text-text uppercase [@media((max-width:_960px))]:hidden">
<span>{m.projects_home_project()}</span>
<span>{m.projects_home_agents()}</span>
<span>{m.projects_home_experiments()}</span>
<span>{m.projects_home_repository()}</span>
</div>
{projects.length === 0 ? (
<div className="py-8 px-4 text-sm text-muted">No projects yet — create one to get started.</div>
<div className="py-8 px-4 text-sm text-muted">{m.projects_home_no_projects_yet_create_one_to_get_started()}</div>
) : (
[...projects].sort((a, b) => {
const aActivity = activityByProject[a.id]?.lastMessageAt ?? a.createdAt;
@@ -309,46 +311,46 @@ export function ProjectsHome({
.replace(/^https?:\/\/github\.com\//, "")
.replace(/\.git$/, "")
.replace(/\/$/, "")
: "Local";
: m.projects_local();
const agentsLabel = summary
? summary.activeAgents > 0
? `${summary.activeAgents} active`
: "Idle"
? m.projects_active_count({ count: fmtNumber(summary.activeAgents) })
: m.projects_idle()
: "—";
const agentTotal = summary
? `${summary.totalAgents} total agent${summary.totalAgents === 1 ? "" : "s"}`
? summary.totalAgents === 1 ? m.projects_one_agent() : m.projects_agents_count({ count: fmtNumber(summary.totalAgents) })
: "—";
const experimentLabel = !summary
? "—"
: summary.runningExperiments > 0
? `${summary.runningExperiments} running`
? m.projects_running_count({ count: fmtNumber(summary.runningExperiments) })
: summary.totalExperiments === 0
? "None"
: `${summary.totalExperiments} total`;
? m.settings_none()
: m.projects_total_count({ count: fmtNumber(summary.totalExperiments) });
const experimentTotal =
summary && summary.runningExperiments > 0
? `${summary.totalExperiments} total`
? m.projects_total_count({ count: fmtNumber(summary.totalExperiments) })
: null;
return (
<div
key={p.id}
className="group project-row relative grid cursor-pointer grid-cols-[minmax(0,1fr)_9rem_9rem_minmax(18rem,max-content)] items-center gap-3 border-b border-border-variant py-4 pl-4 pr-2 text-left transition-colors duration-120 ease-standard last:border-b-0 hover:bg-surface-bright focus-within:bg-surface-bright [@media((max-width:_960px))]:grid-cols-[minmax(0,0.8fr)_minmax(0,0.8fr)_minmax(0,1.4fr)] [@media((max-width:_960px))]:items-start [@media((max-width:_960px))]:gap-x-4 [@media((max-width:_960px))]:gap-y-3 [@media((max-width:_960px))]:py-4 [@media((max-width:_960px))]:px-4 [@media((max-width:_600px))]:grid-cols-2"
className="group project-row relative grid cursor-pointer grid-cols-[minmax(0,1fr)_9rem_9rem_minmax(18rem,max-content)] items-center gap-3 border-b border-border-variant py-4 ps-4 pe-2 text-start transition-colors duration-120 ease-standard last:border-b-0 hover:bg-surface-bright focus-within:bg-surface-bright [@media((max-width:_960px))]:grid-cols-[minmax(0,0.8fr)_minmax(0,0.8fr)_minmax(0,1.4fr)] [@media((max-width:_960px))]:items-start [@media((max-width:_960px))]:gap-x-4 [@media((max-width:_960px))]:gap-y-3 [@media((max-width:_960px))]:py-4 [@media((max-width:_960px))]:px-4 [@media((max-width:_600px))]:grid-cols-2"
>
<button
className="project-row-open absolute inset-0 z-0 cursor-pointer rounded-[inherit] focus-visible:outline focus-visible:outline-2 focus-visible:outline-text focus-visible:outline-offset-[-2px]"
aria-label={`Open ${p.name}`}
aria-label={m.a11y_open_item({ name: autoDir(p.name) })}
onClick={() => onOpen(p.id)}
/>
{/* Cells stay click-transparent so the stretched button owns row navigation. */}
<div className="relative z-1 flex min-w-0 flex-col gap-1 pointer-events-none [@media((max-width:_960px))]:col-span-3 [@media((max-width:_600px))]:col-span-2">
<span className="project-row-title whitespace-normal break-words text-base font-semibold text-text pointer-events-none">{p.name}</span>
<span dir="auto" className="project-row-title whitespace-normal break-words text-base font-semibold text-text pointer-events-none">{p.name}</span>
<span className="relative z-2 flex items-center gap-1.5 text-xs text-muted [@media((max-width:_960px))]:flex-wrap">
<span>Created {timeAgo(p.createdAt)}</span>
<span>{m.projects_home_created()} {timeAgo(p.createdAt)}</span>
{p.paperId && <span aria-hidden="true">·</span>}
{p.paperId && <span>arXiv paper ID: {p.paperId}</span>}
{p.paperId && <span>{m.projects_home_ar_xiv_paper_id()} {ltr(p.paperId)}</span>}
<button
className="project-row-secondary project-row-delete inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-sm leading-0 text-muted opacity-0 pointer-events-none transition-opacity hover:bg-surface hover:text-accent-red group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto focus:opacity-100 focus:pointer-events-auto focus-visible:outline focus-visible:outline-2 focus-visible:outline-text"
aria-label={`Delete ${p.name}`}
aria-label={m.a11y_delete_item({ name: autoDir(p.name) })}
disabled={deleting === p.id}
onClick={(event) => {
event.stopPropagation();
@@ -361,7 +363,7 @@ export function ProjectsHome({
</span>
</div>
<div className="relative z-1 flex min-w-0 flex-col gap-1 pointer-events-none">
<span className="hidden text-2xs font-medium tracking-[0.06em] text-text uppercase [@media((max-width:_960px))]:block">Agents</span>
<span className="hidden text-2xs font-medium tracking-[0.06em] text-text uppercase [@media((max-width:_960px))]:block">{m.projects_home_agents()}</span>
<span className="inline-flex items-center gap-2 text-md text-text">
{summary && summary.activeAgents > 0 && (
<LiveDot />
@@ -371,7 +373,7 @@ export function ProjectsHome({
<span className="text-xs text-muted">{agentTotal}</span>
</div>
<div className="relative z-1 flex min-w-0 flex-col gap-1 pointer-events-none">
<span className="hidden text-2xs font-medium tracking-[0.06em] text-text uppercase [@media((max-width:_960px))]:block">Experiments</span>
<span className="hidden text-2xs font-medium tracking-[0.06em] text-text uppercase [@media((max-width:_960px))]:block">{m.projects_home_experiments()}</span>
<span className="inline-flex items-center gap-2 text-md text-text">
{summary && summary.runningExperiments > 0 && (
<LiveDot />
@@ -381,17 +383,17 @@ export function ProjectsHome({
{experimentTotal && <span className="text-xs text-muted">{experimentTotal}</span>}
</div>
<div className="relative z-1 min-w-0 pointer-events-none [@media((max-width:_600px))]:col-span-2">
<span className="hidden text-2xs font-medium tracking-[0.06em] text-text uppercase [@media((max-width:_960px))]:mb-1 [@media((max-width:_960px))]:block">Repository</span>
<span className="hidden text-2xs font-medium tracking-[0.06em] text-text uppercase [@media((max-width:_960px))]:mb-1 [@media((max-width:_960px))]:block">{m.projects_home_repository()}</span>
{githubUrl ? (
<a
className="project-row-secondary inline-flex max-w-full items-center gap-2 text-sm text-text no-underline pointer-events-auto hover:underline underline-offset-2"
href={githubUrl}
target="_blank"
rel="noreferrer"
aria-label={`Open ${p.name} on GitHub`}
aria-label={m.a11y_open_on_github({ name: autoDir(p.name) })}
>
<span className="inline-flex shrink-0"><GitHubMark size={14} /></span>
<span className="overflow-hidden text-ellipsis whitespace-nowrap [@media((max-width:_960px))]:whitespace-normal [@media((max-width:_960px))]:break-all">{githubState}</span>
<span className="overflow-hidden text-ellipsis whitespace-nowrap [@media((max-width:_960px))]:whitespace-normal [@media((max-width:_960px))]:break-all">{ltr(githubState)}</span>
</a>
) : (
<span className="text-sm text-text pointer-events-none">{githubState}</span>
File diff suppressed because it is too large Load Diff
+9 -6
View File
@@ -1,3 +1,4 @@
import { m } from "../paraglide/messages.js";
import {
Fragment,
useEffect,
@@ -26,6 +27,8 @@ const MIRRORED_PROPERTIES = [
"letter-spacing",
"word-spacing",
"text-transform",
"direction",
"unicode-bidi",
"tab-size",
"padding-top",
"padding-right",
@@ -172,7 +175,7 @@ function ComposerSkillToken({
tabIndex={0}
aria-controls={cardId}
aria-expanded={open}
aria-label={`Preview /${name} skill`}
aria-label={m.a11y_preview_skill({ name })}
className="composer-chip group/skill pointer-events-auto relative z-1 cursor-text rounded-md bg-background text-[var(--skill-blue)]"
onMouseEnter={show}
onMouseLeave={scheduleClose}
@@ -222,7 +225,7 @@ function ComposerSkillToken({
id={cardId}
ref={cardRef}
role="dialog"
aria-label={`${name} skill`}
aria-label={m.a11y_skill({ name })}
style={{
...position,
maxHeight: "min(28rem, calc(100vh - 2rem))",
@@ -237,12 +240,12 @@ function ComposerSkillToken({
<div className="sticky top-0 z-1 flex items-center gap-2 border-b border-border-variant bg-background px-4 py-3">
<span className="text-md font-medium text-muted">/{name}</span>
<span className="inline-flex h-4 items-center rounded-full border border-border-variant bg-canvas px-1.5 text-2xs font-semibold tracking-[0.05em] text-muted">
SKILL
{m.skill_chips_badge()}
</span>
</div>
<div className="p-4 text-sm text-text">
{loading && content === null ? (
<span className="text-muted">Loading skill…</span>
<span className="text-muted">{m.skill_chips_loading_skill()}</span>
) : (
<Md text={content ?? skill.description} />
)}
@@ -316,7 +319,7 @@ export function ComposerSkillChips({
const observer = new ResizeObserver(sync);
observer.observe(textarea);
return () => observer.disconnect();
}, [textareaRef]);
}, [text, textareaRef]);
// The chips ride the textarea's own scrolling — caret-driven (no scroll event
// on the frame the text changes) as well as user-driven.
@@ -334,7 +337,7 @@ export function ComposerSkillChips({
return (
<div
ref={mirrorRef}
className="composer-chips pointer-events-none absolute inset-y-0 left-0 z-2 box-border overflow-hidden whitespace-pre-wrap break-words border-solid border-transparent text-transparent select-none"
className="composer-chips pointer-events-none absolute inset-y-0 start-0 z-2 box-border overflow-hidden whitespace-pre-wrap break-words border-solid border-transparent text-transparent select-none"
>
{/* Hover padding is painted outside the mirrored text box so it cannot
* shift the textarea's following glyphs. */}
+2 -2
View File
@@ -14,12 +14,12 @@ export function SkillMenu({
onHover: (index: number) => void;
}) {
return (
<div className="skill-menu absolute bottom-[calc(100%_+_8px)] left-0 min-w-85 max-w-full p-1.5 bg-background border border-border rounded-lg shadow-[0_12px_32px_rgba(0,_0,_0,_0.18)] z-50 overflow-hidden">
<div className="skill-menu absolute bottom-[calc(100%_+_8px)] start-0 min-w-85 max-w-full p-1.5 bg-background border border-border rounded-lg shadow-[0_12px_32px_rgba(0,_0,_0,_0.18)] z-50 overflow-hidden">
{skills.map((s, i) => (
<button
key={s.name}
type="button"
className={`skill-item flex flex-col gap-0.5 w-full text-left py-[7px] px-2 rounded-sm [&.active]:bg-surface [&_.skill-name]:text-md [&_.skill-desc]:text-sm [&_.skill-desc]:text-subtext ${i === activeIndex ? "active" : ""}`}
className={`skill-item flex flex-col gap-0.5 w-full text-start py-[7px] px-2 rounded-sm [&.active]:bg-surface [&_.skill-name]:text-md [&_.skill-desc]:text-sm [&_.skill-desc]:text-subtext ${i === activeIndex ? "active" : ""}`}
// mousedown + preventDefault keeps the textarea focused.
onMouseDown={(e) => {
e.preventDefault();
+54 -64
View File
@@ -1,9 +1,12 @@
import { m } from "../paraglide/messages.js";
import { ltr } from "../i18n";
import { Download, FileUp, Trash2, Upload } from "lucide-react";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import {
deleteLatexTemplate,
deleteUserSkill,
fmtBytes,
fmtNumber,
importHarnessSkill,
listHarnessSkills,
listLatexTemplates,
@@ -58,7 +61,7 @@ function isAcceptedName(name: string): boolean {
}
const SCOPE_BUTTON_CLASS_NAME =
"flex-1 flex flex-col gap-0.5 py-2.5 px-3 border rounded-md text-left text-sm font-medium cursor-pointer transition-[border-color,background] duration-120 disabled:opacity-50 disabled:cursor-not-allowed";
"flex-1 flex flex-col gap-0.5 py-2.5 px-3 border rounded-md text-start text-sm font-medium cursor-pointer transition-[border-color,background] duration-120 disabled:opacity-50 disabled:cursor-not-allowed";
function ScopePicker({
scope,
@@ -85,20 +88,20 @@ function ScopePicker({
className={cls(scope === "global")}
onClick={() => onScope("global")}
>
Global
<span className="text-2xs font-normal text-muted">Every project</span>
{m.skills_tab_global()}
<span className="text-2xs font-normal text-muted">{m.skills_tab_every_project()}</span>
</button>
<button
type="button"
aria-pressed={scope === "project"}
className={cls(scope === "project")}
disabled={!project}
title={project ? undefined : "Open a project to scope this to one project"}
title={project ? undefined : m.skills_open_project_to_scope()}
onClick={() => onScope("project")}
>
This project
{m.skills_tab_this_project()}
<span className="text-2xs font-normal text-muted">
{project ? project.name : "No project open"}
{project ? project.name : m.skills_no_project_open()}
</span>
</button>
</div>
@@ -164,15 +167,15 @@ function DropZone({
{busy ? (
<>
<span className={SPINNER_CLASS_NAME} />
<span>Uploading…</span>
<span>{m.skills_tab_uploading()}</span>
</>
) : (
<>
<Upload size={20} strokeWidth={1.5} />
<span>{prompt}</span>
<span className="inline-flex items-center gap-1.5 text-2xs text-subtext">
<FileUp size={12} /> Adding to{" "}
<strong className="text-text font-semibold">{destination}</strong>
<span dir="auto" className="inline-flex items-center gap-1.5 text-2xs text-subtext">
<FileUp size={12} /> {m.skills_tab_adding_to()}{" "}
<strong dir="auto" className="text-text font-semibold">{destination}</strong>
</span>
</>
)}
@@ -198,7 +201,7 @@ function SkillRow({
<code className={SKILL_NAME_CLASS_NAME}>/{skill.name}</code>
<p className={SKILL_DESC_CLASS_NAME}>{skill.description}</p>
</div>
<div className="shrink-0 text-right whitespace-nowrap pt-0.5">
<div className="shrink-0 text-end whitespace-nowrap pt-0.5">
<div className="text-2xs text-subtext">{fmtBytes(skill.bytes)}</div>
{skill.updatedAt > 0 && (
<div className="text-2xs text-muted">{timeAgo(skill.updatedAt)}</div>
@@ -206,12 +209,12 @@ function SkillRow({
</div>
<button
className={ICON_BUTTON_CLASS_NAME}
data-tip="Delete skill"
data-tip={m.skills_tab_delete_skill()}
data-tip-align="end"
aria-label={`Delete skill ${skill.name}`}
aria-label={m.skills_delete_skill_label({ name: ltr(skill.name) })}
disabled={busy}
onClick={() => {
if (!window.confirm(`Delete the "${skill.name}" skill?`)) return;
if (!window.confirm(m.skills_delete_skill_confirm({ name: ltr(skill.name) }))) return;
setBusy(true);
deleteUserSkill({
scope: skill.scope,
@@ -251,7 +254,7 @@ function SkillList({
<h3>{title}</h3>
<p className={CARD_SUB_CLASS_NAME}>{hint}</p>
{skills.length === 0 ? (
<div className="text-muted text-sm">No skills yet.</div>
<div className="text-muted text-sm">{m.skills_tab_no_skills_yet()}</div>
) : (
<div className="flex flex-col">
{skills.map((s) => (
@@ -293,7 +296,7 @@ function HarnessSkillRow({
<button
className={SMALL_BUTTON_CLASS_NAME}
disabled={busy}
title={`Import into ${scopeLabel}`}
title={m.a11y_import_into({ name: scopeLabel })}
onClick={async () => {
setBusy(true);
try {
@@ -304,7 +307,7 @@ function HarnessSkillRow({
}}
>
{busy ? <span className={SPINNER_CLASS_NAME} /> : <Download size={13} />}
{alreadyImported ? "Re-import" : "Import"}
{alreadyImported ? m.skills_reimport() : m.skills_import()}
</button>
</div>
);
@@ -347,11 +350,11 @@ function LatexTemplatesCard({ project }: { project: Project | null }) {
setError(null);
const lower = file.name.toLowerCase();
if (!lower.endsWith(".tex") && !lower.endsWith(".zip")) {
setError("Upload a .tex file or a .zip of a template folder.");
setError(m.skills_upload_template_error());
return;
}
if (file.size > MAX_UPLOAD_BYTES) {
setError("File too large (max 20 MB).");
setError(m.skills_file_too_large());
return;
}
busyRef.current = true;
@@ -378,25 +381,18 @@ function LatexTemplatesCard({ project }: { project: Project | null }) {
return (
<section className={CARD_CLASS_NAME}>
<h3>LaTeX templates</h3>
<h3>{m.skills_tab_la_te_x_templates()}</h3>
<p className={`${CARD_SUB_CLASS_NAME} [&_code]:font-mono [&_code]:text-[0.92em] [&_code]:text-text`}>
A conference class or house style the agent writes papers into instead of its default
preamble. Upload a <code>.tex</code>, or a <code>.zip</code> carrying its{" "}
<code>.cls</code> and <code>.sty</code> files. With exactly one template available the
agent uses it without asking.
{m.skills_templates_description()}
</p>
<ScopePicker scope={scope} onScope={setScope} project={project} label="Template scope" />
<ScopePicker scope={scope} onScope={setScope} project={project} label={m.skills_template_scope()} />
<DropZone
accept=".tex,.zip"
busy={busy}
destination={scope === "global" ? "Global" : (project?.name ?? "")}
prompt={
<>
Drop a <code>.tex</code> or <code>.zip</code> here, or click to choose
</>
}
destination={scope === "global" ? m.skills_global_destination() : (project?.name ?? "")}
prompt={m.skills_drop_template()}
onFile={(file) => void upload(file)}
/>
@@ -404,14 +400,14 @@ function LatexTemplatesCard({ project }: { project: Project | null }) {
{templates === null ? (
<div className="flex items-center gap-2 text-subtext text-md pt-3">
<span className={SPINNER_CLASS_NAME} /> Loading templates…
<span className={SPINNER_CLASS_NAME} /> {m.skills_tab_loading_templates()}
</div>
) : loadError ? (
<div className="text-accent-red text-sm pt-3">
Could not load templates: {loadError}
{m.skills_tab_could_not_load_templates()} {loadError}
</div>
) : applicable.length === 0 ? (
<div className="text-muted text-sm pt-3">No templates yet.</div>
<div className="text-muted text-sm pt-3">{m.skills_tab_no_templates_yet()}</div>
) : (
<div className="flex flex-col mt-1">
{applicable.map((t) => (
@@ -448,15 +444,15 @@ function LatexTemplateRow({
<div className="flex items-center gap-2">
<code className={SKILL_NAME_CLASS_NAME}>{template.name}</code>
<span className={BADGE_CLASS_NAME}>
{template.scope === "global" ? "Global" : "This project"}
{template.scope === "global" ? m.skills_tab_global() : m.skills_tab_this_project()}
</span>
</div>
<p className={SKILL_DESC_CLASS_NAME}>
{template.entry}
{support > 0 && ` + ${support} file${support === 1 ? "" : "s"}`}
{support > 0 && (support === 1 ? m.skills_one_support_file() : m.skills_support_files({ count: fmtNumber(support) }))}
</p>
</div>
<div className="shrink-0 text-right whitespace-nowrap pt-0.5">
<div className="shrink-0 text-end whitespace-nowrap pt-0.5">
<div className="text-2xs text-subtext">{fmtBytes(template.bytes)}</div>
{template.updatedAt > 0 && (
<div className="text-2xs text-muted">{timeAgo(template.updatedAt)}</div>
@@ -464,12 +460,12 @@ function LatexTemplateRow({
</div>
<button
className={ICON_BUTTON_CLASS_NAME}
data-tip="Delete template"
data-tip={m.skills_tab_delete_template()}
data-tip-align="end"
aria-label={`Delete template ${template.name}`}
aria-label={m.skills_delete_template_label({ name: ltr(template.name) })}
disabled={busy}
onClick={() => {
if (!window.confirm(`Delete the "${template.name}" template?`)) return;
if (!window.confirm(m.skills_delete_template_confirm({ name: ltr(template.name) }))) return;
setBusy(true);
deleteLatexTemplate({
scope: template.scope,
@@ -529,11 +525,11 @@ export function SkillsTab({ project }: { project: Project | null }) {
if (busyRef.current) return; // ignore a second drop/pick mid-upload
setError(null);
if (!isAcceptedName(file.name)) {
setError("Upload a SKILL.md file or a .zip of a skill folder.");
setError(m.skills_upload_skill_error());
return;
}
if (file.size > MAX_UPLOAD_BYTES) {
setError("File too large (max 20 MB).");
setError(m.skills_file_too_large());
return;
}
busyRef.current = true;
@@ -577,7 +573,7 @@ export function SkillsTab({ project }: { project: Project | null }) {
const globalSkills = (skills ?? []).filter((s) => s.scope === "global");
const projectSkills = (skills ?? []).filter((s) => s.scope === "project");
const scopeLabel = scope === "global" ? "Global" : (project?.name ?? "this project");
const scopeLabel = scope === "global" ? m.skills_tab_global() : (project?.name ?? m.skills_tab_this_project());
// Names present in the scope an import would target — for the "Re-import" hint.
const existingInScope = new Set(
(skills ?? []).filter((s) => s.scope === scope).map((s) => s.name),
@@ -585,29 +581,23 @@ export function SkillsTab({ project }: { project: Project | null }) {
return (
<div className="settings-view max-w-readable my-0 mx-auto pt-6 px-8 pb-15 [&_h1]:mt-0 [&_h1]:mx-0 [&_h1]:mb-1.5 [&_h1]:text-3xl">
<h1>Customize</h1>
<h1>{m.skills_tab_customize()}</h1>
<p className="mt-0 mx-0 mb-5 text-muted text-md leading-normal [&_code]:font-mono [&_code]:text-[0.92em] [&_code]:text-text">
What the agent brings to every session: LaTeX templates it writes papers into, and{" "}
<code>SKILL.md</code> skills it discovers automatically and you invoke with{" "}
<code>/name</code> in chat. Both apply everywhere, or to just this project.
{m.skills_overview_description()}
</p>
<LatexTemplatesCard project={project} />
<section className={CARD_CLASS_NAME}>
<h3>Add a skill</h3>
<h3>{m.skills_tab_add_a_skill()}</h3>
<ScopePicker scope={scope} onScope={setScope} project={project} label="Skill scope" />
<ScopePicker scope={scope} onScope={setScope} project={project} label={m.skills_skill_scope()} />
<DropZone
accept=".md,.markdown,.zip"
busy={busy}
destination={scope === "global" ? "Global" : (project?.name ?? "")}
prompt={
<>
Drop a <code>SKILL.md</code> or <code>.zip</code> here, or click to choose
</>
}
destination={scope === "global" ? m.skills_global_destination() : (project?.name ?? "")}
prompt={m.skills_drop_skill()}
onFile={(file) => void upload(file)}
/>
@@ -616,10 +606,10 @@ export function SkillsTab({ project }: { project: Project | null }) {
{harnessSkills.length > 0 && (
<section className={CARD_CLASS_NAME}>
<h3>Import from your agent</h3>
<p className={`${CARD_SUB_CLASS_NAME} [&_code]:font-mono [&_code]:text-[0.92em] [&_code]:text-text [&_strong]:text-text [&_strong]:font-semibold`}>
Skills already installed in your coding agents. Import a copy into{" "}
<strong>{scopeLabel}</strong> so it's managed here and invocable with <code>/name</code>.
<h3>{m.skills_tab_import_from_your_agent()}</h3>
<p dir="auto" className={`${CARD_SUB_CLASS_NAME} [&_code]:font-mono [&_code]:text-[0.92em] [&_code]:text-text [&_strong]:text-text [&_strong]:font-semibold`}>
{m.skills_tab_skills_already_installed_in_your_coding_agents_import()}{" "}
<strong>{scopeLabel}</strong> {m.skills_tab_so_it_s_managed_here_and_invocable_with()} <code>/name</code>.
</p>
<div className="flex flex-col">
{harnessSkills.map((s) => (
@@ -637,21 +627,21 @@ export function SkillsTab({ project }: { project: Project | null }) {
{skills === null ? (
<div className="flex items-center gap-2 text-subtext text-md p-3">
<span className={SPINNER_CLASS_NAME} /> Loading skills…
<span className={SPINNER_CLASS_NAME} /> {m.skills_tab_loading_skills()}
</div>
) : (
<>
<SkillList
title="Global skills"
hint="Available to the agent in every project."
title={m.skills_tab_global_skills()}
hint={m.skills_global_hint()}
skills={globalSkills}
onChanged={refresh}
onError={setError}
/>
{project && (
<SkillList
title={`${project.name} skills`}
hint="Available only in this project's sessions. Shadows a global skill of the same name."
title={m.a11y_named_skills({ name: project.name })}
hint={m.skills_project_hint()}
skills={projectSkills}
projectId={project.id}
onChanged={refresh}
+16 -2
View File
@@ -3,6 +3,7 @@
// coloring across the table, graph and drawer.
import { STATUS_BADGE_CLASS_NAME } from "../styleClasses";
import { m } from "../paraglide/messages.js";
export interface StatusStyle {
className: string;
@@ -24,7 +25,20 @@ export function statusStyle(status: string): StatusStyle {
return STATUS_STYLES[status] ?? STATUS_STYLES.idle;
}
function sentenceCase(s: string): string {
const STATUS_LABELS: Record<string, () => string> = {
done: m.status_done,
failed: m.status_failed,
running: m.status_running,
starting: m.status_starting,
cancelling: m.status_cancelling,
cancelled: m.status_cancelled,
editing: m.status_editing,
idle: m.status_idle,
};
export function statusLabel(s: string): string {
const localized = STATUS_LABELS[s];
if (localized) return localized();
return s.charAt(0).toUpperCase() + s.slice(1);
}
@@ -33,7 +47,7 @@ export function StatusBadge({ status, label }: { status: string; label?: string
return (
<span className={`${STATUS_BADGE_CLASS_NAME} ${style.className}${style.live ? " live" : ""}`}>
<span className="dot" />
{label ?? sentenceCase(status)}
{label ?? statusLabel(status)}
</span>
);
}
+3 -2
View File
@@ -1,3 +1,4 @@
import { m } from "../paraglide/messages.js";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { getChatMessages, type ChatMessage, type ChatPart } from "../api";
import { onChatEvent } from "../events";
@@ -133,7 +134,7 @@ export function SubagentTab({
return (
<div className={TAB_BODY_CLASS_NAME}>
<div className={PANE_CONTENT_CLASS_NAME}>
<div className="subagent-empty py-[3px] px-1 text-md text-muted">Loading…</div>
<div className="subagent-empty py-[3px] px-1 text-md text-muted">{m.subagent_tab_loading()}</div>
</div>
</div>
);
@@ -168,7 +169,7 @@ export function SubagentTab({
onOpenSubagent={onOpenSubagent}
/>
) : (
<div className="subagent-empty py-[3px] px-1 text-md text-muted">This sub-agent is no longer available.</div>
<div className="subagent-empty py-[3px] px-1 text-md text-muted">{m.subagent_tab_this_sub_agent_is_no_longer_available()}</div>
)}
</div>
</div>
+16 -16
View File
@@ -1,3 +1,4 @@
import { m } from "../paraglide/messages.js";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
@@ -25,7 +26,7 @@ export function DemoWelcomeModal({
setSaving(true);
setError(null);
void action()
.catch(() => setError("Couldn't save your progress. Try again."))
.catch(() => setError(m.tour_save_error()))
.finally(() => setSaving(false));
},
[saving],
@@ -89,45 +90,44 @@ export function DemoWelcomeModal({
tabIndex={-1}
>
<button
className={`${ICON_BUTTON_CLASS_NAME} !absolute top-3.5 right-3.5`}
aria-label="Close"
className={`${ICON_BUTTON_CLASS_NAME} !absolute top-3.5 end-3.5`}
aria-label={m.tour_close()}
onClick={() => run(onClose)}
disabled={saving}
>
<X size={16} />
</button>
<div className="mb-5 flex items-center gap-3 pr-8">
<div className="mb-5 flex items-center gap-3 pe-8">
<span className="block h-9 w-9 shrink-0 [&_svg]:block [&_svg]:h-full [&_svg]:w-full">
<BrandMark />
</span>
<div>
<div className="mb-0.5 text-xs font-semibold tracking-[0.08em] text-primary uppercase">
Demo project
{m.tour_demo_project()}
</div>
<h2
id="demo-welcome-title"
className="m-0 text-3xl leading-tight tracking-[-0.02em]"
>
Welcome to OpenResearch
{m.tour_welcome_to_open_research()}
</h2>
</div>
</div>
<div className="text-base leading-relaxed text-text [&_p]:m-0 [&_p_+_p]:mt-3">
<p>
This is a demo project showing how OpenResearch works. This demo uses Andrej
Karpathy&apos;s{" "}
<p dir="auto">
{m.tour_this_is_a_demo_project_showing_how_open()}{" "}
<a
dir="ltr"
href="https://github.com/karpathy/nanochat"
target="_blank"
rel="noreferrer"
className="font-semibold text-primary underline decoration-border-strong underline-offset-3 hover:decoration-primary"
>
nanochat
</a>, a repo for training a mini-GPT from scratch.
{m.tour_nanochat()}
</a>{m.tour_a_repo_for_training_a_mini_gpt_from()}
</p>
<p>
Look through the agent conversations, experiments, runs, and artifacts to see how a
project on OpenResearch comes together.
<p dir="auto">
{m.tour_look_through_the_agent_conversations_experiments_runs_and()}
</p>
</div>
{error && <p className="mt-3 mb-0 text-sm text-accent-red">{error}</p>}
@@ -137,14 +137,14 @@ export function DemoWelcomeModal({
onClick={() => run(onCreateProject)}
disabled={saving}
>
Create a new project
{m.tour_create_a_new_project()}
</button>
<button
className={PRIMARY_BUTTON_CLASS_NAME}
onClick={() => run(onClose)}
disabled={saving}
>
{saving ? "Saving…" : "Explore the demo"}
{saving ? m.common_saving() : m.tour_explore_demo()}
</button>
</div>
</div>
+23 -20
View File
@@ -1,3 +1,5 @@
import { m } from "../paraglide/messages.js";
import { ltr } from "../i18n";
import {
Background,
BackgroundVariant,
@@ -13,6 +15,7 @@ import { GitHubMark } from "./BackendLogos";
import { memo, useMemo, useRef } from "react";
import {
githubBranchUrl,
fmtNumber,
runDisplayStatus,
timeAgo,
type Experiment,
@@ -22,7 +25,7 @@ import {
import type { ExperimentView } from "./DetailDrawer";
import type { CodeView } from "./CodeTab";
import { ExpHoverCard, dismissTreeHoverCards, useHoverIntent } from "./ExpHoverCard";
import { StatusBadge } from "./StatusBadge";
import { statusLabel, StatusBadge } from "./StatusBadge";
import { tabOpenGestureHandlers, type TabOpenIntent } from "../tabPreview";
const EMPTY_STATE_CLASS_NAME = [
@@ -181,7 +184,7 @@ const ExpNode = memo(function ExpNode({ data }: NodeProps<ExpFlowNode>) {
const { exp, latestRun, runs, isBaseline, parentSlug, githubOwner, githubRepo, onOpenView, onOpenCode } = data;
const status = latestRun ? runDisplayStatus(latestRun) : undefined;
const live = status === "running" || status === "starting" || status === "cancelling";
const kind = isBaseline ? "Baseline" : live ? "Running" : "Experiment";
const kind = isBaseline ? m.tree_baseline() : live ? m.tree_running() : m.tree_experiment();
const squares = runs.slice(-MAX_SQUARES);
// `data` is rebuilt on every experiments/runs change (a superset of the
@@ -194,7 +197,7 @@ const ExpNode = memo(function ExpNode({ data }: NodeProps<ExpFlowNode>) {
return (
<div
ref={rootRef}
className={`exp-node w-66 border border-border rounded-md bg-background py-2.5 px-3 shadow-[0_1px_2px_rgba(0,_0,_0,_0.04)] text-md transition-[box-shadow] duration-120 ease-standard [&:hover]:shadow-[0_2px_8px_rgba(0,_0,_0,_0.08)] [&.live]:border-accent-teal [&.live]:shadow-[0_2px_12px_rgba(32,_154,_132,_0.2)] [&_.node-overview-link]:block [&_.node-overview-link]:w-full [&_.node-overview-link]:p-0 [&_.node-overview-link]:border-0 [&_.node-overview-link]:bg-transparent [&_.node-overview-link]:text-inherit [&_.node-overview-link]:[font:inherit] [&_.node-overview-link]:text-left [&_.node-overview-link]:cursor-pointer [&_.node-overview-link:hover_.node-slug]:underline [&_.node-overview-link:hover_.node-slug]:underline-offset-[3px] [&_.node-overview-link:focus-visible]:outline-2 [&_.node-overview-link:focus-visible]:outline-solid [&_.node-overview-link:focus-visible]:outline-accent [&_.node-overview-link:focus-visible]:outline-offset-4 [&_.node-overview-link:focus-visible]:rounded-xs [&_.node-eyebrow]:flex [&_.node-eyebrow]:items-center [&_.node-eyebrow]:justify-between [&_.node-eyebrow]:gap-2 [&_.node-eyebrow]:mb-1.5 [&_.node-eyebrow]:text-2xs [&_.node-eyebrow]:font-medium [&_.node-eyebrow]:text-muted [&_.node-head]:flex [&_.node-head]:items-center [&_.node-head]:gap-[7px] [&_.node-head]:min-w-0 [&_.node-status]:w-2 [&_.node-status]:h-2 [&_.node-status]:rounded-full [&_.node-status]:shrink-0 [&_.node-slug]:font-mono [&_.node-slug]:text-sm [&_.node-slug]:font-semibold [&_.node-slug]:text-text [&_.node-slug]:flex-1 [&_.node-slug]:min-w-0 [&_.node-slug]:overflow-hidden [&_.node-slug]:text-ellipsis [&_.node-slug]:whitespace-nowrap [&_.node-title]:mt-1 [&_.node-title]:text-text [&_.node-title]:text-sm [&_.node-title]:line-clamp-2 [&_.node-meta]:mt-2 [&_.node-meta]:flex [&_.node-meta]:items-center [&_.node-meta]:gap-2 [&_.node-meta]:text-2xs [&_.node-meta]:text-muted [&_.node-actions]:mt-2 [&_.node-actions]:pt-1.5 [&_.node-actions]:border-t [&_.node-actions]:border-t-border-variant [&_.node-actions]:flex [&_.node-actions]:items-center [&_.node-actions]:gap-[3px] [&_.node-action]:inline-flex [&_.node-action]:items-center [&_.node-action]:gap-[5px] [&_.node-action]:py-[3px] [&_.node-action]:px-1.5 [&_.node-action]:text-xs [&_.node-action]:font-medium [&_.node-action]:text-text [&_.node-action]:rounded-sm [&_.node-action]:no-underline [&_.node-action:hover]:text-text [&_.node-action:hover]:bg-surface [&_.node-action-ext]:ml-auto [&_.node-action-ext]:py-[3px] [&_.node-action-ext]:px-[5px] ${live ? "live" : ""}`}
className={`exp-node w-66 border border-border rounded-md bg-background py-2.5 px-3 shadow-[0_1px_2px_rgba(0,_0,_0,_0.04)] text-md transition-[box-shadow] duration-120 ease-standard [&:hover]:shadow-[0_2px_8px_rgba(0,_0,_0,_0.08)] [&.live]:border-accent-teal [&.live]:shadow-[0_2px_12px_rgba(32,_154,_132,_0.2)] [&_.node-overview-link]:block [&_.node-overview-link]:w-full [&_.node-overview-link]:p-0 [&_.node-overview-link]:border-0 [&_.node-overview-link]:bg-transparent [&_.node-overview-link]:text-inherit [&_.node-overview-link]:[font:inherit] [&_.node-overview-link]:text-start [&_.node-overview-link]:cursor-pointer [&_.node-overview-link:hover_.node-slug]:underline [&_.node-overview-link:hover_.node-slug]:underline-offset-[3px] [&_.node-overview-link:focus-visible]:outline-2 [&_.node-overview-link:focus-visible]:outline-solid [&_.node-overview-link:focus-visible]:outline-accent [&_.node-overview-link:focus-visible]:outline-offset-4 [&_.node-overview-link:focus-visible]:rounded-xs [&_.node-eyebrow]:flex [&_.node-eyebrow]:items-center [&_.node-eyebrow]:justify-between [&_.node-eyebrow]:gap-2 [&_.node-eyebrow]:mb-1.5 [&_.node-eyebrow]:text-2xs [&_.node-eyebrow]:font-medium [&_.node-eyebrow]:text-muted [&_.node-head]:flex [&_.node-head]:items-center [&_.node-head]:gap-[7px] [&_.node-head]:min-w-0 [&_.node-status]:w-2 [&_.node-status]:h-2 [&_.node-status]:rounded-full [&_.node-status]:shrink-0 [&_.node-slug]:font-mono [&_.node-slug]:text-sm [&_.node-slug]:font-semibold [&_.node-slug]:text-text [&_.node-slug]:flex-1 [&_.node-slug]:min-w-0 [&_.node-slug]:overflow-hidden [&_.node-slug]:text-ellipsis [&_.node-slug]:whitespace-nowrap [&_.node-title]:mt-1 [&_.node-title]:text-text [&_.node-title]:text-sm [&_.node-title]:line-clamp-2 [&_.node-meta]:mt-2 [&_.node-meta]:flex [&_.node-meta]:items-center [&_.node-meta]:gap-2 [&_.node-meta]:text-2xs [&_.node-meta]:text-muted [&_.node-actions]:mt-2 [&_.node-actions]:pt-1.5 [&_.node-actions]:border-t [&_.node-actions]:border-t-border-variant [&_.node-actions]:flex [&_.node-actions]:items-center [&_.node-actions]:gap-[3px] [&_.node-action]:inline-flex [&_.node-action]:items-center [&_.node-action]:gap-[5px] [&_.node-action]:py-[3px] [&_.node-action]:px-1.5 [&_.node-action]:text-xs [&_.node-action]:font-medium [&_.node-action]:text-text [&_.node-action]:rounded-sm [&_.node-action]:no-underline [&_.node-action:hover]:text-text [&_.node-action:hover]:bg-surface [&_.node-action-ext]:ms-auto [&_.node-action-ext]:py-[3px] [&_.node-action-ext]:px-[5px] ${live ? "live" : ""}`}
onMouseEnter={hover.onMouseEnter}
onMouseLeave={hover.onMouseLeave}
>
@@ -218,19 +221,19 @@ const ExpNode = memo(function ExpNode({ data }: NodeProps<ExpFlowNode>) {
<div className="node-title">{exp.title || exp.description}</div>
)}
<div className="node-meta">
<span>Runs</span>
<span>{m.tree_view_runs()}</span>
{squares.length > 0 ? (
<span className="run-squares flex items-center gap-[3px]">
{squares.map((run) => (
<span
key={run.id}
className={`run-sq w-[9px] h-[9px] shrink-0 [&.pass]:bg-accent-green [&.fail]:border-[1.5px] [&.fail]:border-[color-mix(in_oklab,_var(--accent-red)_55%,_transparent)] [&.live]:bg-accent-teal [&.live]:animate-[or-pulse_1.2s_ease-in-out_infinite] [&.other]:border-[1.5px] [&.other]:border-border ${runSquareClass(runDisplayStatus(run))}`}
title={runDisplayStatus(run)}
title={statusLabel(runDisplayStatus(run))}
/>
))}
</span>
) : (
<span>no runs</span>
<span>{m.tree_view_no_runs()}</span>
)}
<span style={{ flex: 1 }} />
{latestRun && <span>{timeAgo(latestRun.createdAt)}</span>}
@@ -241,30 +244,30 @@ const ExpNode = memo(function ExpNode({ data }: NodeProps<ExpFlowNode>) {
{runs.length > 0 && (
<button
className="node-action"
title="Open logs"
title={m.tree_view_open_logs()}
{...tabOpenGestureHandlers<HTMLButtonElement>((intent) =>
onOpenView(exp.id, "terminal", intent),
)}
>
<Terminal size={13} />
Logs
{m.tree_view_logs()}
</button>
)}
<button
className="node-action"
title={`Browse code on ${exp.branchName}`}
title={m.a11y_browse_code_on({ branch: ltr(exp.branchName) })}
{...tabOpenGestureHandlers<HTMLButtonElement>((intent) =>
onOpenCode(exp.id, exp.branchName, "files", intent),
)}
>
<FolderTree size={13} />
Code
{m.tree_view_code()}
</button>
{/* Icon-only: labeled actions + the link overflow the card's fixed width. */}
{githubOwner && githubRepo && <a
className="node-action node-action-ext"
title={`Open ${exp.branchName} on GitHub`}
aria-label={`Open ${exp.branchName} on GitHub`}
title={m.a11y_open_on_github({ name: ltr(exp.branchName) })}
aria-label={m.a11y_open_on_github({ name: ltr(exp.branchName) })}
href={githubBranchUrl(githubOwner, githubRepo, exp.branchName)}
target="_blank"
rel="noopener noreferrer"
@@ -303,10 +306,10 @@ const ElidedNode = memo(function ElidedNode({ data }: NodeProps<ElidedFlowNode>)
// body is a single action.
return (
<div
className="elided-node w-37 h-11 flex items-center gap-2 py-1.5 px-2.5 border border-dashed border-border rounded-md bg-[color-mix(in_oklab,_var(--text)_3%,_transparent)] text-muted text-2xs font-medium text-left transition-[border-color,color] duration-120 ease-standard [&:hover]:border-text [&:hover]:text-text [&_.elided-node-label]:flex [&_.elided-node-label]:flex-col [&_.elided-node-label]:leading-[1.3] [&_.elided-node-sub]:text-muted"
className="elided-node w-37 h-11 flex items-center gap-2 py-1.5 px-2.5 border border-dashed border-border rounded-md bg-[color-mix(in_oklab,_var(--text)_3%,_transparent)] text-muted text-2xs font-medium text-start transition-[border-color,color] duration-120 ease-standard [&:hover]:border-text [&:hover]:text-text [&_.elided-node-label]:flex [&_.elided-node-label]:flex-col [&_.elided-node-label]:leading-[1.3] [&_.elided-node-sub]:text-muted"
role="button"
tabIndex={0}
title="Switch to Entire project to see all experiments"
title={m.tree_view_switch_to_entire_project_to_see_all_experiments()}
onClick={onShowProjectScope}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
@@ -318,8 +321,8 @@ const ElidedNode = memo(function ElidedNode({ data }: NodeProps<ElidedFlowNode>)
<Handle type="target" position={Position.Top} />
<Ellipsis size={14} />
<span className="elided-node-label">
{count} {count === 1 ? "experiment" : "experiments"}
<span className="elided-node-sub">other tasks</span>
{count === 1 ? m.tree_one_experiment() : m.tree_experiment_count({ count: fmtNumber(count) })}
<span className="elided-node-sub">{m.tree_view_other_tasks()}</span>
</span>
<Handle type="source" position={Position.Bottom} />
</div>
@@ -454,8 +457,8 @@ export function TreeView({
if (experiments.length === 0) {
return (
<div className={EMPTY_STATE_CLASS_NAME}>
<p className="empty-state-title">No experiments yet</p>
<p className="empty-state-hint">Ask the agent in chat to create and run your first experiment.</p>
<p className="empty-state-title">{m.tree_view_no_experiments_yet()}</p>
<p className="empty-state-hint">{m.tree_view_ask_the_agent_in_chat_to_create_and()}</p>
</div>
);
}
@@ -464,9 +467,9 @@ export function TreeView({
if (nodes.length === 0 && agentSessionId) {
return (
<div className={EMPTY_STATE_CLASS_NAME}>
<p className="empty-state-title">No experiments from the current task yet</p>
<p className="empty-state-title">{m.tree_view_no_experiments_from_the_current_task_yet()}</p>
<p className="empty-state-hint">
Ask in this task to create one, or switch to Entire project to see all experiments.
{m.tree_view_ask_in_this_task_to_create_one_or()}
</p>
</div>
);
+5 -3
View File
@@ -1,3 +1,5 @@
import { m } from "../paraglide/messages.js";
import { ltr } from "../i18n";
import { RefreshCw, X } from "lucide-react";
import { useEffect, useState } from "react";
import { getUpdateStatus, type UpdateStatus } from "../api";
@@ -54,12 +56,12 @@ export function UpdateBanner() {
>
<RefreshCw size={13} className="shrink-0 text-subtext" />
<span className="min-w-0">
Updated to <strong className="font-semibold">{version}</strong>. Restart to use it.
{m.update_banner_complete({ version: ltr(version) })}
</span>
<button
type="button"
className="ml-auto shrink-0 p-1 rounded-sm text-subtext hover:text-text hover:bg-highlight"
aria-label="Dismiss"
className="ms-auto shrink-0 p-1 rounded-sm text-subtext hover:text-text hover:bg-highlight"
aria-label={m.update_banner_dismiss()}
onClick={() => setDismissed(version)}
>
<X size={13} />
+13 -11
View File
@@ -1,3 +1,5 @@
import { m } from "../paraglide/messages.js";
import { ltr } from "../i18n";
// The pinned Files home for the active chat session's private worktree — what
// the agent is changing right now, before any run/commit exists. The Code tab
// remains committed-state only.
@@ -48,7 +50,7 @@ export function WorktreeTab({
sessionId?: string;
project: Project;
/** Which segmented view is showing (lives on the tab def, so it survives the
* unmount/remount when another right-pane tab fronts this one). */
* unmount/remount when another end-pane tab fronts this one). */
view: WorktreeView;
/** Files-view dirs flipped away from their depth default (on the tab def). */
toggled: ReadonlySet<string>;
@@ -180,11 +182,11 @@ export function WorktreeTab({
const liveWorktree = sessionId && wt?.exists ? wt : null;
const checkedOut =
liveWorktree?.branch ??
(liveWorktree?.baselineBranch ? `detached @ ${liveWorktree.baselineBranch}` : "detached");
(liveWorktree?.baselineBranch ? m.worktree_detached_at({ branch: ltr(liveWorktree.baselineBranch) }) : m.settings_detached());
const fileCount = liveWorktree?.files?.length ?? 0;
const branchChip = liveWorktree
? `Current worktree · ${checkedOut}${fileCount > 0 ? "*" : ""}`
: `Default branch · ${project.baselineBranch}`;
? m.worktree_current({ branch: ltr(`${checkedOut}${fileCount > 0 ? "*" : ""}`) })
: m.worktree_default_branch({ branch: ltr(project.baselineBranch) });
const githubBranch = liveWorktree ? liveWorktree.branch : project.baselineBranch;
return (
@@ -200,19 +202,19 @@ export function WorktreeTab({
? githubBranchUrl(project.githubOwner, project.githubRepo, githubBranch)
: undefined
}
githubTitle={githubBranch ? `Open ${githubBranch} on GitHub` : undefined}
githubTitle={githubBranch ? m.a11y_open_branch_github({ branch: ltr(githubBranch) }) : undefined}
refreshing={loading}
onRefresh={load}
/>
{error && (wt || tree) && <div className={CODE_TAB_NOTE_CLASS_NAME}>Refresh failed: {error}</div>}
{error && (wt || tree) && <div className={CODE_TAB_NOTE_CLASS_NAME}>{m.worktree_tab_refresh_failed()} {ltr(error)}</div>}
{!tree || (sessionId && !wt) ? (
<div className={CODE_TAB_BODY_CLASS_NAME}>
<div className={CODE_TAB_NOTE_CLASS_NAME}>{error ? `Failed to load: ${error}` : "Loading…"}</div>
<div className={CODE_TAB_NOTE_CLASS_NAME}>{error ? m.common_failed_to_load({ error: ltr(error) }) : m.common_loading()}</div>
</div>
) : liveWorktree && view === "changes" ? (
<div className={`${CODE_TAB_BODY_CLASS_NAME} wt-changes pt-0 px-4 pb-6 [&_>_:first-child]:mt-3.5`}>
{fileCount === 0 || !liveWorktree.diff ? (
<div className="changes-note text-sm text-muted">No changes yet.</div>
<div className="changes-note text-sm text-muted">{m.worktree_tab_no_changes_yet()}</div>
) : (
<>
{liveWorktree.diff.truncated && (
@@ -231,12 +233,12 @@ export function WorktreeTab({
) : (
<div className={CODE_TAB_BODY_CLASS_NAME}>
{tree.truncated && (
<div className={CODE_TAB_NOTE_CLASS_NAME}>Listing truncated.</div>
<div className={CODE_TAB_NOTE_CLASS_NAME}>{m.worktree_tab_listing_truncated()}</div>
)}
{!filesTree ? (
<div className={CODE_TAB_NOTE_CLASS_NAME}>Loading…</div>
<div className={CODE_TAB_NOTE_CLASS_NAME}>{m.worktree_tab_loading()}</div>
) : filesTree.dirs.size === 0 && filesTree.files.length === 0 ? (
<div className={CODE_TAB_NOTE_CLASS_NAME}>No files.</div>
<div className={CODE_TAB_NOTE_CLASS_NAME}>{m.worktree_tab_no_files()}</div>
) : (
<div className="file-tree py-1.5 px-0 text-md">
<TreeLevel
+4 -2
View File
@@ -1,6 +1,8 @@
import { useState } from "react";
import { Check, Copy } from "lucide-react";
import { MONO_CLASS_NAME } from "../styleClasses";
import { m } from "../paraglide/messages.js";
import { ltr } from "../i18n";
/** A backtick command from an `agentNote`, rendered as a code pill with its own
* copy button so the user can grab it without retyping. */
@@ -21,8 +23,8 @@ function CommandPill({ cmd }: { cmd: string }) {
})
.catch(() => {});
}}
aria-label={copied ? "Copied" : `Copy ${cmd}`}
title={copied ? "Copied" : "Copy"}
aria-label={copied ? m.common_copied() : m.a11y_copy_value({ value: ltr(cmd) })}
title={copied ? m.common_copied() : m.md_copy()}
>
{copied ? <Check size={11} strokeWidth={3} /> : <Copy size={11} />}
</button>
+6 -4
View File
@@ -1,6 +1,8 @@
// The nested file-tree primitives shared by the code browsers: the committed
// CodeTab and the live WorktreeTab's Files view. A flat, sorted, repo-relative
// path list (from a git listing) becomes a nested dir tree that renders as
import { m } from "../paraglide/messages.js";
import { ltr } from "../i18n";
// collapsible rows; clicking a file bubbles its repo-relative path up. Kept
// source-agnostic — the caller decides which checkout the paths came from and
// how a file open resolves.
@@ -11,7 +13,7 @@ import { tabOpenGestureHandlers, type TabOpenIntent } from "../tabPreview";
const FILE_TREE_ROW_CLASS_NAME = [
"file-tree-row flex items-center gap-1.5 w-full py-[3px] px-2.5 border-0",
"bg-transparent text-text text-left cursor-pointer font-[inherit]",
"bg-transparent text-text text-start cursor-pointer font-[inherit]",
"text-[length:inherit] [&:hover]:bg-panel [&_>_svg]:shrink-0",
"[&_>_svg]:text-subtext [&_>_svg.file-tree-chevron]:text-muted",
].join(" ");
@@ -85,7 +87,7 @@ function DirRow({
<button
type="button"
className={FILE_TREE_ROW_CLASS_NAME}
style={{ paddingLeft: 8 + depth * 14 }}
style={{ paddingInlineStart: 8 + depth * 14 }}
onClick={() => onToggle(path)}
title={path}
>
@@ -151,11 +153,11 @@ export function TreeLevel({
key={`f:${path}`}
type="button"
className={FILE_TREE_ROW_CLASS_NAME}
style={{ paddingLeft: 8 + depth * 14 }}
style={{ paddingInlineStart: 8 + depth * 14 }}
{...tabOpenGestureHandlers<HTMLButtonElement>((intent) =>
onOpenFile(path, intent),
)}
title={`${path} — double-click or Enter to keep open`}
title={m.a11y_keep_open({ name: ltr(path) })}
>
<FileTypeIcon name={name} />
<span className="file-tree-name flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">{name}</span>
+7
View File
@@ -0,0 +1,7 @@
import { getLocale } from "./paraglide/runtime.js";
// LRI/FSI through PDI keep interpolated text from reordering its surrounding sentence.
export const ltr = (value: string | number) => `\u2066${value}\u2069`;
export const autoDir = (value: string | number) => `\u2068${value}\u2069`;
export const fmtNumber = (value: number) => new Intl.NumberFormat(getLocale()).format(value);
+5
View File
@@ -1,8 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import { getLocale } from "./paraglide/runtime.js";
import "./tailwind.css";
const locale = getLocale();
document.documentElement.lang = locale;
document.documentElement.dir = "ltr";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
+2 -1
View File
@@ -1,8 +1,9 @@
import type { SkillInfo } from "./api";
import { m } from "./paraglide/messages.js";
export const PLAN_COMMAND: SkillInfo = {
name: "plan",
description: "Toggle Plan mode for this chat",
description: m.plan_command_description(),
source: "command",
};
+3 -3
View File
@@ -4,7 +4,7 @@ export const ELEVATED_SURFACE_SHADOW_CLASS_NAME =
export const ICON_BUTTON_BASE_CLASS_NAME = [
"icon-btn relative inline-flex items-center justify-center",
"text-subtext [&:hover]:text-text [&:hover]:bg-surface",
"[.chat-header.rail-hidden_>_&:first-child]:mr-3 [&.active]:text-primary",
"[.chat-header.rail-hidden_>_&:first-child]:me-3 [&.active]:text-primary",
"[&.active]:bg-surface",
].join(" ");
@@ -25,7 +25,7 @@ export const COMPOSER_ICON_CONTROL_CLASS_NAME = [
export const MODEL_ITEM_CLASS_NAME = [
"model-item [&.danger]:text-accent-red [&.danger:hover]:text-accent-red flex",
"items-center justify-between gap-2 w-full text-left py-1.5 px-2",
"items-center justify-between gap-2 w-full text-start py-1.5 px-2",
"text-md rounded-sm [&:hover]:bg-surface",
"[&_.model-id]:block [&_.model-id]:font-mono [&_.model-id]:text-2xs",
"[&_.model-id]:text-muted",
@@ -114,7 +114,7 @@ export const STATUS_BADGE_CLASS_NAME = [
export const SETTINGS_SWITCH_CLASS_NAME = [
"settings-switch relative flex-none w-9.5 h-5.5 border border-border rounded-full",
"bg-surface transition-[background,border-color] duration-120 ease-standard",
"[&_span]:absolute [&_span]:top-[3px] [&_span]:left-[3px] [&_span]:w-3.5 [&_span]:h-3.5",
"[&_span]:absolute [&_span]:top-[3px] [&_span]:start-[3px] [&_span]:w-3.5 [&_span]:h-3.5",
"[&_span]:rounded-full [&_span]:bg-muted [&_span]:transition-[translate,background]",
"[&_span]:duration-120 [&_span]:ease-standard [&.on]:border-primary [&.on]:bg-primary",
"[&.on_span]:bg-background [&.on_span]:translate-x-4 [&:disabled]:opacity-45",
+19
View File
@@ -18,6 +18,25 @@
mix-blend-mode: screen;
}
:root[lang="fa"] #root :where(p, h1, h2, h3, h4, h5, h6, button, label, li, th, td, dt, dd, [role="status"], [role="alert"]) {
unicode-bidi: plaintext;
}
.md :where(p, h1, h2, h3, h4, li, th, td, blockquote),
:root[lang="fa"] #root .file-view-note {
unicode-bidi: plaintext;
}
/* The exception preserves DirFooter's RTL front-ellipsis truncation trick. */
:where(pre, code:not(.path-front-ellipsis), .font-mono, .xterm, .openresearch-diff) {
direction: ltr;
unicode-bidi: isolate;
}
.path-front-ellipsis {
unicode-bidi: isolate;
}
@theme inline {
--color-*: initial;
--color-transparent: transparent;
+2 -1
View File
@@ -5,6 +5,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { compileLatex, getLatexEngine } from "./api";
import { m } from "./paraglide/messages.js";
interface CompiledPdf {
/** Checkout-relative path of the PDF, for the raw-file URL. */
@@ -133,7 +134,7 @@ export function useLatexCompile({
setBuiltWithErrors(false);
setNote(result.note);
setShowPdf(false);
setLog(result.log?.trim() || "The engine exited without producing a PDF or a log.");
setLog(result.log?.trim() || m.latex_no_pdf_or_log());
})
.catch((e: unknown) => {
setCompiled(null);
+58
View File
@@ -0,0 +1,58 @@
import assert from "node:assert/strict";
import { cp, mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import test from "node:test";
async function fixture() {
const root = await mkdtemp(join(tmpdir(), "orx-i18n-"));
await mkdir(join(root, "scripts"));
await cp(new URL("../scripts/check-i18n.mjs", import.meta.url), join(root, "scripts/check-i18n.mjs"));
await cp(new URL("../messages", import.meta.url), join(root, "messages"), { recursive: true });
await cp(new URL("../project.inlang", import.meta.url), join(root, "project.inlang"), { recursive: true });
return root;
}
const run = (root) => spawnSync(process.execPath, [join(root, "scripts/check-i18n.mjs")], { encoding: "utf8" });
const catalog = async (root, locale) => JSON.parse(await readFile(join(root, "messages", `${locale}.json`), "utf8"));
const save = (root, locale, value) => writeFile(join(root, "messages", `${locale}.json`), `${JSON.stringify(value, null, 2)}\n`);
const expectFailure = (root, expected, message) => {
const result = run(root);
assert.notEqual(result.status, 0, message);
assert.match(result.stderr, expected, message);
};
test("catalog lint rejects missing files, key drift, and invalid values", async () => {
const roots = [];
try {
const missingFile = await fixture(); roots.push(missingFile);
await unlink(join(missingFile, "messages/fa.json"));
expectFailure(missingFile, /fa: .*ENOENT/, "missing locale file");
const missingKey = await fixture(); roots.push(missingKey);
const zhMissing = await catalog(missingKey, "zh-CN");
delete zhMissing.common_save;
await save(missingKey, "zh-CN", zhMissing);
expectFailure(missingKey, /zh-CN: missing keys: common_save/, "missing key");
const unexpectedKey = await fixture(); roots.push(unexpectedKey);
const faUnexpected = await catalog(unexpectedKey, "fa");
faUnexpected.unexpected_test_key = "اضافی";
await save(unexpectedKey, "fa", faUnexpected);
expectFailure(unexpectedKey, /fa: unexpected keys: unexpected_test_key/, "unexpected key");
for (const [name, value, expected] of [
["empty", "", /empty or non-string values: common_save/],
["non-string", 7, /empty or non-string values: common_save/],
]) {
const root = await fixture(); roots.push(root);
const fa = await catalog(root, "fa");
fa.common_save = value;
await save(root, "fa", fa);
expectFailure(root, expected, name);
}
} finally {
await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })));
}
});
+1 -1
View File
@@ -9,7 +9,7 @@ test("Tinker is always visible in compute and environment settings", async () =>
source("../src/components/SettingsPage.tsx"),
source("../src/components/BackendLogos.tsx"),
]);
assert.match(settings, /tinker: "Tinker"/);
assert.match(settings, /tinker: m\.compute_target_tinker/);
assert.match(settings, /target\.id === "tinker"/);
assert.match(settings, /"TINKER_API_KEY", "HF_TOKEN", "WANDB_API_KEY"/);
assert.match(logos, /case "tinker_job":\s+return <TinkerLogo/);
+13 -1
View File
@@ -1,3 +1,4 @@
import { paraglideVitePlugin } from "@inlang/paraglide-js";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
@@ -7,7 +8,18 @@ import { defineConfig } from "vite";
const backend = process.env.ORX_BACKEND ?? "http://127.0.0.1:4791";
export default defineConfig({
plugins: [react(), tailwindcss()],
plugins: [
paraglideVitePlugin({
project: "./project.inlang",
outdir: "./src/paraglide",
outputStructure: "message-modules",
emitTsDeclarations: true,
localStorageKey: "orx:locale",
strategy: ["localStorage", "preferredLanguage", "baseLocale"],
}),
react(),
tailwindcss(),
],
build: { outDir: "dist" },
server: {
proxy: {