fix: post-merge follow-ups for PRs #312-#316 (#319)

Security:
- Add write scope rules for 4 new workstream POST endpoints
  (delete, open, refresh-title, title) in required_scope() —
  both direct and console-proxied paths

Judge:
- Restore cancel_event check in inner poll loop (was removed)
- Fix fallback delivery off-by-one: items[idx+1:] not items[idx:]
- Skip empty-response retry when finish_reason=="length"
- Reset empty_retries counter after non-empty response
- Document per-turn timeout semantics in JudgeConfig

Google provider:
- Add default base_url for Gemini endpoint in create_client()
- Bump max_output_tokens 8192→65536, set token_param="max_tokens"
- Add api_key detection for googleapis.com in console detect
- Add provider badge CSS (green) and openai-compatible (dim)

Theme:
- Fix POST→PUT for settings persistence (was silently 405-ing)
- Consolidate dual localStorage keys with backwards-compat read
- Lower banner z-index 9999→200, raise login overlay to 10001
- Fix undefined --bg-input, banner contrast for WCAG AA
- Add smooth theme transition with prefers-reduced-motion override
- Console onThemeChange: add title + aria-label updates

Workstream backend:
- Restore close_workstream 400 for last-ws case (was changed to 404)
- Thread-safe _llm_verdicts via _ws_lock on all mutation sites
- Fork: persist tool_calls + provider_data in save_message
- Add get_workstream_metadata to StorageBackend protocol
- Add ChatSession.request_title_refresh() public API
- Use cs.stored_keys() instead of cs._cache
- Redact exception text in delete 500 response
- web_helpers: catch-all logs and returns 500 not 400
- Live-stream ws_created SSE includes title field

Workstream UI:
- Focus traps + Escape on edit-title and delete-ws modals
- Tab close aria-label, mobile breakpoint for action buttons
- Restore name priority (live SSE over stale API)
- Fix double-delete, fork button text, batch delete handler leak
- Optimistic title update, close-last-tab error toast
- ws_id badge show-on-hover, hover states, aria-live, emoji a11y

