diff --git a/app/core/config.py b/app/core/config.py index df47f6d..42d56c2 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -75,6 +75,9 @@ class Settings(BaseSettings): agent_commit_message_timeout_seconds: int = 120 agent_commit_message_diff_max_bytes: int = 100_000 agent_commit_message_args: str = "" + # OpenCode's data directory (holding ``opencode.db``). Empty resolves the + # XDG default (``~/.local/share/opencode``). + opencode_data_dir: str = "" vscode_enabled: bool = True vscode_binary: str = "code-server" diff --git a/app/main.py b/app/main.py index f4c6c7a..b31906e 100644 --- a/app/main.py +++ b/app/main.py @@ -10,6 +10,7 @@ from app.core.errors import validation_exception_handler from app.core.i18n import resolve_language, set_language from app.core.logging import configure_logging +from app.modules.agent_sessions.router import router as agent_sessions_router from app.modules.agents.router import router as agents_router from app.modules.ai_chat.approvals import cancel_all as cancel_pending_approvals from app.modules.ai_chat.router import router as ai_chat_router @@ -113,6 +114,7 @@ async def _apply_accept_language(request: Request, call_next): app.include_router(vscode_router, prefix=settings.api_prefix) app.include_router(browser_router, prefix=settings.api_prefix) app.include_router(agents_router, prefix=settings.api_prefix) + app.include_router(agent_sessions_router, prefix=settings.api_prefix) app.include_router(ai_providers_router, prefix=settings.api_prefix) app.include_router(ai_chat_router, prefix=settings.api_prefix) app.include_router(workspace_router, prefix=settings.api_prefix) diff --git a/app/modules/agent_sessions/__init__.py b/app/modules/agent_sessions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/agent_sessions/router.py b/app/modules/agent_sessions/router.py new file mode 100644 index 0000000..6bfe77d --- /dev/null +++ b/app/modules/agent_sessions/router.py @@ -0,0 +1,26 @@ +from fastapi import APIRouter + +from app.core.deps import DbDep, SettingsDep +from app.core.errors import error_responses +from app.modules.agent_sessions import service +from app.modules.agent_sessions.schemas import AgentSessionListResponse +from app.modules.auth.deps import CurrentUser + +router = APIRouter(prefix="/projects", tags=["agent-sessions"]) + + +@router.get( + "/{project_id}/agent-sessions", + response_model=AgentSessionListResponse, + responses=error_responses(401, 403, 404), +) +def list_agent_sessions( + project_id: int, + current_user: CurrentUser, + db: DbDep, + settings: SettingsDep, + worktree: str | None = None, +) -> AgentSessionListResponse: + return AgentSessionListResponse( + sessions=service.list_project_sessions(db, current_user, project_id, worktree, settings) + ) diff --git a/app/modules/agent_sessions/schemas.py b/app/modules/agent_sessions/schemas.py new file mode 100644 index 0000000..1c9a227 --- /dev/null +++ b/app/modules/agent_sessions/schemas.py @@ -0,0 +1,19 @@ +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel + + +class AgentSessionRead(BaseModel): + """A resumable session, either an in-app chat or a host CLI agent session.""" + + source: Literal["chat", "cli"] + id: str + title: str + agent_id: str | None = None + agent_label: str | None = None + updated_at: datetime + + +class AgentSessionListResponse(BaseModel): + sessions: list[AgentSessionRead] diff --git a/app/modules/agent_sessions/service.py b/app/modules/agent_sessions/service.py new file mode 100644 index 0000000..52d38f4 --- /dev/null +++ b/app/modules/agent_sessions/service.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from pathlib import Path + +from fastapi import status +from sqlalchemy.orm import Session + +from app.core.config import Settings +from app.core.errors import ErrorCode, api_error +from app.modules.agent_sessions.schemas import AgentSessionRead +from app.modules.agents import service as agents_service +from app.modules.agents.sessions import list_sessions as list_cli_sessions +from app.modules.ai_chat import service as chat_service +from app.modules.auth.models import User +from app.modules.git import service as git_service +from app.modules.projects import service as project_service + +MAX_SESSIONS = 200 + + +def resolve_directory( + db: Session, user: User, project_id: int, worktree: str | None, settings: Settings +) -> Path: + """The project or worktree directory whose sessions should be listed.""" + project = project_service.get_project(db, user, project_id) + directory = Path(project.path).resolve() + if worktree: + resolved = git_service.find_worktree(directory, worktree, settings) + if resolved is None or not resolved.is_dir(): + raise api_error(ErrorCode.GIT_WORKTREE_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND) + directory = resolved.resolve() + return directory + + +def list_project_sessions( + db: Session, + user: User, + project_id: int, + worktree: str | None, + settings: Settings, +) -> list[AgentSessionRead]: + """Merge in-app chat conversations and host CLI agent sessions, newest first.""" + directory = resolve_directory(db, user, project_id, worktree, settings) + + sessions: list[AgentSessionRead] = [ + AgentSessionRead( + source="chat", + id=conversation.id, + title=conversation.title, + updated_at=_as_utc(conversation.updated_at), + ) + for conversation in chat_service.list_conversations(db, user, project_id) + ] + + for session in list_cli_sessions(settings, directory): + sessions.append( + AgentSessionRead( + source="cli", + id=session.id, + title=session.title, + agent_id=session.agent_id, + agent_label=agents_service.agent_label(session.agent_id), + updated_at=_as_utc(session.updated_at), + ) + ) + + sessions.sort(key=lambda item: item.updated_at, reverse=True) + return sessions[:MAX_SESSIONS] + + +def _as_utc(value: datetime) -> datetime: + """Normalize a datetime to UTC-aware so mixed sources sort and serialize consistently.""" + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) diff --git a/app/modules/agents/base.py b/app/modules/agents/base.py index c9ac8bc..33c6e10 100644 --- a/app/modules/agents/base.py +++ b/app/modules/agents/base.py @@ -6,6 +6,7 @@ from dataclasses import dataclass PROMPT_PLACEHOLDER = "{prompt}" +SESSION_PLACEHOLDER = "{session}" @dataclass(frozen=True, slots=True) @@ -22,6 +23,9 @@ class AgentSpec: # ``("-p", "{prompt}")`` or ``("exec", "{prompt}")``. Empty means the agent # has no known non-interactive prompt mode. prompt_args: tuple[str, ...] = () + # How to resume a previous session, e.g. ``("--session", "{session}")`` or + # ``("resume", "{session}")``. Empty means the agent cannot resume. + resume_args: tuple[str, ...] = () class Agent(ABC): @@ -49,6 +53,26 @@ def build_launch(self, command: str | None = None, args: str | None = None) -> s resolved_args = (args if args is not None else self.spec.args).strip() return f"{resolved_command} {resolved_args}".strip() + def build_resume_launch( + self, command: str | None, args: str | None, session_id: str | None + ) -> str | None: + """Return the shell line that resumes ``session_id``, or ``None``. + + ``None`` means the agent declares no resume form (see + :attr:`AgentSpec.resume_args`) or the session id is empty, in which case + callers fall back to a plain launch. + """ + if not self.spec.resume_args or not session_id: + return None + resolved_command = (command if command is not None else self.spec.command).strip() + if not resolved_command: + return None + resolved_args = (args if args is not None else self.spec.args).strip() + tail = [ + session_id if token == SESSION_PLACEHOLDER else token for token in self.spec.resume_args + ] + return " ".join(part for part in [resolved_command, resolved_args, *tail] if part) + def build_prompt_command( self, command: str | None, args: str | None, prompt: str ) -> list[str] | None: diff --git a/app/modules/agents/catalog.py b/app/modules/agents/catalog.py index c66bef3..2f32dde 100644 --- a/app/modules/agents/catalog.py +++ b/app/modules/agents/catalog.py @@ -15,7 +15,13 @@ def __init__(self, spec: AgentSpec) -> None: DEFAULT_AGENT_SPECS: tuple[AgentSpec, ...] = ( AgentSpec(id="claude", label="Claude Code", command="claude", prompt_args=("-p", "{prompt}")), AgentSpec(id="agy", label="Antigravity", command="agy"), - AgentSpec(id="opencode", label="OpenCode", command="opencode", prompt_args=("run", "{prompt}")), + AgentSpec( + id="opencode", + label="OpenCode", + command="opencode", + prompt_args=("run", "{prompt}"), + resume_args=("--session", "{session}"), + ), AgentSpec(id="codex", label="Codex", command="codex", prompt_args=("exec", "{prompt}")), AgentSpec(id="gemini", label="Gemini CLI", command="gemini", prompt_args=("-p", "{prompt}")), AgentSpec(id="aider", label="Aider", command="aider"), diff --git a/app/modules/agents/service.py b/app/modules/agents/service.py index 74db010..dd31fc4 100644 --- a/app/modules/agents/service.py +++ b/app/modules/agents/service.py @@ -124,6 +124,16 @@ def agent_exists(agent_id: str) -> bool: return registry.get(agent_id) is not None +def agent_label(agent_id: str) -> str | None: + """The catalog label for a registered agent, or ``None`` if unknown. + + Unlike :func:`list_agents` this does not touch the database or probe the + host, so it is cheap enough for per-request display only. + """ + agent = registry.get(agent_id) + return agent.spec.label if agent is not None else None + + def effective_config(db: Session, user: User, agent: Agent) -> tuple[str, str]: """Effective (command, args) for an agent, applying the user's override.""" override = db.scalar( @@ -144,6 +154,29 @@ def resolve_launch(db: Session, user: User, agent_id: str) -> str | None: return agent.build_launch(*effective_config(db, user, agent)) +# Session ids are supplied by the client and typed verbatim into the shell, so +# only the conservative character set CLI agents use is accepted. +_SESSION_ID_MAX = 128 +_SESSION_ID_RE = re.compile(rf"^[A-Za-z0-9._:-]{{1,{_SESSION_ID_MAX}}}$") + + +def valid_session_id(session_id: str) -> bool: + """Whether ``session_id`` is safe to place on a shell launch line.""" + return _SESSION_ID_RE.fullmatch(session_id) is not None + + +def resolve_resume_launch(db: Session, user: User, agent_id: str, session_id: str) -> str | None: + """Build the line that resumes ``session_id`` for an agent, or ``None``. + + Returns ``None`` for an unknown agent, an agent without a resume form, or an + unsafe session id, so callers simply skip launching. + """ + agent = registry.get(agent_id) + if agent is None or not valid_session_id(session_id): + return None + return agent.build_resume_launch(*effective_config(db, user, agent), session_id) + + def get_default_agent_id(db: Session, user: User) -> str | None: """The agent the user selected as default, or ``None`` when unset.""" return user_settings.get_value(db, user.id, DEFAULT_AGENT_KEY) diff --git a/app/modules/agents/sessions.py b/app/modules/agents/sessions.py new file mode 100644 index 0000000..0d4b87e --- /dev/null +++ b/app/modules/agents/sessions.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import os +import sqlite3 +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Protocol + +from app.core.config import Settings + +DEFAULT_SESSION_LIMIT = 100 +MAX_SESSION_LIMIT = 500 +MAX_TITLE_LENGTH = 200 +UNTITLED_SESSION = "Untitled session" + + +@dataclass(frozen=True, slots=True) +class AgentSession: + """A single resumable session recorded by a CLI agent on the host.""" + + agent_id: str + id: str + title: str + directory: str + updated_at: datetime + + +class AgentSessionSource(Protocol): + """Reads the sessions one agent has recorded for a project directory.""" + + agent_id: str + + def list_sessions(self, directory: Path, limit: int) -> list[AgentSession]: ... + + +class OpencodeSessionSource: + """Reads OpenCode sessions from its local SQLite database, read-only. + + OpenCode records each session's launch directory, so sessions are matched + to a project/worktree by directory rather than by transcript files. + """ + + agent_id = "opencode" + + def __init__(self, data_dir: Path) -> None: + self._data_dir = data_dir + + @property + def database_path(self) -> Path: + return self._data_dir / "opencode.db" + + def list_sessions(self, directory: Path, limit: int) -> list[AgentSession]: + db_path = self.database_path + if limit <= 0 or not db_path.is_file(): + return [] + try: + connection = sqlite3.connect(f"{db_path.as_uri()}?mode=ro", uri=True) + except (sqlite3.Error, ValueError): + return [] + try: + connection.row_factory = sqlite3.Row + rows = connection.execute( + "SELECT id, title, directory, time_updated FROM session " + "WHERE rtrim(directory, '/') = ? AND time_archived IS NULL " + "ORDER BY time_updated DESC LIMIT ?", + (_normalize_directory(directory), limit), + ).fetchall() + except sqlite3.Error: + return [] + finally: + connection.close() + return [_row_to_session(self.agent_id, row) for row in rows] + + +def opencode_data_dir(settings: Settings) -> Path: + """Resolve OpenCode's data directory as an absolute path.""" + configured = settings.opencode_data_dir.strip() + if configured: + return Path(configured).expanduser().resolve() + xdg = os.environ.get("XDG_DATA_HOME") + base = Path(xdg).expanduser() if xdg else Path.home() / ".local" / "share" + return (base / "opencode").resolve() + + +def build_session_sources(settings: Settings) -> list[AgentSessionSource]: + """The session sources the server can read, keyed by agent id. + + Only agents with a known, stable on-disk format are listed; adding another + agent means appending its source here. + """ + return [OpencodeSessionSource(opencode_data_dir(settings))] + + +def list_sessions( + settings: Settings, directory: Path, limit: int = DEFAULT_SESSION_LIMIT +) -> list[AgentSession]: + """Every recorded session whose directory matches ``directory``, newest first.""" + bounded = max(1, min(limit, MAX_SESSION_LIMIT)) + sessions: list[AgentSession] = [] + for source in build_session_sources(settings): + sessions.extend(source.list_sessions(directory, bounded)) + sessions.sort(key=lambda session: session.updated_at, reverse=True) + return sessions[:bounded] + + +def _normalize_directory(directory: Path) -> str: + text = str(directory) + if len(text) > 1 and text.endswith("/"): + return text[:-1] + return text + + +def _row_to_session(agent_id: str, row: sqlite3.Row) -> AgentSession: + title = str(row["title"] or "").strip() or UNTITLED_SESSION + return AgentSession( + agent_id=agent_id, + id=str(row["id"]), + title=title[:MAX_TITLE_LENGTH], + directory=str(row["directory"] or ""), + updated_at=_from_millis(row["time_updated"]), + ) + + +def _from_millis(value: object) -> datetime: + try: + return datetime.fromtimestamp(int(value) / 1000, tz=UTC) + except (TypeError, ValueError, OSError, OverflowError): + return datetime.now(UTC) diff --git a/app/modules/browser/router.py b/app/modules/browser/router.py index 8e9a424..30d28ad 100644 --- a/app/modules/browser/router.py +++ b/app/modules/browser/router.py @@ -151,18 +151,47 @@ async def get_selection( browser = await _ensure_session(db, settings, current_user, project_id, session) db.close() - try: - output = await asyncio.to_thread(browser.run, service.selection_command(settings)) - except service.BrowserError as exc: - raise api_error( - ErrorCode.BROWSER_COMMAND_FAILED, status_code=status.HTTP_502_BAD_GATEWAY - ) from exc + output = await _run( + service.selection_command(settings), + browser, + code=ErrorCode.BROWSER_COMMAND_FAILED, + ) payload = service.parse_status(output) data = payload.get("data") if isinstance(payload, dict) else None text = data.get("result") if isinstance(data, dict) else None return {"text": text if isinstance(text, str) else ""} +@router.post( + "/{project_id}/browser/inspect", + responses=error_responses(401, 404, 502), +) +async def inspect( + request: Request, + project_id: int, + current_user: CurrentUser, + db: DbDep, + settings: SettingsDep, + session: str = Query(service.DEFAULT_SESSION), +) -> dict[str, str]: + if not same_origin(request): + raise api_error(ErrorCode.BROWSER_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN) + + browser = await _ensure_session(db, settings, current_user, project_id, session) + db.close() + output = await _run( + service.inspect_command(settings), + browser, + code=ErrorCode.BROWSER_COMMAND_FAILED, + ) + url = service.parse_inspect_url(output) + if not url or not service.is_loopback_url(url): + raise api_error( + ErrorCode.BROWSER_COMMAND_FAILED, status_code=status.HTTP_502_BAD_GATEWAY + ) + return {"url": url} + + @router.post( "/{project_id}/browser/{action}", responses=error_responses(400, 401, 404, 502), @@ -338,9 +367,9 @@ async def _run( session: service.BrowserSession, *, code: ErrorCode = ErrorCode.BROWSER_NAVIGATION_FAILED, -) -> None: +) -> str | None: try: - await asyncio.to_thread(session.run, command) + return await asyncio.to_thread(session.run, command) except service.BrowserError as exc: raise api_error(code, status_code=status.HTTP_502_BAD_GATEWAY) from exc diff --git a/app/modules/browser/service.py b/app/modules/browser/service.py index 5fee9f9..0bcaac6 100644 --- a/app/modules/browser/service.py +++ b/app/modules/browser/service.py @@ -100,6 +100,11 @@ def viewport_command(width: int, height: int, settings: Settings) -> list[str]: return [settings.browser_binary, "set", "viewport", str(width), str(height)] +def inspect_command(settings: Settings) -> list[str]: + """Start the DevTools proxy for the session and report its frontend URL.""" + return [settings.browser_binary, "inspect"] + + def insert_text_command(text: str, settings: Settings) -> list[str]: # A NUL byte cannot be passed through argv; drop it defensively. return [settings.browser_binary, "keyboard", "inserttext", text.replace("\x00", "")] @@ -124,6 +129,29 @@ def is_http_url(url: str) -> bool: return parts.scheme in {"http", "https"} and bool(parts.netloc) +# ``agent-browser inspect`` prints a human line ending in the local DevTools URL, +# for example ``✓ Opened DevTools: http://127.0.0.1:32825``. +_INSPECT_URL_RE = re.compile(r"https?://[^\s\"']+") + + +def parse_inspect_url(output: str | None) -> str | None: + """Extract the DevTools frontend URL from ``agent-browser inspect`` output.""" + if not output: + return None + match = _INSPECT_URL_RE.search(output) + return match.group(0) if match else None + + +def is_loopback_url(url: str) -> bool: + """Guard the URL handed to the client: only ever open a local DevTools proxy.""" + parts = urlsplit(url) + return parts.scheme in {"http", "https"} and parts.hostname in { + "127.0.0.1", + "localhost", + "::1", + } + + def _default_runner(command: list[str], env: dict[str, str]) -> str: try: result = subprocess.run( diff --git a/app/modules/terminal/router.py b/app/modules/terminal/router.py index 3cb9dae..16811af 100644 --- a/app/modules/terminal/router.py +++ b/app/modules/terminal/router.py @@ -34,6 +34,7 @@ async def project_terminal( settings: SettingsDep, worktree: str | None = None, agent_id: str | None = None, + session: str | None = None, ) -> None: if not same_origin(websocket): await reject(websocket, WS_FORBIDDEN) @@ -68,8 +69,14 @@ async def project_terminal( cwd = resolved # Resolve the launch line and environment from the agent registry before - # releasing the database connection used to read the user's overrides. - launch = agents_service.resolve_launch(db, user, agent_id) if agent_id is not None else None + # releasing the database connection used to read the user's overrides. A + # supplied session id resumes that session instead of starting fresh. + if agent_id is None: + launch = None + elif session is not None: + launch = agents_service.resolve_resume_launch(db, user, agent_id, session) + else: + launch = agents_service.resolve_launch(db, user, agent_id) agent_env = agents_service.agent_env(agent_id) if agent_id is not None else {} # Release the database connection: it is not needed for the (potentially diff --git a/tests/test_agent_sessions.py b/tests/test_agent_sessions.py new file mode 100644 index 0000000..baabb1b --- /dev/null +++ b/tests/test_agent_sessions.py @@ -0,0 +1,138 @@ +import sqlite3 +from datetime import UTC, datetime +from pathlib import Path + +from fastapi.testclient import TestClient + +from app.core.config import get_settings +from app.main import app +from app.modules.agent_sessions import service as sessions_service +from app.modules.agents.catalog import registry +from app.modules.agents.service import valid_session_id +from app.modules.agents.sessions import UNTITLED_SESSION, OpencodeSessionSource +from app.modules.ai_chat.schemas import ConversationRead +from tests.conftest import LOCAL_URL, PROJECTS_URL, _csrf, _login + +SESSIONS_URL = f"{PROJECTS_URL}/{{project_id}}/agent-sessions" + + +def _write_opencode_db(database: Path, rows: list[tuple[str, str, str, int, int | None]]) -> None: + database.parent.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(database) + connection.execute( + "CREATE TABLE session (" + "id TEXT PRIMARY KEY, title TEXT, directory TEXT, " + "time_updated INTEGER, time_archived INTEGER)" + ) + connection.executemany("INSERT INTO session VALUES (?, ?, ?, ?, ?)", rows) + connection.commit() + connection.close() + + +def _register_local(client: TestClient, path: Path) -> dict: + response = client.post(LOCAL_URL, json={"path": str(path)}, headers=_csrf(client)) + assert response.status_code == 201, response.text + return response.json() + + +def test_opencode_source_matches_directory_and_skips_archived(tmp_path: Path) -> None: + data_dir = tmp_path / "opencode" + directory = tmp_path / "project" + directory.mkdir() + other = tmp_path / "other" + other.mkdir() + _write_opencode_db( + data_dir / "opencode.db", + [ + ("ses_1", "First task", str(directory), 2_000, None), + ("ses_2", "Elsewhere", str(other), 3_000, None), + ("ses_3", " ", str(directory), 1_000, None), + ("ses_4", "Archived", str(directory), 5_000, 4_000), + ], + ) + + sessions = OpencodeSessionSource(data_dir).list_sessions(directory, limit=10) + + assert [session.id for session in sessions] == ["ses_1", "ses_3"] + assert sessions[0].title == "First task" + assert sessions[1].title == UNTITLED_SESSION + + +def test_opencode_source_without_database_is_empty(tmp_path: Path) -> None: + source = OpencodeSessionSource(tmp_path / "missing") + assert source.list_sessions(tmp_path, limit=10) == [] + + +def test_valid_session_id_accepts_agent_ids_and_rejects_shell_metacharacters() -> None: + assert valid_session_id("ses_ed4cbe8acffenPv7zxEV0xTZhb") + assert valid_session_id("abc-123.4:5") + assert not valid_session_id("") + assert not valid_session_id("has space") + assert not valid_session_id("x; rm -rf /") + assert not valid_session_id("x" * 129) + + +def test_build_resume_launch_for_opencode_and_unsupported_agent() -> None: + opencode = registry.get("opencode") + assert opencode.build_resume_launch("opencode", "--model fast", "ses_1") == ( + "opencode --model fast --session ses_1" + ) + assert opencode.build_resume_launch("opencode", "", None) is None + + aider = registry.get("aider") + assert aider.build_resume_launch("aider", "", "ses_1") is None + + +def test_agent_sessions_requires_authentication(client: TestClient) -> None: + assert client.get(SESSIONS_URL.format(project_id=1)).status_code == 401 + + +def test_agent_sessions_unknown_project(client: TestClient) -> None: + _login(client) + assert client.get(SESSIONS_URL.format(project_id=9999)).status_code == 404 + + +def test_agent_sessions_lists_cli_and_chat( + client: TestClient, projects_root: Path, tmp_path: Path, monkeypatch +) -> None: + _login(client) + project_dir = projects_root / "demo" + project_dir.mkdir() + project = _register_local(client, project_dir) + + data_dir = tmp_path / "opencode" + _write_opencode_db( + data_dir / "opencode.db", + [("ses_abc", "Refactor browser tab", str(project_dir.resolve()), 1_700_000_000_000, None)], + ) + settings = app.dependency_overrides[get_settings]() + settings.opencode_data_dir = str(data_dir) + + now = datetime(2026, 1, 1, tzinfo=UTC) + monkeypatch.setattr( + sessions_service.chat_service, + "list_conversations", + lambda db, user, project_id: [ + ConversationRead( + id="conv-1", + project_id=project["id"], + provider_id=None, + model_id="gpt-test", + title="Chat about tabs", + created_at=now, + updated_at=now, + ) + ], + ) + + response = client.get(SESSIONS_URL.format(project_id=project["id"])) + + assert response.status_code == 200 + sessions = response.json()["sessions"] + assert {session["source"] for session in sessions} == {"chat", "cli"} + assert sessions[0]["source"] == "chat" + cli = next(session for session in sessions if session["source"] == "cli") + assert cli["agent_id"] == "opencode" + assert cli["agent_label"] == "OpenCode" + assert cli["title"] == "Refactor browser tab" + assert cli["id"] == "ses_abc" diff --git a/tests/test_browser.py b/tests/test_browser.py index 09418d2..8465882 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -386,6 +386,62 @@ def test_parse_status_extracts_json() -> None: assert payload == {"enabled": True, "port": 9223} +def test_inspect_command() -> None: + assert service.inspect_command(Settings(browser_binary="agent-browser")) == [ + "agent-browser", + "inspect", + ] + + +def test_parse_inspect_url() -> None: + assert service.parse_inspect_url(None) is None + assert service.parse_inspect_url("nothing here") is None + assert ( + service.parse_inspect_url("✓ Opened DevTools: http://127.0.0.1:32825") + == "http://127.0.0.1:32825" + ) + + +def test_is_loopback_url() -> None: + assert service.is_loopback_url("http://127.0.0.1:32825") + assert service.is_loopback_url("http://localhost:1/") + assert not service.is_loopback_url("http://evil.example/") + assert not service.is_loopback_url("file:///etc/passwd") + + +def test_browser_inspect_returns_url( + client: TestClient, projects_root: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _login(client) + project_id, _ = _register_repo_with_worktree(client, projects_root) + + def runner(command: list[str], env: dict[str, str]) -> str: + return "✓ Opened DevTools: http://127.0.0.1:32825" + + monkeypatch.setattr(service, "manager", _StubManager(_session_for(1234, runner))) + + response = client.post(_browser_url(project_id, "inspect"), headers=_csrf(client)) + + assert response.status_code == 200 + assert response.json() == {"url": "http://127.0.0.1:32825"} + + +def test_browser_inspect_rejects_non_loopback_url( + client: TestClient, projects_root: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _login(client) + project_id, _ = _register_repo_with_worktree(client, projects_root) + + def runner(command: list[str], env: dict[str, str]) -> str: + return "✓ Opened DevTools: http://evil.example/" + + monkeypatch.setattr(service, "manager", _StubManager(_session_for(1234, runner))) + + response = client.post(_browser_url(project_id, "inspect"), headers=_csrf(client)) + + assert response.status_code == 502 + + def _manager_runner(command: list[str], env: dict[str, str]) -> str: return json.dumps( { diff --git a/web/src/components/IdeShell.tsx b/web/src/components/IdeShell.tsx index c1f7e93..6c73378 100644 --- a/web/src/components/IdeShell.tsx +++ b/web/src/components/IdeShell.tsx @@ -1,6 +1,6 @@ import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import { killTerminal, closeBrowserSession, type Project, type User } from '../lib/api' +import { killTerminal, closeBrowserSession, type AgentSession, type Project, type User } from '../lib/api' import type { AgentDefinition } from '../lib/agents' import { LEFT_MAX, LEFT_MIN, RIGHT_MAX, RIGHT_MIN, type LayoutState } from '../lib/layoutStore' import { @@ -10,6 +10,8 @@ import { withOpenFile, withOpenDiff, withChatConversation, + withChatConversationTab, + withAgentSessionTerminal, withTabTitle, withTabPinned, withTabColor, @@ -162,6 +164,37 @@ export function IdeShell({ })) } + function resumeChatSession(conversationId: string, title: string) { + if (!activeProject) return + const worktree = activeWorktrees[activeProject.id] + update((prev) => ({ + ...prev, + workspaces: withChatConversationTab( + prev.workspaces, + activeProject, + conversationId, + title, + worktree, + ), + })) + } + + function resumeAgentSession(session: AgentSession) { + if (!activeProject || !session.agent_id) return + const agentId = session.agent_id + const worktree = activeWorktrees[activeProject.id] + update((prev) => ({ + ...prev, + workspaces: withAgentSessionTerminal(prev.workspaces, activeProject, { + agentId, + agentLabel: session.agent_label ?? agentId, + sessionId: session.id, + title: session.title, + worktree, + }), + })) + } + function openFile(path: string, line?: number) { if (!activeProject) return update((prev) => ({ @@ -367,6 +400,7 @@ export function IdeShell({ terminalId={tab.id} worktree={tab.worktree} agentId={tab.agentId} + agentSessionId={tab.agentSessionId} active={active} onTitle={setTabTitle} /> @@ -402,6 +436,8 @@ export function IdeShell({ chatWorktrees={chatWorktrees} onOpenFile={openFile} onOpenDiff={openDiff} + onResumeChat={resumeChatSession} + onResumeSession={resumeAgentSession} > {tabPanes} @@ -492,6 +528,8 @@ export function IdeShell({ onOpenFile={openFile} onOpenDiff={openDiff} onWorktreeDeleted={closeWorktreeTabs} + onResumeChat={resumeChatSession} + onResumeSession={resumeAgentSession} /> ) : ( diff --git a/web/src/components/MobileShell.tsx b/web/src/components/MobileShell.tsx index d63b366..8a3226d 100644 --- a/web/src/components/MobileShell.tsx +++ b/web/src/components/MobileShell.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState, type ReactNode } from 'react' import { useTranslation } from 'react-i18next' -import type { Project, User } from '../lib/api' +import type { AgentSession, Project, User } from '../lib/api' import type { AgentDefinition } from '../lib/agents' import type { WorkspaceTab } from '../lib/workspaceStore' import { useViewportHeight } from '../lib/useViewportHeight' @@ -57,6 +57,8 @@ interface MobileShellProps { chatWorktrees: Record onOpenFile: (path: string, line?: number) => void onOpenDiff: (path: string, staged: boolean, origPath?: string | null) => void + onResumeChat: (conversationId: string, title: string) => void + onResumeSession: (session: AgentSession) => void children: ReactNode } @@ -82,6 +84,8 @@ export function MobileShell({ chatWorktrees, onOpenFile, onOpenDiff, + onResumeChat, + onResumeSession, children, }: MobileShellProps) { const { t } = useTranslation() @@ -245,10 +249,19 @@ export function MobileShell({
setPanelOpen(false)} onOpenFile={onOpenFile} onOpenDiff={onOpenDiff} + onResumeChat={(conversationId, title) => { + onResumeChat(conversationId, title) + setPanelOpen(false) + }} + onResumeSession={(session) => { + onResumeSession(session) + setPanelOpen(false) + }} />
diff --git a/web/src/components/ide/AgentSessionPanel.tsx b/web/src/components/ide/AgentSessionPanel.tsx new file mode 100644 index 0000000..a14f108 --- /dev/null +++ b/web/src/components/ide/AgentSessionPanel.tsx @@ -0,0 +1,139 @@ +import { useTranslation } from 'react-i18next' +import { fetchAgentSessions, type AgentSession } from '../../lib/api' +import { useAsyncData } from '../../lib/useAsyncData' +import { ChatIcon, HistoryIcon, RefreshIcon, TerminalIcon } from './icons' + +function formatTimestamp(value: string): string { + const date = new Date(value) + if (Number.isNaN(date.getTime())) return '' + return date.toLocaleString() +} + +function SessionRow({ + session, + onResume, +}: { + session: AgentSession + onResume: () => void +}) { + const { t } = useTranslation() + const isChat = session.source === 'chat' + const timestamp = formatTimestamp(session.updated_at) + return ( +
+ + {isChat ? : } + +
+

+ {session.title || t('sessions.untitled')} +

+
+ {!isChat && session.agent_label && ( + + {session.agent_label} + + )} + {timestamp && {timestamp}} +
+
+ +
+ ) +} + +interface AgentSessionPanelProps { + projectId: number + worktree?: string + onResumeChat: (conversationId: string, title: string) => void + onResumeSession: (session: AgentSession) => void +} + +export function AgentSessionPanel({ + projectId, + worktree, + onResumeChat, + onResumeSession, +}: AgentSessionPanelProps) { + const { t } = useTranslation() + const { data, error, loading, reload } = useAsyncData( + () => fetchAgentSessions(projectId, worktree), + t('sessions.loadFailed'), + [projectId, worktree], + ) + + const sessions = data ?? [] + const chatSessions = sessions.filter((session) => session.source === 'chat') + const cliSessions = sessions.filter((session) => session.source === 'cli') + + function resume(session: AgentSession) { + if (session.source === 'chat') { + onResumeChat(session.id, session.title) + } else { + onResumeSession(session) + } + } + + function renderSection(title: string, items: AgentSession[]) { + if (items.length === 0) return null + return ( +
+

+ {title} +

+
+ {items.map((session) => ( + resume(session)} + /> + ))} +
+
+ ) + } + + return ( +
+
+ + {t('sessions.title')} + +
+ +
+ {loading && ( +

{t('common.loading')}

+ )} + {!loading && error && ( +

{error}

+ )} + {!loading && !error && sessions.length === 0 && ( +

{t('sessions.empty')}

+ )} + {!loading && !error && sessions.length > 0 && ( +
+ {renderSection(t('sessions.cliSection'), cliSessions)} + {renderSection(t('sessions.chatSection'), chatSessions)} +
+ )} +
+
+ ) +} diff --git a/web/src/components/ide/BrowserTab.tsx b/web/src/components/ide/BrowserTab.tsx index b871e09..7b54b7b 100644 --- a/web/src/components/ide/BrowserTab.tsx +++ b/web/src/components/ide/BrowserTab.tsx @@ -13,11 +13,12 @@ import { browserStreamUrl, controlBrowser, fetchBrowserSelection, + inspectBrowser, insertBrowserText, navigateBrowser, setBrowserViewport, } from '../../lib/api' -import { ArrowLeftIcon, ArrowRightIcon, GlobeIcon, MaximizeIcon, MinimizeIcon, RefreshIcon } from './icons' +import { ArrowLeftIcon, ArrowRightIcon, BugIcon, GlobeIcon, MaximizeIcon, MinimizeIcon, RefreshIcon } from './icons' interface FrameMessage { data: string @@ -26,9 +27,15 @@ interface FrameMessage { type ConnectionState = 'connecting' | 'open' | 'closed' +type ContextMenuEntry = { label: string; run: () => void } | { separator: true } + const RECONNECT_BASE_MS = 500 const RECONNECT_MAX_MS = 15000 +const CONTEXT_MENU_WIDTH = 176 +const CONTEXT_MENU_HEIGHT = 232 +const CONTEXT_MENU_MARGIN = 8 + function normalizeUrl(raw: string): string { const value = raw.trim() if (value === '') return '' @@ -84,6 +91,7 @@ export function BrowserTab({ const [reconnectNonce, setReconnectNonce] = useState(0) const [isFullscreen, setIsFullscreen] = useState(false) const [caret, setCaret] = useState<{ left: number; top: number }>({ left: 0, top: 0 }) + const [menu, setMenu] = useState<{ x: number; y: number } | null>(null) const send = useCallback((payload: unknown) => { const socket = socketRef.current @@ -276,6 +284,41 @@ export function BrowserTab({ return () => document.removeEventListener('fullscreenchange', onChange) }, []) + useEffect(() => { + if (!menu) return + function close() { + setMenu(null) + } + function onKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') setMenu(null) + } + document.addEventListener('mousedown', close) + document.addEventListener('keydown', onKeyDown) + window.addEventListener('resize', close) + window.addEventListener('scroll', close, true) + return () => { + document.removeEventListener('mousedown', close) + document.removeEventListener('keydown', onKeyDown) + window.removeEventListener('resize', close) + window.removeEventListener('scroll', close, true) + } + }, [menu]) + + function openContextMenu(clientX: number, clientY: number) { + const maxX = Math.max( + CONTEXT_MENU_MARGIN, + window.innerWidth - CONTEXT_MENU_WIDTH - CONTEXT_MENU_MARGIN, + ) + const maxY = Math.max( + CONTEXT_MENU_MARGIN, + window.innerHeight - CONTEXT_MENU_HEIGHT - CONTEXT_MENU_MARGIN, + ) + setMenu({ + x: Math.min(clientX, maxX), + y: Math.min(clientY, maxY), + }) + } + function toggleFullscreen() { if (document.fullscreenElement) { void document.exitFullscreen() @@ -309,7 +352,9 @@ export function BrowserTab({ } function onPointerDown(event: ReactPointerEvent) { - event.preventDefault() + // Left/middle presses suppress native focus and selection. The right button + // is left alone so the browser still emits `contextmenu` for our menu. + if (event.button !== 2) event.preventDefault() const { x, y } = targetPoint(event) const canvas = canvasRef.current const rect = canvas?.getBoundingClientRect() @@ -472,6 +517,25 @@ export function BrowserTab({ } } + async function inspect() { + // Open the tab synchronously so the click still counts as a user gesture; + // the DevTools URL only exists after the async round-trip, so point the tab + // at it once it arrives. + const tab = window.open('', '_blank') + if (tab) tab.opener = null + try { + const url = await inspectBrowser(projectId, sessionId) + if (url && tab) { + tab.location.href = url + } else { + tab?.close() + } + } catch (err) { + tab?.close() + setError(err instanceof Error ? err.message : t('apiErrors.unknown')) + } + } + async function copyOrCut(cut: boolean) { try { const text = await fetchBrowserSelection(projectId, sessionId) @@ -493,6 +557,42 @@ export function BrowserTab({ } } + function selectAll() { + for (const eventType of ['keyDown', 'keyUp'] as const) { + send({ + type: 'input_keyboard', + eventType, + key: 'a', + code: 'KeyA', + modifiers: 2, + windowsVirtualKeyCode: 65, + nativeVirtualKeyCode: 65, + }) + } + } + + async function pasteFromClipboard() { + try { + const text = await navigator.clipboard.readText() + if (text) await insertBrowserText(projectId, sessionId, text) + } catch (err) { + setError(err instanceof Error ? err.message : t('apiErrors.unknown')) + } + } + + const menuItem = + 'flex w-full items-center px-3 py-1.5 text-left text-[var(--fg-2)] transition hover:bg-[var(--active)]' + + const contextMenuEntries: ContextMenuEntry[] = [ + { label: t('ide.browserMenuCopy'), run: () => void copyOrCut(false) }, + { label: t('ide.browserMenuPaste'), run: () => void pasteFromClipboard() }, + { label: t('ide.browserMenuSelectAll'), run: selectAll }, + { separator: true }, + { label: t('ide.browserBack'), run: () => void control('back') }, + { label: t('ide.browserForward'), run: () => void control('forward') }, + { label: t('ide.browserReload'), run: () => void control('reload') }, + ] + const toolbarButton = 'shrink-0 rounded-md p-1.5 text-[var(--muted)] transition hover:bg-[var(--hover)] hover:text-[var(--fg-strong)] disabled:opacity-40 disabled:hover:bg-transparent' @@ -529,6 +629,15 @@ export function BrowserTab({ > +