Time Travel workspace selector + LiteLLM globals

Add selectable workspace support to the Time Travel plugin and introduce normalized global LiteLLM configuration handling.

models.py: add DEFAULT_LITELLM_GLOBAL_KWARGS and helpers to normalize, load, apply, and merge LiteLLM global kwargs; call configure_litellm/set per-call merges in LiteLLM wrappers so framework defaults, configured globals, and per-call overrides combine correctly. Add tests to assert merging and runtime application.

plugins/_time_travel: update docs and plugin metadata to describe workdir/project selection. API handlers now accept workspace_id and a new history_workspaces endpoint lists selectable workspaces. helpers/time_travel: implement workspace listing, selection, and resolution logic (workdir/project options, availability/locking, default selection). UI: add workspace picker to time-travel panel, wire selection/load into time-travel store, pass workspace_id to API calls, and add click hook to open the panel. Update styles and refresh/load behavior accordingly.

Tests: extend tests for LiteLLM global kwargs merging and adapt stream test to expect merged params; add tests for selectable workspaces, default selection, and locked external workdir handling.
This commit is contained in:
frdel 2026-06-09 17:16:41 +02:00
parent 60462eabbd
commit 77f7aa0274
17 changed files with 580 additions and 79 deletions

View file

@ -101,6 +101,7 @@ Key Files:
- helpers/plugins.py: Plugin discovery and configuration logic.
- webui/js/AlpineStore.js: Store factory for reactive frontend state.
- helpers/api.py: Base class for all API endpoints.
- models.py: LLM provider configuration and LiteLLM wrappers; framework LiteLLM defaults such as `drop_params=True` are merged with `litellm_global_kwargs`, configured values override framework defaults, and the merged kwargs are applied at LiteLLM module and per-call boundaries.
- scripts/openrouter_release_notes_system_prompt.md: Editable system prompt used to generate GitHub release notes during Docker publishing.
- knowledge/main/about/: Agent self-knowledge files, indexed into the vector DB for runtime recall. Not user-facing docs - written for the agent's internal reference.
- webui/components/AGENTS.md: DOX contract for Alpine component architecture.

129
models.py
View file

