From 534fb9239153f4108de8e858d095d4c60c3ebb71 Mon Sep 17 00:00:00 2001 From: ABDULLAH <149819669+A-AL-ANAZI@users.noreply.github.com> Date: Fri, 30 Jan 2026 10:21:11 +0300 Subject: [PATCH] Agents: keep fish fallback and adjust sh tests --- src/agents/bash-tools.exec.ts | 7 +- src/agents/shell-utils.test.ts | 7 +- src/agents/shell-utils.ts | 20 +++- ui/src/styles/base.css | 6 + ui/src/styles/chat/layout.css | 34 ++++++ ui/src/styles/layout.css | 78 +++++++++++++ ui/src/styles/layout.mobile.css | 9 ++ ui/src/ui/app-lifecycle.ts | 4 + ui/src/ui/app-render.helpers.ts | 60 +++++++--- ui/src/ui/app-render.ts | 72 +++++++----- ui/src/ui/app-settings.test.ts | 3 + ui/src/ui/app-settings.ts | 15 +++ ui/src/ui/app-view-state.ts | 10 ++ ui/src/ui/app.ts | 141 ++++++++++++++++++++++++ ui/src/ui/i18n.ts | 187 ++++++++++++++++++++++++++++++++ ui/src/ui/icons.ts | 2 + ui/src/ui/navigation.test.ts | 48 ++++---- ui/src/ui/navigation.ts | 60 +++++----- ui/src/ui/storage.ts | 33 +++++- ui/src/ui/views/chat.test.ts | 8 ++ ui/src/ui/views/chat.ts | 105 ++++++++++++++---- ui/src/ui/voice.ts | 45 ++++++++ 22 files changed, 828 insertions(+), 126 deletions(-) create mode 100644 ui/src/ui/i18n.ts create mode 100644 ui/src/ui/voice.ts diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index b9de81872..1d193bc34 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -876,7 +876,9 @@ export function createExecTool( containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir, }) : mergedEnv; - if (!sandbox && host === "gateway" && !params.env?.PATH) { + const hasExplicitPath = + params.env && Object.prototype.hasOwnProperty.call(params.env, "PATH"); + if (!sandbox && host === "gateway" && !hasExplicitPath) { const shellPath = getShellPathFromLoginShell({ env: process.env, timeoutMs: resolveShellEnvFallbackTimeoutMs(process.env), @@ -1398,6 +1400,9 @@ export function createExecTool( timeoutSec: effectiveTimeout, onUpdate, }); + if (allowBackground && backgroundRequested) { + markBackgrounded(run.session); + } let yielded = false; let yieldTimer: NodeJS.Timeout | null = null; diff --git a/src/agents/shell-utils.test.ts b/src/agents/shell-utils.test.ts index 9c45d0c98..69d19de06 100644 --- a/src/agents/shell-utils.test.ts +++ b/src/agents/shell-utils.test.ts @@ -77,6 +77,11 @@ describe("getShellConfig", () => { delete process.env.SHELL; process.env.PATH = ""; const { shell } = getShellConfig(); - expect(shell).toBe("sh"); + const expected = fs.existsSync("/bin/sh") + ? "/bin/sh" + : fs.existsSync("/usr/bin/sh") + ? "/usr/bin/sh" + : "sh"; + expect(shell).toBe(expected); }); }); diff --git a/src/agents/shell-utils.ts b/src/agents/shell-utils.ts index 6d4efac59..39b9d8e51 100644 --- a/src/agents/shell-utils.ts +++ b/src/agents/shell-utils.ts @@ -38,8 +38,14 @@ export function getShellConfig(): { shell: string; args: string[] } { if (bash) return { shell: bash, args: ["-c"] }; const sh = resolveShellFromPath("sh"); if (sh) return { shell: sh, args: ["-c"] }; + return { shell: envShell, args: ["-c"] }; } - const shell = envShell && envShell.length > 0 ? envShell : "sh"; + if (envShell && envShell.length > 0) { + return { shell: envShell, args: ["-c"] }; + } + const fallbackShell = + resolveShellFromPath("sh") ?? resolveShellFromCandidates(["/bin/sh", "/usr/bin/sh"]); + const shell = fallbackShell ?? "sh"; return { shell, args: ["-c"] }; } @@ -59,6 +65,18 @@ function resolveShellFromPath(name: string): string | undefined { return undefined; } +function resolveShellFromCandidates(candidates: string[]): string | undefined { + for (const candidate of candidates) { + try { + fs.accessSync(candidate, fs.constants.X_OK); + return candidate; + } catch { + // ignore missing or non-executable entries + } + } + return undefined; +} + export function sanitizeBinaryOutput(text: string): string { const scrubbed = text.replace(/[\p{Format}\p{Surrogate}]/gu, ""); if (!scrubbed) return scrubbed; diff --git a/ui/src/styles/base.css b/ui/src/styles/base.css index f77cff9ed..370403691 100644 --- a/ui/src/styles/base.css +++ b/ui/src/styles/base.css @@ -1,4 +1,5 @@ @import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=Tajawal:wght@400;500;700&display=swap"); :root { /* Background - Warmer dark with depth */ @@ -111,6 +112,11 @@ color-scheme: dark; } +:root[lang="ar"] { + --font-body: "Tajawal", "Segoe UI", Tahoma, Arial, sans-serif; + --font-display: "Tajawal", "Segoe UI", Tahoma, Arial, sans-serif; +} + /* Light theme - Clean with subtle warmth */ :root[data-theme="light"] { --bg: #fafafa; diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 589b0b62d..8605b5fda 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -313,6 +313,12 @@ background: rgba(255, 255, 255, 0.06); } +.btn--icon.active { + color: var(--accent); + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent-muted); +} + /* Controls separator */ .chat-controls__separator { color: rgba(255, 255, 255, 0.4); @@ -338,6 +344,12 @@ color: var(--muted); } +:root[data-theme="light"] .btn--icon.active { + color: var(--accent); + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent-muted); +} + :root[data-theme="light"] .btn--icon:hover { background: #ffffff; border-color: var(--border-strong); @@ -395,3 +407,25 @@ min-width: 120px; } } + +:root[dir="rtl"] .chat-focus-exit { + right: auto; + left: 12px; +} + +:root[dir="rtl"] .chat-attachment__remove { + right: auto; + left: 4px; +} + +:root[dir="rtl"] .chat-compose__actions { + flex-direction: row-reverse; +} + +:root[dir="rtl"] .chat-compose__field textarea { + text-align: right; +} + +:root[dir="rtl"] .chat-attachments { + align-self: flex-end; +} diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index c2a5c6fe3..e7c734724 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -22,6 +22,18 @@ overflow: hidden; } +:root[dir="rtl"] .shell { + grid-template-columns: minmax(0, 1fr) var(--shell-nav-width); + grid-template-areas: + "topbar topbar" + "content nav"; +} + +:root[dir="rtl"] .shell--nav-collapsed, +:root[dir="rtl"] .shell--chat-focus { + grid-template-columns: minmax(0, 1fr) 0px; +} + @supports (height: 100dvh) { .shell { height: 100dvh; @@ -84,6 +96,18 @@ background: var(--bg); } +:root[dir="rtl"] .topbar { + flex-direction: row-reverse; +} + +:root[dir="rtl"] .topbar-left { + flex-direction: row-reverse; +} + +:root[dir="rtl"] .brand-text { + text-align: right; +} + .topbar-left { display: flex; align-items: center; @@ -155,6 +179,48 @@ gap: 8px; } +.language-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 8px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--panel); + color: var(--text); +} + +.language-toggle__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; +} + +.language-toggle__icon svg { + width: 16px; + height: 16px; + stroke: currentColor; + fill: none; + stroke-width: 1.5px; +} + +.language-toggle select { + border: none; + background: transparent; + color: inherit; + font-size: 12px; + font-family: var(--font-body); + outline: none; + cursor: pointer; +} + +:root[dir="rtl"] .language-toggle { + flex-direction: row-reverse; +} + .topbar-status .pill { padding: 6px 10px; gap: 6px; @@ -316,6 +382,10 @@ background var(--duration-fast) ease; } +:root[dir="rtl"] .nav-label { + text-align: right; +} + .nav-label:hover { color: var(--text); background: var(--bg-hover); @@ -364,6 +434,14 @@ color var(--duration-fast) ease; } +:root[dir="rtl"] .nav-item { + justify-content: flex-end; +} + +:root[dir="rtl"] .nav-item__text { + text-align: right; +} + .nav-item__icon { width: 16px; height: 16px; diff --git a/ui/src/styles/layout.mobile.css b/ui/src/styles/layout.mobile.css index 450a83608..14af05a1a 100644 --- a/ui/src/styles/layout.mobile.css +++ b/ui/src/styles/layout.mobile.css @@ -80,6 +80,15 @@ flex-wrap: nowrap; } + .language-toggle { + height: 28px; + padding: 0 6px; + } + + .language-toggle select { + font-size: 11px; + } + .topbar-status .pill { padding: 4px 8px; font-size: 11px; diff --git a/ui/src/ui/app-lifecycle.ts b/ui/src/ui/app-lifecycle.ts index cf5214250..a9bfff1fd 100644 --- a/ui/src/ui/app-lifecycle.ts +++ b/ui/src/ui/app-lifecycle.ts @@ -5,6 +5,7 @@ import { attachThemeListener, detachThemeListener, inferBasePath, + syncLocaleWithSettings, syncTabWithLocation, syncThemeWithSettings, } from "./app-settings"; @@ -45,6 +46,9 @@ export function handleConnected(host: LifecycleHost) { syncThemeWithSettings( host as unknown as Parameters[0], ); + syncLocaleWithSettings( + host as unknown as Parameters[0], + ); attachThemeListener( host as unknown as Parameters[0], ); diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index c2190e1c9..ad8d805f4 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -10,8 +10,9 @@ import { syncUrlWithSessionKey } from "./app-settings"; import type { SessionsListResult } from "./types"; import type { ThemeMode } from "./theme"; import type { ThemeTransitionContext } from "./theme-transition"; +import { t, type LanguageSetting, type Locale } from "./i18n"; -export function renderTab(state: AppViewState, tab: Tab) { +export function renderTab(state: AppViewState, tab: Tab, locale: Locale) { const href = pathForTab(tab, state.basePath); return html` - ${titleForTab(tab)} + ${titleForTab(tab, locale)} `; } -export function renderChatControls(state: AppViewState) { +export function renderChatControls(state: AppViewState, locale: Locale) { const mainSessionKey = resolveMainSessionKey(state.hello, state.sessionsResult); const sessionOptions = resolveSessionOptions( state.sessionKey, @@ -95,7 +96,7 @@ export function renderChatControls(state: AppViewState) { state.resetToolStream(); void refreshChat(state as unknown as Parameters[0]); }} - title="Refresh chat data" + title=${t(locale, "chat.controls.refresh")} > ${refreshIcon} @@ -112,8 +113,8 @@ export function renderChatControls(state: AppViewState) { }} aria-pressed=${showThinking} title=${disableThinkingToggle - ? "Disabled during onboarding" - : "Toggle assistant thinking/working output"} + ? t(locale, "chat.controls.disabled") + : t(locale, "chat.controls.thinking")} > ${icons.brain} @@ -129,8 +130,8 @@ export function renderChatControls(state: AppViewState) { }} aria-pressed=${focusActive} title=${disableFocusToggle - ? "Disabled during onboarding" - : "Toggle focus mode (hide sidebar + page header)"} + ? t(locale, "chat.controls.disabled") + : t(locale, "chat.controls.focus")} > ${focusIcon} @@ -209,14 +210,14 @@ export function renderThemeToggle(state: AppViewState) { return html`
-
+
@@ -224,8 +225,8 @@ export function renderThemeToggle(state: AppViewState) { class="theme-toggle__button ${state.theme === "light" ? "active" : ""}" @click=${applyTheme("light")} aria-pressed=${state.theme === "light"} - aria-label="Light theme" - title="Light" + aria-label=${t(state.locale, "theme.light")} + title=${t(state.locale, "theme.light")} > ${renderSunIcon()} @@ -233,8 +234,8 @@ export function renderThemeToggle(state: AppViewState) { class="theme-toggle__button ${state.theme === "dark" ? "active" : ""}" @click=${applyTheme("dark")} aria-pressed=${state.theme === "dark"} - aria-label="Dark theme" - title="Dark" + aria-label=${t(state.locale, "theme.dark")} + title=${t(state.locale, "theme.dark")} > ${renderMoonIcon()} @@ -243,6 +244,33 @@ export function renderThemeToggle(state: AppViewState) { `; } +const LANGUAGE_OPTIONS: Array<{ value: LanguageSetting; labelKey: string }> = [ + { value: "system", labelKey: "language.system" }, + { value: "en", labelKey: "language.en" }, + { value: "ar", labelKey: "language.ar" }, +]; + +export function renderLanguageToggle(state: AppViewState) { + return html` + + `; +} + function renderSunIcon() { return html`
+
@@ -134,22 +141,27 @@ export function renderApp(state: AppViewState) {
MOLTBOT
-
Gateway Dashboard
+
${t(state.locale, "app.brand.subtitle")}
- Health - ${state.connected ? "OK" : "Offline"} + ${t(state.locale, "app.health")} + + ${state.connected + ? t(state.locale, "app.status.ok") + : t(state.locale, "app.status.offline")} +
+ ${renderLanguageToggle(state)} ${renderThemeToggle(state)}