diff --git a/CHANGELOG.md b/CHANGELOG.md index 00e615c..e5e1008 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to this project will be documented in this file. +## TaskSync v3.0.10 (04-03-26) +- feat: add agent orchestration toggle, single-session routing mode, and always-returned session_id tool payloads +- fix: tighten the gap below the view toolbar, focus dialogs on open, and let TaskSync dialogs close on `Escape` + ## TaskSync v3.0.7 (03-28-26) - fix: remove deprecated wordWrap (redundant with overflowWrap) diff --git a/README.md b/README.md index 9f416ad..f110dc9 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ A dedicated VS Code sidebar extension with smart prompt queue system. _Setup ins **Features:** - Smart Queue Mode - batch responses for AI agents - Autopilot - let agents work autonomously with customizable auto-responses +- Agent orchestration toggle - switch between multi-session routing and a single-session lane per workspace - Remote Access - control from your phone via LAN or Tailscale, with PWA and code review - Give new tasks/feedback using ask_user tool - File, folder, tool, and context references with `#` autocomplete diff --git a/tasksync-chat/README.md b/tasksync-chat/README.md index 945a222..1adee15 100644 --- a/tasksync-chat/README.md +++ b/tasksync-chat/README.md @@ -69,6 +69,12 @@ Paste or drag-and-drop images directly into the chat input. Images are automatic - Access full history via the history button in the title bar - Remove individual entries or clear all history +### Agent Orchestration +Choose how TaskSync manages sessions: +- **Enabled by default**: keep separate agent sessions, the sessions list, session switching, and split view +- **Disabled**: stay in one single-session lane; TaskSync hides the sessions list, turns off split view, and routes every `ask_user` call into the active TaskSync session +- Turning it off hides extra sessions. It does not delete them. Turn it back on to see them again. + ### Auto Append Two features for appending text to every `ask_user` response: @@ -224,7 +230,10 @@ In VS Code Settings (search "tasksync"): **Debug:** - `tasksync.debugLogging`: Verbose extension debug logging (default: false) -All other settings (Autopilot, timeout, human-like delay, sound, etc.) are managed through the TaskSync Settings modal (gear icon). +**Session Model:** +- `tasksync.agentOrchestration`: Keep separate TaskSync agent sessions, the sessions list, switching, and split view. Turn it off to force single-session routing in the current workspace. (default: true) + +Most other settings (Autopilot, timeout, human-like delay, sound, etc.) are managed through the TaskSync Settings modal (gear icon). ## Requirements diff --git a/tasksync-chat/media/main.css b/tasksync-chat/media/main.css index 8846fac..78650c1 100644 --- a/tasksync-chat/media/main.css +++ b/tasksync-chat/media/main.css @@ -297,7 +297,7 @@ body { align-items: center; justify-content: space-between; gap: 8px; - padding: 10px 12px; + padding: 6px 12px; border-bottom: 1px solid var(--vscode-panel-border); } @@ -3206,6 +3206,20 @@ button#send-btn:disabled { box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); } +.settings-modal-overlay:focus, +.settings-modal-overlay:focus-visible, +.history-modal-overlay:focus, +.history-modal-overlay:focus-visible { + outline: none; +} + +.settings-modal:focus, +.settings-modal:focus-visible, +.history-modal:focus, +.history-modal:focus-visible { + outline: none; +} + .settings-modal-header { display: flex; align-items: center; @@ -3710,10 +3724,14 @@ button#send-btn:disabled { appearance: none; -webkit-appearance: none; -moz-appearance: none; - background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='%23888' d='M4.5 5.5L8 9l3.5-3.5z'/%3E%3C/svg%3E"); + background-image: + linear-gradient(45deg, transparent 50%, #888 50%), + linear-gradient(135deg, #888 50%, transparent 50%); background-repeat: no-repeat; - background-position: right 8px center; - background-size: 12px; + background-position: + calc(100% - 13px) calc(50% - 2px), + calc(100% - 8px) calc(50% - 2px); + background-size: 6px 6px, 6px 6px; padding-right: 28px; cursor: pointer; width: 100%; diff --git a/tasksync-chat/media/webview.js b/tasksync-chat/media/webview.js index 1dcf50c..942c141 100644 --- a/tasksync-chat/media/webview.js +++ b/tasksync-chat/media/webview.js @@ -293,6 +293,16 @@ function mapToRemoteMessage(msg) { return null; // Multi-session operations — forward to server as-is case "switchSession": + if (!agentOrchestrationEnabled) { + if (typeof syncClientSessionSelection === "function") { + syncClientSessionSelection( + serverActiveSessionId || activeSessionId || null, + ); + } + renderSessionsList(); + updateWelcomeSectionVisibility(); + return null; + } if (!msg.sessionId) { // Back to hub — handle locally, no server round-trip needed if (typeof saveActiveSessionComposerState === "function") { @@ -741,6 +751,17 @@ function applySettingsData(s) { queueEnabled = s.queueEnabled; updateQueueVisibility(); } + if (s.agentOrchestrationEnabled !== undefined) { + agentOrchestrationEnabled = s.agentOrchestrationEnabled; + if (!agentOrchestrationEnabled) { + splitViewEnabled = false; + if (typeof syncClientSessionSelection === "function") { + syncClientSessionSelection( + serverActiveSessionId || activeSessionId || null, + ); + } + } + } if (s.autoAppendEnabled !== undefined) { autoAppendEnabled = s.autoAppendEnabled; } @@ -889,6 +910,7 @@ function updatePendingUI() { function applySettingsToUI() { updateSoundToggleUI(); updateInteractiveApprovalToggleUI(); + updateAgentOrchestrationToggleUI(); updateAutoAppendToggleUI(); updateAutoAppendTextUI(); updateSendWithCtrlEnterToggleUI(); @@ -901,6 +923,8 @@ function applySettingsToUI() { workspacePromptListUI.render(); renderPromptsList(); updateQueueVisibility(); + renderSessionsList(); + updateWelcomeSectionVisibility(); } // ==================== End Communication Adapter ==================== @@ -1015,6 +1039,7 @@ let lastPendingContentHtml = ""; // Settings state (initialized from constants to maintain SSOT) let soundEnabled = true; let interactiveApprovalEnabled = true; +let agentOrchestrationEnabled = true; let autoAppendEnabled = false; let autoAppendText = ""; // Custom text appended to responses for the active session let alwaysAppendReminder = false; // Global AskUser reminder toggle @@ -1116,6 +1141,7 @@ let simpleAlertModalOverlay = null; let settingsModal, settingsModalOverlay, settingsModalClose; let soundToggle, interactiveApprovalToggle, + agentOrchestrationToggle, autoAppendToggle, autoAppendTextRow, autoAppendTextInput, @@ -1161,6 +1187,45 @@ function sessionExists(sessionId) { ); } +function resolveSingleSessionId() { + if (sessionExists(serverActiveSessionId)) { + return serverActiveSessionId; + } + if (sessionExists(activeSessionId)) { + return activeSessionId; + } + var fallbackSession = Array.isArray(sessions) + ? sessions.find(function (session) { + return session.status === "active"; + }) + : null; + return fallbackSession ? fallbackSession.id : null; +} + +function getVisibleSessions() { + if (agentOrchestrationEnabled) { + return Array.isArray(sessions) ? sessions : []; + } + var singletonSessionId = resolveSingleSessionId(); + if (!singletonSessionId) { + return []; + } + return sessions.filter(function (session) { + return session.id === singletonSessionId; + }); +} + +function getWaitingActiveSessions() { + return Array.isArray(sessions) + ? sessions.filter(function (session) { + return ( + session.status === "active" && + (session.waitingOnUser || !!session.pendingToolCallId) + ); + }) + : []; +} + function requestFollowServerActiveSession() { followServerActiveSessionOnce = true; } @@ -1168,6 +1233,12 @@ function requestFollowServerActiveSession() { function syncClientSessionSelection(nextServerActiveSessionId) { serverActiveSessionId = nextServerActiveSessionId || null; + if (!agentOrchestrationEnabled) { + activeSessionId = resolveSingleSessionId(); + followServerActiveSessionOnce = false; + return; + } + if (!sessionExists(activeSessionId)) { activeSessionId = null; } @@ -1196,7 +1267,11 @@ function getSubmitSessionId() { } function isSplitViewLayoutActive() { - return splitViewEnabled && sessionExists(activeSessionId); + return ( + agentOrchestrationEnabled && + splitViewEnabled && + sessionExists(activeSessionId) + ); } function init() { try { @@ -1210,6 +1285,7 @@ function init() { initSessionPromptListUI(); createNewSessionModal(); createResetSessionModal(); + createDisableAgentOrchestrationModal(); createTimeoutWarningModal(); createSimpleAlertModal(); bindEventListeners(); @@ -1411,6 +1487,7 @@ function createHistoryModal() { historyModal.setAttribute("role", "dialog"); historyModal.setAttribute("aria-modal", "true"); historyModal.setAttribute("aria-label", "Session History"); + historyModal.tabIndex = -1; // Modal header let modalHeader = document.createElement("div"); @@ -1555,6 +1632,7 @@ function createSettingsModal() { settingsModal.id = "settings-modal"; settingsModal.setAttribute("role", "dialog"); settingsModal.setAttribute("aria-labelledby", "settings-modal-title"); + settingsModal.tabIndex = -1; // Modal header let modalHeader = document.createElement("div"); @@ -1618,6 +1696,20 @@ function createSettingsModal() { ""; modalContent.appendChild(approvalSection); + // Agent orchestration section - toggle between multi-session and single-session mode + let agentOrchestrationSection = document.createElement("div"); + agentOrchestrationSection.className = "settings-section"; + agentOrchestrationSection.innerHTML = + '
' + + '
' + + ' Agent Orchestration' + + '' + + '' + + "
" + + '
' + + "
"; + modalContent.appendChild(agentOrchestrationSection); + // Send shortcut section - switch between Enter and Ctrl/Cmd+Enter send let sendShortcutSection = document.createElement("div"); sendShortcutSection.className = "settings-section"; @@ -1805,6 +1897,9 @@ function createSettingsModal() { interactiveApprovalToggle = document.getElementById( "interactive-approval-toggle", ); + agentOrchestrationToggle = document.getElementById( + "agent-orchestration-toggle", + ); alwaysAppendReminderToggle = document.getElementById( "always-append-reminder-toggle", ); @@ -1839,6 +1934,7 @@ function createSessionSettingsModal() { "aria-labelledby", "session-settings-title", ); + sessionSettingsModal.tabIndex = -1; // Modal header var ssHeader = document.createElement("div"); @@ -1962,6 +2058,7 @@ function createSessionSettingsModal() { var newSessionModalOverlay = null; var resetSessionModalOverlay = null; +var disableAgentOrchestrationModalOverlay = null; function createSessionActionModal(config) { var overlay = document.createElement("div"); @@ -1972,6 +2069,7 @@ function createSessionActionModal(config) { modal.className = "settings-modal new-session-modal"; modal.setAttribute("role", "dialog"); modal.setAttribute("aria-labelledby", config.titleId); + modal.tabIndex = -1; var header = document.createElement("div"); header.className = "settings-modal-header"; @@ -2047,6 +2145,7 @@ function createSessionActionModal(config) { modal.appendChild(content); overlay.appendChild(modal); document.body.appendChild(overlay); + overlay.__taskSyncInitialFocusSelector = config.initialFocusSelector || null; overlay.addEventListener("click", function (e) { if (e.target === overlay) closeSessionActionModal(overlay); @@ -2058,11 +2157,13 @@ function createSessionActionModal(config) { function openSessionActionModal(overlay) { if (!overlay) return; overlay.classList.remove("hidden"); + focusDialogSurface(overlay, overlay.__taskSyncInitialFocusSelector); } function closeSessionActionModal(overlay) { if (!overlay) return; overlay.classList.add("hidden"); + restoreDialogFocus(overlay); } function createNewSessionModal() { @@ -2102,6 +2203,7 @@ function createNewSessionModal() { warningText: "Start a fresh Copilot chat, or end the current session and start a fresh one.", extraContent: extra, + initialFocusSelector: "#new-session-prompt", actions: [ { label: "New Session", @@ -2152,7 +2254,10 @@ function openNewSessionModal() { sessions.find(function (s) { return s.id === activeSessionId; }); - var hasActiveSession = !!activeSession && !activeSession.sessionTerminated; + var hasActiveSession = + agentOrchestrationEnabled && + !!activeSession && + !activeSession.sessionTerminated; if (endBtn) { endBtn.classList.toggle("hidden", !hasActiveSession); } @@ -2161,7 +2266,9 @@ function openNewSessionModal() { if (warningEl) { warningEl.textContent = hasActiveSession ? "Start a fresh Copilot chat, or end the current session and start a fresh one." - : "Start a fresh Copilot chat session."; + : agentOrchestrationEnabled + ? "Start a fresh Copilot chat session." + : "Start a fresh Copilot chat using the current TaskSync session."; } // Refresh queue checkbox visibility and label based on current queue state var queueRow = document.getElementById("new-session-queue-row"); @@ -2194,10 +2301,51 @@ function createResetSessionModal() { warningText: "This will clear the current session history without starting a fresh Copilot chat.", confirmLabel: "Reset Session", + initialFocusSelector: ".form-btn-save", messageType: "resetSession", }); } +function createDisableAgentOrchestrationModal() { + disableAgentOrchestrationModalOverlay = createSessionActionModal({ + overlayId: "disable-agent-orchestration-modal-overlay", + titleId: "disable-agent-orchestration-modal-title", + title: "Turn Off Agent Orchestration", + warningText: "", + initialFocusSelector: ".form-btn-cancel", + actions: [ + { + label: "Cancel", + className: "form-btn form-btn-cancel", + }, + { + id: "disable-agent-orchestration-confirm-btn", + label: "Stop current session(s) and turn off Agent Orchestration", + className: "form-btn form-btn-danger", + onClick: stopSessionsAndDisableAgentOrchestration, + }, + ], + }); +} + +function openStopSessionsAndDisableAgentOrchestrationModal(waitingSessions) { + if (!disableAgentOrchestrationModalOverlay) { + showAgentOrchestrationDisableAlert(waitingSessions); + return; + } + var warningEl = disableAgentOrchestrationModalOverlay.querySelector( + ".new-session-warning", + ); + if (warningEl) { + warningEl.textContent = + waitingSessions.length === 1 + ? "1 session is still waiting on you. Stopping it will cancel that pending ask_user and then turn Agent Orchestration off." + : waitingSessions.length + + " sessions are still waiting on you. Stopping them will cancel those pending ask_user calls and then turn Agent Orchestration off."; + } + openSessionActionModal(disableAgentOrchestrationModalOverlay); +} + function openResetSessionModal() { openSessionActionModal(resetSessionModalOverlay); } @@ -2351,10 +2499,7 @@ function showTimeoutWarning(value) { } timeoutWarningModalOverlay.classList.remove("hidden"); - - // Focus the cancel button for accessibility - var cancelBtn = document.getElementById("timeout-warning-cancel-btn"); - if (cancelBtn) cancelBtn.focus(); + focusDialogSurface(timeoutWarningModalOverlay, "#timeout-warning-cancel-btn"); } function cancelTimeoutWarning() { @@ -2362,6 +2507,7 @@ function cancelTimeoutWarning() { if (timeoutWarningModalOverlay) { timeoutWarningModalOverlay.classList.add("hidden"); } + restoreDialogFocus(timeoutWarningModalOverlay); // Revert dropdown to current value and restore focus if (responseTimeoutSelect) { responseTimeoutSelect.value = String(responseTimeout); @@ -2381,6 +2527,7 @@ function confirmTimeoutWarning() { if (timeoutWarningModalOverlay) { timeoutWarningModalOverlay.classList.add("hidden"); } + restoreDialogFocus(timeoutWarningModalOverlay); // Restore focus to dropdown if (responseTimeoutSelect) { responseTimeoutSelect.focus(); @@ -2476,19 +2623,228 @@ function showSimpleAlert(title, message, iconClass) { } simpleAlertModalOverlay.classList.remove("hidden"); - - // Focus the OK button for keyboard accessibility - var okBtn = document.getElementById("simple-alert-ok-btn"); - if (okBtn) okBtn.focus(); + focusDialogSurface(simpleAlertModalOverlay, "#simple-alert-ok-btn"); } function closeSimpleAlert() { if (simpleAlertModalOverlay) { simpleAlertModalOverlay.classList.add("hidden"); } + restoreDialogFocus(simpleAlertModalOverlay); } // ==================== Event Listeners ==================== +/** + * Keep Escape handling centralized so dialogs created in different files close the same way. + */ +function isOverlayVisible(overlay) { + return !!( + overlay && + overlay.classList && + typeof overlay.classList.contains === "function" && + !overlay.classList.contains("hidden") + ); +} + +/** + * Move keyboard focus into an opened dialog so keyboard shortcuts work immediately. + */ +function clearPendingDialogFocus(overlay) { + if (!overlay || overlay.__tasksyncFocusTimer == null) return; + clearTimeout(overlay.__tasksyncFocusTimer); + overlay.__tasksyncFocusTimer = null; +} + +/** + * Resolve the best focus target inside an open dialog. + */ +function resolveDialogFocusTarget(overlay, preferredSelector) { + if (!overlay) return null; + + var target = null; + if (preferredSelector && typeof overlay.querySelector === "function") { + target = overlay.querySelector(preferredSelector); + } + if (!target && typeof overlay.querySelector === "function") { + target = overlay.querySelector( + 'textarea:not([disabled]), input:not([disabled]), select:not([disabled]), button:not([disabled]), [role="switch"][tabindex], [tabindex]:not([tabindex="-1"])', + ); + } + if (!target && typeof overlay.querySelector === "function") { + target = overlay.querySelector('[role="dialog"], [role="alertdialog"]'); + } + if (!target && typeof overlay.focus === "function") { + target = overlay; + } + + return target; +} + +/** + * Avoid restoring focus to toolbar-style opener buttons because their visible focus ring looks like a stale selection. + */ +function shouldRestoreDialogFocusTarget(target) { + if (!target || typeof target.focus !== "function") return false; + + var tagName = + typeof target.tagName === "string" ? target.tagName.toUpperCase() : ""; + if (tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT") { + return true; + } + if (target.isContentEditable) { + return true; + } + + var role = + typeof target.getAttribute === "function" + ? target.getAttribute("role") + : null; + if (role === "button" || role === "switch") { + return false; + } + + if (tagName === "BUTTON") { + return false; + } + + var classList = target.classList; + if ( + classList && + typeof classList.contains === "function" && + (classList.contains("icon-btn") || + classList.contains("remote-btn") || + classList.contains("settings-modal-header-btn")) + ) { + return false; + } + + return true; +} + +/** + * Keep only the currently opened dialog eligible for deferred focus. + */ +function focusDialogSurface(overlay, preferredSelector) { + if (!overlay) return; + clearPendingDialogFocus(overlay); + + if ( + typeof document !== "undefined" && + document.activeElement && + document.activeElement !== document.body && + document.activeElement !== overlay && + (!overlay.contains || !overlay.contains(document.activeElement)) + ) { + overlay.__tasksyncReturnFocus = document.activeElement; + } + + overlay.__tasksyncFocusTimer = setTimeout(function () { + overlay.__tasksyncFocusTimer = null; + if (!isOverlayVisible(overlay)) return; + + var target = resolveDialogFocusTarget(overlay, preferredSelector); + if (target && typeof target.focus === "function") { + target.focus(); + } + }, 0); +} + +/** + * Return keyboard focus after a dialog closes so the user can continue without an extra click. + */ +function restoreDialogFocus(overlay) { + if (!overlay) return; + clearPendingDialogFocus(overlay); + + var target = overlay.__tasksyncReturnFocus; + overlay.__tasksyncReturnFocus = null; + + if ( + shouldRestoreDialogFocusTarget(target) && + target && + typeof target.focus === "function" && + typeof document !== "undefined" && + typeof document.contains === "function" && + document.contains(target) + ) { + target.focus(); + return; + } + + if (chatInput && typeof chatInput.focus === "function") { + chatInput.focus(); + } +} + +/** + * Close only the topmost visible dialog so Escape never dismisses multiple layers at once. + */ +function handleGlobalDocumentKeydown(e) { + if (e.defaultPrevented || e.key !== "Escape") return; + + if (isOverlayVisible(simpleAlertModalOverlay)) { + e.preventDefault(); + e.stopPropagation(); + closeSimpleAlert(); + return; + } + + if (isOverlayVisible(timeoutWarningModalOverlay)) { + e.preventDefault(); + e.stopPropagation(); + cancelTimeoutWarning(); + return; + } + + if (isOverlayVisible(disableAgentOrchestrationModalOverlay)) { + e.preventDefault(); + e.stopPropagation(); + closeSessionActionModal(disableAgentOrchestrationModalOverlay); + return; + } + + if (isOverlayVisible(resetSessionModalOverlay)) { + e.preventDefault(); + e.stopPropagation(); + closeSessionActionModal(resetSessionModalOverlay); + return; + } + + if (isOverlayVisible(newSessionModalOverlay)) { + e.preventDefault(); + e.stopPropagation(); + closeSessionActionModal(newSessionModalOverlay); + return; + } + + if (isOverlayVisible(sessionSettingsOverlay)) { + e.preventDefault(); + e.stopPropagation(); + closeSessionSettingsModal(); + return; + } + + if (isOverlayVisible(settingsModalOverlay)) { + e.preventDefault(); + e.stopPropagation(); + closeSettingsModal(); + return; + } + + if (isOverlayVisible(historyModalOverlay)) { + e.preventDefault(); + e.stopPropagation(); + closeHistoryModal(); + return; + } + + if (isOverlayVisible(changesModalOverlay)) { + e.preventDefault(); + e.stopPropagation(); + toggleChangesPanel(false); + } +} + function bindEventListeners() { if (chatInput) { chatInput.addEventListener("input", handleTextareaInput); @@ -2560,6 +2916,7 @@ function bindEventListeners() { document.addEventListener("contextmenu", handleContextMenu); // Intercept Copy when nothing is selected and copy clicked message text as-is. document.addEventListener("copy", handleCopy); + document.addEventListener("keydown", handleGlobalDocumentKeydown); if (queueHeader) queueHeader.addEventListener("click", handleQueueHeaderClick); @@ -2576,6 +2933,7 @@ function bindEventListeners() { // Hub & Thread Shell events if (threadBackBtn) { threadBackBtn.addEventListener("click", function () { + if (!agentOrchestrationEnabled) return; saveActiveSessionComposerState(); activeSessionId = null; restoreActiveSessionComposerState(); @@ -2594,6 +2952,7 @@ function bindEventListeners() { var threadEditBtn = document.getElementById("thread-edit-btn"); if (threadEditBtn) { threadEditBtn.addEventListener("click", function () { + if (!agentOrchestrationEnabled) return; var titleEl = document.getElementById("thread-title"); if (!titleEl || !activeSessionId) return; var currentTitle = titleEl.textContent || ""; @@ -2682,6 +3041,18 @@ function bindEventListeners() { } }); } + if (agentOrchestrationToggle) { + agentOrchestrationToggle.addEventListener( + "click", + toggleAgentOrchestrationSetting, + ); + agentOrchestrationToggle.addEventListener("keydown", function (e) { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleAgentOrchestrationSetting(); + } + }); + } if (autoAppendToggle) { autoAppendToggle.addEventListener("click", toggleAutoAppendSetting); autoAppendToggle.addEventListener("keydown", function (e) { @@ -2865,11 +3236,13 @@ function openHistoryModal() { } historyModalOverlay.classList.remove("hidden"); + focusDialogSurface(historyModalOverlay, "#history-modal"); } function closeHistoryModal() { if (!historyModalOverlay) return; historyModalOverlay.classList.add("hidden"); + restoreDialogFocus(historyModalOverlay); } function clearAllPersistedHistory() { @@ -3593,6 +3966,15 @@ function handleExtensionMessage(event) { case "updateSettings": soundEnabled = message.soundEnabled !== false; interactiveApprovalEnabled = message.interactiveApprovalEnabled !== false; + agentOrchestrationEnabled = message.agentOrchestrationEnabled !== false; + if (!agentOrchestrationEnabled) { + splitViewEnabled = false; + if (typeof syncClientSessionSelection === "function") { + syncClientSessionSelection( + serverActiveSessionId || activeSessionId || null, + ); + } + } autoAppendEnabled = message.autoAppendEnabled === true; autoAppendText = typeof message.autoAppendText === "string" @@ -3635,6 +4017,7 @@ function handleExtensionMessage(event) { : DEFAULT_HUMAN_DELAY_MAX; updateSoundToggleUI(); updateInteractiveApprovalToggleUI(); + updateAgentOrchestrationToggleUI(); updateAutoAppendToggleUI(); updateAutoAppendTextUI(); updateAlwaysAppendReminderToggleUI(); @@ -3647,6 +4030,8 @@ function handleExtensionMessage(event) { updateRemoteMaxDevicesUI(); updateHumanDelayUI(); renderPromptsList(); + renderSessionsList(); + updateWelcomeSectionVisibility(); break; case "slashCommandResults": showSlashDropdown(message.prompts || []); @@ -4551,6 +4936,13 @@ function renderMermaidDiagrams() { * On narrow viewports (<= 480 px) CSS flips this to vertical automatically. */ function toggleSplitView() { + if (!agentOrchestrationEnabled) { + splitViewEnabled = false; + syncSplitViewLayout(); + updateWelcomeSectionVisibility(); + saveWebviewState(); + return; + } splitViewEnabled = !splitViewEnabled; syncSplitViewLayout(); updateWelcomeSectionVisibility(); @@ -4706,6 +5098,16 @@ function syncHiddenListUnreadIndicators() { var backBtn = document.getElementById("thread-back-btn"); var collapseBar = document.getElementById("sessions-collapse-bar"); var hubEl = document.getElementById("workspace-hub"); + if (!agentOrchestrationEnabled) { + if (backBtn) { + backBtn.classList.remove("has-unread-indicator"); + } + if (collapseBar) { + collapseBar.classList.remove("has-unread-indicator"); + collapseBar.classList.add("hidden"); + } + return; + } var hasOpenSession = !!activeSessionId; var hasUnreadOtherSession = (sessions || []).some(function (session) { return session.id !== activeSessionId && session.unread === true; @@ -4755,7 +5157,8 @@ function syncSplitViewLayout() { resizer.classList.toggle("hidden", !effectiveSplitView); } if (remoteSplitBtn) { - remoteSplitBtn.classList.toggle("active", splitViewEnabled); + remoteSplitBtn.classList.toggle("hidden", !agentOrchestrationEnabled); + remoteSplitBtn.classList.toggle("active", effectiveSplitView); } if (effectiveSplitView) { @@ -4787,6 +5190,19 @@ function updateWelcomeSectionVisibility() { var placeholderEl = document.getElementById("split-placeholder"); var threadHeadEl = document.getElementById("thread-head"); var composerEl = document.getElementById("input-area-container"); + var threadTitle = document.getElementById("thread-title"); + var backBtn = document.getElementById("thread-back-btn"); + var editBtn = document.getElementById("thread-edit-btn"); + + if (threadTitle) { + threadTitle.classList.toggle("hidden", !agentOrchestrationEnabled); + } + if (backBtn) { + backBtn.classList.toggle("hidden", !agentOrchestrationEnabled); + } + if (editBtn) { + editBtn.classList.toggle("hidden", !agentOrchestrationEnabled); + } syncSplitViewLayout(); @@ -4803,7 +5219,6 @@ function updateWelcomeSectionVisibility() { var activeSession = (sessions || []).find(function (s) { return s.id === activeSessionId; }); - var threadTitle = document.getElementById("thread-title"); if (activeSession && threadTitle) { threadTitle.textContent = activeSession.title; } @@ -4830,7 +5245,6 @@ function updateWelcomeSectionVisibility() { var activeSession = (sessions || []).find(function (s) { return s.id === activeSessionId; }); - var threadTitle = document.getElementById("thread-title"); if (activeSession) { if (threadTitle) threadTitle.textContent = activeSession.title; } @@ -4860,16 +5274,29 @@ function scrollToBottom() { function renderSessionsList() { var sessionsListEl = document.getElementById("sessions-list"); var sessionsPanelEl = document.getElementById("sessions-panel"); + var visibleSessions = getVisibleSessions(); if (!sessionsListEl) return; // Update collapse bar session count var countEl = document.getElementById("sessions-collapse-count"); if (countEl) { countEl.textContent = - sessions.length > 0 ? "(" + sessions.length + ")" : ""; + agentOrchestrationEnabled && visibleSessions.length > 0 + ? "(" + visibleSessions.length + ")" + : ""; } - if (!sessions || sessions.length === 0) { + if (!agentOrchestrationEnabled) { + sessionsListEl.innerHTML = ""; + if (sessionsPanelEl) sessionsPanelEl.classList.add("hidden"); + if (welcomeSection) { + welcomeSection.classList.toggle("hidden", visibleSessions.length > 0); + } + syncHiddenListUnreadIndicators(); + return; + } + + if (!visibleSessions || visibleSessions.length === 0) { sessionsListEl.innerHTML = ""; if (sessionsPanelEl) sessionsPanelEl.classList.add("hidden"); if (welcomeSection) welcomeSection.classList.remove("hidden"); @@ -4881,7 +5308,7 @@ function renderSessionsList() { if (welcomeSection) welcomeSection.classList.add("hidden"); // Sort: active sessions first (newest first), then archived - var sorted = sessions.slice().sort(function (a, b) { + var sorted = visibleSessions.slice().sort(function (a, b) { if (a.status !== b.status) { return a.status === "active" ? -1 : 1; } @@ -5750,14 +6177,17 @@ function createPromptListUI(opts) { function openSettingsModal() { if (!settingsModalOverlay) return; - vscode.postMessage({ type: "openSettingsModal" }); + // Keep modal opening local so a stale settings refresh cannot override + // a user's first orchestration toggle while the modal is already opening. settingsModalOverlay.classList.remove("hidden"); + focusDialogSurface(settingsModalOverlay, "#settings-modal"); } function closeSettingsModal() { if (!settingsModalOverlay) return; settingsModalOverlay.classList.add("hidden"); hideAddPromptForm(); + restoreDialogFocus(settingsModalOverlay); } function toggleSoundSetting() { @@ -5783,6 +6213,66 @@ function updateInteractiveApprovalToggleUI() { setToggle(interactiveApprovalToggle, interactiveApprovalEnabled); } +function showAgentOrchestrationDisableAlert(waitingSessions) { + var message = + waitingSessions.length === 1 + ? "There is still 1 session waiting on you." + : "There are still " + + waitingSessions.length + + " sessions waiting on you."; + showSimpleAlert( + "Keep Agent Orchestration On", + message + + " Reply to them or stop those sessions before turning Agent Orchestration off.", + "codicon-warning", + ); +} + +function stopSessionsAndDisableAgentOrchestration() { + vscode.postMessage({ type: "disableAgentOrchestrationAndStopSessions" }); +} + +function toggleAgentOrchestrationSetting() { + if (agentOrchestrationEnabled) { + var waitingSessions = + typeof getWaitingActiveSessions === "function" + ? getWaitingActiveSessions() + : []; + if (waitingSessions.length > 1) { + if ( + typeof openStopSessionsAndDisableAgentOrchestrationModal === "function" + ) { + openStopSessionsAndDisableAgentOrchestrationModal(waitingSessions); + } else { + showAgentOrchestrationDisableAlert(waitingSessions); + } + return; + } + } + agentOrchestrationEnabled = !agentOrchestrationEnabled; + if (!agentOrchestrationEnabled) { + splitViewEnabled = false; + } + if (typeof syncClientSessionSelection === "function") { + syncClientSessionSelection( + serverActiveSessionId || activeSessionId || null, + ); + } + updateAgentOrchestrationToggleUI(); + renderSessionsList(); + updateWelcomeSectionVisibility(); + saveWebviewState(); + vscode.postMessage({ + type: "updateAgentOrchestrationSetting", + enabled: agentOrchestrationEnabled, + }); +} + +function updateAgentOrchestrationToggleUI() { + if (!agentOrchestrationToggle) return; + setToggle(agentOrchestrationToggle, agentOrchestrationEnabled); +} + function toggleAutoAppendSetting() { autoAppendEnabled = !autoAppendEnabled; updateAutoAppendToggleUI(); @@ -6264,6 +6754,7 @@ function openSessionSettingsModal() { if (!sessionSettingsOverlay) return; vscode.postMessage({ type: "requestSessionSettings" }); sessionSettingsOverlay.classList.remove("hidden"); + focusDialogSurface(sessionSettingsOverlay, "#ss-close-btn"); } function closeSessionSettingsModal() { @@ -6272,6 +6763,7 @@ function closeSessionSettingsModal() { saveSessionSettings(); sessionSettingsOverlay.classList.add("hidden"); ssHideAddPromptForm(); + restoreDialogFocus(sessionSettingsOverlay); } function saveSessionSettings() { @@ -7274,6 +7766,7 @@ async function toggleChangesPanel(forceVisible) { if (changesModalOverlay) { changesModalOverlay.classList.add("hidden"); } + restoreDialogFocus(changesModalOverlay); updateChangesHeaderButton(); return; } @@ -7310,6 +7803,7 @@ async function toggleChangesPanel(forceVisible) { changesPanelVisible = true; if (changesModalOverlay) { changesModalOverlay.classList.remove("hidden"); + focusDialogSurface(changesModalOverlay, "#changes-close-btn"); } updateChangesHeaderButton(); applyChangesState(data); @@ -7329,6 +7823,7 @@ async function toggleChangesPanel(forceVisible) { changesPanelVisible = true; if (changesModalOverlay) { changesModalOverlay.classList.remove("hidden"); + focusDialogSurface(changesModalOverlay, "#changes-close-btn"); } updateChangesHeaderButton(); requestChangesRefresh(); diff --git a/tasksync-chat/package-lock.json b/tasksync-chat/package-lock.json index aa6ff17..da3ed7c 100644 --- a/tasksync-chat/package-lock.json +++ b/tasksync-chat/package-lock.json @@ -1,12 +1,12 @@ { "name": "tasksync-chat", - "version": "3.0.9", + "version": "3.0.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tasksync-chat", - "version": "3.0.9", + "version": "3.0.10", "license": "MIT", "dependencies": { "@vscode/codicons": "^0.0.36", @@ -3625,4 +3625,4 @@ } } } -} +} \ No newline at end of file diff --git a/tasksync-chat/package.json b/tasksync-chat/package.json index b9e8f1e..074e3b8 100644 --- a/tasksync-chat/package.json +++ b/tasksync-chat/package.json @@ -4,7 +4,7 @@ "displayName": "TaskSync", "description": "Queue your prompts or tasks. Work uninterrupted.", "icon": "media/Tasksync-logo.png", - "version": "3.0.9", + "version": "3.0.10", "engines": { "vscode": "^1.90.0" }, @@ -242,6 +242,12 @@ "default": true, "description": "Show interactive approval and choice buttons for Yes/No questions and multiple choice options." }, + "tasksync.agentOrchestration": { + "type": "boolean", + "default": true, + "scope": "window", + "description": "Enable multiple TaskSync agent sessions with the sessions list, switching, and split view. Turn off to keep TaskSync in a single-session lane." + }, "tasksync.autoAppendText": { "type": "string", "default": "", @@ -350,7 +356,7 @@ }, { "command": "tasksync.toggleSplitView", - "when": "view == taskSyncView", + "when": "view == taskSyncView && config.tasksync.agentOrchestration", "group": "navigation@1.1" }, { @@ -405,4 +411,4 @@ "typescript": "^5.3.3", "vitest": "^4.0.18" } -} +} \ No newline at end of file diff --git a/tasksync-chat/src/constants/remoteConstants.ts b/tasksync-chat/src/constants/remoteConstants.ts index 5200e60..d5670a8 100644 --- a/tasksync-chat/src/constants/remoteConstants.ts +++ b/tasksync-chat/src/constants/remoteConstants.ts @@ -60,7 +60,7 @@ export const HUMAN_DELAY_MAX_UPPER = 60; // maximum allowed for "max delay" inpu // Superseded ask_user directive — returned as normal tool result (not CancellationError) // so the ToolCallingLoop keeps running. Instructs the LLM to re-ask the same question. export const ASKUSER_SUPERSEDED_MESSAGE = - "[CANCELLED: This ask_user request was superseded internally. When you call ask_user again, re-ask the exact same question — do not rephrase or summarize differently.]"; + "[CANCELLED. THIS ask_user WAS SUPERSEDED. CALL ask_user AGAIN NOW. RE-ASK THE EXACT SAME QUESTION. DO NOT REPHRASE. DO NOT REPLY IN PLAIN CHAT.]"; // Shared askUser prompt fragments (used by both local and remote session starts) export const ASKUSER_VISIBILITY_TEXT = diff --git a/tasksync-chat/src/server/remoteServer.ts b/tasksync-chat/src/server/remoteServer.ts index 5d5bfab..27f9c16 100644 --- a/tasksync-chat/src/server/remoteServer.ts +++ b/tasksync-chat/src/server/remoteServer.ts @@ -41,7 +41,7 @@ function getDebugEnabled(): boolean { .get("remoteDebugLogging", false); } function debugLog(...args: unknown[]): void { - if (getDebugEnabled()) console.error("[TaskSync Remote Debug]", ...args); + if (getDebugEnabled()) console.info("[TaskSync Remote Debug]", ...args); } /** Get the configured VS Code command for opening chat from remote sessions. */ diff --git a/tasksync-chat/src/server/remoteSettingsHandler.test.ts b/tasksync-chat/src/server/remoteSettingsHandler.test.ts new file mode 100644 index 0000000..06cbf7e --- /dev/null +++ b/tasksync-chat/src/server/remoteSettingsHandler.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { WebSocket } from "ws"; +import "../__mocks__/vscode"; +import * as settingsH from "../webview/settingsHandlers"; +import { dispatchSettingsMessage } from "./remoteSettingsHandler"; + +describe("dispatchSettingsMessage", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("handles updateAgentOrchestrationSetting without a duplicate dispatcher broadcast", async () => { + const ws = { send: vi.fn() } as unknown as WebSocket; + const provider = {} as any; + const broadcast = vi.fn(); + + const updateSpy = vi + .spyOn(settingsH, "handleUpdateAgentOrchestrationSetting") + .mockResolvedValue(undefined); + + const handled = await dispatchSettingsMessage(ws, provider, broadcast, { + type: "updateAgentOrchestrationSetting", + enabled: false, + }); + + expect(handled).toBe(true); + expect(updateSpy).toHaveBeenCalledWith(provider, false); + expect(broadcast).not.toHaveBeenCalled(); + }); + + it("handles disableAgentOrchestrationAndStopSessions without a duplicate dispatcher broadcast", async () => { + const ws = { send: vi.fn() } as unknown as WebSocket; + const provider = {} as any; + const broadcast = vi.fn(); + + const stopSpy = vi + .spyOn(settingsH, "handleStopSessionsAndDisableAgentOrchestration") + .mockResolvedValue(undefined); + + const handled = await dispatchSettingsMessage(ws, provider, broadcast, { + type: "disableAgentOrchestrationAndStopSessions", + }); + + expect(handled).toBe(true); + expect(stopSpy).toHaveBeenCalledWith(provider); + expect(broadcast).not.toHaveBeenCalled(); + }); +}); diff --git a/tasksync-chat/src/server/remoteSettingsHandler.ts b/tasksync-chat/src/server/remoteSettingsHandler.ts index d73b5c4..ea55be4 100644 --- a/tasksync-chat/src/server/remoteSettingsHandler.ts +++ b/tasksync-chat/src/server/remoteSettingsHandler.ts @@ -32,6 +32,17 @@ export async function dispatchSettingsMessage( broadcastSettingsChanged(provider, broadcastFn); return true; + case "updateAgentOrchestrationSetting": + await settingsH.handleUpdateAgentOrchestrationSetting( + provider, + msg.enabled === true, + ); + return true; + + case "disableAgentOrchestrationAndStopSessions": + await settingsH.handleStopSessionsAndDisableAgentOrchestration(provider); + return true; + case "updateAutoAppendSetting": await settingsH.handleUpdateAutoAppendSetting( provider, diff --git a/tasksync-chat/src/tools.test.ts b/tasksync-chat/src/tools.test.ts index 4a5be09..cd9362b 100644 --- a/tasksync-chat/src/tools.test.ts +++ b/tasksync-chat/src/tools.test.ts @@ -280,6 +280,49 @@ function createToken() { }; } +/** + * Build a minimal single-session provider stub for invoke() regressions that + * must exercise the real stale-session boundary without reaching singleton rebinding. + */ +function createSingleSessionInvokeProvider( + overrides: Record = {}, +) { + return { + _agentOrchestrationEnabled: false, + _bindSession: vi.fn(() => { + throw new Error( + "_bindSession should not run for stale single-session rejections", + ); + }), + _getSession: vi.fn(() => undefined), + _sessionManager: { + getActiveSessionId: () => "1", + getSession: vi.fn(() => undefined), + isDeletedSessionId: () => false, + }, + _pendingRequests: new Map(), + _toolCallSessionMap: new Map(), + _currentSessionCallsMap: new Map(), + _currentToolCallId: null, + _webviewReady: true, + _view: { + webview: { + postMessage: vi.fn(), + }, + show: vi.fn(), + }, + _alwaysAppendReminder: false, + playNotificationSound: vi.fn(), + _updateSessionsUI: vi.fn(), + _saveSessionsToDisk: vi.fn(), + _syncActiveSessionState: vi.fn(), + _clearResponseTimeoutTimer: vi.fn(), + _applyHumanLikeDelay: vi.fn(), + _remoteServer: { broadcast: vi.fn() }, + ...overrides, + } as any; +} + /** * Reload the tools module cleanly so each test gets a fresh registerTool capture. */ @@ -308,6 +351,13 @@ describe("askUser cancellation handling", () => { attachments: [], queue: false, cancelled: true, + directive: { + kind: "cancelled", + reason: "superseded", + action: "call_ask_user_again", + sessionId: "1", + reaskExactSameQuestion: true, + }, }), }; @@ -319,6 +369,13 @@ describe("askUser cancellation handling", () => { expect(result.response).toBe(ASKUSER_SUPERSEDED_MESSAGE); expect(result.attachments).toEqual([]); expect(result.queue).toBe(false); + expect(result.directive).toMatchObject({ + kind: "cancelled", + reason: "superseded", + action: "call_ask_user_again", + sessionId: "1", + reaskExactSameQuestion: true, + }); }); /** @@ -333,6 +390,13 @@ describe("askUser cancellation handling", () => { attachments: [], queue: false, cancelled: true, + directive: { + kind: "cancelled", + reason: "superseded", + action: "call_ask_user_again", + sessionId: "1", + reaskExactSameQuestion: true, + }, }), _autoAppendEnabled: false, _autoAppendText: "", @@ -356,10 +420,230 @@ describe("askUser cancellation handling", () => { // The response text should contain the cancelled message const textPart = result.parts[0]; const parsed = JSON.parse(textPart.value); + expect(parsed.session_id).toBe("1"); expect(parsed.response).toBe(ASKUSER_SUPERSEDED_MESSAGE); + expect(parsed.directive).toEqual({ + kind: "cancelled", + reason: "superseded", + action: "call_ask_user_again", + session_id: "1", + reask_exact_same_question: true, + }); expect(showErrorMessageMock).not.toHaveBeenCalled(); }); + it("includes bootstrap directive in the registered tool payload when session_id is auto", async () => { + const { registerTools } = await import("./tools"); + const provider = { + createSessionForMissingId: vi.fn(() => ({ id: "12" })), + waitForUserResponse: vi.fn().mockResolvedValue({ + value: "Handled", + attachments: [], + queue: false, + }), + _autoAppendEnabled: false, + _autoAppendText: "", + _alwaysAppendReminder: false, + _sessionManager: { getSession: vi.fn(() => undefined) }, + }; + const context = { subscriptions: [] as unknown[] }; + + registerTools(context as any, provider as any); + + const toolDefinition = registerToolMock.mock.calls[0]?.[1]; + expect(toolDefinition).toBeTruthy(); + + const result = await toolDefinition.invoke( + { input: { question: "Bootstrap", session_id: "auto" } }, + createToken() as any, + ); + + const textPart = result.parts[0]; + const parsed = JSON.parse(textPart.value); + expect(parsed.session_id).toBe("12"); + expect(parsed.directive).toEqual({ + kind: "bootstrap", + reason: "auto_assigned_session", + action: "call_ask_user_again", + session_id: "12", + }); + }); + + it("always includes session_id in the registered tool payload", async () => { + const { registerTools } = await import("./tools"); + const provider = { + waitForUserResponse: vi.fn().mockResolvedValue({ + value: "Handled", + attachments: [], + queue: true, + }), + _autoAppendEnabled: false, + _autoAppendText: "", + _alwaysAppendReminder: false, + _sessionManager: { getSession: vi.fn(() => undefined) }, + }; + const context = { subscriptions: [] as unknown[] }; + + registerTools(context as any, provider as any); + + const toolDefinition = registerToolMock.mock.calls[0]?.[1]; + expect(toolDefinition).toBeTruthy(); + + const result = await toolDefinition.invoke( + { input: { question: "Continue", session_id: "12" } }, + createToken() as any, + ); + + const textPart = result.parts[0]; + const parsed = JSON.parse(textPart.value); + expect(parsed.session_id).toBe("12"); + expect(parsed.response).toBe("Handled"); + expect(parsed.queued).toBe(true); + }); + + /** + * Deleted single-session stale recovery must not leak the old auto-bootstrap + * wording through the final registered ask_user payload. + */ + it("returns deleted single-session stale recovery without auto bootstrap text from the registered tool invoke handler", async () => { + const { registerTools } = await import("./tools"); + const { waitForUserResponse } = await import("./webview/toolCallHandler"); + const provider = createSingleSessionInvokeProvider({ + _sessionManager: { + getActiveSessionId: () => "1", + getSession: vi.fn(() => undefined), + isDeletedSessionId: vi.fn((id: string) => id === "deleted-99"), + }, + }); + provider.waitForUserResponse = (question: string, sessionId: string) => + waitForUserResponse(provider as any, question, sessionId); + const context = { subscriptions: [] as unknown[] }; + + registerTools(context as any, provider as any); + + const toolDefinition = registerToolMock.mock.calls[0]?.[1]; + expect(toolDefinition).toBeTruthy(); + + const result = await toolDefinition.invoke( + { input: { question: "Recover deleted", session_id: "deleted-99" } }, + createToken() as any, + ); + + const textPart = result.parts[0]; + const parsed = JSON.parse(textPart.value); + expect(parsed.session_id).toBe("deleted-99"); + expect(parsed.response).toContain( + 'REJECTED. session_id "deleted-99" WAS DELETED.', + ); + expect(parsed.response).toContain( + "START A NEW CHAT TO GET A NEW session_id.", + ); + expect(parsed.response).not.toContain( + 'CALL ask_user AGAIN WITH session_id "auto".', + ); + expect(parsed.directive).toEqual({ + kind: "rejected", + reason: "deleted_session", + action: "start_new_chat_with_new_session_id", + }); + expect(provider._bindSession).not.toHaveBeenCalled(); + }); + + /** + * Terminated single-session stale recovery must stay aligned with the new-chat + * directive so the model is not pointed back at the singleton bootstrap path. + */ + it("returns terminated single-session stale recovery without auto bootstrap text from the registered tool invoke handler", async () => { + const { registerTools } = await import("./tools"); + const { waitForUserResponse } = await import("./webview/toolCallHandler"); + const terminatedSession = { + id: "terminated-5", + sessionTerminated: true, + pendingToolCallId: null, + queue: [], + queueEnabled: false, + }; + const provider = createSingleSessionInvokeProvider({ + _getSession: vi.fn((id: string) => + id === "terminated-5" ? terminatedSession : undefined, + ), + }); + provider.waitForUserResponse = (question: string, sessionId: string) => + waitForUserResponse(provider as any, question, sessionId); + const context = { subscriptions: [] as unknown[] }; + + registerTools(context as any, provider as any); + + const toolDefinition = registerToolMock.mock.calls[0]?.[1]; + expect(toolDefinition).toBeTruthy(); + + const result = await toolDefinition.invoke( + { input: { question: "Recover terminated", session_id: "terminated-5" } }, + createToken() as any, + ); + + const textPart = result.parts[0]; + const parsed = JSON.parse(textPart.value); + expect(parsed.session_id).toBe("terminated-5"); + expect(parsed.response).toContain( + 'REJECTED. session_id "terminated-5" IS TERMINATED.', + ); + expect(parsed.response).toContain( + "START A NEW CHAT TO GET A NEW session_id.", + ); + expect(parsed.response).not.toContain( + 'CALL ask_user AGAIN WITH session_id "auto".', + ); + expect(parsed.directive).toEqual({ + kind: "rejected", + reason: "terminated_session", + action: "start_new_chat_with_new_session_id", + }); + expect(provider._bindSession).not.toHaveBeenCalled(); + }); + + /** + * Generic stale single-session recovery must keep its own directive reason so + * the payload does not pretend the session_id was missing. + */ + it("returns stale single-session recovery with a dedicated stale_session_id directive reason", async () => { + const { registerTools } = await import("./tools"); + const { waitForUserResponse } = await import("./webview/toolCallHandler"); + const provider = createSingleSessionInvokeProvider({ + _sessionManager: { + getActiveSessionId: () => "1", + getSession: vi.fn(() => undefined), + isDeletedSessionId: () => false, + }, + }); + provider.waitForUserResponse = (question: string, sessionId: string) => + waitForUserResponse(provider as any, question, sessionId); + const context = { subscriptions: [] as unknown[] }; + + registerTools(context as any, provider as any); + + const toolDefinition = registerToolMock.mock.calls[0]?.[1]; + expect(toolDefinition).toBeTruthy(); + + const result = await toolDefinition.invoke( + { input: { question: "Recover stale", session_id: "stale-99" } }, + createToken() as any, + ); + + const textPart = result.parts[0]; + const parsed = JSON.parse(textPart.value); + expect(parsed.session_id).toBe("stale-99"); + expect(parsed.response).toContain( + 'REJECTED. session_id "stale-99" IS STALE FOR THE CURRENT SINGLE SESSION.', + ); + expect(parsed.directive).toEqual({ + kind: "rejected", + reason: "stale_session_id", + action: "start_new_chat_with_new_session_id", + }); + expect(provider._bindSession).not.toHaveBeenCalled(); + }); + /** * Real token cancellation (CancellationToken fires) must still throw CancellationError. */ @@ -442,6 +726,7 @@ describe("askUser session_id coercion", () => { expect(provider.waitForUserResponse).toHaveBeenCalledWith("Hello", "7"); expect(result.response).toBe("OK"); + expect(result.sessionId).toBe("7"); }); it("coerces session_id 0 (falsy number) to string '0' instead of auto-assigning", async () => { @@ -485,7 +770,15 @@ describe("askUser session_id coercion", () => { expect(provider.createSessionForMissingId).toHaveBeenCalledTimes(1); expect(provider.waitForUserResponse).toHaveBeenCalledWith("Null?", "5"); - expect(result.response).toContain("auto-assigned session_id"); + expect(result.response).toContain("TaskSync assigned session_id"); + expect(result.response).toContain("Do not reply in plain chat"); + expect(result.response).toContain("CALL ask_user again now"); + expect(result.directive).toMatchObject({ + kind: "bootstrap", + reason: "auto_assigned_session", + action: "call_ask_user_again", + sessionId: "5", + }); }); it("treats undefined session_id as missing and auto-assigns", async () => { @@ -508,7 +801,7 @@ describe("askUser session_id coercion", () => { expect(provider.createSessionForMissingId).toHaveBeenCalledTimes(1); expect(provider.waitForUserResponse).toHaveBeenCalledWith("Undef?", "6"); - expect(result.response).toContain("auto-assigned session_id"); + expect(result.response).toContain("TaskSync assigned session_id"); }); it("treats object session_id as missing and auto-assigns", async () => { @@ -530,7 +823,7 @@ describe("askUser session_id coercion", () => { ); expect(provider.createSessionForMissingId).toHaveBeenCalledTimes(1); - expect(result.response).toContain("auto-assigned session_id"); + expect(result.response).toContain("TaskSync assigned session_id"); }); it("treats whitespace-only session_id as missing and auto-assigns", async () => { @@ -552,7 +845,7 @@ describe("askUser session_id coercion", () => { ); expect(provider.createSessionForMissingId).toHaveBeenCalledTimes(1); - expect(result.response).toContain("auto-assigned session_id"); + expect(result.response).toContain("TaskSync assigned session_id"); }); it("invoke handler coerces numeric session_id before passing to askUser", async () => { @@ -611,7 +904,7 @@ describe("askUser session_id coercion", () => { expect(provider.createSessionForMissingId).toHaveBeenCalledTimes(1); expect(provider.waitForUserResponse).toHaveBeenCalledWith("Test", "9"); - expect(result.response).toContain("auto-assigned session_id"); + expect(result.response).toContain("TaskSync assigned session_id"); }); it("auto-assigns a session_id when the tool is invoked without one", async () => { @@ -636,9 +929,18 @@ describe("askUser session_id coercion", () => { "Start from Copilot chat", "7", ); - expect(result.response).toContain('TaskSync auto-assigned session_id "7"'); + expect(result.response).toContain('TaskSync assigned session_id "7"'); + expect(result.response).toContain( + "Use this exact session_id on every ask_user call.", + ); expect(result.response).toContain( - "Use this exact session_id on every future ask_user call in this chat.", + 'CALL ask_user again now with session_id "7".', ); + expect(result.directive).toMatchObject({ + kind: "bootstrap", + reason: "auto_assigned_session", + action: "call_ask_user_again", + sessionId: "7", + }); }); }); diff --git a/tasksync-chat/src/tools.ts b/tasksync-chat/src/tools.ts index deb79ea..eda049a 100644 --- a/tasksync-chat/src/tools.ts +++ b/tasksync-chat/src/tools.ts @@ -2,6 +2,7 @@ import * as fs from "fs"; import * as vscode from "vscode"; import { getImageMimeType } from "./utils/imageUtils"; import { TaskSyncWebviewProvider } from "./webview/webviewProvider"; +import type { AskUserDirective } from "./webview/webviewTypes"; import { appendAutoAppendText, buildFinalResponseText, @@ -37,10 +38,39 @@ export interface AskUserToolResult { queue: boolean; autoAppendEnabled: boolean; autoAppendText: string; + sessionId: string; + directive?: AskUserDirective; } function buildAssignedSessionInstruction(sessionId: string): string { - return `TaskSync auto-assigned session_id "${sessionId}" for this Copilot chat because it was omitted. Use this exact session_id on every future ask_user call in this chat.`; + return `TaskSync assigned session_id "${sessionId}". Normal chat is invisible here. Use this exact session_id on every ask_user call. Do not reply in plain chat. CALL ask_user again now with session_id "${sessionId}".`; +} + +function buildBootstrapDirective(sessionId: string): AskUserDirective { + return { + kind: "bootstrap", + reason: "auto_assigned_session", + action: "call_ask_user_again", + sessionId, + }; +} + +function toDirectivePayload(directive: AskUserDirective): { + kind: AskUserDirective["kind"]; + reason: AskUserDirective["reason"]; + action: AskUserDirective["action"]; + session_id?: string; + reask_exact_same_question?: boolean; +} { + return { + kind: directive.kind, + reason: directive.reason, + action: directive.action, + ...(directive.sessionId ? { session_id: directive.sessionId } : {}), + ...(directive.reaskExactSameQuestion + ? { reask_exact_same_question: true } + : {}), + }; } /** @@ -156,6 +186,8 @@ export async function askUser( queue: result.queue, autoAppendEnabled: false, autoAppendText: "", + sessionId: effectiveSessionId, + directive: result.directive, }; } debugLog( @@ -218,6 +250,10 @@ export async function askUser( typeof respondingSession?.autoAppendText === "string" ? respondingSession.autoAppendText : "", + sessionId: effectiveSessionId, + directive: autoAssignedSessionId + ? buildBootstrapDirective(effectiveSessionId) + : result.directive, }; } catch (error) { // Re-throw cancellation errors without logging (they're expected) @@ -239,6 +275,7 @@ export async function askUser( queue: false, autoAppendEnabled: false, autoAppendText: "", + sessionId: effectiveSessionId, }; } finally { // Always clean up the cancellation listener to prevent memory leaks @@ -309,13 +346,26 @@ export function registerTools( ); const resultPayload: { + session_id: string; response: string; + directive?: { + kind: AskUserDirective["kind"]; + reason: AskUserDirective["reason"]; + action: AskUserDirective["action"]; + session_id?: string; + reask_exact_same_question?: boolean; + }; queued?: boolean; attachmentCount?: number; } = { + session_id: result.sessionId, response: finalResponse, }; + if (result.directive) { + resultPayload.directive = toDirectivePayload(result.directive); + } + if (result.queue) { resultPayload.queued = true; } diff --git a/tasksync-chat/src/webview-ui/adapter.js b/tasksync-chat/src/webview-ui/adapter.js index 1a6e719..3ab726f 100644 --- a/tasksync-chat/src/webview-ui/adapter.js +++ b/tasksync-chat/src/webview-ui/adapter.js @@ -241,6 +241,16 @@ function mapToRemoteMessage(msg) { return null; // Multi-session operations — forward to server as-is case "switchSession": + if (!agentOrchestrationEnabled) { + if (typeof syncClientSessionSelection === "function") { + syncClientSessionSelection( + serverActiveSessionId || activeSessionId || null, + ); + } + renderSessionsList(); + updateWelcomeSectionVisibility(); + return null; + } if (!msg.sessionId) { // Back to hub — handle locally, no server round-trip needed if (typeof saveActiveSessionComposerState === "function") { @@ -689,6 +699,17 @@ function applySettingsData(s) { queueEnabled = s.queueEnabled; updateQueueVisibility(); } + if (s.agentOrchestrationEnabled !== undefined) { + agentOrchestrationEnabled = s.agentOrchestrationEnabled; + if (!agentOrchestrationEnabled) { + splitViewEnabled = false; + if (typeof syncClientSessionSelection === "function") { + syncClientSessionSelection( + serverActiveSessionId || activeSessionId || null, + ); + } + } + } if (s.autoAppendEnabled !== undefined) { autoAppendEnabled = s.autoAppendEnabled; } @@ -837,6 +858,7 @@ function updatePendingUI() { function applySettingsToUI() { updateSoundToggleUI(); updateInteractiveApprovalToggleUI(); + updateAgentOrchestrationToggleUI(); updateAutoAppendToggleUI(); updateAutoAppendTextUI(); updateSendWithCtrlEnterToggleUI(); @@ -849,6 +871,8 @@ function applySettingsToUI() { workspacePromptListUI.render(); renderPromptsList(); updateQueueVisibility(); + renderSessionsList(); + updateWelcomeSectionVisibility(); } // ==================== End Communication Adapter ==================== diff --git a/tasksync-chat/src/webview-ui/events.js b/tasksync-chat/src/webview-ui/events.js index 4cbc65e..54a91de 100644 --- a/tasksync-chat/src/webview-ui/events.js +++ b/tasksync-chat/src/webview-ui/events.js @@ -1,5 +1,216 @@ // ==================== Event Listeners ==================== +/** + * Keep Escape handling centralized so dialogs created in different files close the same way. + */ +function isOverlayVisible(overlay) { + return !!( + overlay && + overlay.classList && + typeof overlay.classList.contains === "function" && + !overlay.classList.contains("hidden") + ); +} + +/** + * Move keyboard focus into an opened dialog so keyboard shortcuts work immediately. + */ +function clearPendingDialogFocus(overlay) { + if (!overlay || overlay.__tasksyncFocusTimer == null) return; + clearTimeout(overlay.__tasksyncFocusTimer); + overlay.__tasksyncFocusTimer = null; +} + +/** + * Resolve the best focus target inside an open dialog. + */ +function resolveDialogFocusTarget(overlay, preferredSelector) { + if (!overlay) return null; + + var target = null; + if (preferredSelector && typeof overlay.querySelector === "function") { + target = overlay.querySelector(preferredSelector); + } + if (!target && typeof overlay.querySelector === "function") { + target = overlay.querySelector( + 'textarea:not([disabled]), input:not([disabled]), select:not([disabled]), button:not([disabled]), [role="switch"][tabindex], [tabindex]:not([tabindex="-1"])', + ); + } + if (!target && typeof overlay.querySelector === "function") { + target = overlay.querySelector('[role="dialog"], [role="alertdialog"]'); + } + if (!target && typeof overlay.focus === "function") { + target = overlay; + } + + return target; +} + +/** + * Avoid restoring focus to toolbar-style opener buttons because their visible focus ring looks like a stale selection. + */ +function shouldRestoreDialogFocusTarget(target) { + if (!target || typeof target.focus !== "function") return false; + + var tagName = + typeof target.tagName === "string" ? target.tagName.toUpperCase() : ""; + if (tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT") { + return true; + } + if (target.isContentEditable) { + return true; + } + + var role = + typeof target.getAttribute === "function" + ? target.getAttribute("role") + : null; + if (role === "button" || role === "switch") { + return false; + } + + if (tagName === "BUTTON") { + return false; + } + + var classList = target.classList; + if ( + classList && + typeof classList.contains === "function" && + (classList.contains("icon-btn") || + classList.contains("remote-btn") || + classList.contains("settings-modal-header-btn")) + ) { + return false; + } + + return true; +} + +/** + * Keep only the currently opened dialog eligible for deferred focus. + */ +function focusDialogSurface(overlay, preferredSelector) { + if (!overlay) return; + clearPendingDialogFocus(overlay); + + if ( + typeof document !== "undefined" && + document.activeElement && + document.activeElement !== document.body && + document.activeElement !== overlay && + (!overlay.contains || !overlay.contains(document.activeElement)) + ) { + overlay.__tasksyncReturnFocus = document.activeElement; + } + + overlay.__tasksyncFocusTimer = setTimeout(function () { + overlay.__tasksyncFocusTimer = null; + if (!isOverlayVisible(overlay)) return; + + var target = resolveDialogFocusTarget(overlay, preferredSelector); + if (target && typeof target.focus === "function") { + target.focus(); + } + }, 0); +} + +/** + * Return keyboard focus after a dialog closes so the user can continue without an extra click. + */ +function restoreDialogFocus(overlay) { + if (!overlay) return; + clearPendingDialogFocus(overlay); + + var target = overlay.__tasksyncReturnFocus; + overlay.__tasksyncReturnFocus = null; + + if ( + shouldRestoreDialogFocusTarget(target) && + target && + typeof target.focus === "function" && + typeof document !== "undefined" && + typeof document.contains === "function" && + document.contains(target) + ) { + target.focus(); + return; + } + + if (chatInput && typeof chatInput.focus === "function") { + chatInput.focus(); + } +} + +/** + * Close only the topmost visible dialog so Escape never dismisses multiple layers at once. + */ +function handleGlobalDocumentKeydown(e) { + if (e.defaultPrevented || e.key !== "Escape") return; + + if (isOverlayVisible(simpleAlertModalOverlay)) { + e.preventDefault(); + e.stopPropagation(); + closeSimpleAlert(); + return; + } + + if (isOverlayVisible(timeoutWarningModalOverlay)) { + e.preventDefault(); + e.stopPropagation(); + cancelTimeoutWarning(); + return; + } + + if (isOverlayVisible(disableAgentOrchestrationModalOverlay)) { + e.preventDefault(); + e.stopPropagation(); + closeSessionActionModal(disableAgentOrchestrationModalOverlay); + return; + } + + if (isOverlayVisible(resetSessionModalOverlay)) { + e.preventDefault(); + e.stopPropagation(); + closeSessionActionModal(resetSessionModalOverlay); + return; + } + + if (isOverlayVisible(newSessionModalOverlay)) { + e.preventDefault(); + e.stopPropagation(); + closeSessionActionModal(newSessionModalOverlay); + return; + } + + if (isOverlayVisible(sessionSettingsOverlay)) { + e.preventDefault(); + e.stopPropagation(); + closeSessionSettingsModal(); + return; + } + + if (isOverlayVisible(settingsModalOverlay)) { + e.preventDefault(); + e.stopPropagation(); + closeSettingsModal(); + return; + } + + if (isOverlayVisible(historyModalOverlay)) { + e.preventDefault(); + e.stopPropagation(); + closeHistoryModal(); + return; + } + + if (isOverlayVisible(changesModalOverlay)) { + e.preventDefault(); + e.stopPropagation(); + toggleChangesPanel(false); + } +} + function bindEventListeners() { if (chatInput) { chatInput.addEventListener("input", handleTextareaInput); @@ -71,6 +282,7 @@ function bindEventListeners() { document.addEventListener("contextmenu", handleContextMenu); // Intercept Copy when nothing is selected and copy clicked message text as-is. document.addEventListener("copy", handleCopy); + document.addEventListener("keydown", handleGlobalDocumentKeydown); if (queueHeader) queueHeader.addEventListener("click", handleQueueHeaderClick); @@ -87,6 +299,7 @@ function bindEventListeners() { // Hub & Thread Shell events if (threadBackBtn) { threadBackBtn.addEventListener("click", function () { + if (!agentOrchestrationEnabled) return; saveActiveSessionComposerState(); activeSessionId = null; restoreActiveSessionComposerState(); @@ -105,6 +318,7 @@ function bindEventListeners() { var threadEditBtn = document.getElementById("thread-edit-btn"); if (threadEditBtn) { threadEditBtn.addEventListener("click", function () { + if (!agentOrchestrationEnabled) return; var titleEl = document.getElementById("thread-title"); if (!titleEl || !activeSessionId) return; var currentTitle = titleEl.textContent || ""; @@ -193,6 +407,18 @@ function bindEventListeners() { } }); } + if (agentOrchestrationToggle) { + agentOrchestrationToggle.addEventListener( + "click", + toggleAgentOrchestrationSetting, + ); + agentOrchestrationToggle.addEventListener("keydown", function (e) { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleAgentOrchestrationSetting(); + } + }); + } if (autoAppendToggle) { autoAppendToggle.addEventListener("click", toggleAutoAppendSetting); autoAppendToggle.addEventListener("keydown", function (e) { diff --git a/tasksync-chat/src/webview-ui/events.test.ts b/tasksync-chat/src/webview-ui/events.test.ts new file mode 100644 index 0000000..c6fcfbe --- /dev/null +++ b/tasksync-chat/src/webview-ui/events.test.ts @@ -0,0 +1,237 @@ +import { readFileSync } from "fs"; +import { join } from "path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +function createOverlay(hidden = false) { + return { + __tasksyncReturnFocus: null as unknown, + classList: { + contains: vi.fn((className: string) => { + if (className !== "hidden") { + return false; + } + return hidden; + }), + }, + contains: vi.fn(() => false), + querySelector: vi.fn(() => null), + focus: vi.fn(), + }; +} + +function loadEventsHarness(overrides?: { + document?: { + activeElement: unknown; + body: unknown; + contains: (value: unknown) => boolean; + }; + newSessionModalOverlay?: { + classList: { contains: (className: string) => boolean }; + } | null; + settingsModalOverlay?: { + classList: { contains: (className: string) => boolean }; + } | null; +}) { + const source = readFileSync(join(__dirname, "events.js"), "utf8"); + const closeHistoryModal = vi.fn(); + const closeSettingsModal = vi.fn(); + const closeSessionSettingsModal = vi.fn(); + const closeSessionActionModal = vi.fn(); + const cancelTimeoutWarning = vi.fn(); + const closeSimpleAlert = vi.fn(); + const toggleChangesPanel = vi.fn(); + const chatInput = { focus: vi.fn() }; + const documentRef = overrides?.document ?? { + activeElement: null, + body: null, + contains: () => false, + }; + const [ + historyModalOverlay, + defaultSettingsModalOverlay, + sessionSettingsOverlay, + defaultNewSessionModalOverlay, + resetSessionModalOverlay, + disableAgentOrchestrationModalOverlay, + timeoutWarningModalOverlay, + simpleAlertModalOverlay, + changesModalOverlay, + ] = Array.from({ length: 9 }, () => createOverlay(true)); + + const factory = new Function( + "document", + "historyModalOverlay", + "settingsModalOverlay", + "sessionSettingsOverlay", + "newSessionModalOverlay", + "resetSessionModalOverlay", + "disableAgentOrchestrationModalOverlay", + "timeoutWarningModalOverlay", + "simpleAlertModalOverlay", + "changesModalOverlay", + "closeHistoryModal", + "closeSettingsModal", + "closeSessionSettingsModal", + "closeSessionActionModal", + "cancelTimeoutWarning", + "closeSimpleAlert", + "chatInput", + "toggleChangesPanel", + source + + "\nreturn { focusDialogSurface, restoreDialogFocus, handleGlobalDocumentKeydown };", + ); + + const harness = factory( + documentRef, + historyModalOverlay, + overrides?.settingsModalOverlay ?? defaultSettingsModalOverlay, + sessionSettingsOverlay, + overrides?.newSessionModalOverlay ?? defaultNewSessionModalOverlay, + resetSessionModalOverlay, + disableAgentOrchestrationModalOverlay, + timeoutWarningModalOverlay, + simpleAlertModalOverlay, + changesModalOverlay, + closeHistoryModal, + closeSettingsModal, + closeSessionSettingsModal, + closeSessionActionModal, + cancelTimeoutWarning, + closeSimpleAlert, + chatInput, + toggleChangesPanel, + ); + + return { + harness, + closeHistoryModal, + closeSettingsModal, + closeSessionSettingsModal, + closeSessionActionModal, + cancelTimeoutWarning, + closeSimpleAlert, + chatInput, + toggleChangesPanel, + }; +} + +beforeEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe("handleGlobalDocumentKeydown", () => { + it("moves focus into the preferred dialog control after the opener click finishes", () => { + vi.useFakeTimers(); + const activeElement = { focus: vi.fn() }; + const preferredTarget = { focus: vi.fn() }; + const overlay = createOverlay(false); + overlay.querySelector = vi + .fn() + .mockReturnValueOnce(preferredTarget) + .mockReturnValueOnce(null) + .mockReturnValueOnce(null); + const { harness } = loadEventsHarness({ + document: { + activeElement, + body: {}, + contains: () => true, + }, + }); + + harness.focusDialogSurface(overlay, "#preferred"); + + expect(preferredTarget.focus).not.toHaveBeenCalled(); + vi.runAllTimers(); + + expect(overlay.querySelector).toHaveBeenCalledWith("#preferred"); + expect(preferredTarget.focus).toHaveBeenCalledTimes(1); + expect(overlay.__tasksyncReturnFocus).toBe(activeElement); + }); + + it("restores focus after the dialog closes", () => { + const previousTarget = { focus: vi.fn() }; + const overlay = createOverlay(false); + overlay.__tasksyncReturnFocus = previousTarget; + const { harness } = loadEventsHarness({ + document: { + activeElement: null, + body: {}, + contains: (value) => value === previousTarget, + }, + }); + + harness.restoreDialogFocus(overlay); + + expect(previousTarget.focus).toHaveBeenCalledTimes(1); + expect(overlay.__tasksyncReturnFocus).toBeNull(); + }); + + it("falls back to the chat input instead of restoring focus to an opener button", () => { + const openerButton = { + focus: vi.fn(), + tagName: "BUTTON", + getAttribute: vi.fn(() => null), + classList: { contains: vi.fn(() => false) }, + }; + const overlay = createOverlay(false); + overlay.__tasksyncReturnFocus = openerButton; + const { harness, chatInput } = loadEventsHarness({ + document: { + activeElement: null, + body: {}, + contains: (value) => value === openerButton, + }, + }); + + harness.restoreDialogFocus(overlay); + + expect(openerButton.focus).not.toHaveBeenCalled(); + expect(chatInput.focus).toHaveBeenCalledTimes(1); + }); + + it("closes the new session dialog on Escape", () => { + const newSessionModalOverlay = createOverlay(false); + const { harness, closeSessionActionModal, closeSettingsModal } = + loadEventsHarness({ + newSessionModalOverlay, + settingsModalOverlay: createOverlay(false), + }); + const event = { + key: "Escape", + defaultPrevented: false, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }; + + harness.handleGlobalDocumentKeydown(event); + + expect(closeSessionActionModal).toHaveBeenCalledWith( + newSessionModalOverlay, + ); + expect(closeSettingsModal).not.toHaveBeenCalled(); + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(event.stopPropagation).toHaveBeenCalledTimes(1); + }); + + it("does nothing when Escape was already handled elsewhere", () => { + const { harness, closeSessionActionModal, closeSettingsModal } = + loadEventsHarness({ + newSessionModalOverlay: createOverlay(false), + settingsModalOverlay: createOverlay(false), + }); + const event = { + key: "Escape", + defaultPrevented: true, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }; + + harness.handleGlobalDocumentKeydown(event); + + expect(closeSessionActionModal).not.toHaveBeenCalled(); + expect(closeSettingsModal).not.toHaveBeenCalled(); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(event.stopPropagation).not.toHaveBeenCalled(); + }); +}); diff --git a/tasksync-chat/src/webview-ui/extras.js b/tasksync-chat/src/webview-ui/extras.js index cd2c3a2..a067fbc 100644 --- a/tasksync-chat/src/webview-ui/extras.js +++ b/tasksync-chat/src/webview-ui/extras.js @@ -670,6 +670,7 @@ async function toggleChangesPanel(forceVisible) { if (changesModalOverlay) { changesModalOverlay.classList.add("hidden"); } + restoreDialogFocus(changesModalOverlay); updateChangesHeaderButton(); return; } @@ -706,6 +707,7 @@ async function toggleChangesPanel(forceVisible) { changesPanelVisible = true; if (changesModalOverlay) { changesModalOverlay.classList.remove("hidden"); + focusDialogSurface(changesModalOverlay, "#changes-close-btn"); } updateChangesHeaderButton(); applyChangesState(data); @@ -725,6 +727,7 @@ async function toggleChangesPanel(forceVisible) { changesPanelVisible = true; if (changesModalOverlay) { changesModalOverlay.classList.remove("hidden"); + focusDialogSurface(changesModalOverlay, "#changes-close-btn"); } updateChangesHeaderButton(); requestChangesRefresh(); diff --git a/tasksync-chat/src/webview-ui/history.js b/tasksync-chat/src/webview-ui/history.js index bb224bd..ce971f2 100644 --- a/tasksync-chat/src/webview-ui/history.js +++ b/tasksync-chat/src/webview-ui/history.js @@ -14,11 +14,13 @@ function openHistoryModal() { } historyModalOverlay.classList.remove("hidden"); + focusDialogSurface(historyModalOverlay, "#history-modal"); } function closeHistoryModal() { if (!historyModalOverlay) return; historyModalOverlay.classList.add("hidden"); + restoreDialogFocus(historyModalOverlay); } function clearAllPersistedHistory() { diff --git a/tasksync-chat/src/webview-ui/history.test.ts b/tasksync-chat/src/webview-ui/history.test.ts new file mode 100644 index 0000000..1894fac --- /dev/null +++ b/tasksync-chat/src/webview-ui/history.test.ts @@ -0,0 +1,104 @@ +import { readFileSync } from "fs"; +import { join } from "path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +function createClassListStub() { + return { + add: vi.fn(), + remove: vi.fn(), + }; +} + +function loadHistoryHarness(options?: { + isRemoteMode?: boolean; + currentSessionCalls?: Array; + persistedHistory?: Array; +}) { + const source = readFileSync(join(__dirname, "history.js"), "utf8"); + const historyModalOverlay = { classList: createClassListStub() }; + const vscode = { postMessage: vi.fn() }; + const renderHistoryModal = vi.fn(); + const focusDialogSurface = vi.fn(); + const restoreDialogFocus = vi.fn(); + + const factory = new Function( + "historyModalOverlay", + "isRemoteMode", + "currentSessionCalls", + "persistedHistory", + "renderHistoryModal", + "vscode", + "focusDialogSurface", + "restoreDialogFocus", + source + + "\nreturn { openHistoryModal, closeHistoryModal, getPersistedHistory: function () { return persistedHistory; } };", + ); + + const harness = factory( + historyModalOverlay, + options?.isRemoteMode ?? false, + options?.currentSessionCalls ?? [], + options?.persistedHistory ?? [], + renderHistoryModal, + vscode, + focusDialogSurface, + restoreDialogFocus, + ); + + return { + harness, + historyModalOverlay, + vscode, + renderHistoryModal, + focusDialogSurface, + restoreDialogFocus, + }; +} + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +describe("openHistoryModal", () => { + it("opens the modal and focuses the dialog container instead of the close button", () => { + const { harness, historyModalOverlay, focusDialogSurface, vscode } = + loadHistoryHarness(); + + harness.openHistoryModal(); + + expect(historyModalOverlay.classList.remove).toHaveBeenCalledWith("hidden"); + expect(focusDialogSurface).toHaveBeenCalledWith( + historyModalOverlay, + "#history-modal", + ); + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "openHistoryModal", + }); + }); + + it("renders remote history locally before opening when running in remote mode", () => { + const calls = [{ id: "1" }, { id: "2" }]; + const { harness, renderHistoryModal, vscode } = loadHistoryHarness({ + isRemoteMode: true, + currentSessionCalls: calls, + }); + + harness.openHistoryModal(); + + expect(renderHistoryModal).toHaveBeenCalledTimes(1); + expect(vscode.postMessage).not.toHaveBeenCalled(); + expect(harness.getPersistedHistory()).toEqual(calls.slice().reverse()); + }); +}); + +describe("closeHistoryModal", () => { + it("hides the modal and restores focus", () => { + const { harness, historyModalOverlay, restoreDialogFocus } = + loadHistoryHarness(); + + harness.closeHistoryModal(); + + expect(historyModalOverlay.classList.add).toHaveBeenCalledWith("hidden"); + expect(restoreDialogFocus).toHaveBeenCalledWith(historyModalOverlay); + }); +}); diff --git a/tasksync-chat/src/webview-ui/init.js b/tasksync-chat/src/webview-ui/init.js index 9b7f113..cf5849e 100644 --- a/tasksync-chat/src/webview-ui/init.js +++ b/tasksync-chat/src/webview-ui/init.js @@ -10,6 +10,7 @@ function init() { initSessionPromptListUI(); createNewSessionModal(); createResetSessionModal(); + createDisableAgentOrchestrationModal(); createTimeoutWarningModal(); createSimpleAlertModal(); bindEventListeners(); @@ -211,6 +212,7 @@ function createHistoryModal() { historyModal.setAttribute("role", "dialog"); historyModal.setAttribute("aria-modal", "true"); historyModal.setAttribute("aria-label", "Session History"); + historyModal.tabIndex = -1; // Modal header let modalHeader = document.createElement("div"); @@ -355,6 +357,7 @@ function createSettingsModal() { settingsModal.id = "settings-modal"; settingsModal.setAttribute("role", "dialog"); settingsModal.setAttribute("aria-labelledby", "settings-modal-title"); + settingsModal.tabIndex = -1; // Modal header let modalHeader = document.createElement("div"); @@ -418,6 +421,20 @@ function createSettingsModal() { ""; modalContent.appendChild(approvalSection); + // Agent orchestration section - toggle between multi-session and single-session mode + let agentOrchestrationSection = document.createElement("div"); + agentOrchestrationSection.className = "settings-section"; + agentOrchestrationSection.innerHTML = + '
' + + '
' + + ' Agent Orchestration' + + '' + + '' + + "
" + + '
' + + "
"; + modalContent.appendChild(agentOrchestrationSection); + // Send shortcut section - switch between Enter and Ctrl/Cmd+Enter send let sendShortcutSection = document.createElement("div"); sendShortcutSection.className = "settings-section"; @@ -605,6 +622,9 @@ function createSettingsModal() { interactiveApprovalToggle = document.getElementById( "interactive-approval-toggle", ); + agentOrchestrationToggle = document.getElementById( + "agent-orchestration-toggle", + ); alwaysAppendReminderToggle = document.getElementById( "always-append-reminder-toggle", ); @@ -639,6 +659,7 @@ function createSessionSettingsModal() { "aria-labelledby", "session-settings-title", ); + sessionSettingsModal.tabIndex = -1; // Modal header var ssHeader = document.createElement("div"); @@ -762,6 +783,7 @@ function createSessionSettingsModal() { var newSessionModalOverlay = null; var resetSessionModalOverlay = null; +var disableAgentOrchestrationModalOverlay = null; function createSessionActionModal(config) { var overlay = document.createElement("div"); @@ -772,6 +794,7 @@ function createSessionActionModal(config) { modal.className = "settings-modal new-session-modal"; modal.setAttribute("role", "dialog"); modal.setAttribute("aria-labelledby", config.titleId); + modal.tabIndex = -1; var header = document.createElement("div"); header.className = "settings-modal-header"; @@ -847,6 +870,7 @@ function createSessionActionModal(config) { modal.appendChild(content); overlay.appendChild(modal); document.body.appendChild(overlay); + overlay.__taskSyncInitialFocusSelector = config.initialFocusSelector || null; overlay.addEventListener("click", function (e) { if (e.target === overlay) closeSessionActionModal(overlay); @@ -858,11 +882,13 @@ function createSessionActionModal(config) { function openSessionActionModal(overlay) { if (!overlay) return; overlay.classList.remove("hidden"); + focusDialogSurface(overlay, overlay.__taskSyncInitialFocusSelector); } function closeSessionActionModal(overlay) { if (!overlay) return; overlay.classList.add("hidden"); + restoreDialogFocus(overlay); } function createNewSessionModal() { @@ -902,6 +928,7 @@ function createNewSessionModal() { warningText: "Start a fresh Copilot chat, or end the current session and start a fresh one.", extraContent: extra, + initialFocusSelector: "#new-session-prompt", actions: [ { label: "New Session", @@ -952,7 +979,10 @@ function openNewSessionModal() { sessions.find(function (s) { return s.id === activeSessionId; }); - var hasActiveSession = !!activeSession && !activeSession.sessionTerminated; + var hasActiveSession = + agentOrchestrationEnabled && + !!activeSession && + !activeSession.sessionTerminated; if (endBtn) { endBtn.classList.toggle("hidden", !hasActiveSession); } @@ -961,7 +991,9 @@ function openNewSessionModal() { if (warningEl) { warningEl.textContent = hasActiveSession ? "Start a fresh Copilot chat, or end the current session and start a fresh one." - : "Start a fresh Copilot chat session."; + : agentOrchestrationEnabled + ? "Start a fresh Copilot chat session." + : "Start a fresh Copilot chat using the current TaskSync session."; } // Refresh queue checkbox visibility and label based on current queue state var queueRow = document.getElementById("new-session-queue-row"); @@ -994,10 +1026,51 @@ function createResetSessionModal() { warningText: "This will clear the current session history without starting a fresh Copilot chat.", confirmLabel: "Reset Session", + initialFocusSelector: ".form-btn-save", messageType: "resetSession", }); } +function createDisableAgentOrchestrationModal() { + disableAgentOrchestrationModalOverlay = createSessionActionModal({ + overlayId: "disable-agent-orchestration-modal-overlay", + titleId: "disable-agent-orchestration-modal-title", + title: "Turn Off Agent Orchestration", + warningText: "", + initialFocusSelector: ".form-btn-cancel", + actions: [ + { + label: "Cancel", + className: "form-btn form-btn-cancel", + }, + { + id: "disable-agent-orchestration-confirm-btn", + label: "Stop current session(s) and turn off Agent Orchestration", + className: "form-btn form-btn-danger", + onClick: stopSessionsAndDisableAgentOrchestration, + }, + ], + }); +} + +function openStopSessionsAndDisableAgentOrchestrationModal(waitingSessions) { + if (!disableAgentOrchestrationModalOverlay) { + showAgentOrchestrationDisableAlert(waitingSessions); + return; + } + var warningEl = disableAgentOrchestrationModalOverlay.querySelector( + ".new-session-warning", + ); + if (warningEl) { + warningEl.textContent = + waitingSessions.length === 1 + ? "1 session is still waiting on you. Stopping it will cancel that pending ask_user and then turn Agent Orchestration off." + : waitingSessions.length + + " sessions are still waiting on you. Stopping them will cancel those pending ask_user calls and then turn Agent Orchestration off."; + } + openSessionActionModal(disableAgentOrchestrationModalOverlay); +} + function openResetSessionModal() { openSessionActionModal(resetSessionModalOverlay); } @@ -1151,10 +1224,7 @@ function showTimeoutWarning(value) { } timeoutWarningModalOverlay.classList.remove("hidden"); - - // Focus the cancel button for accessibility - var cancelBtn = document.getElementById("timeout-warning-cancel-btn"); - if (cancelBtn) cancelBtn.focus(); + focusDialogSurface(timeoutWarningModalOverlay, "#timeout-warning-cancel-btn"); } function cancelTimeoutWarning() { @@ -1162,6 +1232,7 @@ function cancelTimeoutWarning() { if (timeoutWarningModalOverlay) { timeoutWarningModalOverlay.classList.add("hidden"); } + restoreDialogFocus(timeoutWarningModalOverlay); // Revert dropdown to current value and restore focus if (responseTimeoutSelect) { responseTimeoutSelect.value = String(responseTimeout); @@ -1181,6 +1252,7 @@ function confirmTimeoutWarning() { if (timeoutWarningModalOverlay) { timeoutWarningModalOverlay.classList.add("hidden"); } + restoreDialogFocus(timeoutWarningModalOverlay); // Restore focus to dropdown if (responseTimeoutSelect) { responseTimeoutSelect.focus(); @@ -1276,14 +1348,12 @@ function showSimpleAlert(title, message, iconClass) { } simpleAlertModalOverlay.classList.remove("hidden"); - - // Focus the OK button for keyboard accessibility - var okBtn = document.getElementById("simple-alert-ok-btn"); - if (okBtn) okBtn.focus(); + focusDialogSurface(simpleAlertModalOverlay, "#simple-alert-ok-btn"); } function closeSimpleAlert() { if (simpleAlertModalOverlay) { simpleAlertModalOverlay.classList.add("hidden"); } + restoreDialogFocus(simpleAlertModalOverlay); } diff --git a/tasksync-chat/src/webview-ui/messageHandler.js b/tasksync-chat/src/webview-ui/messageHandler.js index 51350ea..0fbfd81 100644 --- a/tasksync-chat/src/webview-ui/messageHandler.js +++ b/tasksync-chat/src/webview-ui/messageHandler.js @@ -68,6 +68,15 @@ function handleExtensionMessage(event) { case "updateSettings": soundEnabled = message.soundEnabled !== false; interactiveApprovalEnabled = message.interactiveApprovalEnabled !== false; + agentOrchestrationEnabled = message.agentOrchestrationEnabled !== false; + if (!agentOrchestrationEnabled) { + splitViewEnabled = false; + if (typeof syncClientSessionSelection === "function") { + syncClientSessionSelection( + serverActiveSessionId || activeSessionId || null, + ); + } + } autoAppendEnabled = message.autoAppendEnabled === true; autoAppendText = typeof message.autoAppendText === "string" @@ -110,6 +119,7 @@ function handleExtensionMessage(event) { : DEFAULT_HUMAN_DELAY_MAX; updateSoundToggleUI(); updateInteractiveApprovalToggleUI(); + updateAgentOrchestrationToggleUI(); updateAutoAppendToggleUI(); updateAutoAppendTextUI(); updateAlwaysAppendReminderToggleUI(); @@ -122,6 +132,8 @@ function handleExtensionMessage(event) { updateRemoteMaxDevicesUI(); updateHumanDelayUI(); renderPromptsList(); + renderSessionsList(); + updateWelcomeSectionVisibility(); break; case "slashCommandResults": showSlashDropdown(message.prompts || []); diff --git a/tasksync-chat/src/webview-ui/rendering.js b/tasksync-chat/src/webview-ui/rendering.js index 8e2537c..cba7567 100644 --- a/tasksync-chat/src/webview-ui/rendering.js +++ b/tasksync-chat/src/webview-ui/rendering.js @@ -518,6 +518,13 @@ function renderMermaidDiagrams() { * On narrow viewports (<= 480 px) CSS flips this to vertical automatically. */ function toggleSplitView() { + if (!agentOrchestrationEnabled) { + splitViewEnabled = false; + syncSplitViewLayout(); + updateWelcomeSectionVisibility(); + saveWebviewState(); + return; + } splitViewEnabled = !splitViewEnabled; syncSplitViewLayout(); updateWelcomeSectionVisibility(); @@ -673,6 +680,16 @@ function syncHiddenListUnreadIndicators() { var backBtn = document.getElementById("thread-back-btn"); var collapseBar = document.getElementById("sessions-collapse-bar"); var hubEl = document.getElementById("workspace-hub"); + if (!agentOrchestrationEnabled) { + if (backBtn) { + backBtn.classList.remove("has-unread-indicator"); + } + if (collapseBar) { + collapseBar.classList.remove("has-unread-indicator"); + collapseBar.classList.add("hidden"); + } + return; + } var hasOpenSession = !!activeSessionId; var hasUnreadOtherSession = (sessions || []).some(function (session) { return session.id !== activeSessionId && session.unread === true; @@ -722,7 +739,8 @@ function syncSplitViewLayout() { resizer.classList.toggle("hidden", !effectiveSplitView); } if (remoteSplitBtn) { - remoteSplitBtn.classList.toggle("active", splitViewEnabled); + remoteSplitBtn.classList.toggle("hidden", !agentOrchestrationEnabled); + remoteSplitBtn.classList.toggle("active", effectiveSplitView); } if (effectiveSplitView) { @@ -754,6 +772,19 @@ function updateWelcomeSectionVisibility() { var placeholderEl = document.getElementById("split-placeholder"); var threadHeadEl = document.getElementById("thread-head"); var composerEl = document.getElementById("input-area-container"); + var threadTitle = document.getElementById("thread-title"); + var backBtn = document.getElementById("thread-back-btn"); + var editBtn = document.getElementById("thread-edit-btn"); + + if (threadTitle) { + threadTitle.classList.toggle("hidden", !agentOrchestrationEnabled); + } + if (backBtn) { + backBtn.classList.toggle("hidden", !agentOrchestrationEnabled); + } + if (editBtn) { + editBtn.classList.toggle("hidden", !agentOrchestrationEnabled); + } syncSplitViewLayout(); @@ -770,7 +801,6 @@ function updateWelcomeSectionVisibility() { var activeSession = (sessions || []).find(function (s) { return s.id === activeSessionId; }); - var threadTitle = document.getElementById("thread-title"); if (activeSession && threadTitle) { threadTitle.textContent = activeSession.title; } @@ -797,7 +827,6 @@ function updateWelcomeSectionVisibility() { var activeSession = (sessions || []).find(function (s) { return s.id === activeSessionId; }); - var threadTitle = document.getElementById("thread-title"); if (activeSession) { if (threadTitle) threadTitle.textContent = activeSession.title; } @@ -827,16 +856,29 @@ function scrollToBottom() { function renderSessionsList() { var sessionsListEl = document.getElementById("sessions-list"); var sessionsPanelEl = document.getElementById("sessions-panel"); + var visibleSessions = getVisibleSessions(); if (!sessionsListEl) return; // Update collapse bar session count var countEl = document.getElementById("sessions-collapse-count"); if (countEl) { countEl.textContent = - sessions.length > 0 ? "(" + sessions.length + ")" : ""; + agentOrchestrationEnabled && visibleSessions.length > 0 + ? "(" + visibleSessions.length + ")" + : ""; + } + + if (!agentOrchestrationEnabled) { + sessionsListEl.innerHTML = ""; + if (sessionsPanelEl) sessionsPanelEl.classList.add("hidden"); + if (welcomeSection) { + welcomeSection.classList.toggle("hidden", visibleSessions.length > 0); + } + syncHiddenListUnreadIndicators(); + return; } - if (!sessions || sessions.length === 0) { + if (!visibleSessions || visibleSessions.length === 0) { sessionsListEl.innerHTML = ""; if (sessionsPanelEl) sessionsPanelEl.classList.add("hidden"); if (welcomeSection) welcomeSection.classList.remove("hidden"); @@ -848,7 +890,7 @@ function renderSessionsList() { if (welcomeSection) welcomeSection.classList.add("hidden"); // Sort: active sessions first (newest first), then archived - var sorted = sessions.slice().sort(function (a, b) { + var sorted = visibleSessions.slice().sort(function (a, b) { if (a.status !== b.status) { return a.status === "active" ? -1 : 1; } diff --git a/tasksync-chat/src/webview-ui/rendering.test.ts b/tasksync-chat/src/webview-ui/rendering.test.ts index adecb07..657af68 100644 --- a/tasksync-chat/src/webview-ui/rendering.test.ts +++ b/tasksync-chat/src/webview-ui/rendering.test.ts @@ -110,11 +110,13 @@ type RenderingBaseContext = { sessions: SessionSummary[]; activeSessionId: string | null; splitViewEnabled: boolean; + agentOrchestrationEnabled: boolean; splitRatio: number; vertSplitRatio: number; welcomeSection: FakeElement; vscode: { postMessage: ReturnType }; requestFollowServerActiveSession: ReturnType; + getVisibleSessions: () => SessionSummary[]; isSplitViewLayoutActive: () => boolean; escapeHtml: (value: string) => string; convertMarkdownLists: (value: string) => string; @@ -194,13 +196,17 @@ function createRenderingHarness() { sessions: [], activeSessionId: null, splitViewEnabled: false, + agentOrchestrationEnabled: true, splitRatio: 38, vertSplitRatio: 35, welcomeSection, vscode: { postMessage: vi.fn() }, requestFollowServerActiveSession: vi.fn(), + getVisibleSessions: () => baseContext.sessions, isSplitViewLayoutActive: () => - baseContext.splitViewEnabled && baseContext.activeSessionId !== null, + baseContext.agentOrchestrationEnabled && + baseContext.splitViewEnabled && + baseContext.activeSessionId !== null, escapeHtml: (value: string) => value, convertMarkdownLists: (value: string) => value, processTableBuffer: (lines: string[]) => lines.join("\n"), @@ -418,4 +424,19 @@ describe("rendering unread indicators", () => { elements.sessionsCollapseBar.classList.contains("has-unread-indicator"), ).toBe(false); }); + + it("hides the thread title when agent orchestration is off", () => { + const { context, elements } = createRenderingHarness(); + + context.sessions = [{ id: "session-1", title: "Agent 1", unread: false }]; + context.activeSessionId = "session-1"; + + context.agentOrchestrationEnabled = false; + context.updateWelcomeSectionVisibility(); + expect(elements.threadTitle.classList.contains("hidden")).toBe(true); + + context.agentOrchestrationEnabled = true; + context.updateWelcomeSectionVisibility(); + expect(elements.threadTitle.classList.contains("hidden")).toBe(false); + }); }); diff --git a/tasksync-chat/src/webview-ui/sessionActionModal.test.ts b/tasksync-chat/src/webview-ui/sessionActionModal.test.ts new file mode 100644 index 0000000..3a33604 --- /dev/null +++ b/tasksync-chat/src/webview-ui/sessionActionModal.test.ts @@ -0,0 +1,110 @@ +import { readFileSync } from "fs"; +import { join } from "path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +function extractSessionActionModalSource() { + const source = readFileSync(join(__dirname, "init.js"), "utf8"); + const start = source.indexOf("function createSessionActionModal(config) {"); + const end = source.indexOf("function createNewSessionModal() {"); + return source.slice(start, end); +} + +function createElementStub() { + return { + className: "", + id: "", + textContent: "", + innerHTML: "", + tabIndex: 0, + children: [] as unknown[], + classList: { + add: vi.fn(), + remove: vi.fn(), + }, + setAttribute: vi.fn(), + appendChild(child: unknown) { + this.children.push(child); + return child; + }, + addEventListener: vi.fn(), + }; +} + +function loadSessionActionHarness() { + const source = extractSessionActionModalSource(); + const focusDialogSurface = vi.fn(); + const restoreDialogFocus = vi.fn(); + const body = { appendChild: vi.fn() }; + const documentRef = { + body, + createElement: vi.fn(() => createElementStub()), + }; + const vscode = { postMessage: vi.fn() }; + + const factory = new Function( + "document", + "vscode", + "focusDialogSurface", + "restoreDialogFocus", + source + + "\nreturn { createSessionActionModal, openSessionActionModal, closeSessionActionModal };", + ); + + const harness = factory( + documentRef, + vscode, + focusDialogSurface, + restoreDialogFocus, + ); + + return { + harness, + focusDialogSurface, + restoreDialogFocus, + }; +} + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +describe("session action modal focus", () => { + it("stores the preferred selector and focuses it when the modal opens", () => { + const { harness, focusDialogSurface } = loadSessionActionHarness(); + const overlay = harness.createSessionActionModal({ + overlayId: "new-session-modal-overlay", + titleId: "new-session-modal-title", + title: "New Session", + warningText: "warning", + initialFocusSelector: "#new-session-prompt", + confirmLabel: "Continue", + messageType: "newSession", + }); + + harness.openSessionActionModal(overlay); + + expect(overlay.__taskSyncInitialFocusSelector).toBe("#new-session-prompt"); + expect(overlay.classList.remove).toHaveBeenCalledWith("hidden"); + expect(focusDialogSurface).toHaveBeenCalledWith( + overlay, + "#new-session-prompt", + ); + }); + + it("restores focus when the modal closes", () => { + const { harness, restoreDialogFocus } = loadSessionActionHarness(); + const overlay = harness.createSessionActionModal({ + overlayId: "reset-session-modal-overlay", + titleId: "reset-session-modal-title", + title: "Reset Session", + warningText: "warning", + confirmLabel: "Reset", + messageType: "resetSession", + }); + + harness.closeSessionActionModal(overlay); + + expect(overlay.classList.add).toHaveBeenCalledWith("hidden"); + expect(restoreDialogFocus).toHaveBeenCalledWith(overlay); + }); +}); diff --git a/tasksync-chat/src/webview-ui/sessionSettings.js b/tasksync-chat/src/webview-ui/sessionSettings.js index 2487a6e..0d9dc1e 100644 --- a/tasksync-chat/src/webview-ui/sessionSettings.js +++ b/tasksync-chat/src/webview-ui/sessionSettings.js @@ -71,6 +71,7 @@ function openSessionSettingsModal() { if (!sessionSettingsOverlay) return; vscode.postMessage({ type: "requestSessionSettings" }); sessionSettingsOverlay.classList.remove("hidden"); + focusDialogSurface(sessionSettingsOverlay, "#ss-close-btn"); } function closeSessionSettingsModal() { @@ -79,6 +80,7 @@ function closeSessionSettingsModal() { saveSessionSettings(); sessionSettingsOverlay.classList.add("hidden"); ssHideAddPromptForm(); + restoreDialogFocus(sessionSettingsOverlay); } function saveSessionSettings() { diff --git a/tasksync-chat/src/webview-ui/settings.js b/tasksync-chat/src/webview-ui/settings.js index 672da3d..8bba524 100644 --- a/tasksync-chat/src/webview-ui/settings.js +++ b/tasksync-chat/src/webview-ui/settings.js @@ -2,14 +2,17 @@ function openSettingsModal() { if (!settingsModalOverlay) return; - vscode.postMessage({ type: "openSettingsModal" }); + // Keep modal opening local so a stale settings refresh cannot override + // a user's first orchestration toggle while the modal is already opening. settingsModalOverlay.classList.remove("hidden"); + focusDialogSurface(settingsModalOverlay, "#settings-modal"); } function closeSettingsModal() { if (!settingsModalOverlay) return; settingsModalOverlay.classList.add("hidden"); hideAddPromptForm(); + restoreDialogFocus(settingsModalOverlay); } function toggleSoundSetting() { @@ -35,6 +38,66 @@ function updateInteractiveApprovalToggleUI() { setToggle(interactiveApprovalToggle, interactiveApprovalEnabled); } +function showAgentOrchestrationDisableAlert(waitingSessions) { + var message = + waitingSessions.length === 1 + ? "There is still 1 session waiting on you." + : "There are still " + + waitingSessions.length + + " sessions waiting on you."; + showSimpleAlert( + "Keep Agent Orchestration On", + message + + " Reply to them or stop those sessions before turning Agent Orchestration off.", + "codicon-warning", + ); +} + +function stopSessionsAndDisableAgentOrchestration() { + vscode.postMessage({ type: "disableAgentOrchestrationAndStopSessions" }); +} + +function toggleAgentOrchestrationSetting() { + if (agentOrchestrationEnabled) { + var waitingSessions = + typeof getWaitingActiveSessions === "function" + ? getWaitingActiveSessions() + : []; + if (waitingSessions.length > 1) { + if ( + typeof openStopSessionsAndDisableAgentOrchestrationModal === "function" + ) { + openStopSessionsAndDisableAgentOrchestrationModal(waitingSessions); + } else { + showAgentOrchestrationDisableAlert(waitingSessions); + } + return; + } + } + agentOrchestrationEnabled = !agentOrchestrationEnabled; + if (!agentOrchestrationEnabled) { + splitViewEnabled = false; + } + if (typeof syncClientSessionSelection === "function") { + syncClientSessionSelection( + serverActiveSessionId || activeSessionId || null, + ); + } + updateAgentOrchestrationToggleUI(); + renderSessionsList(); + updateWelcomeSectionVisibility(); + saveWebviewState(); + vscode.postMessage({ + type: "updateAgentOrchestrationSetting", + enabled: agentOrchestrationEnabled, + }); +} + +function updateAgentOrchestrationToggleUI() { + if (!agentOrchestrationToggle) return; + setToggle(agentOrchestrationToggle, agentOrchestrationEnabled); +} + function toggleAutoAppendSetting() { autoAppendEnabled = !autoAppendEnabled; updateAutoAppendToggleUI(); diff --git a/tasksync-chat/src/webview-ui/settings.test.ts b/tasksync-chat/src/webview-ui/settings.test.ts new file mode 100644 index 0000000..8961fe7 --- /dev/null +++ b/tasksync-chat/src/webview-ui/settings.test.ts @@ -0,0 +1,235 @@ +import { readFileSync } from "fs"; +import { join } from "path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +function createClassListStub() { + return { + add: vi.fn(), + remove: vi.fn(), + }; +} + +function normalizeSelectorList(selectorText: string) { + return selectorText + .split(",") + .map((selector) => selector.trim()) + .filter(Boolean) + .sort() + .join(","); +} + +function findCssDeclarations( + css: string, + expectedSelectors: string[], +): string | undefined { + const normalizedExpected = normalizeSelectorList(expectedSelectors.join(",")); + + for (const block of css.split("}")) { + const parts = block.split("{"); + if (parts.length !== 2) continue; + + const selectorText = parts[0]?.trim(); + const declarations = parts[1]?.trim(); + if (!selectorText || !declarations) continue; + + if (normalizeSelectorList(selectorText) === normalizedExpected) { + return declarations; + } + } + + return undefined; +} + +function loadSettingsHarness(options?: { + agentOrchestrationEnabled?: boolean; + splitViewEnabled?: boolean; + waitingSessions?: Array; +}) { + const source = readFileSync(join(__dirname, "settings.js"), "utf8"); + const vscode = { postMessage: vi.fn() }; + const settingsModalOverlay = { classList: createClassListStub() }; + const hideAddPromptForm = vi.fn(); + const showSimpleAlert = vi.fn(); + const openStopSessionsAndDisableAgentOrchestrationModal = vi.fn(); + const setToggle = vi.fn(); + const focusDialogSurface = vi.fn(); + const restoreDialogFocus = vi.fn(); + const syncClientSessionSelection = vi.fn(); + const renderSessionsList = vi.fn(); + const updateWelcomeSectionVisibility = vi.fn(); + const saveWebviewState = vi.fn(); + const createPromptListUI = vi.fn(() => ({ bindEvents: vi.fn() })); + const waitingSessions = options?.waitingSessions ?? []; + + const factory = new Function( + "setToggle", + "settingsModalOverlay", + "focusDialogSurface", + "restoreDialogFocus", + "hideAddPromptForm", + "soundEnabled", + "soundToggle", + "interactiveApprovalEnabled", + "interactiveApprovalToggle", + "agentOrchestrationEnabled", + "agentOrchestrationToggle", + "autoAppendEnabled", + "autoAppendToggle", + "autoAppendTextRow", + "showSimpleAlert", + "openStopSessionsAndDisableAgentOrchestrationModal", + "getWaitingActiveSessions", + "splitViewEnabled", + "syncClientSessionSelection", + "serverActiveSessionId", + "activeSessionId", + "renderSessionsList", + "updateWelcomeSectionVisibility", + "saveWebviewState", + "vscode", + "createPromptListUI", + source + + "\nreturn { openSettingsModal, closeSettingsModal, toggleAgentOrchestrationSetting, stopSessionsAndDisableAgentOrchestration, getAgentOrchestrationEnabled: function () { return agentOrchestrationEnabled; }, getSplitViewEnabled: function () { return splitViewEnabled; } };", + ); + + const harness = factory( + setToggle, + settingsModalOverlay, + focusDialogSurface, + restoreDialogFocus, + hideAddPromptForm, + true, + null, + true, + null, + options?.agentOrchestrationEnabled ?? true, + {}, + false, + null, + null, + showSimpleAlert, + openStopSessionsAndDisableAgentOrchestrationModal, + () => waitingSessions, + options?.splitViewEnabled ?? true, + syncClientSessionSelection, + null, + null, + renderSessionsList, + updateWelcomeSectionVisibility, + saveWebviewState, + vscode, + createPromptListUI, + ); + + return { + harness, + settingsModalOverlay, + focusDialogSurface, + restoreDialogFocus, + hideAddPromptForm, + showSimpleAlert, + openStopSessionsAndDisableAgentOrchestrationModal, + vscode, + syncClientSessionSelection, + renderSessionsList, + updateWelcomeSectionVisibility, + saveWebviewState, + }; +} + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +describe("openSettingsModal", () => { + it("opens the modal locally without triggering a redundant settings refresh round-trip", () => { + const { harness, settingsModalOverlay, focusDialogSurface, vscode } = + loadSettingsHarness(); + + harness.openSettingsModal(); + + expect(settingsModalOverlay.classList.remove).toHaveBeenCalledWith( + "hidden", + ); + expect(focusDialogSurface).toHaveBeenCalledWith( + settingsModalOverlay, + "#settings-modal", + ); + expect(vscode.postMessage).not.toHaveBeenCalled(); + }); +}); + +describe("dialog focus visibility", () => { + it("keeps dialog surfaces visible while overlays stay outline-free", () => { + const mainCss = readFileSync( + join(__dirname, "../../media/main.css"), + "utf8", + ); + const overlayDeclarations = findCssDeclarations(mainCss, [ + ".settings-modal-overlay:focus", + ".settings-modal-overlay:focus-visible", + ".history-modal-overlay:focus", + ".history-modal-overlay:focus-visible", + ]); + const dialogDeclarations = findCssDeclarations(mainCss, [ + ".settings-modal:focus", + ".settings-modal:focus-visible", + ".history-modal:focus", + ".history-modal:focus-visible", + ]); + + expect(overlayDeclarations).toContain("outline: none;"); + expect(dialogDeclarations).toContain("outline: none;"); + expect(dialogDeclarations).toContain("outline: none;"); + }); +}); + +describe("toggleAgentOrchestrationSetting", () => { + it("shows a blocking modal when multiple sessions are already waiting", () => { + const { + harness, + openStopSessionsAndDisableAgentOrchestrationModal, + vscode, + saveWebviewState, + } = loadSettingsHarness({ + waitingSessions: [{ id: "1" }, { id: "2" }], + }); + + harness.toggleAgentOrchestrationSetting(); + + expect( + openStopSessionsAndDisableAgentOrchestrationModal, + ).toHaveBeenCalledWith([{ id: "1" }, { id: "2" }]); + expect(vscode.postMessage).not.toHaveBeenCalled(); + expect(saveWebviewState).not.toHaveBeenCalled(); + expect(harness.getAgentOrchestrationEnabled()).toBe(true); + }); + + it("posts the stop-and-disable action when the modal confirm path runs", () => { + const { harness, vscode } = loadSettingsHarness(); + + harness.stopSessionsAndDisableAgentOrchestration(); + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "disableAgentOrchestrationAndStopSessions", + }); + }); + + it("disables orchestration when the transition is safe", () => { + const { harness, vscode, syncClientSessionSelection, saveWebviewState } = + loadSettingsHarness({ + waitingSessions: [{ id: "1" }], + }); + + harness.toggleAgentOrchestrationSetting(); + + expect(syncClientSessionSelection).toHaveBeenCalledTimes(1); + expect(saveWebviewState).toHaveBeenCalledTimes(1); + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "updateAgentOrchestrationSetting", + enabled: false, + }); + expect(harness.getAgentOrchestrationEnabled()).toBe(false); + expect(harness.getSplitViewEnabled()).toBe(false); + }); +}); diff --git a/tasksync-chat/src/webview-ui/state.js b/tasksync-chat/src/webview-ui/state.js index 804a5a8..67340c9 100644 --- a/tasksync-chat/src/webview-ui/state.js +++ b/tasksync-chat/src/webview-ui/state.js @@ -109,6 +109,7 @@ let lastPendingContentHtml = ""; // Settings state (initialized from constants to maintain SSOT) let soundEnabled = true; let interactiveApprovalEnabled = true; +let agentOrchestrationEnabled = true; let autoAppendEnabled = false; let autoAppendText = ""; // Custom text appended to responses for the active session let alwaysAppendReminder = false; // Global AskUser reminder toggle @@ -210,6 +211,7 @@ let simpleAlertModalOverlay = null; let settingsModal, settingsModalOverlay, settingsModalClose; let soundToggle, interactiveApprovalToggle, + agentOrchestrationToggle, autoAppendToggle, autoAppendTextRow, autoAppendTextInput, @@ -255,6 +257,45 @@ function sessionExists(sessionId) { ); } +function resolveSingleSessionId() { + if (sessionExists(serverActiveSessionId)) { + return serverActiveSessionId; + } + if (sessionExists(activeSessionId)) { + return activeSessionId; + } + var fallbackSession = Array.isArray(sessions) + ? sessions.find(function (session) { + return session.status === "active"; + }) + : null; + return fallbackSession ? fallbackSession.id : null; +} + +function getVisibleSessions() { + if (agentOrchestrationEnabled) { + return Array.isArray(sessions) ? sessions : []; + } + var singletonSessionId = resolveSingleSessionId(); + if (!singletonSessionId) { + return []; + } + return sessions.filter(function (session) { + return session.id === singletonSessionId; + }); +} + +function getWaitingActiveSessions() { + return Array.isArray(sessions) + ? sessions.filter(function (session) { + return ( + session.status === "active" && + (session.waitingOnUser || !!session.pendingToolCallId) + ); + }) + : []; +} + function requestFollowServerActiveSession() { followServerActiveSessionOnce = true; } @@ -262,6 +303,12 @@ function requestFollowServerActiveSession() { function syncClientSessionSelection(nextServerActiveSessionId) { serverActiveSessionId = nextServerActiveSessionId || null; + if (!agentOrchestrationEnabled) { + activeSessionId = resolveSingleSessionId(); + followServerActiveSessionOnce = false; + return; + } + if (!sessionExists(activeSessionId)) { activeSessionId = null; } @@ -290,5 +337,9 @@ function getSubmitSessionId() { } function isSplitViewLayoutActive() { - return splitViewEnabled && sessionExists(activeSessionId); + return ( + agentOrchestrationEnabled && + splitViewEnabled && + sessionExists(activeSessionId) + ); } diff --git a/tasksync-chat/src/webview/messageRouter.test.ts b/tasksync-chat/src/webview/messageRouter.test.ts index aa86d63..b2e47d2 100644 --- a/tasksync-chat/src/webview/messageRouter.test.ts +++ b/tasksync-chat/src/webview/messageRouter.test.ts @@ -6,6 +6,7 @@ import { handleWebviewMessage, handleWebviewReady, } from "./messageRouter"; +import * as settingsH from "./settingsHandlers"; /** * Create the smallest provider stub needed to verify session-routing behavior. @@ -129,6 +130,35 @@ describe("handleWebviewMessage session actions", () => { }); }); +describe("handleWebviewMessage settings actions", () => { + it("routes updateAgentOrchestrationSetting to the settings handler", () => { + const p = createMockP(); + const spy = vi + .spyOn(settingsH, "handleUpdateAgentOrchestrationSetting") + .mockResolvedValue(undefined); + + handleWebviewMessage(p, { + type: "updateAgentOrchestrationSetting", + enabled: false, + }); + + expect(spy).toHaveBeenCalledWith(p, false); + }); + + it("routes disableAgentOrchestrationAndStopSessions to the settings handler", () => { + const p = createMockP(); + const spy = vi + .spyOn(settingsH, "handleStopSessionsAndDisableAgentOrchestration") + .mockResolvedValue(undefined); + + handleWebviewMessage(p, { + type: "disableAgentOrchestrationAndStopSessions", + }); + + expect(spy).toHaveBeenCalledWith(p); + }); +}); + describe("handleWebviewReady", () => { function createReadyMockP(overrides: Partial = {}) { return { diff --git a/tasksync-chat/src/webview/messageRouter.ts b/tasksync-chat/src/webview/messageRouter.ts index e370529..eae3629 100644 --- a/tasksync-chat/src/webview/messageRouter.ts +++ b/tasksync-chat/src/webview/messageRouter.ts @@ -112,6 +112,12 @@ export function handleWebviewMessage(p: P, message: FromWebviewMessage): void { case "updateInteractiveApprovalSetting": settingsH.handleUpdateInteractiveApprovalSetting(p, message.enabled); break; + case "updateAgentOrchestrationSetting": + settingsH.handleUpdateAgentOrchestrationSetting(p, message.enabled); + break; + case "disableAgentOrchestrationAndStopSessions": + void settingsH.handleStopSessionsAndDisableAgentOrchestration(p); + break; case "updateAutoAppendSetting": settingsH.handleUpdateAutoAppendSetting(p, message.enabled); break; diff --git a/tasksync-chat/src/webview/settingsHandlers.comprehensive.test.ts b/tasksync-chat/src/webview/settingsHandlers.comprehensive.test.ts index 26e6902..353370c 100644 --- a/tasksync-chat/src/webview/settingsHandlers.comprehensive.test.ts +++ b/tasksync-chat/src/webview/settingsHandlers.comprehensive.test.ts @@ -16,6 +16,7 @@ import { SESSION_WARNING_HOURS_MIN, } from "../constants/remoteConstants"; import { + AGENT_ORCHESTRATION_MULTI_WAITING_WARNING, applyAutoAppendToResponse, broadcastAllSettingsToRemote, buildSettingsPayload, @@ -28,6 +29,8 @@ import { handleRemoveReusablePrompt, handleReorderAutopilotPrompts, handleSearchSlashCommands, + handleStopSessionsAndDisableAgentOrchestration, + handleUpdateAgentOrchestrationSetting, handleUpdateAutoAppendSetting, handleUpdateAutoAppendText, handleUpdateAutopilotSetting, @@ -61,6 +64,7 @@ function createMockP(overrides: Partial = {}) { return { _soundEnabled: true, _interactiveApprovalEnabled: true, + _agentOrchestrationEnabled: true, _autoAppendEnabled: false, _autoAppendText: "", _sendWithCtrlEnter: false, @@ -74,18 +78,26 @@ function createMockP(overrides: Partial = {}) { _humanLikeDelayMin: DEFAULT_HUMAN_LIKE_DELAY_MIN, _humanLikeDelayMax: DEFAULT_HUMAN_LIKE_DELAY_MAX, _sessionWarningHours: DEFAULT_SESSION_WARNING_HOURS, + _sessionFrozenElapsed: null, _consecutiveAutoResponses: 0, _isUpdatingConfig: false, _AUTOPILOT_DEFAULT_TEXT: "Continue", + _stopSessionTimerInterval: vi.fn(), + _updateViewTitle: vi.fn(), _view: { webview: { postMessage: vi.fn(), }, }, _remoteServer: null as any, + _updateSessionsUI: vi.fn(), + _getSingleSession: vi.fn(() => activeSession), + cancelPendingToolCall: vi.fn(() => true), _saveSessionsToDisk: vi.fn(), _sessionManager: { getActiveSession: () => activeSession, + getActiveSessions: () => [activeSession], + getActiveSessionId: () => activeSession.id, }, ...overrides, } as any; @@ -391,6 +403,39 @@ describe("loadSettings", () => { expect(p._autopilotPrompts).toEqual([]); expect(p._autopilotText).toBe("Continue"); }); + + it("keeps default singleton collapse but can skip it for config refresh", () => { + const config = createMockConfig({ + notificationSound: false, + interactiveApproval: false, + agentOrchestration: false, + }); + config.inspect.mockReturnValue(undefined); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP(); + + // Default callers must keep the existing collapse behavior. + loadSettings(p); + expect(p._soundEnabled).toBe(false); + expect(p._interactiveApprovalEnabled).toBe(false); + expect(p._agentOrchestrationEnabled).toBe(false); + expect(p._getSingleSession).toHaveBeenCalledTimes(1); + + p._getSingleSession.mockClear(); + p._soundEnabled = true; + p._interactiveApprovalEnabled = true; + p._agentOrchestrationEnabled = true; + + // The config-refresh opt-in should still reload settings while skipping collapse. + loadSettings(p, { skipSingleSessionCollapse: true }); + expect(p._soundEnabled).toBe(false); + expect(p._interactiveApprovalEnabled).toBe(false); + expect(p._agentOrchestrationEnabled).toBe(false); + expect(p._getSingleSession).not.toHaveBeenCalled(); + }); }); // ─── buildSettingsPayload ─────────────────────────────────── @@ -421,6 +466,7 @@ describe("buildSettingsPayload", () => { const payload = buildSettingsPayload(p); expect(payload.soundEnabled).toBe(false); + expect(payload.agentOrchestrationEnabled).toBe(true); expect(payload.autoAppendEnabled).toBe(false); expect(payload.autoAppendText).toBe(""); expect(payload.autopilotEnabled).toBe(true); @@ -527,6 +573,141 @@ describe("handleUpdateInteractiveApprovalSetting", () => { }); }); +describe("handleUpdateAgentOrchestrationSetting", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("updates the workspace setting and resolves the singleton when disabled", async () => { + const config = createMockConfig({}); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + const broadcast = vi.fn(); + + const p = createMockP({ + _remoteServer: { broadcast }, + }); + await handleUpdateAgentOrchestrationSetting(p, false); + + expect(p._agentOrchestrationEnabled).toBe(false); + expect(config.update).toHaveBeenCalledWith( + "agentOrchestration", + false, + vscode.ConfigurationTarget.Workspace, + ); + expect(p._getSingleSession).toHaveBeenCalledTimes(1); + expect(p._updateSessionsUI).toHaveBeenCalledTimes(1); + expect(broadcast).toHaveBeenCalledTimes(1); + expect(broadcast).toHaveBeenCalledWith( + "settingsChanged", + expect.objectContaining({ agentOrchestrationEnabled: false }), + ); + }); + + it("does not resolve a singleton session when left enabled", async () => { + const config = createMockConfig({}); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + + const p = createMockP(); + await handleUpdateAgentOrchestrationSetting(p, true); + + expect(p._agentOrchestrationEnabled).toBe(true); + expect(p._getSingleSession).not.toHaveBeenCalled(); + }); + + it("refuses to disable when multiple active sessions are already waiting", async () => { + const config = createMockConfig({}); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + const warningSpy = vi + .spyOn(vscode.window, "showWarningMessage") + .mockResolvedValue(undefined as any); + + const p = createMockP(); + const activeSession = p._sessionManager.getActiveSession(); + activeSession.waitingOnUser = true; + activeSession.pendingToolCallId = "tc_1"; + const secondWaitingSession = { + ...activeSession, + id: "2", + title: "Agent 2", + pendingToolCallId: "tc_2", + }; + p._sessionManager.getActiveSessions = () => [ + activeSession, + secondWaitingSession, + ]; + + await handleUpdateAgentOrchestrationSetting(p, false); + + expect(p._agentOrchestrationEnabled).toBe(true); + expect(config.update).not.toHaveBeenCalled(); + expect(p._getSingleSession).not.toHaveBeenCalled(); + expect(warningSpy).toHaveBeenCalledWith( + AGENT_ORCHESTRATION_MULTI_WAITING_WARNING, + ); + expect(p._updateSessionsUI).toHaveBeenCalledTimes(1); + }); + + it("stops waiting sessions and then disables orchestration", async () => { + const config = createMockConfig({}); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + const broadcast = vi.fn(); + + const p = createMockP({ + _remoteServer: { broadcast }, + }); + const activeSession = p._sessionManager.getActiveSession(); + activeSession.waitingOnUser = true; + activeSession.pendingToolCallId = "tc_1"; + activeSession.sessionStartTime = 123; + activeSession.aiTurnActive = false; + activeSession.unread = true; + activeSession.sessionTerminated = false; + const secondWaitingSession = { + ...activeSession, + id: "2", + title: "Agent 2", + pendingToolCallId: "tc_2", + sessionFrozenElapsed: null, + }; + p._sessionManager.getActiveSessions = () => [ + activeSession, + secondWaitingSession, + ]; + + await handleStopSessionsAndDisableAgentOrchestration(p); + + expect(p.cancelPendingToolCall).toHaveBeenCalledWith( + "[Session stopped before disabling Agent Orchestration]", + "1", + ); + expect(p.cancelPendingToolCall).toHaveBeenCalledWith( + "[Session stopped before disabling Agent Orchestration]", + "2", + ); + expect(activeSession.sessionTerminated).toBe(true); + expect(secondWaitingSession.sessionTerminated).toBe(true); + expect(config.update).toHaveBeenCalledWith( + "agentOrchestration", + false, + vscode.ConfigurationTarget.Workspace, + ); + expect(p._agentOrchestrationEnabled).toBe(false); + expect(broadcast).toHaveBeenCalledTimes(1); + expect(broadcast).toHaveBeenCalledWith( + "settingsChanged", + expect.objectContaining({ agentOrchestrationEnabled: false }), + ); + }); +}); + describe("handleUpdateAutoAppendSetting", () => { beforeEach(() => { vi.restoreAllMocks(); diff --git a/tasksync-chat/src/webview/settingsHandlers.ts b/tasksync-chat/src/webview/settingsHandlers.ts index f6c393c..b92e6b6 100644 --- a/tasksync-chat/src/webview/settingsHandlers.ts +++ b/tasksync-chat/src/webview/settingsHandlers.ts @@ -17,8 +17,17 @@ import { SESSION_WARNING_HOURS_MAX, SESSION_WARNING_HOURS_MIN, } from "../constants/remoteConstants"; -import type { P, ReusablePrompt, ToWebviewMessage } from "./webviewTypes"; -import { buildFinalResponseText, generateId } from "./webviewUtils"; +import type { + ChatSession, + P, + ReusablePrompt, + ToWebviewMessage, +} from "./webviewTypes"; +import { + buildFinalResponseText, + generateId, + markSessionTerminated, +} from "./webviewUtils"; let vscode: typeof vscodeTypes; try { @@ -167,7 +176,14 @@ export function normalizeRemoteMaxDevices(value: unknown): number { return Math.max(MIN_REMOTE_MAX_DEVICES, parsedValue); } -export function loadSettings(p: P): void { +/** + * Keep config-refresh reads side-effect free until the caller decides whether singleton collapse is valid. + */ +export type LoadSettingsOptions = { + skipSingleSessionCollapse?: boolean; +}; + +export function loadSettings(p: P, options: LoadSettingsOptions = {}): void { const config = vscode.workspace.getConfiguration(CONFIG_SECTION); const activeSession = p._sessionManager?.getActiveSession?.(); p._soundEnabled = config.get("notificationSound", true); @@ -175,6 +191,10 @@ export function loadSettings(p: P): void { "interactiveApproval", true, ); + p._agentOrchestrationEnabled = config.get( + "agentOrchestration", + true, + ); p._autoAppendEnabled = activeSession?.autoAppendEnabled === true; p._autoAppendText = typeof activeSession?.autoAppendText === "string" @@ -240,6 +260,13 @@ export function loadSettings(p: P): void { ) : DEFAULT_SESSION_WARNING_HOURS; p._sendWithCtrlEnter = config.get("sendWithCtrlEnter", false); + if ( + !options.skipSingleSessionCollapse && + !p._agentOrchestrationEnabled && + p._sessionManager.getActiveSessions().length + ) { + p._getSingleSession(); + } // Ensure min <= max if (p._humanLikeDelayMin > p._humanLikeDelayMax) { p._humanLikeDelayMin = p._humanLikeDelayMax; @@ -265,6 +292,7 @@ export async function saveReusablePrompts(p: P): Promise { export function buildSettingsPayload(p: P): { soundEnabled: boolean; interactiveApprovalEnabled: boolean; + agentOrchestrationEnabled: boolean; autoAppendEnabled: boolean; autoAppendText: string; alwaysAppendReminder: boolean; @@ -286,6 +314,7 @@ export function buildSettingsPayload(p: P): { return { soundEnabled: p._soundEnabled, interactiveApprovalEnabled: p._interactiveApprovalEnabled, + agentOrchestrationEnabled: p._agentOrchestrationEnabled, autoAppendEnabled: p._autoAppendEnabled, autoAppendText: p._autoAppendText, alwaysAppendReminder: p._alwaysAppendReminder, @@ -310,6 +339,20 @@ export function buildSettingsPayload(p: P): { }; } +export const AGENT_ORCHESTRATION_MULTI_WAITING_WARNING = + "TaskSync can't disable Agent Orchestration while multiple sessions are waiting on you. Reply to or clear those prompts first."; + +export function getWaitingActiveSessions( + p: Pick, +): ChatSession[] { + return p._sessionManager + .getActiveSessions() + .filter((session) => session.waitingOnUser || !!session.pendingToolCallId); +} + +const STOP_AND_DISABLE_REASON = + "[Session stopped before disabling Agent Orchestration]"; + export function updateSettingsUI(p: P): void { const payload = buildSettingsPayload(p); p._view?.webview.postMessage({ @@ -361,6 +404,51 @@ export async function handleUpdateInteractiveApprovalSetting( }); } +export async function handleUpdateAgentOrchestrationSetting( + p: P, + enabled: boolean, +): Promise { + if (!enabled && getWaitingActiveSessions(p).length > 1) { + p._agentOrchestrationEnabled = true; + vscode.window.showWarningMessage(AGENT_ORCHESTRATION_MULTI_WAITING_WARNING); + updateSettingsUI(p); + sendSessionSettingsToWebview(p); + p._updateSessionsUI(); + broadcastAllSettingsToRemote(p); + return; + } + p._agentOrchestrationEnabled = enabled; + await withConfigGuard(p, async () => { + const config = vscode.workspace.getConfiguration(CONFIG_SECTION); + await config.update( + "agentOrchestration", + enabled, + vscode.ConfigurationTarget.Workspace, + ); + }); + if (!enabled) { + p._getSingleSession(); + } + updateSettingsUI(p); + sendSessionSettingsToWebview(p); + p._updateSessionsUI(); + broadcastAllSettingsToRemote(p); +} + +export async function handleStopSessionsAndDisableAgentOrchestration( + p: P, +): Promise { + const waitingSessions = getWaitingActiveSessions(p); + for (const session of waitingSessions) { + if (session.pendingToolCallId) { + p.cancelPendingToolCall(STOP_AND_DISABLE_REASON, session.id); + } + markSessionTerminated(p, session); + } + p._saveSessionsToDisk?.(); + await handleUpdateAgentOrchestrationSetting(p, false); +} + export async function handleUpdateAutoAppendSetting( p: P, enabled: boolean, diff --git a/tasksync-chat/src/webview/toolCallHandler.test.ts b/tasksync-chat/src/webview/toolCallHandler.test.ts index df3e920..f590a36 100644 --- a/tasksync-chat/src/webview/toolCallHandler.test.ts +++ b/tasksync-chat/src/webview/toolCallHandler.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import * as vscode from "../__mocks__/vscode"; +import { ASKUSER_SUPERSEDED_MESSAGE } from "../constants/remoteConstants"; import { handleResponseTimeout, waitForUserResponse } from "./toolCallHandler"; describe("waitForUserResponse", () => { @@ -93,7 +94,15 @@ describe("waitForUserResponse", () => { expect(oldResolve).toHaveBeenCalledWith( expect.objectContaining({ + value: ASKUSER_SUPERSEDED_MESSAGE, cancelled: true, + directive: expect.objectContaining({ + kind: "cancelled", + reason: "superseded", + action: "call_ask_user_again", + sessionId: "1", + reaskExactSameQuestion: true, + }), }), ); expect(session.pendingToolCallId).not.toBe("tc_old"); @@ -473,6 +482,96 @@ describe("waitForUserResponse", () => { }); }); + it("rejects stale explicit session ids before singleton rebinding when orchestration is off", async () => { + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue({ + get: vi.fn((key: string, defaultValue?: unknown) => + key === "responseTimeout" ? "0" : defaultValue, + ), + inspect: vi.fn(), + } as any); + + const session = { + id: "1", + title: "Agent 1", + status: "active", + queue: [], + queueEnabled: true, + history: [], + attachments: [], + autopilotEnabled: false, + waitingOnUser: false, + unread: false, + createdAt: Date.now(), + pendingToolCallId: null, + sessionStartTime: null, + sessionFrozenElapsed: null, + sessionTerminated: false, + sessionWarningShown: false, + aiTurnActive: false, + consecutiveAutoResponses: 0, + autopilotIndex: 0, + }; + + const p = { + _agentOrchestrationEnabled: false, + _bindSession: vi.fn(() => session), + _getSession: vi.fn(() => undefined), + _sessionManager: { + getActiveSessionId: () => "1", + isDeletedSessionId: () => false, + }, + _pendingRequests: new Map(), + _toolCallSessionMap: new Map(), + _currentSessionCallsMap: new Map(), + _currentToolCallId: null, + _webviewReady: true, + _view: { + webview: { + postMessage: vi.fn(), + }, + show: vi.fn(), + }, + playNotificationSound: vi.fn(), + _updateSessionsUI: vi.fn(), + _saveSessionsToDisk: vi.fn(), + _syncActiveSessionState: vi.fn(() => { + p._currentToolCallId = session.pendingToolCallId; + }), + _clearResponseTimeoutTimer: vi.fn(), + _applyHumanLikeDelay: vi.fn(), + _remoteServer: { broadcast: vi.fn() }, + } as any; + + const promise = waitForUserResponse( + p, + "Stay out of the singleton", + "stale-99", + ); + + await Promise.resolve(); + + expect(p._bindSession).not.toHaveBeenCalled(); + const resolvePending = p._pendingRequests.get(session.pendingToolCallId); + if (typeof resolvePending === "function") { + resolvePending({ + value: "Answer", + queue: false, + attachments: [], + }); + } + + await expect(promise).resolves.toMatchObject({ + cancelled: true, + value: expect.stringContaining('REJECTED. session_id "stale-99"'), + directive: expect.objectContaining({ + kind: "rejected", + reason: "stale_session_id", + action: "start_new_chat_with_new_session_id", + }), + }); + expect(session.history).toHaveLength(0); + }); + it("does not create unread when queue auto-responds without a real pending entry", async () => { vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue({ get: vi.fn((key: string, defaultValue?: unknown) => @@ -707,7 +806,15 @@ describe("waitForUserResponse — session_id defensive validation", () => { const p = {} as any; const result = await waitForUserResponse(p, "Test?", undefined); expect(result.cancelled).toBe(true); - expect(result.value).toContain("session_id is required"); + expect(result.value).toContain("REJECTED. session_id IS REQUIRED."); + expect(result.value).toContain( + "CALL ask_user AGAIN WITH A VALID session_id.", + ); + expect(result.directive).toMatchObject({ + kind: "rejected", + reason: "missing_session_id", + action: "pass_exact_session_id", + }); }); it("rejects when sessionId is a number (defensive — should be coerced upstream)", async () => { @@ -718,7 +825,7 @@ describe("waitForUserResponse — session_id defensive validation", () => { 42 as unknown as string, ); expect(result.cancelled).toBe(true); - expect(result.value).toContain("session_id is required"); + expect(result.value).toContain("REJECTED. session_id IS REQUIRED."); }); it("rejects when sessionId is null", async () => { @@ -729,7 +836,7 @@ describe("waitForUserResponse — session_id defensive validation", () => { null as unknown as string, ); expect(result.cancelled).toBe(true); - expect(result.value).toContain("session_id is required"); + expect(result.value).toContain("REJECTED. session_id IS REQUIRED."); }); it("rejects with terminated message when session is terminated", async () => { @@ -750,7 +857,147 @@ describe("waitForUserResponse — session_id defensive validation", () => { const result = await waitForUserResponse(p, "Test?", "5"); expect(result.cancelled).toBe(true); - expect(result.value).toContain("already terminated"); + expect(result.value).toContain('REJECTED. session_id "5" IS TERMINATED.'); + expect(result.value).toContain( + 'CALL ask_user AGAIN WITH session_id "auto".', + ); + expect(result.directive).toMatchObject({ + kind: "rejected", + reason: "terminated_session", + action: "call_ask_user_again_with_auto_session", + sessionId: "auto", + }); + }); + + it("rejects stale deleted ids with a new-chat directive in single-session mode", async () => { + const reboundSession = { + id: "1", + sessionTerminated: false, + pendingToolCallId: null, + queue: [], + queueEnabled: true, + history: [], + attachments: [], + autopilotEnabled: false, + waitingOnUser: false, + unread: false, + createdAt: Date.now(), + sessionStartTime: null, + sessionFrozenElapsed: null, + sessionWarningShown: false, + aiTurnActive: false, + consecutiveAutoResponses: 0, + autopilotIndex: 0, + }; + const p = { + _agentOrchestrationEnabled: false, + _bindSession: vi.fn(() => reboundSession), + _getSession: vi.fn(() => undefined), + _sessionManager: { + getActiveSessionId: () => "1", + isDeletedSessionId: vi.fn((id: string) => id === "deleted-99"), + }, + _pendingRequests: new Map(), + _toolCallSessionMap: new Map(), + _currentSessionCallsMap: new Map(), + _currentToolCallId: null, + _webviewReady: true, + _view: { webview: { postMessage: vi.fn() }, show: vi.fn() }, + playNotificationSound: vi.fn(), + _updateSessionsUI: vi.fn(), + _saveSessionsToDisk: vi.fn(), + _syncActiveSessionState: vi.fn(), + _clearResponseTimeoutTimer: vi.fn(), + _applyHumanLikeDelay: vi.fn(), + _remoteServer: { broadcast: vi.fn() }, + } as any; + + const result = await waitForUserResponse(p, "Test?", "deleted-99"); + + expect(result.cancelled).toBe(true); + expect(result.value).toContain( + 'REJECTED. session_id "deleted-99" WAS DELETED.', + ); + expect(result.value).toContain("START A NEW CHAT TO GET A NEW session_id."); + expect(result.value).not.toContain( + 'CALL ask_user AGAIN WITH session_id "auto".', + ); + expect(result.directive).toMatchObject({ + kind: "rejected", + reason: "deleted_session", + action: "start_new_chat_with_new_session_id", + }); + expect(p._bindSession).not.toHaveBeenCalled(); + }); + + it("rejects stale terminated ids with a new-chat directive in single-session mode", async () => { + const terminatedSession = { + id: "terminated-5", + sessionTerminated: true, + pendingToolCallId: null, + queue: [], + queueEnabled: false, + }; + const reboundSession = { + id: "1", + sessionTerminated: false, + pendingToolCallId: null, + queue: [], + queueEnabled: true, + history: [], + attachments: [], + autopilotEnabled: false, + waitingOnUser: false, + unread: false, + createdAt: Date.now(), + sessionStartTime: null, + sessionFrozenElapsed: null, + sessionWarningShown: false, + aiTurnActive: false, + consecutiveAutoResponses: 0, + autopilotIndex: 0, + }; + const p = { + _agentOrchestrationEnabled: false, + _bindSession: vi.fn(() => reboundSession), + _getSession: vi.fn((id: string) => + id === "terminated-5" ? terminatedSession : undefined, + ), + _sessionManager: { + getActiveSessionId: () => "1", + isDeletedSessionId: () => false, + }, + _pendingRequests: new Map(), + _toolCallSessionMap: new Map(), + _currentSessionCallsMap: new Map(), + _currentToolCallId: null, + _webviewReady: true, + _view: { webview: { postMessage: vi.fn() }, show: vi.fn() }, + playNotificationSound: vi.fn(), + _updateSessionsUI: vi.fn(), + _saveSessionsToDisk: vi.fn(), + _syncActiveSessionState: vi.fn(), + _clearResponseTimeoutTimer: vi.fn(), + _applyHumanLikeDelay: vi.fn(), + _remoteServer: { broadcast: vi.fn() }, + } as any; + + const result = await waitForUserResponse(p, "Test?", "terminated-5"); + + expect(result.cancelled).toBe(true); + expect(result.value).toContain( + 'REJECTED. session_id "terminated-5" IS TERMINATED.', + ); + expect(result.value).toContain("START A NEW CHAT TO GET A NEW session_id."); + expect(result.value).not.toContain( + 'CALL ask_user AGAIN WITH session_id "auto".', + ); + expect(result.directive).toMatchObject({ + kind: "rejected", + reason: "terminated_session", + action: "start_new_chat_with_new_session_id", + }); + expect(p._bindSession).not.toHaveBeenCalled(); }); it("rejects at boundary when session ID is tombstoned (before creating session)", async () => { @@ -763,7 +1010,18 @@ describe("waitForUserResponse — session_id defensive validation", () => { const result = await waitForUserResponse(p, "Test?", "deleted-99"); expect(result.cancelled).toBe(true); - expect(result.value).toContain("session was deleted"); + expect(result.value).toContain( + 'REJECTED. session_id "deleted-99" WAS DELETED.', + ); + expect(result.value).toContain( + 'CALL ask_user AGAIN WITH session_id "auto".', + ); + expect(result.directive).toMatchObject({ + kind: "rejected", + reason: "deleted_session", + action: "call_ask_user_again_with_auto_session", + sessionId: "auto", + }); // _bindSession must NOT have been called — no session object created expect(p._bindSession).not.toHaveBeenCalled(); }); diff --git a/tasksync-chat/src/webview/toolCallHandler.ts b/tasksync-chat/src/webview/toolCallHandler.ts index 85a5ea0..eea7bf6 100644 --- a/tasksync-chat/src/webview/toolCallHandler.ts +++ b/tasksync-chat/src/webview/toolCallHandler.ts @@ -4,6 +4,7 @@ */ import * as vscode from "vscode"; import { + ASKUSER_SUPERSEDED_MESSAGE, CONFIG_SECTION, DEFAULT_MAX_CONSECUTIVE_AUTO_RESPONSES, } from "../constants/remoteConstants"; @@ -11,6 +12,7 @@ import { import { isApprovalQuestion, parseChoices } from "./choiceParser"; import * as settingsH from "./settingsHandlers"; import type { + AskUserDirective, ChatSession, P, ToolCallEntry, @@ -42,19 +44,100 @@ function persistSessionState(p: P, session: ChatSession): void { function buildRejectedResult( session: ChatSession | undefined, message: string, + directive?: AskUserDirective, ): UserResponseResult { return { value: message, queue: session ? sessionHasQueuedItems(session) : false, attachments: [], cancelled: true, + directive, }; } +function buildMissingSessionIdRejectedMessage(): string { + return "REJECTED. session_id IS REQUIRED. CALL ask_user AGAIN WITH A VALID session_id."; +} + +function buildDeletedSessionRejectedMessage(sessionId: string): string { + return `REJECTED. session_id "${sessionId}" WAS DELETED. DO NOT REUSE IT. CALL ask_user AGAIN WITH session_id "auto".`; +} + +function buildTerminatedSessionRejectedMessage(sessionId: string): string { + return `REJECTED. session_id "${sessionId}" IS TERMINATED. DO NOT REUSE IT. CALL ask_user AGAIN WITH session_id "auto".`; +} + +/** + * Keep single-session stale recovery text aligned with the new-chat directive + * so the model is not pointed back at the singleton bootstrap path. + */ +function buildSingleSessionDeletedSessionRejectedMessage( + sessionId: string, +): string { + return `REJECTED. session_id "${sessionId}" WAS DELETED. DO NOT REUSE IT. START A NEW CHAT TO GET A NEW session_id.`; +} + +/** + * Keep single-session stale recovery text aligned with the new-chat directive + * so the model is not pointed back at the singleton bootstrap path. + */ +function buildSingleSessionTerminatedSessionRejectedMessage( + sessionId: string, +): string { + return `REJECTED. session_id "${sessionId}" IS TERMINATED. DO NOT REUSE IT. START A NEW CHAT TO GET A NEW session_id.`; +} + +function buildStaleSingleSessionRejectedMessage(sessionId: string): string { + return `REJECTED. session_id "${sessionId}" IS STALE FOR THE CURRENT SINGLE SESSION. DO NOT REUSE IT. START A NEW CHAT TO GET A NEW session_id.`; +} + function isSessionActive(p: P, sessionId: string): boolean { return p._sessionManager.getActiveSessionId() === sessionId; } +/** + * Resolve the single-session validation target without rebinding the request. + * This mirrors the singleton selection rules closely enough to reject stale + * explicit ids before `_bindSession()` can discard the caller's session_id. + */ +function getSingleSessionValidationTargetId(p: P): string | undefined { + const waitingSessions = + typeof p._sessionManager.getActiveSessions === "function" + ? settingsH.getWaitingActiveSessions(p) + : []; + const preferredSession = waitingSessions[0]; + if (preferredSession) { + return preferredSession.id; + } + + const activeSession = p._sessionManager.getActiveSession?.(); + if (activeSession && !activeSession.sessionTerminated) { + return activeSession.id; + } + + return ( + p._sessionManager + .getActiveSessions?.() + .find((session) => !session.sessionTerminated)?.id ?? + p._sessionManager.getActiveSessionId?.() ?? + undefined + ); +} + +/** + * Reuse the existing rejected-result transport for single-session stale ids + * instead of widening the tool protocol for this narrow fix. + */ +function buildSingleSessionNewChatDirective( + reason: AskUserDirective["reason"], +): AskUserDirective { + return { + kind: "rejected", + reason, + action: "start_new_chat_with_new_session_id", + }; +} + /** * SSOT: Resolve the effective autopilot text from a session, cycling through * prompts when configured. Mutates `session.autopilotIndex` on each call. @@ -97,11 +180,17 @@ function replacePendingToolCallForSession(p: P, session: ChatSession): void { if (previousResolve) { previousResolve({ - value: - "TaskSync replaced a previous unanswered ask_user in this same session because a newer ask_user arrived.", + value: ASKUSER_SUPERSEDED_MESSAGE, queue: sessionHasQueuedItems(session), attachments: [], cancelled: true, + directive: { + kind: "cancelled", + reason: "superseded", + action: "call_ask_user_again", + sessionId: session.id, + reaskExactSameQuestion: true, + }, } satisfies UserResponseResult); } } @@ -143,6 +232,7 @@ export async function waitForUserResponse( ): Promise { const normalizedSessionId = typeof sessionId === "string" ? sessionId.trim() : undefined; + const agentOrchestrationEnabled = p._agentOrchestrationEnabled !== false; debugLog( "[TaskSync] waitForUserResponse — question:", question.slice(0, 80), @@ -153,16 +243,58 @@ export async function waitForUserResponse( if (!normalizedSessionId) { return buildRejectedResult( undefined, - "TaskSync rejected ask_user because session_id is required. Start a TaskSync conversation and pass that exact session_id on every ask_user call.", + buildMissingSessionIdRejectedMessage(), + { + kind: "rejected", + reason: "missing_session_id", + action: "pass_exact_session_id", + }, ); } + if (!agentOrchestrationEnabled) { + if (p._sessionManager.isDeletedSessionId(normalizedSessionId)) { + return buildRejectedResult( + undefined, + buildSingleSessionDeletedSessionRejectedMessage(normalizedSessionId), + buildSingleSessionNewChatDirective("deleted_session"), + ); + } + + const explicitSession = p._getSession?.(normalizedSessionId); + if (explicitSession?.sessionTerminated) { + return buildRejectedResult( + explicitSession, + buildSingleSessionTerminatedSessionRejectedMessage(normalizedSessionId), + buildSingleSessionNewChatDirective("terminated_session"), + ); + } + + const validationTargetId = getSingleSessionValidationTargetId(p); + if (validationTargetId && normalizedSessionId !== validationTargetId) { + return buildRejectedResult( + undefined, + buildStaleSingleSessionRejectedMessage(normalizedSessionId), + buildSingleSessionNewChatDirective("stale_session_id"), + ); + } + } + // Reject stale deleted session IDs at the boundary — before creating // any session object — so tombstoned IDs never leak into the session pool. - if (p._sessionManager.isDeletedSessionId(normalizedSessionId)) { + if ( + agentOrchestrationEnabled && + p._sessionManager.isDeletedSessionId(normalizedSessionId) + ) { return buildRejectedResult( undefined, - `TaskSync rejected ask_user for session_id ${normalizedSessionId} because this session was deleted. Start a new Copilot chat that uses a new session_id instead of reusing this one.`, + buildDeletedSessionRejectedMessage(normalizedSessionId), + { + kind: "rejected", + reason: "deleted_session", + action: "call_ask_user_again_with_auto_session", + sessionId: "auto", + }, ); } @@ -176,7 +308,13 @@ export async function waitForUserResponse( if (session.sessionTerminated) { return buildRejectedResult( session, - `TaskSync rejected ask_user for session_id ${session.id} because this conversation is already terminated. Start a new Copilot chat that uses a new session_id instead of reusing this one.`, + buildTerminatedSessionRejectedMessage(session.id), + { + kind: "rejected", + reason: "terminated_session", + action: "call_ask_user_again_with_auto_session", + sessionId: "auto", + }, ); } diff --git a/tasksync-chat/src/webview/webviewProvider.test.ts b/tasksync-chat/src/webview/webviewProvider.test.ts index 566131a..17d8155 100644 --- a/tasksync-chat/src/webview/webviewProvider.test.ts +++ b/tasksync-chat/src/webview/webviewProvider.test.ts @@ -1,6 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import "../__mocks__/vscode"; +import * as vscode from "../__mocks__/vscode"; +import { CONFIG_SECTION } from "../constants/remoteConstants"; +import * as chatSessionUtils from "../utils/chatSessionUtils"; import { ChatSessionManager } from "./chatSessionManager"; +import * as settingsH from "./settingsHandlers"; const loadSessionsFromDiskAsyncMock = vi.fn(); @@ -18,11 +22,26 @@ function createProviderHarness(manager: ChatSessionManager) { _currentSessionCallsMap: new Map(), _currentSessionCalls: [], _promptQueue: [], - attachments: [], + _attachments: [], _pendingRequests: new Map(), _queueEnabled: true, _currentToolCallId: null, + _soundEnabled: true, + _interactiveApprovalEnabled: true, + _agentOrchestrationEnabled: true, + _autoAppendEnabled: false, + _autoAppendText: "", + _alwaysAppendReminder: false, _autopilotEnabled: false, + _autopilotText: "Continue", + _autopilotPrompts: [], + _reusablePrompts: [], + _humanLikeDelayEnabled: true, + _humanLikeDelayMin: 1, + _humanLikeDelayMax: 5, + _sessionWarningHours: 2, + _sendWithCtrlEnter: false, + _AUTOPILOT_DEFAULT_TEXT: "Continue", _sessionStartTime: null, _sessionFrozenElapsed: null, _sessionTerminated: false, @@ -30,15 +49,94 @@ function createProviderHarness(manager: ChatSessionManager) { _aiTurnActive: false, _consecutiveAutoResponses: 0, _autopilotIndex: 0, + _view: { + show: vi.fn(), + webview: { + postMessage: vi.fn(), + }, + }, + _remoteServer: null, _updateViewTitle: vi.fn(), _updateCurrentSessionUI: vi.fn(), _updateQueueUI: vi.fn(), _updateAttachmentsUI: vi.fn(), _loadSettings: vi.fn(), _updateSettingsUI: vi.fn(), + _saveSessionsToDisk: vi.fn(), _startSessionTimerInterval: vi.fn(), _stopSessionTimerInterval: vi.fn(), _updateSessionsUI: vi.fn(), + _responseTimeoutTimers: new Map(), + }; +} + +/** + * Keep config-driven tests on the same mock shape used across the suite. + */ +function createMockConfig(values: Record = {}) { + return { + get: vi.fn((key: string, defaultValue?: unknown) => + Object.prototype.hasOwnProperty.call(values, key) + ? values[key] + : defaultValue, + ), + update: vi.fn().mockResolvedValue(undefined), + inspect: vi.fn(() => undefined), + }; +} + +/** + * Route the test through the real settings helpers so config-refresh side effects are observable. + */ +function enableRealSettingsRefreshFlow( + provider: ReturnType, + TaskSyncWebviewProvider: typeof import("./webviewProvider").TaskSyncWebviewProvider, +) { + Object.assign(provider, { + _loadSettings: + TaskSyncWebviewProvider.prototype._loadSettings.bind(provider), + _updateSettingsUI: + TaskSyncWebviewProvider.prototype._updateSettingsUI.bind(provider), + }); +} + +/** + * Inspect only the posted messages relevant to the behavior under test. + */ +function getPostedMessages( + provider: ReturnType, + type: string, +) { + return provider._view.webview.postMessage.mock.calls + .map((call) => call[0]) + .filter((message) => message?.type === type); +} + +/** + * Assert the latest remote chat bootstrap call without repeating the same + * shape checks in each single-session fresh-chat regression. + */ +function expectLatestStartNewSessionChatCall( + spy: { mock: { calls: unknown[][] } }, + expectedSessionId: string, +) { + const latestCall = spy.mock.calls.at(-1); + expect(latestCall).toBeDefined(); + if (!latestCall) { + throw new Error("expected startNewSessionChat to be called"); + } + expect(latestCall[0]).toBe(expectedSessionId); + for (const argument of latestCall.slice(1)) { + expect(argument).toEqual(expect.any(String)); + } +} + +/** + * Build a minimal configuration-change event with only the VS Code surface this provider uses. + */ +function createConfigurationChangeEvent(affectedKeys: string[]) { + return { + affectsConfiguration: (key: string) => affectedKeys.includes(key), }; } @@ -214,3 +312,437 @@ describe("TaskSyncWebviewProvider unread shared state", () => { expect(provider._updateSessionsUI).toHaveBeenCalledTimes(1); }); }); + +describe("TaskSyncWebviewProvider modal commands", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + }); + + it("focuses the webview before opening settings from the title bar", async () => { + const { TaskSyncWebviewProvider } = await import("./webviewProvider"); + const manager = new ChatSessionManager(); + const provider = Object.assign( + Object.create(TaskSyncWebviewProvider.prototype), + createProviderHarness(manager), + ); + + provider.openSettingsModal(); + + expect(provider._view.show).toHaveBeenCalledWith(false); + expect(provider._view.webview.postMessage).toHaveBeenCalledWith({ + type: "openSettingsModal", + }); + }); + + it("focuses the webview before opening a new session dialog from the title bar", async () => { + const { TaskSyncWebviewProvider } = await import("./webviewProvider"); + const manager = new ChatSessionManager(); + const provider = Object.assign( + Object.create(TaskSyncWebviewProvider.prototype), + createProviderHarness(manager), + ); + + const opened = provider.openNewSessionModal(); + + expect(opened).toBe(true); + expect(provider._view.show).toHaveBeenCalledWith(false); + expect(provider._view.webview.postMessage).toHaveBeenCalledWith({ + type: "openNewSessionModal", + }); + }); +}); + +describe("TaskSyncWebviewProvider single-session helpers", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + loadSessionsFromDiskAsyncMock.mockReset(); + }); + + it("reuses the active singleton for missing session ids when orchestration is off", async () => { + const { TaskSyncWebviewProvider } = await import("./webviewProvider"); + const manager = new ChatSessionManager(); + const activeSession = manager.createSession("Agent 1"); + + const provider = Object.assign( + Object.create(TaskSyncWebviewProvider.prototype), + createProviderHarness(manager), + { + _agentOrchestrationEnabled: false, + }, + ); + + const resolvedSession = provider.createSessionForMissingId(); + + expect(resolvedSession.id).toBe(activeSession.id); + expect(manager.size).toBe(1); + }); + + it("binds arbitrary incoming ids to the singleton session when orchestration is off", async () => { + const { TaskSyncWebviewProvider } = await import("./webviewProvider"); + const manager = new ChatSessionManager(); + const activeSession = manager.createSession("Agent 1"); + + const provider = Object.assign( + Object.create(TaskSyncWebviewProvider.prototype), + createProviderHarness(manager), + { + _agentOrchestrationEnabled: false, + }, + ); + + const resolvedSession = provider._bindSession("99"); + + expect(resolvedSession.id).toBe(activeSession.id); + expect(manager.size).toBe(1); + }); + + it("prefers the waiting session when collapsing into a singleton", async () => { + const { TaskSyncWebviewProvider } = await import("./webviewProvider"); + const manager = new ChatSessionManager(); + const activeSession = manager.createSession("Agent 1"); + const waitingSession = manager.createSession("Agent 2"); + waitingSession.waitingOnUser = true; + waitingSession.pendingToolCallId = "tc_waiting"; + manager.setActiveSession(activeSession.id); + const broadcast = vi.fn(); + const remoteState = { activeSessionId: waitingSession.id, sessions: [] }; + + const provider = Object.assign( + Object.create(TaskSyncWebviewProvider.prototype), + createProviderHarness(manager), + { + _agentOrchestrationEnabled: false, + _remoteServer: { broadcast }, + getRemoteState: vi.fn(() => remoteState), + }, + ); + + const resolvedSession = provider._getSingleSession(); + + expect(resolvedSession.id).toBe(waitingSession.id); + expect(manager.getActiveSessionId()).toBe(waitingSession.id); + expect(provider._updateSessionsUI).toHaveBeenCalledTimes(1); + expect(provider.getRemoteState).toHaveBeenCalledTimes(1); + expect(broadcast).toHaveBeenCalledTimes(1); + expect(broadcast).toHaveBeenCalledWith("state", remoteState); + }); + + it("creates a fresh singleton when the only active session is terminated", async () => { + const { TaskSyncWebviewProvider } = await import("./webviewProvider"); + const manager = new ChatSessionManager(); + const terminatedSession = manager.createSession("Agent 1"); + terminatedSession.sessionTerminated = true; + const broadcast = vi.fn(); + const remoteState = { activeSessionId: "next", sessions: [] }; + + const provider = Object.assign( + Object.create(TaskSyncWebviewProvider.prototype), + createProviderHarness(manager), + { + _agentOrchestrationEnabled: false, + _remoteServer: { broadcast }, + getRemoteState: vi.fn(() => remoteState), + }, + ); + + const resolvedSession = provider._getSingleSession(); + + expect(resolvedSession.id).not.toBe(terminatedSession.id); + expect(resolvedSession.sessionTerminated).toBe(false); + expect(manager.size).toBe(2); + expect(provider.getRemoteState).toHaveBeenCalledTimes(1); + expect(broadcast).toHaveBeenCalledTimes(1); + expect(broadcast).toHaveBeenCalledWith("state", remoteState); + }); + + it("rejects an external disable without singleton collapse side effects", async () => { + const { TaskSyncWebviewProvider } = await import("./webviewProvider"); + const config = createMockConfig({ + agentOrchestration: false, + }); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + const manager = new ChatSessionManager(); + const activeSession = manager.createSession("Agent 1"); + const firstWaitingSession = manager.createSession("Agent 2"); + firstWaitingSession.waitingOnUser = true; + firstWaitingSession.pendingToolCallId = "tc_1"; + const secondWaitingSession = manager.createSession("Agent 3"); + secondWaitingSession.waitingOnUser = true; + secondWaitingSession.pendingToolCallId = "tc_2"; + manager.setActiveSession(activeSession.id); + const provider = Object.assign( + Object.create(TaskSyncWebviewProvider.prototype), + createProviderHarness(manager), + { + _isUpdatingConfig: false, + }, + ); + enableRealSettingsRefreshFlow(provider, TaskSyncWebviewProvider); + const warningSpy = vi + .spyOn(vscode.window, "showWarningMessage") + .mockImplementation(async () => undefined); + const revertSpy = vi + .spyOn(settingsH, "handleUpdateAgentOrchestrationSetting") + .mockResolvedValue(undefined); + + provider._handleConfigurationChange( + createConfigurationChangeEvent([`${CONFIG_SECTION}.agentOrchestration`]), + ); + + expect(warningSpy).toHaveBeenCalledWith( + settingsH.AGENT_ORCHESTRATION_MULTI_WAITING_WARNING, + ); + expect(revertSpy).toHaveBeenCalledWith(provider, true); + expect(manager.getActiveSessionId()).toBe(activeSession.id); + expect(getPostedMessages(provider, "updateSettings")).toHaveLength(0); + expect(provider._updateSessionsUI).not.toHaveBeenCalled(); + expect(provider._saveSessionsToDisk).not.toHaveBeenCalled(); + }); + + it("reloads another setting from the same rejected config event", async () => { + const { TaskSyncWebviewProvider } = await import("./webviewProvider"); + const config = createMockConfig({ + agentOrchestration: false, + notificationSound: false, + }); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + const manager = new ChatSessionManager(); + const activeSession = manager.createSession("Agent 1"); + const firstWaitingSession = manager.createSession("Agent 1"); + firstWaitingSession.waitingOnUser = true; + firstWaitingSession.pendingToolCallId = "tc_1"; + const secondWaitingSession = manager.createSession("Agent 2"); + secondWaitingSession.waitingOnUser = true; + secondWaitingSession.pendingToolCallId = "tc_2"; + manager.setActiveSession(activeSession.id); + + const provider = Object.assign( + Object.create(TaskSyncWebviewProvider.prototype), + createProviderHarness(manager), + { + _isUpdatingConfig: false, + }, + ); + enableRealSettingsRefreshFlow(provider, TaskSyncWebviewProvider); + const warningSpy = vi + .spyOn(vscode.window, "showWarningMessage") + .mockImplementation(async () => undefined); + const revertSpy = vi.spyOn( + settingsH, + "handleUpdateAgentOrchestrationSetting", + ); + + provider._handleConfigurationChange( + createConfigurationChangeEvent([ + `${CONFIG_SECTION}.agentOrchestration`, + `${CONFIG_SECTION}.notificationSound`, + ]), + ); + + expect(warningSpy).toHaveBeenCalledWith( + settingsH.AGENT_ORCHESTRATION_MULTI_WAITING_WARNING, + ); + expect(provider._soundEnabled).toBe(false); + expect(revertSpy).toHaveBeenCalledWith(provider, true); + await revertSpy.mock.results[0]?.value; + + expect(getPostedMessages(provider, "updateSettings").at(-1)).toEqual( + expect.objectContaining({ + type: "updateSettings", + soundEnabled: false, + agentOrchestrationEnabled: true, + }), + ); + }); + + it("still collapses to the preferred waiting session when external disable is allowed", async () => { + const { TaskSyncWebviewProvider } = await import("./webviewProvider"); + const config = createMockConfig({ + agentOrchestration: false, + }); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + const manager = new ChatSessionManager(); + const activeSession = manager.createSession("Agent 1"); + const waitingSession = manager.createSession("Agent 2"); + waitingSession.waitingOnUser = true; + waitingSession.pendingToolCallId = "tc_waiting"; + manager.setActiveSession(activeSession.id); + + const provider = Object.assign( + Object.create(TaskSyncWebviewProvider.prototype), + createProviderHarness(manager), + { + _isUpdatingConfig: false, + }, + ); + enableRealSettingsRefreshFlow(provider, TaskSyncWebviewProvider); + const warningSpy = vi + .spyOn(vscode.window, "showWarningMessage") + .mockImplementation(async () => undefined); + const revertSpy = vi.spyOn( + settingsH, + "handleUpdateAgentOrchestrationSetting", + ); + + provider._handleConfigurationChange( + createConfigurationChangeEvent([`${CONFIG_SECTION}.agentOrchestration`]), + ); + + expect(warningSpy).not.toHaveBeenCalled(); + expect(revertSpy).not.toHaveBeenCalled(); + expect(manager.getActiveSessionId()).toBe(waitingSession.id); + expect(provider._updateSessionsUI).toHaveBeenCalledTimes(1); + expect(provider._saveSessionsToDisk).toHaveBeenCalledTimes(1); + }); + + it("starts plain single-session New Session with a fresh TaskSync session id", async () => { + const { TaskSyncWebviewProvider } = await import("./webviewProvider"); + const config = createMockConfig({ + agentOrchestration: false, + tremoteChatCommand: "chat.command", + }); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + const startNewSessionChatSpy = vi + .spyOn(chatSessionUtils, "startNewSessionChat") + .mockResolvedValue(undefined); + const manager = new ChatSessionManager(); + const previousSession = manager.createSession("Agent 1"); + previousSession.waitingOnUser = true; + previousSession.unread = true; + previousSession.pendingToolCallId = "tc-old"; + const pendingEntry = { + id: "tc-old", + sessionId: previousSession.id, + prompt: "Old question?", + response: "", + timestamp: 1, + isFromQueue: false, + status: "pending" as const, + }; + previousSession.history.unshift(pendingEntry); + const resolver = vi.fn(); + + const provider = Object.assign( + Object.create(TaskSyncWebviewProvider.prototype), + createProviderHarness(manager), + { + _agentOrchestrationEnabled: false, + _pendingRequests: new Map([["tc-old", resolver]]), + _toolCallSessionMap: new Map([["tc-old", previousSession.id]]), + _currentSessionCallsMap: new Map([["tc-old", pendingEntry]]), + _remoteServer: { broadcast: vi.fn() }, + }, + ); + + await provider.startNewSessionAndResetCopilotChat(); + + const newActiveSessionId = manager.getActiveSessionId(); + expect(newActiveSessionId).toBeDefined(); + if (!newActiveSessionId) { + throw new Error("expected a fresh single-session id to be created"); + } + expect(newActiveSessionId).not.toBe(previousSession.id); + expect(previousSession.sessionTerminated).toBe(false); + expect(previousSession.pendingToolCallId).toBeNull(); + expect(previousSession.waitingOnUser).toBe(false); + expect(previousSession.unread).toBe(false); + expect(previousSession.history[0]).toMatchObject({ + status: "cancelled", + response: "[Session reset by user]", + }); + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ + value: "[Session reset by user]", + cancelled: true, + }), + ); + expect(provider._getSingleSession().id).toBe(newActiveSessionId); + expectLatestStartNewSessionChatCall( + startNewSessionChatSpy, + newActiveSessionId, + ); + }); + + it("starts stopCurrentSession in single-session mode with a fresh TaskSync session id", async () => { + const { TaskSyncWebviewProvider } = await import("./webviewProvider"); + const config = createMockConfig({ + agentOrchestration: false, + tremoteChatCommand: "chat.command", + }); + vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue( + config as any, + ); + const startNewSessionChatSpy = vi + .spyOn(chatSessionUtils, "startNewSessionChat") + .mockResolvedValue(undefined); + const manager = new ChatSessionManager(); + const previousSession = manager.createSession("Agent 1"); + previousSession.waitingOnUser = true; + previousSession.unread = true; + previousSession.pendingToolCallId = "tc-stop"; + previousSession.sessionStartTime = Date.now() - 1000; + const pendingEntry = { + id: "tc-stop", + sessionId: previousSession.id, + prompt: "Stop question?", + response: "", + timestamp: 1, + isFromQueue: false, + status: "pending" as const, + }; + previousSession.history.unshift(pendingEntry); + const resolver = vi.fn(); + + const provider = Object.assign( + Object.create(TaskSyncWebviewProvider.prototype), + createProviderHarness(manager), + { + _agentOrchestrationEnabled: false, + _pendingRequests: new Map([["tc-stop", resolver]]), + _toolCallSessionMap: new Map([["tc-stop", previousSession.id]]), + _currentSessionCallsMap: new Map([["tc-stop", pendingEntry]]), + _remoteServer: { broadcast: vi.fn() }, + }, + ); + + await provider.startNewSessionAndResetCopilotChat({ + stopCurrentSession: true, + }); + + const newActiveSessionId = manager.getActiveSessionId(); + expect(newActiveSessionId).toBeDefined(); + if (!newActiveSessionId) { + throw new Error("expected a fresh single-session id to be created"); + } + expect(newActiveSessionId).not.toBe(previousSession.id); + expect(previousSession.sessionTerminated).toBe(true); + expect(previousSession.pendingToolCallId).toBeNull(); + expect(previousSession.waitingOnUser).toBe(false); + expect(previousSession.history[0]).toMatchObject({ + status: "cancelled", + response: "[Session stopped by user]", + }); + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ + value: "[Session stopped by user]", + cancelled: true, + }), + ); + expect(previousSession.sessionFrozenElapsed).not.toBeNull(); + expect(provider._getSingleSession().id).toBe(newActiveSessionId); + expectLatestStartNewSessionChatCall( + startNewSessionChatSpy, + newActiveSessionId, + ); + }); +}); diff --git a/tasksync-chat/src/webview/webviewProvider.ts b/tasksync-chat/src/webview/webviewProvider.ts index 827eac8..0bb22ef 100644 --- a/tasksync-chat/src/webview/webviewProvider.ts +++ b/tasksync-chat/src/webview/webviewProvider.ts @@ -153,6 +153,7 @@ export class TaskSyncWebviewProvider // Interactive approval buttons enabled (loaded from VS Code settings) _interactiveApprovalEnabled: boolean = true; + _agentOrchestrationEnabled: boolean = true; // Mirrors the ACTIVE session's Auto Append state. _autoAppendEnabled: boolean = false; _autoAppendText: string = ""; @@ -253,37 +254,54 @@ export class TaskSyncWebviewProvider // Listen for settings changes this._disposables.push( vscode.workspace.onDidChangeConfiguration((e) => { - // Skip reload if we're the ones updating config (prevents race condition) - if (this._isUpdatingConfig) { - return; - } - if ( - e.affectsConfiguration(`${CONFIG_SECTION}.notificationSound`) || - e.affectsConfiguration(`${CONFIG_SECTION}.interactiveApproval`) || - e.affectsConfiguration( - `${CONFIG_SECTION}.alwaysAppendAskUserReminder`, - ) || - e.affectsConfiguration(`${CONFIG_SECTION}.reusablePrompts`) || - e.affectsConfiguration(`${CONFIG_SECTION}.responseTimeout`) || - e.affectsConfiguration(`${CONFIG_SECTION}.remoteMaxDevices`) || - e.affectsConfiguration(`${CONFIG_SECTION}.sessionWarningHours`) || - e.affectsConfiguration( - `${CONFIG_SECTION}.maxConsecutiveAutoResponses`, - ) || - e.affectsConfiguration(`${CONFIG_SECTION}.humanLikeDelay`) || - e.affectsConfiguration(`${CONFIG_SECTION}.humanLikeDelayMin`) || - e.affectsConfiguration(`${CONFIG_SECTION}.humanLikeDelayMax`) || - e.affectsConfiguration(`${CONFIG_SECTION}.sendWithCtrlEnter`) - ) { - this._loadSettings(); - this._updateSettingsUI(); - // Broadcast all settings to remote clients - settingsH.broadcastAllSettingsToRemote(this); - } + this._handleConfigurationChange(e); }), ); } + public _handleConfigurationChange(e: vscode.ConfigurationChangeEvent): void { + if (this._isUpdatingConfig) { + return; + } + const orchestrationChanged = e.affectsConfiguration( + `${CONFIG_SECTION}.agentOrchestration`, + ); + if ( + e.affectsConfiguration(`${CONFIG_SECTION}.notificationSound`) || + e.affectsConfiguration(`${CONFIG_SECTION}.interactiveApproval`) || + orchestrationChanged || + e.affectsConfiguration(`${CONFIG_SECTION}.alwaysAppendAskUserReminder`) || + e.affectsConfiguration(`${CONFIG_SECTION}.reusablePrompts`) || + e.affectsConfiguration(`${CONFIG_SECTION}.responseTimeout`) || + e.affectsConfiguration(`${CONFIG_SECTION}.remoteMaxDevices`) || + e.affectsConfiguration(`${CONFIG_SECTION}.sessionWarningHours`) || + e.affectsConfiguration(`${CONFIG_SECTION}.maxConsecutiveAutoResponses`) || + e.affectsConfiguration(`${CONFIG_SECTION}.humanLikeDelay`) || + e.affectsConfiguration(`${CONFIG_SECTION}.humanLikeDelayMin`) || + e.affectsConfiguration(`${CONFIG_SECTION}.humanLikeDelayMax`) || + e.affectsConfiguration(`${CONFIG_SECTION}.sendWithCtrlEnter`) + ) { + this._loadSettings({ skipSingleSessionCollapse: true }); + if ( + orchestrationChanged && + !this._agentOrchestrationEnabled && + settingsH.getWaitingActiveSessions(this).length > 1 + ) { + vscode.window.showWarningMessage( + settingsH.AGENT_ORCHESTRATION_MULTI_WAITING_WARNING, + ); + void settingsH.handleUpdateAgentOrchestrationSetting(this, true); + return; + } + if (orchestrationChanged && !this._agentOrchestrationEnabled) { + this._getSingleSession(); + } + this._updateSettingsUI(); + settingsH.sendSessionSettingsToWebview(this); + settingsH.broadcastAllSettingsToRemote(this); + } + } + /** * Save current tool call history to persisted history (called on deactivate) * Uses synchronous save because deactivate cannot await async operations @@ -316,6 +334,7 @@ export class TaskSyncWebviewProvider } public openHistoryModal(): void { + this._view?.show(false); this._view?.webview.postMessage({ type: "openHistoryModal", } satisfies ToWebviewMessage); @@ -323,6 +342,7 @@ export class TaskSyncWebviewProvider } public openSettingsModal(): void { + this._view?.show(false); this._view?.webview.postMessage({ type: "openSettingsModal", } satisfies ToWebviewMessage); @@ -387,6 +407,10 @@ export class TaskSyncWebviewProvider } public _setActiveSession(sessionId: string | null): boolean { + if (!this._agentOrchestrationEnabled) { + this._getSingleSession(); + return true; + } const switched = this._sessionManager.setActiveSession(sessionId); if (switched) { this._syncActiveSessionState(); @@ -449,7 +473,49 @@ export class TaskSyncWebviewProvider this._updateSettingsUI(); } + public _getSingleSession(): ChatSession { + const activeSession = this._sessionManager.getActiveSession(); + const preferredSession = settingsH.getWaitingActiveSessions(this)[0]; + if ( + activeSession && + !activeSession.sessionTerminated && + (!preferredSession || preferredSession.id === activeSession.id) + ) { + return activeSession; + } + + const fallbackSession = + preferredSession ?? + this._sessionManager + .getActiveSessions() + .find((session) => !session.sessionTerminated); + if (fallbackSession) { + this._sessionManager.setActiveSession(fallbackSession.id); + this._syncActiveSessionState(); + // Keep remote clients aligned with the newly active singleton's full state. + this._remoteServer?.broadcast("state", this.getRemoteState()); + this._updateSessionsUI(); + this._saveSessionsToDisk(); + return fallbackSession; + } + + const session = this._sessionManager.createSession( + this._sessionManager.getNextAgentTitle(), + undefined, + this._sessionDefaults(), + ); + this._syncActiveSessionState(); + // Keep remote clients aligned with the newly created singleton's full state. + this._remoteServer?.broadcast("state", this.getRemoteState()); + this._updateSessionsUI(); + this._saveSessionsToDisk(); + return session; + } + public _bindSession(sessionId: string): ChatSession { + if (!this._agentOrchestrationEnabled) { + return this._getSingleSession(); + } return this._ensureSession( sessionId, this._sessionManager.getNextAgentTitle(), @@ -457,6 +523,9 @@ export class TaskSyncWebviewProvider } public createSessionForMissingId(): ChatSession { + if (!this._agentOrchestrationEnabled) { + return this._getSingleSession(); + } const session = this._sessionManager.createSession( this._sessionManager.getNextAgentTitle(), this._sessionManager.getNextSessionId(), @@ -488,7 +557,9 @@ export class TaskSyncWebviewProvider useQueuedPrompt?: boolean; stopCurrentSession?: boolean; }): Promise { - const previousActiveSession = this._sessionManager.getActiveSession(); + const previousActiveSession = this._agentOrchestrationEnabled + ? this._sessionManager.getActiveSession() + : this._getSingleSession(); let queuedPromptFromPrevious: QueuedPrompt | undefined; const useQueuedPrompt = options?.useQueuedPrompt; @@ -501,7 +572,33 @@ export class TaskSyncWebviewProvider } } - if (options?.stopCurrentSession && previousActiveSession) { + if (!this._agentOrchestrationEnabled && previousActiveSession) { + if (options?.stopCurrentSession) { + if (previousActiveSession.pendingToolCallId) { + this.cancelPendingToolCall( + "[Session stopped by user]", + previousActiveSession.id, + ); + } + markSessionTerminated(this, previousActiveSession); + this._saveSessionsToDisk(); + } else { + // Plain "New Session" must stop the old waiting state without + // terminating the old session, otherwise the singleton chooser can + // collapse back to the stale waiting session. + if (previousActiveSession.pendingToolCallId) { + this.cancelPendingToolCall( + "[Session reset by user]", + previousActiveSession.id, + ); + } + previousActiveSession.pendingToolCallId = null; + previousActiveSession.waitingOnUser = false; + previousActiveSession.unread = false; + previousActiveSession.aiTurnActive = false; + this._saveSessionsToDisk(); + } + } else if (options?.stopCurrentSession && previousActiveSession) { if (previousActiveSession.pendingToolCallId) { this.cancelPendingToolCall( "[Session stopped by user]", @@ -579,6 +676,7 @@ export class TaskSyncWebviewProvider public openNewSessionModal(): boolean { if (!this._view) return false; + this._view.show(false); this._view.webview.postMessage({ type: "openNewSessionModal", } satisfies ToWebviewMessage); @@ -587,6 +685,7 @@ export class TaskSyncWebviewProvider public openResetSessionModal(): boolean { if (!this._view) return false; + this._view.show(false); this._view.webview.postMessage({ type: "openResetSessionModal", } satisfies ToWebviewMessage); @@ -609,8 +708,8 @@ export class TaskSyncWebviewProvider session.stopSessionTimerInterval(this); } - _loadSettings(): void { - settingsH.loadSettings(this); + _loadSettings(options?: settingsH.LoadSettingsOptions): void { + settingsH.loadSettings(this, options); } /** diff --git a/tasksync-chat/src/webview/webviewTypes.ts b/tasksync-chat/src/webview/webviewTypes.ts index de03e72..e7698c5 100644 --- a/tasksync-chat/src/webview/webviewTypes.ts +++ b/tasksync-chat/src/webview/webviewTypes.ts @@ -44,6 +44,25 @@ export interface UserResponseResult { queue: boolean; attachments: AttachmentInfo[]; cancelled?: boolean; // Indicates if the request was superseded by a new one + directive?: AskUserDirective; +} + +export interface AskUserDirective { + kind: "bootstrap" | "cancelled" | "rejected"; + reason: + | "auto_assigned_session" + | "superseded" + | "missing_session_id" + | "stale_session_id" + | "deleted_session" + | "terminated_session"; + action: + | "call_ask_user_again" + | "call_ask_user_again_with_auto_session" + | "pass_exact_session_id" + | "start_new_chat_with_new_session_id"; + sessionId?: string; + reaskExactSameQuestion?: boolean; } // Tool call history entry @@ -131,6 +150,7 @@ export type ToWebviewMessage = type: "updateSettings"; soundEnabled: boolean; interactiveApprovalEnabled: boolean; + agentOrchestrationEnabled: boolean; autoAppendEnabled: boolean; autoAppendText: string; autopilotEnabled: boolean; @@ -230,6 +250,8 @@ export type FromWebviewMessage = | { type: "openSettingsModal" } | { type: "updateSoundSetting"; enabled: boolean } | { type: "updateInteractiveApprovalSetting"; enabled: boolean } + | { type: "updateAgentOrchestrationSetting"; enabled: boolean } + | { type: "disableAgentOrchestrationAndStopSessions" } | { type: "updateAutoAppendSetting"; enabled: boolean } | { type: "updateAutoAppendText"; text: string } | { type: "updateAlwaysAppendReminderSetting"; enabled: boolean } diff --git a/tasksync-chat/src/webview/webviewUtils.ts b/tasksync-chat/src/webview/webviewUtils.ts index bec49f3..736668f 100644 --- a/tasksync-chat/src/webview/webviewUtils.ts +++ b/tasksync-chat/src/webview/webviewUtils.ts @@ -25,7 +25,7 @@ export { generateId }; /** * Debug log — only outputs when `tasksync.debugLogging` is enabled. - * Uses console.error to appear in the VS Code debug console. + * Uses console.info so debug traces do not appear as errors in the VS Code debug console. */ export function debugLog(...args: unknown[]): void { if ( @@ -33,7 +33,7 @@ export function debugLog(...args: unknown[]): void { .getConfiguration(CONFIG_SECTION) .get("debugLogging", false) ) { - console.error("[TaskSync]", ...args); + console.info("[TaskSync]", ...args); } }