diff --git a/.env.example b/.env.example
index 9a4fd72a5..61a709acd 100644
--- a/.env.example
+++ b/.env.example
@@ -99,7 +99,7 @@ OPENCODE_MODEL_ID=big-pickle
# BASH_TOOL_DISPLAY_MAX_LENGTH=128
# Bot locale: supported locale code (default: en)
-# Supported locales: en, ar, de, es, fr, it, ko, pt, ru, zh
+# Supported locales: en, ar, de, es, fr, it, ko, pt, ru, tr, zh
# BOT_LOCALE=en
# Track background sessions in the currently selected project/worktree (default: true)
@@ -125,6 +125,7 @@ OPENCODE_MODEL_ID=big-pickle
# any setting the user has already changed via /settings is left untouched.
# Supported keys: ttsMode ("off"|"all"|"auto"), compactOutputMode (bool),
# showThinkingContent (bool), showAssistantRunFooter (bool),
+# pinnedDashboardEnabled (bool),
# responseStreamingMode ("edit"|"draft"), sendDiffFileAttachments (bool),
# promptQueueEnabled (bool)
# The preset is validated at startup: invalid JSON, a non-object value, unknown
diff --git a/PRODUCT.md b/PRODUCT.md
index ab04d1834..26731d60f 100644
--- a/PRODUCT.md
+++ b/PRODUCT.md
@@ -61,7 +61,8 @@ No public inbound ports are required for normal usage.
### Result delivery
- Send each completed assistant response after completion signal from SSE
-- Show elapsed time for tool calls running longer than 20 seconds, updated on a timer so it keeps counting while a tool blocks without producing output; covers subagent cards and compact mode, and the total duration stays on the finished tool line. A finished subagent card keeps the time its whole run took. Durations use the same `· 🕒 1h 2m 3s` format as the assistant run footer
+- Compact output mode shows thinking and writing on its single progress message from the start of work; that message is removed or marked finished when the run completes
+- Show elapsed time for tool calls running longer than 20 seconds, updated on a timer so it keeps counting while a tool blocks without producing output; covers subagent cards and compact mode, and the total duration stays on the finished tool line. In compact mode, while several tools of one step are in flight, the progress line shows the still-running one (the most recently started if several), with that tool's timer — not a finished sibling. A finished subagent card keeps the time its whole run took. Durations use the same `· 🕒 1h 2m 3s` format as the assistant run footer
- A subagent card shows Task, Agent, and Model; when OpenCode sends a variant, the Model line is `provider/id (variant)`
- Render assistant replies with native Telegram formatting: real tables with the column alignment declared in markdown, bullet lists with their nesting, block quotes that keep their nested content, headings, and syntax-highlighted code. Numbered lists and checklists keep literal markers (`1.`, ✅/🔲), because Telegram clients number a native ordered list from zero and do not draw the native checkbox at all
- Deliver reasoning as a collapsed quote that expands on tap
@@ -72,7 +73,7 @@ No public inbound ports are required for normal usage.
### Session status in chat
-- Keep a pinned status message in the chat
+- Keep a pinned status message in the chat; it can be turned off in `/settings` (default on)
- Show session title, project, model, context usage, and changed files; when a variant is set, the model line is `provider/id (variant)`
- Auto-update status from SSE and tool events
- Preserve pinned message ID across bot restarts
@@ -82,6 +83,7 @@ No public inbound ports are required for normal usage.
- Whitelist by Telegram user ID (single-user mode)
- Ignore messages from non-authorized users
- Ignore updates queued while the bot was offline or unreachable, so they are not executed on startup
+- If Telegram is unreachable at startup (network error, 5xx, 429), keep retrying with a growing delay capped at 60 seconds until it answers, then start polling; a rejected or invalid token (401/404) or any other fatal startup error logs the cause and exits the process with code 1 so a supervisor can restart it
### Configuration
@@ -94,7 +96,7 @@ No public inbound ports are required for normal usage.
- Configurable scheduled task limit (default: 10)
- Configurable bot locale
- Configurable visibility for thinking content and diff-file attachments
-- Configurable compact output, assistant footer, message queue, and TTS modes (`/settings`)
+- Configurable compact output, assistant footer, pinned session dashboard, message queue, and TTS modes (`/settings`)
- Configurable opt-in display of full thinking/reasoning content
- Configurable max code file size in KB (default: 100)
- Optional STT settings for voice transcription (`STT_API_URL`, `STT_API_KEY`, `STT_MODEL`, `STT_LANGUAGE`)
@@ -165,7 +167,7 @@ Agent picker behavior:
- [x] Background notifications for detached/non-current sessions in the currently selected project/worktree
- [x] Telegram-friendly result delivery, including sending generated code/files when needed
- [x] Interactive question and permission handling directly in chat (buttons + custom answers)
-- [x] Live pinned session status in chat (project, model with variant in parentheses when set, context usage, changed files)
+- [x] Live pinned session status in chat (project, model with variant in parentheses when set, context usage, changed files), with an opt-out in `/settings` that defaults to on
- [x] In-chat controls for model, agent, variant, and context
- [x] Built-in and custom command catalog access (`/commands`)
- [x] Trusted local JSON commands from the persistent application home, executed without OpenCode or model tokens
@@ -193,6 +195,7 @@ Agent picker behavior:
- [x] Optional message queue for text, voice, photos, rich formatted messages with photos, documents, and media groups sent while the agent is busy, managed from the bottom keyboard
- [x] Native Telegram rich message formatting for assistant replies (Bot API 10.1)
- [x] Incoming Telegram rich formatted messages (Bot API 10.1): converted to Markdown, accepted anywhere text is accepted, with photos attached and unsupported message types answered explicitly
+- [x] Startup either reaches Telegram polling or the process exits: transient Telegram failures are retried in-process; a bad token or other fatal startup error exits with code 1
## Current Task List
diff --git a/README.md b/README.md
index 165d12213..634553d88 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@ Scheduled tasks support. Turns the bot into a lightweight OpenClaw alternative f
Platforms: macOS, Windows, Linux
-Languages: English (`en`), العربية (`ar`), Deutsch (`de`), Español (`es`), Français (`fr`), Italiano (`it`), 한국어 (`ko`), Português (Brasil) (`pt`), Русский (`ru`), 简体中文 (`zh`)
+Languages: English (`en`), العربية (`ar`), Deutsch (`de`), Español (`es`), Français (`fr`), Italiano (`it`), 한국어 (`ko`), Português (Brasil) (`pt`), Русский (`ru`), Türkçe (`tr`), 简体中文 (`zh`)
@@ -196,7 +196,7 @@ For this to work, the console OpenCode instance must be started on the same port
### Localization
-- Supported locales: `en`, `ar`, `de`, `es`, `fr`, `it`, `ko`, `pt`, `ru`, `zh`
+- Supported locales: `en`, `ar`, `de`, `es`, `fr`, `it`, `ko`, `pt`, `ru`, `tr`, `zh`
- The setup wizard asks for language first
- You can change locale later with `BOT_LOCALE`
@@ -237,7 +237,7 @@ Configuration can be provided through process environment variables or an `.env`
| `OPENCODE_SERVER_PASSWORD` | Server auth password | No | — |
| `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
| `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
-| `BOT_LOCALE` | Bot UI language (supported locale code, e.g. `en`, `ar`, `de`, `es`, `fr`, `it`, `ko`, `pt`, `ru`, `zh`) | No | `en` |
+| `BOT_LOCALE` | Bot UI language (supported locale code, e.g. `en`, `ar`, `de`, `es`, `fr`, `it`, `ko`, `pt`, `ru`, `tr`, `zh`) | No | `en` |
| `SESSIONS_LIST_LIMIT` | Sessions per page in `/sessions` | No | `10` |
| `MESSAGES_LIST_LIMIT` | User messages per page in `/messages` | No | `10` |
| `PROJECTS_LIST_LIMIT` | Projects per page in `/projects` | No | `10` |
@@ -279,10 +279,11 @@ Logs are written to `./logs` when running from sources and to the runtime config
Runtime preferences are changed from `/settings` and stored in `settings.json`:
-- Compact output mode
+- Compact output mode: the progress message appears as soon as the model starts thinking or writing
- Delete progress on finish: available while compact output mode is on, removes the progress message when the run completes
- Thinking content display
- Assistant run footer display
+- Pin session dashboard
- Diff file attachments
- Response streaming mode: `edit` or `draft (experimental)`; applies only to final assistant replies, not thinking messages
- Audio replies: `off`, `all`, or `auto` when TTS is configured
@@ -293,7 +294,7 @@ With the message queue enabled, text, transcribed voice, photos, rich formatted
You can seed the initial defaults for any of these settings without hard-coding them in your Docker image by setting `INITIAL_SETTINGS_PRESET` to a JSON object. Only keys not yet persisted in `settings.json` are affected — settings the user has already changed via `/settings` are left untouched:
```env
-INITIAL_SETTINGS_PRESET={"showAssistantRunFooter":false,"compactOutputMode":true,"ttsMode":"auto"}
+INITIAL_SETTINGS_PRESET={"showAssistantRunFooter":false,"compactOutputMode":true,"ttsMode":"auto","pinnedDashboardEnabled":true}
```
Settings are written atomically: the new content goes to a temporary file that then replaces `settings.json`, and the previous version is kept as `settings.json.bak`. A crash during a write can never leave a truncated file — the bot falls back to the backup on the next start. If both `settings.json` and `settings.json.bak` are unreadable, the bot refuses to start instead of overwriting them, and the error names the file so you can fix or remove it manually.
diff --git a/docs/LOCALIZATION_GUIDE.md b/docs/LOCALIZATION_GUIDE.md
index 0462d964b..a8a23245a 100644
--- a/docs/LOCALIZATION_GUIDE.md
+++ b/docs/LOCALIZATION_GUIDE.md
@@ -109,6 +109,11 @@ Manual checks:
By default, adding a new locale dictionary and registering it does not require new tests if localization logic is unchanged.
+Two existing tests keep a hand-written list of locales and must get the new one, otherwise they silently skip it:
+
+- `tests/i18n/placeholders.test.ts` - placeholders match `en`
+- `tests/i18n/callback-answer-length.test.ts` - callback answers fit Telegram's limit
+
Add or update tests only if you change locale resolution/normalization behavior (for example alias handling).
Relevant test files if needed:
@@ -123,5 +128,6 @@ Relevant test files if needed:
- [ ] Added locale entry to `LOCALE_DEFINITIONS` (`en` first, then alphabetical)
- [ ] Updated `README.md` (`Languages:` line, `Supported locales:` list, `BOT_LOCALE` table row)
- [ ] Updated `.env.example` (`# Supported locales:` comment)
+- [ ] Added the locale to `tests/i18n/placeholders.test.ts` and `tests/i18n/callback-answer-length.test.ts`
- [ ] Ran `npm run build`, `npm run lint`, `npm run typecheck`, `npm test`
- [ ] Manually validated `BOT_LOCALE` and wizard language selection
diff --git a/docs/release-notes/v0.25.2.md b/docs/release-notes/v0.25.2.md
new file mode 100644
index 000000000..95dee1548
--- /dev/null
+++ b/docs/release-notes/v0.25.2.md
@@ -0,0 +1,12 @@
+## Changes
+
+- **Optional dashboard pinning** — the pinned session status message can now be turned off with the "Pin session dashboard" toggle in `/settings`.
+- **Turkish localization** — the bot interface is now available in Turkish.
+
+## Fixes
+
+- **Compact progress at the start of work** — in compact mode the progress message now appears as soon as the model starts thinking or writing, instead of the chat staying silent until the first tool runs.
+- **Compact progress timer with parallel operations** — when several operations run in one step, the compact progress line now keeps showing the still-running one with its timer instead of getting stuck on an already finished sibling.
+- **Startup when Telegram is unreachable** — the bot now keeps retrying Telegram at startup until it answers, and exits with an error on an invalid token instead of hanging without polling.
+
+Full changelog: https://github.com/grinev/opencode-telegram-bot/compare/v0.25.1...v0.25.2
diff --git a/package-lock.json b/package-lock.json
index c44659051..4ea6435a3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@grinev/opencode-telegram-bot",
- "version": "0.25.1",
+ "version": "0.25.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@grinev/opencode-telegram-bot",
- "version": "0.25.1",
+ "version": "0.25.2",
"license": "MIT",
"dependencies": {
"@google-cloud/text-to-speech": "^6.4.0",
diff --git a/package.json b/package.json
index d61ac2a50..b3025f713 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@grinev/opencode-telegram-bot",
- "version": "0.25.1",
+ "version": "0.25.2",
"description": "Telegram bot client for OpenCode to run and monitor coding tasks from chat.",
"type": "module",
"main": "./dist/index.js",
diff --git a/src/app/bootstrap/start-bot-app.ts b/src/app/bootstrap/start-bot-app.ts
index e9ad497d1..a76ef6c47 100644
--- a/src/app/bootstrap/start-bot-app.ts
+++ b/src/app/bootstrap/start-bot-app.ts
@@ -1,5 +1,5 @@
import fs from "node:fs/promises";
-import { cleanupBotRuntime, createBot } from "../../bot/index.js";
+import { cleanupBotRuntime, createBot, restoreFollowedSessionOnPollingStart } from "../../bot/index.js";
import { createScheduledTaskDeliverySender } from "../../bot/messages/scheduled-task-delivery.js";
import { config } from "../../config.js";
import { opencodeAutoRestartService } from "../../opencode/auto-restart.js";
@@ -19,10 +19,112 @@ import { clearServiceStateFile } from "../../runtime/service/manager.js";
import { getServiceStateFilePathFromEnv, isServiceChildProcess } from "../../runtime/service/env.js";
import { flushLogger, getLogFilePath, initializeLogger, logger } from "../../utils/logger.js";
import { safeBackgroundTask } from "../../utils/safe-background-task.js";
+import { getTelegramRetryAfterMs } from "../../utils/telegram-rate-limit-retry.js";
const SHUTDOWN_TIMEOUT_MS = 5000;
const SETTINGS_FLUSH_TIMEOUT_MS = 1000;
const LOG_FLUSH_TIMEOUT_MS = 1000;
+const TELEGRAM_STARTUP_RETRY_BASE_MS = 1000;
+const TELEGRAM_STARTUP_RETRY_CAP_MS = 60_000;
+const TELEGRAM_FATAL_TOKEN_CODES = new Set([401, 404]);
+
+function getTelegramErrorCode(error: unknown): number | null {
+ if (typeof error !== "object" || error === null) {
+ return null;
+ }
+
+ const errorCode = Reflect.get(error, "error_code");
+ if (typeof errorCode === "number" && Number.isFinite(errorCode)) {
+ return errorCode;
+ }
+
+ return null;
+}
+
+function isFatalTelegramTokenError(error: unknown): boolean {
+ const errorCode = getTelegramErrorCode(error);
+ return errorCode !== null && TELEGRAM_FATAL_TOKEN_CODES.has(errorCode);
+}
+
+function isTelegramStartupServerError(error: unknown): boolean {
+ const errorCode = getTelegramErrorCode(error);
+ return errorCode !== null && errorCode >= 500 && errorCode < 600;
+}
+
+function isRetryableTelegramStartupError(error: unknown): boolean {
+ if (isTelegramStartupServerError(error) || getTelegramRetryAfterMs(error) !== null) {
+ return true;
+ }
+
+ if (typeof error === "object" && error !== null && Reflect.get(error, "name") === "HttpError") {
+ return true;
+ }
+
+ if (error instanceof Error && /Network request for '.+' failed/i.test(error.message)) {
+ return true;
+ }
+
+ return false;
+}
+
+function nextTelegramStartupRetryDelayMs(error: unknown, attempt: number): number {
+ const exponentialMs = Math.min(
+ TELEGRAM_STARTUP_RETRY_BASE_MS * 2 ** Math.max(0, attempt - 1),
+ TELEGRAM_STARTUP_RETRY_CAP_MS,
+ );
+ const fromErrorMs = getTelegramRetryAfterMs(error, TELEGRAM_STARTUP_RETRY_BASE_MS, attempt - 1);
+ if (fromErrorMs === null) {
+ return exponentialMs;
+ }
+
+ return Math.min(Math.max(exponentialMs, fromErrorMs), TELEGRAM_STARTUP_RETRY_CAP_MS);
+}
+
+function wait(ms: number): Promise {
+ return new Promise((resolve) => {
+ setTimeout(resolve, ms);
+ });
+}
+
+async function callTelegramAtStartup(
+ label: string,
+ operation: () => Promise,
+ isShutdown: () => boolean,
+): Promise {
+ let attempt = 0;
+
+ while (true) {
+ if (isShutdown()) {
+ return "shutdown";
+ }
+
+ try {
+ return await operation();
+ } catch (error) {
+ if (isShutdown()) {
+ return "shutdown";
+ }
+
+ if (isFatalTelegramTokenError(error)) {
+ logger.error("[App] Telegram rejected the bot token; not retrying", error);
+ throw error;
+ }
+
+ if (!isRetryableTelegramStartupError(error)) {
+ logger.error("[App] Telegram startup failed; not retrying", error);
+ throw error;
+ }
+
+ attempt += 1;
+ const delayMs = nextTelegramStartupRetryDelayMs(error, attempt);
+ logger.warn(
+ `[App] Telegram ${label} failed (attempt ${attempt}); retrying in ${delayMs}ms`,
+ error,
+ );
+ await wait(delayMs);
+ }
+ }
+}
export async function startBotApp(): Promise {
await initializeLogger();
@@ -159,7 +261,16 @@ export async function startBotApp(): Promise {
process.on("SIGINT", handleSigint);
process.on("SIGTERM", handleSigterm);
- const webhookInfo = await bot.api.getWebhookInfo();
+ const isShutdown = (): boolean => shutdownStarted;
+ const webhookInfo = await callTelegramAtStartup(
+ "getWebhookInfo",
+ () => bot.api.getWebhookInfo(),
+ isShutdown,
+ );
+ if (webhookInfo === "shutdown") {
+ return;
+ }
+
if (webhookInfo.pending_update_count > 0) {
// Approximate: more updates can arrive before long polling actually drops
// the queue, and Telegram does not report how many were discarded.
@@ -169,17 +280,34 @@ export async function startBotApp(): Promise {
}
if (webhookInfo.url) {
logger.info(`[Bot] Webhook detected: ${webhookInfo.url}, removing...`);
- await bot.api.deleteWebhook();
+ const deleted = await callTelegramAtStartup("deleteWebhook", () => bot.api.deleteWebhook(), isShutdown);
+ if (deleted === "shutdown") {
+ return;
+ }
logger.info("[Bot] Webhook removed, switching to long polling");
}
+ const identity = await callTelegramAtStartup("getMe", () => bot.api.getMe(), isShutdown);
+ if (identity === "shutdown" || shutdownStarted) {
+ return;
+ }
+ bot.botInfo = identity;
+
try {
await bot.start({
drop_pending_updates: true,
onStart: (botInfo) => {
logger.info(`Bot @${botInfo.username} started!`);
+ restoreFollowedSessionOnPollingStart(bot);
},
});
+ } catch (error) {
+ if (isFatalTelegramTokenError(error)) {
+ logger.error("[App] Telegram rejected the bot token; not retrying", error);
+ } else {
+ logger.error("[App] Telegram startup failed; not retrying", error);
+ }
+ throw error;
} finally {
process.off("unhandledRejection", unhandledRejectionHandler);
process.off("uncaughtException", uncaughtExceptionHandler);
diff --git a/src/app/stores/settings-store.ts b/src/app/stores/settings-store.ts
index 97e5a918f..63113f6e9 100644
--- a/src/app/stores/settings-store.ts
+++ b/src/app/stores/settings-store.ts
@@ -219,6 +219,15 @@ export function setShowAssistantRunFooter(enabled: boolean): void {
void writeSettingsFile(currentSettings);
}
+export function getPinnedDashboardEnabled(): boolean {
+ return currentSettings.pinnedDashboardEnabled ?? true;
+}
+
+export function setPinnedDashboardEnabled(enabled: boolean): void {
+ currentSettings.pinnedDashboardEnabled = enabled;
+ void writeSettingsFile(currentSettings);
+}
+
export type { ResponseStreamingMode };
export function getResponseStreamingMode(): ResponseStreamingMode {
@@ -340,6 +349,7 @@ function applyInitialSettingsPreset(preset: Record): void {
"deleteCompactProgressOnFinish",
"showThinkingContent",
"showAssistantRunFooter",
+ "pinnedDashboardEnabled",
"responseStreamingMode",
"sendDiffFileAttachments",
"promptQueueEnabled",
@@ -373,7 +383,7 @@ function applyInitialSettingsPreset(preset: Record): void {
currentSettings.responseStreamingMode = value as ResponseStreamingMode;
}
} else {
- // Boolean settings: compactOutputMode, deleteCompactProgressOnFinish, showThinkingContent, showAssistantRunFooter, sendDiffFileAttachments, promptQueueEnabled
+ // Boolean settings: compactOutputMode, deleteCompactProgressOnFinish, showThinkingContent, showAssistantRunFooter, pinnedDashboardEnabled, sendDiffFileAttachments, promptQueueEnabled
if (typeof value !== "boolean") {
throw new Error(
`INITIAL_SETTINGS_PRESET: "${key}" must be a boolean.`,
@@ -396,6 +406,10 @@ function applyInitialSettingsPreset(preset: Record): void {
if (currentSettings.showAssistantRunFooter === undefined)
currentSettings.showAssistantRunFooter = value;
break;
+ case "pinnedDashboardEnabled":
+ if (currentSettings.pinnedDashboardEnabled === undefined)
+ currentSettings.pinnedDashboardEnabled = value;
+ break;
case "sendDiffFileAttachments":
if (currentSettings.sendDiffFileAttachments === undefined)
currentSettings.sendDiffFileAttachments = value;
diff --git a/src/app/types/settings.ts b/src/app/types/settings.ts
index 893623ce2..0cc876290 100644
--- a/src/app/types/settings.ts
+++ b/src/app/types/settings.ts
@@ -21,6 +21,7 @@ export interface Settings {
deleteCompactProgressOnFinish?: boolean | undefined;
showThinkingContent?: boolean | undefined;
showAssistantRunFooter?: boolean | undefined;
+ pinnedDashboardEnabled?: boolean | undefined;
responseStreamingMode?: ResponseStreamingMode | undefined;
sendDiffFileAttachments?: boolean | undefined;
promptQueueEnabled?: boolean | undefined;
diff --git a/src/bot/callbacks/settings-callback-handler.ts b/src/bot/callbacks/settings-callback-handler.ts
index 1e2999af6..131f6ba1b 100644
--- a/src/bot/callbacks/settings-callback-handler.ts
+++ b/src/bot/callbacks/settings-callback-handler.ts
@@ -6,11 +6,13 @@ import {
getPromptQueueEnabled,
getResponseStreamingMode,
getSendDiffFileAttachments,
+ getPinnedDashboardEnabled,
getShowAssistantRunFooter,
getShowThinkingContent,
getTtsMode,
setCompactOutputMode,
setDeleteCompactProgressOnFinish,
+ setPinnedDashboardEnabled,
setPromptQueueEnabled,
setResponseStreamingMode,
setSendDiffFileAttachments,
@@ -23,6 +25,7 @@ import {
import { t } from "../../i18n/index.js";
import { logger } from "../../utils/logger.js";
import { appendInlineMenuCancelButton, ensureActiveInlineMenu } from "../menus/inline-menu.js";
+import { pinnedMessageManager } from "../pinned/pinned-message-manager.js";
import {
buildSettingsMenuView,
SETTINGS_ASSISTANT_FOOTER_CALLBACK,
@@ -30,6 +33,7 @@ import {
SETTINGS_COMPACT_OUTPUT_CALLBACK,
SETTINGS_DELETE_PROGRESS_ON_FINISH_CALLBACK,
SETTINGS_DIFF_FILES_CALLBACK,
+ SETTINGS_PIN_SESSION_DASHBOARD_CALLBACK,
SETTINGS_PROMPT_QUEUE_CALLBACK,
SETTINGS_RESPONSE_STREAMING_CALLBACK,
SETTINGS_THINKING_CONTENT_CALLBACK,
@@ -147,6 +151,22 @@ export async function handleSettingsCallback(ctx: Context): Promise {
return true;
}
+ if (callbackData === SETTINGS_PIN_SESSION_DASHBOARD_CALLBACK) {
+ const nextEnabled = !getPinnedDashboardEnabled();
+ const chatId = ctx.chat?.id ?? ctx.callbackQuery?.message?.chat?.id;
+ if (ctx.api && chatId !== undefined) {
+ pinnedMessageManager.initialize(ctx.api, chatId);
+ }
+ await pinnedMessageManager.applyPinnedDashboardEnabled(nextEnabled);
+ setPinnedDashboardEnabled(nextEnabled);
+ const { text, keyboard } = buildSettingsMenuView();
+ await ctx.answerCallbackQuery({ text: t("settings.saved") });
+ await ctx.editMessageText(text, {
+ reply_markup: appendInlineMenuCancelButton(keyboard, "settings"),
+ });
+ return true;
+ }
+
if (callbackData === SETTINGS_TTS_CALLBACK) {
const nextMode = getNextTtsMode(getTtsMode());
diff --git a/src/bot/index.ts b/src/bot/index.ts
index e60131f18..9af711ca3 100644
--- a/src/bot/index.ts
+++ b/src/bot/index.ts
@@ -43,6 +43,8 @@ const TRANSIENT_RETRY_SAFE_TELEGRAM_METHODS = new Set([
"sendRichMessageDraft",
]);
+const STARTUP_MANAGED_TELEGRAM_METHODS = new Set(["deleteWebhook", "getMe", "getWebhookInfo"]);
+
interface TelegramApiErrorResponse {
ok: false;
error_code: number;
@@ -134,6 +136,10 @@ export function createBot(localCommandRegistry = LocalCommandRegistry.empty()):
return prev(method, payload, signal);
}
+ if (STARTUP_MANAGED_TELEGRAM_METHODS.has(method)) {
+ return prev(method, payload, signal);
+ }
+
if (method === "sendMessage") {
logger.debug(`[Bot API] sendMessage to chat ${(payload as { chat_id?: number }).chat_id}`);
}
@@ -230,6 +236,19 @@ export function createBot(localCommandRegistry = LocalCommandRegistry.empty()):
return bot;
}
+export function restoreFollowedSessionOnPollingStart(bot: Bot): void {
+ safeBackgroundTask({
+ taskName: "bot.restoreAfterPollingStart",
+ task: () =>
+ restoreAttachedCurrentSession({
+ bot,
+ chatId: config.telegram.allowedUserId,
+ ensureEventSubscription: eventSubscriptionService.ensureEventSubscription,
+ forceFullRestore: true,
+ }),
+ });
+}
+
export function cleanupBotRuntime(reason: string): void {
unsubscribeReadyRestore?.();
unsubscribeReadyRestore = null;
diff --git a/src/bot/menus/settings-menu.ts b/src/bot/menus/settings-menu.ts
index cc8608203..57d7a8b99 100644
--- a/src/bot/menus/settings-menu.ts
+++ b/src/bot/menus/settings-menu.ts
@@ -5,6 +5,7 @@ import {
getPromptQueueEnabled,
getResponseStreamingMode,
getSendDiffFileAttachments,
+ getPinnedDashboardEnabled,
getShowAssistantRunFooter,
getShowThinkingContent,
getTtsMode,
@@ -20,6 +21,7 @@ export const SETTINGS_THINKING_CONTENT_CALLBACK = `${SETTINGS_CALLBACK_PREFIX}th
export const SETTINGS_RESPONSE_STREAMING_CALLBACK = `${SETTINGS_CALLBACK_PREFIX}response_streaming`;
export const SETTINGS_DIFF_FILES_CALLBACK = `${SETTINGS_CALLBACK_PREFIX}diff_files`;
export const SETTINGS_ASSISTANT_FOOTER_CALLBACK = `${SETTINGS_CALLBACK_PREFIX}assistant_footer`;
+export const SETTINGS_PIN_SESSION_DASHBOARD_CALLBACK = `${SETTINGS_CALLBACK_PREFIX}pin_session_dashboard`;
export const SETTINGS_TTS_CALLBACK = `${SETTINGS_CALLBACK_PREFIX}tts`;
export const SETTINGS_PROMPT_QUEUE_CALLBACK = `${SETTINGS_CALLBACK_PREFIX}prompt_queue`;
@@ -52,6 +54,7 @@ export function buildSettingsMenuView(): { text: string; keyboard: InlineKeyboar
const responseStreamingMode = getResponseStreamingMode();
const sendDiffFileAttachments = getSendDiffFileAttachments();
const showAssistantRunFooter = getShowAssistantRunFooter();
+ const pinnedDashboardEnabled = getPinnedDashboardEnabled();
const ttsMode = getTtsMode();
const promptQueueEnabled = getPromptQueueEnabled();
const keyboard = new InlineKeyboard()
@@ -89,6 +92,11 @@ export function buildSettingsMenuView(): { text: string; keyboard: InlineKeyboar
SETTINGS_ASSISTANT_FOOTER_CALLBACK,
)
.row()
+ .text(
+ `${t("settings.pin_session_dashboard.label")}: ${formatBooleanSettingValue(pinnedDashboardEnabled)}`,
+ SETTINGS_PIN_SESSION_DASHBOARD_CALLBACK,
+ )
+ .row()
.text(`${t("settings.tts.label")}: ${formatTtsModeValue(ttsMode)}`, SETTINGS_TTS_CALLBACK)
.row()
.text(
diff --git a/src/bot/pinned/pinned-message-manager.ts b/src/bot/pinned/pinned-message-manager.ts
index 7232ee2c4..b5af50560 100644
--- a/src/bot/pinned/pinned-message-manager.ts
+++ b/src/bot/pinned/pinned-message-manager.ts
@@ -5,6 +5,7 @@ import { getGitWorktreeContext } from "../../app/services/worktree-service.js";
import { getCurrentSession } from "../../app/services/session-service.js";
import {
getCurrentProject,
+ getPinnedDashboardEnabled,
getPinnedMessageId,
setPinnedMessageId,
clearPinnedMessageId,
@@ -52,6 +53,7 @@ class PinnedMessageManager {
private pendingUpdate = false;
private pendingForceUpdate = false;
private lastRenderedMessageText: string | null = null;
+ private leftoverUnpinMessageId: number | null = null;
/**
* Initialize manager with bot API and chat ID
@@ -63,8 +65,12 @@ class PinnedMessageManager {
// Restore pinned message ID from settings
const savedMessageId = getPinnedMessageId();
if (savedMessageId) {
- this.state.messageId = savedMessageId;
this.state.chatId = chatId;
+ if (getPinnedDashboardEnabled()) {
+ this.state.messageId = savedMessageId;
+ } else {
+ this.leftoverUnpinMessageId = savedMessageId;
+ }
}
}
@@ -74,6 +80,10 @@ class PinnedMessageManager {
async onSessionChange(sessionId: string, sessionTitle: string): Promise {
logger.info(`[PinnedManager] Session changed: ${sessionId}, title: ${sessionTitle}`);
+ if (!getPinnedDashboardEnabled()) {
+ await this.dropOwnedDashboardQuietly();
+ }
+
// Reset tokens for new session
this.state.tokensUsed = 0;
this.state.cost = 0;
@@ -100,11 +110,11 @@ class PinnedMessageManager {
this.pendingUpdate = false;
this.pendingForceUpdate = false;
- // Unpin old message and create new one
- await this.unpinOldMessage();
- await this.createPinnedMessage();
+ if (getPinnedDashboardEnabled()) {
+ await this.unpinOldMessage();
+ await this.createPinnedMessage();
+ }
- // Load existing diffs from API (for session restoration)
await this.loadDiffsFromApi(sessionId);
}
@@ -114,6 +124,10 @@ class PinnedMessageManager {
async restoreExistingSession(sessionId: string, sessionTitle: string): Promise {
logger.info(`[PinnedManager] Restoring existing pinned message for session: ${sessionId}`);
+ if (!getPinnedDashboardEnabled()) {
+ await this.dropOwnedDashboardQuietly();
+ }
+
this.state.sessionId = sessionId;
this.state.sessionTitle = sessionTitle || t("pinned.default_session_title");
this.state.attachActive = false;
@@ -130,7 +144,9 @@ class PinnedMessageManager {
this.onKeyboardUpdateCallback(this.state.tokensUsed, this.state.tokensLimit);
}
- await this.updatePinnedMessage(true);
+ if (getPinnedDashboardEnabled()) {
+ await this.updatePinnedMessage(true);
+ }
await this.loadDiffsFromApi(sessionId);
}
@@ -760,10 +776,175 @@ class PinnedMessageManager {
return lines.join("\n");
}
- /**
- * Create and pin a new status message
- */
+
+ async applyPinnedDashboardEnabled(enabled: boolean): Promise {
+ if (!enabled) {
+ await this.unpinOwnedDashboard();
+ return;
+ }
+
+ if (!this.api || !this.chatId) {
+ throw new Error("Pinned manager is not initialized");
+ }
+
+ if (this.leftoverUnpinMessageId) {
+ await this.unpinMessageOrThrow(this.leftoverUnpinMessageId);
+ this.forgetOwnedDashboard();
+ }
+
+ const session = getCurrentSession();
+ if (!session) {
+ return;
+ }
+
+ if (this.state.messageId && this.state.sessionId === session.id) {
+ try {
+ await this.api.pinChatMessage(this.chatId, this.state.messageId, {
+ disable_notification: true,
+ });
+ setPinnedMessageId(this.state.messageId);
+ return;
+ } catch (err) {
+ if (!this.isMessageMissingError(err)) {
+ throw err;
+ }
+ this.forgetOwnedDashboard();
+ }
+ } else if (this.state.messageId) {
+ await this.unpinOwnedDashboard();
+ }
+
+ this.state.sessionId = session.id;
+ this.state.sessionTitle = session.title || t("pinned.default_session_title");
+ this.hydrateDashboardFromLocalState();
+ await this.sendAndPinForEnable();
+ }
+
+ private async sendAndPinForEnable(): Promise {
+ if (!this.api || !this.chatId) {
+ throw new Error("Pinned manager is not initialized");
+ }
+
+ const text = this.formatMessage();
+ const sentMessage = await this.api.sendMessage(this.chatId, text);
+
+ this.state.messageId = sentMessage.message_id;
+ this.state.chatId = this.chatId;
+ this.state.lastUpdated = Date.now();
+ this.lastRenderedMessageText = text;
+
+ try {
+ await this.api.pinChatMessage(this.chatId, sentMessage.message_id, {
+ disable_notification: true,
+ });
+ } catch (err) {
+ if (this.isMessageMissingError(err)) {
+ this.forgetOwnedDashboard();
+ }
+ throw err;
+ }
+
+ setPinnedMessageId(sentMessage.message_id);
+ logger.info(`[PinnedManager] Created and pinned message: ${sentMessage.message_id}`);
+ }
+
+ private async unpinOwnedDashboard(): Promise {
+ const messageId = this.state.messageId ?? this.leftoverUnpinMessageId;
+ if (!messageId) {
+ return;
+ }
+
+ if (!this.api || !this.chatId) {
+ this.forgetOwnedDashboard();
+ return;
+ }
+
+ await this.unpinMessageOrThrow(messageId);
+ this.forgetOwnedDashboard();
+ }
+
+ private async unpinMessageOrThrow(messageId: number): Promise {
+ if (!this.api || !this.chatId) {
+ throw new Error("Pinned manager is not initialized");
+ }
+
+ try {
+ await this.api.unpinChatMessage(this.chatId, messageId);
+ } catch (err) {
+ if (!this.isMessageMissingError(err)) {
+ throw err;
+ }
+ }
+ }
+
+ private forgetOwnedDashboard(): void {
+ this.state.messageId = null;
+ this.leftoverUnpinMessageId = null;
+ this.lastRenderedMessageText = null;
+ this.pendingUpdate = false;
+ this.pendingForceUpdate = false;
+ clearPinnedMessageId();
+ }
+
+ private hydrateDashboardFromLocalState(): void {
+ if (!this.state.projectPath) {
+ const project = getCurrentProject();
+ this.state.projectPath = project?.worktree || t("pinned.unknown");
+ }
+
+ if (!this.state.tokensLimit) {
+ const limit = this.contextLimit || DEFAULT_CONTEXT_LIMIT;
+ this.contextLimit = limit;
+ this.state.tokensLimit = limit;
+ }
+ }
+
+ private async dropOwnedDashboardQuietly(): Promise {
+ const messageId = this.state.messageId ?? this.leftoverUnpinMessageId;
+ if (!messageId) {
+ return;
+ }
+
+ if (!this.api || !this.chatId) {
+ this.parkLeftoverDashboard(messageId);
+ return;
+ }
+
+ try {
+ await this.api.unpinChatMessage(this.chatId, messageId);
+ } catch (err) {
+ if (!this.isMessageMissingError(err)) {
+ logger.warn("[PinnedManager] Could not unpin leftover dashboard:", err);
+ this.parkLeftoverDashboard(messageId);
+ return;
+ }
+ }
+
+ this.forgetOwnedDashboard();
+ }
+
+ private parkLeftoverDashboard(messageId: number): void {
+ this.leftoverUnpinMessageId = messageId;
+ this.state.messageId = null;
+ this.lastRenderedMessageText = null;
+ }
+
+ private isMessageMissingError(err: unknown): boolean {
+ const errorMessage = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
+ return (
+ errorMessage.includes("message to edit not found") ||
+ errorMessage.includes("message to pin not found") ||
+ errorMessage.includes("message to unpin not found") ||
+ errorMessage.includes("message not found") ||
+ errorMessage.includes("not pinned")
+ );
+ }
+
private async createPinnedMessage(): Promise {
+ if (!getPinnedDashboardEnabled()) {
+ return;
+ }
+
if (!this.api || !this.chatId) {
logger.warn("[PinnedManager] API or chatId not initialized");
return;
@@ -798,6 +979,13 @@ class PinnedMessageManager {
* Update existing pinned message text
*/
private async updatePinnedMessage(forceUpdate: boolean = false): Promise {
+ if (!getPinnedDashboardEnabled()) {
+ if (this.onKeyboardUpdateCallback && this.state.tokensLimit > 0) {
+ this.onKeyboardUpdateCallback(this.state.tokensUsed, this.state.tokensLimit);
+ }
+ return;
+ }
+
if (!this.api || !this.chatId || !this.state.messageId) {
return;
}
@@ -866,7 +1054,9 @@ class PinnedMessageManager {
this.lastRenderedMessageText = null;
this.pendingForceUpdate = false;
clearPinnedMessageId();
- await this.createPinnedMessage();
+ if (getPinnedDashboardEnabled()) {
+ await this.createPinnedMessage();
+ }
continue;
}
@@ -879,6 +1069,11 @@ class PinnedMessageManager {
* Unpin old message before creating new one
*/
private async unpinOldMessage(): Promise {
+ if (!getPinnedDashboardEnabled()) {
+ this.forgetOwnedDashboard();
+ return;
+ }
+
if (!this.api || !this.chatId) {
return;
}
@@ -917,46 +1112,31 @@ class PinnedMessageManager {
* Clear pinned message (when switching projects)
*/
async clear(): Promise {
+ if (!getPinnedDashboardEnabled()) {
+ await this.dropOwnedDashboardQuietly();
+ }
+
+ const parkedLeftover = this.leftoverUnpinMessageId;
+
if (!this.api || !this.chatId) {
- // Just reset state if not initialized
- this.state.messageId = null;
- this.state.sessionId = null;
- this.state.sessionTitle = t("pinned.default_session_title");
- this.state.attachActive = false;
- this.state.attachBusy = false;
- this.state.tokensUsed = 0;
- this.state.tokensLimit = 0;
- this.state.projectPath = "";
- this.state.projectBranch = null;
- this.state.projectWorktreePath = null;
- this.state.changedFiles = [];
- this.lastRenderedMessageText = null;
- this.pendingUpdate = false;
- this.pendingForceUpdate = false;
- clearPinnedMessageId();
+ this.resetClearedSessionState();
+ this.leftoverUnpinMessageId = parkedLeftover;
+ if (!parkedLeftover) {
+ clearPinnedMessageId();
+ }
return;
}
try {
- // Unpin all messages
- await this.api.unpinAllChatMessages(this.chatId).catch(() => {});
+ if (getPinnedDashboardEnabled()) {
+ await this.api.unpinAllChatMessages(this.chatId).catch(() => {});
+ }
- // Reset state
- this.state.messageId = null;
- this.state.sessionId = null;
- this.state.sessionTitle = t("pinned.default_session_title");
- this.state.attachActive = false;
- this.state.attachBusy = false;
- this.state.projectPath = "";
- this.state.projectBranch = null;
- this.state.projectWorktreePath = null;
- this.state.tokensUsed = 0;
- this.state.tokensLimit = 0;
- this.state.changedFiles = [];
- this.lastRenderedMessageText = null;
- this.pendingUpdate = false;
- this.pendingForceUpdate = false;
- clearPinnedMessageId();
+ this.resetClearedSessionState();
+ this.leftoverUnpinMessageId = parkedLeftover;
+ if (!parkedLeftover) {
+ clearPinnedMessageId();
+ }
logger.info("[PinnedManager] Cleared pinned message state");
} catch (err) {
@@ -964,6 +1144,23 @@ class PinnedMessageManager {
}
}
+ private resetClearedSessionState(): void {
+ this.state.messageId = null;
+ this.state.sessionId = null;
+ this.state.sessionTitle = t("pinned.default_session_title");
+ this.state.attachActive = false;
+ this.state.attachBusy = false;
+ this.state.projectPath = "";
+ this.state.projectBranch = null;
+ this.state.projectWorktreePath = null;
+ this.state.tokensUsed = 0;
+ this.state.tokensLimit = 0;
+ this.state.changedFiles = [];
+ this.lastRenderedMessageText = null;
+ this.pendingUpdate = false;
+ this.pendingForceUpdate = false;
+ }
+
__resetForTests(): void {
if (this.updateDebounceTimer) {
clearTimeout(this.updateDebounceTimer);
@@ -978,6 +1175,7 @@ class PinnedMessageManager {
this.pendingUpdate = false;
this.pendingForceUpdate = false;
this.lastRenderedMessageText = null;
+ this.leftoverUnpinMessageId = null;
this.state = {
messageId: null,
chatId: null,
diff --git a/src/bot/services/event-subscription-service.ts b/src/bot/services/event-subscription-service.ts
index e2fe62caa..cc305ab67 100644
--- a/src/bot/services/event-subscription-service.ts
+++ b/src/bot/services/event-subscription-service.ts
@@ -9,7 +9,11 @@ import {
type SubagentInfo,
type ToolInfo,
} from "../../app/managers/summary-aggregation-manager.js";
-import { formatCompactToolActivity, formatToolInfo } from "../../app/formatters/summary-formatter.js";
+import {
+ formatCompactToolActivity,
+ formatCompactToolInfo,
+ formatToolInfo,
+} from "../../app/formatters/summary-formatter.js";
import { renderSubagentCard } from "../../app/formatters/subagent-formatter.js";
import {
RUNNING_ICON,
@@ -132,7 +136,6 @@ class EventSubscriptionService implements BotEventSubscriptionService {
private nextDraftId = 1;
private readonly thinkingSections = new Map();
private readonly sessionCompletionTasks = new Map>();
- private readonly compactProgressFinalizationTasks = new Map>();
private readonly assistantEditResponseStreamer: ResponseStreamer;
private readonly assistantDraftResponseStreamer: ResponseStreamer;
private readonly thinkingResponseStreamer: ResponseStreamer;
@@ -520,7 +523,6 @@ class EventSubscriptionService implements BotEventSubscriptionService {
this.toolCallStreamer.clearAll(reason);
this.toolMessageBatcher.clearAll(reason);
this.compactProgressStreamer.clearAll(reason);
- this.compactProgressFinalizationTasks.clear();
this.thinkingSections.clear();
this.sessionCompletionTasks.clear();
this.clearToolElapsedState(null, reason);
@@ -553,7 +555,6 @@ class EventSubscriptionService implements BotEventSubscriptionService {
this.toolCallStreamer.clearAll("summary_aggregator_clear");
this.clearAllResponseStreams("summary_aggregator_clear");
this.compactProgressStreamer.clearAll("summary_aggregator_clear");
- this.compactProgressFinalizationTasks.clear();
this.thinkingSections.clear();
this.clearToolElapsedState(null, "summary_aggregator_clear");
});
@@ -568,28 +569,8 @@ class EventSubscriptionService implements BotEventSubscriptionService {
return;
}
- if (isCompactProgressMode()) {
- void this.finalizeCompactProgress(sessionId)
- .then(() => {
- const activeSession = getCurrentSession();
- if (!activeSession || activeSession.id !== sessionId) {
- return;
- }
-
- const preparedStreamPayload = this.prepareStreamingPayload(messageText);
- if (!preparedStreamPayload) {
- return;
- }
-
- preparedStreamPayload.sendOptions = { disable_notification: true };
- preparedStreamPayload.editOptions = undefined;
-
- this.enqueueAssistantResponse(sessionId, messageId, preparedStreamPayload);
- })
- .catch((error) => {
- logger.error("[Bot] Failed to finalize compact progress before assistant stream", error);
- });
- return;
+ if (isCompactProgressMode() && !this.runningToolTracker.newestCallId(sessionId)) {
+ this.compactProgressStreamer.updateResponding(sessionId);
}
const preparedStreamPayload = this.prepareStreamingPayload(messageText);
@@ -644,10 +625,6 @@ class EventSubscriptionService implements BotEventSubscriptionService {
await this.completeThinkingStream(sessionId, messageId);
- if (isCompactProgressMode()) {
- await this.finalizeCompactProgress(sessionId);
- }
-
const assistantResponseMode = this.getAssistantResponseStreamMode(sessionId, messageId);
await finalizeAssistantResponse({
@@ -778,13 +755,25 @@ class EventSubscriptionService implements BotEventSubscriptionService {
return;
}
- const activity = this.getCompactToolActivity(toolInfo);
- if (activity) {
- this.compactActivityBySession.set(toolInfo.sessionId, {
- callId: toolInfo.callId,
- activity,
- });
- this.compactProgressStreamer.updateActivity(toolInfo.sessionId, activity);
+ if (isTerminal) {
+ const fallback = this.pickCompactFallback(toolInfo.sessionId);
+ if (fallback) {
+ this.syncCompactToolActivity(toolInfo.sessionId, fallback.callId, fallback.activity);
+ } else if (!this.runningToolTracker.newestCallId(toolInfo.sessionId)) {
+ const activity = this.getCompactToolActivity(toolInfo);
+ if (activity) {
+ this.compactActivityBySession.set(toolInfo.sessionId, {
+ callId: toolInfo.callId,
+ activity,
+ });
+ this.compactProgressStreamer.updateActivity(toolInfo.sessionId, activity);
+ }
+ }
+ } else if (this.runningToolTracker.newestCallId(toolInfo.sessionId) === toolInfo.callId) {
+ const activity = this.getCompactToolActivity(toolInfo);
+ if (activity) {
+ this.syncCompactToolActivity(toolInfo.sessionId, toolInfo.callId, activity);
+ }
}
if (status === "completed") {
@@ -1039,7 +1028,9 @@ class EventSubscriptionService implements BotEventSubscriptionService {
});
if (isCompactProgressMode()) {
- this.compactProgressStreamer.updateThinking(update.sessionId);
+ if (!this.runningToolTracker.newestCallId(update.sessionId)) {
+ this.compactProgressStreamer.updateThinking(update.sessionId);
+ }
if (update.isFirstUpdate && pinnedMessageManager.isInitialized()) {
await pinnedMessageManager.refresh();
@@ -1155,23 +1146,31 @@ class EventSubscriptionService implements BotEventSubscriptionService {
summaryAggregator.setOnSessionIdle(async (sessionId) => {
resetStreamThrottle(sessionId);
await markAttachedSessionIdle(sessionId);
- // Cleared unconditionally: a session can go idle after it stopped being
- // the current one, and the early returns below would leak the tracker
- // or fire a compact-progress timer armed before the run stopped.
+ // Dropped immediately when this session is no longer current: the early
+ // returns below would otherwise leave a compact-progress timer armed.
+ // A still-current session keeps the card until after in-flight completion
+ // work, then finalizes it (delete or finished summary).
this.clearToolElapsedState(sessionId, "session_idle");
- this.compactProgressStreamer.clearSession(sessionId, "session_idle");
+ const currentSessionAtIdle = getCurrentSession();
+ const canFinalizeCompactProgress =
+ Boolean(this.botInstance && this.chatIdInstance) && currentSessionAtIdle?.id === sessionId;
+ if (!canFinalizeCompactProgress) {
+ this.compactProgressStreamer.clearSession(sessionId, "session_idle");
+ }
await this.sessionCompletionTasks.get(sessionId)?.catch(() => undefined);
const completedRun = assistantRunState.finishRun(sessionId, "session_idle");
clearPromptResponseMode(sessionId);
if (!this.botInstance || !this.chatIdInstance) {
+ this.compactProgressStreamer.clearSession(sessionId, "session_idle");
foregroundSessionState.markIdle(sessionId);
return;
}
const currentSession = getCurrentSession();
if (!currentSession || currentSession.id !== sessionId) {
+ this.compactProgressStreamer.clearSession(sessionId, "session_idle");
foregroundSessionState.markIdle(sessionId);
await scheduledTaskRuntime.flushDeferredDeliveries();
return;
@@ -1183,6 +1182,8 @@ class EventSubscriptionService implements BotEventSubscriptionService {
this.toolCallStreamer.breakSession(sessionId, "session_idle"),
]);
+ await this.finalizeCompactProgress(sessionId);
+
if (getShowAssistantRunFooter() && completedRun?.hasCompletedResponse) {
const agent = completedRun.actualAgent || completedRun.configuredAgent;
const providerID = completedRun.actualProviderID || completedRun.configuredProviderID;
@@ -1656,21 +1657,7 @@ class EventSubscriptionService implements BotEventSubscriptionService {
}
private finalizeCompactProgress(sessionId: string): Promise {
- const existingTask = this.compactProgressFinalizationTasks.get(sessionId);
- if (existingTask) {
- return existingTask;
- }
-
- const nextTask = this.compactProgressStreamer
- .finalize(sessionId, getDeleteCompactProgressOnFinish())
- .finally(() => {
- if (this.compactProgressFinalizationTasks.get(sessionId) === nextTask) {
- this.compactProgressFinalizationTasks.delete(sessionId);
- }
- });
-
- this.compactProgressFinalizationTasks.set(sessionId, nextTask);
- return nextTask;
+ return this.compactProgressStreamer.finalize(sessionId, getDeleteCompactProgressOnFinish());
}
private getNextDraftId(): number {
@@ -1691,12 +1678,42 @@ class EventSubscriptionService implements BotEventSubscriptionService {
return `subagent:${cardId}`;
}
- private getCompactToolActivity(toolInfo: ToolInfo): string | null {
+ private getCompactToolActivity(toolInfo: ToolInfo): string {
if (toolInfo.tool === "task") {
return t("progress.compact.task");
}
- return formatCompactToolActivity(toolInfo, 128);
+ return formatCompactToolActivity(toolInfo, 128) ?? formatCompactToolInfo(toolInfo, 128, toolInfo.tool);
+ }
+
+ private pickCompactFallback(
+ sessionId: string,
+ ): { callId: string; activity: string } | null {
+ for (const callId of this.runningToolTracker.trackedCallIds(sessionId)) {
+ const info = this.runningToolInfos.get(this.getToolCacheKey(sessionId, callId));
+ const activity = info ? this.getCompactToolActivity(info) : null;
+ if (activity) {
+ return { callId, activity };
+ }
+ }
+
+ return null;
+ }
+
+ private syncCompactToolActivity(sessionId: string, callId: string, activity: string): void {
+ this.compactActivityBySession.set(sessionId, { callId, activity });
+
+ const tick = this.runningToolTracker.displayTick(callId);
+ const text = tick
+ ? appendDuration(
+ activity,
+ tick.isFinal
+ ? formatDurationOverHours(TOOL_ELAPSED_MAX_TRACKING_HOURS)
+ : formatDuration(tick.elapsedMs),
+ )
+ : activity;
+
+ this.compactProgressStreamer.updateActivity(sessionId, text);
}
private formatShortSessionId(sessionId: string): string {
diff --git a/src/bot/streaming/compact-progress-streamer.ts b/src/bot/streaming/compact-progress-streamer.ts
index aaa52cefc..dd68c144b 100644
--- a/src/bot/streaming/compact-progress-streamer.ts
+++ b/src/bot/streaming/compact-progress-streamer.ts
@@ -42,6 +42,7 @@ function createInitialState(sessionId: string): CompactProgressState {
export class CompactProgressStreamer {
private readonly states = new Map();
+ private readonly finalizing = new Set();
private readonly throttleMs: StreamThrottleMs;
private readonly sendText: CompactProgressStreamerOptions["sendText"];
private readonly editText: CompactProgressStreamerOptions["editText"];
@@ -63,7 +64,7 @@ export class CompactProgressStreamer {
}
updateThinking(sessionId: string): void {
- this.updateActivityState(sessionId, t("progress.compact.thinking"), false);
+ this.updateActivity(sessionId, t("progress.compact.thinking"));
}
updateResponding(sessionId: string): void {
@@ -102,24 +103,32 @@ export class CompactProgressStreamer {
}
this.clearTimer(state);
- await state.task.catch(() => false);
+ this.states.delete(sessionId);
+ this.finalizing.add(state);
- if (deleteOnFinish && this.deleteText) {
- await this.deleteProgressMessage(state);
- this.cancelState(state);
- this.states.delete(sessionId);
- return;
- }
+ try {
+ await state.task.catch(() => false);
- state.latestText = t("progress.compact.done", {
- header: t("progress.compact.finished_header"),
- tools: state.toolCallIds.size,
- files: state.filePaths.size,
- });
+ if (state.cancelled) {
+ return;
+ }
- await this.syncState(state, "finalize");
- this.cancelState(state);
- this.states.delete(sessionId);
+ if (deleteOnFinish && this.deleteText) {
+ await this.deleteProgressMessage(state);
+ return;
+ }
+
+ state.latestText = t("progress.compact.done", {
+ header: t("progress.compact.finished_header"),
+ tools: state.toolCallIds.size,
+ files: state.filePaths.size,
+ });
+
+ await this.syncState(state, "finalize");
+ } finally {
+ this.finalizing.delete(state);
+ this.cancelState(state);
+ }
}
private async deleteProgressMessage(state: CompactProgressState): Promise {
@@ -139,13 +148,17 @@ export class CompactProgressStreamer {
clearSession(sessionId: string, reason: string): void {
const state = this.states.get(sessionId);
- if (!state) {
+ if (state) {
+ this.clearTimer(state);
+ this.cancelState(state);
+ this.states.delete(sessionId);
+ }
+
+ const cancelledFinalizing = this.cancelFinalizing(sessionId);
+ if (!state && !cancelledFinalizing) {
return;
}
- this.clearTimer(state);
- this.cancelState(state);
- this.states.delete(sessionId);
logger.debug(`[CompactProgress] Cleared session: session=${sessionId}, reason=${reason}`);
}
@@ -155,6 +168,7 @@ export class CompactProgressStreamer {
this.cancelState(state);
}
this.states.clear();
+ this.cancelFinalizing();
logger.debug(`[CompactProgress] Cleared all sessions: reason=${reason}`);
}
@@ -256,4 +270,15 @@ export class CompactProgressStreamer {
private cancelState(state: CompactProgressState): void {
state.cancelled = true;
}
+
+ private cancelFinalizing(sessionId?: string): boolean {
+ let cancelled = false;
+ for (const state of this.finalizing) {
+ if (sessionId === undefined || state.sessionId === sessionId) {
+ this.cancelState(state);
+ cancelled = true;
+ }
+ }
+ return cancelled;
+ }
}
diff --git a/src/bot/streaming/running-tool-tracker.ts b/src/bot/streaming/running-tool-tracker.ts
index 640f76021..54477fbcf 100644
--- a/src/bot/streaming/running-tool-tracker.ts
+++ b/src/bot/streaming/running-tool-tracker.ts
@@ -123,6 +123,40 @@ export class RunningToolTracker {
}
}
+ trackedCallIds(sessionId: string): string[] {
+ const matches: { callId: string; startedAt: number }[] = [];
+
+ for (const [callId, call] of this.calls) {
+ if (call.sessionId === sessionId) {
+ matches.push({ callId, startedAt: call.startedAt });
+ }
+ }
+
+ matches.reverse();
+ matches.sort((left, right) => right.startedAt - left.startedAt);
+ return matches.map((entry) => entry.callId);
+ }
+
+ newestCallId(sessionId: string): string | undefined {
+ return this.trackedCallIds(sessionId)[0];
+ }
+
+ displayTick(callId: string, now: number = Date.now()): RunningToolTick | undefined {
+ const call = this.calls.get(callId);
+ if (!call) {
+ return undefined;
+ }
+
+ const elapsedMs = now - call.startedAt;
+ if (elapsedMs < this.thresholdMs) {
+ return undefined;
+ }
+
+ const isFinal = elapsedMs >= this.maxTrackingMs || call.stopped;
+ const bucketMs = isFinal ? this.maxTrackingMs : bucketElapsedMs(elapsedMs);
+ return { sessionId: call.sessionId, callId, elapsedMs: bucketMs, isFinal };
+ }
+
private ensureTimer(): void {
if (this.timer) {
return;
diff --git a/src/cli.ts b/src/cli.ts
index 0121a9847..5ab232891 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -2,6 +2,7 @@
import type { RuntimeMode } from "./runtime/mode.js";
import { parseCliArgs } from "./cli/args.js";
+import { handleCliFailure } from "./cli/failure-exit.js";
import { resolveRuntimeMode, setRuntimeMode } from "./runtime/mode.js";
import { getUnsupportedNodeVersionMessage } from "./runtime/node-version.js";
import { getRuntimePaths } from "./runtime/paths.js";
@@ -38,7 +39,6 @@ const CLI_MESSAGES = {
lineUptimeSec: (seconds: number) => `Uptime: ${seconds} sec`,
lineLogFile: (filePath: string) => `Log file: ${filePath}`,
lineAppHome: (appHome: string) => `App home: ${appHome}`,
- errorPrefix: (message: string) => `CLI error: ${message}`,
} as const;
function writeStdout(message: string): void {
@@ -257,11 +257,5 @@ void runCli(process.argv.slice(2))
process.exitCode = exitCode;
})
.catch((error: unknown) => {
- if (error instanceof Error) {
- writeStderr(CLI_MESSAGES.errorPrefix(error.message));
- } else {
- writeStderr(CLI_MESSAGES.errorPrefix(String(error)));
- }
-
- process.exitCode = EXIT_RUNTIME_ERROR;
+ void handleCliFailure(error);
});
diff --git a/src/cli/failure-exit.ts b/src/cli/failure-exit.ts
new file mode 100644
index 000000000..9f83e1345
--- /dev/null
+++ b/src/cli/failure-exit.ts
@@ -0,0 +1,16 @@
+import { flushLogger } from "../utils/logger.js";
+
+const LOG_FLUSH_TIMEOUT_MS = 1000;
+const EXIT_RUNTIME_ERROR = 1;
+
+export async function handleCliFailure(error: unknown): Promise {
+ const message = error instanceof Error ? error.message : String(error);
+ process.stderr.write(`CLI error: ${message}\n`);
+
+ await Promise.race([
+ flushLogger().catch(() => {}),
+ new Promise((resolve) => setTimeout(resolve, LOG_FLUSH_TIMEOUT_MS)),
+ ]);
+
+ process.exit(EXIT_RUNTIME_ERROR);
+}
diff --git a/src/i18n/ar.ts b/src/i18n/ar.ts
index 4228364a7..3fdf27702 100644
--- a/src/i18n/ar.ts
+++ b/src/i18n/ar.ts
@@ -159,6 +159,7 @@ export const ar: I18nDictionary = {
"settings.response_streaming.draft": "draft (experimental)",
"settings.diff_files.label": "ملفات diff",
"settings.assistant_footer.label": "تذييل الرد",
+ "settings.pin_session_dashboard.label": "Pin session dashboard",
"settings.tts.label": "الردود الصوتية",
"settings.prompt_queue.label": "قائمة انتظار الرسائل",
"settings.value.on": "مفعّل",
diff --git a/src/i18n/de.ts b/src/i18n/de.ts
index 62a6d9845..9aa6f2181 100644
--- a/src/i18n/de.ts
+++ b/src/i18n/de.ts
@@ -166,6 +166,7 @@ export const de: I18nDictionary = {
"settings.response_streaming.draft": "draft (experimental)",
"settings.diff_files.label": "Diff-Dateien",
"settings.assistant_footer.label": "Antwort-Footer",
+ "settings.pin_session_dashboard.label": "Pin session dashboard",
"settings.tts.label": "Audioantworten",
"settings.prompt_queue.label": "Nachrichtenwarteschlange",
"settings.value.on": "Ein",
diff --git a/src/i18n/en.ts b/src/i18n/en.ts
index 6d4bae7ef..85994473c 100644
--- a/src/i18n/en.ts
+++ b/src/i18n/en.ts
@@ -157,6 +157,7 @@ export const en = {
"settings.response_streaming.draft": "draft (experimental)",
"settings.diff_files.label": "Diff files",
"settings.assistant_footer.label": "Assistant footer",
+ "settings.pin_session_dashboard.label": "Pin session dashboard",
"settings.tts.label": "Audio replies",
"settings.prompt_queue.label": "Message queue",
"settings.value.on": "On",
diff --git a/src/i18n/es.ts b/src/i18n/es.ts
index 4ba8c672b..61a992282 100644
--- a/src/i18n/es.ts
+++ b/src/i18n/es.ts
@@ -166,6 +166,7 @@ export const es: I18nDictionary = {
"settings.response_streaming.draft": "draft (experimental)",
"settings.diff_files.label": "Archivos diff",
"settings.assistant_footer.label": "Pie de respuesta",
+ "settings.pin_session_dashboard.label": "Pin session dashboard",
"settings.tts.label": "Respuestas de audio",
"settings.prompt_queue.label": "Cola de mensajes",
"settings.value.on": "Activado",
diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts
index 2c5a9bc57..effcc1644 100644
--- a/src/i18n/fr.ts
+++ b/src/i18n/fr.ts
@@ -168,6 +168,7 @@ export const fr: I18nDictionary = {
"settings.response_streaming.draft": "draft (experimental)",
"settings.diff_files.label": "Fichiers diff",
"settings.assistant_footer.label": "Pied de réponse",
+ "settings.pin_session_dashboard.label": "Pin session dashboard",
"settings.tts.label": "Réponses audio",
"settings.prompt_queue.label": "File d'attente des messages",
"settings.value.on": "Activé",
diff --git a/src/i18n/index.ts b/src/i18n/index.ts
index f6c1f3348..554178d24 100644
--- a/src/i18n/index.ts
+++ b/src/i18n/index.ts
@@ -7,6 +7,7 @@ import { it } from "./it.js";
import { ko } from "./ko.js";
import { pt } from "./pt.js";
import { ru } from "./ru.js";
+import { tr } from "./tr.js";
import { zh } from "./zh.js";
interface LocaleDefinition {
@@ -71,6 +72,12 @@ const LOCALE_DEFINITIONS = [
dateLocale: "ru-RU",
dictionary: ru,
},
+ {
+ code: "tr",
+ label: "Türkçe",
+ dateLocale: "tr-TR",
+ dictionary: tr,
+ },
{
code: "zh",
label: "简体中文",
diff --git a/src/i18n/it.ts b/src/i18n/it.ts
index 53775c9d9..58955d6d9 100644
--- a/src/i18n/it.ts
+++ b/src/i18n/it.ts
@@ -170,6 +170,7 @@ export const it: I18nDictionary = {
"settings.response_streaming.draft": "bozza (sperimentale)",
"settings.diff_files.label": "File di diff",
"settings.assistant_footer.label": "Footer dell'assistente",
+ "settings.pin_session_dashboard.label": "Pin session dashboard",
"settings.tts.label": "Risposte audio",
"settings.prompt_queue.label": "Coda messaggi",
"settings.value.on": "On",
diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts
index 7cbb9a821..2b0cf467d 100644
--- a/src/i18n/ko.ts
+++ b/src/i18n/ko.ts
@@ -166,6 +166,7 @@ export const ko: I18nDictionary = {
"settings.response_streaming.draft": "초안 (실험적)",
"settings.diff_files.label": "Diff 파일",
"settings.assistant_footer.label": "어시스턴트 푸터",
+ "settings.pin_session_dashboard.label": "Pin session dashboard",
"settings.tts.label": "음성 답변",
"settings.prompt_queue.label": "메시지 대기열",
"settings.value.on": "켜기",
diff --git a/src/i18n/pt.ts b/src/i18n/pt.ts
index 5477ce8a3..ead549ee4 100644
--- a/src/i18n/pt.ts
+++ b/src/i18n/pt.ts
@@ -166,6 +166,7 @@ export const pt: I18nDictionary = {
"settings.response_streaming.draft": "draft (experimental)",
"settings.diff_files.label": "Arquivos de diff",
"settings.assistant_footer.label": "Rodapé do assistente",
+ "settings.pin_session_dashboard.label": "Pin session dashboard",
"settings.tts.label": "Respostas em áudio",
"settings.prompt_queue.label": "Fila de mensagens",
"settings.value.on": "Ligado",
diff --git a/src/i18n/ru.ts b/src/i18n/ru.ts
index f7ce7fd8d..6bf2c7fb4 100644
--- a/src/i18n/ru.ts
+++ b/src/i18n/ru.ts
@@ -157,6 +157,7 @@ export const ru: I18nDictionary = {
"settings.response_streaming.draft": "draft (experimental)",
"settings.diff_files.label": "Файлы с diff",
"settings.assistant_footer.label": "Футер ответа",
+ "settings.pin_session_dashboard.label": "Закреплять дашборд сессии",
"settings.tts.label": "Аудиоответы",
"settings.prompt_queue.label": "Очередь сообщений",
"settings.value.on": "Вкл",
diff --git a/src/i18n/tr.ts b/src/i18n/tr.ts
new file mode 100644
index 000000000..5f3e08fa8
--- /dev/null
+++ b/src/i18n/tr.ts
@@ -0,0 +1,663 @@
+import type { I18nDictionary } from "./en.js";
+
+export const tr: I18nDictionary = {
+ "cmd.description.status": "Sunucu ve oturum durumu",
+ "cmd.description.new": "Yeni oturum oluştur",
+ "cmd.description.stop": "Geçerli işlemi durdur",
+ "cmd.description.detach": "Geçerli oturumdan ayrıl",
+ "cmd.description.sessions": "Oturumları listele",
+ "cmd.description.messages": "Oturum mesajlarına göz at",
+ "cmd.description.settings": "Bot ayarlarını değiştir",
+ "cmd.description.projects": "Projeleri listele",
+ "cmd.description.worktree": "Git worktree değiştir",
+ "cmd.description.task": "Zamanlanmış görev oluştur",
+ "cmd.description.tasklist": "Zamanlanmış görevleri listele",
+ "cmd.description.commands": "Özel komutlar",
+ "cmd.description.skills": "Yetenek kataloğu",
+ "cmd.description.mcps": "MCP sunucuları",
+ "cmd.description.opencode_start": "OpenCode sunucusunu başlat",
+ "cmd.description.opencode_stop": "OpenCode sunucusunu durdur",
+ "cmd.description.ls": "Dizin içeriğini listele",
+ "cmd.description.help": "Yardım",
+
+ "callback.unknown_command": "Bilinmeyen komut",
+ "callback.processing_error": "İşleme hatası",
+
+ "error.load_agents": "❌ Ajan listesi yüklenemedi",
+ "error.load_models": "❌ Model listesi yüklenemedi",
+ "error.load_variants": "❌ Varyant listesi yüklenemedi",
+ "error.context_button": "❌ Bağlam düğmesi işlenemedi",
+ "error.generic": "🔴 Bir şeyler ters gitti.",
+
+ "interaction.blocked.expired": "⚠️ Bu etkileşimin süresi doldu. Lütfen yeniden başlatın.",
+ "interaction.blocked.expected_callback":
+ "⚠️ Bu adım için lütfen satır içi düğmeleri kullanın veya İptal'e dokunun.",
+ "interaction.blocked.expected_text": "⚠️ Bu adım için lütfen bir metin mesajı gönderin.",
+ "interaction.blocked.expected_command": "⚠️ Bu adım için lütfen bir komut gönderin.",
+ "interaction.blocked.command_not_allowed": "⚠️ Bu komut geçerli adımda kullanılamaz.",
+ "interaction.blocked.finish_current":
+ "⚠️ Başka bir menü açmadan önce geçerli etkileşimi tamamlayın (yanıtlayın veya iptal edin).",
+
+ "inline.blocked.expected_choice":
+ "⚠️ Satır içi düğmelerle bir seçenek seçin veya İptal'e dokunun.",
+ "inline.blocked.command_not_allowed": "⚠️ Satır içi menü etkinken bu komut kullanılamaz.",
+
+ "question.blocked.expected_answer":
+ "⚠️ Geçerli soruyu düğmelerle, Özel yanıt ile veya İptal ile yanıtlayın.",
+ "question.blocked.command_not_allowed":
+ "⚠️ Geçerli soru akışı tamamlanana kadar bu komut kullanılamaz.",
+
+ "inline.button.cancel": "❌ İptal",
+ "inline.button.close": "❌ Kapat",
+ "inline.inactive_callback": "Bu menü etkin değil",
+
+ "common.cancelled": "İptal edildi",
+ "common.unknown": "bilinmiyor",
+ "common.unknown_error": "bilinmeyen hata",
+
+ "start.welcome":
+ "👋 OpenCode Telegram Bot'a hoş geldiniz!\n\nKomutları kullanın:\n/projects — proje seç\n/sessions — oturum listesi\n/new — yeni oturum\n/commands — özel komutlar\n/skills — yetenek kataloğu\n/task — zamanlanmış görev\n/tasklist — zamanlanmış görevler\n/status — durum\n/help — yardım\n\nAjanı, modeli ve varyantı seçmek için alttaki düğmeleri kullanın.",
+ "help.keyboard_hint":
+ "💡 Ajan, model, varyant ve bağlam işlemleri için alttaki klavye düğmelerini kullanın.",
+ "help.text":
+ "📖 **Yardım**\n\n/status - Sunucu durumunu kontrol et\n/sessions - Oturum listesi\n/new - Yeni oturum oluştur\n/help - Yardım",
+
+ "bot.thinking": "💭 Düşünüyor...",
+ "progress.compact.activity": "{header}\n{activity}",
+ "progress.compact.working_header": "⏳ Çalışıyor",
+ "progress.compact.finished_header": "✅ Çalışma tamamlandı",
+ "progress.compact.thinking": "💭 Düşünüyor...",
+ "progress.compact.responding": "✍️ Yanıt yazılıyor...",
+ "progress.compact.waiting_question": "❓ Yanıtınız bekleniyor...",
+ "progress.compact.waiting_permission": "🔐 İzin bekleniyor...",
+ "progress.compact.retrying": "🔁 Yeniden deneniyor...",
+ "progress.compact.task": "🤖 Görev çalışıyor",
+ "progress.compact.done": "{header}\naraç çağrıları: {tools} · değişen dosyalar: {files}",
+ "bot.project_not_selected": "🏗 Proje seçilmedi.\n\nÖnce /projects ile bir proje seçin.",
+ "bot.creating_session": "🔄 Yeni oturum oluşturuluyor...",
+ "bot.create_session_error":
+ "🔴 Oturum oluşturulamadı. /new komutunu deneyin veya sunucu durumunu /status ile kontrol edin.",
+ "bot.session_created": "✅ Oturum oluşturuldu: {title}",
+ "bot.session_busy":
+ "⏳ Ajan zaten bir görev yürütüyor. Tamamlanmasını bekleyin veya geçerli çalışmayı kesmek için /abort kullanın.",
+ "bot.session_reset_project_mismatch":
+ "⚠️ Etkin oturum seçili projeyle eşleşmediği için sıfırlandı. Birini seçmek için /sessions veya yeni oturum oluşturmak için /new kullanın.",
+ "bot.prompt_send_error": "İstek OpenCode'a gönderilemedi.",
+ "bot.session_error": "🔴 OpenCode bir hata döndürdü: {message}",
+ "bot.session_retry":
+ "🔁 {message}\n\nSağlayıcı tekrarlanan denemelerde aynı hatayı döndürmeye devam ediyor. Durdurmak için /abort kullanın.",
+ "bot.external_user_input": "Harici kullanıcı girdisi",
+ "background.session_fallback": "oturum {id}",
+ "background.assistant_response": "🔔 Asistan arka plan oturumunda yanıt verdi: {session}",
+ "background.question_asked": "❓ Arka plan oturumu bir yanıt bekliyor: {session}",
+ "background.permission_asked": "🔐 Arka plan oturumu izin istedi: {session}",
+ "background.open_session_button": "Oturumu aç",
+ "bot.unknown_command":
+ "⚠️ Bilinmeyen komut: {command}. Kullanılabilir komutları görmek için /help kullanın.",
+ "bot.photo_downloading": "⏳ Fotoğraf indiriliyor...",
+ "bot.photo_too_large": "⚠️ Fotoğraf çok büyük (en fazla {maxSizeMb}MB)",
+ "bot.photo_model_no_image":
+ "⚠️ Geçerli model görsel girdisini desteklemiyor. Yalnızca metin gönderiliyor.",
+ "bot.photo_download_error": "🔴 Fotoğraf indirilemedi",
+ "bot.photo_no_caption":
+ "💡 İpucu: Bu fotoğrafla ne yapmak istediğinizi anlatan bir açıklama ekleyin.",
+ "bot.file_downloading": "⏳ Dosya indiriliyor...",
+ "bot.files_downloading": "⏳ Dosyalar indiriliyor...",
+ "bot.file_too_large": "⚠️ Dosya çok büyük (en fazla {maxSizeMb}MB)",
+ "bot.file_download_error": "🔴 Dosya indirilemedi",
+ "bot.file_type_unsupported":
+ "⚠️ Bu dosya türü desteklenmiyor. Bir görsel, belge (PDF, DOCX, PPTX) veya metin/kod dosyası gönderin.",
+ "bot.rich_message_media_skipped": "⚠️ Desteklenmeyen {count} medya parçası atlandı.",
+ "bot.message_type_unsupported": "⚠️ Bu mesaj türü desteklenmiyor.",
+ "bot.media_group_not_processed":
+ "⚠️ Bu albümdeki bir veya daha fazla dosya işlenemiyor. OpenCode'a hiçbir şey gönderilmedi.",
+ "bot.media_group_download_error":
+ "🔴 Dosyalardan biri indirilemedi. OpenCode'a hiçbir şey gönderilmedi.",
+ "bot.model_no_pdf": "⚠️ Geçerli model PDF girdisini desteklemiyor. Yalnızca metin gönderiliyor.",
+ "bot.document_extraction_error": "🔴 Belge metni çıkarılamadı.",
+ "bot.text_file_too_large": "⚠️ Metin dosyası çok büyük (en fazla {maxSizeKb}KB)",
+
+ "status.header_running": "🟢 OpenCode Sunucusu çalışıyor",
+ "status.health.healthy": "Sağlıklı",
+ "status.health.unhealthy": "Sağlıksız",
+ "status.line.health": "Durum: {health}",
+ "status.line.version": "OpenCode sürümü: {version}",
+ "status.line.bot_version": "Bot sürümü: {version}",
+ "status.line.managed_yes": "Bot tarafından başlatıldı: Evet",
+ "status.line.managed_no": "Bot tarafından başlatıldı: Hayır",
+ "status.line.pid": "PID: {pid}",
+ "status.line.uptime_sec": "Çalışma süresi: {seconds} sn",
+ "status.line.mode": "Ajan: {mode}",
+ "status.line.model": "Model: {model}",
+ "status.line.tts": "Sesli yanıtlar: {tts}",
+ "status.tts.off": "Kapalı",
+ "status.tts.all": "Tümü",
+ "status.tts.auto": "Otomatik",
+ "status.agent_not_set": "ayarlanmadı",
+ "status.project_selected": "Proje: {project}",
+ "status.worktree_selected": "Worktree: {worktree}",
+ "status.project_not_selected": "Proje: seçilmedi",
+ "status.project_hint": "Proje seçmek için /projects kullanın",
+ "status.session_selected": "Geçerli oturum: {title}",
+ "status.session_not_selected": "Geçerli oturum: seçilmedi",
+ "status.session_hint": "Seçmek için /sessions veya oluşturmak için /new kullanın",
+ "status.header_unavailable": "🔴 OpenCode Sunucusu kullanılamıyor",
+ "status.unavailable_hint": "Sunucuyu başlatmak için /opencode_start kullanın.",
+
+ "tts.off": "🔇 Sesli yanıtlar kapatıldı.",
+ "tts.all": "🔊 Sesli yanıtlar tüm mesajlar için açıldı.",
+ "tts.auto": "🎤 Sesli yanıtlar yalnızca sesli/ses mesajları için açıldı.",
+ "tts.not_configured":
+ "⚠️ Sesli yanıtlar kullanılamıyor. Önce `TTS_API_URL` ve `TTS_API_KEY` ayarlayın.",
+ "tts.failed": "⚠️ Sesli yanıt oluşturulamadı.",
+
+ "settings.menu.title": "⚙️ Bot ayarları\nDeğerini değiştirmek için bir ayara dokunun:",
+ "settings.compact_output.label": "Kompakt çıktı modu",
+ "settings.delete_progress_on_finish.label": "Bitince ilerlemeyi sil",
+ "settings.thinking_content.label": "Düşünme içeriği",
+ "settings.response_streaming.label": "Yanıt akışı",
+ "settings.response_streaming.edit": "düzenleme",
+ "settings.response_streaming.draft": "taslak (deneysel)",
+ "settings.diff_files.label": "Diff dosyaları",
+ "settings.assistant_footer.label": "Asistan alt bilgisi",
+ "settings.pin_session_dashboard.label": "Oturum panosunu sabitle",
+ "settings.tts.label": "Sesli yanıtlar",
+ "settings.prompt_queue.label": "Mesaj kuyruğu",
+ "settings.value.on": "Açık",
+ "settings.value.off": "Kapalı",
+ "settings.saved": "✅ Ayar kaydedildi.",
+
+ "projects.empty":
+ "📭 Proje bulunamadı.\n\nOpenCode'da bir dizin açın ve en az bir oturum oluşturun, ardından burada görünecektir.",
+ "projects.select": "Bir proje seçin:",
+ "projects.select_with_current": "Bir proje seçin:\n\nGeçerli: 🏗 {project}",
+ "projects.page_indicator": "Sayfa {current}/{total}",
+ "projects.prev_page": "⬅️ Önceki",
+ "projects.next_page": "Sonraki ➡️",
+ "projects.fetch_error":
+ "🔴 OpenCode Sunucusu kullanılamıyor veya projeler yüklenirken bir hata oluştu.",
+ "projects.page_load_error": "Bu sayfa yüklenemiyor. Lütfen tekrar deneyin.",
+ "projects.selected":
+ "✅ Proje seçildi: {project}\n\n📋 Oturum sıfırlandı. Bu proje için /sessions veya /new kullanın.",
+ "projects.select_error": "🔴 Proje seçilemedi.",
+
+ "sessions.project_not_selected": "🏗 Proje seçilmedi.\n\nÖnce /projects ile bir proje seçin.",
+ "sessions.empty": "📭 Oturum bulunamadı.\n\n/new ile yeni bir oturum oluşturun.",
+ "sessions.select": "Bir oturum seçin:",
+ "sessions.select_page": "Bir oturum seçin (sayfa {page}):",
+ "sessions.fetch_error":
+ "🔴 OpenCode Sunucusu kullanılamıyor veya oturumlar yüklenirken bir hata oluştu.",
+ "sessions.select_project_first": "🔴 Proje seçilmedi. /projects kullanın.",
+ "sessions.page_empty_callback": "Bu sayfada oturum yok",
+ "sessions.page_load_error_callback": "Bu sayfa yüklenemiyor. Lütfen tekrar deneyin.",
+ "sessions.button.prev_page": "⬅️ Önceki",
+ "sessions.button.next_page": "Sonraki ➡️",
+ "sessions.loading_context": "⏳ Bağlam ve son mesajlar yükleniyor...",
+ "sessions.selected": "✅ Oturum seçildi: {title}",
+ "sessions.select_error": "🔴 Oturum seçilemedi.",
+ "sessions.preview.empty": "Son mesaj yok.",
+ "sessions.preview.title": "Son mesajlar:",
+ "sessions.preview.you": "Siz:",
+ "sessions.preview.agent": "Ajan:",
+
+ "messages.project_not_selected": "🏗 Proje seçilmedi.\n\nÖnce /projects ile bir proje seçin.",
+ "messages.session_not_selected":
+ "💬 Oturum seçilmedi.\n\nÖnce /sessions ile bir oturum seçin veya /new ile oluşturun.",
+ "messages.session_project_mismatch":
+ "⚠️ Seçili oturum geçerli projeyle eşleşmiyor. Oturumu /sessions ile yeniden seçin.",
+ "messages.empty": "📭 Geçerli oturumda kullanıcı mesajı yok.",
+ "messages.select": "Bir mesaj seçin:",
+ "messages.select_page": "Bir mesaj seçin (sayfa {page}):",
+ "messages.fetch_error":
+ "🔴 OpenCode Sunucusu kullanılamıyor veya mesajlar yüklenirken bir hata oluştu.",
+ "messages.inactive_callback": "Bu mesaj menüsü etkin değil",
+ "messages.page_empty_callback": "Bu sayfada mesaj yok",
+ "messages.button.prev_page": "⬅️ Önceki",
+ "messages.button.next_page": "Sonraki ➡️",
+ "messages.button.revert": "↩️ Revert",
+ "messages.button.fork": "🔀 Fork",
+ "messages.button.back": "⬅️ Geri",
+ "messages.button.cancel": "❌ İptal",
+ "messages.revert_success": "✅ Şu mesaja geri dönüldü:\n\n{text}",
+ "messages.revert_error": "❌ Mesaja geri dönülemedi. Lütfen tekrar deneyin.",
+ "messages.fork_success": "🔀 Şu mesajdan fork oluşturuldu:\n\n{text}",
+ "messages.fork_error": "❌ Fork oluşturulamadı. Lütfen tekrar deneyin.",
+
+ "attach.project_not_selected": "🏗 Proje seçilmedi.\n\nÖnce /projects ile bir proje seçin.",
+ "attach.session_not_selected": "💬 Oturum seçilmedi.\n\nÖnce /sessions ile bir oturum seçin.",
+ "attach.session_project_mismatch":
+ "⚠️ Seçili oturum geçerli projeyle eşleşmiyor. Oturumu /sessions ile yeniden seçin.",
+ "attach.connected": "✅ Oturuma bağlanıldı: {title}",
+ "attach.already_connected": "ℹ️ Oturuma zaten bağlı: {title}",
+ "attach.status.idle_message": "Durum: boşta. Yeni olaylar bekleniyor.",
+ "attach.status.busy_message": "Durum: meşgul. Yeni istemler geçici olarak engellendi.",
+ "attach.restored_question": "Bu oturum için bekleyen bir soru geri yüklendi.",
+ "attach.restored_permissions": "Bekleyen izin istekleri geri yüklendi: {count}.",
+ "attach.disconnect_hint": "Bağlantıyı kesmek için başka bir oturuma veya projeye geçin.",
+ "attach.error": "🔴 Geçerli oturuma bağlanılamadı.",
+
+ "detach.project_not_selected": "🏗 Proje seçilmedi.\n\nÖnce /projects ile bir proje seçin.",
+ "detach.no_active_session": "ℹ️ Bot zaten hiçbir oturuma bağlı değil.",
+ "detach.success":
+ "✅ Oturumdan ayrılındı: {title}\n\nOpenCode oturumu durdurulmadı. Hâlâ çalışıyorsa ayrı olarak devam edecek. Daha sonra kontrol etmek için /sessions ile yeniden seçin.",
+ "detach.error": "🔴 Geçerli oturumdan ayrılınamadı.",
+
+ "new.project_not_selected": "🏗 Proje seçilmedi.\n\nÖnce /projects ile bir proje seçin.",
+ "new.created": "✅ Yeni oturum oluşturuldu: {title}",
+ "new.create_error":
+ "🔴 OpenCode Sunucusu kullanılamıyor veya oturum oluşturulurken bir hata oluştu.",
+
+ "stop.no_active_session":
+ "🛑 Ajan başlatılmadı\n\n/new ile bir oturum oluşturun veya /sessions ile birini seçin.",
+ "stop.in_progress":
+ "🛑 Olay akışı durduruldu, iptal sinyali gönderiliyor...\n\nAjanın durması bekleniyor.",
+ "stop.warn_unconfirmed":
+ "⚠️ Olay akışı durduruldu, ancak sunucu iptali onaylamadı.\n\n/status ile kontrol edin ve birkaç saniye içinde /abort komutunu tekrar deneyin.",
+ "stop.warn_maybe_finished": "⚠️ Olay akışı durduruldu, ancak ajan zaten bitirmiş olabilir.",
+ "stop.success": "✅ Ajan işlemi kesildi. Bu çalışmadan başka mesaj gönderilmeyecek.",
+ "stop.warn_still_busy":
+ "⚠️ Sinyal gönderildi, ancak ajan hâlâ meşgul.\n\nOlay akışı zaten kapalı, bu yüzden ara mesaj gönderilmeyecek.",
+ "stop.warn_timeout":
+ "⚠️ İptal isteği zaman aşımına uğradı.\n\nOlay akışı zaten kapalı, birkaç saniye içinde /abort komutunu tekrar deneyin.",
+ "stop.warn_local_only":
+ "⚠️ Olay akışı yerel olarak durduruldu, ancak sunucu tarafında iptal başarısız oldu.",
+ "stop.error": "🔴 İşlem durdurulamadı.\n\nOlay akışı durduruldu, /abort komutunu tekrar deneyin.",
+
+ "opencode_start.already_running_managed":
+ "⚠️ OpenCode Sunucusu zaten çalışıyor\n\nPID: {pid}\nÇalışma süresi: {seconds} saniye",
+ "opencode_start.already_running_external":
+ "✅ OpenCode Sunucusu zaten harici bir süreç olarak çalışıyor\n\nSürüm: {version}\n\nBu sunucu bot tarafından başlatılmadı, bu yüzden /opencode-stop onu durduramaz.",
+ "opencode_start.already_running": "✅ OpenCode Sunucusu zaten çalışıyor\n\nSürüm: {version}",
+ "opencode_start.remote_configured":
+ "⚠️ /opencode_start yalnızca yerel bir OpenCode Sunucusu ile çalışır.",
+ "opencode_start.starting": "🔄 OpenCode Sunucusu başlatılıyor...",
+ "opencode_start.start_error":
+ "🔴 OpenCode Sunucusu başlatılamadı\n\nHata: {error}\n\nOpenCode CLI'nin kurulu ve PATH içinde erişilebilir olduğunu kontrol edin:\nopencode --version\nnpm install -g @opencode-ai/cli",
+ "opencode_start.started_not_ready":
+ "⚠️ OpenCode Sunucusu başlatıldı, ancak yanıt vermiyor\n\nPID: {pid}\n\nSunucu hâlâ başlıyor olabilir. Birkaç saniye içinde /status deneyin.",
+ "opencode_start.success":
+ "✅ OpenCode Sunucusu başarıyla başlatıldı\n\nPID: {pid}\nSürüm: {version}",
+ "opencode_start.error":
+ "🔴 Sunucu başlatılırken bir hata oluştu.\n\nAyrıntılar için uygulama günlüklerini kontrol edin.",
+ "opencode_stop.external_running":
+ "⚠️ OpenCode Sunucusu harici bir süreç olarak çalışıyor\n\nBu sunucu /opencode-start ile başlatılmadı.\nElle durdurun veya durumu kontrol etmek için /status kullanın.",
+ "opencode_stop.remote_configured":
+ "⚠️ /opencode_stop yalnızca yerel bir OpenCode Sunucusu ile çalışır.",
+ "opencode_stop.not_running": "⚠️ OpenCode Sunucusu çalışmıyor",
+ "opencode_stop.pid_not_found":
+ "⚠️ OpenCode Sunucusu {port} bağlantı noktasında yanıt veriyor, ancak durdurulacak yerel bir süreç bulunamadı.",
+ "opencode_stop.stopping": "🛑 OpenCode Sunucusu durduruluyor...\n\nPID: {pid}",
+ "opencode_stop.stop_error": "🔴 OpenCode Sunucusu durdurulamadı\n\nHata: {error}",
+ "opencode_stop.still_running": "Durdurma isteğinden sonra sunucu hâlâ yanıt veriyor.",
+ "opencode_stop.success": "✅ OpenCode Sunucusu başarıyla durduruldu",
+ "opencode_stop.error":
+ "🔴 Sunucu durdurulurken bir hata oluştu.\n\nAyrıntılar için uygulama günlüklerini kontrol edin.",
+
+ "agent.changed_message": "✅ Ajan değiştirildi: {name}",
+ "agent.change_error_callback": "Ajan değiştirilemedi",
+ "agent.menu.current": "Geçerli ajan: {name}\n\nAjan seçin:",
+ "agent.menu.select": "Ajan seçin:",
+ "agent.menu.empty": "⚠️ Kullanılabilir ajan yok",
+ "agent.menu.error": "🔴 Ajan listesi alınamadı",
+
+ "model.changed_message": "✅ Model değiştirildi: {name}",
+ "model.change_error_callback": "Model değiştirilemedi",
+ "model.menu.empty": "⚠️ Kullanılabilir model yok",
+ "model.menu.select": "Model seçin:",
+ "model.menu.current": "Geçerli model: {name}\n\nModel seçin:",
+ "model.menu.favorites_title": "⭐ Favoriler (Modelleri OpenCode CLI'de favorilere ekleyin)",
+ "model.menu.favorites_empty": "— Boş.",
+ "model.menu.recent_title": "🕘 Son kullanılanlar",
+ "model.menu.recent_empty": "— Boş.",
+ "model.menu.favorites_hint":
+ "ℹ️ Modelleri en üstte tutmak için OpenCode CLI'de favorilere ekleyin.",
+ "model.menu.error": "🔴 Model listesi alınamadı",
+ "model.search.button": "🔍 Ara",
+ "model.search.prompt": "🔍 Aramak için model adını girin:",
+ "model.search.results_title": '"{query}" için arama sonuçları:',
+ "model.search.no_results": '"{query}" için model bulunamadı',
+ "model.search.search_again": "↩ Yeniden ara",
+ "model.search.error": "Arama başarısız oldu",
+ "model.button.back": "⬅️ Geri",
+ "model.providers.button": "🗂 Sağlayıcılar",
+ "model.providers.title": "Listeden sağlayıcı seçin:",
+ "model.providers.empty": "⚠️ Bağlı sağlayıcı yok",
+ "model.providers.error": "Sağlayıcı listesi alınamadı",
+ "model.providers.page_indicator": "Sayfa {current}/{total}",
+ "model.providers.prev_page": "⬅️ Önceki",
+ "model.providers.next_page": "Sonraki ➡️",
+ "model.provider_models.title": "{provider} — model seçin:",
+ "model.provider_models.empty": "⚠️ {provider} için kullanılabilir model yok",
+ "model.provider_models.page_indicator": "Sayfa {current}/{total}",
+
+ "variant.model_not_selected_callback": "Hata: model seçilmedi",
+ "variant.changed_message": "✅ Varyant değiştirildi: {name}",
+ "variant.change_error_callback": "Varyant değiştirilemedi",
+ "variant.select_model_first": "⚠️ Önce bir model seçin",
+ "variant.menu.empty": "⚠️ Kullanılabilir varyant yok",
+ "variant.menu.current": "Geçerli varyant: {name}\n\nVaryant seçin:",
+ "variant.menu.error": "🔴 Varyant listesi alınamadı",
+
+ "context.button.confirm": "✅ Evet, bağlamı sıkıştır",
+ "context.no_active_session": "⚠️ Etkin oturum yok. /new ile bir oturum oluşturun",
+ "context.confirm_text":
+ '📊 "{title}" oturumu için bağlam sıkıştırma\n\nBu işlem, geçmişteki eski mesajları kaldırarak bağlam kullanımını azaltır. Geçerli görev kesilmeyecek.\n\nDevam edilsin mi?',
+ "context.callback_compacting": "Bağlam sıkıştırılıyor...",
+ "context.progress": "⏳ Bağlam sıkıştırılıyor...",
+ "context.error": "❌ Bağlam sıkıştırma başarısız oldu",
+ "context.success": "✅ Bağlam başarıyla sıkıştırıldı",
+
+ "permission.inactive_callback": "İzin isteği etkin değil",
+ "permission.processing_error_callback": "İşleme hatası",
+ "permission.no_active_request_callback": "Hata: etkin istek yok",
+ "permission.reply.once": "Bir kez izin verildi",
+ "permission.reply.always": "Her zaman izin verildi",
+ "permission.reply.reject": "Reddedildi",
+ "permission.send_reply_error": "❌ İzin yanıtı gönderilemedi",
+ "permission.blocked.expected_reply":
+ "⚠️ Lütfen önce yukarıdaki düğmelerle izin isteğini yanıtlayın.",
+ "permission.blocked.command_not_allowed":
+ "⚠️ İzin isteğini yanıtlayana kadar bu komut kullanılamaz.",
+ "permission.header": "{emoji} İzin isteği: {name}\n\n",
+ "permission.grouped_count": "\n⚠️ {count} özdeş istek bekliyor — yanıtınız hepsine uygulanır.\n",
+ "permission.button.allow": "✅ Bir kez izin ver",
+ "permission.button.always": "🔓 Her zaman izin ver",
+ "permission.button.reject": "❌ Reddet",
+ "permission.name.bash": "Bash",
+ "permission.name.edit": "Düzenleme",
+ "permission.name.write": "Yazma",
+ "permission.name.read": "Okuma",
+ "permission.name.webfetch": "Web'den getirme",
+ "permission.name.websearch": "Web araması",
+ "permission.name.glob": "Dosya araması",
+ "permission.name.grep": "İçerik araması",
+ "permission.name.list": "Dizin listeleme",
+ "permission.name.task": "Görev",
+ "permission.name.lsp": "LSP",
+ "permission.name.external_directory": "Harici dizin",
+
+ "question.inactive_callback": "Anket etkin değil",
+ "question.processing_error_callback": "İşleme hatası",
+ "question.select_one_required_callback": "En az bir seçenek seçin",
+ "question.enter_custom_callback": "Özel yanıtınızı mesaj olarak gönderin",
+ "question.cancelled": "❌ Anket iptal edildi",
+ "question.answer_already_received": "Yanıt zaten alındı, lütfen bekleyin...",
+ "question.completed_no_answers": "✅ Anket tamamlandı (yanıt yok)",
+ "question.no_active_project": "❌ Etkin proje yok",
+ "question.no_active_request": "❌ Etkin istek yok",
+ "question.send_answers_error": "❌ Yanıtlar ajana gönderilemedi",
+ "question.multi_hint": "\n(Birden fazla seçenek seçebilirsiniz)",
+ "question.button.submit": "✅ Tamam",
+ "question.button.custom": "🔤 Özel yanıt",
+ "question.button.cancel": "❌ İptal",
+ "question.use_custom_button_first":
+ '⚠️ Metin göndermek için önce geçerli sorudaki "Özel yanıt" düğmesine dokunun.',
+ "question.summary.title": "✅ Anket tamamlandı!\n\n",
+ "question.summary.question": "Soru {index}:\n{question}\n\n",
+ "question.summary.answer": "Yanıt:\n{answer}\n\n",
+
+ "keyboard.agent_mode": "{emoji} {name} Agent",
+ "keyboard.context": "📊 {used} / {limit} ({percent}%)",
+ "keyboard.context_empty": "📊 0",
+ "keyboard.variant": "💭 {name}",
+ "keyboard.variant_default": "💡 Varsayılan",
+ "keyboard.queued_prompt": "❌ {index}. {text}",
+ "queue.added": "📥 Kuyruğa eklendi ({count}/{max}). Geçerli görev bittiğinde gönderilecek.",
+ "queue.full":
+ "⚠️ Kuyruk dolu ({max}). Bir mesajı kaldırın veya geçerli görevin bitmesini bekleyin.",
+ "queue.media_limit":
+ "⚠️ Kuyruktaki medya {maxSizeMb} MiB ile sınırlıdır. Bir öğenin gönderilmesini bekleyip tekrar deneyin.",
+ "queue.removed": "🗑 Mesaj kuyruktan kaldırıldı.",
+ "queue.not_found": "Bu mesaj artık kuyrukta değil.",
+ "queue.disabled_hint": "Mesaj kuyruğu /settings içinden açılabilir.",
+ "keyboard.updated": "⌨️ Klavye güncellendi",
+
+ "pinned.default_session_title": "yeni oturum",
+ "pinned.unknown": "Bilinmiyor",
+ "pinned.line.project": "Proje: {project}",
+ "pinned.line.worktree": "Worktree: {worktree}",
+ "pinned.line.model": "Model: {model}",
+ "pinned.line.attach": "Takip: {status}",
+ "pinned.attach.status.idle": "etkin, boşta",
+ "pinned.attach.status.busy": "etkin, meşgul",
+ "pinned.line.context": "Bağlam: {used} / {limit} ({percent}%)",
+ "pinned.line.cost": "Maliyet: {cost} harcandı",
+ "subagent.header": "Alt ajan {agent}: {description}",
+ "subagent.line.status": "Durum: {status}",
+ "subagent.line.task": "Görev: {task}",
+ "subagent.line.agent": "Ajan: {agent}",
+ "subagent.working": "Çalışıyor...",
+ "subagent.working_with_details": "Çalışıyor: {details}",
+ "subagent.completed": "Tamamlandı",
+ "subagent.failed": "Görev başarısız oldu",
+ "subagent.status.pending": "bekliyor",
+ "subagent.status.running": "çalışıyor",
+ "subagent.status.completed": "tamamlandı",
+ "subagent.status.error": "hata",
+ "pinned.files.title": "Dosyalar ({count}):",
+ "pinned.files.item": " {path}{diff}",
+ "pinned.files.more": " ... ve {count} tane daha",
+
+ "tool.todo.overflow": "*({count} görev daha)*",
+ "tool.file_header.write":
+ "Dosya/Yol yaz: {path}\n============================================================\n\n",
+ "tool.file_header.edit":
+ "Dosya/Yol düzenle: {path}\n============================================================\n\n",
+
+ "runtime.wizard.ask_token": "Telegram bot token'ını girin (@BotFather'dan alabilirsiniz).\n> ",
+ "runtime.wizard.ask_language":
+ "Arayüz dilini seçin.\nListeden dil numarasını veya yerel ayar kodunu girin.\nVarsayılan dili korumak için Enter'a basın: {defaultLocale}\n{options}\n> ",
+ "runtime.wizard.language_invalid":
+ "Listeden bir dil numarası veya desteklenen bir yerel ayar kodu girin.\n",
+ "runtime.wizard.language_selected": "Seçilen dil: {language}\n",
+ "runtime.wizard.token_required": "Token gereklidir. Lütfen tekrar deneyin.\n",
+ "runtime.wizard.token_invalid":
+ "Token geçersiz görünüyor (beklenen biçim :). Lütfen tekrar deneyin.\n",
+ "runtime.wizard.ask_user_id":
+ "Telegram Kullanıcı ID'nizi girin (@userinfobot'tan alabilirsiniz).\n> ",
+ "runtime.wizard.user_id_invalid": "Pozitif bir tam sayı girin (> 0).\n",
+ "runtime.wizard.ask_api_url":
+ "OpenCode API URL'sini girin (isteğe bağlı).\nVarsayılanı kullanmak için Enter'a basın: {defaultUrl}\n> ",
+ "runtime.wizard.ask_server_username":
+ "OpenCode sunucu kullanıcı adını girin (isteğe bağlı).\nVarsayılanı kullanmak için Enter'a basın: {defaultUsername}\n> ",
+ "runtime.wizard.ask_server_password":
+ "OpenCode sunucu parolasını girin (isteğe bağlı).\nBoş bırakmak için Enter'a basın.\n> ",
+ "runtime.wizard.api_url_invalid":
+ "Geçerli bir URL (http/https) girin veya varsayılan için Enter'a basın.\n",
+ "runtime.wizard.start": "OpenCode Telegram Bot kurulumu.\n",
+ "runtime.wizard.saved": "Yapılandırma kaydedildi:\n- {envPath}\n",
+ "runtime.wizard.not_configured_starting":
+ "Uygulama henüz yapılandırılmadı. Sihirbaz başlatılıyor...\n",
+ "runtime.wizard.tty_required":
+ "Etkileşimli sihirbaz bir TTY terminali gerektirir. `opencode-telegram config` komutunu etkileşimli bir kabukta çalıştırın.",
+ "runtime.container.command_unavailable": "⚠️ Bu komut Docker imajında kullanılamaz.",
+
+ "rename.no_session": "⚠️ Etkin oturum yok. Önce bir oturum oluşturun veya seçin.",
+ "rename.prompt": "📝 Oturum için yeni başlık girin:\n\nGeçerli: {title}",
+ "rename.empty_title": "⚠️ Başlık boş olamaz.",
+ "rename.success": "✅ Oturumun yeni adı: {title}",
+ "rename.error": "🔴 Oturum yeniden adlandırılamadı.",
+ "rename.cancelled": "❌ Yeniden adlandırma iptal edildi.",
+ "rename.inactive_callback": "Yeniden adlandırma isteği etkin değil",
+ "rename.inactive":
+ "⚠️ Yeniden adlandırma isteği etkin değil. /rename komutunu tekrar çalıştırın.",
+ "rename.blocked.expected_name":
+ "⚠️ Yeni oturum adını metin olarak girin veya yeniden adlandırma mesajındaki İptal'e dokunun.",
+ "rename.blocked.command_not_allowed":
+ "⚠️ Yeniden adlandırma yeni ad beklerken bu komut kullanılamaz.",
+ "rename.button.cancel": "❌ İptal",
+
+ "task.prompt.schedule":
+ "⏰ Görev zamanlamasını doğal dilde gönderin.\n\nÖrnekler:\n- her 5 dakikada bir\n- her gün 17:00'de\n- yarın 12:00'de",
+ "task.schedule_empty": "⚠️ Zamanlama boş olamaz.",
+ "task.parse.in_progress": "⏳ Zamanlama çözümleniyor...",
+ "task.parse_error":
+ "🔴 Zamanlama çözümlenemedi.\n\n{message}\n\nZamanlamayı daha açık bir biçimde tekrar gönderin.",
+ "task.schedule_preview":
+ "✅ Zamanlama çözümlendi\n\nNasıl anladım: {summary}\n{cronLine}Saat dilimi: {timezone}\nTür: {kind}\nSonraki çalışma: {nextRunAt}",
+ "task.schedule_preview.cron": "Cron: {cron}",
+ "task.prompt.body": "📝 Şimdi botun zamanlamaya göre ne yapması gerektiğini gönderin.",
+ "task.prompt_empty": "⚠️ Görev metni boş olamaz.",
+ "task.created":
+ "✅ Zamanlanmış görev oluşturuldu\n\nGörev: {description}\nProje: {project}\nAjan: {agent}\nModel: {model}\nZamanlama: {schedule}\n{cronLine}Sonraki çalışma: {nextRunAt}",
+ "task.created.cron": "Cron: {cron}",
+ "task.button.retry_schedule": "🔁 Zamanlamayı yeniden gir",
+ "task.button.cancel": "❌ İptal",
+ "task.retry_schedule_callback": "Zamanlama yeniden giriliyor...",
+ "task.inactive_callback": "Bu zamanlanmış görev akışı etkin değil",
+ "task.inactive": "⚠️ Zamanlanmış görev oluşturma etkin değil. /task komutunu tekrar çalıştırın.",
+ "task.blocked.expected_input":
+ "⚠️ Önce metin göndererek veya zamanlama mesajındaki düğmeyi kullanarak geçerli zamanlanmış görev kurulumunu tamamlayın.",
+ "task.blocked.command_not_allowed":
+ "⚠️ Zamanlanmış görev oluşturma etkinken bu komut kullanılamaz.",
+ "task.limit_reached":
+ "⚠️ Görev sınırına ulaşıldı ({limit}). Önce mevcut bir zamanlanmış görevi silin.",
+ "task.schedule_too_frequent":
+ "Yinelenen zamanlama çok sık. İzin verilen en kısa aralık 5 dakikada birdir.",
+ "task.kind.cron": "yinelenen",
+ "task.kind.once": "tek seferlik",
+ "task.run.success": "⏰ Zamanlanmış görev tamamlandı: {description}",
+ "task.run.error": "🔴 Zamanlanmış görev başarısız oldu: {description}\n\nHata: {error}",
+ "task.run.error.interactive_question":
+ "Zamanlanmış görev etkileşimli bir soru sordu ve gözetimsiz devam edemiyor.",
+ "task.run.error.interactive_permission":
+ "Zamanlanmış görev etkileşimli izin istedi ve gözetimsiz devam edemiyor.",
+
+ "tasklist.empty": "📭 Henüz zamanlanmış görev yok.",
+ "tasklist.select": "Bir zamanlanmış görev seçin:",
+ "tasklist.details":
+ "⏰ Zamanlanmış görev\n\nGörev: {prompt}\nProje: {project}\nZamanlama: {schedule}\n{cronLine}Saat dilimi: {timezone}\nSonraki çalışma: {nextRunAt}\nSon çalışma: {lastRunAt}\nÇalışma sayısı: {runCount}",
+ "tasklist.details.cron": "Cron: {cron}",
+ "tasklist.button.delete": "🗑 Sil",
+ "tasklist.button.cancel": "❌ İptal",
+ "tasklist.deleted_callback": "Silindi",
+ "tasklist.inactive_callback": "Bu zamanlanmış görev menüsü etkin değil",
+ "tasklist.load_error": "🔴 Zamanlanmış görevler yüklenemedi.",
+
+ "commands.select": "Bir OpenCode komutu seçin:",
+ "commands.empty": "📭 Bu proje için kullanılabilir OpenCode komutu yok.",
+ "commands.fetch_error": "🔴 OpenCode komutları yüklenemedi.",
+ "commands.no_description": "Açıklama yok",
+ "commands.button.execute": "✅ Çalıştır",
+ "commands.button.cancel": "❌ İptal",
+ "commands.confirm":
+ "{command} komutunun çalıştırılmasını onaylayın. Argümanlarla çalıştırmak için argümanları mesaj olarak gönderin.",
+ "commands.inactive_callback": "Bu komut menüsü etkin değil",
+ "commands.execute_callback": "Komut çalıştırılıyor...",
+ "commands.executing_prefix": "⚡ Komut çalıştırılıyor:",
+ "commands.arguments_empty": "⚠️ Argümanlar boş olamaz. Metin gönderin veya Çalıştır'a dokunun.",
+ "commands.execute_error": "🔴 OpenCode komutu çalıştırılamadı.",
+ "commands.select_page": "Bir OpenCode komutu seçin (sayfa {page}):",
+ "commands.button.prev_page": "⬅️ Önceki",
+ "commands.button.next_page": "Sonraki ➡️",
+ "commands.page_empty_callback": "Bu sayfada komut yok",
+ "commands.page_load_error_callback": "Bu sayfa yüklenemiyor. Lütfen tekrar deneyin.",
+ "commands.download.no_roots": "İzin verilen tarayıcı kök dizini yapılandırılmamış.",
+ "commands.download.downloading": "Dosya indiriliyor...",
+ "commands.download.not_found": "Dosya bulunamadı",
+ "commands.download.not_file": "Yol bir dosya değil",
+ "commands.download.file_too_large": "Dosya çok büyük",
+ "commands.download.size": "Boyut",
+ "commands.download.modified": "Değiştirilme",
+ "commands.download.error": "Dosya indirilemedi.",
+
+ "skills.select": "Bir OpenCode yeteneği seçin:",
+ "skills.empty": "📭 Bu proje için kullanılabilir OpenCode yeteneği yok.",
+ "skills.fetch_error": "🔴 OpenCode yetenekleri yüklenemedi.",
+ "skills.no_description": "Açıklama yok",
+ "skills.button.execute": "✅ Çalıştır",
+ "skills.button.cancel": "❌ İptal",
+ "skills.confirm":
+ "{skill} yeteneğinin çalıştırılmasını onaylayın. Argümanlarla çalıştırmak için argümanları mesaj olarak gönderin.",
+ "skills.inactive_callback": "Bu yetenek menüsü etkin değil",
+ "skills.execute_callback": "Yetenek kullanılıyor...",
+ "skills.executing_prefix": "⚡ Yetenek kullanılıyor:",
+ "skills.arguments_empty": "⚠️ Argümanlar boş olamaz. Metin gönderin veya Çalıştır'a dokunun.",
+ "skills.select_page": "Bir OpenCode yeteneği seçin (sayfa {page}):",
+ "skills.button.prev_page": "⬅️ Önceki",
+ "skills.button.next_page": "Sonraki ➡️",
+ "skills.page_empty_callback": "Bu sayfada yetenek yok",
+ "skills.page_load_error_callback": "Bu sayfa yüklenemiyor. Lütfen tekrar deneyin.",
+
+ "mcps.select": "MCP sunucuları:",
+ "mcps.empty": "📭 Yapılandırılmış MCP sunucusu yok.",
+ "mcps.fetch_error": "🔴 MCP sunucuları yüklenemedi.",
+ "mcps.toggle_error": "🔴 MCP sunucusunun durumu değiştirilemedi.",
+ "mcps.enabling": "Etkinleştiriliyor...",
+ "mcps.disabling": "Devre dışı bırakılıyor...",
+ "mcps.status.connected": "🟢 Bağlı",
+ "mcps.status.disabled": "🔴 Devre dışı",
+ "mcps.status.failed": "⚠️ Başarısız",
+ "mcps.status.needs_auth": "🔒 Yetkilendirme gerekli",
+ "mcps.status.needs_client_registration": "🔒 Kayıt gerekli",
+ "mcps.detail.title": "Sunucu: {name}",
+ "mcps.detail.status": "Durum: {status}",
+ "mcps.detail.error": "Hata: {error}",
+ "mcps.button.enable": "🟢 Etkinleştir",
+ "mcps.button.disable": "🔴 Devre dışı bırak",
+ "mcps.button.back": "⬅️ Geri",
+ "mcps.auth_required": "Bu sunucu yetkilendirme gerektiriyor ve bottan etkinleştirilemez.",
+
+ "cmd.description.rename": "Geçerli oturumu yeniden adlandır",
+
+ "legacy.models.fetch_error":
+ "🔴 Model listesi alınamadı. Sunucu durumunu /status ile kontrol edin.",
+ "legacy.models.empty": "📋 Kullanılabilir model yok. OpenCode'da sağlayıcıları yapılandırın.",
+ "legacy.models.header": "📋 Kullanılabilir modeller:\n\n",
+ "legacy.models.no_provider_models": " ⚠️ Kullanılabilir model yok\n",
+ "legacy.models.env_hint": "💡 Modeli .env içinde kullanmak için:\n",
+ "legacy.models.error": "🔴 Model listesi yüklenirken bir hata oluştu.",
+
+ "stt.recognizing": "🎤 Ses tanınıyor...",
+ "stt.recognized": "🎤 Tanınan:",
+ "stt.not_configured":
+ "🎤 Ses tanıma yapılandırılmamış.\n\nEtkinleştirmek için .env içinde STT_API_URL ve STT_API_KEY ayarlayın.",
+ "stt.error": "🔴 Ses tanınamadı: {error}",
+ "stt.empty_result": "🎤 Ses mesajında konuşma algılanmadı.",
+
+ "cmd.description.open": "Dizinlere göz atarak proje ekle",
+ "worktree.branch_detached": "detached HEAD",
+ "worktree.select_with_current": "Bir worktree seçin:",
+ "worktree.project_not_selected": "🏗 Proje seçilmedi.\n\nÖnce /projects ile bir proje seçin.",
+ "worktree.not_git_repo":
+ "🌿 Geçerli proje için git worktree kullanılamıyor. Önce bir git deposu seçin.",
+ "worktree.not_git_repo_callback": "Geçerli proje bir git deposu değil",
+ "worktree.empty": "📭 Geçerli depo için git worktree bulunamadı.",
+ "worktree.fetch_error": "🔴 Git worktree listesi yüklenemedi.",
+ "worktree.page_empty_callback": "Bu sayfada worktree yok",
+ "worktree.selection_missing_callback": "Seçilen worktree artık kullanılamıyor",
+ "worktree.already_selected_callback": "Bu worktree zaten seçili",
+ "worktree.selected":
+ "✅ Worktree seçildi: {worktree}\n\n📋 Oturum sıfırlandı. Devam etmek için /sessions veya /new kullanın.",
+ "worktree.select_error": "🔴 Worktree seçilemedi.",
+ "open.back": "⬆️ Yukarı",
+ "open.roots": "📋 Kök dizinlere dön",
+ "open.prev_page": "⬅️ Önceki",
+ "open.next_page": "Sonraki ➡️",
+ "open.select_current": "✅ Bu klasörü seç",
+ "open.select_root": "📂 Göz atmak için bir kök dizin seçin:",
+ "open.access_denied": "⛔ Erişim reddedildi: yol izin verilen kök dizinlerin dışında",
+ "open.scan_error": "🔴 Dizine göz atılamıyor: {error}",
+ "open.open_error": "🔴 Dizin tarayıcısı açılamadı.",
+ "open.selected":
+ "✅ Proje eklendi: {project}\n\n📋 Çalışmaya başlamak için /sessions veya /new kullanın.",
+ "open.select_error": "🔴 Proje eklenemedi.",
+ "open.no_subfolders": "📭 Alt klasör yok",
+ "open.subfolder_count": "{count} alt klasör",
+ "open.subfolders_count": "{count} alt klasör",
+ "ls.access_denied": "⛔ Erişim reddedildi: yol geçerli projenin dışında",
+ "ls.scan_error": "🔴 Dizin listelenemiyor",
+ "ls.header": "Dizin listesi",
+ "ls.total": "Toplam: {count} öğe",
+ "ls.file.header": "Dosya ayrıntıları",
+ "ls.file.download": "📥 İndir",
+ "ls.file.back": "⬅️ Geri",
+ "ls.file.attach": "📎 Sonraki isteme ekle",
+ "attachment.added": "📎 Eklendi: {path}\n\nMesajınızı gönderin, dosya onunla birlikte gidecek.",
+ "attachment.cancel": "❌ Eki iptal et",
+ "attachment.cancelled": "❌ Ek iptal edildi",
+ "attachment.invalid": "⚠️ Eklenen dosya artık kullanılamıyor. Mesaj dosya olmadan gönderiliyor.",
+ "local_command.empty_output": "Komut herhangi bir çıktı üretmedi.",
+ "local_command.failed": "Komut {exitCode} çıkış koduyla başarısız oldu: {stderr}",
+ "local_command.timeout": "Komut zaman aşımına uğradı.",
+};
diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts
index 7678bba03..aa62907ca 100644
--- a/src/i18n/zh.ts
+++ b/src/i18n/zh.ts
@@ -146,6 +146,7 @@ export const zh: I18nDictionary = {
"settings.response_streaming.draft": "draft (experimental)",
"settings.diff_files.label": "Diff 文件",
"settings.assistant_footer.label": "回复页脚",
+ "settings.pin_session_dashboard.label": "Pin session dashboard",
"settings.tts.label": "语音回复",
"settings.prompt_queue.label": "消息队列",
"settings.value.on": "开启",
diff --git a/tests/app/start-bot-app.test.ts b/tests/app/start-bot-app.test.ts
index dc1824f6e..86664b6d6 100644
--- a/tests/app/start-bot-app.test.ts
+++ b/tests/app/start-bot-app.test.ts
@@ -25,6 +25,7 @@ const mocked = vi.hoisted(() => ({
initializeLoggerMock: vi.fn(),
getLogFilePathMock: vi.fn(),
flushLoggerMock: vi.fn(),
+ restoreFollowedSessionOnPollingStartMock: vi.fn(),
config: {
opencode: {
apiUrl: "http://localhost:4096",
@@ -38,6 +39,7 @@ const mocked = vi.hoisted(() => ({
vi.mock("../../src/bot/index.js", () => ({
cleanupBotRuntime: mocked.cleanupBotRuntimeMock,
createBot: mocked.createBotMock,
+ restoreFollowedSessionOnPollingStart: mocked.restoreFollowedSessionOnPollingStartMock,
}));
vi.mock("../../src/config.js", () => ({
@@ -108,6 +110,7 @@ function createBot() {
return {
api: {
deleteWebhook: vi.fn().mockResolvedValue(undefined),
+ getMe: vi.fn().mockResolvedValue({ username: "test_bot" }),
getWebhookInfo: vi.fn().mockResolvedValue({ url: "" }),
},
start: vi.fn().mockImplementation(async ({ onStart }) => {
@@ -125,6 +128,7 @@ function createPendingBot() {
const bot = {
api: {
deleteWebhook: vi.fn().mockResolvedValue(undefined),
+ getMe: vi.fn().mockResolvedValue({ username: "test_bot" }),
getWebhookInfo: vi.fn().mockResolvedValue({ url: "" }),
},
start: vi.fn().mockImplementation(async ({ onStart }) => {
@@ -194,6 +198,7 @@ describe("app/start-bot-app", () => {
mocked.initializeLoggerMock.mockReset();
mocked.getLogFilePathMock.mockReset();
mocked.flushLoggerMock.mockReset();
+ mocked.restoreFollowedSessionOnPollingStartMock.mockReset();
mocked.createBotMock.mockReturnValue(createBot());
mocked.autoRestartStartMock.mockResolvedValue(false);
@@ -405,4 +410,249 @@ describe("app/start-bot-app", () => {
defined(mocked.scheduledTaskShutdownMock.mock.invocationCallOrder[0]),
);
});
+
+ it("retries getWebhookInfo on a network error then starts polling", async () => {
+ vi.useFakeTimers();
+ const bot = createBot();
+ const networkError = Object.assign(new Error("Network request for 'getWebhookInfo' failed!"), {
+ name: "HttpError",
+ });
+ bot.api.getWebhookInfo
+ .mockRejectedValueOnce(networkError)
+ .mockResolvedValueOnce({ url: "" });
+ mocked.createBotMock.mockReturnValue(bot);
+
+ const appPromise = startBotApp();
+ await vi.advanceTimersByTimeAsync(1000);
+ await appPromise;
+
+ expect(bot.api.getWebhookInfo).toHaveBeenCalledTimes(2);
+ expect(bot.start).toHaveBeenCalledTimes(1);
+ expect(mocked.loggerWarnMock).toHaveBeenCalledWith(
+ "[App] Telegram getWebhookInfo failed (attempt 1); retrying in 1000ms",
+ networkError,
+ );
+
+ vi.useRealTimers();
+ });
+
+ it("retries a 501 server error then starts polling", async () => {
+ vi.useFakeTimers();
+ const bot = createBot();
+ const serverError = Object.assign(new Error("Call to 'getWebhookInfo' failed! (501: Not Implemented)"), {
+ error_code: 501,
+ });
+ bot.api.getWebhookInfo.mockRejectedValueOnce(serverError).mockResolvedValueOnce({ url: "" });
+ mocked.createBotMock.mockReturnValue(bot);
+
+ const appPromise = startBotApp();
+ await vi.advanceTimersByTimeAsync(1000);
+ await appPromise;
+
+ expect(bot.api.getWebhookInfo).toHaveBeenCalledTimes(2);
+ expect(bot.start).toHaveBeenCalledTimes(1);
+
+ vi.useRealTimers();
+ });
+
+ it("caps a 429 retry_after at 60 seconds", async () => {
+ vi.useFakeTimers();
+ const bot = createBot();
+ const rateLimitError = Object.assign(
+ new Error("Call to 'getWebhookInfo' failed! (429: Too Many Requests)"),
+ {
+ error_code: 429,
+ parameters: { retry_after: 120 },
+ },
+ );
+ bot.api.getWebhookInfo.mockRejectedValueOnce(rateLimitError).mockResolvedValueOnce({ url: "" });
+ mocked.createBotMock.mockReturnValue(bot);
+
+ const appPromise = startBotApp();
+ await vi.advanceTimersByTimeAsync(59_000);
+ expect(bot.start).not.toHaveBeenCalled();
+ await vi.advanceTimersByTimeAsync(1000);
+ await appPromise;
+
+ expect(bot.start).toHaveBeenCalledTimes(1);
+ expect(mocked.loggerWarnMock).toHaveBeenCalledWith(
+ "[App] Telegram getWebhookInfo failed (attempt 1); retrying in 60000ms",
+ rateLimitError,
+ );
+
+ vi.useRealTimers();
+ });
+
+ it("retries getMe on a network error then starts polling", async () => {
+ vi.useFakeTimers();
+ const bot = createBot();
+ const networkError = Object.assign(new Error("Network request for 'getMe' failed!"), {
+ name: "HttpError",
+ });
+ bot.api.getMe.mockRejectedValueOnce(networkError).mockResolvedValueOnce({ username: "test_bot" });
+ mocked.createBotMock.mockReturnValue(bot);
+
+ const appPromise = startBotApp();
+ await vi.advanceTimersByTimeAsync(1000);
+ await appPromise;
+
+ expect(bot.api.getMe).toHaveBeenCalledTimes(2);
+ expect(bot.start).toHaveBeenCalledTimes(1);
+ expect(mocked.loggerWarnMock).toHaveBeenCalledWith(
+ "[App] Telegram getMe failed (attempt 1); retrying in 1000ms",
+ networkError,
+ );
+
+ vi.useRealTimers();
+ });
+
+ it("retries getWebhookInfo on a 5xx error then starts polling", async () => {
+ vi.useFakeTimers();
+ const bot = createBot();
+ const serverError = Object.assign(new Error("Call to 'getWebhookInfo' failed! (502: Bad Gateway)"), {
+ error_code: 502,
+ });
+ bot.api.getWebhookInfo.mockRejectedValueOnce(serverError).mockResolvedValueOnce({ url: "" });
+ mocked.createBotMock.mockReturnValue(bot);
+
+ const appPromise = startBotApp();
+ await vi.advanceTimersByTimeAsync(1000);
+ await appPromise;
+
+ expect(bot.api.getWebhookInfo).toHaveBeenCalledTimes(2);
+ expect(bot.start).toHaveBeenCalledTimes(1);
+
+ vi.useRealTimers();
+ });
+
+ it("retries deleteWebhook on a network error then starts polling", async () => {
+ vi.useFakeTimers();
+ const bot = createBot();
+ const networkError = Object.assign(new Error("Network request for 'deleteWebhook' failed!"), {
+ name: "HttpError",
+ });
+ bot.api.getWebhookInfo.mockResolvedValue({ url: "https://example.invalid/hook", pending_update_count: 0 });
+ bot.api.deleteWebhook.mockRejectedValueOnce(networkError).mockResolvedValueOnce(undefined);
+ mocked.createBotMock.mockReturnValue(bot);
+
+ const appPromise = startBotApp();
+ await vi.advanceTimersByTimeAsync(1000);
+ await appPromise;
+
+ expect(bot.api.deleteWebhook).toHaveBeenCalledTimes(2);
+ expect(bot.start).toHaveBeenCalledTimes(1);
+ expect(mocked.loggerWarnMock).toHaveBeenCalledWith(
+ "[App] Telegram deleteWebhook failed (attempt 1); retrying in 1000ms",
+ networkError,
+ );
+
+ vi.useRealTimers();
+ });
+
+ it("does not retry a rejected bot token and never starts polling", async () => {
+ const bot = createBot();
+ const tokenError = Object.assign(new Error("Call to 'getWebhookInfo' failed! (401: Unauthorized)"), {
+ error_code: 401,
+ });
+ bot.api.getWebhookInfo.mockRejectedValue(tokenError);
+ mocked.createBotMock.mockReturnValue(bot);
+
+ await expect(startBotApp()).rejects.toBe(tokenError);
+
+ expect(bot.api.getWebhookInfo).toHaveBeenCalledTimes(1);
+ expect(bot.start).not.toHaveBeenCalled();
+ expect(mocked.loggerErrorMock).toHaveBeenCalledWith(
+ "[App] Telegram rejected the bot token; not retrying",
+ tokenError,
+ );
+ });
+
+ it("does not retry a 404 token error", async () => {
+ const bot = createBot();
+ const tokenError = Object.assign(new Error("Call to 'getWebhookInfo' failed! (404: Not Found)"), {
+ error_code: 404,
+ });
+ bot.api.getWebhookInfo.mockRejectedValue(tokenError);
+ mocked.createBotMock.mockReturnValue(bot);
+
+ await expect(startBotApp()).rejects.toBe(tokenError);
+ expect(bot.start).not.toHaveBeenCalled();
+ });
+
+ it("does not retry other Telegram startup errors", async () => {
+ const bot = createBot();
+ const conflictError = Object.assign(new Error("Call to 'getWebhookInfo' failed! (409: Conflict)"), {
+ error_code: 409,
+ });
+ bot.api.getWebhookInfo.mockRejectedValue(conflictError);
+ mocked.createBotMock.mockReturnValue(bot);
+
+ await expect(startBotApp()).rejects.toBe(conflictError);
+ expect(bot.start).not.toHaveBeenCalled();
+ expect(mocked.loggerErrorMock).toHaveBeenCalledWith(
+ "[App] Telegram startup failed; not retrying",
+ conflictError,
+ );
+ });
+
+ it("caps the retry delay at 60 seconds", async () => {
+ vi.useFakeTimers();
+ const bot = createBot();
+ const networkError = Object.assign(new Error("Network request for 'getWebhookInfo' failed!"), {
+ name: "HttpError",
+ });
+ bot.api.getWebhookInfo.mockRejectedValue(networkError);
+ mocked.createBotMock.mockReturnValue(bot);
+
+ const appPromise = startBotApp();
+ for (let i = 0; i < 6; i += 1) {
+ await vi.advanceTimersByTimeAsync(60_000);
+ }
+
+ expect(mocked.loggerWarnMock).toHaveBeenCalledWith(
+ "[App] Telegram getWebhookInfo failed (attempt 7); retrying in 60000ms",
+ networkError,
+ );
+ expect(bot.start).not.toHaveBeenCalled();
+
+ bot.api.getWebhookInfo.mockResolvedValueOnce({ url: "" });
+ await vi.advanceTimersByTimeAsync(60_000);
+ await appPromise;
+ expect(bot.start).toHaveBeenCalledTimes(1);
+
+ vi.useRealTimers();
+ });
+
+ it("does not start polling when shutdown begins during a retry wait", async () => {
+ vi.useFakeTimers();
+ const bot = createBot();
+ const networkError = Object.assign(new Error("Network request for 'getWebhookInfo' failed!"), {
+ name: "HttpError",
+ });
+ bot.api.getWebhookInfo.mockRejectedValue(networkError);
+ mocked.createBotMock.mockReturnValue(bot);
+
+ const appPromise = startBotApp();
+ await vi.waitFor(() => {
+ expect(mocked.loggerWarnMock).toHaveBeenCalled();
+ });
+
+ expectHandler("SIGINT")();
+ await vi.advanceTimersByTimeAsync(1000);
+ await appPromise;
+
+ expect(bot.start).not.toHaveBeenCalled();
+
+ vi.useRealTimers();
+ });
+
+ it("restores the followed session when polling starts", async () => {
+ const bot = createBot();
+ mocked.createBotMock.mockReturnValue(bot);
+
+ await startBotApp();
+
+ expect(bot).toMatchObject({ botInfo: { username: "test_bot" } });
+ expect(mocked.restoreFollowedSessionOnPollingStartMock).toHaveBeenCalledWith(bot);
+ });
});
diff --git a/tests/app/stores/settings-store.test.ts b/tests/app/stores/settings-store.test.ts
index ef3d990c5..bd6ed16bd 100644
--- a/tests/app/stores/settings-store.test.ts
+++ b/tests/app/stores/settings-store.test.ts
@@ -11,6 +11,7 @@ import {
getPromptQueueEnabled,
getResponseStreamingMode,
getSendDiffFileAttachments,
+ getPinnedDashboardEnabled,
getShowAssistantRunFooter,
getShowThinkingContent,
getScheduledTasks,
@@ -85,6 +86,23 @@ describe("app/stores/settings-store", () => {
expect(getShowAssistantRunFooter()).toBe(true);
});
+ it("enables the pinned session dashboard by default", async () => {
+ await loadSettings();
+
+ expect(getPinnedDashboardEnabled()).toBe(true);
+ });
+
+ it("loads the pinned session dashboard setting from settings.json", async () => {
+ await writeFile(
+ path.join(tempHome, "settings.json"),
+ JSON.stringify({ pinnedDashboardEnabled: false }),
+ );
+
+ await loadSettings();
+
+ expect(getPinnedDashboardEnabled()).toBe(false);
+ });
+
it("disables the prompt queue by default", async () => {
await loadSettings();
@@ -106,7 +124,7 @@ describe("app/stores/settings-store", () => {
vi.resetModules();
vi.stubEnv(
"INITIAL_SETTINGS_PRESET",
- '{"showAssistantRunFooter":false,"compactOutputMode":true,"deleteCompactProgressOnFinish":true,"ttsMode":"auto","responseStreamingMode":"draft","sendDiffFileAttachments":false,"showThinkingContent":false,"promptQueueEnabled":true}',
+ '{"showAssistantRunFooter":false,"compactOutputMode":true,"deleteCompactProgressOnFinish":true,"ttsMode":"auto","responseStreamingMode":"draft","sendDiffFileAttachments":false,"showThinkingContent":false,"promptQueueEnabled":true,"pinnedDashboardEnabled":false}',
);
const store = await import("../../../src/app/stores/settings-store.js");
@@ -120,6 +138,7 @@ describe("app/stores/settings-store", () => {
expect(store.getSendDiffFileAttachments()).toBe(false);
expect(store.getShowThinkingContent()).toBe(false);
expect(store.getPromptQueueEnabled()).toBe(true);
+ expect(store.getPinnedDashboardEnabled()).toBe(false);
vi.unstubAllEnvs();
vi.resetModules();
diff --git a/tests/bot/commands/settings.test.ts b/tests/bot/commands/settings.test.ts
index 622e3482d..834a02174 100644
--- a/tests/bot/commands/settings.test.ts
+++ b/tests/bot/commands/settings.test.ts
@@ -4,6 +4,16 @@ import { settingsCommand } from "../../../src/bot/commands/settings-command.js";
import { handleSettingsCallback } from "../../../src/bot/callbacks/settings-callback-handler.js";
import { interactionManager } from "../../../src/app/managers/interaction-manager.js";
import { t } from "../../../src/i18n/index.js";
+import { ar } from "../../../src/i18n/ar.js";
+import { de } from "../../../src/i18n/de.js";
+import { en } from "../../../src/i18n/en.js";
+import { es } from "../../../src/i18n/es.js";
+import { fr } from "../../../src/i18n/fr.js";
+import { it as itLocale } from "../../../src/i18n/it.js";
+import { ko } from "../../../src/i18n/ko.js";
+import { pt } from "../../../src/i18n/pt.js";
+import { ru } from "../../../src/i18n/ru.js";
+import { zh } from "../../../src/i18n/zh.js";
import { defined } from "../../helpers/defined.js";
import {
SETTINGS_ASSISTANT_FOOTER_CALLBACK,
@@ -11,6 +21,7 @@ import {
SETTINGS_COMPACT_OUTPUT_CALLBACK,
SETTINGS_DELETE_PROGRESS_ON_FINISH_CALLBACK,
SETTINGS_DIFF_FILES_CALLBACK,
+ SETTINGS_PIN_SESSION_DASHBOARD_CALLBACK,
SETTINGS_PROMPT_QUEUE_CALLBACK,
SETTINGS_RESPONSE_STREAMING_CALLBACK,
SETTINGS_THINKING_CONTENT_CALLBACK,
@@ -30,6 +41,9 @@ const mocked = vi.hoisted(() => ({
setShowThinkingContentMock: vi.fn(),
getShowAssistantRunFooterMock: vi.fn(),
setShowAssistantRunFooterMock: vi.fn(),
+ getPinnedDashboardEnabledMock: vi.fn(),
+ setPinnedDashboardEnabledMock: vi.fn(),
+ applyPinnedDashboardEnabledMock: vi.fn(),
getTtsModeMock: vi.fn(),
setTtsModeMock: vi.fn(),
getPromptQueueEnabledMock: vi.fn(),
@@ -50,6 +64,8 @@ vi.mock("../../../src/app/stores/settings-store.js", () => ({
setShowThinkingContent: mocked.setShowThinkingContentMock,
getShowAssistantRunFooter: mocked.getShowAssistantRunFooterMock,
setShowAssistantRunFooter: mocked.setShowAssistantRunFooterMock,
+ getPinnedDashboardEnabled: mocked.getPinnedDashboardEnabledMock,
+ setPinnedDashboardEnabled: mocked.setPinnedDashboardEnabledMock,
getTtsMode: mocked.getTtsModeMock,
setTtsMode: mocked.setTtsModeMock,
getPromptQueueEnabled: mocked.getPromptQueueEnabledMock,
@@ -60,6 +76,13 @@ vi.mock("../../../src/app/services/tts-service.js", () => ({
isTtsConfigured: mocked.isTtsConfiguredMock,
}));
+vi.mock("../../../src/bot/pinned/pinned-message-manager.js", () => ({
+ pinnedMessageManager: {
+ initialize: vi.fn(),
+ applyPinnedDashboardEnabled: mocked.applyPinnedDashboardEnabledMock,
+ },
+}));
+
describe("bot/commands/settings-command", () => {
beforeEach(() => {
mocked.getCompactOutputModeMock.mockReset();
@@ -74,6 +97,9 @@ describe("bot/commands/settings-command", () => {
mocked.setShowThinkingContentMock.mockReset();
mocked.getShowAssistantRunFooterMock.mockReset();
mocked.setShowAssistantRunFooterMock.mockReset();
+ mocked.getPinnedDashboardEnabledMock.mockReset();
+ mocked.setPinnedDashboardEnabledMock.mockReset();
+ mocked.applyPinnedDashboardEnabledMock.mockReset();
mocked.getTtsModeMock.mockReset();
mocked.setTtsModeMock.mockReset();
mocked.getPromptQueueEnabledMock.mockReset();
@@ -82,6 +108,8 @@ describe("bot/commands/settings-command", () => {
mocked.getResponseStreamingModeMock.mockReturnValue("edit");
mocked.getSendDiffFileAttachmentsMock.mockReturnValue(true);
mocked.getShowAssistantRunFooterMock.mockReturnValue(true);
+ mocked.getPinnedDashboardEnabledMock.mockReturnValue(true);
+ mocked.applyPinnedDashboardEnabledMock.mockResolvedValue(undefined);
mocked.getPromptQueueEnabledMock.mockReturnValue(false);
interactionManager.clear("settings_test_reset");
});
@@ -117,12 +145,15 @@ describe("bot/commands/settings-command", () => {
`${t("settings.assistant_footer.label")}: ${t("settings.value.on")}`,
);
expect(opts.reply_markup.inline_keyboard[4][0].text).toBe(
- `${t("settings.tts.label")}: ${t("status.tts.auto")}`,
+ `${t("settings.pin_session_dashboard.label")}: ${t("settings.value.on")}`,
);
expect(opts.reply_markup.inline_keyboard[5][0].text).toBe(
+ `${t("settings.tts.label")}: ${t("status.tts.auto")}`,
+ );
+ expect(opts.reply_markup.inline_keyboard[6][0].text).toBe(
`${t("settings.prompt_queue.label")}: ${t("settings.value.off")}`,
);
- expect(opts.reply_markup.inline_keyboard[6][0].text).toBe(t("inline.button.close"));
+ expect(opts.reply_markup.inline_keyboard[7][0].text).toBe(t("inline.button.close"));
});
it("shows thinking content setting when compact output is disabled", async () => {
@@ -154,9 +185,12 @@ describe("bot/commands/settings-command", () => {
`${t("settings.assistant_footer.label")}: ${t("settings.value.on")}`,
);
expect(opts.reply_markup.inline_keyboard[5][0].text).toBe(
- `${t("settings.tts.label")}: ${t("status.tts.off")}`,
+ `${t("settings.pin_session_dashboard.label")}: ${t("settings.value.on")}`,
);
expect(opts.reply_markup.inline_keyboard[6][0].text).toBe(
+ `${t("settings.tts.label")}: ${t("status.tts.off")}`,
+ );
+ expect(opts.reply_markup.inline_keyboard[7][0].text).toBe(
`${t("settings.prompt_queue.label")}: ${t("settings.value.off")}`,
);
});
@@ -198,6 +232,9 @@ describe("bot/callbacks/settings-callback-handler", () => {
mocked.setShowThinkingContentMock.mockReset();
mocked.getShowAssistantRunFooterMock.mockReset();
mocked.setShowAssistantRunFooterMock.mockReset();
+ mocked.getPinnedDashboardEnabledMock.mockReset();
+ mocked.setPinnedDashboardEnabledMock.mockReset();
+ mocked.applyPinnedDashboardEnabledMock.mockReset();
mocked.getTtsModeMock.mockReset();
mocked.setTtsModeMock.mockReset();
mocked.getPromptQueueEnabledMock.mockReset();
@@ -206,6 +243,8 @@ describe("bot/callbacks/settings-callback-handler", () => {
mocked.getResponseStreamingModeMock.mockReturnValue("edit");
mocked.getSendDiffFileAttachmentsMock.mockReturnValue(true);
mocked.getShowAssistantRunFooterMock.mockReturnValue(true);
+ mocked.getPinnedDashboardEnabledMock.mockReturnValue(true);
+ mocked.applyPinnedDashboardEnabledMock.mockResolvedValue(undefined);
mocked.getPromptQueueEnabledMock.mockReturnValue(false);
interactionManager.clear("settings_test_reset");
});
@@ -258,6 +297,9 @@ describe("bot/callbacks/settings-callback-handler", () => {
`${t("settings.assistant_footer.label")}: ${t("settings.value.on")}`,
);
expect(defined(opts?.reply_markup?.inline_keyboard[4]?.[0]).text).toBe(
+ `${t("settings.pin_session_dashboard.label")}: ${t("settings.value.on")}`,
+ );
+ expect(defined(opts?.reply_markup?.inline_keyboard[5]?.[0]).text).toBe(
`${t("settings.tts.label")}: ${t("status.tts.off")}`,
);
});
@@ -383,7 +425,7 @@ describe("bot/callbacks/settings-callback-handler", () => {
const call = defined(vi.mocked(ctx.editMessageText).mock.calls[0]);
const [text, opts] = call;
expect(text).toBe(t("settings.menu.title"));
- expect(defined(opts?.reply_markup?.inline_keyboard[6]?.[0]).text).toBe(
+ expect(defined(opts?.reply_markup?.inline_keyboard[7]?.[0]).text).toBe(
`${t("settings.prompt_queue.label")}: ${t("settings.value.on")}`,
);
});
@@ -404,7 +446,7 @@ describe("bot/callbacks/settings-callback-handler", () => {
const call = defined(vi.mocked(ctx.editMessageText).mock.calls[0]);
const [text, opts] = call;
expect(text).toBe(t("settings.menu.title"));
- expect(defined(opts?.reply_markup?.inline_keyboard[5]?.[0]).text).toBe(
+ expect(defined(opts?.reply_markup?.inline_keyboard[6]?.[0]).text).toBe(
`${t("settings.tts.label")}: ${t("status.tts.all")}`,
);
});
@@ -442,6 +484,40 @@ describe("bot/callbacks/settings-callback-handler", () => {
expect(ctx.editMessageText).toHaveBeenCalledTimes(1);
});
+ it("toggles pin session dashboard and applies it immediately", async () => {
+ mocked.getCompactOutputModeMock.mockReturnValue(false);
+ mocked.getShowThinkingContentMock.mockReturnValue(true);
+ mocked.getTtsModeMock.mockReturnValue("off");
+ mocked.getPinnedDashboardEnabledMock.mockReturnValueOnce(true).mockReturnValueOnce(false);
+ activateSettingsMenu();
+ const ctx = createCallbackContext(SETTINGS_PIN_SESSION_DASHBOARD_CALLBACK);
+
+ const result = await handleSettingsCallback(ctx);
+
+ expect(result).toBe(true);
+ expect(mocked.applyPinnedDashboardEnabledMock).toHaveBeenCalledWith(false);
+ expect(mocked.setPinnedDashboardEnabledMock).toHaveBeenCalledWith(false);
+ expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ text: t("settings.saved") });
+ const call = defined(vi.mocked(ctx.editMessageText).mock.calls[0]);
+ const [, opts] = call;
+ expect(defined(opts?.reply_markup?.inline_keyboard[5]?.[0]).text).toBe(
+ `${t("settings.pin_session_dashboard.label")}: ${t("settings.value.off")}`,
+ );
+ });
+
+ it("does not persist pin session dashboard when applying it fails", async () => {
+ mocked.applyPinnedDashboardEnabledMock.mockRejectedValue(new Error("pin failed"));
+ activateSettingsMenu();
+ const ctx = createCallbackContext(SETTINGS_PIN_SESSION_DASHBOARD_CALLBACK);
+
+ const result = await handleSettingsCallback(ctx);
+
+ expect(result).toBe(true);
+ expect(mocked.setPinnedDashboardEnabledMock).not.toHaveBeenCalled();
+ expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ text: t("callback.processing_error") });
+ expect(ctx.editMessageText).not.toHaveBeenCalled();
+ });
+
it("ignores unrelated callbacks", async () => {
const ctx = createCallbackContext("unknown:data");
@@ -461,3 +537,13 @@ describe("bot/callbacks/settings-callback-handler", () => {
expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ text: t("callback.processing_error") });
});
});
+
+describe("pin session dashboard labels", () => {
+ it("uses the settled English and Russian labels and English in every other dictionary", () => {
+ expect(en["settings.pin_session_dashboard.label"]).toBe("Pin session dashboard");
+ expect(ru["settings.pin_session_dashboard.label"]).toBe("Закреплять дашборд сессии");
+ for (const dictionary of [ar, de, es, fr, itLocale, ko, pt, zh]) {
+ expect(dictionary["settings.pin_session_dashboard.label"]).toBe("Pin session dashboard");
+ }
+ });
+});
diff --git a/tests/bot/pinned/pinned-message-manager.test.ts b/tests/bot/pinned/pinned-message-manager.test.ts
index 1f241b330..7805af4f5 100644
--- a/tests/bot/pinned/pinned-message-manager.test.ts
+++ b/tests/bot/pinned/pinned-message-manager.test.ts
@@ -16,6 +16,7 @@ const mocked = vi.hoisted(() => ({
getPinnedMessageId: vi.fn().mockReturnValue(null),
setPinnedMessageId: vi.fn(),
clearPinnedMessageId: vi.fn(),
+ getPinnedDashboardEnabled: vi.fn().mockReturnValue(true),
getStoredModel: vi.fn().mockReturnValue(null),
getModelContextLimit: vi.fn().mockResolvedValue(204800),
getGitWorktreeContext: vi.fn(),
@@ -34,6 +35,7 @@ vi.mock("../../../src/app/stores/settings-store.js", () => ({
getPinnedMessageId: mocked.getPinnedMessageId,
setPinnedMessageId: mocked.setPinnedMessageId,
clearPinnedMessageId: mocked.clearPinnedMessageId,
+ getPinnedDashboardEnabled: mocked.getPinnedDashboardEnabled,
}));
vi.mock("../../../src/app/services/model-selection-service.js", () => ({ getStoredModel: mocked.getStoredModel }));
vi.mock("../../../src/app/services/model-context-limit-service.js", () => ({
@@ -79,6 +81,7 @@ describe("pinned/manager", () => {
sendMessage: ReturnType;
editMessageText: ReturnType;
pinChatMessage: ReturnType;
+ unpinChatMessage: ReturnType;
unpinAllChatMessages: ReturnType;
};
@@ -87,6 +90,7 @@ describe("pinned/manager", () => {
sendMessage: vi.fn().mockResolvedValue({ message_id: 999 }),
editMessageText: vi.fn().mockResolvedValue(undefined),
pinChatMessage: vi.fn().mockResolvedValue(undefined),
+ unpinChatMessage: vi.fn().mockResolvedValue(undefined),
unpinAllChatMessages: vi.fn().mockResolvedValue(undefined),
};
@@ -101,6 +105,7 @@ describe("pinned/manager", () => {
mocked.getStoredModel.mockReturnValue({ providerID: "openai", modelID: "gpt-5" });
mocked.getModelContextLimit.mockResolvedValue(204800);
mocked.getPinnedMessageId.mockReturnValue(null);
+ mocked.getPinnedDashboardEnabled.mockReturnValue(true);
mocked.opencodeClient.session.messages.mockResolvedValue({ data: [] });
mocked.opencodeClient.session.diff.mockResolvedValue({ data: [] });
mocked.opencodeClient.session.get.mockResolvedValue({ data: null });
@@ -894,4 +899,286 @@ describe("pinned/manager", () => {
expect(mocked.clearPinnedMessageId).toHaveBeenCalledTimes(1);
});
});
+
+ describe("pinned dashboard setting", () => {
+ it("does not send, pin, or unpin-all on session change when off", async () => {
+ mocked.getPinnedDashboardEnabled.mockReturnValue(false);
+
+ await pinnedMessageManager.onSessionChange("ses-1", "Test Session");
+
+ expect(fakeApi.sendMessage).not.toHaveBeenCalled();
+ expect(fakeApi.pinChatMessage).not.toHaveBeenCalled();
+ expect(fakeApi.unpinAllChatMessages).not.toHaveBeenCalled();
+ expect(fakeApi.editMessageText).not.toHaveBeenCalled();
+ });
+
+ it("does not edit a leftover message on restore when off", async () => {
+ mocked.getPinnedDashboardEnabled.mockReturnValue(false);
+ mocked.getPinnedMessageId.mockReturnValue(777);
+ pinnedMessageManager.initialize(fakeApi as never, 123);
+
+ await pinnedMessageManager.restoreExistingSession("ses-1", "Restored session");
+
+ expect(fakeApi.unpinChatMessage).toHaveBeenCalledWith(123, 777);
+ expect(fakeApi.editMessageText).not.toHaveBeenCalled();
+ expect(fakeApi.sendMessage).not.toHaveBeenCalled();
+ expect(fakeApi.pinChatMessage).not.toHaveBeenCalled();
+ expect(pinnedMessageManager.getState().messageId).toBeNull();
+ });
+
+ it("keeps the leftover id when restore unpin fails transiently", async () => {
+ mocked.getPinnedDashboardEnabled.mockReturnValue(false);
+ mocked.getPinnedMessageId.mockReturnValue(777);
+ pinnedMessageManager.initialize(fakeApi as never, 123);
+ fakeApi.unpinChatMessage.mockRejectedValueOnce(new Error("Bad Request: too many requests"));
+ mocked.clearPinnedMessageId.mockClear();
+
+ await pinnedMessageManager.restoreExistingSession("ses-1", "Restored session");
+
+ expect(fakeApi.unpinChatMessage).toHaveBeenCalledWith(123, 777);
+ expect(mocked.clearPinnedMessageId).not.toHaveBeenCalled();
+ expect(pinnedMessageManager.getState().messageId).toBeNull();
+
+ fakeApi.unpinChatMessage.mockResolvedValue(undefined);
+ fakeApi.sendMessage.mockClear();
+ await pinnedMessageManager.applyPinnedDashboardEnabled(true);
+
+ expect(fakeApi.unpinChatMessage).toHaveBeenLastCalledWith(123, 777);
+ expect(fakeApi.sendMessage).toHaveBeenCalledTimes(1);
+ expect(fakeApi.pinChatMessage).toHaveBeenCalledWith(123, 999, {
+ disable_notification: true,
+ });
+ });
+
+ it("forgets the leftover id when restore unpin says the message is gone", async () => {
+ mocked.getPinnedDashboardEnabled.mockReturnValue(false);
+ mocked.getPinnedMessageId.mockReturnValue(777);
+ pinnedMessageManager.initialize(fakeApi as never, 123);
+ fakeApi.unpinChatMessage.mockRejectedValueOnce(
+ new Error("Bad Request: message to unpin not found"),
+ );
+
+ await pinnedMessageManager.restoreExistingSession("ses-1", "Restored session");
+
+ expect(pinnedMessageManager.getState().messageId).toBeNull();
+ expect(mocked.clearPinnedMessageId).toHaveBeenCalled();
+ });
+
+ it("does not unpin-all on clear when off", async () => {
+ await pinnedMessageManager.onSessionChange("ses-1", "Test Session");
+ fakeApi.unpinAllChatMessages.mockClear();
+ mocked.getPinnedDashboardEnabled.mockReturnValue(false);
+
+ await pinnedMessageManager.clear();
+
+ expect(fakeApi.unpinAllChatMessages).not.toHaveBeenCalled();
+ });
+
+ it("keeps parked leftover ownership across clear while off", async () => {
+ mocked.getPinnedDashboardEnabled.mockReturnValue(false);
+ mocked.getPinnedMessageId.mockReturnValue(777);
+ pinnedMessageManager.initialize(fakeApi as never, 123);
+ fakeApi.unpinChatMessage.mockRejectedValue(new Error("Bad Request: too many requests"));
+ mocked.clearPinnedMessageId.mockClear();
+
+ await pinnedMessageManager.restoreExistingSession("ses-1", "Restored session");
+ await pinnedMessageManager.clear();
+
+ expect(mocked.clearPinnedMessageId).not.toHaveBeenCalled();
+
+ fakeApi.unpinChatMessage.mockReset();
+ fakeApi.unpinChatMessage.mockResolvedValue(undefined);
+ fakeApi.sendMessage.mockClear();
+ await pinnedMessageManager.applyPinnedDashboardEnabled(true);
+
+ expect(fakeApi.unpinChatMessage).toHaveBeenLastCalledWith(123, 777);
+ expect(fakeApi.sendMessage).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not edit the leftover dashboard on later updates when off", async () => {
+ await pinnedMessageManager.onSessionChange("ses-1", "Test Session");
+ fakeApi.editMessageText.mockClear();
+ mocked.getPinnedDashboardEnabled.mockReturnValue(false);
+
+ await pinnedMessageManager.onMessageComplete({
+ input: 100,
+ output: 10,
+ reasoning: 0,
+ cacheRead: 0,
+ cacheWrite: 0,
+ });
+
+ expect(fakeApi.editMessageText).not.toHaveBeenCalled();
+ expect(fakeApi.sendMessage).toHaveBeenCalledTimes(1);
+ });
+
+ it("still fires the keyboard callback when off", async () => {
+ mocked.getPinnedDashboardEnabled.mockReturnValue(false);
+ const callback = vi.fn();
+ pinnedMessageManager.setOnKeyboardUpdate(callback);
+
+ await pinnedMessageManager.onSessionChange("ses-1", "Test Session");
+
+ expect(callback).toHaveBeenCalled();
+ expect(fakeApi.sendMessage).not.toHaveBeenCalled();
+ });
+
+ it("unpins only the dashboard message when the setting is turned off", async () => {
+ await pinnedMessageManager.onSessionChange("ses-1", "Test Session");
+ fakeApi.unpinAllChatMessages.mockClear();
+ mocked.clearPinnedMessageId.mockClear();
+
+ await pinnedMessageManager.applyPinnedDashboardEnabled(false);
+
+ expect(fakeApi.unpinChatMessage).toHaveBeenCalledWith(123, 999);
+ expect(fakeApi.unpinAllChatMessages).not.toHaveBeenCalled();
+ expect(mocked.clearPinnedMessageId).toHaveBeenCalled();
+ expect(pinnedMessageManager.getState().messageId).toBeNull();
+ });
+
+ it("sends and pins a dashboard when turned on with a session", async () => {
+ mocked.getPinnedDashboardEnabled.mockReturnValue(false);
+ pinnedMessageManager.__resetForTests();
+ pinnedMessageManager.initialize(fakeApi as never, 123);
+
+ await pinnedMessageManager.applyPinnedDashboardEnabled(true);
+
+ expect(fakeApi.sendMessage).toHaveBeenCalledTimes(1);
+ expect(String(defined(fakeApi.sendMessage.mock.calls[0]?.[1]))).toContain("D:/repo");
+ expect(String(defined(fakeApi.sendMessage.mock.calls[0]?.[1]))).toContain("0/204800");
+ expect(fakeApi.pinChatMessage).toHaveBeenCalledWith(123, 999, {
+ disable_notification: true,
+ });
+ expect(mocked.setPinnedMessageId).toHaveBeenCalledWith(999);
+ });
+
+ it("does nothing when turned on with no session", async () => {
+ mocked.getCurrentSession.mockReturnValue(undefined);
+
+ await pinnedMessageManager.applyPinnedDashboardEnabled(true);
+
+ expect(fakeApi.sendMessage).not.toHaveBeenCalled();
+ expect(fakeApi.pinChatMessage).not.toHaveBeenCalled();
+ });
+
+ it("unpins a parked leftover when turned on with no session", async () => {
+ mocked.getPinnedDashboardEnabled.mockReturnValue(false);
+ mocked.getPinnedMessageId.mockReturnValue(777);
+ mocked.getCurrentSession.mockReturnValue(undefined);
+ pinnedMessageManager.initialize(fakeApi as never, 123);
+ fakeApi.unpinChatMessage.mockRejectedValueOnce(new Error("Bad Request: too many requests"));
+ await pinnedMessageManager.restoreExistingSession("ses-1", "Restored session");
+ fakeApi.unpinChatMessage.mockResolvedValue(undefined);
+ mocked.clearPinnedMessageId.mockClear();
+
+ await pinnedMessageManager.applyPinnedDashboardEnabled(true);
+
+ expect(fakeApi.unpinChatMessage).toHaveBeenLastCalledWith(123, 777);
+ expect(mocked.clearPinnedMessageId).toHaveBeenCalled();
+ expect(fakeApi.sendMessage).not.toHaveBeenCalled();
+ });
+
+ it("retries pinning the same message after send-ok pin-fail", async () => {
+ mocked.getPinnedDashboardEnabled.mockReturnValue(false);
+ fakeApi.pinChatMessage.mockRejectedValueOnce(new Error("Bad Request: too many requests"));
+
+ await expect(pinnedMessageManager.applyPinnedDashboardEnabled(true)).rejects.toThrow(
+ /too many requests/,
+ );
+ expect(pinnedMessageManager.getState().messageId).toBe(999);
+ expect(mocked.setPinnedMessageId).not.toHaveBeenCalled();
+ fakeApi.sendMessage.mockClear();
+
+ fakeApi.pinChatMessage.mockResolvedValue(undefined);
+ await pinnedMessageManager.applyPinnedDashboardEnabled(true);
+
+ expect(fakeApi.sendMessage).not.toHaveBeenCalled();
+ expect(fakeApi.pinChatMessage).toHaveBeenCalledWith(123, 999, {
+ disable_notification: true,
+ });
+ });
+
+ it("does not pin a leftover dashboard after a session change while off", async () => {
+ mocked.getPinnedDashboardEnabled.mockReturnValue(false);
+ mocked.getPinnedMessageId.mockReturnValue(777);
+ pinnedMessageManager.initialize(fakeApi as never, 123);
+ fakeApi.unpinChatMessage.mockRejectedValue(new Error("Bad Request: too many requests"));
+
+ await pinnedMessageManager.onSessionChange("ses-2", "Other");
+ expect(pinnedMessageManager.getState().messageId).toBeNull();
+ expect(pinnedMessageManager.getState().sessionId).toBe("ses-2");
+
+ fakeApi.unpinChatMessage.mockReset();
+ fakeApi.unpinChatMessage.mockResolvedValue(undefined);
+ fakeApi.sendMessage.mockClear();
+ fakeApi.sendMessage.mockResolvedValue({ message_id: 1005 });
+ mocked.getCurrentSession.mockReturnValue({ id: "ses-2", title: "Other" });
+
+ await pinnedMessageManager.applyPinnedDashboardEnabled(true);
+
+ expect(fakeApi.unpinChatMessage).toHaveBeenCalledWith(123, 777);
+ expect(fakeApi.sendMessage).toHaveBeenCalledTimes(1);
+ expect(fakeApi.pinChatMessage).toHaveBeenCalledWith(123, 1005, {
+ disable_notification: true,
+ });
+ expect(fakeApi.pinChatMessage).not.toHaveBeenCalledWith(123, 777, expect.anything());
+ });
+
+ it("creates the current dashboard when the session changes before retry", async () => {
+ mocked.getPinnedDashboardEnabled.mockReturnValue(false);
+ fakeApi.pinChatMessage.mockRejectedValueOnce(new Error("Bad Request: too many requests"));
+ await expect(pinnedMessageManager.applyPinnedDashboardEnabled(true)).rejects.toThrow();
+ mocked.getCurrentSession.mockReturnValue({ id: "ses-2", title: "Other" });
+
+ await pinnedMessageManager.onSessionChange("ses-2", "Other");
+ fakeApi.sendMessage.mockClear();
+ fakeApi.sendMessage.mockResolvedValue({ message_id: 1002 });
+ fakeApi.pinChatMessage.mockResolvedValue(undefined);
+
+ await pinnedMessageManager.applyPinnedDashboardEnabled(true);
+
+ expect(fakeApi.sendMessage).toHaveBeenCalledTimes(1);
+ expect(fakeApi.pinChatMessage).toHaveBeenCalledWith(123, 1002, {
+ disable_notification: true,
+ });
+ });
+
+ it("creates a new dashboard after restart between pin failure and retry", async () => {
+ mocked.getPinnedDashboardEnabled.mockReturnValue(false);
+ fakeApi.pinChatMessage.mockRejectedValueOnce(new Error("Bad Request: too many requests"));
+ await expect(pinnedMessageManager.applyPinnedDashboardEnabled(true)).rejects.toThrow();
+ mocked.getPinnedMessageId.mockReturnValue(999);
+ pinnedMessageManager.__resetForTests();
+ pinnedMessageManager.initialize(fakeApi as never, 123);
+ fakeApi.sendMessage.mockClear();
+ fakeApi.sendMessage.mockResolvedValue({ message_id: 1003 });
+ fakeApi.pinChatMessage.mockResolvedValue(undefined);
+
+ await pinnedMessageManager.applyPinnedDashboardEnabled(true);
+
+ expect(fakeApi.sendMessage).toHaveBeenCalledTimes(1);
+ expect(fakeApi.pinChatMessage).toHaveBeenCalledWith(123, 1003, {
+ disable_notification: true,
+ });
+ });
+
+ it("creates a new dashboard when the retained message is gone", async () => {
+ mocked.getPinnedDashboardEnabled.mockReturnValue(false);
+ fakeApi.pinChatMessage
+ .mockRejectedValueOnce(new Error("Bad Request: too many requests"))
+ .mockRejectedValueOnce(new Error("Bad Request: message to pin not found"));
+ await expect(pinnedMessageManager.applyPinnedDashboardEnabled(true)).rejects.toThrow(
+ /too many requests/,
+ );
+ fakeApi.sendMessage.mockClear();
+ fakeApi.sendMessage.mockResolvedValue({ message_id: 1004 });
+
+ await pinnedMessageManager.applyPinnedDashboardEnabled(true);
+
+ expect(fakeApi.sendMessage).toHaveBeenCalledTimes(1);
+ expect(fakeApi.pinChatMessage).toHaveBeenCalledWith(123, 1004, {
+ disable_notification: true,
+ });
+ });
+ });
});
diff --git a/tests/bot/services/event-subscription-service.test.ts b/tests/bot/services/event-subscription-service.test.ts
index af4b85fa3..2c674ad4e 100644
--- a/tests/bot/services/event-subscription-service.test.ts
+++ b/tests/bot/services/event-subscription-service.test.ts
@@ -42,12 +42,15 @@ function createFakeBot(): { bot: Bot; api: FakeBotApi } {
return { bot: { api } as unknown as Bot, api };
}
-function emitAssistantMessage(summaryAggregator: { processEvent(event: Event): void }): void {
+function emitAssistantMessage(
+ summaryAggregator: { processEvent(event: Event): void },
+ messageId = "message-1",
+): void {
summaryAggregator.processEvent({
type: "message.updated",
properties: {
info: {
- id: "message-1",
+ id: messageId,
sessionID: "session-1",
role: "assistant",
time: { created: Date.now() },
@@ -83,14 +86,15 @@ function emitWriteTool(summaryAggregator: { processEvent(event: Event): void }):
function emitThinkingPart(
summaryAggregator: { processEvent(event: Event): void },
text: string,
+ messageId = "message-1",
): void {
summaryAggregator.processEvent({
type: "message.part.updated",
properties: {
part: {
- id: "reasoning-1",
+ id: `reasoning-${messageId}`,
sessionID: "session-1",
- messageID: "message-1",
+ messageID: messageId,
type: "reasoning",
text,
},
@@ -161,20 +165,22 @@ function emitPermissionAsked(
function emitBashTool(
summaryAggregator: { processEvent(event: Event): void },
status: "running" | "completed" | "error",
+ options: { callId?: string; command?: string } = {},
): void {
+ const callId = options.callId ?? "call-bash";
summaryAggregator.processEvent({
type: "message.part.updated",
properties: {
part: {
- id: "part-bash",
+ id: `part-${callId}`,
sessionID: "session-1",
messageID: "message-1",
type: "tool",
- callID: "call-bash",
+ callID: callId,
tool: "bash",
state: {
status,
- input: { command: "npm test" },
+ input: { command: options.command ?? "npm test" },
metadata: {},
...(status === "completed" ? { output: "ok" } : {}),
...(status === "error" ? { error: "command failed" } : {}),
@@ -184,25 +190,86 @@ function emitBashTool(
} as unknown as Event);
}
-function emitTaskTool(summaryAggregator: { processEvent(event: Event): void }): void {
+function emitReadTool(
+ summaryAggregator: { processEvent(event: Event): void },
+ status: "running" | "completed" | "error",
+ options: { callId: string; filePath: string },
+): void {
+ summaryAggregator.processEvent({
+ type: "message.part.updated",
+ properties: {
+ part: {
+ id: `part-${options.callId}`,
+ sessionID: "session-1",
+ messageID: "message-1",
+ type: "tool",
+ callID: options.callId,
+ tool: "read",
+ state: {
+ status,
+ input: { filePath: options.filePath },
+ metadata: {},
+ ...(status === "completed" ? { output: "ok" } : {}),
+ ...(status === "error" ? { error: "read failed" } : {}),
+ },
+ },
+ },
+ } as unknown as Event);
+}
+
+function emitBareTool(
+ summaryAggregator: { processEvent(event: Event): void },
+ status: "running" | "completed" | "error",
+ callId: string,
+): void {
summaryAggregator.processEvent({
type: "message.part.updated",
properties: {
part: {
- id: "part-task",
+ id: `part-${callId}`,
sessionID: "session-1",
messageID: "message-1",
type: "tool",
- callID: "call-task",
+ callID: callId,
+ tool: "unknown_tool",
+ state: {
+ status,
+ input: {},
+ metadata: {},
+ ...(status === "completed" ? { output: "ok" } : {}),
+ ...(status === "error" ? { error: "failed" } : {}),
+ },
+ },
+ },
+ } as unknown as Event);
+}
+
+function emitTaskTool(
+ summaryAggregator: { processEvent(event: Event): void },
+ options: { callId?: string; status?: "running" | "completed" | "error" } = {},
+): void {
+ const callId = options.callId ?? "call-task";
+ const status = options.status ?? "running";
+ summaryAggregator.processEvent({
+ type: "message.part.updated",
+ properties: {
+ part: {
+ id: `part-${callId}`,
+ sessionID: "session-1",
+ messageID: "message-1",
+ type: "tool",
+ callID: callId,
tool: "task",
state: {
- status: "running",
+ status,
input: {
description: "Explore the project",
subagent_type: "explore",
prompt: "Inspect architecture",
},
metadata: {},
+ ...(status === "completed" ? { output: "ok" } : {}),
+ ...(status === "error" ? { error: "task failed" } : {}),
},
},
},
@@ -590,6 +657,219 @@ describe("bot/services/event-subscription-service", () => {
);
});
+ it("falls back to the still-running bash after parallel reads finish in compact mode", async () => {
+ const { api, summaryAggregator } = await setupService(false);
+ const settingsStore = await import("../../../src/app/stores/settings-store.js");
+ settingsStore.setCompactOutputMode(true);
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ useTrackerFakeTimers();
+ emitBashTool(summaryAggregator, "running", { command: "sleep 90" });
+ emitReadTool(summaryAggregator, "running", { callId: "call-read-1", filePath: "README.md" });
+ emitReadTool(summaryAggregator, "completed", { callId: "call-read-1", filePath: "README.md" });
+ emitReadTool(summaryAggregator, "running", { callId: "call-read-2", filePath: "AGENTS.md" });
+ emitReadTool(summaryAggregator, "completed", { callId: "call-read-2", filePath: "AGENTS.md" });
+ await vi.advanceTimersByTimeAsync(ELAPSED_SETTLE_MS);
+
+ const lastText = collectSentTexts(api).pop() ?? "";
+ expect(lastText).toContain("sleep 90");
+ expect(lastText).toContain("· 🕒 20s");
+ expect(lastText).not.toContain("AGENTS.md");
+ expect(lastText).not.toContain("README.md");
+ });
+
+ it("lets a new compact tool take the line and returns to the remaining one with its timer", async () => {
+ const { api, summaryAggregator } = await setupService(false);
+ const settingsStore = await import("../../../src/app/stores/settings-store.js");
+ settingsStore.setCompactOutputMode(true);
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ useTrackerFakeTimers();
+ emitBashTool(summaryAggregator, "running", { command: "sleep 90" });
+ await vi.advanceTimersByTimeAsync(ELAPSED_SETTLE_MS);
+ emitReadTool(summaryAggregator, "running", { callId: "call-read-1", filePath: "AGENTS.md" });
+ await flushPendingDispatch();
+
+ expect(collectSentTexts(api).pop() ?? "").toContain("AGENTS.md");
+
+ emitReadTool(summaryAggregator, "completed", { callId: "call-read-1", filePath: "AGENTS.md" });
+ await flushPendingDispatch();
+
+ const lastText = collectSentTexts(api).pop() ?? "";
+ expect(lastText).toContain("sleep 90");
+ expect(lastText).toMatch(/· 🕒 \d+[ms]/);
+ expect(lastText).not.toContain("AGENTS.md");
+ });
+
+ it("shows the most recently started still-running tool in compact mode", async () => {
+ const { api, summaryAggregator } = await setupService(false);
+ const settingsStore = await import("../../../src/app/stores/settings-store.js");
+ settingsStore.setCompactOutputMode(true);
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ useTrackerFakeTimers();
+ emitBashTool(summaryAggregator, "running", { command: "sleep 90" });
+ await flushPendingDispatch();
+ emitReadTool(summaryAggregator, "running", { callId: "call-read-1", filePath: "AGENTS.md" });
+ await flushPendingDispatch();
+
+ const lastText = collectSentTexts(api).pop() ?? "";
+ expect(lastText).toContain("AGENTS.md");
+ expect(lastText).not.toContain("sleep 90");
+ });
+
+ it("leaves the finished tool on the compact line once nothing is still running", async () => {
+ const { api, summaryAggregator } = await setupService(false);
+ const settingsStore = await import("../../../src/app/stores/settings-store.js");
+ settingsStore.setCompactOutputMode(true);
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ useTrackerFakeTimers();
+ emitBashTool(summaryAggregator, "running", { command: "sleep 90" });
+ emitReadTool(summaryAggregator, "running", { callId: "call-read-1", filePath: "AGENTS.md" });
+ emitReadTool(summaryAggregator, "completed", { callId: "call-read-1", filePath: "AGENTS.md" });
+ emitBashTool(summaryAggregator, "completed", { command: "sleep 90" });
+ await flushPendingDispatch();
+
+ const lastText = collectSentTexts(api).pop() ?? "";
+ expect(lastText).toContain("sleep 90");
+ expect(lastText).not.toContain("🕒");
+ });
+
+ it("treats an error sibling like a finish and falls back to the still-running tool", async () => {
+ const { api, summaryAggregator } = await setupService(false);
+ const settingsStore = await import("../../../src/app/stores/settings-store.js");
+ settingsStore.setCompactOutputMode(true);
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ useTrackerFakeTimers();
+ emitBashTool(summaryAggregator, "running", { command: "sleep 90" });
+ emitReadTool(summaryAggregator, "running", { callId: "call-read-1", filePath: "AGENTS.md" });
+ emitReadTool(summaryAggregator, "error", { callId: "call-read-1", filePath: "AGENTS.md" });
+ await vi.advanceTimersByTimeAsync(ELAPSED_SETTLE_MS);
+
+ const lastText = collectSentTexts(api).pop() ?? "";
+ expect(lastText).toContain("sleep 90");
+ expect(lastText).toContain("· 🕒 20s");
+ expect(lastText).not.toContain("AGENTS.md");
+ });
+
+ it("applies the same compact fallback when a task tool runs beside bash", async () => {
+ const { api, summaryAggregator } = await setupService(false);
+ const settingsStore = await import("../../../src/app/stores/settings-store.js");
+ settingsStore.setCompactOutputMode(true);
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ useTrackerFakeTimers();
+ emitBashTool(summaryAggregator, "running", { command: "sleep 90" });
+ emitTaskTool(summaryAggregator);
+ await flushPendingDispatch();
+ expect(collectSentTexts(api).pop() ?? "").toContain("Running Task");
+
+ emitTaskTool(summaryAggregator, { status: "completed" });
+ await vi.advanceTimersByTimeAsync(ELAPSED_SETTLE_MS);
+
+ const lastText = collectSentTexts(api).pop() ?? "";
+ expect(lastText).toContain("sleep 90");
+ expect(lastText).toMatch(/· 🕒 \d+[ms]/);
+ });
+
+ it("keeps the running compact tool on the line when thinking or writing arrives", async () => {
+ const { api, summaryAggregator } = await setupService(false);
+ const settingsStore = await import("../../../src/app/stores/settings-store.js");
+ settingsStore.setCompactOutputMode(true);
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ useTrackerFakeTimers();
+ emitBashTool(summaryAggregator, "running", { command: "sleep 90" });
+ await flushPendingDispatch();
+ emitThinkingPart(summaryAggregator, "planning");
+ await flushPendingDispatch();
+ emitAssistantTextPart(summaryAggregator, "Here is the answer");
+ await flushPendingDispatch();
+
+ const lastText = collectSentTexts(api).pop() ?? "";
+ expect(lastText).toContain("sleep 90");
+ expect(lastText).not.toContain("Thinking");
+ expect(lastText).not.toContain("Writing answer");
+ });
+
+ it("shows a detail-less running sibling instead of a finished compact tool", async () => {
+ const { api, summaryAggregator } = await setupService(false);
+ const settingsStore = await import("../../../src/app/stores/settings-store.js");
+ settingsStore.setCompactOutputMode(true);
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ useTrackerFakeTimers();
+ emitBashTool(summaryAggregator, "running", { command: "sleep 90" });
+ emitReadTool(summaryAggregator, "running", { callId: "call-read-1", filePath: "AGENTS.md" });
+ emitBareTool(summaryAggregator, "running", "call-bare");
+ emitReadTool(summaryAggregator, "completed", { callId: "call-read-1", filePath: "AGENTS.md" });
+ await vi.advanceTimersByTimeAsync(ELAPSED_SETTLE_MS);
+
+ const lastText = collectSentTexts(api).pop() ?? "";
+ expect(lastText).toContain("unknown_tool");
+ expect(lastText).toContain("· 🕒 20s");
+ expect(lastText).not.toContain("AGENTS.md");
+ });
+
+ it("does not leave a finished compact tool on the line when only a detail-less sibling remains", async () => {
+ const { api, summaryAggregator } = await setupService(false);
+ const settingsStore = await import("../../../src/app/stores/settings-store.js");
+ settingsStore.setCompactOutputMode(true);
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ useTrackerFakeTimers();
+ emitReadTool(summaryAggregator, "running", { callId: "call-read-1", filePath: "AGENTS.md" });
+ emitBareTool(summaryAggregator, "running", "call-bare");
+ emitReadTool(summaryAggregator, "completed", { callId: "call-read-1", filePath: "AGENTS.md" });
+ await vi.advanceTimersByTimeAsync(ELAPSED_SETTLE_MS);
+
+ const lastText = collectSentTexts(api).pop() ?? "";
+ expect(lastText).not.toContain("AGENTS.md");
+ expect(lastText).toContain("unknown_tool");
+ expect(lastText).toContain("· 🕒 20s");
+ });
+
+ it("does not let an older compact tool steal the line on a later running update", async () => {
+ const { api, summaryAggregator } = await setupService(false);
+ const settingsStore = await import("../../../src/app/stores/settings-store.js");
+ settingsStore.setCompactOutputMode(true);
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ useTrackerFakeTimers();
+ emitBashTool(summaryAggregator, "running", { command: "sleep 90" });
+ await flushPendingDispatch();
+ emitReadTool(summaryAggregator, "running", { callId: "call-read-1", filePath: "AGENTS.md" });
+ await flushPendingDispatch();
+ emitBashTool(summaryAggregator, "running", { command: "sleep 90" });
+ await flushPendingDispatch();
+
+ const lastText = collectSentTexts(api).pop() ?? "";
+ expect(lastText).toContain("AGENTS.md");
+ expect(lastText).not.toContain("sleep 90");
+ });
+
+ it("shows the existing duration bucket when falling back after ten minutes", async () => {
+ const { api, summaryAggregator } = await setupService(false);
+ const settingsStore = await import("../../../src/app/stores/settings-store.js");
+ settingsStore.setCompactOutputMode(true);
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ useTrackerFakeTimers();
+ emitBashTool(summaryAggregator, "running", { command: "sleep 90" });
+ await vi.advanceTimersByTimeAsync(12 * 60_000);
+ emitReadTool(summaryAggregator, "running", { callId: "call-read-1", filePath: "AGENTS.md" });
+ emitReadTool(summaryAggregator, "completed", { callId: "call-read-1", filePath: "AGENTS.md" });
+ await flushPendingDispatch();
+
+ const lastText = collectSentTexts(api).pop() ?? "";
+ expect(lastText).toContain("sleep 90");
+ expect(lastText).toContain("· 🕒 10m");
+ expect(lastText).not.toContain("12m");
+ expect(lastText).not.toContain("AGENTS.md");
+ });
+
it("keeps subagent cards ticking without any incoming events", async () => {
const { api, summaryAggregator } = await setupService(false);
@@ -629,6 +909,133 @@ describe("bot/services/event-subscription-service", () => {
});
});
+ describe("compact progress at the start of work", () => {
+ it("shows thinking on the progress message before any tool runs", async () => {
+ const { api, summaryAggregator } = await setupService(false);
+ const settingsStore = await import("../../../src/app/stores/settings-store.js");
+ settingsStore.setCompactOutputMode(true);
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ useTrackerFakeTimers();
+ emitThinkingPart(summaryAggregator, "planning");
+ await flushPendingDispatch();
+
+ const texts = collectSentTexts(api);
+ expect(texts.some((text) => text.includes("💭 Thinking..."))).toBe(true);
+ expect(api.sendMessage).toHaveBeenCalledTimes(1);
+ });
+
+ it("shows writing on the progress message when the model writes first", async () => {
+ const { api, summaryAggregator } = await setupService(false);
+ const settingsStore = await import("../../../src/app/stores/settings-store.js");
+ settingsStore.setCompactOutputMode(true);
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ useTrackerFakeTimers();
+ emitAssistantTextPart(summaryAggregator, "Here is the answer");
+ await flushPendingDispatch();
+
+ expect(collectSentTexts(api).some((text) => text.includes("✍️ Writing answer..."))).toBe(true);
+ });
+
+ it("reuses the thinking message for a later tool and then writing", async () => {
+ const { api, summaryAggregator } = await setupService(false);
+ const settingsStore = await import("../../../src/app/stores/settings-store.js");
+ settingsStore.setCompactOutputMode(true);
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ useTrackerFakeTimers();
+ emitThinkingPart(summaryAggregator, "planning");
+ await flushPendingDispatch();
+ emitBashTool(summaryAggregator, "running");
+ await flushPendingDispatch();
+ emitAssistantTextPart(summaryAggregator, "not yet");
+ await flushPendingDispatch();
+
+ expect(collectSentTexts(api).some((text) => text.includes("✍️ Writing answer..."))).toBe(false);
+ expect(collectSentTexts(api).pop() ?? "").toContain("npm test");
+
+ emitBashTool(summaryAggregator, "completed");
+ await flushPendingDispatch();
+ emitAssistantTextPart(summaryAggregator, "Here is the answer");
+ await flushPendingDispatch();
+
+ const progressSends = api.sendMessage.mock.calls.filter((call) =>
+ String(call[1]).includes("⏳ Working"),
+ );
+ expect(progressSends).toHaveLength(1);
+ expect(collectSentTexts(api).some((text) => text.includes("✍️ Writing answer..."))).toBe(true);
+ });
+
+ it("deletes the progress message on idle when delete on finish is on", async () => {
+ const { api, summaryAggregator } = await setupService(false);
+ const settingsStore = await import("../../../src/app/stores/settings-store.js");
+ settingsStore.setCompactOutputMode(true);
+ settingsStore.setDeleteCompactProgressOnFinish(true);
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ useTrackerFakeTimers();
+ emitThinkingPart(summaryAggregator, "planning");
+ await flushPendingDispatch();
+ emitSessionIdle(summaryAggregator);
+ await flushPendingDispatch();
+
+ expect(api.deleteMessage).toHaveBeenCalled();
+ });
+
+ it("finalizes a second run while the first card is still being finalized", async () => {
+ const { api, summaryAggregator } = await setupService(false);
+ const settingsStore = await import("../../../src/app/stores/settings-store.js");
+ settingsStore.setCompactOutputMode(true);
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ let nextMessageId = 100;
+ api.sendMessage.mockImplementation(async () => ({ message_id: nextMessageId++ }));
+
+ let releaseFirstEdit: (() => void) | undefined;
+ api.editMessageText.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ releaseFirstEdit = () => resolve(undefined);
+ }),
+ );
+
+ useTrackerFakeTimers();
+ emitThinkingPart(summaryAggregator, "run-a");
+ await flushPendingDispatch();
+ emitSessionIdle(summaryAggregator);
+ await flushPendingDispatch();
+
+ emitAssistantMessage(summaryAggregator, "message-2");
+ emitThinkingPart(summaryAggregator, "run-b", "message-2");
+ await flushPendingDispatch();
+ emitSessionIdle(summaryAggregator);
+ await flushPendingDispatch();
+
+ expect(api.editMessageText).toHaveBeenCalledTimes(2);
+ expect(
+ api.editMessageText.mock.calls.every((call) => String(call[2]).includes("✅ Finished Work")),
+ ).toBe(true);
+
+ releaseFirstEdit?.();
+ });
+
+ it("edits the progress message to the finished summary on idle when delete on finish is off", async () => {
+ const { api, summaryAggregator } = await setupService(false);
+ const settingsStore = await import("../../../src/app/stores/settings-store.js");
+ settingsStore.setCompactOutputMode(true);
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ useTrackerFakeTimers();
+ emitThinkingPart(summaryAggregator, "planning");
+ await flushPendingDispatch();
+ emitSessionIdle(summaryAggregator);
+ await flushPendingDispatch();
+
+ expect(collectSentTexts(api).some((text) => text.includes("✅ Finished Work"))).toBe(true);
+ });
+ });
+
it("uses edit streaming for visible thinking content when assistant responses use draft mode", async () => {
const { api, summaryAggregator } = await setupService(true, {
responseStreamingMode: "draft",
diff --git a/tests/bot/streaming/compact-progress-streamer.test.ts b/tests/bot/streaming/compact-progress-streamer.test.ts
index 6d9dfa920..9c44e98ea 100644
--- a/tests/bot/streaming/compact-progress-streamer.test.ts
+++ b/tests/bot/streaming/compact-progress-streamer.test.ts
@@ -71,7 +71,7 @@ describe("bot/streaming/compact-progress-streamer", () => {
);
});
- it("does not create a message for thinking-only activity", async () => {
+ it("creates a message for thinking-only activity", async () => {
const sendText = vi.fn().mockResolvedValue(10);
const editText = vi.fn().mockResolvedValue(undefined);
const streamer = new CompactProgressStreamer({ throttleMs: 0, sendText, editText });
@@ -80,8 +80,33 @@ describe("bot/streaming/compact-progress-streamer", () => {
await new Promise((resolve) => setTimeout(resolve, 0));
await streamer.finalize("s1");
- expect(sendText).not.toHaveBeenCalled();
- expect(editText).not.toHaveBeenCalled();
+ expect(sendText).toHaveBeenCalledTimes(1);
+ expect(sendText).toHaveBeenCalledWith("s1", "⏳ Working\n💭 Thinking...");
+ expect(editText).toHaveBeenCalledTimes(1);
+ expect(editText).toHaveBeenCalledWith(
+ "s1",
+ 10,
+ "✅ Finished Work\ntool calls: 0 · changed files: 0",
+ );
+ });
+
+ it("creates a message for writing-only activity", async () => {
+ const sendText = vi.fn().mockResolvedValue(10);
+ const editText = vi.fn().mockResolvedValue(undefined);
+ const streamer = new CompactProgressStreamer({ throttleMs: 0, sendText, editText });
+
+ streamer.updateResponding("s1");
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ await streamer.finalize("s1");
+
+ expect(sendText).toHaveBeenCalledTimes(1);
+ expect(sendText).toHaveBeenCalledWith("s1", "⏳ Working\n✍️ Writing answer...");
+ expect(editText).toHaveBeenCalledTimes(1);
+ expect(editText).toHaveBeenCalledWith(
+ "s1",
+ 10,
+ "✅ Finished Work\ntool calls: 0 · changed files: 0",
+ );
});
it("updates active progress when thinking starts", async () => {
@@ -108,6 +133,31 @@ describe("bot/streaming/compact-progress-streamer", () => {
);
});
+ it("keeps thinking, a tool, and writing on one message", async () => {
+ const sendText = vi.fn().mockResolvedValue(10);
+ const editText = vi.fn().mockResolvedValue(undefined);
+ const streamer = new CompactProgressStreamer({ throttleMs: 0, sendText, editText });
+
+ streamer.updateThinking("s1");
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ streamer.updateActivity("s1", "reading");
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ streamer.updateResponding("s1");
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ await streamer.finalize("s1");
+
+ expect(sendText).toHaveBeenCalledTimes(1);
+ expect(sendText).toHaveBeenCalledWith("s1", "⏳ Working\n💭 Thinking...");
+ expect(editText).toHaveBeenNthCalledWith(1, "s1", 10, "⏳ Working\nreading");
+ expect(editText).toHaveBeenNthCalledWith(2, "s1", 10, "⏳ Working\n✍️ Writing answer...");
+ expect(editText).toHaveBeenNthCalledWith(
+ 3,
+ "s1",
+ 10,
+ "✅ Finished Work\ntool calls: 0 · changed files: 0",
+ );
+ });
+
it("counts unique tool calls and changed files", async () => {
const sendText = vi.fn().mockResolvedValue(20);
const editText = vi.fn().mockResolvedValue(undefined);
@@ -131,6 +181,90 @@ describe("bot/streaming/compact-progress-streamer", () => {
expect(editText).not.toHaveBeenCalled();
});
+ it("lets a new run create progress while the previous card is still finalizing", async () => {
+ const sendText = vi.fn().mockResolvedValue(10);
+ const editText = vi.fn().mockResolvedValue(undefined);
+ const streamer = new CompactProgressStreamer({ throttleMs: 0, sendText, editText });
+
+ streamer.updateActivity("s1", "run-a");
+ const finalizePromise = streamer.finalize("s1");
+ streamer.updateThinking("s1");
+ await finalizePromise;
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ expect(sendText.mock.calls.some((call) => String(call[1]).includes("run-a") || String(call[1]).includes("Finished Work"))).toBe(
+ true,
+ );
+ expect(sendText.mock.calls.some((call) => String(call[1]).includes("💭 Thinking..."))).toBe(true);
+ });
+
+ it("finalizes a second run while the first card is still being finalized", async () => {
+ const sendText = vi.fn().mockImplementation(async (_sessionId: string, _text: string) => {
+ return sendText.mock.calls.length * 10;
+ });
+ const editText = vi.fn().mockResolvedValue(undefined);
+ const streamer = new CompactProgressStreamer({ throttleMs: 0, sendText, editText });
+
+ streamer.updateActivity("s1", "run-a");
+ const firstFinalize = streamer.finalize("s1");
+ streamer.updateThinking("s1");
+ const secondFinalize = streamer.finalize("s1");
+ await Promise.all([firstFinalize, secondFinalize]);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ expect(sendText.mock.calls.some((call) => String(call[1]).includes("run-a"))).toBe(true);
+ expect(sendText.mock.calls.some((call) => String(call[1]).includes("💭 Thinking..."))).toBe(true);
+ expect(editText).toHaveBeenCalledTimes(2);
+ expect(editText.mock.calls.every((call) => String(call[2]).includes("✅ Finished Work"))).toBe(true);
+ });
+
+ it("does not finish a detached card after the session is cleared", async () => {
+ let releaseSend: (() => void) | undefined;
+ const sendText = vi.fn().mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ releaseSend = () => resolve(10);
+ }),
+ );
+ const editText = vi.fn().mockResolvedValue(undefined);
+ const deleteText = vi.fn().mockResolvedValue(undefined);
+ const streamer = new CompactProgressStreamer({
+ throttleMs: 0,
+ sendText,
+ editText,
+ deleteText,
+ });
+
+ streamer.updateActivity("s1", "working");
+ const finalizePromise = streamer.finalize("s1", true);
+ streamer.clearSession("s1", "session_error");
+ releaseSend?.();
+ await finalizePromise;
+
+ expect(editText).not.toHaveBeenCalled();
+ expect(deleteText).not.toHaveBeenCalled();
+ });
+
+ it("does not finish a detached card after all sessions are cleared", async () => {
+ let releaseSend: (() => void) | undefined;
+ const sendText = vi.fn().mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ releaseSend = () => resolve(10);
+ }),
+ );
+ const editText = vi.fn().mockResolvedValue(undefined);
+ const streamer = new CompactProgressStreamer({ throttleMs: 0, sendText, editText });
+
+ streamer.updateActivity("s1", "working");
+ const finalizePromise = streamer.finalize("s1");
+ streamer.clearAll("runtime_clear");
+ releaseSend?.();
+ await finalizePromise;
+
+ expect(editText).not.toHaveBeenCalled();
+ });
+
it("does not create a message when finalizing an inactive session", async () => {
const sendText = vi.fn().mockResolvedValue(20);
const editText = vi.fn().mockResolvedValue(undefined);
diff --git a/tests/bot/streaming/running-tool-tracker.test.ts b/tests/bot/streaming/running-tool-tracker.test.ts
index 941a3e6bd..8aa79a05f 100644
--- a/tests/bot/streaming/running-tool-tracker.test.ts
+++ b/tests/bot/streaming/running-tool-tracker.test.ts
@@ -179,4 +179,91 @@ describe("bot/streaming/running-tool-tracker", () => {
expect(heartbeats).toEqual([]);
expect(ticks.map((tick) => tick.callId)).toEqual(["call-2"]);
});
+
+ it("returns undefined from newestCallId when the session has no calls", () => {
+ const { tracker } = createTracker();
+
+ tracker.track("s2", "call-2");
+
+ expect(tracker.newestCallId("s1")).toBeUndefined();
+ });
+
+ it("returns the only tracked call for the session", () => {
+ const { tracker } = createTracker();
+
+ tracker.track("s1", "call-1");
+
+ expect(tracker.newestCallId("s1")).toBe("call-1");
+ });
+
+ it("picks the later start when two calls belong to one session", () => {
+ vi.useFakeTimers();
+ const { tracker } = createTracker();
+
+ tracker.track("s1", "call-1");
+ vi.advanceTimersByTime(1000);
+ tracker.track("s1", "call-2");
+
+ expect(tracker.newestCallId("s1")).toBe("call-2");
+ });
+
+ it("does not let a released call win newestCallId", () => {
+ vi.useFakeTimers();
+ const { tracker } = createTracker();
+
+ tracker.track("s1", "call-1");
+ vi.advanceTimersByTime(1000);
+ tracker.track("s1", "call-2");
+ tracker.release("call-2");
+
+ expect(tracker.newestCallId("s1")).toBe("call-1");
+ });
+
+ it("lists tracked calls newest first", () => {
+ vi.useFakeTimers();
+ const { tracker } = createTracker();
+
+ tracker.track("s1", "call-1");
+ vi.advanceTimersByTime(1000);
+ tracker.track("s1", "call-2");
+ tracker.track("s2", "call-other");
+
+ expect(tracker.trackedCallIds("s1")).toEqual(["call-2", "call-1"]);
+ });
+
+ it("ignores a call from another session when picking newestCallId", () => {
+ vi.useFakeTimers();
+ const { tracker } = createTracker();
+
+ tracker.track("s1", "call-1");
+ vi.advanceTimersByTime(1000);
+ tracker.track("s2", "call-2");
+
+ expect(tracker.newestCallId("s1")).toBe("call-1");
+ });
+
+ it("returns no display tick until the threshold", async () => {
+ vi.useFakeTimers();
+ const { tracker } = createTracker();
+
+ tracker.track("s1", "call-1");
+ await vi.advanceTimersByTimeAsync(15 * SECOND);
+
+ expect(tracker.displayTick("call-1")).toBeUndefined();
+ });
+
+ it("returns the bucketed display tick, not raw elapsed", async () => {
+ vi.useFakeTimers();
+ const { tracker } = createTracker();
+
+ tracker.track("s1", "call-1");
+ await vi.advanceTimersByTimeAsync(12 * MINUTE);
+
+ expect(tracker.displayTick("call-1")).toEqual({
+ sessionId: "s1",
+ callId: "call-1",
+ elapsedMs: 10 * MINUTE,
+ isFinal: false,
+ });
+ });
});
diff --git a/tests/bot/telegram-server-error-retry-policy.test.ts b/tests/bot/telegram-server-error-retry-policy.test.ts
index d374e885d..d6632066b 100644
--- a/tests/bot/telegram-server-error-retry-policy.test.ts
+++ b/tests/bot/telegram-server-error-retry-policy.test.ts
@@ -101,4 +101,12 @@ describe("bot Telegram 5xx retry policy", () => {
await expect(result).resolves.toEqual({ message_id: 1 });
expect(mocked.fetch).toHaveBeenCalledTimes(2);
});
+
+ it("does not apply the API transformer retry to startup-managed methods", async () => {
+ mocked.fetch.mockResolvedValue(telegramApiResponse(429));
+ const bot = createBot();
+
+ await expect(bot.api.getWebhookInfo()).rejects.toMatchObject({ error_code: 429 });
+ expect(mocked.fetch).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/tests/cli/failure-exit.test.ts b/tests/cli/failure-exit.test.ts
new file mode 100644
index 000000000..ab6e9dd1e
--- /dev/null
+++ b/tests/cli/failure-exit.test.ts
@@ -0,0 +1,60 @@
+import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest";
+import { defined } from "../helpers/defined.js";
+
+const mocked = vi.hoisted(() => ({
+ flushLoggerMock: vi.fn(),
+}));
+
+vi.mock("../../src/utils/logger.js", () => ({
+ flushLogger: mocked.flushLoggerMock,
+ logger: {
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+import { handleCliFailure } from "../../src/cli/failure-exit.js";
+
+describe("cli/failure-exit", () => {
+ let processExitSpy: MockInstance;
+ let stderrSpy: MockInstance;
+
+ beforeEach(() => {
+ mocked.flushLoggerMock.mockReset();
+ mocked.flushLoggerMock.mockResolvedValue(undefined);
+ processExitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as unknown as typeof process.exit);
+ stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((() => true) as unknown as typeof process.stderr.write);
+ });
+
+ afterEach(() => {
+ processExitSpy.mockRestore();
+ stderrSpy.mockRestore();
+ });
+
+ it("writes the CLI error, flushes the log, then exits 1", async () => {
+ const error = new Error("Network request for 'getWebhookInfo' failed!");
+
+ await handleCliFailure(error);
+
+ expect(stderrSpy).toHaveBeenCalledWith("CLI error: Network request for 'getWebhookInfo' failed!\n");
+ expect(mocked.flushLoggerMock).toHaveBeenCalledTimes(1);
+ expect(processExitSpy).toHaveBeenCalledWith(1);
+ expect(defined(processExitSpy.mock.invocationCallOrder[0])).toBeGreaterThan(
+ defined(mocked.flushLoggerMock.mock.invocationCallOrder[0]),
+ );
+ });
+
+ it("exits 1 when the log flush hangs past the bound", async () => {
+ vi.useFakeTimers();
+ mocked.flushLoggerMock.mockReturnValue(new Promise(() => undefined));
+
+ const done = handleCliFailure(new Error("boom"));
+ await vi.advanceTimersByTimeAsync(1000);
+ await done;
+
+ expect(processExitSpy).toHaveBeenCalledWith(1);
+ vi.useRealTimers();
+ });
+});
diff --git a/tests/i18n/callback-answer-length.test.ts b/tests/i18n/callback-answer-length.test.ts
index 466593a9f..a3c86276e 100644
--- a/tests/i18n/callback-answer-length.test.ts
+++ b/tests/i18n/callback-answer-length.test.ts
@@ -9,6 +9,7 @@ import { fr } from "../../src/i18n/fr.js";
import { it as itLocale } from "../../src/i18n/it.js";
import { pt } from "../../src/i18n/pt.js";
import { ru } from "../../src/i18n/ru.js";
+import { tr } from "../../src/i18n/tr.js";
import { zh } from "../../src/i18n/zh.js";
import { defined } from "../helpers/defined.js";
@@ -24,6 +25,7 @@ const LOCALES: Record = {
it: itLocale,
pt,
ru,
+ tr,
zh,
};
diff --git a/tests/i18n/placeholders.test.ts b/tests/i18n/placeholders.test.ts
index 572a9990a..4abd223e0 100644
--- a/tests/i18n/placeholders.test.ts
+++ b/tests/i18n/placeholders.test.ts
@@ -8,12 +8,24 @@ import { it as itLocale } from "../../src/i18n/it.js";
import { ko } from "../../src/i18n/ko.js";
import { pt } from "../../src/i18n/pt.js";
import { ru } from "../../src/i18n/ru.js";
+import { tr } from "../../src/i18n/tr.js";
import { zh } from "../../src/i18n/zh.js";
// Must stay in sync with the interpolation pattern used in src/i18n/index.ts.
const PLACEHOLDER_PATTERN = /\{([a-zA-Z0-9_]+)\}/g;
-const LOCALES: Record = { ar, de, es, fr, it: itLocale, ko, pt, ru, zh };
+const LOCALES: Record = {
+ ar,
+ de,
+ es,
+ fr,
+ it: itLocale,
+ ko,
+ pt,
+ ru,
+ tr,
+ zh,
+};
function extractPlaceholders(template: string): Set {
return new Set(template.match(PLACEHOLDER_PATTERN) ?? []);