mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -130,9 +130,10 @@ _JS_PROXY_SHIM = """\
|
||||
|
||||
_CONSOLE_BANNER_TEMPLATE = (
|
||||
'<div class="console-banner">'
|
||||
'<a href="/" class="console-banner-link">← Console</a>'
|
||||
'<a href="/" class="console-banner-link" aria-label="Return to console">← 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)
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
...
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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 = "×";
|
||||
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 = "✗";
|
||||
btn.disabled = false;
|
||||
btn.onclick = function () { _setTitleState(wsId, "idle"); refreshWorkstreamTitle(); };
|
||||
btn.onclick = function () {
|
||||
_setTitleState(wsId, "idle");
|
||||
refreshWorkstreamTitle();
|
||||
};
|
||||
return;
|
||||
} else {
|
||||
btn.innerHTML = "↻";
|
||||
@@ -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" ? "☀" : "☾";
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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">↻</button>
|
||||
<button id="edit-title-btn" class="header-btn ws-action-btn" onclick="editWorkstreamTitle()" aria-label="Edit title" title="Edit title" style="display:none">✎</button>
|
||||
<button id="fork-ws-btn" class="header-btn ws-action-btn" onclick="forkWorkstream()" aria-label="Fork workstream" title="Fork workstream" style="display:none">⑂</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">🗑</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">🗑</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">☾</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">🗑 Delete</button>
|
||||
<button id="ws-delete-btn" class="ws-delete-btn" onclick="startWsDeleteMode()" title="Delete workstreams"><span aria-hidden="true">🗑</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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user