diff --git a/src/app/managers/background-session-manager.ts b/src/app/managers/background-session-manager.ts index ea6e8eb5..ce2088e8 100644 --- a/src/app/managers/background-session-manager.ts +++ b/src/app/managers/background-session-manager.ts @@ -92,6 +92,20 @@ class BackgroundSessionTracker { case "session.idle": this.handleSessionIdle(event.properties as SessionIdleEventProperties, currentSessionId); break; + case "session.deleted": { + const props = event.properties as SessionInfoEventProperties; + const deletedId = props.info?.id; + if (deletedId) { + this.sessionTitles.delete(deletedId); + this.childSessionIds.delete(deletedId); + this.completedAssistantMessageIds.delete(deletedId); + this.pendingAssistantResponsesBySessionId.delete(deletedId); + this.questionRequestIds.delete(deletedId); + this.permissionRequestIds.delete(deletedId); + logger.debug(`[BackgroundSessionTracker] Cleaned up deleted session: id=${deletedId}`); + } + break; + } case "question.asked": this.handleRequestEvent( "question_asked", diff --git a/src/app/managers/interaction-manager.ts b/src/app/managers/interaction-manager.ts index fcbdf735..479c962f 100644 --- a/src/app/managers/interaction-manager.ts +++ b/src/app/managers/interaction-manager.ts @@ -6,7 +6,6 @@ import type { } from "../types/interaction.js"; import { permissionManager } from "./permission-manager.js"; import { questionManager } from "./question-manager.js"; -import { renameManager } from "./rename-manager.js"; import { taskCreationManager } from "./scheduled-task-creation-manager.js"; import { logger } from "../../utils/logger.js"; @@ -159,27 +158,24 @@ export const interactionManager = new InteractionManager(); export function clearAllInteractionState(reason: string): void { const questionActive = questionManager.isActive(); const permissionActive = permissionManager.isActive(); - const renameActive = renameManager.isWaitingForName(); const taskCreationActive = taskCreationManager.isActive(); const interactionSnapshot = interactionManager.getSnapshot(); questionManager.clear(); permissionManager.clear(); - renameManager.clear(); taskCreationManager.clear(); interactionManager.clear(reason); const hasAnyActiveState = questionActive || permissionActive || - renameActive || taskCreationActive || interactionSnapshot !== null; const message = `[InteractionCleanup] Cleared state: reason=${reason}, ` + `questionActive=${questionActive}, permissionActive=${permissionActive}, ` + - `renameActive=${renameActive}, taskCreationActive=${taskCreationActive}, ` + + `taskCreationActive=${taskCreationActive}, ` + `interactionKind=${interactionSnapshot?.kind || "none"}`; if (hasAnyActiveState) { diff --git a/src/app/managers/rename-manager.ts b/src/app/managers/rename-manager.ts deleted file mode 100644 index b81201f3..00000000 --- a/src/app/managers/rename-manager.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { logger } from "../../utils/logger.js"; - -interface RenameState { - isWaiting: boolean; - sessionId: string | null; - sessionDirectory: string | null; - currentTitle: string | null; - messageId: number | null; -} - -class RenameManager { - private state: RenameState = { - isWaiting: false, - sessionId: null, - sessionDirectory: null, - currentTitle: null, - messageId: null, - }; - - startWaiting(sessionId: string, directory: string, currentTitle: string): void { - logger.info(`[RenameManager] Starting rename flow for session: ${sessionId}`); - this.state = { - isWaiting: true, - sessionId, - sessionDirectory: directory, - currentTitle, - messageId: null, - }; - } - - setMessageId(messageId: number): void { - this.state.messageId = messageId; - } - - getMessageId(): number | null { - return this.state.messageId; - } - - isActiveMessage(messageId: number | null): boolean { - return ( - this.state.isWaiting && this.state.messageId !== null && this.state.messageId === messageId - ); - } - - isWaitingForName(): boolean { - return this.state.isWaiting; - } - - getSessionInfo(): { sessionId: string; directory: string; currentTitle: string } | null { - if (!this.state.isWaiting || !this.state.sessionId) { - return null; - } - return { - sessionId: this.state.sessionId, - directory: this.state.sessionDirectory!, - currentTitle: this.state.currentTitle!, - }; - } - - clear(): void { - logger.debug("[RenameManager] Clearing rename state"); - this.state = { - isWaiting: false, - sessionId: null, - sessionDirectory: null, - currentTitle: null, - messageId: null, - }; - } -} - -export const renameManager = new RenameManager(); diff --git a/src/app/services/agent-selection-service.ts b/src/app/services/agent-selection-service.ts index c6bb26c7..6cf128d1 100644 --- a/src/app/services/agent-selection-service.ts +++ b/src/app/services/agent-selection-service.ts @@ -25,9 +25,17 @@ export async function getAvailableAgents(): Promise { } // Filter out hidden agents and subagents (only show primary and all) - const filtered = agents.filter( - (agent) => !agent.hidden && (agent.mode === "primary" || agent.mode === "all"), - ); + const filtered = agents + .filter((agent) => !agent.hidden && (agent.mode === "primary" || agent.mode === "all")) + .map((agent) => ({ + name: agent.name, + description: agent.description, + color: agent.color, + mode: agent.mode, + hidden: agent.hidden, + steps: agent.steps, + model: agent.model ? { modelID: agent.model.modelID, providerID: agent.model.providerID } : undefined, + })); logger.debug(`[AgentManager] Fetched ${filtered.length} available agents`); return filtered; @@ -137,6 +145,12 @@ export function selectAgent(agentName: string): void { setCurrentAgent(agentName); } +export async function getModelForAgent(agentName: string): Promise<{ modelID: string; providerID: string } | null> { + const agents = await getAvailableAgents(); + const agent = agents.find((a) => a.name === agentName); + return agent?.model ?? null; +} + /** * Get stored agent from settings (synchronous) * @returns Current agent name or default "build" diff --git a/src/app/services/model-selection-service.ts b/src/app/services/model-selection-service.ts index 4392de47..c06f5cf5 100644 --- a/src/app/services/model-selection-service.ts +++ b/src/app/services/model-selection-service.ts @@ -1,4 +1,9 @@ -import { getCurrentModel, setCurrentModel } from "../stores/settings-store.js"; +import { + getCurrentModel, + getCurrentProject, + getCurrentSession, + setCurrentModel, +} from "../stores/settings-store.js"; import { config } from "../../config.js"; import { opencodeClient } from "../../opencode/client.js"; import { logger } from "../../utils/logger.js"; @@ -443,6 +448,61 @@ export function fetchCurrentModel(): ModelInfo { return getStoredModel(); } +export async function fetchCurrentModelFromSession(): Promise { + const storedModel = getStoredModel(); + const session = getCurrentSession(); + const project = getCurrentProject(); + + if (!session || !project) { + return storedModel; + } + + try { + const { data: messages, error } = await opencodeClient.session.messages({ + sessionID: session.id, + directory: project.worktree, + limit: 1, + }); + + if (error || !messages || messages.length === 0) { + logger.debug("[ModelManager] No messages found, using stored model"); + return storedModel; + } + + const messageInfo = messages[0].info; + const providerID = + messageInfo.role === "user" ? messageInfo.model.providerID : messageInfo.providerID; + const modelID = messageInfo.role === "user" ? messageInfo.model.modelID : messageInfo.modelID; + + if (!providerID || !modelID) { + logger.debug("[ModelManager] Session message has no model info, using stored model"); + return storedModel; + } + + const sessionModel: ModelInfo = { + providerID, + modelID, + variant: storedModel.variant || "default", + }; + const sessionModelKey = getModelKey(sessionModel.providerID, sessionModel.modelID); + const storedModelKey = getModelKey(storedModel.providerID, storedModel.modelID); + + if (storedModelKey === sessionModelKey) { + logger.debug(`[ModelManager] Session model matches stored: ${sessionModelKey}`); + return storedModel; + } + + logger.info( + `[ModelManager] Syncing model from session: ${sessionModelKey} (was ${storedModelKey})`, + ); + selectModel(sessionModel); + return sessionModel; + } catch (err) { + logger.error("[ModelManager] Error fetching model from session:", err); + return storedModel; + } +} + /** * Select model and persist to settings * @param modelInfo Model to select diff --git a/src/app/types/agent.ts b/src/app/types/agent.ts index 460128b5..73e8d1f3 100644 --- a/src/app/types/agent.ts +++ b/src/app/types/agent.ts @@ -8,6 +8,10 @@ export interface AgentInfo { mode: "subagent" | "primary" | "all"; hidden?: boolean; steps?: number; + model?: { + modelID: string; + providerID: string; + }; } /** diff --git a/src/app/types/interaction.ts b/src/app/types/interaction.ts index b5573ec1..ba0743b4 100644 --- a/src/app/types/interaction.ts +++ b/src/app/types/interaction.ts @@ -1,4 +1,4 @@ -export type InteractionKind = "inline" | "permission" | "question" | "rename" | "task" | "custom"; +export type InteractionKind = "inline" | "permission" | "question" | "task" | "custom"; export type ExpectedInput = "callback" | "text" | "command" | "mixed"; diff --git a/src/bot/callbacks/agent-selection-callback-handler.ts b/src/bot/callbacks/agent-selection-callback-handler.ts index 23de48ee..9256495b 100644 --- a/src/bot/callbacks/agent-selection-callback-handler.ts +++ b/src/bot/callbacks/agent-selection-callback-handler.ts @@ -1,6 +1,6 @@ import { Context } from "grammy"; -import { selectAgent } from "../../app/services/agent-selection-service.js"; -import { getStoredModel } from "../../app/services/model-selection-service.js"; +import { selectAgent, getModelForAgent } from "../../app/services/agent-selection-service.js"; +import { getStoredModel, selectModel } from "../../app/services/model-selection-service.js"; import { formatVariantForButton } from "../../app/services/variant-selection-service.js"; import { getAgentDisplayName } from "../../app/types/agent.js"; import { logger } from "../../utils/logger.js"; @@ -43,7 +43,16 @@ export async function handleAgentSelect(ctx: Context): Promise { // Select agent and persist selectAgent(agentName); - // Update keyboard manager state + const agentModel = await getModelForAgent(agentName); + if (agentModel) { + selectModel({ + providerID: agentModel.providerID, + modelID: agentModel.modelID, + variant: "default", + }); + await pinnedMessageManager.refreshContextLimit(); + } + keyboardManager.updateAgent(agentName); // Update Reply Keyboard with new agent, current model, and context diff --git a/src/bot/callbacks/callback-router.ts b/src/bot/callbacks/callback-router.ts index 395075bf..af2f83fd 100644 --- a/src/bot/callbacks/callback-router.ts +++ b/src/bot/callbacks/callback-router.ts @@ -17,9 +17,9 @@ import { import { handlePermissionCallback } from "./permission-callback-handler.js"; import { handleProjectSelect } from "./project-callback-handler.js"; import { handleQuestionCallback } from "./question-callback-handler.js"; -import { handleRenameCancel } from "./rename-callback-handler.js"; import { handleBackgroundSessionOpen, + handleRenameCancelCallback, handleSessionSelect, } from "./session-callback-handler.js"; import { handleSkillsCallback } from "./skills-catalog-callback-handler.js"; @@ -79,7 +79,7 @@ export function registerCallbackRouter(bot: Bot, deps: CallbackRouterDe const handledCompactConfirm = await handleCompactConfirm(ctx); const handledTask = await handleTaskCallback(ctx); const handledTaskList = await handleTaskListCallback(ctx); - const handledRenameCancel = await handleRenameCancel(ctx); + const handledRenameCancel = await handleRenameCancelCallback(ctx); const handledCommands = await handleCommandsCallback(ctx, { bot, ensureEventSubscription: deps.ensureEventSubscription, diff --git a/src/bot/callbacks/rename-callback-handler.ts b/src/bot/callbacks/rename-callback-handler.ts deleted file mode 100644 index 5319fc67..00000000 --- a/src/bot/callbacks/rename-callback-handler.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { Context } from "grammy"; -import { opencodeClient } from "../../opencode/client.js"; -import { setCurrentSession } from "../../app/services/session-service.js"; -import { renameManager } from "../../app/managers/rename-manager.js"; -import { interactionManager } from "../../app/managers/interaction-manager.js"; -import { pinnedMessageManager } from "../pinned/pinned-message-manager.js"; -import { logger } from "../../utils/logger.js"; -import { t } from "../../i18n/index.js"; -import { RENAME_CANCEL_CALLBACK } from "../menus/rename-menu.js"; - -function getCallbackMessageId(ctx: Context): number | null { - const message = ctx.callbackQuery?.message; - if (!message || !("message_id" in message)) { - return null; - } - - const messageId = (message as { message_id?: number }).message_id; - return typeof messageId === "number" ? messageId : null; -} - -function clearRenameInteraction(reason: string): void { - const state = interactionManager.getSnapshot(); - if (state?.kind === "rename") { - interactionManager.clear(reason); - } -} - -export async function handleRenameCancel(ctx: Context): Promise { - const data = ctx.callbackQuery?.data; - if (!data || data !== RENAME_CANCEL_CALLBACK) { - return false; - } - - logger.debug("[RenameHandler] Cancel callback received"); - - if (!renameManager.isWaitingForName()) { - clearRenameInteraction("rename_cancel_inactive"); - await ctx.answerCallbackQuery({ text: t("rename.inactive_callback"), show_alert: true }); - return true; - } - - const interactionState = interactionManager.getSnapshot(); - if (interactionState?.kind !== "rename") { - renameManager.clear(); - await ctx.answerCallbackQuery({ text: t("rename.inactive_callback"), show_alert: true }); - return true; - } - - const callbackMessageId = getCallbackMessageId(ctx); - if (!renameManager.isActiveMessage(callbackMessageId)) { - await ctx.answerCallbackQuery({ text: t("rename.inactive_callback"), show_alert: true }); - return true; - } - - renameManager.clear(); - clearRenameInteraction("rename_cancelled"); - - await ctx.answerCallbackQuery(); - await ctx.editMessageText(t("rename.cancelled")).catch(() => {}); - - return true; -} - -export async function handleRenameTextAnswer(ctx: Context): Promise { - if (!renameManager.isWaitingForName()) { - return false; - } - - const text = ctx.message?.text; - if (!text) { - return false; - } - - if (text.startsWith("/")) { - return false; - } - - const interactionState = interactionManager.getSnapshot(); - if (interactionState?.kind !== "rename") { - renameManager.clear(); - await ctx.reply(t("rename.inactive")); - return true; - } - - const sessionInfo = renameManager.getSessionInfo(); - if (!sessionInfo) { - renameManager.clear(); - clearRenameInteraction("rename_missing_session_info"); - return false; - } - - const newTitle = text.trim(); - if (!newTitle) { - await ctx.reply(t("rename.empty_title")); - return true; - } - - logger.info(`[RenameHandler] Renaming session ${sessionInfo.sessionId} to: ${newTitle}`); - - try { - const { data: updatedSession, error } = await opencodeClient.session.update({ - sessionID: sessionInfo.sessionId, - directory: sessionInfo.directory, - title: newTitle, - }); - - if (error || !updatedSession) { - throw error || new Error("Failed to update session"); - } - - setCurrentSession({ - id: sessionInfo.sessionId, - title: newTitle, - directory: sessionInfo.directory, - }); - - if (pinnedMessageManager.isInitialized()) { - await pinnedMessageManager.onSessionChange(sessionInfo.sessionId, newTitle); - } - - const messageId = renameManager.getMessageId(); - if (messageId && ctx.chat) { - await ctx.api.deleteMessage(ctx.chat.id, messageId).catch(() => {}); - } - - await ctx.reply(t("rename.success", { title: newTitle })); - - logger.info(`[RenameHandler] Session renamed successfully: ${newTitle}`); - } catch (error) { - logger.error("[RenameHandler] Error renaming session:", error); - await ctx.reply(t("rename.error")); - } - - renameManager.clear(); - clearRenameInteraction("rename_completed"); - return true; -} diff --git a/src/bot/callbacks/session-callback-handler.ts b/src/bot/callbacks/session-callback-handler.ts index f4cdb408..2954506b 100644 --- a/src/bot/callbacks/session-callback-handler.ts +++ b/src/bot/callbacks/session-callback-handler.ts @@ -1,9 +1,9 @@ -import type { Bot, Context } from "grammy"; +import { InlineKeyboard, type Bot, type Context } from "grammy"; import { opencodeClient } from "../../opencode/client.js"; -import { resolveProjectAgent } from "../../app/services/agent-selection-service.js"; -import { setCurrentSession } from "../../app/services/session-service.js"; +import { fetchCurrentAgent } from "../../app/services/agent-selection-service.js"; +import { clearSession, getCurrentSession, setCurrentSession } from "../../app/services/session-service.js"; import type { SessionInfo } from "../../app/types/session.js"; -import { getCurrentProject } from "../../app/stores/settings-store.js"; +import { clearCurrentAgent, getCurrentProject } from "../../app/stores/settings-store.js"; import { clearAllInteractionState, interactionManager } from "../../app/managers/interaction-manager.js"; import { keyboardManager } from "../keyboards/keyboard-manager.js"; import { appendInlineMenuCancelButton, ensureActiveInlineMenu } from "../menus/inline-menu.js"; @@ -13,7 +13,12 @@ import { logger } from "../../utils/logger.js"; import { safeBackgroundTask } from "../../utils/safe-background-task.js"; import { config } from "../../config.js"; import { t } from "../../i18n/index.js"; -import { attachToSession } from "../../app/services/attach-service.js"; +import { attachToSession, detachAttachedSession } from "../../app/services/attach-service.js"; +import { fetchCurrentModelFromSession } from "../../app/services/model-selection-service.js"; +import { foregroundSessionState } from "../../app/managers/foreground-session-state-manager.js"; +import { assistantRunState } from "../../app/managers/assistant-run-state-manager.js"; +import { pinnedMessageManager } from "../pinned/pinned-message-manager.js"; +import { clearPromptResponseMode } from "../handlers/prompt.js"; import { renderAssistantFinalPartsSafe } from "../messages/assistant-rendering.js"; import { sendRenderedBotPart } from "../messages/telegram-text.js"; import { @@ -25,6 +30,13 @@ import { loadSessionPage, } from "../menus/session-selection-menu.js"; +const SESSION_SELECT_PREFIX = "session:select:"; +const SESSION_RENAME_PREFIX = "session:rename:"; +const SESSION_DELETE_PREFIX = "session:delete:"; +const SESSION_DELETE_CONFIRM_PREFIX = "session:delete:confirm:"; +const SESSION_DELETE_CANCEL_PREFIX = "session:delete:cancel:"; +const RENAME_CANCEL_CALLBACK = "rename:cancel"; + export interface SessionSelectDeps { bot: Bot; ensureEventSubscription: (directory: string) => Promise; @@ -59,6 +71,13 @@ type SessionMessageLike = { parts: Array<{ type: string; text?: string }>; }; +type SessionRenameMetadata = { + action: "session_rename"; + sessionId: string; + directory: string; + currentTitle: string; +}; + async function removeCallbackReplyMarkup(ctx: Context): Promise { try { await ctx.editMessageReplyMarkup(); @@ -136,11 +155,24 @@ async function selectSessionById( if (ctx.chat) { const chatId = ctx.chat.id; - const currentAgent = await resolveProjectAgent(); + + clearCurrentAgent(); + + const currentAgent = await fetchCurrentAgent(); keyboardManager.updateAgent(currentAgent); - const contextInfo = keyboardManager.getContextInfo(); + const currentModel = await fetchCurrentModelFromSession(); + keyboardManager.updateModel(currentModel); + + await pinnedMessageManager.refreshContextLimit(); + + const currentSession = getCurrentSession(); + if (currentSession) { + await pinnedMessageManager.loadContextFromHistory(currentSession.id, currentProject.worktree); + } + + const contextInfo = pinnedMessageManager.getContextInfo() ?? keyboardManager.getContextInfo(); if (contextInfo) { keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit); } @@ -297,12 +329,48 @@ export async function handleSessionSelect(ctx: Context, deps: SessionSelectDeps) return true; } - await selectSessionById(ctx, deps, sessionId, { - source: "menu", - deleteCallbackMessage: true, - removeCallbackReplyMarkup: false, - postSelectAction: "preview", - }); + if (callbackQuery.data.startsWith(SESSION_SELECT_PREFIX)) { + const selectSessionId = callbackQuery.data.slice(SESSION_SELECT_PREFIX.length); + if (!selectSessionId) { + await ctx.answerCallbackQuery({ text: t("callback.processing_error") }); + return true; + } + + await selectSessionById(ctx, deps, selectSessionId, { + source: "menu", + deleteCallbackMessage: false, + removeCallbackReplyMarkup: true, + postSelectAction: "none", + }); + } else if (callbackQuery.data.startsWith(SESSION_RENAME_PREFIX)) { + const renameSessionId = callbackQuery.data.slice(SESSION_RENAME_PREFIX.length); + if (!renameSessionId) { + await ctx.answerCallbackQuery({ text: t("callback.processing_error") }); + return true; + } + + await handleSessionRenameCallback(ctx, renameSessionId, currentProject.worktree); + } else if (callbackQuery.data.startsWith(SESSION_DELETE_CANCEL_PREFIX)) { + await handleSessionDeleteCancelCallback(ctx, currentProject.worktree); + } else if (callbackQuery.data.startsWith(SESSION_DELETE_CONFIRM_PREFIX)) { + const confirmSessionId = callbackQuery.data.slice(SESSION_DELETE_CONFIRM_PREFIX.length); + if (!confirmSessionId) { + await ctx.answerCallbackQuery({ text: t("callback.processing_error") }); + return true; + } + + await handleSessionDeleteConfirmCallback(ctx, confirmSessionId, currentProject.worktree); + } else if (callbackQuery.data.startsWith(SESSION_DELETE_PREFIX)) { + const deleteSessionId = callbackQuery.data.slice(SESSION_DELETE_PREFIX.length); + if (!deleteSessionId) { + await ctx.answerCallbackQuery({ text: t("callback.processing_error") }); + return true; + } + + await handleSessionDeleteCallback(ctx, deleteSessionId, currentProject.worktree); + } else { + await handleSessionPreviewCallback(ctx, sessionId, currentProject.worktree); + } } catch (error) { clearAllInteractionState("session_select_error"); logger.error("[Sessions] Error selecting session:", error); @@ -313,6 +381,286 @@ export async function handleSessionSelect(ctx: Context, deps: SessionSelectDeps) return true; } +function buildSessionPreviewKeyboard(sessionId: string): InlineKeyboard { + return new InlineKeyboard() + .text(t("sessions.button.select"), `${SESSION_SELECT_PREFIX}${sessionId}`) + .text(t("sessions.button.rename"), `${SESSION_RENAME_PREFIX}${sessionId}`) + .row() + .text(t("sessions.button.delete"), `${SESSION_DELETE_PREFIX}${sessionId}`) + .text(t("sessions.button.close"), "inline:cancel:session"); +} + +async function handleSessionPreviewCallback( + ctx: Context, + sessionId: string, + directory: string, +): Promise { + const { data: session, error } = await opencodeClient.session.get({ + sessionID: sessionId, + directory, + }); + + if (error || !session) { + await ctx.answerCallbackQuery({ text: t("sessions.select_error"), show_alert: true }); + return; + } + + const previewItems = await loadSessionPreview(sessionId, directory); + const previewText = formatSessionPreview(session.title, previewItems); + const keyboard = buildSessionPreviewKeyboard(sessionId); + + try { + await ctx.editMessageText(previewText, { reply_markup: keyboard }); + } catch (err) { + logger.warn("[Sessions] Failed to edit message for preview, sending new:", err); + await ctx.reply(previewText, { reply_markup: keyboard }); + } + + await ctx.answerCallbackQuery(); + logger.info(`[Sessions] Preview shown for session: id=${sessionId}, title="${session.title}"`); +} + +async function handleSessionRenameCallback( + ctx: Context, + sessionId: string, + directory: string, +): Promise { + const { data: session, error } = await opencodeClient.session.get({ + sessionID: sessionId, + directory, + }); + + if (error || !session) { + await ctx.answerCallbackQuery({ text: t("sessions.select_error"), show_alert: true }); + return; + } + + const keyboard = new InlineKeyboard().text(t("sessions.rename.cancel"), RENAME_CANCEL_CALLBACK); + + try { + await ctx.editMessageText(t("sessions.rename.prompt", { title: session.title }), { + reply_markup: keyboard, + }); + } catch (err) { + logger.warn("[Sessions] Failed to edit message for rename prompt:", err); + } + + interactionManager.start({ + kind: "custom", + expectedInput: "text", + metadata: { + action: "session_rename", + sessionId, + directory, + currentTitle: session.title, + }, + }); + + await ctx.answerCallbackQuery(); + logger.info(`[Sessions] Rename flow started for session: id=${sessionId}`); +} + +export async function handleRenameCancelCallback(ctx: Context): Promise { + const data = ctx.callbackQuery?.data; + if (data !== RENAME_CANCEL_CALLBACK) { + return false; + } + + const state = interactionManager.getSnapshot(); + if (!state || state.kind !== "custom" || state.metadata?.action !== "session_rename") { + await ctx.answerCallbackQuery({ text: t("callback.processing_error") }); + return true; + } + + const { sessionId, directory } = state.metadata as SessionRenameMetadata; + interactionManager.clear("rename_cancelled"); + + const { data: session } = await opencodeClient.session.get({ sessionID: sessionId, directory }); + if (session) { + const previewItems = await loadSessionPreview(sessionId, directory); + const previewText = formatSessionPreview(session.title, previewItems); + const keyboard = buildSessionPreviewKeyboard(sessionId); + try { + await ctx.editMessageText(previewText, { reply_markup: keyboard }); + } catch (err) { + logger.warn("[Sessions] Failed to restore preview after rename cancel:", err); + } + } + + await ctx.answerCallbackQuery(); + logger.info(`[Sessions] Rename cancelled for session: id=${sessionId}`); + return true; +} + +export async function handleRenameTextAnswer(ctx: Context): Promise { + const state = interactionManager.getSnapshot(); + if (!state || state.kind !== "custom" || state.metadata?.action !== "session_rename") { + return false; + } + + const text = ctx.message?.text; + if (!text || text.startsWith("/")) { + return false; + } + + const { sessionId, directory } = state.metadata as SessionRenameMetadata; + const newTitle = text.trim(); + if (!newTitle) { + await ctx.reply(t("sessions.rename.empty")); + return true; + } + + try { + const { data: updatedSession, error } = await opencodeClient.session.update({ + sessionID: sessionId, + directory, + title: newTitle, + }); + + if (error || !updatedSession) { + throw error || new Error("Failed to update session"); + } + + const currentSession = getCurrentSession(); + if (currentSession?.id === sessionId) { + setCurrentSession({ id: sessionId, title: newTitle, directory }); + if (pinnedMessageManager.isInitialized()) { + await pinnedMessageManager.onSessionChange(sessionId, newTitle); + } + } + + interactionManager.clear("rename_completed"); + await ctx.reply(t("sessions.rename.success", { title: newTitle })); + logger.info(`[Sessions] Session renamed successfully: ${newTitle}`); + } catch (error) { + logger.error("[Sessions] Error renaming session:", error); + await ctx.reply(t("sessions.rename.error")); + } + + return true; +} + +async function handleSessionDeleteCallback( + ctx: Context, + sessionId: string, + directory: string, +): Promise { + const { data: session, error } = await opencodeClient.session.get({ + sessionID: sessionId, + directory, + }); + + if (error || !session) { + await ctx.answerCallbackQuery({ text: t("sessions.select_error"), show_alert: true }); + return; + } + + const keyboard = new InlineKeyboard() + .text(t("sessions.delete.yes"), `${SESSION_DELETE_CONFIRM_PREFIX}${sessionId}`) + .text(t("sessions.delete.no"), `${SESSION_DELETE_CANCEL_PREFIX}${sessionId}`); + + try { + await ctx.editMessageText(t("sessions.delete.confirm", { title: session.title }), { + reply_markup: keyboard, + }); + } catch (err) { + logger.warn("[Sessions] Failed to edit message for delete confirm:", err); + } + + await ctx.answerCallbackQuery(); + logger.info(`[Sessions] Delete confirmation shown for session: id=${sessionId}`); +} + +async function handleSessionDeleteConfirmCallback( + ctx: Context, + sessionId: string, + directory: string, +): Promise { + try { + const { data: sessionBeforeDelete, error: getError } = await opencodeClient.session.get({ + sessionID: sessionId, + directory, + }); + if (getError) { + logger.warn("[Sessions] Failed to fetch session before delete:", getError); + } + + const deletedTitle = sessionBeforeDelete?.title ?? sessionId; + const { error } = await opencodeClient.session.delete({ sessionID: sessionId, directory }); + if (error) { + const isNotFound = (error as { status?: number })?.status === 404; + const errorMessage = isNotFound + ? t("sessions.delete.not_found") + : t("sessions.delete.error"); + await ctx.editMessageText(errorMessage).catch(() => {}); + await ctx.answerCallbackQuery({ text: errorMessage, show_alert: true }); + return; + } + + const currentSession = getCurrentSession(); + if (currentSession?.id === sessionId) { + detachAttachedSession("session_deleted"); + clearPromptResponseMode(sessionId); + foregroundSessionState.markIdle(sessionId); + assistantRunState.clearRun(sessionId, "session_deleted"); + clearAllInteractionState("session_deleted"); + clearSession(); + + if (pinnedMessageManager.isInitialized()) { + await pinnedMessageManager.clear(); + } + + if (ctx.chat) { + keyboardManager.initialize(ctx.api, ctx.chat.id); + } + + await pinnedMessageManager.refreshContextLimit(); + keyboardManager.updateContext(0, pinnedMessageManager.getContextLimit()); + } else { + clearAllInteractionState("session_deleted_other"); + } + + const successMessage = t("sessions.delete.success", { title: deletedTitle }); + try { + await ctx.editMessageText(successMessage); + } catch (err) { + logger.warn("[Sessions] Failed to edit message after delete:", err); + await ctx.reply(successMessage); + } + + await ctx.answerCallbackQuery(); + logger.info(`[Sessions] Session deleted: id=${sessionId}`); + } catch (error) { + logger.error("[Sessions] Error deleting session:", error); + await ctx.editMessageText(t("sessions.delete.error")).catch(() => ctx.reply(t("sessions.delete.error"))); + await ctx.answerCallbackQuery({ text: t("sessions.delete.error"), show_alert: true }).catch(() => {}); + } +} + +async function handleSessionDeleteCancelCallback(ctx: Context, directory: string): Promise { + const data = ctx.callbackQuery?.data; + const sessionId = data?.startsWith(SESSION_DELETE_CANCEL_PREFIX) + ? data.slice(SESSION_DELETE_CANCEL_PREFIX.length) + : null; + + if (sessionId) { + const { data: session } = await opencodeClient.session.get({ sessionID: sessionId, directory }); + if (session) { + const previewItems = await loadSessionPreview(sessionId, directory); + const previewText = formatSessionPreview(session.title, previewItems); + const keyboard = buildSessionPreviewKeyboard(sessionId); + try { + await ctx.editMessageText(previewText, { reply_markup: keyboard }); + } catch (err) { + logger.warn("[Sessions] Failed to restore preview after delete cancel:", err); + } + } + } + + await ctx.answerCallbackQuery(); + logger.info(`[Sessions] Delete cancelled for session: id=${sessionId}`); +} + function extractTextParts( parts: Array<{ type: string; text?: string }>, options: { trim?: boolean } = {}, diff --git a/src/bot/commands/definitions.ts b/src/bot/commands/definitions.ts index 5e5d2043..493c07ef 100644 --- a/src/bot/commands/definitions.ts +++ b/src/bot/commands/definitions.ts @@ -32,7 +32,6 @@ const COMMAND_DEFINITIONS: BotCommandI18nDefinition[] = [ { command: "worktree", descriptionKey: "cmd.description.worktree" }, { command: "task", descriptionKey: "cmd.description.task" }, { command: "tasklist", descriptionKey: "cmd.description.tasklist" }, - { command: "rename", descriptionKey: "cmd.description.rename" }, { command: "commands", descriptionKey: "cmd.description.commands" }, { command: "skills", descriptionKey: "cmd.description.skills" }, { command: "mcps", descriptionKey: "cmd.description.mcps" }, diff --git a/src/bot/commands/rename-command.ts b/src/bot/commands/rename-command.ts deleted file mode 100644 index f1ecd58d..00000000 --- a/src/bot/commands/rename-command.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { CommandContext, Context } from "grammy"; -import { getCurrentSession } from "../../app/services/session-service.js"; -import { renameManager } from "../../app/managers/rename-manager.js"; -import { interactionManager } from "../../app/managers/interaction-manager.js"; -import { logger } from "../../utils/logger.js"; -import { t } from "../../i18n/index.js"; -import { buildRenameCancelKeyboard } from "../menus/rename-menu.js"; - -export async function renameCommand(ctx: CommandContext): Promise { - try { - const currentSession = getCurrentSession(); - - if (!currentSession) { - await ctx.reply(t("rename.no_session")); - return; - } - - const message = await ctx.reply(t("rename.prompt", { title: currentSession.title }), { - reply_markup: buildRenameCancelKeyboard(), - }); - - renameManager.startWaiting(currentSession.id, currentSession.directory, currentSession.title); - renameManager.setMessageId(message.message_id); - interactionManager.start({ - kind: "rename", - expectedInput: "text", - metadata: { - sessionId: currentSession.id, - messageId: message.message_id, - }, - }); - - logger.info(`[RenameCommand] Waiting for new title for session: ${currentSession.id}`); - } catch (error) { - logger.error("[RenameCommand] Error starting rename flow:", error); - await ctx.reply(t("rename.error")); - } -} diff --git a/src/bot/menus/rename-menu.ts b/src/bot/menus/rename-menu.ts deleted file mode 100644 index a0346bc2..00000000 --- a/src/bot/menus/rename-menu.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { InlineKeyboard } from "grammy"; -import { t } from "../../i18n/index.js"; - -export const RENAME_CANCEL_CALLBACK = "rename:cancel"; - -export function buildRenameCancelKeyboard(): InlineKeyboard { - return new InlineKeyboard().text(t("rename.button.cancel"), RENAME_CANCEL_CALLBACK); -} diff --git a/src/bot/menus/session-selection-menu.ts b/src/bot/menus/session-selection-menu.ts index 74099772..9047edae 100644 --- a/src/bot/menus/session-selection-menu.ts +++ b/src/bot/menus/session-selection-menu.ts @@ -4,6 +4,7 @@ import { getDateLocale, t } from "../../i18n/index.js"; import { logger } from "../../utils/logger.js"; export const SESSION_CALLBACK_PREFIX = "session:"; +export const SESSION_PREVIEW_CALLBACK_PREFIX = "session:preview:"; const SESSION_PAGE_CALLBACK_PREFIX = "session:page:"; const BACKGROUND_SESSION_CALLBACK_PREFIX = "background-session:"; const SESSION_FETCH_EXTRA_COUNT = 1; @@ -69,10 +70,24 @@ export function parseSessionIdCallback(data: string): string | null { return null; } + const previewSessionId = parseSessionPreviewCallback(data); + if (previewSessionId) { + return previewSessionId; + } + const sessionId = data.slice(SESSION_CALLBACK_PREFIX.length); return sessionId.length > 0 ? sessionId : null; } +export function parseSessionPreviewCallback(data: string): string | null { + if (!data.startsWith(SESSION_PREVIEW_CALLBACK_PREFIX)) { + return null; + } + + const sessionId = data.slice(SESSION_PREVIEW_CALLBACK_PREFIX.length); + return sessionId.length > 0 ? sessionId : null; +} + export function parseBackgroundSessionCallback( data: string, ): BackgroundSessionCallbackPayload | null { @@ -155,7 +170,7 @@ function buildSessionsKeyboard(pageData: SessionPage, pageSize: number): InlineK pageData.sessions.forEach((session, index) => { const date = new Date(session.time.created).toLocaleDateString(localeForDate); const label = `${pageStartIndex + index + 1}. ${session.title} (${date})`; - keyboard.text(label, `${SESSION_CALLBACK_PREFIX}${session.id}`).row(); + keyboard.text(label, `${SESSION_PREVIEW_CALLBACK_PREFIX}${session.id}`).row(); }); if (pageData.page > 0) { diff --git a/src/bot/middleware/interaction-guard-decision.ts b/src/bot/middleware/interaction-guard-decision.ts index 3b4c1376..9527d3ea 100644 --- a/src/bot/middleware/interaction-guard-decision.ts +++ b/src/bot/middleware/interaction-guard-decision.ts @@ -124,18 +124,19 @@ function createBusyBlockDecision( }; } -function isAllowedRenameCancelCallback(ctx: Context, state: InteractionState): boolean { +function isAllowedTaskCallback(ctx: Context, state: InteractionState): boolean { return ( - state.kind === "rename" && - state.expectedInput === "text" && - ctx.callbackQuery?.data === "rename:cancel" + state.kind === "task" && + (ctx.callbackQuery?.data === "task:cancel" || ctx.callbackQuery?.data === "task:retry-schedule") ); } -function isAllowedTaskCallback(ctx: Context, state: InteractionState): boolean { +function isAllowedSessionRenameCancelCallback(ctx: Context, state: InteractionState): boolean { return ( - state.kind === "task" && - (ctx.callbackQuery?.data === "task:cancel" || ctx.callbackQuery?.data === "task:retry-schedule") + state.kind === "custom" && + state.expectedInput === "text" && + state.metadata?.action === "session_rename" && + ctx.callbackQuery?.data === "rename:cancel" ); } @@ -206,7 +207,7 @@ export function resolveInteractionGuardDecision(ctx: Context): GuardDecision { return createBlockDecision(inputType, state, "expected_text", command); } - if (inputType === "callback" && isAllowedRenameCancelCallback(ctx, state)) { + if (inputType === "callback" && isAllowedSessionRenameCancelCallback(ctx, state)) { return createAllowDecision(inputType, state, command); } diff --git a/src/bot/middleware/interaction-guard.ts b/src/bot/middleware/interaction-guard.ts index 8004e3ff..3a92074d 100644 --- a/src/bot/middleware/interaction-guard.ts +++ b/src/bot/middleware/interaction-guard.ts @@ -45,18 +45,6 @@ function getInteractionBlockedMessage( } } - if (interactionKind === "rename") { - switch (reason) { - case "command_not_allowed": - return t("rename.blocked.command_not_allowed"); - case "expected_callback": - case "expected_command": - case "expected_text": - default: - return t("rename.blocked.expected_name"); - } - } - if (interactionKind === "task") { switch (reason) { case "command_not_allowed": diff --git a/src/bot/routers/command-router.ts b/src/bot/routers/command-router.ts index 0fd4ca6f..b7f11bcb 100644 --- a/src/bot/routers/command-router.ts +++ b/src/bot/routers/command-router.ts @@ -14,7 +14,6 @@ import { abortCommand } from "../commands/abort-command.js"; import { detachCommand } from "../commands/detach-command.js"; import { taskCommand } from "../commands/task-command.js"; import { taskListCommand } from "../commands/tasklist-command.js"; -import { renameCommand } from "../commands/rename-command.js"; import { commandsCommand } from "../commands/command-catalog-command.js"; import { skillsCommand } from "../commands/skills-catalog-command.js"; import { mcpsCommand } from "../commands/mcp-catalog-command.js"; @@ -77,7 +76,6 @@ export function registerCommandRouter(bot: Bot, deps: CommandRouterDeps bot.command("detach", detachCommand); bot.command("task", taskCommand); bot.command("tasklist", taskListCommand); - bot.command("rename", renameCommand); bot.command("commands", commandsCommand); bot.command("skills", skillsCommand); bot.command("mcps", mcpsCommand); diff --git a/src/bot/routers/message-router.ts b/src/bot/routers/message-router.ts index 443a34cc..7af29012 100644 --- a/src/bot/routers/message-router.ts +++ b/src/bot/routers/message-router.ts @@ -8,7 +8,7 @@ import { handleModelSearchTextInput, } from "../callbacks/model-selection-callback-handler.js"; import { handleQuestionTextAnswer } from "../callbacks/question-callback-handler.js"; -import { handleRenameTextAnswer } from "../callbacks/rename-callback-handler.js"; +import { handleRenameTextAnswer } from "../callbacks/session-callback-handler.js"; import { handleContextButtonPress } from "../menus/context-control-menu.js"; import { showAgentSelectionMenu } from "../menus/agent-selection-menu.js"; import { showModelSelectionMenu } from "../menus/model-selection-menu.js"; diff --git a/src/bot/services/event-subscription-service.ts b/src/bot/services/event-subscription-service.ts index 3d481c32..1de9c715 100644 --- a/src/bot/services/event-subscription-service.ts +++ b/src/bot/services/event-subscription-service.ts @@ -8,7 +8,7 @@ import { summaryAggregator } from "../../app/managers/summary-aggregation-manage import { formatToolInfo } from "../../app/formatters/summary-formatter.js"; import { renderSubagentCards } from "../../app/formatters/subagent-formatter.js"; import { ToolMessageBatcher } from "../../app/formatters/tool-message-batcher.js"; -import { getCurrentSession } from "../../app/services/session-service.js"; +import { clearSession, getCurrentSession } from "../../app/services/session-service.js"; import { ingestSessionInfoForCache } from "../../app/services/session-cache-service.js"; import { logger } from "../../utils/logger.js"; import { safeBackgroundTask } from "../../utils/safe-background-task.js"; @@ -40,6 +40,7 @@ import { ResponseStreamer, type StreamingMessagePayload } from "../streaming/res import { ToolCallStreamer, type ToolStreamKey } from "../streaming/tool-call-streamer.js"; import { attachManager } from "../../app/managers/attach-manager.js"; import { + detachAttachedSession, markAttachedSessionBusy, markAttachedSessionIdle, } from "../../app/services/attach-service.js"; @@ -813,6 +814,43 @@ class EventSubscriptionService implements BotEventSubscriptionService { } } + if (event.type === "session.deleted") { + const props = event.properties as { info?: { id?: string; title?: string } }; + const deletedId = props.info?.id; + const deletedTitle = props.info?.title ?? deletedId ?? "unknown"; + const currentSession = getCurrentSession(); + + if (deletedId && currentSession?.id === deletedId) { + logger.info(`[Bot] Current session was deleted externally: id=${deletedId}`); + detachAttachedSession("session_deleted_external"); + clearPromptResponseMode(deletedId); + foregroundSessionState.markIdle(deletedId); + assistantRunState.clearRun(deletedId, "session_deleted_external"); + clearAllInteractionState("session_deleted_external"); + clearSession(); + + const bot = this.botInstance; + const chatId = this.chatIdInstance; + if (bot && chatId) { + void (async () => { + if (pinnedMessageManager.isInitialized()) { + await pinnedMessageManager.clear(); + } + + keyboardManager.initialize(bot.api, chatId); + await pinnedMessageManager.refreshContextLimit(); + keyboardManager.updateContext(0, pinnedMessageManager.getContextLimit()); + await bot.api.sendMessage( + chatId, + t("sessions.deleted_external", { title: deletedTitle }), + ); + })().catch((err) => { + logger.error("[Bot] Failed to handle external session deletion:", err); + }); + } + } + } + if (config.bot.trackBackgroundSessions) { backgroundSessionTracker.processEvent(event, getCurrentSession()?.id ?? null); } diff --git a/src/i18n/ar.ts b/src/i18n/ar.ts index ccf81eeb..ed2cc8f0 100644 --- a/src/i18n/ar.ts +++ b/src/i18n/ar.ts @@ -26,7 +26,6 @@ export const ar: I18nDictionary = { "cmd.description.opencode_stop": "إيقاف خادم OpenCode", "cmd.description.ls": "استعراض ملفات المجلد", "cmd.description.help": "المساعدة", - "cmd.description.rename": "تغيير اسم الجلسة الحالية", "cmd.description.open": "إضافة مشروع عبر استعراض المجلدات", "callback.unknown_command": "الأمر غير معروف", @@ -147,6 +146,24 @@ export const ar: I18nDictionary = { "sessions.preview.title": "أحدث الرسائل:", "sessions.preview.you": "أنت:", "sessions.preview.agent": "الوكيل:", + "sessions.button.select": "✅ اختيار", + "sessions.button.rename": "✏️ إعادة تسمية", + "sessions.button.delete": "🗑 حذف", + "sessions.button.close": "✖ إغلاق", + "sessions.current_session": "محددة بالفعل", + "sessions.rename.prompt": "📝 أدخل عنوانًا جديدًا:\n\nالحالي: {title}", + "sessions.rename.cancel": "❌ إلغاء", + "sessions.rename.empty": "⚠️ لا يمكن أن يكون العنوان فارغًا.", + "sessions.rename.success": "✅ تمت إعادة تسمية الجلسة إلى: {title}", + "sessions.rename.error": "🔴 تعذر إعادة تسمية الجلسة.", + "sessions.delete.confirm": + "⚠️ حذف الجلسة\n\n\"{title}\"\nستتم إزالة كل الرسائل والسجل نهائيًا.", + "sessions.delete.yes": "✅ نعم، احذف", + "sessions.delete.no": "❌ لا", + "sessions.delete.success": "✅ تم حذف الجلسة: {title}", + "sessions.delete.not_found": "🔴 لم يتم العثور على الجلسة. ربما حُذفت بالفعل.", + "sessions.delete.error": "🔴 تعذر حذف الجلسة.", + "sessions.deleted_external": "🗑 تم حذف الجلسة: {title}\nتم قطع اتصالك بها.", "messages.project_not_selected": "🏗 لم تحدد مشروعًا بعد.\n\nاختر مشروعًا أولًا باستخدام /projects.", "messages.session_not_selected": "💬 لم تحدد جلسة بعد.\n\nاختر جلسة باستخدام /sessions أو ابدأ جلسة جديدة باستخدام /new.", @@ -382,20 +399,6 @@ export const ar: I18nDictionary = { "runtime.wizard.tty_required": "يتطلب معالج الإعداد التفاعلي طرفية TTY. شغّل `opencode-telegram config` في shell تفاعلية.", - "rename.no_session": "⚠️ لا توجد جلسة نشطة. أنشئ جلسة أو اختر واحدة أولًا.", - "rename.prompt": "📝 أدخل عنوانًا جديدًا للجلسة:\n\nالحالي: {title}", - "rename.empty_title": "⚠️ لا يمكن أن يكون العنوان فارغًا.", - "rename.success": "✅ تمت إعادة تسمية الجلسة إلى: {title}", - "rename.error": "🔴 تعذر تغيير اسم الجلسة.", - "rename.cancelled": "❌ تم إلغاء تغيير الاسم.", - "rename.inactive_callback": "انتهت صلاحية طلب تغيير الاسم", - "rename.inactive": "⚠️ طلب تغيير الاسم غير نشط. شغّل /rename مرة أخرى.", - "rename.blocked.expected_name": - "⚠️ أدخل اسم الجلسة الجديد كنص أو اضغط إلغاء في رسالة تغيير الاسم.", - "rename.blocked.command_not_allowed": - "⚠️ لا يمكن استخدام هذا الأمر أثناء انتظار اسم جديد للجلسة.", - "rename.button.cancel": "❌ إلغاء", - "task.prompt.schedule": "⏰ أرسل موعد المهمة بلغة طبيعية.\n\nأمثلة:\n- كل 5 دقائق\n- كل يوم الساعة 17:00\n- غدًا الساعة 12:00", "task.schedule_empty": "⚠️ لا يمكن أن يكون الموعد فارغًا.", diff --git a/src/i18n/de.ts b/src/i18n/de.ts index ea6487f0..c23a8a9d 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -172,6 +172,25 @@ export const de: I18nDictionary = { "sessions.preview.title": "Letzte Nachrichten:", "sessions.preview.you": "Du:", "sessions.preview.agent": "Agent:", + "sessions.button.select": "✅ Auswählen", + "sessions.button.rename": "✏️ Umbenennen", + "sessions.button.delete": "🗑 Löschen", + "sessions.button.close": "✖ Schließen", + "sessions.current_session": "Bereits ausgewählt", + "sessions.rename.prompt": "📝 Neuen Titel eingeben:\n\nAktuell: {title}", + "sessions.rename.cancel": "❌ Abbrechen", + "sessions.rename.empty": "⚠️ Titel darf nicht leer sein.", + "sessions.rename.success": "✅ Sitzung umbenannt zu: {title}", + "sessions.rename.error": "🔴 Sitzung konnte nicht umbenannt werden.", + "sessions.delete.confirm": + '⚠️ Sitzung löschen\n\n"{title}"\nAlle Nachrichten und Verlauf werden dauerhaft entfernt.', + "sessions.delete.yes": "✅ Ja, löschen", + "sessions.delete.no": "❌ Nein", + "sessions.delete.success": "✅ Sitzung gelöscht: {title}", + "sessions.delete.not_found": + "🔴 Sitzung nicht gefunden. Möglicherweise wurde sie bereits gelöscht.", + "sessions.delete.error": "🔴 Sitzung konnte nicht gelöscht werden.", + "sessions.deleted_external": "🗑 Sitzung wurde gelöscht: {title}\nSie wurden abgemeldet.", "messages.project_not_selected": "🏗 Kein Projekt ausgewählt.\n\nWähle zuerst ein Projekt mit /projects.", @@ -438,20 +457,6 @@ export const de: I18nDictionary = { "runtime.wizard.tty_required": "Der interaktive Assistent erfordert ein TTY-Terminal. Führe `opencode-telegram config` in einer interaktiven Shell aus.", - "rename.no_session": "⚠️ Keine aktive Sitzung. Erstelle oder wähle zuerst eine Sitzung.", - "rename.prompt": "📝 Neuen Titel für die Sitzung eingeben:\n\nAktuell: {title}", - "rename.empty_title": "⚠️ Titel darf nicht leer sein.", - "rename.success": "✅ Sitzung umbenannt in: {title}", - "rename.error": "🔴 Sitzung konnte nicht umbenannt werden.", - "rename.cancelled": "❌ Umbenennen abgebrochen.", - "rename.inactive_callback": "Umbenennen-Anfrage ist inaktiv", - "rename.inactive": "⚠️ Umbenennen-Anfrage ist nicht aktiv. Starte /rename erneut.", - "rename.blocked.expected_name": - "⚠️ Sende den neuen Sitzungsnamen als Text oder tippe in der Umbenennen-Nachricht auf Abbrechen.", - "rename.blocked.command_not_allowed": - "⚠️ Dieser Befehl ist nicht verfügbar, solange beim Umbenennen auf einen neuen Namen gewartet wird.", - "rename.button.cancel": "❌ Abbrechen", - "task.prompt.schedule": "⏰ Sende den Zeitplan der Aufgabe in natürlicher Sprache.\n\nBeispiele:\n- alle 5 Minuten\n- jeden Tag um 17:00\n- morgen um 12:00", "task.schedule_empty": "⚠️ Der Zeitplan darf nicht leer sein.", @@ -572,8 +577,6 @@ export const de: I18nDictionary = { "mcps.button.back": "⬅️ Back", "mcps.auth_required": "This server requires authorization and cannot be enabled from the bot.", - "cmd.description.rename": "Aktuelle Sitzung umbenennen", - "legacy.models.fetch_error": "🔴 Modellliste konnte nicht geladen werden. Prüfe den Serverstatus mit /status.", "legacy.models.empty": "📋 Keine verfügbaren Modelle. Konfiguriere Provider in OpenCode.", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 382e5ff8..4a7ce85c 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -164,6 +164,24 @@ export const en = { "sessions.preview.title": "Recent messages:", "sessions.preview.you": "You:", "sessions.preview.agent": "Agent:", + "sessions.button.select": "✅ Select", + "sessions.button.rename": "✏️ Rename", + "sessions.button.delete": "🗑 Delete", + "sessions.button.close": "✖ Close", + "sessions.current_session": "Already selected", + "sessions.rename.prompt": "📝 Enter new title:\n\nCurrent: {title}", + "sessions.rename.cancel": "❌ Cancel", + "sessions.rename.empty": "⚠️ Title cannot be empty.", + "sessions.rename.success": "✅ Session renamed to: {title}", + "sessions.rename.error": "🔴 Failed to rename session.", + "sessions.delete.confirm": + '⚠️ Delete Session\n\n"{title}"\nAll messages and history will be permanently removed.', + "sessions.delete.yes": "✅ Yes, Delete", + "sessions.delete.no": "❌ No", + "sessions.delete.success": "✅ Session deleted: {title}", + "sessions.delete.not_found": "🔴 Session not found. It may have been deleted already.", + "sessions.delete.error": "🔴 Failed to delete session.", + "sessions.deleted_external": "🗑 Session was deleted: {title}\nYou have been detached.", "messages.project_not_selected": "🏗 Project is not selected.\n\nFirst select a project with /projects.", @@ -421,20 +439,6 @@ export const en = { "runtime.wizard.tty_required": "Interactive wizard requires a TTY terminal. Run `opencode-telegram config` in an interactive shell.", - "rename.no_session": "⚠️ No active session. Create or select a session first.", - "rename.prompt": "📝 Enter new title for session:\n\nCurrent: {title}", - "rename.empty_title": "⚠️ Title cannot be empty.", - "rename.success": "✅ Session renamed to: {title}", - "rename.error": "🔴 Failed to rename session.", - "rename.cancelled": "❌ Rename cancelled.", - "rename.inactive_callback": "Rename request is inactive", - "rename.inactive": "⚠️ Rename request is not active. Run /rename again.", - "rename.blocked.expected_name": - "⚠️ Enter a new session name as text or tap Cancel in rename message.", - "rename.blocked.command_not_allowed": - "⚠️ This command is not available while rename is waiting for a new name.", - "rename.button.cancel": "❌ Cancel", - "task.prompt.schedule": "⏰ Send the task schedule in natural language.\n\nExamples:\n- every 5 minutes\n- every day at 17:00\n- tomorrow at 12:00", "task.schedule_empty": "⚠️ Schedule cannot be empty.", @@ -550,8 +554,6 @@ export const en = { "mcps.button.back": "⬅️ Back", "mcps.auth_required": "This server requires authorization and cannot be enabled from the bot.", - "cmd.description.rename": "Rename current session", - "legacy.models.fetch_error": "🔴 Failed to get models list. Check server status with /status.", "legacy.models.empty": "📋 No available models. Configure providers in OpenCode.", "legacy.models.header": "📋 Available models:\n\n", diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 39d89999..258bfa89 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -171,6 +171,26 @@ export const es: I18nDictionary = { "sessions.preview.title": "Mensajes recientes:", "sessions.preview.you": "Tú:", "sessions.preview.agent": "Agente:", + "sessions.button.select": "✅ Seleccionar", + "sessions.button.rename": "✏️ Renombrar", + "sessions.button.delete": "🗑 Eliminar", + "sessions.button.close": "✖ Cerrar", + "sessions.current_session": "Ya seleccionada", + "sessions.rename.prompt": "📝 Ingrese nuevo título:\n\nActual: {title}", + "sessions.rename.cancel": "❌ Cancelar", + "sessions.rename.empty": "⚠️ El título no puede estar vacío.", + "sessions.rename.success": "✅ Sesión renombrada a: {title}", + "sessions.rename.error": "🔴 Error al renombrar la sesión.", + "sessions.delete.confirm": + '⚠️ Eliminar sesión\n\n"{title}"\nTodos los mensajes e historial se eliminarán permanentemente.', + "sessions.delete.yes": "✅ Sí, eliminar", + "sessions.delete.no": "❌ No", + "sessions.delete.success": "✅ Sesión eliminada: {title}", + "sessions.delete.not_found": + "🔴 Sesión no encontrada. Puede que ya haya sido eliminada.", + "sessions.delete.error": "🔴 Error al eliminar la sesión.", + "sessions.deleted_external": + "🗑 La sesión fue eliminada: {title}\nHas sido desconectado.", "messages.project_not_selected": "🏗 No hay ningún proyecto seleccionado.\n\nPrimero selecciona un proyecto con /projects.", @@ -436,21 +456,6 @@ export const es: I18nDictionary = { "runtime.wizard.tty_required": "El asistente interactivo requiere un terminal TTY. Ejecuta `opencode-telegram config` en una shell interactiva.", - "rename.no_session": "⚠️ No hay una sesión activa. Crea o selecciona una sesión primero.", - "rename.prompt": "📝 Introduce un nuevo título para la sesión:\n\nActual: {title}", - "rename.empty_title": "⚠️ El título no puede estar vacío.", - "rename.success": "✅ Sesión renombrada a: {title}", - "rename.error": "🔴 No se pudo renombrar la sesión.", - "rename.cancelled": "❌ Cambio de nombre cancelado.", - "rename.inactive_callback": "La solicitud de cambio de nombre está inactiva", - "rename.inactive": - "⚠️ La solicitud de cambio de nombre no está activa. Ejecuta /rename otra vez.", - "rename.blocked.expected_name": - "⚠️ Introduce el nuevo nombre de la sesión como texto o toca Cancelar en el mensaje de cambio de nombre.", - "rename.blocked.command_not_allowed": - "⚠️ Este comando no está disponible mientras el cambio de nombre espera un nuevo nombre.", - "rename.button.cancel": "❌ Cancelar", - "task.prompt.schedule": "⏰ Envía el horario de la tarea en lenguaje natural.\n\nEjemplos:\n- cada 5 minutos\n- cada día a las 17:00\n- mañana a las 12:00", "task.schedule_empty": "⚠️ El horario no puede estar vacío.", @@ -571,8 +576,6 @@ export const es: I18nDictionary = { "mcps.button.back": "⬅️ Back", "mcps.auth_required": "This server requires authorization and cannot be enabled from the bot.", - "cmd.description.rename": "Renombrar la sesión actual", - "legacy.models.fetch_error": "🔴 No se pudo obtener la lista de modelos. Revisa el estado del servidor con /status.", "legacy.models.empty": "📋 No hay modelos disponibles. Configura los proveedores en OpenCode.", diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index 37735a3b..90a11aa9 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -173,6 +173,26 @@ export const fr: I18nDictionary = { "sessions.preview.title": "Messages récents :", "sessions.preview.you": "Vous :", "sessions.preview.agent": "Agent :", + "sessions.button.select": "✅ Sélectionner", + "sessions.button.rename": "✏️ Renommer", + "sessions.button.delete": "🗑 Supprimer", + "sessions.button.close": "✖ Fermer", + "sessions.current_session": "Déjà sélectionnée", + "sessions.rename.prompt": "📝 Entrez le nouveau titre :\n\nActuel : {title}", + "sessions.rename.cancel": "❌ Annuler", + "sessions.rename.empty": "⚠️ Le titre ne peut pas être vide.", + "sessions.rename.success": "✅ Session renommée en : {title}", + "sessions.rename.error": "🔴 Échec du renommage de la session.", + "sessions.delete.confirm": + '⚠️ Supprimer la session\n\n"{title}"\nTous les messages et l\'historique seront définitivement supprimés.', + "sessions.delete.yes": "✅ Oui, supprimer", + "sessions.delete.no": "❌ Non", + "sessions.delete.success": "✅ Session supprimée : {title}", + "sessions.delete.not_found": + "🔴 Session introuvable. Elle a peut-être déjà été supprimée.", + "sessions.delete.error": "🔴 Échec de la suppression de la session.", + "sessions.deleted_external": + "🗑 La session a été supprimée : {title}\nVous avez été détaché.", "messages.project_not_selected": "🏗 Aucun projet sélectionné.\n\nSélectionnez d'abord un projet avec /projects.", @@ -441,20 +461,6 @@ export const fr: I18nDictionary = { "runtime.wizard.tty_required": "L'assistant interactif nécessite un terminal TTY. Exécutez `opencode-telegram config` dans un shell interactif.", - "rename.no_session": "⚠️ Aucune session active. Créez ou sélectionnez d'abord une session.", - "rename.prompt": "📝 Entrez le nouveau titre de la session :\n\nActuel : {title}", - "rename.empty_title": "⚠️ Le titre ne peut pas être vide.", - "rename.success": "✅ Session renommée en : {title}", - "rename.error": "🔴 Impossible de renommer la session.", - "rename.cancelled": "❌ Renommage annulé.", - "rename.inactive_callback": "La demande de renommage est inactive", - "rename.inactive": "⚠️ La demande de renommage n'est pas active. Exécutez /rename à nouveau.", - "rename.blocked.expected_name": - "⚠️ Entrez le nouveau nom de la session sous forme de texte ou appuyez sur Annuler dans le message de renommage.", - "rename.blocked.command_not_allowed": - "⚠️ Cette commande n'est pas disponible tant que le renommage attend un nouveau nom.", - "rename.button.cancel": "❌ Annuler", - "task.prompt.schedule": "⏰ Envoyez le planning de la tâche en langage naturel.\n\nExemples :\n- toutes les 5 minutes\n- chaque jour à 17:00\n- demain à 12:00", "task.schedule_empty": "⚠️ Le planning ne peut pas être vide.", @@ -573,8 +579,6 @@ export const fr: I18nDictionary = { "mcps.button.back": "⬅️ Back", "mcps.auth_required": "This server requires authorization and cannot be enabled from the bot.", - "cmd.description.rename": "Renommer la session actuelle", - "legacy.models.fetch_error": "🔴 Impossible de récupérer la liste des modèles. Vérifiez l'état du serveur avec /status.", "legacy.models.empty": "📋 Aucun modèle disponible. Configurez les fournisseurs dans OpenCode.", diff --git a/src/i18n/ru.ts b/src/i18n/ru.ts index bebe2bd4..89290207 100644 --- a/src/i18n/ru.ts +++ b/src/i18n/ru.ts @@ -164,6 +164,25 @@ export const ru: I18nDictionary = { "sessions.preview.title": "Последние сообщения:", "sessions.preview.you": "Вы:", "sessions.preview.agent": "Агент:", + "sessions.button.select": "✅ Выбрать", + "sessions.button.rename": "✏️ Переименовать", + "sessions.button.delete": "🗑 Удалить", + "sessions.button.close": "✖ Закрыть", + "sessions.current_session": "Уже выбрана", + "sessions.rename.prompt": "📝 Введите новый заголовок:\n\nТекущий: {title}", + "sessions.rename.cancel": "❌ Отмена", + "sessions.rename.empty": "⚠️ Заголовок не может быть пустым.", + "sessions.rename.success": "✅ Сессия переименована в: {title}", + "sessions.rename.error": "🔴 Не удалось переименовать сессию.", + "sessions.delete.confirm": + '⚠️ Удалить сессию\n\n"{title}"\nВсе сообщения и история будут удалены безвозвратно.', + "sessions.delete.yes": "✅ Да, удалить", + "sessions.delete.no": "❌ Нет", + "sessions.delete.success": "✅ Сессия удалена: {title}", + "sessions.delete.not_found": + "🔴 Сессия не найдена. Возможно, она уже была удалена.", + "sessions.delete.error": "🔴 Не удалось удалить сессию.", + "sessions.deleted_external": "🗑 Сессия была удалена: {title}\nВы были отсоединены.", "messages.project_not_selected": "🏗 Проект не выбран.\n\nСначала выберите проект командой /projects.", @@ -423,20 +442,6 @@ export const ru: I18nDictionary = { "runtime.wizard.tty_required": "Интерактивный wizard требует TTY-терминал. Запустите `opencode-telegram config` в интерактивной оболочке.", - "rename.no_session": "⚠️ Нет активной сессии. Сначала создайте или выберите сессию.", - "rename.prompt": "📝 Введите новое название сессии:\n\nТекущее: {title}", - "rename.empty_title": "⚠️ Название не может быть пустым.", - "rename.success": "✅ Сессия переименована в: {title}", - "rename.error": "🔴 Не удалось переименовать сессию.", - "rename.cancelled": "❌ Переименование отменено.", - "rename.inactive_callback": "Запрос переименования неактивен", - "rename.inactive": "⚠️ Запрос переименования неактивен. Выполните /rename снова.", - "rename.blocked.expected_name": - "⚠️ Введите новое название текстом или нажмите Отмена в сообщении переименования.", - "rename.blocked.command_not_allowed": - "⚠️ Эта команда недоступна, пока ожидается новое название сессии.", - "rename.button.cancel": "❌ Отмена", - "task.prompt.schedule": "⏰ Отправьте расписание задачи обычным языком.\n\nПримеры:\n- каждые 5 минут\n- каждый день в 17:00\n- завтра в 12:00", "task.schedule_empty": "⚠️ Расписание не может быть пустым.", @@ -557,8 +562,6 @@ export const ru: I18nDictionary = { "mcps.button.back": "⬅️ Назад", "mcps.auth_required": "Этот сервер требует авторизации и не может быть включен из бота.", - "cmd.description.rename": "Переименовать текущую сессию", - "legacy.models.fetch_error": "🔴 Не удалось получить список моделей. Проверьте статус сервера /status.", "legacy.models.empty": "📋 Нет доступных моделей. Настройте провайдеры через OpenCode.", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 8a86099c..0c8317b5 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -147,6 +147,24 @@ export const zh: I18nDictionary = { "sessions.preview.title": "最近消息:", "sessions.preview.you": "你:", "sessions.preview.agent": "代理:", + "sessions.button.select": "✅ 选择", + "sessions.button.rename": "✏️ 重命名", + "sessions.button.delete": "🗑 删除", + "sessions.button.close": "✖ 关闭", + "sessions.current_session": "已选择", + "sessions.rename.prompt": "📝 输入新标题:\n\n当前:{title}", + "sessions.rename.cancel": "❌ 取消", + "sessions.rename.empty": "⚠️ 标题不能为空。", + "sessions.rename.success": "✅ 会话已重命名为:{title}", + "sessions.rename.error": "🔴 重命名会话失败。", + "sessions.delete.confirm": + '⚠️ 删除会话\n\n"{title}"\n所有消息和历史记录将被永久删除。', + "sessions.delete.yes": "✅ 是,删除", + "sessions.delete.no": "❌ 否", + "sessions.delete.success": "✅ 会话已删除:{title}", + "sessions.delete.not_found": "🔴 会话未找到。可能已被删除。", + "sessions.delete.error": "🔴 删除会话失败。", + "sessions.deleted_external": "🗑 会话已被删除:{title}\n您已自动分离。", "messages.project_not_selected": "🏗 未选择项目。\n\n请先使用 /projects 选择项目。", "messages.session_not_selected": @@ -381,18 +399,6 @@ export const zh: I18nDictionary = { "runtime.wizard.tty_required": "交互式向导需要 TTY 终端。请在交互式 shell 中运行 `opencode-telegram config`。", - "rename.no_session": "⚠️ 没有活动会话。请先创建或选择一个会话。", - "rename.prompt": "📝 请输入会话的新标题:\n\n当前:{title}", - "rename.empty_title": "⚠️ 标题不能为空。", - "rename.success": "✅ 会话已重命名为:{title}", - "rename.error": "🔴 重命名会话失败。", - "rename.cancelled": "❌ 重命名已取消。", - "rename.inactive_callback": "重命名请求已失效", - "rename.inactive": "⚠️ 重命名请求未激活。请再次运行 /rename。", - "rename.blocked.expected_name": "⚠️ 请以文本输入新会话名称,或在重命名消息中点击取消。", - "rename.blocked.command_not_allowed": "⚠️ 重命名等待新名称期间不可用此命令。", - "rename.button.cancel": "❌ 取消", - "task.prompt.schedule": "⏰ 请用自然语言发送任务的时间安排。\n\n示例:\n- 每 5 分钟\n- 每天 17:00\n- 明天 12:00", "task.schedule_empty": "⚠️ 时间安排不能为空。", @@ -501,8 +507,6 @@ export const zh: I18nDictionary = { "mcps.button.back": "⬅️ Back", "mcps.auth_required": "This server requires authorization and cannot be enabled from the bot.", - "cmd.description.rename": "重命名当前会话", - "legacy.models.fetch_error": "🔴 获取模型列表失败。请使用 /status 检查服务器状态。", "legacy.models.empty": "📋 没有可用模型。请在 OpenCode 中配置 providers。", "legacy.models.header": "📋 可用模型:\n\n", diff --git a/tests/app/managers/interaction-cleanup.test.ts b/tests/app/managers/interaction-cleanup.test.ts index 56cdd24b..d408505f 100644 --- a/tests/app/managers/interaction-cleanup.test.ts +++ b/tests/app/managers/interaction-cleanup.test.ts @@ -3,7 +3,6 @@ import { clearAllInteractionState } from "../../../src/app/managers/interaction- import { interactionManager } from "../../../src/app/managers/interaction-manager.js"; import { questionManager } from "../../../src/app/managers/question-manager.js"; import { permissionManager } from "../../../src/app/managers/permission-manager.js"; -import { renameManager } from "../../../src/app/managers/rename-manager.js"; import type { Question } from "../../../src/app/types/question.js"; import type { PermissionRequest } from "../../../src/app/types/permission.js"; @@ -33,9 +32,8 @@ describe("app/managers/interaction-cleanup", () => { it("clears all interaction-related managers", () => { questionManager.startQuestions([TEST_QUESTION], "req-1"); permissionManager.startPermission(TEST_PERMISSION, 101); - renameManager.startWaiting("session-1", "D:/repo", "Old title"); interactionManager.start({ - kind: "rename", + kind: "custom", expectedInput: "text", metadata: { sessionId: "session-1" }, }); @@ -44,7 +42,6 @@ describe("app/managers/interaction-cleanup", () => { expect(questionManager.isActive()).toBe(false); expect(permissionManager.isActive()).toBe(false); - expect(renameManager.isWaitingForName()).toBe(false); expect(interactionManager.getSnapshot()).toBeNull(); }); diff --git a/tests/app/managers/interaction-manager.test.ts b/tests/app/managers/interaction-manager.test.ts index 30eeed84..76b6de0e 100644 --- a/tests/app/managers/interaction-manager.test.ts +++ b/tests/app/managers/interaction-manager.test.ts @@ -37,7 +37,7 @@ describe("interactionManager", () => { it("transitions active interaction", () => { interactionManager.start({ - kind: "rename", + kind: "task", expectedInput: "text", metadata: { step: 1 }, }); diff --git a/tests/app/managers/rename-manager.test.ts b/tests/app/managers/rename-manager.test.ts deleted file mode 100644 index 795063b3..00000000 --- a/tests/app/managers/rename-manager.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, it, beforeEach } from "vitest"; -import { renameManager } from "../../../src/app/managers/rename-manager.js"; - -describe("renameManager", () => { - beforeEach(() => { - renameManager.clear(); - }); - - it("starts waiting for rename and tracks state", () => { - renameManager.startWaiting("session-123", "/path/to/project", "Old Title"); - - expect(renameManager.isWaitingForName()).toBe(true); - const info = renameManager.getSessionInfo(); - expect(info).toEqual({ - sessionId: "session-123", - directory: "/path/to/project", - currentTitle: "Old Title", - }); - }); - - it("tracks message ID for cleanup", () => { - renameManager.startWaiting("session-456", "/path", "Test"); - renameManager.setMessageId(42); - - expect(renameManager.getMessageId()).toBe(42); - expect(renameManager.isActiveMessage(42)).toBe(true); - expect(renameManager.isActiveMessage(99)).toBe(false); - }); - - it("clears state completely", () => { - renameManager.startWaiting("session-789", "/path", "Title"); - renameManager.setMessageId(100); - - renameManager.clear(); - - expect(renameManager.isWaitingForName()).toBe(false); - expect(renameManager.getSessionInfo()).toBeNull(); - expect(renameManager.getMessageId()).toBeNull(); - }); - - it("returns null session info when not waiting", () => { - expect(renameManager.isWaitingForName()).toBe(false); - expect(renameManager.getSessionInfo()).toBeNull(); - }); -}); diff --git a/tests/bot/callbacks/callback-router.test.ts b/tests/bot/callbacks/callback-router.test.ts index 84df0ca9..70e03e5d 100644 --- a/tests/bot/callbacks/callback-router.test.ts +++ b/tests/bot/callbacks/callback-router.test.ts @@ -17,7 +17,7 @@ const mocked = vi.hoisted(() => ({ handlePermissionCallback: vi.fn(), handleProjectSelect: vi.fn(), handleQuestionCallback: vi.fn(), - handleRenameCancel: vi.fn(), + handleRenameCancelCallback: vi.fn(), handleBackgroundSessionOpen: vi.fn(), handleSessionSelect: vi.fn(), handleSkillsCallback: vi.fn(), @@ -76,11 +76,9 @@ vi.mock("../../../src/bot/callbacks/project-callback-handler.js", () => ({ vi.mock("../../../src/bot/callbacks/question-callback-handler.js", () => ({ handleQuestionCallback: mocked.handleQuestionCallback, })); -vi.mock("../../../src/bot/callbacks/rename-callback-handler.js", () => ({ - handleRenameCancel: mocked.handleRenameCancel, -})); vi.mock("../../../src/bot/callbacks/session-callback-handler.js", () => ({ handleBackgroundSessionOpen: mocked.handleBackgroundSessionOpen, + handleRenameCancelCallback: mocked.handleRenameCancelCallback, handleSessionSelect: mocked.handleSessionSelect, })); vi.mock("../../../src/bot/callbacks/skills-catalog-callback-handler.js", () => ({ @@ -118,7 +116,7 @@ const allHandlers = [ mocked.handlePermissionCallback, mocked.handleProjectSelect, mocked.handleQuestionCallback, - mocked.handleRenameCancel, + mocked.handleRenameCancelCallback, mocked.handleBackgroundSessionOpen, mocked.handleSessionSelect, mocked.handleSkillsCallback, diff --git a/tests/bot/commands/abort.test.ts b/tests/bot/commands/abort.test.ts index 3497ff78..ee392781 100644 --- a/tests/bot/commands/abort.test.ts +++ b/tests/bot/commands/abort.test.ts @@ -4,7 +4,6 @@ import { abortCommand, abortCurrentOperation } from "../../../src/bot/commands/a import { clearAllInteractionState } from "../../../src/app/managers/interaction-manager.js"; import { questionManager } from "../../../src/app/managers/question-manager.js"; import { permissionManager } from "../../../src/app/managers/permission-manager.js"; -import { renameManager } from "../../../src/app/managers/rename-manager.js"; import { interactionManager } from "../../../src/app/managers/interaction-manager.js"; import { foregroundSessionState } from "../../../src/app/managers/foreground-session-state-manager.js"; import type { Question } from "../../../src/app/types/question.js"; @@ -72,9 +71,8 @@ const TEST_PERMISSION: PermissionRequest = { function activateInteractionState(): void { questionManager.startQuestions([TEST_QUESTION], "req-abort"); permissionManager.startPermission(TEST_PERMISSION, 101); - renameManager.startWaiting("session-1", "D:/repo", "Old title"); interactionManager.start({ - kind: "rename", + kind: "custom", expectedInput: "text", metadata: { sessionId: "session-1" }, }); @@ -118,7 +116,6 @@ describe("bot/commands/abort", () => { expect(replyMock).toHaveBeenCalledWith(t("stop.no_active_session")); expect(questionManager.isActive()).toBe(false); expect(permissionManager.isActive()).toBe(false); - expect(renameManager.isWaitingForName()).toBe(false); expect(interactionManager.getSnapshot()).toBeNull(); expect(mocked.abortMock).not.toHaveBeenCalled(); }); @@ -160,7 +157,6 @@ describe("bot/commands/abort", () => { expect(questionManager.isActive()).toBe(false); expect(permissionManager.isActive()).toBe(false); - expect(renameManager.isWaitingForName()).toBe(false); expect(interactionManager.getSnapshot()).toBeNull(); expectAbortStateReleased("abort_confirmed"); expect(shouldSuppressUserAbortSessionError("session-1", "Aborted")).toBe(true); @@ -234,7 +230,6 @@ describe("bot/commands/abort", () => { expect(questionManager.isActive()).toBe(false); expect(permissionManager.isActive()).toBe(false); - expect(renameManager.isWaitingForName()).toBe(false); expect(interactionManager.getSnapshot()).toBeNull(); expectAbortStateReleased("abort_confirmed"); }); diff --git a/tests/bot/commands/rename.test.ts b/tests/bot/commands/rename.test.ts deleted file mode 100644 index 2a7fb48a..00000000 --- a/tests/bot/commands/rename.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { Context } from "grammy"; -import { renameCommand } from "../../../src/bot/commands/rename-command.js"; -import { - handleRenameCancel, - handleRenameTextAnswer, -} from "../../../src/bot/callbacks/rename-callback-handler.js"; -import { renameManager } from "../../../src/app/managers/rename-manager.js"; -import { interactionManager } from "../../../src/app/managers/interaction-manager.js"; -import { t } from "../../../src/i18n/index.js"; - -const mocked = vi.hoisted(() => ({ - currentSession: { - id: "session-1", - title: "Old title", - directory: "D:/repo", - } as { id: string; title: string; directory: string } | null, - updateSessionMock: vi.fn(), - setCurrentSessionMock: vi.fn(), - pinnedOnSessionChangeMock: vi.fn(), -})); - -vi.mock("../../../src/opencode/client.js", () => ({ - opencodeClient: { - session: { - update: mocked.updateSessionMock, - }, - }, -})); - -vi.mock("../../../src/app/services/session-service.js", () => ({ - getCurrentSession: vi.fn(() => mocked.currentSession), - setCurrentSession: mocked.setCurrentSessionMock, -})); - -vi.mock("../../../src/bot/pinned/pinned-message-manager.js", () => ({ - pinnedMessageManager: { - isInitialized: vi.fn(() => false), - onSessionChange: mocked.pinnedOnSessionChangeMock, - }, -})); - -function createRenameCommandContext(messageId: number): Context { - return { - reply: vi.fn().mockResolvedValue({ message_id: messageId }), - } as unknown as Context; -} - -function createRenameTextContext(text: string): Context { - return { - chat: { id: 101 }, - message: { text } as Context["message"], - api: { - deleteMessage: vi.fn().mockResolvedValue(true), - }, - reply: vi.fn().mockResolvedValue(undefined), - } as unknown as Context; -} - -function createRenameCallbackContext(messageId: number): Context { - return { - callbackQuery: { - data: "rename:cancel", - message: { - message_id: messageId, - }, - } as Context["callbackQuery"], - answerCallbackQuery: vi.fn().mockResolvedValue(undefined), - editMessageText: vi.fn().mockResolvedValue(undefined), - } as unknown as Context; -} - -describe("bot/commands/rename", () => { - beforeEach(() => { - renameManager.clear(); - interactionManager.clear("test_setup"); - - mocked.currentSession = { - id: "session-1", - title: "Old title", - directory: "D:/repo", - }; - mocked.updateSessionMock.mockReset(); - mocked.updateSessionMock.mockResolvedValue({ - data: { id: "session-1", title: "New title" }, - error: null, - }); - mocked.setCurrentSessionMock.mockReset(); - mocked.pinnedOnSessionChangeMock.mockReset(); - mocked.pinnedOnSessionChangeMock.mockResolvedValue(undefined); - }); - - it("starts rename flow and interaction state", async () => { - const ctx = createRenameCommandContext(555); - - await renameCommand(ctx as never); - - expect(renameManager.isWaitingForName()).toBe(true); - expect(renameManager.getMessageId()).toBe(555); - - const interactionState = interactionManager.getSnapshot(); - expect(interactionState?.kind).toBe("rename"); - expect(interactionState?.expectedInput).toBe("text"); - expect(interactionState?.metadata.sessionId).toBe("session-1"); - expect(interactionState?.metadata.messageId).toBe(555); - }); - - it("renames session on valid text and clears states", async () => { - renameManager.startWaiting("session-1", "D:/repo", "Old title"); - renameManager.setMessageId(555); - interactionManager.start({ - kind: "rename", - expectedInput: "text", - metadata: { sessionId: "session-1", messageId: 555 }, - }); - - const ctx = createRenameTextContext(" New title "); - const handled = await handleRenameTextAnswer(ctx); - - expect(handled).toBe(true); - expect(mocked.updateSessionMock).toHaveBeenCalledWith({ - sessionID: "session-1", - directory: "D:/repo", - title: "New title", - }); - expect(mocked.setCurrentSessionMock).toHaveBeenCalledWith({ - id: "session-1", - title: "New title", - directory: "D:/repo", - }); - expect(ctx.api.deleteMessage).toHaveBeenCalledWith(101, 555); - expect(ctx.reply).toHaveBeenCalledWith(t("rename.success", { title: "New title" })); - expect(renameManager.isWaitingForName()).toBe(false); - expect(interactionManager.getSnapshot()).toBeNull(); - }); - - it("keeps rename flow active on empty title", async () => { - renameManager.startWaiting("session-1", "D:/repo", "Old title"); - renameManager.setMessageId(555); - interactionManager.start({ - kind: "rename", - expectedInput: "text", - metadata: { sessionId: "session-1", messageId: 555 }, - }); - - const ctx = createRenameTextContext(" "); - const handled = await handleRenameTextAnswer(ctx); - - expect(handled).toBe(true); - expect(ctx.reply).toHaveBeenCalledWith(t("rename.empty_title")); - expect(mocked.updateSessionMock).not.toHaveBeenCalled(); - expect(renameManager.isWaitingForName()).toBe(true); - expect(interactionManager.getSnapshot()?.kind).toBe("rename"); - }); - - it("rejects stale rename cancel callback", async () => { - renameManager.startWaiting("session-1", "D:/repo", "Old title"); - renameManager.setMessageId(555); - interactionManager.start({ - kind: "rename", - expectedInput: "text", - metadata: { sessionId: "session-1", messageId: 555 }, - }); - - const ctx = createRenameCallbackContext(999); - const handled = await handleRenameCancel(ctx); - - expect(handled).toBe(true); - expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ - text: t("rename.inactive_callback"), - show_alert: true, - }); - expect(renameManager.isWaitingForName()).toBe(true); - expect(interactionManager.getSnapshot()?.kind).toBe("rename"); - }); - - it("cancels active rename and clears states", async () => { - renameManager.startWaiting("session-1", "D:/repo", "Old title"); - renameManager.setMessageId(555); - interactionManager.start({ - kind: "rename", - expectedInput: "text", - metadata: { sessionId: "session-1", messageId: 555 }, - }); - - const ctx = createRenameCallbackContext(555); - const handled = await handleRenameCancel(ctx); - - expect(handled).toBe(true); - expect(ctx.answerCallbackQuery).toHaveBeenCalled(); - expect(ctx.editMessageText).toHaveBeenCalledWith(t("rename.cancelled")); - expect(renameManager.isWaitingForName()).toBe(false); - expect(interactionManager.getSnapshot()).toBeNull(); - }); - - it("clears stale rename manager state when interaction is missing", async () => { - renameManager.startWaiting("session-1", "D:/repo", "Old title"); - renameManager.setMessageId(555); - - const ctx = createRenameTextContext("New title"); - const handled = await handleRenameTextAnswer(ctx); - - expect(handled).toBe(true); - expect(ctx.reply).toHaveBeenCalledWith(t("rename.inactive")); - expect(mocked.updateSessionMock).not.toHaveBeenCalled(); - expect(renameManager.isWaitingForName()).toBe(false); - }); -}); diff --git a/tests/bot/middleware/interaction-guard-decision.test.ts b/tests/bot/middleware/interaction-guard-decision.test.ts index add56b2c..7a93dcda 100644 --- a/tests/bot/middleware/interaction-guard-decision.test.ts +++ b/tests/bot/middleware/interaction-guard-decision.test.ts @@ -169,7 +169,7 @@ describe("interaction guard", () => { it("blocks voice input when text input is expected", () => { interactionManager.start({ - kind: "rename", + kind: "task", expectedInput: "text", }); @@ -233,24 +233,9 @@ describe("interaction guard", () => { expect(decision.state?.kind).toBe("question"); }); - it("allows rename cancel callback when rename expects text", () => { + it("blocks non-cancel callback while task expects text", () => { interactionManager.start({ - kind: "rename", - expectedInput: "text", - }); - - const decision = resolveInteractionGuardDecision( - createContext({ callbackData: "rename:cancel" }), - ); - - expect(decision.allow).toBe(true); - expect(decision.inputType).toBe("callback"); - expect(decision.state?.kind).toBe("rename"); - }); - - it("blocks non-rename callback while rename expects text", () => { - interactionManager.start({ - kind: "rename", + kind: "task", expectedInput: "text", }); @@ -260,21 +245,7 @@ describe("interaction guard", () => { expect(decision.allow).toBe(false); expect(decision.reason).toBe("expected_text"); - expect(decision.state?.kind).toBe("rename"); - }); - - it("blocks photo input when text input is expected (rename)", () => { - interactionManager.start({ - kind: "rename", - expectedInput: "text", - }); - - const decision = resolveInteractionGuardDecision(createContext({ photo: true })); - - expect(decision.allow).toBe(false); - expect(decision.reason).toBe("expected_text"); - expect(decision.inputType).toBe("other"); - expect(decision.state?.kind).toBe("rename"); + expect(decision.state?.kind).toBe("task"); }); it("blocks photo input when mixed input is expected (question)", () => { @@ -375,15 +346,15 @@ describe("interaction guard", () => { expect(textDecision.busy).toBe(true); }); - it("does not allow rename callback to bypass busy state", () => { + it("does not allow task callback to bypass busy state", () => { foregroundSessionState.markBusy("session-1", "D:\\Projects\\Repo"); interactionManager.start({ - kind: "rename", + kind: "task", expectedInput: "text", }); const decision = resolveInteractionGuardDecision( - createContext({ callbackData: "rename:cancel" }), + createContext({ callbackData: "task:cancel" }), ); expect(decision.allow).toBe(false); diff --git a/tests/bot/middleware/interaction-guard.test.ts b/tests/bot/middleware/interaction-guard.test.ts index a6ad77b8..b1600633 100644 --- a/tests/bot/middleware/interaction-guard.test.ts +++ b/tests/bot/middleware/interaction-guard.test.ts @@ -74,7 +74,7 @@ describe("interactionGuardMiddleware", () => { it("blocks callback and answers callback query when text is expected", async () => { interactionManager.start({ - kind: "rename", + kind: "task", expectedInput: "text", }); @@ -85,7 +85,7 @@ describe("interactionGuardMiddleware", () => { expect(next).not.toHaveBeenCalled(); expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ - text: t("rename.blocked.expected_name"), + text: t("task.blocked.expected_input"), }); expect(ctx.reply).not.toHaveBeenCalled(); }); @@ -169,37 +169,6 @@ describe("interactionGuardMiddleware", () => { expect(ctx.reply).toHaveBeenCalledWith(t("permission.blocked.command_not_allowed")); }); - it("shows rename-specific message for disallowed command", async () => { - interactionManager.start({ - kind: "rename", - expectedInput: "text", - allowedCommands: ["/status"], - }); - - const ctx = createTextContext("/new"); - const next: NextFunction = vi.fn().mockResolvedValue(undefined); - - await interactionGuardMiddleware(ctx, next); - - expect(next).not.toHaveBeenCalled(); - expect(ctx.reply).toHaveBeenCalledWith(t("rename.blocked.command_not_allowed")); - }); - - it("blocks voice input while rename interaction expects text", async () => { - interactionManager.start({ - kind: "rename", - expectedInput: "text", - }); - - const ctx = createVoiceContext(); - const next: NextFunction = vi.fn().mockResolvedValue(undefined); - - await interactionGuardMiddleware(ctx, next); - - expect(next).not.toHaveBeenCalled(); - expect(ctx.reply).toHaveBeenCalledWith(t("rename.blocked.expected_name")); - }); - it("shows question-specific message for blocked text", async () => { interactionManager.start({ kind: "question", diff --git a/tests/helpers/reset-singleton-state.ts b/tests/helpers/reset-singleton-state.ts index c4f573b6..88e0a64d 100644 --- a/tests/helpers/reset-singleton-state.ts +++ b/tests/helpers/reset-singleton-state.ts @@ -48,7 +48,6 @@ export async function resetSingletonState(): Promise { const [ { questionManager }, { permissionManager }, - { renameManager }, { interactionManager }, { summaryAggregator }, { keyboardManager }, @@ -59,7 +58,6 @@ export async function resetSingletonState(): Promise { ] = await Promise.all([ import("../../src/app/managers/question-manager.js"), import("../../src/app/managers/permission-manager.js"), - import("../../src/app/managers/rename-manager.js"), import("../../src/app/managers/interaction-manager.js"), import("../../src/app/managers/summary-aggregation-manager.js"), import("../../src/bot/keyboards/keyboard-manager.js"), @@ -72,7 +70,6 @@ export async function resetSingletonState(): Promise { stopEventListening(); questionManager.clear(); permissionManager.clear(); - renameManager.clear(); interactionManager.clear("test_reset"); summaryAggregator.clear();