diff --git a/.gitignore b/.gitignore index eb03e3e..36249f2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules +PLAN.md *.log diff --git a/CHANGELOG.md b/CHANGELOG.md index 3448199..7c98227 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,25 @@ ## [Unreleased] -_No unreleased changes._ +### Added + +- Backend tools: add `agents.js list --json` support with expanded session metadata (`session_id`, `created_at`, `workspace`, `title`, `dynamic_title`, `template_id`, `template_name`, `last_output_at`, `output_active`). ([#31](https://github.com/kcosr/termstation/pull/31)) +- Backend tools: add `agents.js create --workspace` plus `AGENTS_WORKSPACE` support, defaulting peer agent sessions to the `Default` workspace. +- Backend tools: restore full title overrides for `agents.js create` via `--title` and `SESSION_TITLE`, with `--title` taking precedence over the environment variable. +- Frontend: add a Developer WebSocket Session Trace viewer (in-memory ring buffer) with refresh, clear, and copy actions for client-side session/websocket event debugging. ([#31](https://github.com/kcosr/termstation/pull/31)) + +### Changed + +- Backend tools: require an explicit `-` sentinel to read `agents.js create/send` message text from stdin, so omitted messages no longer block on non-TTY stdin. + +### Fixed + +- Backend: ensure `SESSION_TOK` and `SESSIONS_API_BASE_URL` are injected for `isolation_mode=none` sessions, including route-created and auto-started sessions. ([#31](https://github.com/kcosr/termstation/pull/31)) +- Backend/frontend: stop dynamic OSC title churn from forcing repeated session refresh work while an explicit session title override is set. +- Frontend desktop: keep keyboard session-tab navigation local to the current window instead of focusing a dedicated session window. ([#31](https://github.com/kcosr/termstation/pull/31)) +- Frontend desktop/macOS: remove dedicated-window header/sidebar toggle inset in fullscreen by syncing renderer fullscreen state reliably for child windows. ([#31](https://github.com/kcosr/termstation/pull/31)) +- Frontend mobile: reduce Android dictation-triggered horizontal panning by constraining terminal container overflow and adjusting xterm helper textarea placement on coarse-pointer devices. ([#31](https://github.com/kcosr/termstation/pull/31)) +- Frontend: restrict foreground session reattach/history reload handling to actual mobile runtimes so desktop and non-mobile web do not redraw active sessions on every app focus change. ## [0.0.8] - 2026-03-13 diff --git a/backend/config/files/agent_communication.md b/backend/config/files/agent_communication.md index 8f528d2..ae3cc44 100644 --- a/backend/config/files/agent_communication.md +++ b/backend/config/files/agent_communication.md @@ -8,15 +8,18 @@ Only create or use peer agent sessions when the user explicitly asks you to invo - Do not create peer sessions proactively if the user has not requested one. - Always use `{BOOTSTRAP_DIR}/bin/agents.js` — do not print peer messages directly. - Create peer (`ISSUE_ID` is required so the peer session is linked to the correct issue): - - `ISSUE_ID= {BOOTSTRAP_DIR}/bin/agents.js create [--description "..."]` + - `ISSUE_ID= {BOOTSTRAP_DIR}/bin/agents.js create [--title ""] [--description "..."] [--workspace ""]` - Example for code review: - `ISSUE_ID=1 {BOOTSTRAP_DIR}/bin/agents.js create claude --description 'Review PR #2 for issue #1 (Gitea TypeScript webhook handler)'` + `ISSUE_ID=1 {BOOTSTRAP_DIR}/bin/agents.js create claude --title 'Review PR #2' --workspace 'Reviews'` + - To pipe the initial prompt, pass `-` as the final argument. + - Workspace defaults to `Default`. Override it with `--workspace` or `AGENTS_WORKSPACE=`. + - Title can be fully overridden with `--title` or `SESSION_TITLE=`. `--title` takes precedence. - Send a message: - Single-line: `{BOOTSTRAP_DIR}/bin/agents.js send "Message"` - - Multi-line (preferred): + - Multi-line (preferred, pass `-` to read from stdin): ```bash - cat << 'MSG' | {BOOTSTRAP_DIR}/bin/agents.js send + cat << 'MSG' | {BOOTSTRAP_DIR}/bin/agents.js send - Please review PR # for #. MSG ``` diff --git a/backend/files/AGENTS.md b/backend/files/AGENTS.md index ecdc477..a8ffc61 100644 --- a/backend/files/AGENTS.md +++ b/backend/files/AGENTS.md @@ -28,9 +28,9 @@ To send a message to a peer agent: Examples (single‑line): - `{BOOTSTRAP_DIR}/bin/agents.js send "Hello! How can I help?"` -Preferred for multi‑line/special characters (single‑quoted heredoc to avoid shell expansion): +Preferred for multi‑line/special characters (single‑quoted heredoc to avoid shell expansion, with `-` to read from stdin): ```bash -cat << 'MSG' | {BOOTSTRAP_DIR}/bin/agents.js send +cat << 'MSG' | {BOOTSTRAP_DIR}/bin/agents.js send - Please review MR ! for #. Summary @@ -55,17 +55,19 @@ To get a list of active peer agent session IDs, use: - `{BOOTSTRAP_DIR}/bin/agents.js list` To create a new peer agent (when user asks you to get help from claude, codex, or cursor): -- Use `{BOOTSTRAP_DIR}/bin/agents.js create [--description ""]` (you can also pipe a prompt) -- Example (single‑line): `{BOOTSTRAP_DIR}/bin/agents.js create claude --description "Review my MR changes"` -- Preferred heredoc for multi‑line prompts or special characters: +- Use `{BOOTSTRAP_DIR}/bin/agents.js create [--title ""] [--description ""] [--workspace ""]` +- Example (single‑line): `{BOOTSTRAP_DIR}/bin/agents.js create claude --title "Review Session" --workspace "Reviews"` +- Preferred heredoc for multi‑line prompts or special characters (pass `-` to read the prompt from stdin): ```bash - cat << 'MSG' | {BOOTSTRAP_DIR}/bin/agents.js create claude --description "Review MR changes" + cat << 'MSG' | {BOOTSTRAP_DIR}/bin/agents.js create claude --description "Review MR changes" - Please review MR ! for # — brief summary, key files, and links. MSG ``` - On success, it prints: `Peer agent is available` - Then send your instructions with: `{BOOTSTRAP_DIR}/bin/agents.js send ""` - Never create more than one session to the same agent. +- Workspace defaults to `Default`. Override it with `--workspace` or `AGENTS_WORKSPACE=`. +- Title can be fully overridden with `--title` or `SESSION_TITLE=`. `--title` takes precedence. To stop a peer agent session: - Only stop peer sessions when the user explicitly instructs you to do so. @@ -80,10 +82,10 @@ Session Title and Issue Assignment - With `REPO` and `ISSUE_ID`: ` #` - With `REPO` only: `` - Otherwise: `Session for ` -- Provide a brief `--description` to append to the title (recommended). Example: +- Provide a brief `--description` to append to the auto-generated title when you do not need a full override. Example: - `ISSUE_ID=751 {BOOTSTRAP_DIR}/bin/agents.js create codex --description "Implement pagination"` - - Title becomes: ` #751: Implement pagination` -- Note: `SESSION_TITLE` is no longer supported; use `--description` for a short suffix, or pass a full custom title via future tooling when available. +- Title becomes: ` #751: Implement pagination` +- Use `SESSION_TITLE` or `--title` when you want a full custom title instead of the computed default. `--title` takes precedence over `SESSION_TITLE`. - Branch is derived automatically as `issue/` when `ISSUE_ID` is set (unless `BRANCH` is provided). - Example: - `ISSUE_ID=751 {BOOTSTRAP_DIR}/bin/agents.js create codex` diff --git a/backend/models/terminal-session.js b/backend/models/terminal-session.js index fe280e8..08c5d27 100644 --- a/backend/models/terminal-session.js +++ b/backend/models/terminal-session.js @@ -68,6 +68,7 @@ export class TerminalSession { this.workspace_service_port = Number.isFinite(Number(options.workspace_service_port)) ? Math.floor(Number(options.workspace_service_port)) : null; + this.session_token = typeof options.session_token === 'string' ? options.session_token : ''; // Fork metadata this.is_fork = options.is_fork === true; this.forked_from_session_id = options.forked_from_session_id || null; @@ -224,6 +225,10 @@ export class TerminalSession { TERMSTATION_USER: this.created_by, SESSIONS_BASE_URL: config.SESSIONS_BASE_URL }; + if (String(this.isolation_mode || 'none') === 'none') { + if (this.session_token) env.SESSION_TOK = this.session_token; + if (config.SESSIONS_API_BASE_URL) env.SESSIONS_API_BASE_URL = config.SESSIONS_API_BASE_URL; + } // For host sessions (non-container) without a per-session bootstrap, make // backend-managed bootstrap tools available by appending backend/bootstrap/bin @@ -446,11 +451,13 @@ export class TerminalSession { if (typeof data === 'string' && data) { const { title, carry } = parseOscTitles(data, this._oscBuffer || ''); this._oscBuffer = carry || ''; - // If changed, update and broadcast session update + // If changed, update and broadcast session update unless an explicit + // title override is already set for the session. if (title && title !== this.dynamic_title) { this.dynamic_title = title; try { - if (global.connectionManager) { + const hasExplicitTitle = typeof this.title === 'string' && this.title.trim().length > 0; + if (!hasExplicitTitle && global.connectionManager) { global.connectionManager.broadcast({ type: 'session_updated', update_type: 'updated', diff --git a/backend/routes/sessions.js b/backend/routes/sessions.js index 3546eb7..38ab47d 100644 --- a/backend/routes/sessions.js +++ b/backend/routes/sessions.js @@ -919,6 +919,7 @@ router.post('/', async (req, res) => { workspace_service_port: workspaceServicePort, // Persist effective parameters (provided + defaults) on the session template_parameters: resolvedTemplateParameters, + session_token: sessionUnifiedToken || '', session_id: initialSessionId, // Optional alias (safe slug only) ...(computedAlias ? { session_alias: computedAlias } : {}) diff --git a/backend/services/auto-start.js b/backend/services/auto-start.js index 409935b..8e417f2 100644 --- a/backend/services/auto-start.js +++ b/backend/services/auto-start.js @@ -227,6 +227,7 @@ export async function runAutoStartTemplates({ logger } = {}) { workspace_service_enabled_for_session: workspaceServiceEnabledForSession, workspace_service_port: workspaceServicePort, template_parameters: resolveWithDefaults(tplForRun, paramValues), + session_token: sessionUnifiedToken || '', // Pass alias to SessionManager so it registers the mapping ...(computedAlias ? { session_alias: computedAlias } : {}) }; diff --git a/backend/tests/agents-config.test.mjs b/backend/tests/agents-config.test.mjs index 75ad9e7..beb7ae9 100644 --- a/backend/tests/agents-config.test.mjs +++ b/backend/tests/agents-config.test.mjs @@ -4,6 +4,7 @@ import { loadConfig } from '../tools/agents/lib/config.mjs'; const originalSessionId = process.env.SESSION_ID; const originalApiBase = process.env.SESSIONS_API_BASE_URL; const originalForge = process.env.FORGE; +const originalSessionTitle = process.env.SESSION_TITLE; beforeEach(() => { if (!process.env.SESSION_ID) process.env.SESSION_ID = 'test-session-id'; @@ -19,6 +20,9 @@ afterEach(() => { if (originalForge === undefined) delete process.env.FORGE; else process.env.FORGE = originalForge; + + if (originalSessionTitle === undefined) delete process.env.SESSION_TITLE; + else process.env.SESSION_TITLE = originalSessionTitle; }); describe('agents config FORGE handling', () => { @@ -33,5 +37,10 @@ describe('agents config FORGE handling', () => { const cfg = loadConfig(); expect(cfg.FORGE).toBe(''); }); -}); + it('includes SESSION_TITLE from environment when set', () => { + process.env.SESSION_TITLE = 'My explicit title'; + const cfg = loadConfig(); + expect(cfg.SESSION_TITLE).toBe('My explicit title'); + }); +}); diff --git a/backend/tests/agents-io.test.mjs b/backend/tests/agents-io.test.mjs new file mode 100644 index 0000000..bfd7a49 --- /dev/null +++ b/backend/tests/agents-io.test.mjs @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import { + resolveInlineMessageArg, + shouldReadMessageFromStdin, +} from '../tools/agents/lib/io.mjs'; + +describe('agents stdin/message resolution', () => { + it('keeps regular message arguments inline', () => { + expect(resolveInlineMessageArg('review this change')).toBe('review this change'); + }); + + it('treats "-" as the explicit stdin sentinel', () => { + expect(resolveInlineMessageArg('-')).toBe(''); + expect(shouldReadMessageFromStdin('-')).toBe(true); + }); + + it('does not read stdin implicitly when no message is provided', () => { + expect(resolveInlineMessageArg(undefined)).toBe(''); + expect(resolveInlineMessageArg('')).toBe(''); + expect(shouldReadMessageFromStdin(undefined)).toBe(false); + expect(shouldReadMessageFromStdin('')).toBe(false); + }); +}); diff --git a/backend/tests/agents-title.test.mjs b/backend/tests/agents-title.test.mjs new file mode 100644 index 0000000..db4033a --- /dev/null +++ b/backend/tests/agents-title.test.mjs @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { resolveCreateTitle } from '../tools/agents/agents.mjs'; + +describe('resolveCreateTitle', () => { + it('defaults to Session for when no repo or override is provided', () => { + expect(resolveCreateTitle({ agent: 'codex' })).toBe('Session for codex'); + }); + + it('uses repo and issue id when available', () => { + expect(resolveCreateTitle({ + agent: 'codex', + repo: 'devtools/termstation', + issueId: '123', + })).toBe('devtools/termstation #123'); + }); + + it('appends description to computed titles when no explicit title override is present', () => { + expect(resolveCreateTitle({ + agent: 'codex', + repo: 'devtools/termstation', + issueId: '123', + description: 'Review reconnect behavior', + })).toBe('devtools/termstation #123: Review reconnect behavior'); + }); + + it('prefers --title over SESSION_TITLE and computed titles', () => { + expect(resolveCreateTitle({ + agent: 'codex', + repo: 'devtools/termstation', + issueId: '123', + description: 'Review reconnect behavior', + optionTitle: 'Manual override', + envTitle: 'Env override', + })).toBe('Manual override'); + }); + + it('uses SESSION_TITLE when --title is not provided', () => { + expect(resolveCreateTitle({ + agent: 'codex', + repo: 'devtools/termstation', + issueId: '123', + description: 'Review reconnect behavior', + envTitle: 'Env override', + })).toBe('Env override'); + }); +}); diff --git a/backend/tests/agents-workspace.test.mjs b/backend/tests/agents-workspace.test.mjs new file mode 100644 index 0000000..f424b1d --- /dev/null +++ b/backend/tests/agents-workspace.test.mjs @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { resolveCreateWorkspace } from '../tools/agents/agents.mjs'; + +describe('resolveCreateWorkspace', () => { + it('defaults to Default when no override is provided', () => { + expect(resolveCreateWorkspace({})).toBe('Default'); + }); + + it('uses AGENTS_WORKSPACE when provided', () => { + expect(resolveCreateWorkspace({ envWorkspace: 'Reviews' })).toBe('Reviews'); + }); + + it('prefers the CLI workspace option over AGENTS_WORKSPACE', () => { + expect(resolveCreateWorkspace({ + optionWorkspace: 'Pairing', + envWorkspace: 'Reviews', + })).toBe('Pairing'); + }); + + it('normalizes default workspace casing', () => { + expect(resolveCreateWorkspace({ optionWorkspace: 'default' })).toBe('Default'); + expect(resolveCreateWorkspace({ envWorkspace: 'DEFAULT' })).toBe('Default'); + }); +}); diff --git a/backend/tests/terminal-session-dynamic-title-broadcast.test.mjs b/backend/tests/terminal-session-dynamic-title-broadcast.test.mjs new file mode 100644 index 0000000..dc7a1f5 --- /dev/null +++ b/backend/tests/terminal-session-dynamic-title-broadcast.test.mjs @@ -0,0 +1,80 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; +import { createTestConfig, cleanupTestConfig } from './helpers/test-utils.mjs'; + +let configDir; +let TerminalSession; +let mockPtyProcess; + +vi.mock('node-pty', () => ({ + spawn: vi.fn(() => { + mockPtyProcess = { + pid: 12345, + _onData: null, + _onExit: null, + onData: vi.fn((cb) => { mockPtyProcess._onData = cb; }), + onExit: vi.fn((cb) => { mockPtyProcess._onExit = cb; }), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn() + }; + return mockPtyProcess; + }) +})); + +beforeEach(async () => { + configDir = createTestConfig(); + process.env.TERMSTATION_CONFIG_DIR = configDir; + ({ TerminalSession } = await import('../models/terminal-session.js')); + global.connectionManager = { broadcast: vi.fn() }; +}); + +afterEach(() => { + cleanupTestConfig(configDir); + delete process.env.TERMSTATION_CONFIG_DIR; + delete global.connectionManager; + mockPtyProcess = null; + vi.clearAllMocks(); +}); + +describe('TerminalSession OSC dynamic title broadcasts', () => { + it('broadcasts dynamic title updates when no explicit title is set', async () => { + const session = new TerminalSession({ + session_id: 'osc-broadcast-no-title', + working_directory: '/tmp', + save_session_history: false + }); + + await session.createPtyProcess(); + + mockPtyProcess._onData('\u001b]0;Rotating title\u0007'); + + expect(session.dynamic_title).toBe('Rotating title'); + expect(global.connectionManager.broadcast).toHaveBeenCalledTimes(1); + expect(global.connectionManager.broadcast).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'session_updated', + update_type: 'updated', + session_data: expect.objectContaining({ + session_id: 'osc-broadcast-no-title', + dynamic_title: 'Rotating title' + }) + }) + ); + }); + + it('tracks dynamic title updates without broadcasting when an explicit title is set', async () => { + const session = new TerminalSession({ + session_id: 'osc-broadcast-explicit-title', + title: 'Pinned title', + working_directory: '/tmp', + save_session_history: false + }); + + await session.createPtyProcess(); + + mockPtyProcess._onData('\u001b]0;Rotating title\u0007'); + + expect(session.dynamic_title).toBe('Rotating title'); + expect(global.connectionManager.broadcast).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/tests/terminal-session-none-env.test.mjs b/backend/tests/terminal-session-none-env.test.mjs new file mode 100644 index 0000000..92dc783 --- /dev/null +++ b/backend/tests/terminal-session-none-env.test.mjs @@ -0,0 +1,53 @@ +import { beforeEach, afterEach, test, expect, vi } from 'vitest'; +import { createTestConfig, cleanupTestConfig } from './helpers/test-utils.mjs'; + +let configDir; +let TerminalSession; +let config; +let spawnMock; + +beforeEach(async () => { + configDir = createTestConfig(); + process.env.TERMSTATION_CONFIG_DIR = configDir; + vi.resetModules(); + + spawnMock = vi.fn(() => ({ + pid: 1234, + onData: () => {}, + onExit: () => {}, + write: () => {}, + resize: () => {}, + kill: () => {} + })); + + vi.doMock('node-pty', () => ({ + spawn: spawnMock + })); + + ({ TerminalSession } = await import('../models/terminal-session.js')); + ({ config } = await import('../config-loader.js')); +}); + +afterEach(() => { + cleanupTestConfig(configDir); + delete process.env.TERMSTATION_CONFIG_DIR; + vi.resetModules(); +}); + +test('createPtyProcess exports SESSION_TOK and SESSIONS_API_BASE_URL for none isolation', async () => { + const session = new TerminalSession({ + session_id: 'host-none-session', + isolation_mode: 'none', + session_token: 'tok-123', + working_directory: process.cwd(), + save_session_history: false + }); + + await session.createPtyProcess(); + + expect(spawnMock).toHaveBeenCalledTimes(1); + const [, , spawnOptions] = spawnMock.mock.calls[0]; + expect(spawnOptions.env.SESSION_TOK).toBe('tok-123'); + expect(spawnOptions.env.SESSIONS_API_BASE_URL).toBe(config.SESSIONS_API_BASE_URL); + expect(spawnOptions.env.SESSIONS_BASE_URL).toBe(config.SESSIONS_BASE_URL); +}); diff --git a/backend/tools/agents/agents.mjs b/backend/tools/agents/agents.mjs index 1eac1f1..533c365 100644 --- a/backend/tools/agents/agents.mjs +++ b/backend/tools/agents/agents.mjs @@ -7,6 +7,56 @@ import { ApiClient } from './lib/apiClient.mjs'; import { CliError } from './lib/errors.mjs'; import { getMessageArgOrStdin, prefixMessage } from './lib/io.mjs'; +function toListJsonEntry(session) { + if (!session || typeof session !== 'object') return null; + const sessionId = session.session_id || session.id || ''; + if (!sessionId) return null; + return { + session_id: sessionId, + created_at: Object.prototype.hasOwnProperty.call(session, 'created_at') ? session.created_at : null, + workspace: Object.prototype.hasOwnProperty.call(session, 'workspace') ? session.workspace : null, + title: Object.prototype.hasOwnProperty.call(session, 'title') ? session.title : '', + dynamic_title: Object.prototype.hasOwnProperty.call(session, 'dynamic_title') ? session.dynamic_title : '', + template_id: Object.prototype.hasOwnProperty.call(session, 'template_id') ? session.template_id : null, + template_name: Object.prototype.hasOwnProperty.call(session, 'template_name') ? session.template_name : null, + last_output_at: Object.prototype.hasOwnProperty.call(session, 'last_output_at') ? session.last_output_at : null, + output_active: Object.prototype.hasOwnProperty.call(session, 'output_active') ? session.output_active : null, + }; +} + +export function resolveCreateWorkspace({ optionWorkspace, envWorkspace } = {}) { + const optionValue = typeof optionWorkspace === 'string' ? optionWorkspace.trim() : ''; + if (optionValue) return optionValue.toLowerCase() === 'default' ? 'Default' : optionValue; + + const envValue = typeof envWorkspace === 'string' ? envWorkspace.trim() : ''; + if (envValue) return envValue.toLowerCase() === 'default' ? 'Default' : envValue; + + return 'Default'; +} + +export function resolveCreateTitle({ + agent, + repo, + issueId, + description, + optionTitle, + envTitle, +} = {}) { + const optionValue = typeof optionTitle === 'string' ? optionTitle.trim() : ''; + if (optionValue) return optionValue; + + const envValue = typeof envTitle === 'string' ? envTitle.trim() : ''; + if (envValue) return envValue; + + let title = 'Session for ' + agent; + if (repo && issueId) title = `${repo} #${issueId}`; + else if (repo) title = repo; + + const desc = typeof description === 'string' ? description.trim() : ''; + if (desc) title = `${title}: ${desc}`; + return title; +} + async function main() { const program = new Command(); program @@ -17,8 +67,9 @@ async function main() { program .command('list') + .option('--json', 'Output session details as JSON') .description('List active peer agent sessions (excluding your own)') - .action(async () => { + .action(async (cmdOpts) => { const opts = program.opts(); const cfg = loadConfig(); const api = new ApiClient(cfg.SESSIONS_API_BASE_URL, { debug: opts.debug || cfg.DEBUG, token: cfg.SESSION_TOK }); @@ -38,6 +89,14 @@ async function main() { return ta - tb; }); + if (cmdOpts && cmdOpts.json) { + const rows = filtered + .map(toListJsonEntry) + .filter(Boolean); + process.stdout.write(JSON.stringify(rows, null, 2) + '\n'); + return; + } + for (const s of filtered) { const id = s.session_id || s.id || ''; if (id) process.stdout.write(id + '\n'); @@ -47,7 +106,7 @@ async function main() { program .command('send') .argument('', 'Peer agent session ID') - .argument('[message]', 'Message to send (or read from stdin)') + .argument('[message]', 'Message to send, or "-" to read from stdin') .description('Send a message to a peer agent session') .action(async (peerId, messageArg) => { const opts = program.opts(); @@ -89,9 +148,12 @@ async function main() { program .command('create') .argument('', 'Agent template ID (e.g., claude, codex)') - .argument('[message]', 'Optional prompt (or read from stdin)') + .argument('[message]', 'Optional prompt, or "-" to read from stdin') .option('--post-create-delay ', 'Seconds to wait after successful creation (default: 10)') + .option('--title ', 'Full title override for the created session') .option('--description <description>', 'Short description to append to the session title') + .option('--workspace <name>', 'Workspace for the created session') + .option('--cwd <path>', 'Override working directory for the created session') .description('Create a new peer agent session') .action(async (agent, messageArg, cmd) => { const opts = program.opts(); @@ -102,13 +164,14 @@ async function main() { const msg = await getMessageArgOrStdin(messageArg); const fullPrompt = msg && msg.length ? prefixMessage(cfg.SESSION_ID, msg) : ''; - // Title policy: manual only, no glab - let title = 'Session for ' + agent; - if (cfg.REPO && cfg.ISSUE_ID) title = `${cfg.REPO} #${cfg.ISSUE_ID}`; - else if (cfg.REPO) title = cfg.REPO; - // Optionally append a brief description to the computed title - const desc = cmd.description || ''; - if (desc) title = `${title}: ${desc}`; + const title = resolveCreateTitle({ + agent, + repo: cfg.REPO, + issueId: cfg.ISSUE_ID, + description: cmd.description, + optionTitle: cmd.title, + envTitle: cfg.SESSION_TITLE, + }); const template_parameters = {}; if (fullPrompt) template_parameters.prompt = fullPrompt; @@ -117,12 +180,16 @@ async function main() { if (cfg.ISSUE_ID) template_parameters.issue_id = isNaN(Number(cfg.ISSUE_ID)) ? cfg.ISSUE_ID : Number(cfg.ISSUE_ID); const forge = opts.forge || cfg.FORGE; if (forge) template_parameters.forge = forge; + const workspace = resolveCreateWorkspace({ + optionWorkspace: cmd.workspace, + envWorkspace: cfg.AGENTS_WORKSPACE, + }); const payload = { template_id: agent, template_parameters, interactive: true, - workspace: 'Agents', + workspace, cols: 187, rows: 58, visibility: 'private', @@ -130,6 +197,8 @@ async function main() { code_review: true, as_user: cfg.TERMSTATION_USER, }; + const cwd = (typeof cmd?.cwd === 'string') ? cmd.cwd.trim() : ''; + if (cwd) payload.working_directory = cwd; const resp = await api.createSession(payload); const id = resp?.session_id || resp?.id; diff --git a/backend/tools/agents/lib/config.mjs b/backend/tools/agents/lib/config.mjs index 557ff92..988394d 100644 --- a/backend/tools/agents/lib/config.mjs +++ b/backend/tools/agents/lib/config.mjs @@ -15,9 +15,9 @@ export function loadConfig() { const REPO = env.REPO || ''; const ISSUE_ID = env.ISSUE_ID || ''; const FORGE = env.FORGE || ''; + const AGENTS_WORKSPACE = env.AGENTS_WORKSPACE || ''; + const SESSION_TITLE = env.SESSION_TITLE || ''; let BRANCH = env.BRANCH || ''; - // SESSION_TITLE was previously supported to fully override the title. - // It has been removed in favor of explicit CLI options (e.g., --description). if (!SESSION_ID) throw new CliError('SESSION_ID is required but not set', 2); if (!SESSIONS_API_BASE_URL) throw new CliError('SESSIONS_API_BASE_URL is required but not set', 2); @@ -34,6 +34,8 @@ export function loadConfig() { REPO, ISSUE_ID, FORGE, + AGENTS_WORKSPACE, + SESSION_TITLE, BRANCH, DEBUG, }; diff --git a/backend/tools/agents/lib/io.mjs b/backend/tools/agents/lib/io.mjs index a6ec651..0fa6fc4 100644 --- a/backend/tools/agents/lib/io.mjs +++ b/backend/tools/agents/lib/io.mjs @@ -14,9 +14,18 @@ export async function readFromStdin() { }); } +export function shouldReadMessageFromStdin(arg) { + return arg === '-'; +} + +export function resolveInlineMessageArg(arg) { + return (typeof arg === 'string' && arg.length > 0 && arg !== '-') ? arg : ''; +} + export async function getMessageArgOrStdin(arg) { - if (typeof arg === 'string' && arg.length > 0) return arg; - if (!process.stdin.isTTY) { + const inline = resolveInlineMessageArg(arg); + if (inline) return inline; + if (shouldReadMessageFromStdin(arg)) { const s = await readFromStdin(); return s; } @@ -26,4 +35,3 @@ export async function getMessageArgOrStdin(arg) { export function prefixMessage(sessionId, message) { return `Message from peer agent ${sessionId}: ${message}`; } - diff --git a/desktop/main.js b/desktop/main.js index 86bfeeb..f751fea 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -1333,6 +1333,17 @@ function createSessionWindow({ sessionId, title }) { titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default' }); + // Reflect fullscreen state into the child renderer DOM so CSS can + // remove macOS traffic-light inset when the window is fullscreen. + const updateChildFullscreenClass = () => { + try { + if (!child || child.isDestroyed() || !child.webContents) return; + const isFs = child.isFullScreen(); + const js = `try { document.documentElement.classList.toggle('is-fullscreen', ${isFs}); } catch (e) {}`; + child.webContents.executeJavaScript(js).catch(() => {}); + } catch (_) { /* ignore */ } + }; + if (title && typeof title === 'string') { try { child.setTitle(`TermStation — ${title}`); } catch (_) {} } else { @@ -1480,6 +1491,13 @@ function createSessionWindow({ sessionId, title }) { }, 2000); }); + // Keep renderer fullscreen class in sync for dedicated windows. + child.on('enter-full-screen', () => updateChildFullscreenClass()); + child.on('leave-full-screen', () => updateChildFullscreenClass()); + child.on('show', () => updateChildFullscreenClass()); + child.webContents.on('dom-ready', () => updateChildFullscreenClass()); + child.webContents.on('did-finish-load', () => updateChildFullscreenClass()); + // Apply current effects (opacity) to new window as well child.webContents.on('did-finish-load', () => { try { applyWindowEffects(_currentWindowEffects); } catch (_) {} diff --git a/frontend/public/css/style.css b/frontend/public/css/style.css index 221f704..dd73e15 100644 --- a/frontend/public/css/style.css +++ b/frontend/public/css/style.css @@ -995,6 +995,30 @@ textarea { background-color: inherit !important; } +/* Mobile dictation: avoid Android viewport pan toward off-screen IME helper textarea */ +@media (hover: none) and (pointer: coarse) { + html, + body, + #app, + .app-content, + .main-layout, + .terminal-container, + .terminal-content-area, + .terminal-view { + max-width: 100%; + overflow-x: hidden !important; + } + + .xterm .xterm-helper-textarea { + left: 0 !important; + top: 0 !important; + width: 1px !important; + height: 1px !important; + opacity: 0 !important; + z-index: -1 !important; + } +} + /* Debug markers overlay removed */ /* Minimal visual hint when dragging over xterm */ @@ -3615,6 +3639,23 @@ body.session-selected #text-input-btn { flex-direction: column; } +.ws-session-trace-output { + margin: 0; + min-height: 220px; + max-height: 55vh; + overflow: auto; + padding: 10px; + border: 1px solid var(--border-color, #444); + border-radius: 6px; + background: rgba(0, 0, 0, 0.45); + color: var(--text-color, #eee); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 12px; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; +} + /* Make new session modal specifically taller */ #new-session-modal .modal-content { max-height: 95vh; /* Even taller for new session modal */ @@ -4374,6 +4415,10 @@ body.responsive-window .terminal-header .dropdown-menu { .is-electron.platform-mac.is-fullscreen body.responsive-window .terminal-header { padding-left: 1rem; } +/* Fallback selector when fullscreen class is present on <body> */ +.is-electron.platform-mac body.is-fullscreen.responsive-window .terminal-header { + padding-left: 1rem; +} /* Ensure xterm viewport uses full height */ body.responsive-minimal .terminal-view .xterm .xterm-viewport { @@ -4530,6 +4575,10 @@ body[data-sidebar-layout="overlay"]:not(.responsive-minimal) .mobile-sidebar-clo .is-electron.platform-mac body.responsive-window .session-info-toolbar { padding-left: var(--mac-header-left-inset, 80px); } +.is-electron.platform-mac.is-fullscreen body.responsive-window .session-info-toolbar, +.is-electron.platform-mac body.is-fullscreen.responsive-window .session-info-toolbar { + padding-left: 1rem; +} /* When in compact height (toolbars reduced), hide session tabs wrappers to eliminate extra borders/space */ body.responsive-compact .session-tabs-bar, diff --git a/frontend/public/index.html b/frontend/public/index.html index 66373ba..513c39f 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -1347,6 +1347,10 @@ <h3>Developer</h3> <button type="button" id="open-devtools-btn" class="btn btn-secondary">Open DevTools</button> <small class="form-help">Opens the native DevTools window (Electron desktop only).</small> </div> + <div class="form-group"> + <button type="button" id="open-ws-trace-btn" class="btn btn-secondary">Open WS Session Trace</button> + <small class="form-help">Shows recent client-side websocket/session trace events with copy support.</small> + </div> <div class="form-group"> <div class="checkbox-wrapper"> <input type="checkbox" id="allow-insecure-certs"> @@ -1440,6 +1444,26 @@ <h3>State File</h3> </div> </div> + <div id="ws-session-trace-modal" class="modal" tabindex="-1" role="dialog" aria-modal="true" aria-labelledby="ws-session-trace-title"> + <div class="modal-content"> + <div class="modal-header"> + <h2 id="ws-session-trace-title">WebSocket Session Trace</h2> + <button class="modal-close" id="ws-session-trace-close" title="Close">×</button> + </div> + <div class="modal-body"> + <div class="form-group"> + <small class="form-help">Recent in-memory trace events for websocket/session lifecycle. Useful for reproducing frozen-session issues.</small> + </div> + <pre id="ws-session-trace-output" class="ws-session-trace-output"></pre> + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-secondary" id="ws-session-trace-refresh">Refresh</button> + <button type="button" class="btn btn-secondary" id="ws-session-trace-clear">Clear</button> + <button type="button" class="btn btn-primary" id="ws-session-trace-copy">Copy</button> + </div> + </div> + </div> + <!-- Keyboard Shortcuts Modal --> <div id="keyboard-shortcuts-modal" class="modal" tabindex="-1" role="dialog" aria-modal="true" aria-labelledby="keyboard-shortcuts-title"> <div class="modal-content"> diff --git a/frontend/public/js/core/app.js b/frontend/public/js/core/app.js index 41e3446..a5aada9 100644 --- a/frontend/public/js/core/app.js +++ b/frontend/public/js/core/app.js @@ -33,6 +33,7 @@ import { passwordResetModal } from '../modules/auth/password-reset-modal.js'; import { isAnyModalOpen } from '../modules/ui/modal.js'; import { authOrchestrator } from './auth-orchestrator.js'; import { initWindowTitleSync } from '../utils/window-title.js'; +import { wsSessionTrace } from '../utils/ws-session-trace.js'; // (cleanup) Removed legacy isChildOf helper injection; not required. @@ -157,28 +158,38 @@ class Application { setupFullScreenToggle() { const btn = document.getElementById('window-fullscreen-toggle'); const iconSpan = document.getElementById('window-fullscreen-toggle-icon'); - if (!btn || !iconSpan) return; const isElectron = !!(window.desktop && window.desktop.isElectron); if (!isElectron) { - try { btn.style.display = 'none'; } catch (_) {} + try { if (btn) btn.style.display = 'none'; } catch (_) {} return; } + const root = document.documentElement; + const body = document.body; + const applyState = (fs) => { const fullscreen = !!fs; - try { btn.setAttribute('aria-pressed', fullscreen ? 'true' : 'false'); } catch (_) {} - try { btn.title = fullscreen ? 'Exit full screen' : 'Enter full screen'; } catch (_) {} + // Keep both html/body in sync so CSS selectors can target either. + try { root.classList.toggle('is-fullscreen', fullscreen); } catch (_) {} + try { body?.classList?.toggle('is-fullscreen', fullscreen); } catch (_) {} + try { btn?.setAttribute('aria-pressed', fullscreen ? 'true' : 'false'); } catch (_) {} + try { if (btn) btn.title = fullscreen ? 'Exit full screen' : 'Enter full screen'; } catch (_) {} try { // Swap icon - iconSpan.innerHTML = ''; - iconSpan.appendChild(iconUtils.createIcon(fullscreen ? 'fullscreen-exit' : 'fullscreen', { size: 16 })); + if (iconSpan) { + iconSpan.innerHTML = ''; + iconSpan.appendChild(iconUtils.createIcon(fullscreen ? 'fullscreen-exit' : 'fullscreen', { size: 16 })); + } } catch (_) {} }; + const refreshFromDesktop = () => { + try { window.desktop.getFullScreen().then((fs) => applyState(!!fs)).catch(() => {}); } catch (_) {} + }; + // Reflect DOM class changes applied by the Electron main process try { - const root = document.documentElement; const obs = new MutationObserver(() => applyState(root.classList.contains('is-fullscreen'))); obs.observe(root, { attributes: true, attributeFilter: ['class'] }); // Initial apply based on current class @@ -186,12 +197,23 @@ class Application { } catch (_) { /* ignore */ } // Also query initial fullscreen state from the desktop API (in case class not yet set) - try { window.desktop.getFullScreen().then((fs) => applyState(!!fs)).catch(() => {}); } catch (_) {} + refreshFromDesktop(); + + // Dedicated windows can miss class propagation in some fullscreen transitions. + // Re-query fullscreen state on common window lifecycle events as a fallback. + try { window.addEventListener('resize', refreshFromDesktop, { passive: true }); } catch (_) {} + try { window.addEventListener('focus', refreshFromDesktop, { passive: true }); } catch (_) {} + try { + document.addEventListener('visibilitychange', () => { + if (!document.hidden) refreshFromDesktop(); + }); + } catch (_) {} // Wire click handler - btn.addEventListener('click', () => { + btn?.addEventListener('click', () => { try { window.desktop.toggleFullScreen(); } catch (_) {} try { btn.blur && btn.blur(); } catch (_) {} + setTimeout(() => refreshFromDesktop(), 120); }); } @@ -742,24 +764,45 @@ class Application { document.addEventListener('visibilitychange', async () => { // Only act when page becomes visible again if (document.hidden) return; + wsSessionTrace.push('app.visibility.visible'); const state = websocketService.getState(); if (state === 'disconnected') { + wsSessionTrace.push('app.visibility.reconnect_disconnected'); // If disconnected while backgrounded, reconnect await this.connectWebSocketWithAuth(this.serverRequiresAuth); return; } + // The foreground reattach path exists for real mobile runtimes where + // transports or server-side subscriptions can be suspended in the background. + // Desktop/Electron and non-mobile web should keep the live attachment intact. + if (!mobileDetection.isMobileRuntime()) { + return; + } + // If still "connected" after resume, proactively reattach the active session. // On some mobile browsers, the transport resumes but server-side stream // subscriptions are lost; reattach ensures stdout resumes. if (state === 'connected') { try { + const probeTimeout = Math.max( + 1000, + Math.min(6000, Number(websocketService.options?.pongTimeout) || 4000) + ); + const healthy = await websocketService.probeHealth(probeTimeout); + if (!healthy) { + wsSessionTrace.push('app.visibility.probe_failed'); + console.warn('[App] WebSocket health probe failed after visibilitychange, forcing reconnect'); + websocketService.forceReconnect('Foreground health probe failed'); + return; + } + const mgr = this.modules && this.modules.terminal ? this.modules.terminal : null; if (mgr && mgr.currentSessionId && mgr.attachedSessions && mgr.attachedSessions.has(mgr.currentSessionId)) { const sessionObj = (mgr.sessions && typeof mgr.sessions.get === 'function') ? mgr.sessions.get(mgr.currentSessionId) : null; if (sessionObj && typeof sessionObj.attach === 'function') { - await sessionObj.attach(true); + await sessionObj.attach(true, { forceReattach: true }); } else if (typeof mgr.attachToCurrentSession === 'function') { await mgr.attachToCurrentSession(); } diff --git a/frontend/public/js/modules/settings/settings-manager.js b/frontend/public/js/modules/settings/settings-manager.js index 86eb3ad..a70d2af 100644 --- a/frontend/public/js/modules/settings/settings-manager.js +++ b/frontend/public/js/modules/settings/settings-manager.js @@ -15,6 +15,7 @@ import { getEffectiveTheme, onSystemThemeChange } from '../../utils/theme-utils. import { uiFonts } from '../../utils/ui-fonts.js'; import { apiService } from '../../services/api.service.js'; import { ConfirmationModal } from '../ui/modal.js'; +import { wsSessionTrace } from '../../utils/ws-session-trace.js'; import { parseColor, getContrastColor } from '../../utils/color-utils.js'; import { createDefaultSessionBadgeRule, @@ -433,7 +434,14 @@ export class SettingsManager { reloadStateBtn: document.getElementById('reload-state-btn'), // Developer (desktop) openDevToolsBtn: document.getElementById('open-devtools-btn'), + openWsTraceBtn: document.getElementById('open-ws-trace-btn'), developerSection: document.getElementById('developer-settings-section'), + wsTraceModal: document.getElementById('ws-session-trace-modal'), + wsTraceCloseBtn: document.getElementById('ws-session-trace-close'), + wsTraceRefreshBtn: document.getElementById('ws-session-trace-refresh'), + wsTraceClearBtn: document.getElementById('ws-session-trace-clear'), + wsTraceCopyBtn: document.getElementById('ws-session-trace-copy'), + wsTraceOutput: document.getElementById('ws-session-trace-output'), resetTokenBtn: document.getElementById('reset-session-token-btn'), resetTokenGroup: document.getElementById('reset-session-token-group'), confirmAdminActionModal: document.getElementById('confirm-admin-action-modal'), @@ -969,6 +977,49 @@ export class SettingsManager { console.error('[Settings] Failed to open DevTools via desktop bridge:', e); } }); + this.elements.openWsTraceBtn?.addEventListener('click', () => { + this.openWsTraceModal(); + }); + this.elements.wsTraceCloseBtn?.addEventListener('click', () => { + this.closeWsTraceModal(); + }); + this.elements.wsTraceRefreshBtn?.addEventListener('click', () => { + this.renderWsTraceOutput(); + }); + this.elements.wsTraceClearBtn?.addEventListener('click', () => { + wsSessionTrace.clear(); + this.renderWsTraceOutput(); + notificationDisplay?.show?.({ + notification_type: 'info', + title: 'Trace Cleared', + message: 'WebSocket session trace events were cleared.', + timestamp: new Date().toISOString() + }, { duration: 2500 }); + }); + this.elements.wsTraceCopyBtn?.addEventListener('click', async () => { + const text = wsSessionTrace.toPrettyText(); + const copied = await this.copyTextToClipboard(text); + if (copied) { + notificationDisplay?.show?.({ + notification_type: 'success', + title: 'Trace Copied', + message: 'Trace output copied to clipboard.', + timestamp: new Date().toISOString() + }, { duration: 2500 }); + } else { + notificationDisplay?.show?.({ + notification_type: 'error', + title: 'Copy Failed', + message: 'Could not copy trace output.', + timestamp: new Date().toISOString() + }, { duration: 3500 }); + } + }); + this.elements.wsTraceModal?.addEventListener('click', (e) => { + if (e.target === this.elements.wsTraceModal) { + this.closeWsTraceModal(); + } + }); // Shared confirmation modal for admin actions (reset token / reload config) const adminModalEl = this.elements.confirmAdminActionModal; @@ -1526,6 +1577,12 @@ export class SettingsManager { // Handle keyboard shortcuts for modal document.addEventListener('keydown', (e) => { + if (this.elements.wsTraceModal?.classList.contains('show')) { + if (e.key === 'Escape') { + this.closeWsTraceModal(); + } + return; + } if (this.modal?.classList.contains('show')) { if (e.key === 'Escape') { this.closeModal(); @@ -2052,11 +2109,59 @@ export class SettingsManager { } catch (_) {} this._themeSaved = false; this.modal?.classList.remove('show'); + this.closeWsTraceModal(); if (this.prevFocused && this.prevFocused.focus) { this.prevFocused.focus(); } } + openWsTraceModal() { + this.renderWsTraceOutput(); + try { + this.elements.wsTraceModal?.classList.add('show'); + this.elements.wsTraceModal?.focus?.(); + } catch (_) {} + } + + closeWsTraceModal() { + try { + this.elements.wsTraceModal?.classList.remove('show'); + } catch (_) {} + } + + renderWsTraceOutput() { + if (!this.elements.wsTraceOutput) return; + this.elements.wsTraceOutput.textContent = wsSessionTrace.toPrettyText(); + try { + this.elements.wsTraceOutput.scrollTop = this.elements.wsTraceOutput.scrollHeight; + } catch (_) {} + } + + async copyTextToClipboard(text) { + const value = String(text || ''); + try { + if (navigator?.clipboard?.writeText) { + await navigator.clipboard.writeText(value); + return true; + } + } catch (_) {} + + try { + const ta = document.createElement('textarea'); + ta.value = value; + ta.setAttribute('readonly', 'true'); + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + const ok = document.execCommand('copy'); + document.body.removeChild(ta); + return ok === true; + } catch (_) { + return false; + } + } + /** * Update UI elements from current store state */ diff --git a/frontend/public/js/modules/terminal/manager.js b/frontend/public/js/modules/terminal/manager.js index 5bab1f9..08b4a1f 100644 --- a/frontend/public/js/modules/terminal/manager.js +++ b/frontend/public/js/modules/terminal/manager.js @@ -37,11 +37,14 @@ import { appStore } from '../../core/store.js'; import { iconUtils } from '../../utils/icon-utils.js'; import { computeDisplayTitle, getDynamicTitleMode } from '../../utils/title-utils.js'; import { resolveSessionBadgeRule } from '../../utils/session-badge-rules.js'; +import { isDynamicTitleOnlySessionUpdate } from '../../utils/dynamic-title-updates.js'; +import { applyWindowTitleFromSessionData } from '../../utils/window-title.js'; import { settingsManager } from '../settings/settings-manager.js'; import { fontDetector } from '../../utils/font-detector.js'; import { keyboardShortcuts } from '../shortcuts/keyboard-shortcuts.js'; import { openAddRuleModal } from '../ui/scheduled-input-modals.js'; import { createDebug } from '../../utils/debug.js'; +import { wsSessionTrace } from '../../utils/ws-session-trace.js'; import { getSettingsStore } from '../../core/settings-store/index.js'; import { WORKSPACE_SORT_MODE_MANUAL, @@ -324,6 +327,7 @@ export class TerminalManager { // Track a pending manual selection across workspace/search re-renders this.pendingManualSelectionId = null; + this.liveDynamicTitles = new Map(); // Helper: show an Attach prompt inside a container tab view for a child session this.showContainerAttachPrompt = (childId) => { @@ -823,13 +827,99 @@ export class TerminalManager { try { const sid = sessionId || this.currentSessionId; if (!sid) return; - const data = this.sessionList?.getSessionData?.(sid) || {}; + const data = this.getDisplaySessionData(sid); const effective = computeDisplayTitle(data, { fallbackOrder: [], defaultValue: 'Session' }); const templateName = data.template_name || null; this.updateSessionInfoToolbar(effective, sid, templateName); } catch (_) { /* ignore */ } } + getDisplaySessionData(sessionOrId = null) { + try { + const sessionData = (typeof sessionOrId === 'string') + ? (this.sessionList?.getSessionData?.(sessionOrId) || null) + : (sessionOrId || null); + const sid = typeof sessionOrId === 'string' + ? sessionOrId + : String(sessionData?.session_id || '').trim(); + if (!sid) return sessionData || {}; + if (!this.liveDynamicTitles.has(sid)) { + return sessionData || {}; + } + const liveDynamicTitle = this.liveDynamicTitles.get(sid); + if (sessionData && sessionData.dynamic_title === liveDynamicTitle) { + return sessionData; + } + return { + ...(sessionData || {}), + session_id: sid, + dynamic_title: liveDynamicTitle + }; + } catch (_) { + return {}; + } + } + + setLiveDynamicTitle(sessionId, dynamicTitle) { + const sid = String(sessionId || '').trim(); + if (!sid) return; + const nextDynamicTitle = typeof dynamicTitle === 'string' ? dynamicTitle : ''; + this.liveDynamicTitles.set(sid, nextDynamicTitle); + try { + const existingSessionData = this.sessionList?.getSessionData?.(sid); + if (existingSessionData && existingSessionData.dynamic_title !== nextDynamicTitle) { + existingSessionData.dynamic_title = nextDynamicTitle; + } + } catch (_) { /* ignore */ } + } + + deleteLiveDynamicTitle(sessionId) { + const sid = String(sessionId || '').trim(); + if (!sid) return; + this.liveDynamicTitles.delete(sid); + } + + reseedLiveDynamicTitlesFromSessions(sessions = []) { + this.liveDynamicTitles.clear(); + if (!Array.isArray(sessions)) return; + sessions.forEach((sessionData) => { + const sid = String(sessionData?.session_id || '').trim(); + if (!sid) return; + if (typeof sessionData.dynamic_title === 'string') { + this.liveDynamicTitles.set(sid, sessionData.dynamic_title); + } + }); + } + + applyDynamicTitleFastPath(sessionId, dynamicTitle) { + this.setLiveDynamicTitle(sessionId, dynamicTitle); + + const displayData = this.getDisplaySessionData(sessionId); + if (!displayData || !displayData.session_id) return; + + try { + this.sessionList?.refreshSessionDisplay?.(sessionId, displayData); + } catch (_) { /* ignore */ } + + try { + this.sessionTabsManager?.updateSessionTab?.(displayData); + } catch (_) { /* ignore */ } + + if (this.currentSessionId !== sessionId) return; + + try { + const mode = getDynamicTitleMode(); + const hasExplicitTitle = typeof displayData.title === 'string' && displayData.title.trim().length > 0; + if (mode === 'always' || (mode === 'ifUnset' && !hasExplicitTitle)) { + const effective = computeDisplayTitle(displayData, { fallbackOrder: [], defaultValue: 'Session' }); + const templateName = displayData.template_name || null; + this.updateSessionInfoToolbar(effective, sessionId, templateName); + } + } catch (_) { /* ignore */ } + + try { applyWindowTitleFromSessionData(displayData); } catch (_) { /* ignore */ } + } + /** * Debounced refresh of sidebar/session chrome for metadata-only updates * (for example dynamic title changes that affect derived badge labels/colors). @@ -906,6 +996,11 @@ export class TerminalManager { if (dyn) { map[sid] = dyn; } else { try { delete map[sid]; } catch (_) {} } queueStateSet('local_session_dynamic_titles', map); } catch (_) { /* ignore */ } + const hasExplicitTitle = typeof data.title === 'string' && data.title.trim().length > 0; + if (hasExplicitTitle) { + this.setLiveDynamicTitle(sid, dyn); + return; + } // Reuse existing update pipeline so header/tabs/sidebar refresh consistently this.handleSessionUpdate({ session_id: sid, dynamic_title: dyn }, 'updated'); } @@ -1879,10 +1974,17 @@ export class TerminalManager { // When the socket disconnects, mark all local sessions as detached so // subsequent reattach attempts actually send an attach message. this.eventBus.on('ws:disconnected', () => { + wsSessionTrace.push('manager.ws.disconnected'); try { if (this.sessions && typeof this.sessions.forEach === 'function') { this.sessions.forEach((session) => { - try { if (session) session.isAttached = false; } catch (_) {} + try { + if (session && typeof session.onTransportDisconnected === 'function') { + session.onTransportDisconnected(); + } else if (session) { + session.isAttached = false; + } + } catch (_) {} }); } // Reset compatibility tracking; preserve attachedSessions set so we know what to reattach @@ -1891,25 +1993,39 @@ export class TerminalManager { }); this.eventBus.on('ws:connected', async () => { + wsSessionTrace.push('manager.ws.connected', { + attached_session_count: this.attachedSessions?.size || 0 + }); // Only reload sessions when reconnected if we were already initialized // This prevents double loading during initial connection if (this.isInitialized) { await this.loadSessions(true, true); - // Ensure the actively viewed session is fully reattached using - // TerminalSession.attach() so the history sync handshake completes - // (required for stdout to resume after reconnects). + // Reattach all sessions that were attached before disconnect. try { + const attachedIds = Array.from(this.attachedSessions || []); + for (const sid of attachedIds) { + const sessionObj = this.sessions && typeof this.sessions.get === 'function' + ? this.sessions.get(sid) + : null; + if (!sessionObj || typeof sessionObj.attach !== 'function') continue; + try { + const forceLoadHistory = !(sessionObj?.sessionData?.local_only === true); + await sessionObj.attach(forceLoadHistory, { forceReattach: true }); + } catch (_) {} + } + + // Fallback for current session if object is missing. if (this.currentSessionId && this.attachedSessions && this.attachedSessions.has(this.currentSessionId)) { - const active = this.sessions && typeof this.sessions.get === 'function' ? this.sessions.get(this.currentSessionId) : null; - if (active && typeof active.attach === 'function') { - await active.attach(true); - } else if (typeof this.attachToCurrentSession === 'function') { + const active = this.sessions && typeof this.sessions.get === 'function' + ? this.sessions.get(this.currentSessionId) + : null; + if (!active && typeof this.attachToCurrentSession === 'function') { await this.attachToCurrentSession(); } } } catch (e) { - console.warn('[TerminalManager] Failed to reattach active session on reconnect:', e); + console.warn('[TerminalManager] Failed to reattach session(s) on reconnect:', e); } } }); @@ -4201,6 +4317,7 @@ export class TerminalManager { // Always load only active sessions for the sidebar const sessions = await apiService.getSessions(); + this.reseedLiveDynamicTitlesFromSessions(sessions); console.log(`[Manager] Loaded ${sessions?.length || 0} sessions:`, sessions?.map(s => ({id: s.session_id, active: s.is_active, title: s.title}))); @@ -6505,6 +6622,11 @@ export class TerminalManager { }); } } catch (_) {} + wsSessionTrace.push('manager.select_session.start', { + session_id: sessionId, + has_existing: !!existingSession, + in_attached_set: this.attachedSessions.has(sessionId) === true + }); if (existingSession) { // Session already exists - switch to it @@ -6543,6 +6665,10 @@ export class TerminalManager { const isActive = sessionListData ? (sessionListData.is_active !== false) : true; if (this.attachedSessions.has(sessionId)) { + wsSessionTrace.push('manager.select_session.show_attached', { + session_id: sessionId, + has_container: !!existingSession.container + }); // Session is attached - show the terminal this.connectedSessionId = sessionId; // For compatibility @@ -6592,6 +6718,9 @@ export class TerminalManager { try { const autoAttach = appStore.getState('preferences.terminal.autoAttachOnSelect') === true; if (autoAttach && isActive) { + wsSessionTrace.push('manager.select_session.auto_attach', { + session_id: sessionId + }); // Ensure header/links visible before attaching (history fetch) try { this.updateSessionUI(sessionId, options); } catch (_) {} await this.attachToCurrentSession(); @@ -6646,7 +6775,7 @@ export class TerminalManager { this.viewController.showLoadingPlaceholder('Loading session...'); // Get session data from session list first - let sessionData = this.sessionList.getSessionData(sessionId); + let sessionData = this.getDisplaySessionData(sessionId); // Check if this is a terminated session const isActiveSession = sessionData && sessionData.is_active !== false; @@ -6842,7 +6971,7 @@ export class TerminalManager { } updateSessionUI(sessionId, options = {}) { // Get session data and update UI elements - const sessionListData = this.sessionList.getSessionData(sessionId); + const sessionListData = this.getDisplaySessionData(sessionId); let sessionTitle = 'Session'; let templateName = null; if (sessionListData) { @@ -7239,6 +7368,10 @@ export class TerminalManager { console.error('[Manager] No current session ID to attach to'); return; } + wsSessionTrace.push('manager.attach_current.start', { + session_id: this.currentSessionId, + has_current_session_object: !!this.currentSession + }); try { // Safeguard: if a dedicated window exists for this session, do not attach in main window @@ -7310,6 +7443,10 @@ export class TerminalManager { } this.updateSessionTabs?.(); + wsSessionTrace.push('manager.attach_current.success', { + session_id: this.currentSessionId, + local_only: true + }); return true; } @@ -7365,6 +7502,10 @@ export class TerminalManager { // Update compatibility tracking this.connectedSessionId = this.currentSessionId; + wsSessionTrace.push('manager.attach_current.success', { + session_id: this.currentSessionId, + local_only: false + }); // Clear the attach button and ensure the terminal is properly displayed this.viewController.clearTerminalView(); @@ -7877,6 +8018,18 @@ export class TerminalManager { // Check if session exists in our list const existingSessionData = this.sessionList.getSessionData(sessionData.session_id); if (existingSessionData) { + if (isDynamicTitleOnlySessionUpdate(sessionData, updateType)) { + const currentDynamicTitle = this.liveDynamicTitles.has(sessionData.session_id) + ? this.liveDynamicTitles.get(sessionData.session_id) + : existingSessionData.dynamic_title; + if (currentDynamicTitle !== sessionData.dynamic_title) { + this.applyDynamicTitleFastPath(sessionData.session_id, sessionData.dynamic_title); + } + return; + } + if (Object.prototype.hasOwnProperty.call(sessionData, 'dynamic_title')) { + this.setLiveDynamicTitle(sessionData.session_id, sessionData.dynamic_title); + } // Keep activity indicator in sync if server includes current state in update try { const live = (Object.prototype.hasOwnProperty.call(sessionData, 'is_active') @@ -7939,6 +8092,10 @@ export class TerminalManager { // If dynamic title changed, update header depending on configured mode const dynamicChanged = Object.prototype.hasOwnProperty.call(sessionData, 'dynamic_title') && sessionData.dynamic_title !== existingSessionData.dynamic_title; + const hasExplicitTitle = !!( + (typeof existingSessionData.title === 'string' && existingSessionData.title.trim()) || + (typeof sessionData.title === 'string' && sessionData.title.trim()) + ); if (dynamicChanged) { const isCurrent = this.currentSessionId === sessionData.session_id; if (isCurrent) { @@ -7947,9 +8104,7 @@ export class TerminalManager { if (mode === 'always') { shouldUpdate = true; } else if (mode === 'ifUnset') { - const hasExplicit = (existingSessionData.title && existingSessionData.title.trim()) || - (sessionData.title && sessionData.title.trim()); - shouldUpdate = !hasExplicit; + shouldUpdate = !hasExplicitTitle; } else { // 'never' -> do not update in response to dynamic change shouldUpdate = false; @@ -7984,7 +8139,7 @@ export class TerminalManager { const badgeRelevantChanged = (Object.prototype.hasOwnProperty.call(sessionData, 'title') && sessionData.title !== prevTitle) || - dynamicChanged || + (dynamicChanged && !hasExplicitTitle) || (Object.prototype.hasOwnProperty.call(sessionData, 'template_badge_label') && sessionData.template_badge_label !== prevTemplateBadgeLabel) || (Object.prototype.hasOwnProperty.call(sessionData, 'template_name') && @@ -8195,6 +8350,7 @@ export class TerminalManager { case 'terminated': { const terminatedId = sessionData.session_id; + this.deleteLiveDynamicTitle(terminatedId); const wasCurrentSession = this.currentSessionId === terminatedId; const persistEndedSessions = this.shouldPersistEndedSessions(); @@ -8299,6 +8455,7 @@ export class TerminalManager { case 'deleted': // Session was deleted (save_session_history=false) - remove completely from UI + this.deleteLiveDynamicTitle(sessionData.session_id); // Check if this was the current active session const wasCurrentSessionDeleted = this.currentSession && this.currentSession.sessionId === sessionData.session_id; diff --git a/frontend/public/js/modules/terminal/session-list.js b/frontend/public/js/modules/terminal/session-list.js index 6c0fa57..f7278b9 100644 --- a/frontend/public/js/modules/terminal/session-list.js +++ b/frontend/public/js/modules/terminal/session-list.js @@ -724,6 +724,7 @@ export class SessionList { * Update an existing session element */ updateSessionElement(sessionItem, sessionData, pinnedSessions, displayNumber = null) { + sessionData = this.manager?.getDisplaySessionData?.(sessionData) || sessionData; // Check if this session is pinned const isPinned = pinnedSessions.has(sessionData.session_id); let showChildPref = true; @@ -1352,6 +1353,35 @@ export class SessionList { const sessions = this.store.getState().sessionList.sessions; return sessions.get(sessionId); } + + refreshSessionDisplay(sessionId, sessionData = null) { + const sessionItem = this.sessions.get(sessionId); + if (!sessionItem) return; + + const state = this.store.getState().sessionList || {}; + const pinnedSessions = state.filters?.pinnedSessions || new Set(); + const visibleOrder = Array.isArray(state.visibleOrder) ? state.visibleOrder : []; + let visibleIndex = visibleOrder.indexOf(sessionId); + if (visibleIndex < 0) { + try { + const visibleItems = Array.from(this.container.querySelectorAll('.session-item')).filter( + (item) => item && item.style.display !== 'none' + ); + visibleIndex = visibleItems.findIndex((item) => item.dataset.sessionId === sessionId); + } catch (_) { + visibleIndex = -1; + } + } + const displayNumber = visibleIndex >= 0 ? (visibleIndex + 1) : null; + const resolvedSessionData = sessionData || this.getSessionData(sessionId); + if (!resolvedSessionData) return; + + this.updateSessionElement(sessionItem, resolvedSessionData, pinnedSessions, displayNumber); + + const isSelected = sessionId === state.activeSessionId; + sessionItem.classList.toggle('active', isSelected); + sessionItem.classList.toggle('selected', isSelected); + } /** * Get fresh session data from store with fallback diff --git a/frontend/public/js/modules/terminal/session-tabs-manager.js b/frontend/public/js/modules/terminal/session-tabs-manager.js index 04f4d22..6bbfbf8 100644 --- a/frontend/public/js/modules/terminal/session-tabs-manager.js +++ b/frontend/public/js/modules/terminal/session-tabs-manager.js @@ -262,6 +262,7 @@ export class SessionTabsManager { * Add a session tab */ addSessionTab(sessionData) { + sessionData = this.manager?.getDisplaySessionData?.(sessionData) || sessionData; const tabButton = document.createElement('button'); tabButton.className = 'session-tab'; tabButton.dataset.sessionId = sessionData.session_id; @@ -744,6 +745,7 @@ export class SessionTabsManager { * Update a session tab (e.g., when title changes) */ updateSessionTab(sessionData) { + sessionData = this.manager?.getDisplaySessionData?.(sessionData) || sessionData; const tab = this.sessionTabs.get(sessionData.session_id); if (tab) { // Update tab title using settings @@ -801,7 +803,7 @@ export class SessionTabsManager { const idx = (direction === 'left') ? (visibleSessions.length - 1) : 0; const target = visibleSessions[idx]; if (target) { - this.selectSessionAndRestoreTab(target.session_id); + this.selectSessionAndRestoreTab(target.session_id, { preferLocal: true }); return; } } @@ -835,7 +837,7 @@ export class SessionTabsManager { const newIndex = currentIndex - 1; const target = visibleSessions[newIndex]; if (target) { - this.selectSessionAndRestoreTab(target.session_id); + this.selectSessionAndRestoreTab(target.session_id, { preferLocal: true }); } return; } else { // right @@ -851,17 +853,18 @@ export class SessionTabsManager { const newIndex = currentIndex + 1; const target = visibleSessions[newIndex]; if (target) { - this.selectSessionAndRestoreTab(target.session_id); + this.selectSessionAndRestoreTab(target.session_id, { preferLocal: true }); } return; } } - selectSessionAndRestoreTab(sessionId) { + selectSessionAndRestoreTab(sessionId, options = {}) { if (!sessionId) return; + const preferLocal = options?.preferLocal === true; // If this session has a dedicated desktop window open, focus it instead of switching locally try { - if (window.desktop && window.desktop.isElectron && typeof window.desktop.getSessionWindow === 'function' && typeof window.desktop.focusSessionWindow === 'function') { + if (!preferLocal && window.desktop && window.desktop.isElectron && typeof window.desktop.getSessionWindow === 'function' && typeof window.desktop.focusSessionWindow === 'function') { return window.desktop.getSessionWindow(sessionId) .then(async (info) => { if (info && info.ok && info.windowId) { diff --git a/frontend/public/js/modules/terminal/session.js b/frontend/public/js/modules/terminal/session.js index 38302be..623aebd 100644 --- a/frontend/public/js/modules/terminal/session.js +++ b/frontend/public/js/modules/terminal/session.js @@ -17,6 +17,7 @@ import { AnsiDebug } from '../../utils/ansi-debug.js'; import { applyAnsiFilters } from '../../utils/ansi-filters.js'; import { streamHistoryToTerminal } from '../../utils/history-streamer.js'; import { isAnyModalOpen } from '../ui/modal.js'; +import { wsSessionTrace } from '../../utils/ws-session-trace.js'; export class TerminalSession { constructor(sessionId, container, wsClient, eventBus, sessionData = null, preloadedHistoryData = null) { @@ -101,6 +102,7 @@ export class TerminalSession { // Client-only ordinal counter for markers (start at 1 to avoid special-case zero) this._nextClientOrdinal = 1; + this._attachPromise = null; } computeInteractive() { @@ -627,17 +629,42 @@ export class TerminalSession { }); } - async attach(forceLoadHistory = false) { - console.log(`[TerminalSession] attach() called for session ${this.sessionId}, forceLoadHistory=${forceLoadHistory}`); + async attach(forceLoadHistory = false, options = {}) { + const forceReattach = options && options.forceReattach === true; + console.log(`[TerminalSession] attach() called for session ${this.sessionId}, forceLoadHistory=${forceLoadHistory}, forceReattach=${forceReattach}`); + wsSessionTrace.push('session.attach.request', { + session_id: this.sessionId, + force_load_history: forceLoadHistory === true, + force_reattach: forceReattach === true, + already_attached: this.isAttached === true + }); + + if (!this.terminal) { + console.log(`[TerminalSession] Skipping attach - no terminal`); + wsSessionTrace.push('session.attach.skipped_no_terminal', { session_id: this.sessionId }); + return; + } + + if (this._attachPromise) return this._attachPromise; + + this._attachPromise = (async () => { + if (this.isAttached && !forceReattach) { + console.log(`[TerminalSession] Skipping attach - already attached`); + wsSessionTrace.push('session.attach.skipped_already_attached', { session_id: this.sessionId }); + return; + } + + if (this.isAttached && forceReattach) { + // Local-only reset: do not send WS detach while transport may be stale. + this.onTransportDisconnected(); + } - if (!this.isAttached && this.terminal) { // Clear terminal before attaching to avoid showing old content this.terminal.clear(); // Gate stdout immediately to prevent duplicates while resolving history this._gateWsStdout(); // Register history sync handler BEFORE sending attach to avoid race // where a fast server response emits ws-attached before we listen. - // This ensures we never hit the 5000ms fallback when the backend is fast. this.handleHistoryLoading(forceLoadHistory); console.log(`[TerminalSession] Sending attach message for session ${this.sessionId}`); @@ -645,47 +672,58 @@ export class TerminalSession { this.wsClient.send('attach', { session_id: this.sessionId }); + wsSessionTrace.push('session.attach.sent', { session_id: this.sessionId }); // Mark as attached immediately to start receiving output this.isAttached = true; console.log(`[TerminalSession] Marked as attached, history sync armed for session ${this.sessionId}`); - + // Ensure proper sizing after DOM has settled - // Use requestAnimationFrame to ensure layout is complete requestAnimationFrame(() => { this.fit(); this.logFitDimensions('attach-fit'); - - // Note: Focus is now handled after history loading completes - // to prevent focus events from corrupting the output stream - - // Emit ready event this.eventBus.emit('terminal-ready', { sessionId: this.sessionId }); }); - } else { - console.log(`[TerminalSession] Skipping attach - already attached or no terminal`); + })(); + + try { + await this._attachPromise; + } finally { + this._attachPromise = null; } } - - detach(dispose = false) { - if (this.isAttached) { - this.wsClient.send('detach', { - session_id: this.sessionId - }); - this.isAttached = false; - } - // Clear any pending output queue on detach + // Reset attachment/sync state after transport loss without sending detach over WS. + onTransportDisconnected() { + wsSessionTrace.push('session.transport_disconnected', { + session_id: this.sessionId, + had_attach_handler: !!this._wsAttachHandler, + was_attached: this.isAttached === true + }); + this.isAttached = false; this.outputQueue = []; - // Drop any buffered early stdout and open the gate this._clearWsStdoutBuffer(); this._openWsStdoutGate(); this.resetHistorySyncState(); - // Remove pending ws-attached handler to avoid races after detach try { if (this._wsAttachHandler) { this.eventBus.off('ws-attached', this._wsAttachHandler); } } catch (_) {} this._wsAttachHandler = null; - // Abort in-flight streamed history try { if (this._historyAbort) { this._historyAbort.abort(); this._historyAbort = null; } } catch (_) {} + } + + detach(dispose = false) { + wsSessionTrace.push('session.detach.request', { + session_id: this.sessionId, + dispose: dispose === true, + is_attached: this.isAttached === true + }); + if (this.isAttached) { + this.wsClient.send('detach', { + session_id: this.sessionId + }); + wsSessionTrace.push('session.detach.sent', { session_id: this.sessionId }); + } + + this.onTransportDisconnected(); // Disconnect observers and timers try { if (this._io) { this._io.disconnect(); this._io = null; } } catch (_) {} @@ -914,6 +952,11 @@ export class TerminalSession { async handleHistoryLoading(forceLoadHistory = false) { console.log(`[TerminalSession] handleHistoryLoading() called for session ${this.sessionId}`); console.log(`[TerminalSession] Session data load_history flag: ${this.sessionData?.load_history}`); + const shouldLoadHistory = (forceLoadHistory || this.sessionData?.load_history !== false); + wsSessionTrace.push('session.history_sync.start', { + session_id: this.sessionId, + should_load_history: shouldLoadHistory === true + }); this.clearHistorySyncTimer(); this.historySyncComplete = false; @@ -929,12 +972,17 @@ export class TerminalSession { if (event.type === 'attached' && event.detail.session_id === this.sessionId) { this.historyMarker = event.detail.history_marker; this.historyByteOffset = event.detail.history_byte_offset != null ? Number(event.detail.history_byte_offset) : null; + wsSessionTrace.push('session.attached.ack', { + session_id: this.sessionId, + history_marker: this.historyMarker, + history_byte_offset: this.historyByteOffset + }); this.eventBus.off('ws-attached', handler); if (this._wsAttachHandler === handler) this._wsAttachHandler = null; console.log(`[TerminalSession] Got history marker ${this.historyMarker}, byte offset ${this.historyByteOffset} for session ${this.sessionId}`); // Only load history if we have a marker and should load. Honor forceLoadHistory override. - if (this.historyMarker !== null && (forceLoadHistory || this.sessionData.load_history !== false)) { + if (this.historyMarker !== null && shouldLoadHistory) { console.log(`[TerminalSession] Loading history for session ${this.sessionId}`); // Set loading flag to queue any incoming output during history load this.isLoadingHistory = true; @@ -976,9 +1024,14 @@ export class TerminalSession { this.historySyncTimer = setTimeout(() => { try { this.eventBus.off('ws-attached', handler); } catch (_) {} if (this._wsAttachHandler === handler) this._wsAttachHandler = null; - // If we still haven't loaded history, proceed without it - if (!this.historySyncComplete && (forceLoadHistory || this.sessionData?.load_history !== false)) { + // If we still haven't loaded history, proceed without it. + // This must run regardless of shouldLoadHistory so we always open the stdout gate. + if (!this.historySyncComplete) { console.log(`[TerminalSession] No attach response received after 5000ms for session ${this.sessionId}, proceeding without history`); + wsSessionTrace.push('session.attached.ack_timeout', { + session_id: this.sessionId, + should_load_history: shouldLoadHistory === true + }); this.finishHistorySync(); // Safe to focus since we're not loading history // Open gate and flush any buffered output to avoid data loss diff --git a/frontend/public/js/modules/websocket/handlers/stdout-handler.js b/frontend/public/js/modules/websocket/handlers/stdout-handler.js index 3a1a3dd..e7b9469 100644 --- a/frontend/public/js/modules/websocket/handlers/stdout-handler.js +++ b/frontend/public/js/modules/websocket/handlers/stdout-handler.js @@ -3,6 +3,7 @@ * Handles terminal output messages */ import { debug } from '../../../utils/debug.js'; +import { wsSessionTrace } from '../../../utils/ws-session-trace.js'; export class StdoutHandler { handle(message, context) { @@ -13,6 +14,10 @@ export class StdoutHandler { // Get session from terminal manager if (context.terminalManager) { + try { + const bytes = typeof message.data === 'string' ? message.data.length : 0; + wsSessionTrace.pushStdout(message.session_id, bytes); + } catch (_) {} const session = context.terminalManager.sessions.get(message.session_id); if (session) { // Pass along the from_queue flag if present (for debugging) diff --git a/frontend/public/js/services/websocket.service.js b/frontend/public/js/services/websocket.service.js index b8927b9..ecf5731 100644 --- a/frontend/public/js/services/websocket.service.js +++ b/frontend/public/js/services/websocket.service.js @@ -3,6 +3,17 @@ * Handles WebSocket connections, reconnection logic, and message routing */ import { appStore } from '../core/store.js'; +import { wsSessionTrace } from '../utils/ws-session-trace.js'; + +const sanitizeWsUrlForTrace = (value) => { + try { + const u = new URL(String(value || ''), window.location?.href || 'http://local'); + u.searchParams.delete('ws_token'); + return `${u.protocol}//${u.host}${u.pathname}`; + } catch (_) { + return String(value || ''); + } +}; export class WebSocketService { constructor(options = {}) { @@ -79,7 +90,13 @@ export class WebSocketService { */ connect(url, options = {}) { return new Promise((resolve, reject) => { + wsSessionTrace.push('ws.connect.request', { + state: this.getState(), + url: sanitizeWsUrlForTrace(url) + }); + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + wsSessionTrace.push('ws.connect.short_circuit_open'); resolve(); return; } @@ -132,6 +149,7 @@ export class WebSocketService { }; const emit = (type, ev) => { const set = shim._handlers[type]; if (!set) return; for (const fn of Array.from(set)) { try { fn(ev); } catch (_) {} } }; bridge.onOpen(() => { shim.readyState = OPEN; emit('open'); + wsSessionTrace.push('ws.open', { via: 'bridge' }); // Mirror the open handler path from native below this.isConnecting = false; this.reconnectAttempts = 0; @@ -206,6 +224,7 @@ export class WebSocketService { } this.ws.addEventListener('open', async () => { + wsSessionTrace.push('ws.open', { via: 'native' }); this.isConnecting = false; this.reconnectAttempts = 0; @@ -310,6 +329,9 @@ export class WebSocketService { }); this.ws.addEventListener('error', (error) => { + wsSessionTrace.push('ws.error.connect_phase', { + message: error?.message || String(error || '') + }); this.isConnecting = false; this.emit('error', error); try { appStore.setPath('connection.websocket', 'error'); } catch (_) {} @@ -548,6 +570,19 @@ export class WebSocketService { this.ws.addEventListener('error', (event) => { console.error('WebSocket error:', event); + wsSessionTrace.push('ws.error', { + state: this.getState(), + message: event?.message || String(event || '') + }); + // Some platforms emit error without a corresponding close event. + // If that happens while still OPEN, force reconnect to avoid zombie sockets. + setTimeout(() => { + try { + if (!this.isClosing && this.ws && this.ws.readyState === WebSocket.OPEN) { + this.forceReconnect('WebSocket error event'); + } + } catch (_) {} + }, 250); }); } @@ -631,6 +666,7 @@ export class WebSocketService { const wasClean = event.wasClean; const code = event.code; const reason = event.reason; + wsSessionTrace.push('ws.close', { code, reason: reason || '', wasClean: wasClean === true }); this.emit('close', { wasClean, code, reason }); try { appStore.setPath('connection.websocket', 'disconnected'); } catch (_) {} @@ -666,6 +702,10 @@ export class WebSocketService { this.reconnectAttempts++; this.emit('reconnecting', { attempt: this.reconnectAttempts, delay }); + wsSessionTrace.push('ws.reconnect.scheduled', { + attempt: this.reconnectAttempts, + delay + }); this.reconnectTimer = setTimeout(() => { (async () => { @@ -754,6 +794,103 @@ export class WebSocketService { } } + /** + * Probe transport health with an immediate ping/pong round-trip. + * Returns false on timeout, close, or send failure. + * @param {number} timeoutMs + * @returns {Promise<boolean>} + */ + async probeHealth(timeoutMs = 4000) { + if (!this.isReady()) return false; + + const effectiveTimeout = Number.isFinite(Number(timeoutMs)) + ? Math.max(500, Math.floor(Number(timeoutMs))) + : 4000; + + wsSessionTrace.push('ws.health.probe.start', { + timeout_ms: effectiveTimeout + }); + + return new Promise((resolve) => { + let settled = false; + + const cleanup = () => { + try { this.off('pong', onPong); } catch (_) {} + try { this.off('close', onClose); } catch (_) {} + if (timer) clearTimeout(timer); + }; + + const finish = (ok) => { + if (settled) return; + settled = true; + cleanup(); + wsSessionTrace.push('ws.health.probe.result', { ok: ok === true }); + resolve(ok === true); + }; + + const onPong = () => finish(true); + const onClose = () => finish(false); + + this.on('pong', onPong); + this.on('close', onClose); + + const timer = setTimeout(() => { + finish(false); + // If the socket is still OPEN after probe timeout, force a reconnect path. + // Some mobile platforms can leave WebSocket in OPEN while transport is dead. + try { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.forceReconnect('Health probe timeout'); + } + } catch (_) {} + }, effectiveTimeout); + + const sent = this.send('ping', { timestamp: Date.now(), probe: true }); + if (!sent) finish(false); + }); + } + + /** + * Force reconnection, with fallback when close events are not delivered. + * @param {string} reason + */ + forceReconnect(reason = 'Forced reconnect') { + wsSessionTrace.push('ws.force_reconnect', { + reason, + state: this.getState() + }); + this.stopPing(); + + // Reset authentication state on forced reconnect. + this.isAuthenticated = false; + this.authenticationRequired = false; + if (this._authTimer) { + clearTimeout(this._authTimer); + this._authTimer = null; + } + this._pendingAuthResolve = null; + this._pendingAuthReject = null; + + const activeWs = this.ws; + if (activeWs) { + try { activeWs.close(4000, reason); } catch (_) {} + + // Fallback: some environments never deliver close for stale sockets. + setTimeout(() => { + try { + if (this.ws && this.ws === activeWs && this.ws.readyState === WebSocket.OPEN) { + this.handleClose({ wasClean: false, code: 4000, reason }); + } + } catch (_) {} + }, 300); + return; + } + + if (!this.isClosing && this.options.reconnect) { + this.scheduleReconnect(); + } + } + /** * Handle pong response * @private diff --git a/frontend/public/js/utils/dynamic-title-updates.js b/frontend/public/js/utils/dynamic-title-updates.js new file mode 100644 index 0000000..d1be1fe --- /dev/null +++ b/frontend/public/js/utils/dynamic-title-updates.js @@ -0,0 +1,12 @@ +export function isDynamicTitleOnlySessionUpdate(sessionData, updateType = 'updated') { + if (!sessionData || typeof sessionData !== 'object') return false; + if (updateType !== 'updated') return false; + + const keys = Object.keys(sessionData); + if (keys.length !== 2) return false; + if (!Object.prototype.hasOwnProperty.call(sessionData, 'session_id')) return false; + if (!Object.prototype.hasOwnProperty.call(sessionData, 'dynamic_title')) return false; + + const sessionId = typeof sessionData.session_id === 'string' ? sessionData.session_id.trim() : ''; + return sessionId.length > 0; +} diff --git a/frontend/public/js/utils/mobile-detection.js b/frontend/public/js/utils/mobile-detection.js index f3fd2f4..86eff8f 100644 --- a/frontend/public/js/utils/mobile-detection.js +++ b/frontend/public/js/utils/mobile-detection.js @@ -3,6 +3,7 @@ * Provides reliable mobile device detection for preventing unwanted mobile behaviors */ import { appStore } from '../core/store.js'; +import { isMobileRuntime } from './mobile-runtime.js'; class MobileDetection { constructor() { @@ -109,12 +110,17 @@ class MobileDetection { return { isMobile: this.isMobile, isTouch: this.isTouch, + isMobileRuntime: this.isMobileRuntime(), shouldPreventAutoFocus: this.shouldPreventAutoFocus(), userAgent: navigator.userAgent, innerWidth: window.innerWidth, innerHeight: window.innerHeight }; } + + isMobileRuntime() { + return isMobileRuntime(globalThis); + } } // Create and export singleton instance diff --git a/frontend/public/js/utils/mobile-runtime.js b/frontend/public/js/utils/mobile-runtime.js new file mode 100644 index 0000000..bc0cd18 --- /dev/null +++ b/frontend/public/js/utils/mobile-runtime.js @@ -0,0 +1,17 @@ +export function isMobileRuntime(target = globalThis) { + const nav = target?.navigator || {}; + const ua = String(nav.userAgent || ''); + const isElectron = !!(target?.window?.desktop && target.window.desktop.isElectron) || /electron/i.test(ua); + if (isElectron) return false; + + const isCapacitor = (() => { + try { + return !!(target?.window?.Capacitor || target?.Capacitor); + } catch (_) { + return false; + } + })(); + + const uaMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua); + return isCapacitor || uaMobile; +} diff --git a/frontend/public/js/utils/window-title.js b/frontend/public/js/utils/window-title.js index 8865f2a..e78daa1 100644 --- a/frontend/public/js/utils/window-title.js +++ b/frontend/public/js/utils/window-title.js @@ -7,6 +7,8 @@ import { getContext } from '../core/context.js'; import { computeDisplayTitle } from './title-utils.js'; +let lastAppliedTitle = null; + function isWindowMode() { try { if (window.WindowModeUtils && typeof WindowModeUtils.shouldUseWindowModeFromUrl === 'function') { @@ -20,34 +22,36 @@ function isWindowMode() { return false; } +function getDisplaySessionData(parentSessionId) { + try { + const terminalManager = getContext()?.app?.modules?.terminal; + if (terminalManager && typeof terminalManager.getDisplaySessionData === 'function') { + return terminalManager.getDisplaySessionData(parentSessionId); + } + } catch (_) { /* ignore */ } + + try { + const { appStore } = getContext(); + if (parentSessionId) { + const sessions = appStore.getState('sessionList.sessions'); + if (sessions && typeof sessions.get === 'function') { + return sessions.get(parentSessionId) || null; + } + } + } catch (_) { /* ignore */ } + + return null; +} + /** Initialize title syncing for window-mode renderers. */ export function initWindowTitleSync() { if (!isWindowMode()) return () => {}; const { appStore } = getContext(); - let lastTitle = null; const applyTitle = (parentSessionId) => { - let finalTitle = 'TermStation'; - try { - let data = null; - if (parentSessionId) { - const sessions = appStore.getState('sessionList.sessions'); - if (sessions && typeof sessions.get === 'function') { - data = sessions.get(parentSessionId); - } - } - // Use empty default so unknown title does not render as placeholder in window title - const display = computeDisplayTitle(data || {}, { fallbackOrder: [], defaultValue: '' }).trim(); - if (display) { - finalTitle = `TermStation — ${display}`; - } - } catch (_) { /* keep default */ } - - if (finalTitle !== lastTitle) { - try { document.title = finalTitle; } catch (_) {} - lastTitle = finalTitle; - } + const data = getDisplaySessionData(parentSessionId); + applyWindowTitleFromSessionData(data); }; const getActiveParentId = () => { @@ -82,3 +86,20 @@ export function initWindowTitleSync() { try { typeof unsubMode === 'function' && unsubMode(); } catch (_) {} }; } + +export function applyWindowTitleFromSessionData(sessionData = null) { + if (!isWindowMode()) return; + + let finalTitle = 'TermStation'; + try { + const display = computeDisplayTitle(sessionData || {}, { fallbackOrder: [], defaultValue: '' }).trim(); + if (display) { + finalTitle = `TermStation — ${display}`; + } + } catch (_) { /* keep default */ } + + if (finalTitle !== lastAppliedTitle) { + try { document.title = finalTitle; } catch (_) {} + lastAppliedTitle = finalTitle; + } +} diff --git a/frontend/public/js/utils/ws-session-trace.js b/frontend/public/js/utils/ws-session-trace.js new file mode 100644 index 0000000..764a266 --- /dev/null +++ b/frontend/public/js/utils/ws-session-trace.js @@ -0,0 +1,105 @@ +import { appStore } from '../core/store.js'; + +const MAX_TRACE_ENTRIES = 500; +const STDOUT_TRACE_INTERVAL_MS = 1500; +const MAX_STRING_LEN = 320; + +class WsSessionTrace { + constructor() { + this.entries = []; + this.sequence = 0; + this.stdoutStats = new Map(); + } + + isEnabled() { + try { + return appStore.getState('preferences.debug.wsSessionTrace') !== false; + } catch (_) { + return true; + } + } + + sanitizeValue(value) { + if (typeof value === 'string') { + if (value.length > MAX_STRING_LEN) { + return `${value.slice(0, MAX_STRING_LEN)}...`; + } + return value; + } + return value; + } + + push(event, details = {}) { + if (!this.isEnabled()) return; + if (!event || typeof event !== 'string') return; + + const payload = {}; + try { + Object.entries(details || {}).forEach(([key, value]) => { + if (value !== undefined) payload[key] = this.sanitizeValue(value); + }); + } catch (_) {} + + const entry = { + seq: ++this.sequence, + ts: new Date().toISOString(), + event, + ...payload + }; + + this.entries.push(entry); + if (this.entries.length > MAX_TRACE_ENTRIES) { + this.entries.splice(0, this.entries.length - MAX_TRACE_ENTRIES); + } + } + + pushStdout(sessionId, bytes = 0) { + if (!this.isEnabled()) return; + + const sid = String(sessionId || 'unknown'); + const now = Date.now(); + const current = this.stdoutStats.get(sid) || { + lastAt: 0, + suppressed: 0, + bytes: 0 + }; + + const chunkBytes = Number.isFinite(Number(bytes)) ? Math.max(0, Number(bytes)) : 0; + current.bytes += chunkBytes; + + if ((now - current.lastAt) < STDOUT_TRACE_INTERVAL_MS) { + current.suppressed += 1; + this.stdoutStats.set(sid, current); + return; + } + + this.push('session.stdout', { + session_id: sid, + bytes: current.bytes, + suppressed: current.suppressed + }); + + current.lastAt = now; + current.suppressed = 0; + current.bytes = 0; + this.stdoutStats.set(sid, current); + } + + clear() { + this.entries = []; + this.stdoutStats.clear(); + } + + getEntries() { + return this.entries.slice(); + } + + toPrettyText() { + const entries = this.getEntries(); + if (!entries.length) return 'No trace events captured yet.'; + return entries.map((entry) => JSON.stringify(entry)).join('\n'); + } +} + +export const wsSessionTrace = new WsSessionTrace(); + diff --git a/frontend/tests/dynamic-title-updates.test.js b/frontend/tests/dynamic-title-updates.test.js new file mode 100644 index 0000000..5a683b4 --- /dev/null +++ b/frontend/tests/dynamic-title-updates.test.js @@ -0,0 +1,36 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { isDynamicTitleOnlySessionUpdate } from '../public/js/utils/dynamic-title-updates.js'; + +test('isDynamicTitleOnlySessionUpdate accepts only session_id plus dynamic_title', () => { + assert.equal(isDynamicTitleOnlySessionUpdate({ + session_id: 'sess-1', + dynamic_title: 'rotating' + }, 'updated'), true); +}); + +test('isDynamicTitleOnlySessionUpdate rejects payloads with extra fields', () => { + assert.equal(isDynamicTitleOnlySessionUpdate({ + session_id: 'sess-1', + dynamic_title: 'rotating', + output_active: true + }, 'updated'), false); + + assert.equal(isDynamicTitleOnlySessionUpdate({ + session_id: 'sess-1', + dynamic_title: 'rotating', + title: 'Pinned' + }, 'updated'), false); +}); + +test('isDynamicTitleOnlySessionUpdate rejects non-updated events and invalid ids', () => { + assert.equal(isDynamicTitleOnlySessionUpdate({ + session_id: 'sess-1', + dynamic_title: 'rotating' + }, 'created'), false); + + assert.equal(isDynamicTitleOnlySessionUpdate({ + session_id: ' ', + dynamic_title: 'rotating' + }, 'updated'), false); +}); diff --git a/frontend/tests/mobile-detection.test.js b/frontend/tests/mobile-detection.test.js new file mode 100644 index 0000000..8dfd4f3 --- /dev/null +++ b/frontend/tests/mobile-detection.test.js @@ -0,0 +1,31 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { isMobileRuntime } from '../public/js/utils/mobile-runtime.js'; + +test('isMobileRuntime returns false for narrow desktop browsers', () => { + assert.equal(isMobileRuntime({ + navigator: { userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/122.0.0.0 Safari/537.36' }, + window: { innerWidth: 500, desktop: null } + }), false); +}); + +test('isMobileRuntime returns false for Electron desktop', () => { + assert.equal(isMobileRuntime({ + navigator: { userAgent: 'Mozilla/5.0 AppleWebKit/537.36 Chrome/122.0.0.0 Electron/30.0.0 Safari/537.36' }, + window: { desktop: { isElectron: true } } + }), false); +}); + +test('isMobileRuntime returns true for mobile browser user agents', () => { + assert.equal(isMobileRuntime({ + navigator: { userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Version/17.0 Mobile/15E148 Safari/604.1' }, + window: {} + }), true); +}); + +test('isMobileRuntime returns true for Capacitor runtimes', () => { + assert.equal(isMobileRuntime({ + navigator: { userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/122.0.0.0 Safari/537.36' }, + window: { Capacitor: {} } + }), true); +}); diff --git a/frontend/tests/window-title.test.js b/frontend/tests/window-title.test.js new file mode 100644 index 0000000..9999b78 --- /dev/null +++ b/frontend/tests/window-title.test.js @@ -0,0 +1,50 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyWindowTitleFromSessionData } from '../public/js/utils/window-title.js'; + +function withWindowMode(testFn) { + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + const originalWindowModeUtils = globalThis.WindowModeUtils; + + const windowModeUtils = { + shouldUseWindowModeFromUrl: () => true + }; + + globalThis.window = { + location: {}, + WindowModeUtils: windowModeUtils + }; + globalThis.WindowModeUtils = windowModeUtils; + globalThis.document = { + title: '', + documentElement: { + getAttribute: () => 'window' + } + }; + + try { + testFn(); + } finally { + globalThis.window = originalWindow; + globalThis.document = originalDocument; + globalThis.WindowModeUtils = originalWindowModeUtils; + } +} + +test('applyWindowTitleFromSessionData sets window title from display title', () => { + withWindowMode(() => { + applyWindowTitleFromSessionData({ title: 'Explicit Title', dynamic_title: 'Ignored' }); + assert.equal(globalThis.document.title, 'TermStation — Explicit Title'); + + applyWindowTitleFromSessionData({ dynamic_title: 'Dynamic Title' }); + assert.equal(globalThis.document.title, 'TermStation — Dynamic Title'); + }); +}); + +test('applyWindowTitleFromSessionData falls back to default app title', () => { + withWindowMode(() => { + applyWindowTitleFromSessionData({}); + assert.equal(globalThis.document.title, 'TermStation'); + }); +});