-
-
history
-
Time Travel
+
+ folder_open
+
-
-
@@ -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;
}
diff --git a/plugins/_time_travel/webui/time-travel-store.js b/plugins/_time_travel/webui/time-travel-store.js
index 9e159fbc0..b1e25d823 100644
--- a/plugins/_time_travel/webui/time-travel-store.js
+++ b/plugins/_time_travel/webui/time-travel-store.js
@@ -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" },
});
diff --git a/tests/test_stream_tool_early_stop.py b/tests/test_stream_tool_early_stop.py
index 6d5279702..e01d5c1fb 100644
--- a/tests/test_stream_tool_early_stop.py
+++ b/tests/test_stream_tool_early_stop.py
@@ -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",
diff --git a/tests/test_time_travel.py b/tests/test_time_travel.py
index 00dc42ee5..9810f68fe 100644
--- a/tests/test_time_travel.py
+++ b/tests/test_time_travel.py
@@ -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"])