@ -45,6 +45,48 @@ from sentence_transformers import SentenceTransformer
from pydantic import ConfigDict
DEFAULT_LITELLM_GLOBAL_KWARGS: dict[str, Any] = {
"drop_params": True,
}
def _normalize_litellm_kwargs(values: dict[str, Any]) -> dict[str, Any]:
# Normalize .env/UI-style scalar strings into native types for LiteLLM.
result: dict[str, Any] = {}
for k, v in values.items():
if isinstance(v, str):
stripped = v.strip()
lowered = stripped.lower()
if lowered == "true":
result[k] = True
elif lowered == "false":
result[k] = False
elif lowered in ("none", "null"):
result[k] = None
else:
try:
result[k] = int(stripped)
except ValueError:
try:
result[k] = float(stripped)
except ValueError:
result[k] = v
else:
result[k] = v
return result
def get_litellm_global_kwargs() -> dict[str, Any]:
kwargs = _normalize_litellm_kwargs(DEFAULT_LITELLM_GLOBAL_KWARGS)
try:
configured = settings.get_settings().get("litellm_global_kwargs", {}) # type: ignore[union-attr]
except Exception:
configured = {}
if isinstance(configured, dict):
kwargs.update(_normalize_litellm_kwargs(configured))
return kwargs
# keep provider logging quiet in normal operation
def turn_off_logging():
os.environ["LITELLM_LOG"] = "ERROR" # only errors
@ -55,9 +97,30 @@ def turn_off_logging():
logging.getLogger(name).setLevel(logging.ERROR)
def set_litellm_params():
global_kwargs = get_litellm_global_kwargs()
for key, value in global_kwargs.items():
setattr(litellm, key, value)
return global_kwargs
def configure_litellm():
turn_off_logging()
set_litellm_params()
def _merge_litellm_call_kwargs(*overrides: dict[str, Any] | None) -> dict[str, Any]:
kwargs = get_litellm_global_kwargs()
for override in overrides:
if isinstance(override, dict):
kwargs.update(override)
return kwargs
# init
load_dotenv()
turn_off_logging()
configure_litellm()
class ModelType(Enum):
CHAT = "Chat"
@ -390,13 +453,16 @@ class LiteLLMChatWrapper(SimpleChatModel):
) -> str:
import asyncio
configure_litellm()
msgs = self._convert_messages(messages)
# Apply rate limiting if configured
apply_rate_limiter_sync(self.a0_model_conf, str(msgs))
# Call the model
call_kwargs = _without_stream_kwarg({**self.kwargs, **kwargs})
call_kwargs = _without_stream_kwarg(
_merge_litellm_call_kwargs(self.kwargs, kwargs)
)
resp = completion(
model=self.model_name, messages=msgs, stop=stop, **call_kwargs
)
@ -415,13 +481,16 @@ class LiteLLMChatWrapper(SimpleChatModel):
) -> Iterator[ChatGenerationChunk]:
import asyncio
configure_litellm()
msgs = self._convert_messages(messages)
# Apply rate limiting if configured
apply_rate_limiter_sync(self.a0_model_conf, str(msgs))
result = ChatGenerationResult()
call_kwargs = _without_stream_kwarg({**self.kwargs, **kwargs})
call_kwargs = _without_stream_kwarg(
_merge_litellm_call_kwargs(self.kwargs, kwargs)
)
for chunk in completion(
model=self.model_name,
@ -447,13 +516,16 @@ class LiteLLMChatWrapper(SimpleChatModel):
run_manager: Optional[AsyncCallbackManagerForLLMRun] = None,
**kwargs: Any,
) -> AsyncIterator[ChatGenerationChunk]:
configure_litellm()
msgs = self._convert_messages(messages)
# Apply rate limiting if configured
await apply_rate_limiter(self.a0_model_conf, str(msgs))
result = ChatGenerationResult()
call_kwargs = _without_stream_kwarg({**self.kwargs, **kwargs})
call_kwargs = _without_stream_kwarg(
_merge_litellm_call_kwargs(self.kwargs, kwargs)
)
response = await acompletion(
model=self.model_name,
@ -488,7 +560,7 @@ class LiteLLMChatWrapper(SimpleChatModel):
**kwargs: Any,
) -> Tuple[str, str]:
turn_off_logging()
configure_litellm()
if not messages:
messages = []
@ -507,7 +579,9 @@ class LiteLLMChatWrapper(SimpleChatModel):
)
# Prepare call kwargs and retry config (strip A0-only params before calling LiteLLM)
call_kwargs: dict[str, Any] = _without_stream_kwarg({**self.kwargs, **kwargs})
call_kwargs: dict[str, Any] = _without_stream_kwarg(
_merge_litellm_call_kwargs(self.kwargs, kwargs)
)
max_retries: int = int(call_kwargs.pop("a0_retry_attempts", 2))
retry_delay_s: float = float(call_kwargs.pop("a0_retry_delay_seconds", 1.5))
stream = reasoning_callback is not None or response_callback is not None or tokens_callback is not None
@ -610,20 +684,30 @@ class LiteLLMEmbeddingWrapper(Embeddings):
self.a0_model_conf = model_config
def embed_documents(self, texts: List[str]) -> List[List[float]]:
configure_litellm()
# Apply rate limiting if configured
apply_rate_limiter_sync(self.a0_model_conf, " ".join(texts))
resp = embedding(model=self.model_name, input=texts, **self.kwargs)
resp = embedding(
model=self.model_name,
input=texts,
**_merge_litellm_call_kwargs(self.kwargs),
)
return [
item.get("embedding") if isinstance(item, dict) else item.embedding # type: ignore
for item in resp.data # type: ignore
]
def embed_query(self, text: str) -> List[float]:
configure_litellm()
# Apply rate limiting if configured
apply_rate_limiter_sync(self.a0_model_conf, text)
resp = embedding(model=self.model_name, input=[text], **self.kwargs)
resp = embedding(
model=self.model_name,
input=[text],
**_merge_litellm_call_kwargs(self.kwargs),
)
item = resp.data[0] # type: ignore
return item.get("embedding") if isinstance(item, dict) else item.embedding # type: ignore
@ -781,22 +865,6 @@ def _adjust_call_args(provider_name: str, model_name: str, kwargs: dict):
def _merge_provider_defaults(
provider_type: ProviderModelType, original_provider: str, kwargs: dict
) -> tuple[str, dict]:
# Normalize .env-style numeric strings (e.g., "timeout=30") into ints/floats for LiteLLM
def _normalize_values(values: dict) -> dict:
result: dict[str, Any] = {}
for k, v in values.items():
if isinstance(v, str):
try:
result[k] = int(v)
except ValueError:
try:
result[k] = float(v)
except ValueError:
result[k] = v
else:
result[k] = v
return result
provider_name = original_provider # default: unchanged
cfg = get_provider_config(provider_type, original_provider)
if cfg:
@ -814,14 +882,11 @@ def _merge_provider_defaults(
if key and key not in ("None", "NA"):
kwargs["api_key"] = key
# Merge LiteLLM global kwargs (timeouts, stream_timeout, etc.)
try:
global_kwargs = settings.get_settings().get("litellm_global_kwargs", {}) # type: ignore[union-attr]
except Exception:
global_kwargs = {}
if isinstance(global_kwargs, dict):
for k, v in _normalize_values(global_kwargs).items():
kwargs.setdefault(k, v)
# Merge LiteLLM global kwargs. Framework defaults are merged first, then
# configured global kwargs override those defaults; explicit provider/model
# kwargs still keep priority via setdefault.
for k, v in get_litellm_global_kwargs().items():
kwargs.setdefault(k, v)
return provider_name, kwargs

View file

@ -2,18 +2,18 @@
## Purpose
- Own Agent Zero workspace history, diff inspection, travel, snapshots, and revert for active `/a0/usr` workspaces.
- Own Agent Zero workspace history, diff inspection, travel, snapshots, and revert for selectable Agent Zero workdir/project workspaces under `/a0/usr`.
## Ownership
- `helpers/time_travel.py` owns history storage, diff, travel, snapshot, preview, and revert mechanics.
- `api/` owns history list, diff, preview, revert, snapshot, and travel endpoints.
- `webui/` owns the time-travel panel, store, main surface, and thumbnail.
- `api/` owns selectable workspace list, history list, diff, preview, revert, snapshot, and travel endpoints.
- `webui/` owns the time-travel panel, workspace selector, store, main surface, and thumbnail.
- `plugin.yaml` and `extensions/` own metadata and hook contributions.
## Local Contracts
- Keep history operations scoped to Agent Zero-owned workspaces.
- Keep history operations scoped to Agent Zero-owned workdir/project workspaces.
- Revert and travel operations must avoid unintended writes outside managed workspace paths.
- Preserve enough metadata for clear preview and diff inspection before destructive actions.
@ -23,7 +23,7 @@
## Verification
- Smoke-test snapshot, list, diff, preview, travel, and revert flows after changes.
- Smoke-test workspace selection, snapshot, list, diff, preview, travel, and revert flows after changes.
## Child DOX Index

View file

@ -12,8 +12,13 @@ from plugins._time_travel.helpers.time_travel import (
class HistoryDiff(ApiHandler):
async def process(self, input: dict, request: Request) -> dict | Response:
context_id = str(input.get("context_id") or "").strip()
workspace_id = str(input.get("workspace_id") or "").strip()
try:
workspace = resolve_workspace(context_id, context_loader=self.use_context)
workspace = resolve_workspace(
context_id,
workspace_id=workspace_id,
context_loader=self.use_context,
)
return TimeTravelService(workspace).history_diff(
commit_hash=str(input.get("commit_hash") or ""),
path=str(input.get("path") or ""),

View file

@ -13,8 +13,13 @@ from plugins._time_travel.helpers.time_travel import (
class HistoryList(ApiHandler):
async def process(self, input: dict, request: Request) -> dict | Response:
context_id = str(input.get("context_id") or "").strip()
workspace_id = str(input.get("workspace_id") or "").strip()
try:
workspace = resolve_workspace(context_id, context_loader=self.use_context)
workspace = resolve_workspace(
context_id,
workspace_id=workspace_id,
context_loader=self.use_context,
)
return TimeTravelService(workspace).history_list(
limit=int(input.get("limit") or 100),
offset=int(input.get("offset") or 0),

View file

@ -12,8 +12,13 @@ from plugins._time_travel.helpers.time_travel import (
class HistoryPreview(ApiHandler):
async def process(self, input: dict, request: Request) -> dict | Response:
context_id = str(input.get("context_id") or "").strip()
workspace_id = str(input.get("workspace_id") or "").strip()
try:
workspace = resolve_workspace(context_id, context_loader=self.use_context)
workspace = resolve_workspace(
context_id,
workspace_id=workspace_id,
context_loader=self.use_context,
)
return TimeTravelService(workspace).preview(
operation=str(input.get("operation") or ""),
commit_hash=str(input.get("commit_hash") or ""),

View file

@ -12,8 +12,13 @@ from plugins._time_travel.helpers.time_travel import (
class HistoryRevert(ApiHandler):
async def process(self, input: dict, request: Request) -> dict | Response:
context_id = str(input.get("context_id") or "").strip()
workspace_id = str(input.get("workspace_id") or "").strip()
try:
workspace = resolve_workspace(context_id, context_loader=self.use_context)
workspace = resolve_workspace(
context_id,
workspace_id=workspace_id,
context_loader=self.use_context,
)
return TimeTravelService(workspace).revert(
commit_hash=str(input.get("commit_hash") or ""),
metadata=input.get("metadata") if isinstance(input.get("metadata"), dict) else {},

View file

@ -13,8 +13,13 @@ from plugins._time_travel.helpers.time_travel import (
class HistorySnapshot(ApiHandler):
async def process(self, input: dict, request: Request) -> dict | Response:
context_id = str(input.get("context_id") or "").strip()
workspace_id = str(input.get("workspace_id") or "").strip()
try:
workspace = resolve_workspace(context_id, context_loader=self.use_context)
workspace = resolve_workspace(
context_id,
workspace_id=workspace_id,
context_loader=self.use_context,
)
snapshot = TimeTravelService(workspace).snapshot(
trigger=str(input.get("trigger") or "manual"),
message=str(input.get("message") or ""),

View file

@ -12,8 +12,13 @@ from plugins._time_travel.helpers.time_travel import (
class HistoryTravel(ApiHandler):
async def process(self, input: dict, request: Request) -> dict | Response:
context_id = str(input.get("context_id") or "").strip()
workspace_id = str(input.get("workspace_id") or "").strip()
try:
workspace = resolve_workspace(context_id, context_loader=self.use_context)
workspace = resolve_workspace(
context_id,
workspace_id=workspace_id,
context_loader=self.use_context,
)
return TimeTravelService(workspace).travel(
commit_hash=str(input.get("commit_hash") or ""),
metadata=input.get("metadata") if isinstance(input.get("metadata"), dict) else {},

View file

@ -0,0 +1,11 @@
from __future__ import annotations
from helpers.api import ApiHandler, Request, Response
from plugins._time_travel.helpers.time_travel import list_selectable_workspaces
class HistoryWorkspaces(ApiHandler):
async def process(self, input: dict, request: Request) -> dict | Response:
context_id = str(input.get("context_id") or "").strip()
data = list_selectable_workspaces(context_id, context_loader=self.use_context)
return {"ok": True, **data}

View file

@ -5,7 +5,7 @@
class="dropdown-item"
id="time-travel-dropdown"
title="Time Travel"
@click="ensureModalOpen('/plugins/_time_travel/webui/main.html'); $store.sidebar.menuClose()">
@click="$store.timeTravel?.onOpen?.(); ensureModalOpen('/plugins/_time_travel/webui/main.html'); $store.sidebar.menuClose()">
<span class="material-symbols-outlined">history</span>
<span>Time Travel</span>
</button>

View file

@ -210,7 +210,132 @@ def canonical_workspace_display_path(display_path: str) -> str:
return (canonical if canonical.startswith("/a0") else normalized).rstrip("/") or canonical
def resolve_workspace(context_id: str = "", *, context_loader=None) -> WorkspaceInfo:
def configured_workdir_display_path() -> str:
from helpers import settings
configured = str(settings.get_settings().get("workdir_path") or "")
fallback = files.normalize_a0_path(files.get_abs_path("usr/workdir"))
return canonical_workspace_display_path(configured or fallback)
def _workspace_option(
*,
kind: str,
display_path: str,
label: str,
name: str = "",
title: str = "",
project_name: str = "",
color: str = "",
) -> dict[str, Any]:
normalized = canonical_workspace_display_path(display_path)
available = is_inside_usr_display(normalized)
error = (
"" if available else "Time Travel is only available for workspaces inside /a0/usr."
)
return {
"id": workspace_id_for(normalized),
"kind": kind,
"name": name,
"title": title or label,
"label": label,
"display_path": normalized.rstrip("/") or normalized,
"path": normalized.rstrip("/") or normalized,
"project_name": project_name,
"color": color,
"available": available,
"locked": not available,
"error": error,
}
def list_selectable_workspaces(
context_id: str = "",
*,
context_loader=None,
) -> dict[str, Any]:
from helpers import projects
context_id = str(context_id or "").strip()
workspaces: list[dict[str, Any]] = []
seen_ids: set[str] = set()
def add_workspace(option: dict[str, Any]) -> None:
option_id = str(option.get("id") or "")
if not option_id or option_id in seen_ids:
return
seen_ids.add(option_id)
workspaces.append(option)
add_workspace(
_workspace_option(
kind="workdir",
display_path=configured_workdir_display_path(),
label="User working directory",
name="workdir",
title="User working directory",
)
)
for project in projects.get_active_projects_list() or []:
project_name = str(project.get("name") or "").strip()
if not project_name:
continue
title = str(project.get("title") or project_name)
add_workspace(
_workspace_option(
kind="project",
display_path=files.normalize_a0_path(
projects.get_project_folder(project_name)
),
label=title,
name=project_name,
title=title,
project_name=project_name,
color=str(project.get("color") or ""),
)
)
default_workspace_id = ""
try:
default_workspace_id = _resolve_context_workspace(
context_id,
context_loader=context_loader,
).id
except TimeTravelError:
default_workspace_id = ""
if default_workspace_id not in seen_ids:
default_workspace_id = ""
if not default_workspace_id and workspaces:
default_workspace_id = str(workspaces[0].get("id") or "")
return {
"context_id": context_id,
"workspaces": workspaces,
"default_workspace_id": default_workspace_id,
}
def _selectable_workspace_by_id(
workspace_id: str,
context_id: str = "",
*,
context_loader=None,
) -> dict[str, Any] | None:
wanted = str(workspace_id or "").strip()
if not wanted:
return None
for workspace in list_selectable_workspaces(
context_id,
context_loader=context_loader,
)["workspaces"]:
if str(workspace.get("id") or "") == wanted:
return workspace
return None
def _resolve_context_workspace(context_id: str = "", *, context_loader=None) -> WorkspaceInfo:
from helpers import projects, settings
context_id = str(context_id or "").strip()
@ -246,9 +371,36 @@ def resolve_workspace(context_id: str = "", *, context_loader=None) -> Workspace
)
def resolve_workspace_for_path_hint(path_hint: str) -> WorkspaceInfo | None:
from helpers import settings
def resolve_workspace(
context_id: str = "",
*,
workspace_id: str = "",
context_loader=None,
) -> WorkspaceInfo:
workspace_id = str(workspace_id or "").strip()
if not workspace_id:
return _resolve_context_workspace(context_id, context_loader=context_loader)
selected = _selectable_workspace_by_id(
workspace_id,
context_id,
context_loader=context_loader,
)
if not selected:
raise WorkspaceRejectedError("Selected Time Travel workspace is not available.")
if selected.get("locked") or selected.get("available") is False:
raise WorkspaceRejectedError(
str(selected.get("error") or "Selected Time Travel workspace is not available.")
)
return _workspace_from_display(
str(selected.get("display_path") or selected.get("path") or ""),
project_name=str(selected.get("project_name") or ""),
context_id=str(context_id or "").strip(),
)
def resolve_workspace_for_path_hint(path_hint: str) -> WorkspaceInfo | None:
normalized = canonical_workspace_display_path(path_hint)
if not is_inside_usr_display(normalized):
return None
@ -258,8 +410,7 @@ def resolve_workspace_for_path_hint(path_hint: str) -> WorkspaceInfo | None:
project_display = f"/a0/usr/projects/{parts[3]}"
return _workspace_from_display(project_display, project_name=parts[3])
configured = str(settings.get_settings().get("workdir_path") or "")
workdir_display = canonical_workspace_display_path(configured or files.normalize_a0_path(files.get_abs_path("usr/workdir")))
workdir_display = configured_workdir_display_path()
if normalized == workdir_display or normalized.startswith(workdir_display.rstrip("/") + "/"):
return _workspace_from_display(workdir_display)

View file

@ -1,6 +1,6 @@
name: _time_travel
title: Time Travel
description: Agent Zero-owned workspace history, diff inspection, travel, and revert for active /a0/usr workspaces.
description: Agent Zero-owned workdir/project history, diff inspection, travel, and revert for /a0/usr workspaces.
version: 0.1.0
always_enabled: false
settings_sections: []

View file

@ -9,16 +9,29 @@
<template x-if="$store.timeTravel">
<div class="time-travel-shell">
<div class="time-travel-toolbar">
<div class="time-travel-title">
<span class="material-symbols-outlined">history</span>
<span>Time Travel</span>
<div class="time-travel-workspace-picker" :title="$store.timeTravel.workspacePath || $store.timeTravel.workspaceOptionLabel($store.timeTravel.selectedWorkspace())">
<span class="material-symbols-outlined" aria-hidden="true">folder_open</span>
<select
aria-label="Workspace"
:value="$store.timeTravel.selectedWorkspaceId"
@change="$store.timeTravel.selectWorkspace($event.target.value)"
:disabled="$store.timeTravel.workspaceLoading || $store.timeTravel.loading || $store.timeTravel.busy"
>
<option value="" x-show="$store.timeTravel.workspaces.length === 0">Workspace</option>
<template x-for="workspace in $store.timeTravel.workspaces" :key="workspace.id">
<option
:value="workspace.id"
:selected="workspace.id === $store.timeTravel.selectedWorkspaceId"
x-text="$store.timeTravel.workspaceOptionLabel(workspace)"
></option>
</template>
</select>
</div>
<div class="time-travel-workspace" :title="$store.timeTravel.workspacePath" x-text="$store.timeTravel.workspacePath || 'workspace'"></div>
<span class="time-travel-spacer"></span>
<button type="button" class="time-travel-icon-button" title="Snapshot" aria-label="Snapshot" @click="$store.timeTravel.manualSnapshot()" :disabled="$store.timeTravel.busy || $store.timeTravel.loading || $store.timeTravel.isLocked()">
<span class="material-symbols-outlined">add_a_photo</span>
</button>
<button type="button" class="time-travel-icon-button" title="Refresh" aria-label="Refresh" @click="$store.timeTravel.refresh({ keepSelection: true })" :disabled="$store.timeTravel.loading">
<button type="button" class="time-travel-icon-button" title="Refresh" aria-label="Refresh" @click="$store.timeTravel.refresh({ keepSelection: true, reloadWorkspaces: true })" :disabled="$store.timeTravel.loading">
<span class="material-symbols-outlined" :class="{ spinning: $store.timeTravel.loading }">refresh</span>
</button>
</div>
@ -270,7 +283,6 @@
.time-travel-toolbar,
.time-travel-status,
.time-travel-title,
.time-travel-filter,
.time-travel-actions,
.time-travel-tool-button,
@ -293,25 +305,48 @@
background: color-mix(in srgb, var(--color-background) 91%, #000 9%);
}
.time-travel-title {
gap: 7px;
font-weight: 750;
font-size: 0.9rem;
}
.time-travel-title .material-symbols-outlined {
font-size: 19px;
}
.time-travel-workspace {
.time-travel-workspace-picker {
display: flex;
align-items: center;
gap: 6px;
flex: 0 1 520px;
min-width: 0;
max-width: 45%;
overflow: hidden;
max-width: 48%;
height: 32px;
padding: 0 8px;
border: 1px solid color-mix(in srgb, var(--color-border) 58%, transparent);
border-radius: 7px;
background: color-mix(in srgb, var(--color-panel) 70%, transparent);
}
.time-travel-workspace-picker .material-symbols-outlined {
flex: 0 0 auto;
color: var(--color-text-muted);
font-size: 17px;
}
.time-travel-workspace-picker select {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--color-text);
cursor: pointer;
font-size: 0.76rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-family-code);
font-size: 0.72rem;
}
.time-travel-workspace-picker select:disabled {
cursor: not-allowed;
opacity: 0.65;
}
.time-travel-workspace-picker option {
background: var(--color-background);
color: var(--color-text);
}
.time-travel-spacer {
@ -877,12 +912,17 @@
justify-content: flex-start;
}
.time-travel-workspace {
display: none;
.time-travel-workspace-picker {
flex: 1 1 160px;
max-width: none;
}
}
@container (max-width: 520px) {
.time-travel-workspace-picker {
flex-basis: 120px;
}
.time-travel-tool-button span:last-child {
display: none;
}

View file

@ -29,10 +29,13 @@ function apiPath(name) {
const model = {
loading: false,
workspaceLoading: false,
busy: false,
error: "",
payload: null,
contextId: "",
workspaces: [],
selectedWorkspaceId: "",
workspacePath: "",
fileFilter: "",
selectedHash: "",
@ -63,15 +66,20 @@ const model = {
if (this._mode !== "modal") {
this.setupCanvasSurface(element);
}
this.contextId = this.resolveContextId();
if (!this.payload && !this.loading) {
await this.refresh({ contextId: this.contextId });
const nextContextId = this.resolveContextId();
const resetWorkspace = this._mode === "modal" || this.contextId !== nextContextId || !this.selectedWorkspaceId;
this.contextId = nextContextId;
await this.loadWorkspaces({ contextId: this.contextId, reset: resetWorkspace });
if (this._mode === "modal" || !this.payload || resetWorkspace) {
await this.refresh({ contextId: this.contextId, keepSelection: !resetWorkspace, skipWorkspaceLoad: true });
}
},
async onOpen(payload = {}) {
const nextContextId = String(payload.contextId || payload.context_id || this.resolveContextId() || "");
await this.refresh({ contextId: nextContextId });
this.contextId = nextContextId;
await this.loadWorkspaces({ contextId: nextContextId, reset: true });
await this.refresh({ contextId: nextContextId, skipWorkspaceLoad: true });
},
cleanup() {
@ -106,18 +114,45 @@ const model = {
if (this._filterTimer) clearTimeout(this._filterTimer);
this._filterTimer = setTimeout(() => {
this._filterTimer = null;
this.refresh({ keepSelection: false });
this.refresh({ keepSelection: false, skipWorkspaceLoad: true });
}, 240);
},
async loadWorkspaces(options = {}) {
const contextId = String(options.contextId || options.context_id || this.resolveContextId() || "");
this.workspaceLoading = true;
try {
const response = await callJsonApi(apiPath("history_workspaces"), {
context_id: contextId,
});
if (!response?.ok) throw new Error(response?.error || "Could not load workspaces.");
this.workspaces = Array.isArray(response.workspaces) ? response.workspaces : [];
const defaultWorkspaceId = String(response.default_workspace_id || "");
const hasSelected = this.workspaces.some((workspace) => workspace?.id === this.selectedWorkspaceId);
if (options.reset || !this.selectedWorkspaceId || !hasSelected) {
this.selectedWorkspaceId = defaultWorkspaceId || String(this.workspaces[0]?.id || "");
}
} catch (error) {
this.workspaces = [];
this.selectedWorkspaceId = "";
this.error = error instanceof Error ? error.message : String(error);
} finally {
this.workspaceLoading = false;
}
},
async refresh(options = {}) {
const contextId = String(options.contextId || options.context_id || this.resolveContextId() || "");
if (!options.skipWorkspaceLoad && (options.reloadWorkspaces || this.workspaces.length === 0)) {
await this.loadWorkspaces({ contextId, reset: Boolean(options.resetWorkspace) });
}
const seq = ++this._requestSeq;
this.loading = true;
this.error = "";
try {
const response = await callJsonApi(apiPath("history_list"), {
context_id: contextId,
workspace_id: this.selectedWorkspaceId,
limit: 100,
offset: 0,
file_filter: this.fileFilter,
@ -126,7 +161,17 @@ const model = {
if (!response?.ok) throw new Error(response?.error || "Could not load history.");
this.payload = response;
this.contextId = String(response.context_id || contextId || "");
this.workspacePath = String(response.workspace?.display_path || response.workspace?.path || "");
if (response.workspace?.id && !this.selectedWorkspaceId) {
this.selectedWorkspaceId = String(response.workspace.id);
}
const selectedWorkspace = this.selectedWorkspace();
this.workspacePath = String(
response.workspace?.display_path
|| response.workspace?.path
|| selectedWorkspace?.display_path
|| selectedWorkspace?.path
|| ""
);
this.reconcileSelection(Boolean(options.keepSelection));
} catch (error) {
if (seq !== this._requestSeq) return;
@ -143,6 +188,7 @@ const model = {
try {
const response = await callJsonApi(apiPath("history_list"), {
context_id: this.contextId,
workspace_id: this.selectedWorkspaceId,
limit: 100,
offset: this.commits().length,
file_filter: this.fileFilter,
@ -190,6 +236,31 @@ const model = {
return Boolean(this.payload?.workspace?.locked || this.payload?.workspace?.available === false);
},
selectedWorkspace() {
return (this.workspaces || []).find((workspace) => workspace?.id === this.selectedWorkspaceId) || null;
},
workspaceOptionLabel(workspace) {
const label = String(workspace?.label || workspace?.title || workspace?.name || "Workspace");
const path = String(workspace?.display_path || workspace?.path || "");
const suffix = workspace?.locked || workspace?.available === false ? " (unavailable)" : "";
return path ? `${label} - ${path}${suffix}` : `${label}${suffix}`;
},
async selectWorkspace(workspaceId) {
const nextWorkspaceId = String(workspaceId || "");
if (!nextWorkspaceId || nextWorkspaceId === this.selectedWorkspaceId || this.busy) return;
this.selectedWorkspaceId = nextWorkspaceId;
this.fileFilter = "";
this.selectedHash = "";
this.selectedPath = "";
this.selectedDiff = null;
this.diffError = "";
this.previewOpen = false;
this.preview = null;
await this.refresh({ keepSelection: false, skipWorkspaceLoad: true });
},
hasHistory() {
return this.commits().length > 0;
},
@ -262,6 +333,7 @@ const model = {
try {
const response = await callJsonApi(apiPath("history_diff"), {
context_id: this.contextId,
workspace_id: this.selectedWorkspaceId,
commit_hash: row.kind === "present" ? this.payload?.current_hash || "" : row.hash,
path: file.path || file.old_path,
mode: row.kind === "present" ? "present" : "commit",
@ -284,6 +356,7 @@ const model = {
try {
const response = await callJsonApi(apiPath("history_snapshot"), {
context_id: this.contextId,
workspace_id: this.selectedWorkspaceId,
trigger: "manual",
});
if (!response?.ok) throw new Error(response?.error || "Snapshot failed.");
@ -309,6 +382,7 @@ const model = {
try {
const response = await callJsonApi(apiPath("history_preview"), {
context_id: this.contextId,
workspace_id: this.selectedWorkspaceId,
operation,
commit_hash: target.hash,
});
@ -340,6 +414,7 @@ const model = {
try {
const response = await callJsonApi(apiPath(endpoint), {
context_id: this.contextId,
workspace_id: this.selectedWorkspaceId,
commit_hash: this.preview.commit_hash,
metadata: { source: "time_travel_ui" },
});

View file

@ -48,6 +48,48 @@ def test_extract_json_root_string_returns_canonical_snapshot():
assert extract_tools.extract_json_root_string('[{"tool_name":"response"}]') is None
def test_litellm_global_kwargs_merge_defaults_and_config(monkeypatch):
monkeypatch.setattr(
models.settings,
"get_settings",
lambda: {"litellm_global_kwargs": {}},
)
assert models._merge_litellm_call_kwargs({})["drop_params"] is True
assert models._merge_litellm_call_kwargs({"temperature": 0}) == {
"drop_params": True,
"temperature": 0,
}
monkeypatch.setattr(
models.settings,
"get_settings",
lambda: {"litellm_global_kwargs": {"drop_params": "false", "timeout": "30"}},
)
assert models._merge_litellm_call_kwargs({}) == {
"drop_params": False,
"timeout": 30,
}
original_drop_params = getattr(models.litellm, "drop_params", None)
had_timeout = hasattr(models.litellm, "timeout")
original_timeout = getattr(models.litellm, "timeout", None)
try:
assert models.set_litellm_params() == {
"drop_params": False,
"timeout": 30,
}
assert models.litellm.drop_params is False
assert models.litellm.timeout == 30
finally:
setattr(models.litellm, "drop_params", original_drop_params)
if had_timeout:
setattr(models.litellm, "timeout", original_timeout)
elif hasattr(models.litellm, "timeout"):
delattr(models.litellm, "timeout")
@pytest.mark.asyncio
async def test_unified_call_stops_after_canonical_root_snapshot(monkeypatch):
stream = _AsyncChunkStream(
@ -61,6 +103,7 @@ async def test_unified_call_stops_after_canonical_root_snapshot(monkeypatch):
async def fake_acompletion(*args, **kwargs):
assert kwargs["stream"] is True
assert kwargs["drop_params"] is True
return stream
async def fake_rate_limiter(*args, **kwargs):
@ -68,6 +111,11 @@ async def test_unified_call_stops_after_canonical_root_snapshot(monkeypatch):
monkeypatch.setattr(models, "acompletion", fake_acompletion)
monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
monkeypatch.setattr(
models.settings,
"get_settings",
lambda: {"litellm_global_kwargs": {}},
)
wrapper = models.LiteLLMChatWrapper(
model="test-model",

View file

@ -373,3 +373,83 @@ def test_workspace_resolution_prefers_project_and_rejects_external_paths(monkeyp
projects_mod.get_context_project_name = lambda _context: ""
with pytest.raises(WorkspaceRejectedError):
resolve_workspace("ctx", context_loader=lambda _ctxid: SimpleNamespace(id="ctx"))
def test_selectable_workspaces_list_workdir_first_and_default_to_context_project(
monkeypatch: pytest.MonkeyPatch,
workspace,
):
root, _service = workspace
(root / "workdir").mkdir()
(root / "demo").mkdir()
(root / "other").mkdir()
projects_mod = ModuleType("helpers.projects")
projects_mod.get_active_projects_list = lambda: [
{"name": "demo", "title": "Demo Project", "color": "#336699"},
{"name": "other", "title": "Other Project", "color": ""},
]
projects_mod.get_context_project_name = lambda _context: "demo"
projects_mod.get_project_folder = lambda name: str(root / name)
settings_mod = ModuleType("helpers.settings")
settings_mod.get_settings = lambda: {"workdir_path": str(root / "workdir")}
import helpers
monkeypatch.setitem(sys.modules, "helpers.projects", projects_mod)
monkeypatch.setitem(sys.modules, "helpers.settings", settings_mod)
monkeypatch.setattr(helpers, "projects", projects_mod, raising=False)
monkeypatch.setattr(helpers, "settings", settings_mod, raising=False)
data = tt.list_selectable_workspaces(
"ctx",
context_loader=lambda _ctxid: SimpleNamespace(id="ctx"),
)
workspaces = data["workspaces"]
demo_workspace = next(item for item in workspaces if item["project_name"] == "demo")
assert workspaces[0]["kind"] == "workdir"
assert workspaces[0]["display_path"].endswith("/workdir")
assert [item["project_name"] for item in workspaces[1:]] == ["demo", "other"]
assert data["default_workspace_id"] == demo_workspace["id"]
resolved = resolve_workspace(
"ctx",
workspace_id=demo_workspace["id"],
context_loader=lambda _ctxid: SimpleNamespace(id="ctx"),
)
assert resolved.project_name == "demo"
assert resolved.display_path == demo_workspace["display_path"]
with pytest.raises(WorkspaceRejectedError):
resolve_workspace(
"ctx",
workspace_id="missing",
context_loader=lambda _ctxid: SimpleNamespace(id="ctx"),
)
def test_external_workdir_workspace_option_is_locked(monkeypatch: pytest.MonkeyPatch):
projects_mod = ModuleType("helpers.projects")
projects_mod.get_active_projects_list = lambda: []
projects_mod.get_context_project_name = lambda _context: ""
settings_mod = ModuleType("helpers.settings")
settings_mod.get_settings = lambda: {"workdir_path": "/tmp/not-a0"}
import helpers
monkeypatch.setitem(sys.modules, "helpers.projects", projects_mod)
monkeypatch.setitem(sys.modules, "helpers.settings", settings_mod)
monkeypatch.setattr(helpers, "projects", projects_mod, raising=False)
monkeypatch.setattr(helpers, "settings", settings_mod, raising=False)
data = tt.list_selectable_workspaces("")
workdir = data["workspaces"][0]
assert workdir["kind"] == "workdir"
assert workdir["locked"] is True
assert data["default_workspace_id"] == workdir["id"]
with pytest.raises(WorkspaceRejectedError):
resolve_workspace("", workspace_id=workdir["id"])