Console admin:
- Banner aria-labels, judge dropdown wording, detect button class
- New-ws modal Escape handler, provider defaults cross-reference
This commit is contained in:
Patrick Buckley
2026-04-06 02:23:00 -07:00
committed by GitHub
parent 40a560b39c
commit 66c856eb6e
22 changed files with 811 additions and 255 deletions
+29
View File
@@ -197,6 +197,35 @@ class TestRequiredScope:
"""Only POST is elevated — GET falls through to read."""
assert required_scope("GET", "/api/_internal/mcp-reload") == "read"
# Workstream sub-resource mutations (parametric paths)
def test_ws_delete_needs_write(self):
assert required_scope("POST", "/api/workstreams/abc123/delete") == "write"
def test_ws_open_needs_write(self):
assert required_scope("POST", "/api/workstreams/abc123/open") == "write"
def test_ws_refresh_title_needs_write(self):
assert required_scope("POST", "/api/workstreams/abc123/refresh-title") == "write"
def test_ws_title_needs_write(self):
assert required_scope("POST", "/api/workstreams/abc123/title") == "write"
def test_v1_ws_delete_needs_write(self):
assert required_scope("POST", "/v1/api/workstreams/abc123/delete") == "write"
def test_proxy_ws_delete_needs_write(self):
assert required_scope("POST", "/node/n1/v1/api/workstreams/abc123/delete") == "write"
def test_proxy_ws_open_needs_write(self):
assert required_scope("POST", "/node/n1/v1/api/workstreams/abc123/open") == "write"
def test_proxy_ws_title_needs_write(self):
assert required_scope("POST", "/node/n1/v1/api/workstreams/abc123/title") == "write"
def test_ws_get_is_still_read(self):
"""GET on workstream sub-resource is not elevated."""
assert required_scope("GET", "/api/workstreams/abc123/delete") == "read"
# ---------------------------------------------------------------------------
# TestExtractBearer
+21
View File
@@ -234,6 +234,27 @@ class TestErrorHandling:
)
assert result is None
def test_empty_content_length_stop_no_retry(self):
"""When finish_reason is 'length', don't retry — return None immediately."""
provider = _make_mock_provider(response_content="")
result_mock = provider.create_completion.return_value
result_mock.tool_calls = None
result_mock.content = ""
result_mock.finish_reason = "length"
judge = _make_judge(provider)
with ThreadPoolExecutor(max_workers=1) as pool:
result = judge._evaluate_single(
_make_item(),
[{"role": "user", "content": "test"}],
cancel_event=None,
executor=pool,
client=MagicMock(),
)
assert result is None
# Should have been called exactly once — no retries
assert provider.create_completion.call_count == 1
# ---------------------------------------------------------------------------
# Multi-turn tool use
+66
View File
@@ -1074,6 +1074,72 @@ class TestProviderFactory:
p2 = create_provider("openai")
assert p1 is p2
# -- Google provider -------------------------------------------------------
def test_create_provider_google(self) -> None:
from turnstone.core.providers import create_provider
from turnstone.core.providers._google import GoogleProvider
provider = create_provider("google")
assert isinstance(provider, GoogleProvider)
assert provider.provider_name == "google"
def test_create_provider_google_singleton(self) -> None:
from turnstone.core.providers import create_provider
p1 = create_provider("google")
p2 = create_provider("google")
assert p1 is p2
@patch("openai.OpenAI")
def test_create_client_google_default_base_url(self, mock_openai_cls: MagicMock) -> None:
from turnstone.core.providers import create_client
from turnstone.core.providers._google import GOOGLE_DEFAULT_BASE_URL
mock_openai_cls.return_value = MagicMock()
create_client("google", base_url="", api_key="test-key")
mock_openai_cls.assert_called_once_with(
base_url=GOOGLE_DEFAULT_BASE_URL, api_key="test-key"
)
@patch("openai.OpenAI")
def test_create_client_google_custom_base_url(self, mock_openai_cls: MagicMock) -> None:
from turnstone.core.providers import create_client
mock_openai_cls.return_value = MagicMock()
create_client("google", base_url="http://custom:8080/v1", api_key="k")
mock_openai_cls.assert_called_once_with(base_url="http://custom:8080/v1", api_key="k")
def test_google_capabilities_defaults(self) -> None:
from turnstone.core.providers import create_provider
provider = create_provider("google")
caps = provider.get_capabilities("gemini-2.5-pro")
assert caps.context_window == 2_000_000
assert caps.max_output_tokens == 65_536
assert caps.token_param == "max_tokens"
assert caps.supports_temperature is True
assert caps.supports_vision is True
def test_google_capabilities_same_for_all_models(self) -> None:
from turnstone.core.providers import create_provider
provider = create_provider("google")
c1 = provider.get_capabilities("gemini-2.5-pro")
c2 = provider.get_capabilities("gemini-2.0-flash")
c3 = provider.get_capabilities("")
assert c1 is c2 is c3
def test_list_known_models_google_empty(self) -> None:
from turnstone.core.providers import list_known_models
assert list_known_models("google") == []
def test_lookup_model_capabilities_google_returns_none(self) -> None:
from turnstone.core.providers import lookup_model_capabilities
assert lookup_model_capabilities("google", "gemini-2.5-pro") is None
# ===========================================================================
# TestDataclasses
+2 -1
View File
@@ -506,7 +506,8 @@ class ClusterCollector:
{
"type": "ws_created",
"ws_id": ws_id,
"name": data.get("name", ""),
"name": data.get("title", "") or data.get("name", ""),
"title": data.get("title", ""),
"node_id": node_id,
}
)
+22 -12
View File
@@ -130,9 +130,10 @@ _JS_PROXY_SHIM = """\
_CONSOLE_BANNER_TEMPLATE = (
'<div class="console-banner">'
'<a href="/" class="console-banner-link">&larr; Console</a>'
'<a href="/" class="console-banner-link" aria-label="Return to console">&larr; Console</a>'
'<span class="console-banner-sep">\u2502</span>'
'<a href="NODE_LINK_PLACEHOLDER" class="console-banner-node">'
'<a href="NODE_LINK_PLACEHOLDER" class="console-banner-node"'
' aria-label="Node: NODE_ID_PLACEHOLDER">'
"NODE_ID_PLACEHOLDER</a>"
"</div>"
)
@@ -144,8 +145,8 @@ _CONSOLE_PROXY_STYLE = (
".dashboard-overlay{top:32px!important}"
".console-banner{background:#111827;border-bottom:1px solid rgba(229,160,66,0.3);"
"padding:6px 20px;font-family:'IBM Plex Mono',monospace;font-size:12px;"
"display:flex;align-items:center;gap:12px;position:relative;z-index:9999}"
".console-banner-link,.console-banner-node{color:#8a93ad;text-decoration:none}"
"display:flex;align-items:center;gap:12px;position:relative;z-index:200}"
".console-banner-link,.console-banner-node{color:#9aa0b8;text-decoration:none}"
".console-banner-link{font-weight:500;padding:2px 0}"
".console-banner-node{font-size:11px}"
".console-banner-sep{color:#3b4463}"
@@ -156,7 +157,7 @@ _CONSOLE_PROXY_STYLE = (
':root[data-theme="light"] .console-banner-node{color:#64748b}'
':root[data-theme="light"] .console-banner-sep{color:#cbd5e1}'
':root[data-theme="light"] .console-banner-link:hover,'
':root[data-theme="light"] .console-banner-node:hover{color:#e5a042}'
':root[data-theme="light"] .console-banner-node:hover{color:#8c5e1b}'
"</style>"
)
@@ -939,7 +940,9 @@ async def proxy_index(request: Request) -> Response:
page = page.replace('href="/shared/', f'href="{prefix}/shared/')
page = page.replace('src="/shared/', f'src="{prefix}/shared/')
# Inject console-return banner + proxy shim after <body>
banner = _CONSOLE_BANNER_TEMPLATE.replace("NODE_ID_PLACEHOLDER", html.escape(node_id)).replace("NODE_LINK_PLACEHOLDER", html.escape(prefix + "/"))
banner = _CONSOLE_BANNER_TEMPLATE.replace(
"NODE_ID_PLACEHOLDER", html.escape(node_id)
).replace("NODE_LINK_PLACEHOLDER", html.escape(prefix + "/"))
shim = (
"<script>"
+ _JS_PROXY_SHIM.replace('"PREFIX_PLACEHOLDER"', json.dumps(prefix))
@@ -1020,7 +1023,7 @@ async def proxy_api(request: Request) -> Response:
if request.method == "GET" and path in ("events", "events/global"):
return await _proxy_sse(request, server_url, path, api_prefix=api_prefix)
if request.method == "POST":
if request.method in ("POST", "PUT"):
return await _proxy_post(request, server_url, path, api_prefix=api_prefix)
return await _proxy_get(request, server_url, f"{api_prefix}/{path}")
@@ -1057,7 +1060,7 @@ async def _proxy_get(request: Request, server_url: str, path: str) -> Response:
async def _proxy_post(
request: Request, server_url: str, path: str, *, api_prefix: str = "api"
) -> Response:
"""Forward a POST request to the target server."""
"""Forward a POST/PUT request to the target server."""
client: httpx.AsyncClient = request.app.state.proxy_client
body = await request.body()
content_type = request.headers.get("content-type", "application/json")
@@ -1065,16 +1068,21 @@ async def _proxy_post(
if request.url.query:
target += f"?{request.url.query}"
try:
post_headers = {"Content-Type": content_type}
post_headers.update(_proxy_auth_headers(request))
resp = await client.post(target, content=body, headers=post_headers)
headers = {"Content-Type": content_type}
headers.update(_proxy_auth_headers(request))
resp = await client.request(
request.method,
target,
content=body,
headers=headers,
)
return Response(
content=resp.content,
status_code=resp.status_code,
media_type=resp.headers.get("content-type", "application/json"),
)
except httpx.HTTPError as exc:
log.debug("Proxy POST error for %s/%s: %s", api_prefix, path, exc)
log.debug("Proxy %s error for %s/%s: %s", request.method, api_prefix, path, exc)
return JSONResponse({"error": "Node unreachable"}, status_code=502)
@@ -5173,6 +5181,7 @@ async def admin_import_mcp_config(request: Request) -> JSONResponse:
_MODEL_ALIAS_RE = re.compile(r"^[a-zA-Z0-9._-]+$")
_MODEL_PROVIDERS = frozenset({"openai", "anthropic", "openai-compatible", "google"})
# Keep in sync with turnstone.core.providers._google.GOOGLE_DEFAULT_BASE_URL
_PROVIDER_DEFAULT_URLS: dict[str, str] = {
"openai": "https://api.openai.com/v1",
"anthropic": "https://api.anthropic.com",
@@ -5630,6 +5639,7 @@ async def admin_detect_model(request: Request) -> JSONResponse:
or _hostname.endswith(".openai.com")
or _hostname == "api.anthropic.com"
or _hostname.endswith(".anthropic.com")
or _hostname.endswith(".googleapis.com")
):
return JSONResponse({"error": "api_key is required"}, status_code=400)
+99 -74
View File
@@ -403,8 +403,8 @@ function confirmDeleteUser(userId, username) {
showConfirmModal(
"Delete User",
"Delete user \u2018" +
username +
"\u2019 and all their tokens and channel links? This cannot be undone.",
username +
"\u2019 and all their tokens and channel links? This cannot be undone.",
"Delete",
function () {
authFetch("/v1/api/admin/users/" + encodeURIComponent(userId), {
@@ -570,19 +570,19 @@ function _confirmUnlinkOidc(issuer, subject, username, userId) {
showConfirmModal(
"Unlink OIDC Identity",
"Unlink " +
shortIssuer +
" identity \u2018" +
shortSubject +
"\u2019 from user " +
username +
"?\n\nThe user will need to log in via OIDC again to re-link.",
shortIssuer +
" identity \u2018" +
shortSubject +
"\u2019 from user " +
username +
"?\n\nThe user will need to log in via OIDC again to re-link.",
"Unlink",
function () {
authFetch(
"/v1/api/admin/oidc-identities?issuer=" +
encodeURIComponent(issuer) +
"&subject=" +
encodeURIComponent(subject),
encodeURIComponent(issuer) +
"&subject=" +
encodeURIComponent(subject),
{ method: "DELETE" },
)
.then(function (r) {
@@ -608,8 +608,8 @@ function _confirmUnlinkOidc(issuer, subject, username, userId) {
'<span class="oidc-detail-empty">Loading\u2026</span>';
authFetch(
"/v1/api/admin/users/" +
encodeURIComponent(userId) +
"/oidc-identities",
encodeURIComponent(userId) +
"/oidc-identities",
)
.then(function (r2) {
if (!r2.ok) throw new Error("Failed");
@@ -878,17 +878,17 @@ function confirmUnlinkChannel(channelType, channelUserId) {
showConfirmModal(
"Unlink Channel",
"Unlink " +
channelType +
" account \u2018" +
channelUserId +
"\u2019? The user will need to re-link via /link to interact with the bot.",
channelType +
" account \u2018" +
channelUserId +
"\u2019? The user will need to re-link via /link to interact with the bot.",
"Unlink",
function () {
authFetch(
"/v1/api/admin/channels/" +
encodeURIComponent(channelType) +
"/" +
encodeURIComponent(channelUserId),
encodeURIComponent(channelType) +
"/" +
encodeURIComponent(channelUserId),
{ method: "DELETE" },
)
.then(function (r) {
@@ -1059,8 +1059,8 @@ function confirmDeleteSchedule(taskId, name) {
showConfirmModal(
"Delete Schedule",
"Delete schedule \u2018" +
name +
"\u2019 and its run history? This cannot be undone.",
name +
"\u2019 and its run history? This cannot be undone.",
"Delete",
function () {
authFetch("/v1/api/admin/schedules/" + encodeURIComponent(taskId), {
@@ -1671,12 +1671,12 @@ function _renderWatches(watches) {
var statusDot = active ? "\u25cf " : "\u25cb ";
var cancelBtn = active
? '<button class="admin-btn-danger" data-cancel-watch="' +
escapeHtml(w.watch_id) +
'" data-watch-node="' +
escapeHtml(w.node_id || "") +
'" data-watch-name="' +
escapeHtml(name) +
'" title="Cancel watch">cancel</button>'
escapeHtml(w.watch_id) +
'" data-watch-node="' +
escapeHtml(w.node_id || "") +
'" data-watch-name="' +
escapeHtml(name) +
'" title="Cancel watch">cancel</button>'
: "";
html +=
'<div class="admin-row" role="listitem">' +
@@ -1797,8 +1797,8 @@ function submitCreateChannel() {
authFetch(
"/v1/api/admin/users/" +
encodeURIComponent(_adminChannelUserId) +
"/channels",
encodeURIComponent(_adminChannelUserId) +
"/channels",
{
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -3045,19 +3045,26 @@ function _saveSettingValue(key) {
"Saved " + key + (restartBadge ? " \u2014 restart required" : ""),
);
// If this is a theme setting, apply it immediately
// If this is a theme setting, apply it immediately. Don't call
// onThemeChange — it would fire a redundant PUT since the settings
// save above already persisted the value.
if (key === "interface.theme") {
var newTheme = value;
document.documentElement.dataset.theme =
newTheme === "light" ? "light" : "";
var isLight = value === "light";
document.documentElement.dataset.theme = isLight ? "light" : "";
localStorage.setItem(
"turnstone-theme",
newTheme === "light" ? "light" : "dark",
"turnstone_interface.theme",
isLight ? "light" : "dark",
);
var themeBtn = document.getElementById("theme-toggle");
if (themeBtn) {
themeBtn.textContent =
newTheme === "light" ? "\u2600" : "\u263E";
themeBtn.textContent = isLight ? "\u2600" : "\u263E";
themeBtn.title = isLight
? "Switch to dark theme"
: "Switch to light theme";
themeBtn.setAttribute(
"aria-label",
isLight ? "Switch to dark theme" : "Switch to light theme",
);
}
}
})
@@ -3074,8 +3081,8 @@ function _resetSetting(key) {
showConfirmModal(
"Reset Setting",
"Reset \u2018" +
key +
"\u2019 to its default value? The stored override will be removed.",
key +
"\u2019 to its default value? The stored override will be removed.",
"Reset",
function () {
authFetch("/v1/api/admin/settings/" + encodeURIComponent(key), {
@@ -3218,13 +3225,13 @@ function _renderMcpServers(items) {
var actions = isConfig
? ""
: '<button class="admin-btn-action" data-mcp-edit="' +
escapeHtml(s.server_id) +
'">edit</button>' +
'<button class="admin-btn-danger" data-mcp-delete="' +
escapeHtml(s.server_id) +
'" data-mcp-name="' +
escapeHtml(s.name) +
'">del</button>';
escapeHtml(s.server_id) +
'">edit</button>' +
'<button class="admin-btn-danger" data-mcp-delete="' +
escapeHtml(s.server_id) +
'" data-mcp-name="' +
escapeHtml(s.name) +
'">del</button>';
html +=
'<div class="admin-row mcp-grid ' +
@@ -3420,11 +3427,11 @@ function _parseMcpForm() {
var argsText = document.getElementById("mcp-args").value.trim();
payload.args = argsText
? argsText
.split("\n")
.map(function (l) {
return l.trim();
})
.filter(Boolean)
.split("\n")
.map(function (l) {
return l.trim();
})
.filter(Boolean)
: [];
var envText = document.getElementById("mcp-env").value.trim();
var envObj = {};
@@ -3576,7 +3583,7 @@ function _openMcpDetail(s) {
'<p style="font-size:12px;color:var(--fg-dim)">Args: <code>' +
escapeHtml(a.join(" ")) +
"</code></p>";
} catch (e) { }
} catch (e) {}
} else {
html +=
'<p style="font-size:12px;color:var(--fg-dim)">URL: <code>' +
@@ -3615,7 +3622,7 @@ function _openMcpDetail(s) {
escapeHtml(meta.website_url) +
"</a></p>";
}
} catch (e) { }
} catch (e) {}
html += "</div>";
}
html += "</div>";
@@ -3907,8 +3914,8 @@ function _renderRegistryResults() {
"</div>" +
(srv.description
? '<div class="mcp-reg-card-desc">' +
escapeHtml(srv.description) +
"</div>"
escapeHtml(srv.description) +
"</div>"
: "") +
'<div class="mcp-reg-card-meta">' +
sourceBadges +
@@ -3916,8 +3923,8 @@ function _renderRegistryResults() {
'<div class="mcp-reg-card-actions">' +
(srv.version
? '<span class="mcp-reg-card-version">v' +
escapeHtml(srv.version) +
"</span>"
escapeHtml(srv.version) +
"</span>"
: "") +
actionHtml +
"</div></div>";
@@ -3940,9 +3947,9 @@ function _renderRegistryResults() {
moreBtn.style.display = "";
countEl.textContent = isFiltered
? visibleCount +
" of " +
_registryResults.length +
" loaded (more available)"
" of " +
_registryResults.length +
" loaded (more available)"
: "Showing " + visibleCount + " results";
} else {
pagEl.style.display = visibleCount > 0 ? "" : "none";
@@ -4020,8 +4027,8 @@ function _showInstallMcpModal(srv, hasRemote, hasPackage) {
"</div>" +
(srv.description
? '<div class="mcp-install-summary-desc">' +
escapeHtml(srv.description) +
"</div>"
escapeHtml(srv.description) +
"</div>"
: "");
// Source selector (only if both remote AND package)
@@ -4341,7 +4348,7 @@ function _pollInstallStatus(serverId, serverName, attempt) {
_pollInstallStatus(serverId, serverName, attempt + 1);
}
})
.catch(function () { });
.catch(function () {});
}, 3000);
}
@@ -4408,7 +4415,11 @@ function _renderModels(items) {
var providerCls =
m.provider === "anthropic"
? "model-provider-anthropic"
: "model-provider-openai";
: m.provider === "google"
? "model-provider-google"
: m.provider === "openai-compatible"
? "model-provider-compat"
: "model-provider-openai";
// Build row via DOM
var row = document.createElement("div");
@@ -4856,9 +4867,9 @@ function _onModelFieldChange() {
if (!modelName) return;
authFetch(
"/v1/api/admin/model-capabilities?provider=" +
encodeURIComponent(provider) +
"&model=" +
encodeURIComponent(modelName),
encodeURIComponent(provider) +
"&model=" +
encodeURIComponent(modelName),
)
.then(function (r) {
return r.json();
@@ -4889,12 +4900,26 @@ function _onModelFieldChange() {
});
}, 500);
}
/* Provider-specific placeholder hints for base_url and model ID fields. */
/* Provider-specific placeholder hints for base_url and model ID fields.
Keep URLs in sync with _PROVIDER_DEFAULT_URLS in console/server.py
and GOOGLE_DEFAULT_BASE_URL in core/providers/_google.py. */
var _providerDefaults = {
openai: { urlPlaceholder: "https://api.openai.com/v1", modelPlaceholder: "gpt-5" },
anthropic: { urlPlaceholder: "https://api.anthropic.com", modelPlaceholder: "claude-" },
google: { urlPlaceholder: "https://generativelanguage.googleapis.com/v1beta/openai/", modelPlaceholder: "gemini-" },
"openai-compatible": { urlPlaceholder: "e.g. https://your-provider.com/v1", modelPlaceholder: "GLM5" },
openai: {
urlPlaceholder: "https://api.openai.com/v1",
modelPlaceholder: "gpt-5",
},
anthropic: {
urlPlaceholder: "https://api.anthropic.com",
modelPlaceholder: "claude-",
},
google: {
urlPlaceholder: "https://generativelanguage.googleapis.com/v1beta/openai/",
modelPlaceholder: "gemini-",
},
"openai-compatible": {
urlPlaceholder: "e.g. https://your-provider.com/v1",
modelPlaceholder: "GLM5",
},
};
/* Update placeholders when provider changes. */
@@ -4914,7 +4939,7 @@ function _refreshModelSuggestions() {
var provider = document.getElementById("model-provider").value;
authFetch(
"/v1/api/admin/model-capabilities/known?provider=" +
encodeURIComponent(provider),
encodeURIComponent(provider),
)
.then(function (r) {
return r.json();
+29 -10
View File
@@ -10,21 +10,35 @@ window.onLogout = function () {
};
window.onThemeChange = function (next) {
var btn = document.getElementById("theme-toggle");
if (btn) btn.textContent = next === "light" ? "\u2600" : "\u263E";
if (btn) {
var isLight = next === "light";
btn.textContent = isLight ? "\u2600" : "\u263E";
btn.title = isLight ? "Switch to dark theme" : "Switch to light theme";
btn.setAttribute(
"aria-label",
isLight ? "Switch to dark theme" : "Switch to light theme",
);
}
// Persist to server so admin settings and node UIs see the change
var themeValue = next === "light" ? "light" : "dark";
authFetch("/v1/api/admin/settings/interface.theme", {
method: "POST",
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value: themeValue }),
}).catch(function () {});
};
// Set initial theme button text
// Set initial theme button text and aria
(function () {
var btn = document.getElementById("theme-toggle");
if (btn)
btn.textContent =
document.documentElement.dataset.theme === "light" ? "\u2600" : "\u263E";
if (btn) {
var isLight = document.documentElement.dataset.theme === "light";
btn.textContent = isLight ? "\u2600" : "\u263E";
btn.title = isLight ? "Switch to dark theme" : "Switch to light theme";
btn.setAttribute(
"aria-label",
isLight ? "Switch to dark theme" : "Switch to light theme",
);
}
})();
// --- State ---
@@ -1291,15 +1305,15 @@ function showNewWsModal() {
var judgeSelect = document.getElementById("new-ws-judge");
modelSelect.textContent = "";
judgeSelect.textContent = "";
var defaultOpt = document.createElement("option");
defaultOpt.value = "";
defaultOpt.textContent = "Default model";
modelSelect.appendChild(defaultOpt);
var defaultJudgeOpt = document.createElement("option");
defaultJudgeOpt.value = "";
defaultJudgeOpt.textContent = "Default (same as agent)";
defaultJudgeOpt.textContent = "Default \u2014 use workstream's agent model";
judgeSelect.appendChild(defaultJudgeOpt);
authFetch("/v1/api/models")
@@ -1313,7 +1327,7 @@ function showNewWsModal() {
opt.textContent =
m.alias === m.model ? m.alias : m.alias + " (" + m.model + ")";
modelSelect.appendChild(opt);
var jOpt = document.createElement("option");
jOpt.value = m.alias;
jOpt.textContent = opt.textContent;
@@ -1345,6 +1359,11 @@ function showNewWsModal() {
if (_newWsTrapHandler)
document.removeEventListener("keydown", _newWsTrapHandler);
_newWsTrapHandler = function (e) {
if (e.key === "Escape") {
e.preventDefault();
hideNewWsModal();
return;
}
if (e.key === "Tab") {
var box = document.getElementById("new-ws-box");
var focusable = box.querySelectorAll("select, input, textarea, button");
+2 -2
View File
@@ -793,7 +793,7 @@ window.TURNSTONE_KB_SHORTCUTS = [
</select>
<label for="new-ws-judge">Judge Model <span class="label-hint">optional</span></label>
<select id="new-ws-judge">
<option value="">Default (same as agent)</option>
<option value="">Default — use workstream's agent model</option>
</select>
<div id="new-ws-buttons">
<button id="new-ws-cancel" onclick="hideNewWsModal()">Cancel</button>
@@ -1540,7 +1540,7 @@ window.TURNSTONE_KB_SHORTCUTS = [
<label style="margin:0;font-size:12px;color:var(--fg-dim)"><input type="checkbox" id="model-enabled" checked style="margin-right:5px">Enabled</label>
</div>
<div id="model-detect-area" style="margin-top:14px">
<button type="button" id="model-detect-btn" class="modal-cancel" onclick="detectModel()" style="width:auto;padding:7px 16px;font-size:12px" title="Probe endpoint to verify connectivity and discover models">Detect</button>
<button type="button" id="model-detect-btn" class="admin-action-btn" onclick="detectModel()" style="width:auto;padding:7px 16px;font-size:12px" title="Probe endpoint to verify connectivity and discover models">Detect</button>
<div id="model-detect-result" role="status" aria-live="polite" style="display:none;margin-top:8px;padding:10px 12px;border-radius:6px;font-size:12px;border:1px solid var(--border);word-break:break-word"></div>
</div>
<div class="modal-buttons">
+2
View File
@@ -2468,6 +2468,8 @@ h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
.model-provider-badge{display:inline-block;font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;padding:1px 6px;border-radius:2px;background:var(--bg-highlight);border:1px solid var(--border)}
.model-provider-openai{color:var(--blue);border-color:rgba(56,189,248,.2)}
.model-provider-anthropic{color:var(--magenta);border-color:rgba(192,132,252,.25)}
.model-provider-google{color:var(--green);border-color:rgba(52,211,153,.2)}
.model-provider-compat{color:var(--fg-dim);border-color:var(--border-strong)}
/* Model source badge */
.scope-db{color:var(--blue);border-color:rgba(56,189,248,.2)}
+15
View File
@@ -434,6 +434,13 @@ def required_scope(method: str, path: str) -> str:
and normalized.endswith("/cancel")
):
return "write"
# Workstream sub-resource mutations: /api/workstreams/{ws_id}/{action}
if (
method == "POST"
and normalized.startswith("/api/workstreams/")
and normalized.rsplit("/", 1)[-1] in {"delete", "open", "refresh-title", "title"}
):
return "write"
# Memory delete: /api/memories/{name}
if method == "DELETE" and normalized.startswith("/api/memories/"):
return "write"
@@ -446,6 +453,14 @@ def required_scope(method: str, path: str) -> str:
return "approve"
if proxied in WRITE_PATHS:
return "write"
# Parametric workstream sub-resource mutations
if proxied.startswith("/api/workstreams/") and proxied.rsplit("/", 1)[-1] in {
"delete",
"open",
"refresh-title",
"title",
}:
return "write"
return "read"
+53 -23
View File
@@ -72,13 +72,19 @@ class IntentVerdict:
@dataclass
class JudgeConfig:
"""Configuration for the intent validation judge."""
"""Configuration for the intent validation judge.
The *timeout* value applies **per turn**, not as a total budget across
all turns. With the default of 60 s and a maximum of 5 turns, a
single tool-call evaluation can take up to 300 s in the worst case
(e.g. a multi-turn tool-use exchange with a slow local model).
"""
enabled: bool = True
model: str = "" # empty = use session model
confidence_threshold: float = 0.7
max_context_ratio: float = 0.5
timeout: float = 60.0
timeout: float = 60.0 # per-turn timeout in seconds (see class docstring)
read_only_tools: bool = True
output_guard: bool = True
redact_secrets: bool = True
@@ -912,7 +918,8 @@ class IntentJudge:
client, model_name, _ = model_registry.resolve(config.model)
self._provider = model_registry.get_provider(config.model)
self._client_factory_args = self._extract_client_config(
client, self._provider.provider_name,
client,
self._provider.provider_name,
)
self._model = model_name
caps = self._provider.get_capabilities(self._model)
@@ -925,7 +932,8 @@ class IntentJudge:
# Model name override with session provider
self._provider = session_provider
self._client_factory_args = self._extract_client_config(
session_client, session_provider.provider_name,
session_client,
session_provider.provider_name,
)
self._model = config.model
caps = self._provider.get_capabilities(self._model)
@@ -934,7 +942,8 @@ class IntentJudge:
# Self-consistency: same model as session
self._provider = session_provider
self._client_factory_args = self._extract_client_config(
session_client, session_provider.provider_name,
session_client,
session_provider.provider_name,
)
self._model = session_model
self._judge_context_window = context_window
@@ -1030,21 +1039,20 @@ class IntentJudge:
if cancel_event and cancel_event.is_set() and self._config.cancel_on_approval:
log.info("judge.cancelled", remaining=len(items) - idx)
self._deliver_fallbacks(
items[idx:], heuristic_verdicts[idx:], callback,
items[idx:],
heuristic_verdicts[idx:],
callback,
"judge cancelled by user approval",
)
return
try:
llm_verdict = self._evaluate_single(
item, messages, cancel_event, executor, client,
item,
messages,
cancel_event,
executor,
client,
)
if cancel_event and cancel_event.is_set() and self._config.cancel_on_approval:
log.info("judge.cancelled.after_eval", call_id=item.get("call_id", ""))
self._deliver_fallbacks(
items[idx:], heuristic_verdicts[idx:], callback,
"judge cancelled by user approval",
)
return
if llm_verdict:
log.info(
"judge.verdict.llm",
@@ -1076,6 +1084,17 @@ class IntentJudge:
call_id=fallback.call_id,
)
callback(fallback)
# After delivering this item's verdict, check if we should
# abort remaining items due to user approval.
if cancel_event and cancel_event.is_set() and self._config.cancel_on_approval:
log.info("judge.cancelled.after_eval", call_id=item.get("call_id", ""))
self._deliver_fallbacks(
items[idx + 1 :],
heuristic_verdicts[idx + 1 :],
callback,
"judge cancelled by user approval",
)
return
except _ExecutorPoisonedError:
executor.shutdown(wait=False, cancel_futures=True)
executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="judge-api")
@@ -1100,7 +1119,7 @@ class IntentJudge:
reason: str,
) -> None:
"""Deliver heuristic fallback verdicts for items the judge didn't complete."""
for item, h_verdict in zip(remaining_items, remaining_verdicts, strict=True):
for _item, h_verdict in zip(remaining_items, remaining_verdicts, strict=True):
fallback = IntentVerdict(
verdict_id=h_verdict.verdict_id,
call_id=h_verdict.call_id,
@@ -1198,18 +1217,21 @@ class IntentJudge:
temperature=0.0,
reasoning_effort="medium",
)
# Poll in 1s increments so we notice timeout promptly
# Poll in 1s increments so we notice cancellation promptly
# instead of blocking for the full per_call_timeout.
deadline = time.monotonic() + per_call_timeout
while True:
remaining = deadline - time.monotonic()
if cancel_event and cancel_event.is_set():
future.cancel()
return None
if remaining <= 0:
raise TimeoutError
try:
result = future.result(timeout=min(remaining, 1.0))
break
except TimeoutError:
pass # loop back to check remaining
pass # loop back to check remaining/cancel
except TimeoutError:
log.info("judge.turn.timeout", turn=turn + 1, timeout=per_call_timeout)
# Safety net: if we have a partial result from a previous turn,
@@ -1239,6 +1261,10 @@ class IntentJudge:
elapsed=round(turn_elapsed, 1),
)
# Reset empty-response counter after any non-empty response
if result.content or result.tool_calls:
empty_retries = 0
# Check for tool calls
if result.tool_calls:
# Execute read-only tools and append results
@@ -1309,8 +1335,15 @@ class IntentJudge:
turn += 1
continue
# Empty response (0 chars, 0 tools) — retry up to 3 times
# without consuming the turn budget.
# Empty response (0 chars, 0 tools). If the model hit the
# output token limit the finish_reason will be "length" — retrying
# with the same prompt and max_tokens is pointless.
if result.finish_reason == "length":
log.info("judge.empty_response.length_stop", turn=turn + 1)
return None
# Transient empty response — retry up to 3 times without
# consuming the turn budget.
empty_retries += 1
if empty_retries <= 3:
log.info("judge.empty_response.retry", retry=empty_retries, max_retries=3)
@@ -1408,10 +1441,7 @@ class IntentJudge:
calls = []
for tc in msg["tool_calls"]:
fn = tc.get("function", {})
calls.append(
f"[Tool Call -> {fn.get('name')}\n"
f"Args: {fn.get('arguments')}]"
)
calls.append(f"[Tool Call -> {fn.get('name')}\nArgs: {fn.get('arguments')}]")
if content_str:
content_str += "\n\n" + "\n".join(calls)
else:
+4
View File
@@ -73,6 +73,10 @@ def create_client(provider_name: str, *, base_url: str, api_key: str) -> Any:
if provider_name in ("openai", "openai-compatible", "google"):
from openai import OpenAI
if not base_url and provider_name == "google":
from turnstone.core.providers._google import GOOGLE_DEFAULT_BASE_URL
base_url = GOOGLE_DEFAULT_BASE_URL
if base_url:
return OpenAI(base_url=base_url, api_key=api_key)
return OpenAI(api_key=api_key)
+21 -4
View File
@@ -3,6 +3,11 @@
Shares the core mechanics of OpenAI Chat Completions but with Google-specific
defaults (large context window, vision support). Uses the Gemini
``/v1beta/openai/`` endpoint which is wire-compatible with the OpenAI SDK.
The caller must provide a ``base_url`` pointing at the Gemini endpoint
(e.g. ``https://generativelanguage.googleapis.com/v1beta/openai/``);
:func:`~turnstone.core.providers.create_client` fills in this default
automatically when ``provider_name="google"`` and no URL is given.
"""
from __future__ import annotations
@@ -10,12 +15,22 @@ from __future__ import annotations
from turnstone.core.providers._openai_chat import OpenAIChatCompletionsProvider
from turnstone.core.providers._protocol import ModelCapabilities
# Dynamic Google capability baseline (assume large context, no temp limits)
# Default endpoint used when no base_url is configured.
GOOGLE_DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/"
# Baseline capabilities for Google models. Since Google updates models
# frequently, we use a single generous default rather than maintaining a
# static per-model table. The values below are safe for Gemini 2.5 Pro
# (the most capable model at time of writing) and degrade gracefully for
# smaller models — the API simply ignores over-specified max_tokens.
_GOOGLE_DEFAULT = ModelCapabilities(
context_window=2_000_000,
max_output_tokens=8192,
max_output_tokens=65_536,
supports_temperature=True,
supports_vision=True,
# Gemini's OpenAI-compat endpoint accepts max_tokens (not
# max_completion_tokens which is OpenAI Responses-specific).
token_param="max_tokens",
)
@@ -27,6 +42,8 @@ class GoogleProvider(OpenAIChatCompletionsProvider):
return "google"
def get_capabilities(self, model: str) -> ModelCapabilities:
# Since Google models update frequently, returning a robust default
# safely handles dynamic versions.
# Returns a single default instance for all Google models.
# lookup_model_capabilities() relies on the identity check
# (caps is default) to correctly return None for Google,
# signalling "no static per-model entry".
return _GOOGLE_DEFAULT
+34 -2
View File
@@ -941,6 +941,21 @@ class ChatSession:
+ output[-half:]
)
def request_title_refresh(self, current_title: str = "") -> None:
"""Request a title regeneration (thread-safe public API).
Resets the title-generated flag and spawns a background thread
to produce a new title via LLM. Safe to call from server endpoints.
"""
self._title_generated = False
import threading
threading.Thread(
target=self._generate_title,
args=(current_title,),
daemon=True,
).start()
def _generate_title(self, current_title: str = "") -> None:
"""Generate a short title for this session via a background LLM call.
@@ -981,7 +996,7 @@ class ChatSession:
snippet += f"\nAssistant: {asst_msg}"
if current_title:
snippet += (
f"\n\nThe current title is: \"{current_title}\"\n"
f'\n\nThe current title is: "{current_title}"\n'
"The user wants a DIFFERENT title. Generate a new, distinct title "
"that is NOT the same as the current one."
)
@@ -1142,7 +1157,24 @@ class ChatSession:
content = msg.get("content", "")
name = msg.get("name")
tool_call_id = msg.get("tool_call_id")
save_message(self._ws_id, role, content, name, tool_call_id=tool_call_id)
# Preserve tool_calls and provider_data so the fork
# reconstructs the full message structure after restart.
tc = msg.get("tool_calls")
tc_json = json.dumps(tc) if tc else None
pd = msg.get("provider_data")
try:
pd_str = json.dumps(pd) if pd and not isinstance(pd, str) else pd
except (TypeError, ValueError):
pd_str = None
save_message(
self._ws_id,
role,
content,
name,
tool_call_id=tool_call_id,
tool_calls=tc_json,
provider_data=pd_str,
)
self._save_config()
self._title_generated = False # allow auto-title for the fork
log.info(
+4
View File
@@ -75,6 +75,10 @@ class StorageBackend(Protocol):
"""Return the alias (or title) for a workstream, or None if unset."""
...
def get_workstream_metadata(self, ws_id: str) -> dict[str, Any] | None:
"""Return workstream metadata dict or None if not found."""
...
def update_workstream_title(self, ws_id: str, title: str) -> None:
"""Set or update the auto-generated title for a workstream."""
...
+4 -1
View File
@@ -31,7 +31,10 @@ async def read_json_or_400(request: Request) -> dict[str, Any] | JSONResponse:
except (ValueError, json.JSONDecodeError):
return _JSONResponse({"error": "Invalid JSON body"}, status_code=400)
except Exception:
return _JSONResponse({"error": "Failed to read request body"}, status_code=400)
import structlog
structlog.get_logger(__name__).warning("read_json_or_400.unexpected", exc_info=True)
return _JSONResponse({"error": "Failed to read request body"}, status_code=500)
def require_storage_or_503(
+62 -44
View File
@@ -224,7 +224,8 @@ class WebUI:
def approve_tools(self, items: list[dict[str, Any]]) -> tuple[bool, str | None]:
self._last_verdict_decision = "" # reset for new approval cycle
self._llm_verdicts.clear() # clear stale verdicts from prior cycle
with self._ws_lock:
self._llm_verdicts.clear() # clear stale verdicts from prior cycle
pending = [it for it in items if it.get("needs_approval") and not it.get("error")]
# Always send tool info to the browser
@@ -526,11 +527,12 @@ class WebUI:
# Cache for replay on SSE reconnect (tab switching)
call_id = verdict.get("call_id", "")
if call_id:
# Evict oldest entry if cache is full (defensive cap of 50)
if len(self._llm_verdicts) >= 50 and call_id not in self._llm_verdicts:
oldest_key = next(iter(self._llm_verdicts))
del self._llm_verdicts[oldest_key]
self._llm_verdicts[call_id] = verdict
with self._ws_lock:
# Evict oldest entry if cache is full (defensive cap of 50)
if len(self._llm_verdicts) >= 50 and call_id not in self._llm_verdicts:
oldest_key = next(iter(self._llm_verdicts))
del self._llm_verdicts[oldest_key]
self._llm_verdicts[call_id] = verdict
self._enqueue({"type": "intent_verdict", **verdict})
# Persist the LLM verdict (fire-and-forget)
try:
@@ -928,7 +930,9 @@ async def events_sse(request: Request) -> Response:
if ui._pending_approval is not None:
yield {"data": json.dumps(ui._pending_approval)}
# Replay any LLM verdicts received since the approval was sent
for v in ui._llm_verdicts.values():
with ui._ws_lock:
cached_verdicts = list(ui._llm_verdicts.values())
for v in cached_verdicts:
yield {"data": json.dumps({"type": "intent_verdict", **v})}
if ui._pending_plan_review is not None:
yield {"data": json.dumps(ui._pending_plan_review)}
@@ -1081,6 +1085,7 @@ async def global_events_sse(request: Request) -> Response:
async def list_workstreams(request: Request) -> JSONResponse:
"""GET /v1/api/workstreams — list all workstreams."""
from turnstone.core.memory import get_workstream_display_name
mgr: WorkstreamManager = request.app.state.workstreams
result = []
for ws in mgr.list_all():
@@ -1948,9 +1953,7 @@ async def create_workstream(request: Request) -> JSONResponse:
# Broadcast a rename so the tab picks up the correct fork name
# (the ws_created event fired before fork with the pre-fork name).
with contextlib.suppress(queue.Full):
gq.put_nowait(
{"type": "ws_rename", "ws_id": ws.id, "name": ws.name}
)
gq.put_nowait({"type": "ws_rename", "ws_id": ws.id, "name": ws.name})
# Apply skill session config (only for new workstreams with a skill)
if skill_data and not resumed and ws.session:
@@ -2065,12 +2068,18 @@ async def close_workstream(request: Request) -> JSONResponse:
return body
ws_id = str(body.get("ws_id", ""))
mgr = request.app.state.workstreams
# Distinguish "last workstream" (400) from "not found" (404).
# Note: get() and close() acquire the manager lock independently, so a
# concurrent close between the two could produce a wrong error code.
# The failure mode is cosmetic (400 instead of 404), not data corruption.
if not mgr.get(ws_id):
return JSONResponse({"error": "Workstream not found"}, status_code=404)
if mgr.close(ws_id):
gq: queue.Queue[dict[str, Any]] = request.app.state.global_queue
with contextlib.suppress(queue.Full):
gq.put_nowait({"type": "ws_closed", "ws_id": ws_id, "reason": "closed"})
return JSONResponse({"status": "ok"})
return JSONResponse({"error": "Workstream not found"}, status_code=404)
return JSONResponse({"error": "Cannot close last workstream"}, status_code=400)
async def delete_workstream_endpoint(request: Request) -> JSONResponse:
@@ -2091,7 +2100,7 @@ async def delete_workstream_endpoint(request: Request) -> JSONResponse:
return JSONResponse({"error": "Workstream not found"}, status_code=404)
except Exception as e:
log.exception("ws.delete.error", ws_id=ws_id[:8], error=str(e))
return JSONResponse({"error": f"Delete failed: {e!s}"}, status_code=500)
return JSONResponse({"error": "Delete failed"}, status_code=500)
async def refresh_workstream_title(request: Request, ws_id: str = "") -> JSONResponse:
@@ -2113,14 +2122,8 @@ async def refresh_workstream_title(request: Request, ws_id: str = "") -> JSONRes
return JSONResponse({"error": "Workstream not found or not active"}, status_code=404)
# Fetch current title so the LLM can generate something different
current_title = get_workstream_display_name(ws_id) or ""
log.info("ws.title.refresh_reset_flag", ws_id=ws_id[:8], current_title=current_title[:50])
ws.session._title_generated = False
threading.Thread(
target=ws.session._generate_title,
args=(current_title,),
daemon=True,
).start()
log.info("ws.title.refresh_triggered", ws_id=ws_id[:8])
log.info("ws.title.refresh_triggered", ws_id=ws_id[:8], current_title=current_title[:50])
ws.session.request_title_refresh(current_title)
return JSONResponse({"status": "ok"})
@@ -2184,11 +2187,13 @@ async def open_workstream(request: Request) -> JSONResponse:
mgr: WorkstreamManager = request.app.state.workstreams
if mgr.get(resolved_id):
return JSONResponse({
"ws_id": resolved_id,
"name": get_workstream_display_name(resolved_id) or resolved_id,
"already_loaded": True,
})
return JSONResponse(
{
"ws_id": resolved_id,
"name": get_workstream_display_name(resolved_id) or resolved_id,
"already_loaded": True,
}
)
_st = _get_storage()
ws_row = _st.get_workstream_metadata(resolved_id)
@@ -2212,14 +2217,13 @@ async def open_workstream(request: Request) -> JSONResponse:
msg = f"Expected WebUI, got {type(ws.ui).__name__}"
raise TypeError(msg)
if ws.session is not None:
if ws.session.resume(resolved_id):
ws.name = get_workstream_display_name(resolved_id) or ws.name
ui = ws.ui
ui._enqueue({"type": "clear_ui"})
history = _build_history(ws.session)
if history:
ui._enqueue({"type": "history", "messages": history})
if ws.session is not None and ws.session.resume(resolved_id):
ws.name = get_workstream_display_name(resolved_id) or ws.name
ui = ws.ui
ui._enqueue({"type": "clear_ui"})
history = _build_history(ws.session)
if history:
ui._enqueue({"type": "history", "messages": history})
gq: queue.Queue[dict[str, Any]] = request.app.state.global_queue
with contextlib.suppress(queue.Full):
@@ -2534,14 +2538,16 @@ def list_interface_settings(request: Request) -> JSONResponse:
if not key.startswith("interface."):
continue
value = cs.get(key) if cs else defn.default
settings.append({
"key": key,
"value": value,
"source": "storage" if cs and key in cs._cache else "default",
"type": defn.type,
"description": defn.description,
"section": defn.section,
})
settings.append(
{
"key": key,
"value": value,
"source": "storage" if cs and key in cs.stored_keys() else "default",
"type": defn.type,
"description": defn.description,
"section": defn.section,
}
)
return JSONResponse({"settings": settings})
@@ -3076,9 +3082,17 @@ def create_app(
Route("/api/workstreams/saved", list_saved_workstreams),
Route("/api/workstreams/new", create_workstream, methods=["POST"]),
Route("/api/workstreams/close", close_workstream, methods=["POST"]),
Route("/api/workstreams/{ws_id}/delete", delete_workstream_endpoint, methods=["POST"]),
Route(
"/api/workstreams/{ws_id}/delete",
delete_workstream_endpoint,
methods=["POST"],
),
Route("/api/workstreams/{ws_id}/open", open_workstream, methods=["POST"]),
Route("/api/workstreams/{ws_id}/refresh-title", refresh_workstream_title, methods=["POST"]),
Route(
"/api/workstreams/{ws_id}/refresh-title",
refresh_workstream_title,
methods=["POST"],
),
Route("/api/workstreams/{ws_id}/title", set_workstream_title, methods=["POST"]),
Route("/api/skills", list_skills_summary),
Route("/api/models", list_available_models),
@@ -3101,7 +3115,11 @@ def create_app(
Route("/api/auth/oidc/authorize", oidc_authorize),
Route("/api/auth/oidc/callback", oidc_callback),
Route("/api/admin/settings", list_interface_settings),
Route("/api/admin/settings/{key:path}", update_interface_setting, methods=["POST"]),
Route(
"/api/admin/settings/{key:path}",
update_interface_setting,
methods=["POST", "PUT"],
),
Route("/api/_internal/config-reload", config_reload, methods=["POST"]),
Route("/api/_internal/mcp-reload", internal_mcp_reload, methods=["POST"]),
Route("/api/_internal/mcp-status", internal_mcp_status),
+5 -1
View File
@@ -85,6 +85,9 @@
--row-alt: rgba(0, 0, 0, 0.015);
}
html, body {
transition: background-color 0.15s ease, color 0.15s ease;
}
html, body {
height: 100%;
background: var(--bg);
@@ -277,7 +280,7 @@ body {
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
z-index: 10001;
}
#login-box {
background: var(--bg-surface);
@@ -539,6 +542,7 @@ body {
Reduced motion base rules
========================================================================== */
@media (prefers-reduced-motion: reduce) {
html, body { transition: none; }
.dash-state-dot[data-state="running"],
.dash-state-dot[data-state="thinking"],
.dash-state-dot[data-state="attention"] { animation: none; opacity: 1; }
+5 -2
View File
@@ -4,12 +4,15 @@
function toggleTheme() {
var next = document.documentElement.dataset.theme === "light" ? "" : "light";
document.documentElement.dataset.theme = next;
localStorage.setItem("turnstone-theme", next || "dark");
localStorage.setItem("turnstone_interface.theme", next || "dark");
if (typeof window.onThemeChange === "function") window.onThemeChange(next);
}
(function initTheme() {
var stored = localStorage.getItem("turnstone-theme");
// Check both keys for backwards compatibility (old key: "turnstone-theme")
var stored =
localStorage.getItem("turnstone_interface.theme") ||
localStorage.getItem("turnstone-theme");
if (stored === "light") {
document.documentElement.dataset.theme = "light";
} else if (
+309 -72
View File
@@ -1320,7 +1320,7 @@ Pane.prototype.updateVerdictBadge = function (verdict) {
var func = verdict.func_name || "";
showToast(
"Judge verdict for " + func + ": " + rec + " (" + conf + "%)",
rec === "approve" ? "success" : rec === "deny" ? "error" : "warning"
rec === "approve" ? "success" : rec === "deny" ? "error" : "warning",
);
return;
}
@@ -1385,7 +1385,10 @@ Pane.prototype.updateVerdictGlow = function (recommendation) {
var recEl = badges[i].querySelector(".verdict-rec");
if (recEl) {
var r = recEl.textContent;
if (r === "deny") { worst = "deny"; break; }
if (r === "deny") {
worst = "deny";
break;
}
if (r === "review" && worst !== "deny") worst = "review";
}
}
@@ -1395,8 +1398,7 @@ Pane.prototype.updateVerdictGlow = function (recommendation) {
"verdict-glow-deny",
"verdict-glow-review",
);
if (worst === "approve")
prompt.classList.add("verdict-glow-approve");
if (worst === "approve") prompt.classList.add("verdict-glow-approve");
else if (worst === "deny") prompt.classList.add("verdict-glow-deny");
else prompt.classList.add("verdict-glow-review");
};
@@ -2249,7 +2251,7 @@ window.onThemeChange = function (next) {
// Persist theme to server settings so it propagates to other clients
var themeValue = next === "light" ? "light" : "dark";
authFetch("/v1/api/admin/settings/interface.theme", {
method: "POST",
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value: themeValue }),
}).catch(function () {});
@@ -2320,6 +2322,10 @@ function renderTabBar() {
close.className = "tab-close";
close.innerHTML = "&times;";
close.title = "Close workstream";
close.setAttribute(
"aria-label",
"Close " + (ws.name || wsId.substring(0, 6)),
);
close.onclick = function (e) {
e.stopPropagation();
closeWorkstream(wsId);
@@ -2462,21 +2468,21 @@ function showNewWsModal(forkFromWsId) {
// Populate model dropdown
var modelSelect = document.getElementById("new-ws-model");
var judgeSelect = document.getElementById("new-ws-judge-model"); // NEW
var judgeSelect = document.getElementById("new-ws-judge-model");
var fp = getFocusedPane();
var curModel = fp ? fp.modelAlias || fp.model || "" : "";
modelSelect.textContent = "";
judgeSelect.textContent = ""; // NEW
judgeSelect.textContent = "";
var defaultOpt = document.createElement("option");
defaultOpt.value = "";
defaultOpt.textContent = curModel
? "Default (" + curModel + ")"
: "Default model";
modelSelect.appendChild(defaultOpt);
var defJudgeOpt = document.createElement("option"); // NEW
defJudgeOpt.value = ""; // NEW
defJudgeOpt.textContent = "Default Judge"; // NEW
judgeSelect.appendChild(defJudgeOpt); // NEW
var defJudgeOpt = document.createElement("option");
defJudgeOpt.value = "";
defJudgeOpt.textContent = "Default \u2014 use workstream's agent model";
judgeSelect.appendChild(defJudgeOpt);
authFetch("/v1/api/models")
.then(function (r) {
return r.json();
@@ -2488,11 +2494,11 @@ function showNewWsModal(forkFromWsId) {
opt.textContent =
m.alias === m.model ? m.alias : m.alias + " (" + m.model + ")";
modelSelect.appendChild(opt);
var judgeOpt = document.createElement("option"); // NEW
judgeOpt.value = m.alias; // NEW
judgeOpt.textContent = opt.textContent; // NEW
judgeSelect.appendChild(judgeOpt); // NEW
var judgeOpt = document.createElement("option");
judgeOpt.value = m.alias;
judgeOpt.textContent = opt.textContent;
judgeSelect.appendChild(judgeOpt);
});
})
.catch(function () {
@@ -2526,7 +2532,7 @@ function showNewWsModal(forkFromWsId) {
errEl.textContent = "";
var submitBtn = document.getElementById("new-ws-submit");
submitBtn.disabled = false;
submitBtn.textContent = "Create";
submitBtn.textContent = _forkFromWsId ? "Fork" : "Create";
document.getElementById("new-ws-cancel").onclick = hideNewWsModal;
submitBtn.onclick = submitNewWs;
@@ -2626,7 +2632,9 @@ function submitNewWs() {
}
})
.catch(function () {
errEl.textContent = _forkFromWsId ? "Failed to fork workstream" : "Failed to create workstream";
errEl.textContent = _forkFromWsId
? "Failed to fork workstream"
: "Failed to create workstream";
errEl.style.display = "block";
submitBtn.disabled = false;
submitBtn.textContent = _forkFromWsId ? "Fork" : "Create";
@@ -2644,7 +2652,11 @@ function _reassignPanesForClosedWs(closedWsId, tabIdsBeforeClose) {
// Determine target ws based on close_tab_action setting
var action = "last_used";
try { action = localStorage.getItem("turnstone_interface.close_tab_action") || "last_used"; } catch (_) {}
try {
action =
localStorage.getItem("turnstone_interface.close_tab_action") ||
"last_used";
} catch (_) {}
if (action === "dashboard" && remaining.length > 0) {
// Show dashboard, but still need to reassign panes to valid ws
@@ -2671,7 +2683,11 @@ function _reassignPanesForClosedWs(closedWsId, tabIdsBeforeClose) {
// Determine preferred target ws_id
var preferredWsId = null;
if (action === "last_used") {
if (_lastActiveWsId && _lastActiveWsId !== closedWsId && workstreams[_lastActiveWsId]) {
if (
_lastActiveWsId &&
_lastActiveWsId !== closedWsId &&
workstreams[_lastActiveWsId]
) {
preferredWsId = _lastActiveWsId;
}
} else if (action === "nearest_left" || action === "nearest_right") {
@@ -2680,21 +2696,33 @@ function _reassignPanesForClosedWs(closedWsId, tabIdsBeforeClose) {
if (action === "nearest_left") {
// Walk left, then right
for (var li = idx - 1; li >= 0; li--) {
if (workstreams[tabIdsBeforeClose[li]]) { preferredWsId = tabIdsBeforeClose[li]; break; }
if (workstreams[tabIdsBeforeClose[li]]) {
preferredWsId = tabIdsBeforeClose[li];
break;
}
}
if (!preferredWsId) {
for (var ri = idx + 1; ri < tabIdsBeforeClose.length; ri++) {
if (workstreams[tabIdsBeforeClose[ri]]) { preferredWsId = tabIdsBeforeClose[ri]; break; }
if (workstreams[tabIdsBeforeClose[ri]]) {
preferredWsId = tabIdsBeforeClose[ri];
break;
}
}
}
} else {
// Walk right, then left
for (var ri2 = idx + 1; ri2 < tabIdsBeforeClose.length; ri2++) {
if (workstreams[tabIdsBeforeClose[ri2]]) { preferredWsId = tabIdsBeforeClose[ri2]; break; }
if (workstreams[tabIdsBeforeClose[ri2]]) {
preferredWsId = tabIdsBeforeClose[ri2];
break;
}
}
if (!preferredWsId) {
for (var li2 = idx - 1; li2 >= 0; li2--) {
if (workstreams[tabIdsBeforeClose[li2]]) { preferredWsId = tabIdsBeforeClose[li2]; break; }
if (workstreams[tabIdsBeforeClose[li2]]) {
preferredWsId = tabIdsBeforeClose[li2];
break;
}
}
}
}
@@ -2781,6 +2809,8 @@ function closeWorkstream(wsId) {
showDashboard();
}
updateWsActionButtons();
} else if (data.error) {
showToast(data.error, "warning");
}
});
}
@@ -2867,8 +2897,8 @@ function renderDashboardTable(wsList, agg) {
wsList.forEach(function (ws) {
var liveState =
(workstreams[ws.id] && workstreams[ws.id].state) || ws.state || "idle";
var liveName = ws.name ||
(workstreams[ws.id] && workstreams[ws.id].name) || ws.id;
var liveName =
(workstreams[ws.id] && workstreams[ws.id].name) || ws.name || ws.id;
var sd = STATE_DISPLAY[liveState] || STATE_DISPLAY.idle;
var row = document.createElement("div");
@@ -3001,10 +3031,14 @@ function renderSavedWorkstreams(items) {
}
items.forEach(function (sess) {
var card = document.createElement("div");
card.className = "dashboard-card" + (_wsDeleteMode ? " ws-delete-mode" : "");
card.className =
"dashboard-card" + (_wsDeleteMode ? " ws-delete-mode" : "");
card.dataset.wsId = sess.ws_id;
var label = sess.alias || sess.title || sess.ws_id;
card.setAttribute("aria-label", _wsDeleteMode ? "Select: " + label : "Resume: " + label);
card.setAttribute(
"aria-label",
_wsDeleteMode ? "Select: " + label : "Resume: " + label,
);
if (_wsDeleteMode) {
var chk = document.createElement("input");
@@ -3038,7 +3072,8 @@ function renderSavedWorkstreams(items) {
};
}
var title = sess.alias || sess.title || sess.name || sess.ws_id.substring(0, 12);
var title =
sess.alias || sess.title || sess.name || sess.ws_id.substring(0, 12);
var meta = sess.message_count + " msgs";
if (sess.updated) meta += " \u00b7 " + formatRelativeTime(sess.updated);
var inner = document.createElement("div");
@@ -3048,7 +3083,9 @@ function renderSavedWorkstreams(items) {
"</div>" +
'<div class="card-meta">' +
escapeHtml(meta) +
' <span class="card-wsid">' + escapeHtml(sess.ws_id.substring(0, 7)) + '</span>' +
' <span class="card-wsid">' +
escapeHtml(sess.ws_id.substring(0, 7)) +
"</span>" +
"</div>";
while (inner.firstChild) card.appendChild(inner.firstChild);
@@ -3093,22 +3130,29 @@ function updateWsDeleteBar() {
if (delBtn) delBtn.disabled = count === 0;
var selBtn = document.getElementById("ws-delete-bar-select-all");
if (selBtn) {
var allSelected = count === _wsSavedItems.length && _wsSavedItems.length > 0;
var allSelected =
count === _wsSavedItems.length && _wsSavedItems.length > 0;
selBtn.textContent = allSelected ? "Deselect All" : "Select All";
}
}
function toggleSelectAll() {
var allSelected = Object.keys(_wsDeleteSelected).length === _wsSavedItems.length && _wsSavedItems.length > 0;
var allSelected =
Object.keys(_wsDeleteSelected).length === _wsSavedItems.length &&
_wsSavedItems.length > 0;
if (allSelected) {
_wsDeleteSelected = {};
} else {
_wsSavedItems.forEach(function (s) { _wsDeleteSelected[s.ws_id] = true; });
_wsSavedItems.forEach(function (s) {
_wsDeleteSelected[s.ws_id] = true;
});
}
renderSavedWorkstreams(_wsSavedItems);
updateWsDeleteBar();
}
var _wsDeleteBatchTrap = null;
function confirmWsDeleteSelection() {
var selected = Object.keys(_wsDeleteSelected);
if (!selected.length) {
@@ -3120,21 +3164,63 @@ function confirmWsDeleteSelection() {
var listEl = document.getElementById("ws-delete-list");
var errorEl = document.getElementById("ws-delete-error");
errorEl.textContent = "";
countEl.textContent = selected.length + " workstream(s) will be permanently deleted:";
countEl.textContent =
selected.length + " workstream(s) will be permanently deleted:";
listEl.innerHTML = "";
selected.forEach(function (wsId) {
var item = _wsSavedItems.find(function (s) { return s.ws_id === wsId; });
var name = item ? (item.alias || item.title || wsId) : wsId;
var item = _wsSavedItems.find(function (s) {
return s.ws_id === wsId;
});
var name = item ? item.alias || item.title || wsId : wsId;
var div = document.createElement("div");
div.className = "ws-delete-item";
div.textContent = name;
listEl.appendChild(div);
});
// Reset confirm button handler (may have been overwritten to "Close" by previous run)
var delBtn = document.getElementById("ws-delete-confirm-btn");
if (delBtn) {
delBtn.textContent = "Delete";
delBtn.disabled = false;
delBtn.onclick = confirmWsDelete;
}
var cancelBtn = document.getElementById("ws-delete-cancel-btn");
if (cancelBtn) cancelBtn.disabled = false;
overlay.style.display = "flex";
// Focus trap + Escape
if (_wsDeleteBatchTrap)
document.removeEventListener("keydown", _wsDeleteBatchTrap);
_wsDeleteBatchTrap = function (e) {
if (e.key === "Escape") {
e.preventDefault();
cancelWsDelete();
return;
}
if (e.key === "Tab") {
var box = document.getElementById("ws-delete-box");
var focusable = box.querySelectorAll("button:not(:disabled)");
var first = focusable[0];
var last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
document.addEventListener("keydown", _wsDeleteBatchTrap);
if (cancelBtn) cancelBtn.focus();
}
function cancelWsDelete() {
document.getElementById("ws-delete-overlay").style.display = "none";
if (_wsDeleteBatchTrap) {
document.removeEventListener("keydown", _wsDeleteBatchTrap);
_wsDeleteBatchTrap = null;
}
}
function confirmWsDelete() {
@@ -3149,14 +3235,19 @@ function confirmWsDelete() {
errorEl.textContent = "";
// Disable buttons during deletion
if (delBtn) { delBtn.disabled = true; delBtn.textContent = "Deleting..."; }
if (delBtn) {
delBtn.disabled = true;
delBtn.textContent = "Deleting...";
}
if (cancelBtn) cancelBtn.disabled = true;
var results = [];
var promises = selected.map(function (wsId) {
var shortId = wsId.substring(0, 8);
var item = _wsSavedItems.find(function (s) { return s.ws_id === wsId; });
var name = item ? (item.alias || item.title || wsId) : wsId;
var item = _wsSavedItems.find(function (s) {
return s.ws_id === wsId;
});
var name = item ? item.alias || item.title || wsId : wsId;
var url = "/v1/api/workstreams/" + encodeURIComponent(wsId) + "/delete";
return authFetch(url, { method: "POST" })
@@ -3174,15 +3265,27 @@ function confirmWsDelete() {
try {
var j = JSON.parse(body);
if (j.error) errMsg = shortId + ": " + j.error;
} catch (_) { /* fall through */ }
} catch (_) {
/* fall through */
}
} else if (body) {
errMsg = shortId + ": " + body.substring(0, 200);
}
results.push({ name: name, shortId: shortId, ok: false, error: errMsg });
results.push({
name: name,
shortId: shortId,
ok: false,
error: errMsg,
});
});
})
.catch(function (err) {
results.push({ name: name, shortId: shortId, ok: false, error: shortId + ": " + err.message });
results.push({
name: name,
shortId: shortId,
ok: false,
error: shortId + ": " + err.message,
});
});
});
@@ -3192,15 +3295,30 @@ function confirmWsDelete() {
results.forEach(function (r) {
var div = document.createElement("div");
div.className = "ws-delete-item" + (r.ok ? "" : " ws-delete-error");
div.textContent = (r.ok ? "\u2713 " : "\u2717 ") + r.name + (r.error ? " — " + r.error : "");
div.textContent =
(r.ok ? "\u2713 " : "\u2717 ") +
r.name +
(r.error ? " — " + r.error : "");
listEl.appendChild(div);
});
var okCount = results.filter(function (r) { return r.ok; }).length;
var failCount = results.filter(function (r) { return !r.ok; }).length;
var okCount = results.filter(function (r) {
return r.ok;
}).length;
var failCount = results.filter(function (r) {
return !r.ok;
}).length;
countEl.textContent = okCount + " deleted, " + failCount + " failed";
if (delBtn) { delBtn.disabled = false; delBtn.textContent = "Close"; delBtn.onclick = function () { cancelWsDelete(); cancelWsDeleteMode(); loadDashboard(); }; }
if (delBtn) {
delBtn.disabled = false;
delBtn.textContent = "Close";
delBtn.onclick = function () {
cancelWsDelete();
cancelWsDeleteMode();
loadDashboard();
};
}
if (cancelBtn) cancelBtn.disabled = false;
});
}
@@ -3215,11 +3333,13 @@ function refreshWorkstreamTitle() {
_setTitleState(wsId, "refreshing");
var url = "/v1/api/workstreams/" + encodeURIComponent(wsId) + "/refresh-title";
var url =
"/v1/api/workstreams/" + encodeURIComponent(wsId) + "/refresh-title";
authFetch(url, { method: "POST" })
.then(function (r) {
if (!r.ok) throw new Error("Failed to refresh title (HTTP " + r.status + ")");
if (!r.ok)
throw new Error("Failed to refresh title (HTTP " + r.status + ")");
return r.json();
})
.then(function (data) {
@@ -3252,7 +3372,10 @@ function _applyTitleButtonState() {
} else if (state === "error") {
btn.innerHTML = "&#x2717;";
btn.disabled = false;
btn.onclick = function () { _setTitleState(wsId, "idle"); refreshWorkstreamTitle(); };
btn.onclick = function () {
_setTitleState(wsId, "idle");
refreshWorkstreamTitle();
};
return;
} else {
btn.innerHTML = "&#x21bb;";
@@ -3261,22 +3384,63 @@ function _applyTitleButtonState() {
}
}
var _editTitleTrap = null;
function editWorkstreamTitle() {
var wsId = getCurrentWsId();
if (!wsId) return;
var currentTitle = "";
var tabEl = document.querySelector('.ws-tab[data-ws-id="' + wsId + '"] .tab-name');
var tabEl = document.querySelector(
'.ws-tab[data-ws-id="' + wsId + '"] .tab-name',
);
if (tabEl) currentTitle = tabEl.textContent.trim();
var overlay = document.getElementById("edit-title-overlay");
var input = document.getElementById("edit-title-input");
input.value = currentTitle;
overlay.style.display = "flex";
setTimeout(function () { input.focus(); input.select(); }, 50);
overlay.onclick = function (e) {
if (e.target === overlay) cancelEditTitle();
};
// Focus trap + Escape
if (_editTitleTrap) document.removeEventListener("keydown", _editTitleTrap);
_editTitleTrap = function (e) {
if (e.key === "Escape") {
e.preventDefault();
cancelEditTitle();
return;
}
if (e.key === "Tab") {
var box = document.getElementById("edit-title-box");
var focusable = box.querySelectorAll("input, button");
var first = focusable[0];
var last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
document.addEventListener("keydown", _editTitleTrap);
setTimeout(function () {
input.focus();
input.select();
}, 50);
}
function cancelEditTitle() {
document.getElementById("edit-title-overlay").style.display = "none";
if (_editTitleTrap) {
document.removeEventListener("keydown", _editTitleTrap);
_editTitleTrap = null;
}
var btn = document.getElementById("edit-title-btn");
if (btn) btn.focus();
}
function submitEditTitle() {
@@ -3284,14 +3448,17 @@ function submitEditTitle() {
if (!wsId) return;
var input = document.getElementById("edit-title-input");
var newTitle = input.value.trim();
if (!newTitle) { showToast("Title cannot be empty", "warning"); return; }
if (!newTitle) {
showToast("Title cannot be empty", "warning");
return;
}
var url = "/v1/api/workstreams/" + encodeURIComponent(wsId) + "/title";
authFetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: newTitle })
body: JSON.stringify({ title: newTitle }),
})
.then(function (r) {
if (!r.ok) throw new Error("Failed to set title (HTTP " + r.status + ")");
@@ -3299,6 +3466,14 @@ function submitEditTitle() {
})
.then(function (data) {
cancelEditTitle();
// Optimistic update — SSE ws_rename will confirm
var nameEls = document.querySelectorAll(
'[data-ws-id="' + wsId + '"] .tab-name',
);
nameEls.forEach(function (el) {
el.textContent = newTitle;
});
if (workstreams[wsId]) workstreams[wsId].name = newTitle;
showToast("Title updated", "success");
})
.catch(function (err) {
@@ -3309,23 +3484,64 @@ function submitEditTitle() {
// --- Workstream deletion ---
var _pendingDeleteWsId = null;
var _deleteWsTrap = null;
function confirmDeleteWorkstream() {
var wsId = getCurrentWsId();
if (!wsId) return;
var tabEl = document.querySelector('.ws-tab[data-ws-id="' + wsId + '"] .tab-name');
var tabEl = document.querySelector(
'.ws-tab[data-ws-id="' + wsId + '"] .tab-name',
);
var name = tabEl ? tabEl.textContent.trim() : wsId.substring(0, 12);
_pendingDeleteWsId = wsId;
var overlay = document.getElementById("delete-ws-overlay");
var msg = document.getElementById("delete-ws-message");
msg.textContent = "Delete \"" + name + "\"? This cannot be undone.";
msg.textContent = 'Delete "' + name + '"? This cannot be undone.';
overlay.style.display = "flex";
// Focus trap + Escape
if (_deleteWsTrap) document.removeEventListener("keydown", _deleteWsTrap);
_deleteWsTrap = function (e) {
if (e.key === "Escape") {
e.preventDefault();
cancelDeleteWs();
return;
}
if (e.key === "Tab") {
var box = document.getElementById("delete-ws-box");
var focusable = box.querySelectorAll("button");
var first = focusable[0];
var last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
document.addEventListener("keydown", _deleteWsTrap);
var cancelBtn = overlay.querySelector("button");
if (cancelBtn) cancelBtn.focus();
}
function cancelDeleteWs() {
_pendingDeleteWsId = null;
document.getElementById("delete-ws-overlay").style.display = "none";
if (_deleteWsTrap) {
document.removeEventListener("keydown", _deleteWsTrap);
_deleteWsTrap = null;
}
var btn = document.getElementById("delete-ws-btn");
if (btn && btn.offsetParent !== null) {
btn.focus();
} else {
var fallback = document.getElementById("new-tab-btn");
if (fallback) fallback.focus();
}
}
function executeDeleteWs() {
@@ -3337,11 +3553,21 @@ function executeDeleteWs() {
authFetch(url, { method: "POST" })
.then(function (r) {
if (!r.ok) throw new Error("Failed to delete workstream (HTTP " + r.status + ")");
if (!r.ok)
throw new Error("Failed to delete workstream (HTTP " + r.status + ")");
return r.json();
})
.then(function () {
closeWorkstream(wsId);
// Update local state directly — don't call closeWorkstream which
// would send a redundant POST to /close for an already-deleted ws.
delete workstreams[wsId];
renderTabBar();
_reassignPanesForClosedWs(wsId, []);
if (!Object.keys(workstreams).length) {
loadDashboard();
showDashboard();
}
updateWsActionButtons();
showToast("Workstream deleted", "success");
})
.catch(function (err) {
@@ -3357,7 +3583,12 @@ function getCurrentWsId() {
function updateWsActionButtons() {
var hasActiveWs = !!getCurrentWsId();
var ids = ["refresh-title-btn", "edit-title-btn", "fork-ws-btn", "delete-ws-btn"];
var ids = [
"refresh-title-btn",
"edit-title-btn",
"fork-ws-btn",
"delete-ws-btn",
];
ids.forEach(function (id) {
var el = document.getElementById(id);
if (el) el.style.display = hasActiveWs ? "" : "none";
@@ -3524,15 +3755,15 @@ function connectGlobalSSE() {
workstreams[data.ws_id].name = data.name || data.ws_id.slice(0, 6);
workstreams[data.ws_id].state = "idle";
renderTabBar();
} else if (data.type === "ws_closed") {
var wsId = data.ws_id;
// Capture tab order from DOM (visual order) before deletion for close_tab_action=nearest_left/right
var sseTabIds = Array.from(
document.querySelectorAll("#tab-list .ws-tab"),
).map(function (tab) {
return tab.dataset.wsId;
});
// Disconnect per-ws SSE on affected panes immediately so stale
} else if (data.type === "ws_closed") {
var wsId = data.ws_id;
// Capture tab order from DOM (visual order) before deletion for close_tab_action=nearest_left/right
var sseTabIds = Array.from(
document.querySelectorAll("#tab-list .ws-tab"),
).map(function (tab) {
return tab.dataset.wsId;
});
// Disconnect per-ws SSE on affected panes immediately so stale
// events from the dying workstream don't leak into reassigned panes.
for (var cid in panes) {
if (panes[cid].wsId === wsId) panes[cid].disconnectSSE();
@@ -4485,13 +4716,17 @@ initWorkstreams();
function loadInterfaceSettings() {
authFetch("/v1/api/admin/settings")
.then(function (r) { return r.json(); })
.then(function (r) {
return r.json();
})
.then(function (data) {
var settings = data.settings || [];
for (var i = 0; i < settings.length; i++) {
var s = settings[i];
if (s.key && s.key.indexOf("interface.") === 0) {
try { localStorage.setItem("turnstone_" + s.key, s.value); } catch (_) {}
try {
localStorage.setItem("turnstone_" + s.key, s.value);
} catch (_) {}
}
}
var theme = localStorage.getItem("turnstone_interface.theme");
@@ -4500,11 +4735,13 @@ function loadInterfaceSettings() {
var effectiveTheme = theme === "light" ? "light" : "";
if (effectiveTheme !== currentTheme) {
document.documentElement.dataset.theme = effectiveTheme;
localStorage.setItem("turnstone-theme", theme);
var btn = document.getElementById("theme-toggle");
if (btn) {
btn.innerHTML = theme === "light" ? "&#9728;" : "&#9790;";
btn.title = theme === "light" ? "Switch to dark theme" : "Switch to light theme";
btn.textContent = theme === "light" ? "\u2600" : "\u263E";
btn.title =
theme === "light"
? "Switch to dark theme"
: "Switch to light theme";
}
reRenderAllMermaid();
}
+4 -4
View File
@@ -20,7 +20,7 @@
<button id="refresh-title-btn" class="header-btn ws-action-btn" onclick="refreshWorkstreamTitle()" aria-label="Regenerate title" title="Regenerate title" style="display:none">&#x21bb;</button>
<button id="edit-title-btn" class="header-btn ws-action-btn" onclick="editWorkstreamTitle()" aria-label="Edit title" title="Edit title" style="display:none">&#x270E;</button>
<button id="fork-ws-btn" class="header-btn ws-action-btn" onclick="forkWorkstream()" aria-label="Fork workstream" title="Fork workstream" style="display:none">&#x2442;</button>
<button id="delete-ws-btn" class="header-btn ws-action-btn ws-action-btn-danger" onclick="confirmDeleteWorkstream()" aria-label="Delete workstream" title="Delete workstream" style="display:none">&#x1f5d1;</button>
<button id="delete-ws-btn" class="header-btn ws-action-btn ws-action-btn-danger" onclick="confirmDeleteWorkstream()" aria-label="Delete workstream" title="Delete workstream" style="display:none"><span aria-hidden="true">&#x1f5d1;</span></button>
<span class="header-spacer"></span>
<button id="theme-toggle" class="header-btn" onclick="toggleTheme()" aria-label="Toggle light/dark theme" title="Switch to light theme">&#9790;</button>
</div>
@@ -59,11 +59,11 @@
<section class="dashboard-section" id="dashboard-saved-ws" aria-label="Saved workstreams">
<div class="dashboard-section-header">
<h2 class="dashboard-section-title">Saved Workstreams</h2>
<button id="ws-delete-btn" class="ws-delete-btn" onclick="startWsDeleteMode()" title="Delete workstreams">&#x1f5d1; Delete</button>
<button id="ws-delete-btn" class="ws-delete-btn" onclick="startWsDeleteMode()" title="Delete workstreams"><span aria-hidden="true">&#x1f5d1;</span> Delete</button>
</div>
<div class="dashboard-cards" id="dashboard-saved-cards"></div>
<div id="ws-delete-bar" class="ws-delete-bar">
<span class="ws-delete-count-label" id="ws-delete-bar-count">0 selected</span>
<span class="ws-delete-count-label" id="ws-delete-bar-count" role="status" aria-live="polite" aria-atomic="true">0 selected</span>
<button class="ws-delete-cancel-btn" onclick="cancelWsDeleteMode()">Cancel</button>
<button class="ws-delete-cancel-btn" id="ws-delete-bar-select-all" onclick="toggleSelectAll()">Select All</button>
<button class="ws-delete-bar-btn" id="ws-delete-bar-delete" onclick="confirmWsDeleteSelection()" disabled>Delete Selected</button>
@@ -84,7 +84,7 @@
<label for="new-ws-model">Model <span class="nws-hint">optional</span></label>
<select id="new-ws-model"><option value="">Default model</option></select>
<label for="new-ws-judge-model">Judge Model <span class="nws-hint">optional</span></label>
<select id="new-ws-judge-model"><option value="">Default model</option></select>
<select id="new-ws-judge-model"><option value="">Default — use workstream's agent model</option></select>
<label for="new-ws-skill">Skill <span class="nws-hint">optional</span></label>
<select id="new-ws-skill"><option value="">Use defaults</option></select>
<div id="new-ws-buttons">
+19 -3
View File
@@ -53,6 +53,9 @@
@media (max-width: 600px) {
.ws-tab .tab-close { opacity: 1; padding: 4px 6px; font-size: 16px; }
#split-btn { display: none; }
#refresh-title-btn { display: none; }
.ws-action-btn { padding: 4px 6px; margin: 0 1px; font-size: 13px; }
.tab-wsid { display: none; }
}
/* ==========================================================================
@@ -126,11 +129,14 @@
.tab-wsid {
font-size: 9px;
color: var(--fg-dim);
opacity: 0.45;
opacity: 0;
margin-left: 4px;
font-family: 'IBM Plex Mono', monospace;
letter-spacing: 0.02em;
transition: opacity 0.15s;
}
.ws-tab:hover .tab-wsid,
.ws-tab.active .tab-wsid { opacity: 0.45; }
/* Subtle ws_id badge in saved workstream cards */
.card-wsid {
@@ -214,7 +220,7 @@
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-input);
background: var(--bg);
color: var(--fg-bright);
font-size: 14px;
font-family: inherit;
@@ -1438,7 +1444,10 @@ audio.media-player {
}
.dashboard-card.ws-selected {
border-color: var(--red);
background: rgba(255, 60, 60, 0.06);
background: rgba(248, 113, 113, 0.08);
}
[data-theme="light"] .dashboard-card.ws-selected {
background: rgba(220, 38, 38, 0.08);
}
.ws-delete-bar {
display: none;
@@ -1462,7 +1471,9 @@ audio.media-player {
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: filter 0.15s;
}
.ws-delete-bar .ws-delete-bar-btn:hover:not(:disabled) { filter: brightness(1.1); }
.ws-delete-bar .ws-delete-bar-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.ws-delete-bar .ws-delete-cancel-btn {
background: transparent;
@@ -1473,6 +1484,11 @@ audio.media-player {
font-size: 12px;
cursor: pointer;
}
.ws-delete-bar .ws-delete-cancel-btn:hover {
color: var(--fg-bright);
border-color: var(--border-strong);
background: var(--bg-highlight);
}
/* Delete modal */
#ws-delete-overlay {