Compare commits

...

10 Commits

Author SHA1 Message Date
Patrick Buckley 701c3fc717 chore: bump version to 1.2.0a5 2026-04-06 15:52:05 -07:00
Patrick Buckley 92ad5bd439 Feat/tab action dropdown (#332)
* feat: replace workstream action buttons with per-tab dropdown menu

Move refresh-title, edit-title, fork, close, and delete actions from
the header toolbar into a dropdown menu on each workstream tab,
triggered by a ▾ chevron that replaces the × close button.

Dropdown follows the existing pane context menu pattern: keyboard
navigation, mutual exclusion, click-outside/Escape dismiss, toggle
on re-click, aria-expanded + aria-haspopup, and focus restoration.

Delete is visually distinct (red text + wash + red focus ring, 6px
separator). Mobile hides "Refresh title" and sizes the chevron to
36px touch targets.

Removes updateWsActionButtons(), _applyTitleButtonState(), and
_wsTitleState tracking (dead code after button removal).

* fix: remove Ctrl+Shift+R shortcut that overrides browser hard refresh

Refresh title is a low-frequency action accessible from the tab
dropdown; no replacement keybind needed.

* fix: address tab dropdown review findings

- Pass wsId through dropdown actions so they target the correct
  workstream even when opened on a non-active tab
- Fix setTimeout race where closeTabDropdown before timeout fires
  could leave stale listeners
- Guard Close and Delete on last workstream (dropdown, keyboard
  shortcuts, and defense-in-depth in confirmDeleteWorkstream)
- Use aria-disabled instead of disabled so screen reader users can
  discover unavailable items via arrow keys
- Enlarge chevron hit target, add hover affordance with subtle
  background highlight
- Add 0.1s dropdown open animation (respects prefers-reduced-motion)
2026-04-06 15:48:13 -07:00
Patrick Buckley 58c81b2b46 fix: resolve CodeQL double-import findings in test files (#331) 2026-04-06 14:18:02 -07:00
Patrick Buckley a2d4598012 fix: address CodeQL findings — BaseException and empty except (#330)
- server.py: catch (Exception, GenerationCancelled) instead of
  BaseException so KeyboardInterrupt/SystemExit propagate normally
- judge.py: log client close failures instead of bare pass
2026-04-06 13:53:26 -07:00
renovate[bot] 4f83dba1b9 chore(deps): lock file maintenance (#326)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-06 13:37:33 -07:00
dependabot[bot] 2629f217d2 chore(deps-dev): bump vite from 8.0.4 to 8.0.5 in /sdk/typescript (#329)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.4 to 8.0.5.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.5/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-06 13:14:32 -07:00
Patrick Buckley d1162b2eb9 fix: preserve Gemini thought_signature via provider_blocks fidelity lane (#328)
Gemini's OpenAI-compat endpoint requires thought_signature to survive
the tool-call round-trip. Previously dropped because the Chat Completions
provider cherry-picks only standard fields (id, type, function).

Fix: GoogleProvider now captures raw tool-call dicts (including
thought_signature) via provider_blocks — the same fidelity lane the
Anthropic provider uses for signature round-tripping. On the next turn,
_prepare_messages reconstructs tool_calls from the stored raw data and
strips _provider_content so it never reaches the wire.

Changes:
- _openai_chat.py: add _prepare_messages and _extract_tool_calls hooks
- _google.py: override hooks + tap-pattern _iter_stream for streaming
- model_registry.py: auto-detect .googleapis.com → google provider
- session.py: read cancel_on_approval from ConfigStore
- console/server.py: add PUT/DELETE to proxy route methods
- server.py: fix fork naming (don't inherit source display name)
2026-04-06 13:13:38 -07:00
Patrick Buckley 217688547e fix: expose channel gateway port for bare-metal deploys
The channel gateway registers with its Docker-internal hostname
(e.g. http://channel:8091) which is unreachable from a host-side
server. Publish port 8091 and set TURNSTONE_CHANNEL_ADVERTISE_URL
to localhost so the server can reach it for schedule notifications.
2026-04-06 10:47:50 -07:00
Patrick Buckley 5dc98f75fb fix: scheduled task notifications not delivered on cancellation
GenerationCancelled extends BaseException, not Exception, so it bypassed
the except handler in _run_initial. The finally block ran but
_extract_last_assistant_content returned "" (response never appended to
messages), and _fire_notify_targets bailed on the empty content guard.

Fixes:
- Catch BaseException (not just Exception) in _run_initial so
  GenerationCancelled is handled and the UI state is cleaned up
- Remove the empty-content suppression in _fire_notify_targets —
  scheduled tasks should always deliver, even with a fallback message
  when no output was captured
2026-04-06 09:54:03 -07:00
renovate[bot] 6980ba5aae chore(deps): lock file maintenance (#325)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-06 04:39:13 -07:00
19 changed files with 728 additions and 186 deletions
+40
View File
@@ -0,0 +1,40 @@
# Bare-metal overlay — expose PostgreSQL and let the console reach
# a turnstone-server running outside Docker on the host machine.
#
# Requires TURNSTONE_HOST_IP set to the host's routable IP address.
#
# Usage:
# export TURNSTONE_HOST_IP="$(hostname -I | awk '{print $1}')"
# docker compose --profile production \
# -f compose.yaml -f deploy/docker-compose.bare-metal.yml up
#
# Then on the host:
# export TURNSTONE_JWT_SECRET="<same as .env>"
# export TURNSTONE_DB_BACKEND=postgresql
# export TURNSTONE_DB_URL="postgresql://turnstone:<pw>@localhost:5432/turnstone"
# export TURNSTONE_NODE_ID="bare-metal-1"
# export TURNSTONE_ADVERTISE_URL="http://${TURNSTONE_HOST_IP}:8080"
# python -m turnstone.server --host 0.0.0.0 --port 8080 \
# --base-url http://localhost:8000/v1 --api-key "$OPENAI_API_KEY"
services:
postgres:
ports:
- "${POSTGRES_PORT:-5432}:5432"
console:
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
# Console needs to reach the bare-metal server on the host
TURNSTONE_SERVER_URL: "http://${TURNSTONE_HOST_IP}:${SERVER_PORT:-8080}"
channel:
ports:
- "${CHANNEL_PORT:-8091}:8091"
environment:
# Channel gateway advertises with host-routable IP so the
# bare-metal server can reach it for schedule notifications
TURNSTONE_CHANNEL_ADVERTISE_URL: "http://${TURNSTONE_HOST_IP}:${CHANNEL_PORT:-8091}"
# Channel needs to reach the bare-metal server on the host
TURNSTONE_SERVER_URL: "http://${TURNSTONE_HOST_IP}:${SERVER_PORT:-8080}"
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "turnstone"
version = "1.2.0a4"
version = "1.2.0a5"
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
readme = "README.md"
license = "BUSL-1.1"
+3 -33
View File
@@ -179,9 +179,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -199,9 +196,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -219,9 +213,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -239,9 +230,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -259,9 +247,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -279,9 +264,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -762,9 +744,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -786,9 +765,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -810,9 +786,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -834,9 +807,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1120,9 +1090,9 @@
}
},
"node_modules/vite": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.4.tgz",
"integrity": "sha512-baBr4jUVSLJ0RPyZ2nK0zS2+W8hNHbM4hEzfvllukmRPVS3xDG5ATTNtbRXrKIOE2b8/FsPWJAOnuIxcs7g3cw==",
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.5.tgz",
"integrity": "sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
+3 -6
View File
@@ -14,6 +14,7 @@ from turnstone.core.auth import (
check_request,
create_jwt,
is_public_path,
load_jwt_secret,
make_clear_cookie,
make_set_cookie,
required_scope,
@@ -1420,13 +1421,11 @@ class TestIsSecureRequest:
class TestSecretStrength:
def test_short_secret_exits(self):
import turnstone.core.auth as auth_mod
old = os.environ.get("TURNSTONE_JWT_SECRET", "")
os.environ["TURNSTONE_JWT_SECRET"] = "short"
try:
with pytest.raises(SystemExit):
auth_mod.load_jwt_secret()
load_jwt_secret()
finally:
if old:
os.environ["TURNSTONE_JWT_SECRET"] = old
@@ -1434,14 +1433,12 @@ class TestSecretStrength:
os.environ.pop("TURNSTONE_JWT_SECRET", None)
def test_missing_secret_exits(self):
import turnstone.core.auth as auth_mod
with (
patch("turnstone.core.config.load_config", return_value={}),
patch.dict(os.environ, {}, clear=True),
pytest.raises(SystemExit),
):
auth_mod.load_jwt_secret()
load_jwt_secret()
class TestCorsConfigurable:
+4 -1
View File
@@ -3,7 +3,10 @@
import argparse
import turnstone.core.config as config_mod
from turnstone.core.config import apply_config, load_config, set_config_path
apply_config = config_mod.apply_config
load_config = config_mod.load_config
set_config_path = config_mod.set_config_path
def _reset_cache():
+5 -2
View File
@@ -349,11 +349,14 @@ class TestFireNotifyTargets:
mock_deliver.assert_not_called()
@patch("turnstone.server._deliver_notification")
def test_empty_content_skipped(self, mock_deliver):
def test_empty_content_delivers_fallback(self, mock_deliver):
"""Empty content should still deliver with a fallback message."""
ws = MagicMock()
ws.notify_targets = '[{"channel_type":"discord","channel_id":"1"}]'
_fire_notify_targets(ws, "")
mock_deliver.assert_not_called()
mock_deliver.assert_called_once()
payload = mock_deliver.call_args[0][1]
assert "no output captured" in payload["message"]
@patch("turnstone.server._deliver_notification")
def test_invalid_json_targets_skipped(self, mock_deliver):
+209
View File
@@ -1140,6 +1140,215 @@ class TestProviderFactory:
assert lookup_model_capabilities("google", "gemini-2.5-pro") is None
def test_resolve_openai_provider_googleapis(self) -> None:
from turnstone.core.model_registry import _resolve_openai_provider
assert (
_resolve_openai_provider(
"openai",
"https://generativelanguage.googleapis.com/v1beta/openai/",
)
== "google"
)
def test_resolve_openai_provider_not_spoofable(self) -> None:
from turnstone.core.model_registry import _resolve_openai_provider
# evil-googleapis.com must NOT match — requires the dot prefix
assert (
_resolve_openai_provider("openai", "https://evil-googleapis.com/v1")
== "openai-compatible"
)
def test_resolve_openai_provider_api_openai_unchanged(self) -> None:
from turnstone.core.model_registry import _resolve_openai_provider
assert _resolve_openai_provider("openai", "https://api.openai.com/v1") == "openai"
# ===========================================================================
# Google provider fidelity
# ===========================================================================
class TestGoogleProviderFidelity:
"""Tests for thought_signature round-trip via provider_blocks."""
def test_prepare_messages_strips_provider_content(self) -> None:
from turnstone.core.providers._google import GoogleProvider
prov = GoogleProvider()
msgs = [
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "c1", "type": "function", "function": {"name": "f", "arguments": "{}"}},
],
"_provider_content": [
{
"id": "c1",
"type": "function",
"function": {"name": "f", "arguments": "{}"},
"thought_signature": "sig123",
},
],
},
{"role": "tool", "tool_call_id": "c1", "content": "ok"},
]
cleaned = prov._prepare_messages(msgs)
# _provider_content must be stripped
for m in cleaned:
assert "_provider_content" not in m
# tool_calls must be reconstructed with thought_signature
tc = cleaned[0]["tool_calls"][0]
assert tc["thought_signature"] == "sig123"
def test_prepare_messages_passthrough_without_provider_content(self) -> None:
from turnstone.core.providers._google import GoogleProvider
prov = GoogleProvider()
msgs = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"},
]
cleaned = prov._prepare_messages(msgs)
assert len(cleaned) == 2
assert cleaned[0]["content"] == "hello"
def test_non_streaming_captures_provider_blocks(self) -> None:
from turnstone.core.providers._google import GoogleProvider
prov = GoogleProvider()
# Build a mock response with thought_signature in __pydantic_extra__
mock_tc = MagicMock()
mock_tc.id = "c1"
mock_tc.function.name = "write_file"
mock_tc.function.arguments = '{"path":"test.txt"}'
mock_tc.model_dump.return_value = {
"id": "c1",
"type": "function",
"function": {"name": "write_file", "arguments": '{"path":"test.txt"}'},
"thought_signature": "sig_abc",
}
mock_msg = MagicMock()
mock_msg.tool_calls = [mock_tc]
mock_msg.content = ""
mock_msg.annotations = None
mock_choice = MagicMock()
mock_choice.message = mock_msg
mock_choice.finish_reason = "tool_calls"
mock_response = MagicMock()
mock_response.choices = [mock_choice]
mock_response.usage = None
mock_client = MagicMock()
mock_client.chat.completions.create.return_value = mock_response
result = prov.create_completion(
client=mock_client,
model="gemini-2.5-pro",
messages=[{"role": "user", "content": "test"}],
)
# Normalised tool_calls should NOT have thought_signature
assert result.tool_calls is not None
assert "thought_signature" not in result.tool_calls[0]
# provider_blocks should have the raw dict WITH thought_signature
assert len(result.provider_blocks) == 1
assert result.provider_blocks[0]["thought_signature"] == "sig_abc"
def test_prepare_messages_base_class_unchanged(self) -> None:
"""Base class _prepare_messages just calls sanitize_messages."""
from turnstone.core.providers._openai_chat import OpenAIChatCompletionsProvider
prov = OpenAIChatCompletionsProvider()
msgs = [
{"role": "assistant", "content": None}, # should get content=""
{"role": "user", "content": "hi"},
]
cleaned = prov._prepare_messages(msgs)
assert cleaned[0]["content"] == ""
def test_streaming_captures_thought_signature(self) -> None:
"""Streaming _iter_stream taps raw deltas and emits provider_blocks."""
from turnstone.core.providers._google import GoogleProvider
prov = GoogleProvider()
# Build a minimal mock stream with 2 chunks:
# chunk 1: tool call header with thought_signature
# chunk 2: finish reason
mock_fn = MagicMock()
mock_fn.name = "write_file"
mock_fn.arguments = '{"path":"test.txt"}'
mock_tc_delta = MagicMock()
mock_tc_delta.index = 0
mock_tc_delta.id = "call_abc"
mock_tc_delta.function = mock_fn
mock_tc_delta.__pydantic_extra__ = {"thought_signature": "sig_stream"}
mock_delta1 = MagicMock()
mock_delta1.content = None
mock_delta1.tool_calls = [mock_tc_delta]
mock_delta1.annotations = None
# reasoning fields
mock_delta1.reasoning = None
mock_delta1.reasoning_content = None
mock_choice1 = MagicMock()
mock_choice1.finish_reason = None
mock_choice1.delta = mock_delta1
mock_chunk1 = MagicMock()
mock_chunk1.choices = [mock_choice1]
mock_chunk1.usage = None
# Finish chunk
mock_delta2 = MagicMock()
mock_delta2.content = None
mock_delta2.tool_calls = None
mock_delta2.annotations = None
mock_delta2.reasoning = None
mock_delta2.reasoning_content = None
mock_choice2 = MagicMock()
mock_choice2.finish_reason = "tool_calls"
mock_choice2.delta = mock_delta2
mock_chunk2 = MagicMock()
mock_chunk2.choices = [mock_choice2]
mock_chunk2.usage = None
chunks = list(prov._iter_stream([mock_chunk1, mock_chunk2]))
# Find the chunk with finish_reason
finish_chunks = [c for c in chunks if c.finish_reason]
assert len(finish_chunks) == 1
fc = finish_chunks[0]
assert len(fc.provider_blocks) == 1
assert fc.provider_blocks[0]["thought_signature"] == "sig_stream"
assert fc.provider_blocks[0]["id"] == "call_abc"
assert fc.provider_blocks[0]["function"]["name"] == "write_file"
def test_base_extract_tool_calls_returns_empty_provider_blocks(self) -> None:
"""Base class _extract_tool_calls returns empty provider_blocks."""
from turnstone.core.providers._openai_chat import OpenAIChatCompletionsProvider
prov = OpenAIChatCompletionsProvider()
mock_tc = MagicMock()
mock_tc.id = "c1"
mock_tc.function.name = "test"
mock_tc.function.arguments = "{}"
tool_calls, provider_blocks = prov._extract_tool_calls([mock_tc])
assert len(tool_calls) == 1
assert provider_blocks == []
# ===========================================================================
# TestDataclasses
+1 -1
View File
@@ -1,3 +1,3 @@
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
__version__ = "1.2.0a4"
__version__ = "1.2.0a5"
+12 -4
View File
@@ -1073,7 +1073,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 in ("POST", "PUT"):
if request.method in ("POST", "PUT", "DELETE"):
return await _proxy_post(request, server_url, path, api_prefix=api_prefix)
return await _proxy_get(request, server_url, f"{api_prefix}/{path}")
@@ -1110,7 +1110,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/PUT request to the target server."""
"""Forward a non-GET request (POST/PUT/DELETE) 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")
@@ -7738,8 +7738,16 @@ def create_app(
Route("/node/{node_id}/", proxy_index),
Route("/node/{node_id}/static/{path:path}", proxy_static),
Route("/node/{node_id}/shared/{path:path}", proxy_shared_static),
Route("/node/{node_id}/v1/api/{path:path}", proxy_api, methods=["GET", "POST"]),
Route("/node/{node_id}/api/{path:path}", proxy_api, methods=["GET", "POST"]),
Route(
"/node/{node_id}/v1/api/{path:path}",
proxy_api,
methods=["GET", "POST", "PUT", "DELETE"],
),
Route(
"/node/{node_id}/api/{path:path}",
proxy_api,
methods=["GET", "POST", "PUT", "DELETE"],
),
Route("/node/{node_id}/{path:path}", proxy_non_api),
],
middleware=_build_console_middleware(cors_origins),
+1 -1
View File
@@ -1109,7 +1109,7 @@ class IntentJudge:
if hasattr(client, "close"):
client.close()
except Exception:
pass
log.debug("judge.client_close_failed", exc_info=True)
def _deliver_fallbacks(
self,
+8
View File
@@ -207,6 +207,14 @@ def _resolve_openai_provider(provider: str, base_url: str) -> str:
and should use the Chat Completions provider (``"openai-compatible"``).
"""
if provider == "openai" and base_url and "api.openai.com" not in base_url:
try:
from urllib.parse import urlparse
hostname = urlparse(base_url).hostname or ""
except Exception:
hostname = ""
if hostname.endswith(".googleapis.com"):
return "google"
return "openai-compatible"
return provider
+113 -2
View File
@@ -8,12 +8,24 @@ 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.
Gemini requires provider-specific fields (e.g. ``thought_signature``)
to survive the tool-call tool-result round-trip. This adapter captures
the raw SDK tool-call objects via ``provider_blocks`` and reconstructs
them in ``_prepare_messages`` the same fidelity pattern used by the
Anthropic provider.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Iterator
from turnstone.core.providers._openai_chat import OpenAIChatCompletionsProvider
from turnstone.core.providers._protocol import ModelCapabilities
from turnstone.core.providers._openai_common import sanitize_messages
from turnstone.core.providers._protocol import ModelCapabilities, StreamChunk
# Default endpoint used when no base_url is configured.
GOOGLE_DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/"
@@ -35,7 +47,12 @@ _GOOGLE_DEFAULT = ModelCapabilities(
class GoogleProvider(OpenAIChatCompletionsProvider):
"""Provider for Google models using the OpenAI-compatible endpoint."""
"""Provider for Google models using the OpenAI-compatible endpoint.
Overrides message preparation and tool-call extraction to preserve
Gemini-specific fields (``thought_signature``) through the round-trip
via the ``provider_blocks`` / ``_provider_content`` fidelity lane.
"""
@property
def provider_name(self) -> str:
@@ -47,3 +64,97 @@ class GoogleProvider(OpenAIChatCompletionsProvider):
# (caps is default) to correctly return None for Google,
# signalling "no static per-model entry".
return _GOOGLE_DEFAULT
# -- message preparation (round-trip fidelity) ---------------------------
def _prepare_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Reconstruct tool_calls from ``_provider_content`` before sending.
When ``_provider_content`` is present on an assistant message, it
contains the raw tool-call dicts (including ``thought_signature``).
We replace the normalised ``tool_calls`` with the raw versions and
strip ``_provider_content`` so it never reaches the wire.
"""
cleaned: list[dict[str, Any]] = []
for msg in messages:
pc = msg.get("_provider_content")
if msg.get("role") == "assistant" and pc and isinstance(pc, list):
# Rebuild the message without _provider_content
msg = {k: v for k, v in msg.items() if k != "_provider_content"}
# Extract raw tool-call dicts from provider_blocks.
# Only type=="function" is expected today; if Gemini adds
# other tool types (e.g. code_execution) they will need
# their own round-trip handling here.
raw_tcs = [b for b in pc if b.get("type") == "function"]
if raw_tcs:
msg["tool_calls"] = raw_tcs
cleaned.append(msg)
return sanitize_messages(cleaned)
# -- tool-call extraction (non-streaming fidelity) -------------------------
def _extract_tool_calls(
self, sdk_tool_calls: list[Any]
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Capture raw tool-call dicts alongside the normalised ones.
``model_dump()`` includes ``thought_signature`` and any other
provider-specific fields. The raw dicts are returned as
``provider_blocks`` so the session stores them in
``_provider_content`` for round-trip fidelity.
"""
tool_calls, _ = super()._extract_tool_calls(sdk_tool_calls)
# model_dump() on the Pydantic SDK objects captures thought_signature
# and any other provider-specific fields alongside the standard ones.
provider_blocks = [tc.model_dump(exclude_none=True) for tc in sdk_tool_calls]
return tool_calls, provider_blocks
# -- streaming -----------------------------------------------------------
def _iter_stream(self, stream: Any) -> Iterator[StreamChunk]:
"""Wrap the base stream to capture raw tool-call metadata.
Taps the raw SDK stream to accumulate provider-specific fields
(e.g. ``thought_signature``) from each tool-call delta, then
delegates all chunk processing to the base class. The accumulated
raw tool-call dicts are emitted as ``provider_blocks`` on the
final chunk so the session stores them as ``_provider_content``.
"""
raw_tool_calls: dict[int, dict[str, Any]] = {}
def _tap(raw_stream: Any) -> Any:
"""Pass-through iterator that captures tool-call extras."""
for chunk in raw_stream:
if chunk.choices:
delta = chunk.choices[0].delta
if delta.tool_calls:
for tc_delta in delta.tool_calls:
idx = tc_delta.index
if idx not in raw_tool_calls:
raw_tool_calls[idx] = {
"id": "",
"type": "function",
"function": {"name": "", "arguments": ""},
}
raw_tc = raw_tool_calls[idx]
if tc_delta.id:
raw_tc["id"] = tc_delta.id
if tc_delta.function:
if tc_delta.function.name:
raw_tc["function"]["name"] = tc_delta.function.name
if tc_delta.function.arguments:
raw_tc["function"]["arguments"] += tc_delta.function.arguments
# Capture provider-specific extras (e.g. thought_signature)
extras = getattr(tc_delta, "__pydantic_extra__", None)
if extras:
for k, v in extras.items():
if k not in ("index", "id", "type", "function"):
raw_tc.setdefault(k, v)
yield chunk
# Delegate all chunk processing to the base class
for sc in super()._iter_stream(_tap(stream)):
# Attach provider_blocks on the finish-reason chunk
if sc.finish_reason and raw_tool_calls:
sc.provider_blocks = [raw_tool_calls[i] for i in sorted(raw_tool_calls)]
yield sc
+42 -13
View File
@@ -46,6 +46,43 @@ class OpenAIChatCompletionsProvider:
def get_capabilities(self, model: str) -> ModelCapabilities:
return lookup_openai_capabilities(model)
# -- message preparation --------------------------------------------------
def _prepare_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Prepare messages for the API request.
Subclasses (e.g. GoogleProvider) override this to reconstruct
provider-specific content from ``_provider_content`` before
sending. The base implementation just calls ``sanitize_messages``.
"""
return sanitize_messages(messages)
# -- tool-call extraction -------------------------------------------------
def _extract_tool_calls(
self, sdk_tool_calls: list[Any]
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Extract normalised tool-call dicts from SDK objects.
Returns ``(tool_calls, provider_blocks)``. The base implementation
returns an empty ``provider_blocks`` list. Subclasses (e.g.
``GoogleProvider``) override this to capture provider-specific
fields (like ``thought_signature``) in ``provider_blocks`` for
round-trip fidelity.
"""
tool_calls = [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments,
},
}
for tc in sdk_tool_calls
]
return tool_calls, []
# -- web search ----------------------------------------------------------
@staticmethod
@@ -88,7 +125,7 @@ class OpenAIChatCompletionsProvider:
cancel_ref: list[Any] | None = None,
) -> Iterator[StreamChunk]:
caps = self.get_capabilities(model)
messages = sanitize_messages(messages)
messages = self._prepare_messages(messages)
kwargs: dict[str, Any] = {
"model": model,
"messages": messages,
@@ -215,7 +252,7 @@ class OpenAIChatCompletionsProvider:
deferred_names: frozenset[str] | None = None,
) -> CompletionResult:
caps = self.get_capabilities(model)
messages = sanitize_messages(messages)
messages = self._prepare_messages(messages)
kwargs: dict[str, Any] = {
"model": model,
"messages": messages,
@@ -244,18 +281,9 @@ class OpenAIChatCompletionsProvider:
msg = choice.message
tool_calls = None
provider_blocks: list[dict[str, Any]] = []
if msg.tool_calls:
tool_calls = [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments,
},
}
for tc in msg.tool_calls
]
tool_calls, provider_blocks = self._extract_tool_calls(msg.tool_calls)
# Extract url_citation annotations from web search models
content = msg.content or ""
@@ -270,6 +298,7 @@ class OpenAIChatCompletionsProvider:
tool_calls=tool_calls,
finish_reason=choice.finish_reason or "stop",
usage=usage,
provider_blocks=provider_blocks,
)
log.debug(
"openai.chat.response",
+1
View File
@@ -487,6 +487,7 @@ class ChatSession:
read_only_tools=cs.get("judge.read_only_tools"),
output_guard=cs.get("judge.output_guard"),
redact_secrets=cs.get("judge.redact_secrets"),
cancel_on_approval=cs.get("judge.cancel_on_approval"),
)
def _get_web_search_backend(self) -> str:
+7 -6
View File
@@ -1735,8 +1735,10 @@ def _extract_last_assistant_content(session: Any) -> str:
def _fire_notify_targets(ws: Any, content: str) -> None:
"""Send completion notifications to all configured targets."""
if not content or not ws.notify_targets:
if not ws.notify_targets:
return
if not content:
content = "(Task completed — no output captured)"
try:
targets = json.loads(ws.notify_targets)
@@ -1928,22 +1930,21 @@ async def create_workstream(request: Request) -> JSONResponse:
resumed = False
message_count = 0
if resume_ws_id and ws.session is not None:
from turnstone.core.memory import get_workstream_display_name, resolve_workstream
from turnstone.core.memory import resolve_workstream
target_id = resolve_workstream(resume_ws_id)
if target_id and ws.session.resume(target_id, fork=True):
resumed = True
message_count = len(ws.session.messages)
# If the user provided a custom name, set it as the fork's alias
# so it takes priority in display. Otherwise inherit the source name.
# so it takes priority in display. Otherwise keep the
# auto-generated name so auto-title can run fresh.
user_name = body.get("name", "").strip()
if user_name:
from turnstone.core.memory import set_workstream_alias
set_workstream_alias(ws.id, user_name)
ws.name = user_name
else:
ws.name = get_workstream_display_name(target_id) or ws.name
ui = ws.ui
if isinstance(ui, WebUI):
ui._enqueue({"type": "clear_ui"})
@@ -2032,7 +2033,7 @@ async def create_workstream(request: Request) -> JSONResponse:
def _run_initial() -> None:
try:
session.send(initial_message)
except Exception:
except (Exception, GenerationCancelled):
if isinstance(ws.ui, WebUI):
ws.ui.on_stream_end()
ws.ui.on_state_change("idle")
+216 -79
View File
@@ -1976,7 +1976,12 @@ var _ctxMenu = null;
var _ctxCloseHandler = null;
var _ctxTriggerElement = null;
var _tabDropdown = null;
var _tabDropdownCloseHandler = null;
var _tabDropdownTrigger = null;
function showPaneContextMenu(x, y, paneId) {
closeTabDropdown();
closePaneContextMenu();
_ctxTriggerElement = document.activeElement;
@@ -2123,6 +2128,178 @@ function closePaneContextMenu() {
}
}
// ---------------------------------------------------------------------------
// 3c. Tab dropdown menu (per-tab workstream actions)
// ---------------------------------------------------------------------------
function showTabDropdown(chevronEl, wsId) {
closePaneContextMenu();
closeTabDropdown();
_tabDropdownTrigger = chevronEl;
chevronEl.setAttribute("aria-expanded", "true");
var menu = document.createElement("div");
menu.className = "ws-tab-dropdown";
menu.setAttribute("role", "menu");
menu.setAttribute("aria-label", "Workstream actions");
menu.addEventListener("contextmenu", function (e) {
e.preventDefault();
});
var isLastWs = Object.keys(workstreams).length <= 1;
var items = [
{
label: "Refresh title",
cls: "mobile-hide",
action: function () {
refreshWorkstreamTitle(wsId);
},
},
{
label: "Edit title",
key: "Ctrl+Shift+E",
action: function () {
editWorkstreamTitle(wsId);
},
},
{
label: "Fork",
key: "Ctrl+Shift+F",
action: function () {
forkWorkstream(wsId);
},
},
{
label: "Close",
key: "Ctrl+W",
disabled: isLastWs,
action: function () {
closeWorkstream(wsId);
},
},
{ separator: true },
{
label: "Delete",
key: "Ctrl+Shift+X",
cls: "destructive",
disabled: isLastWs,
action: function () {
confirmDeleteWorkstream(wsId);
},
},
];
items.forEach(function (item) {
if (item.separator) {
var sep = document.createElement("div");
sep.className = "ws-tab-dropdown-sep";
sep.setAttribute("role", "separator");
menu.appendChild(sep);
return;
}
var btn = document.createElement("button");
btn.className = "ws-tab-dropdown-item" + (item.cls ? " " + item.cls : "");
btn.setAttribute("role", "menuitem");
btn.setAttribute("tabindex", "-1");
if (item.disabled) {
btn.setAttribute("aria-disabled", "true");
btn.setAttribute(
"title",
"Cannot " + item.label.toLowerCase() + " the last workstream",
);
}
var labelSpan = document.createElement("span");
labelSpan.className = "ws-tab-dropdown-label";
labelSpan.textContent = item.label;
btn.appendChild(labelSpan);
if (item.key) {
var keySpan = document.createElement("span");
keySpan.className = "ws-tab-dropdown-key";
keySpan.textContent = item.key;
keySpan.setAttribute("aria-hidden", "true");
btn.appendChild(keySpan);
}
btn.onclick = function () {
if (this.getAttribute("aria-disabled") === "true") return;
closeTabDropdown();
item.action();
};
menu.appendChild(btn);
});
document.body.appendChild(menu);
// Position below chevron, right-aligned
var cr = chevronEl.getBoundingClientRect();
var mr = menu.getBoundingClientRect();
var mx = cr.right - mr.width;
var my = cr.bottom + 2;
if (mx < 0) mx = 4;
if (my + mr.height > window.innerHeight) my = cr.top - mr.height - 2;
if (mx + mr.width > window.innerWidth) mx = window.innerWidth - mr.width - 4;
menu.style.left = mx + "px";
menu.style.top = my + "px";
_tabDropdown = menu;
_tabDropdownCloseHandler = function (e) {
if (e.type === "keydown") {
if (e.key === "Escape" || e.key === "Tab") {
e.preventDefault();
closeTabDropdown();
} else if (
e.key === "ArrowDown" ||
e.key === "ArrowUp" ||
e.key === "Home" ||
e.key === "End"
) {
e.preventDefault();
var btns = Array.from(menu.querySelectorAll(".ws-tab-dropdown-item"));
if (!btns.length) return;
var idx = btns.indexOf(document.activeElement);
if (e.key === "ArrowDown") btns[(idx + 1) % btns.length].focus();
else if (e.key === "ArrowUp")
btns[(idx - 1 + btns.length) % btns.length].focus();
else if (e.key === "Home") btns[0].focus();
else if (e.key === "End") btns[btns.length - 1].focus();
}
} else if (
e.type === "mousedown" &&
!menu.contains(e.target) &&
e.target !== chevronEl
) {
closeTabDropdown();
}
};
var closeHandler = _tabDropdownCloseHandler;
var activeMenu = menu;
setTimeout(function () {
if (_tabDropdown !== activeMenu || !closeHandler) return;
document.addEventListener("mousedown", closeHandler);
document.addEventListener("keydown", closeHandler);
var first = activeMenu.querySelector(".ws-tab-dropdown-item");
if (first) first.focus();
}, 0);
}
function closeTabDropdown() {
if (_tabDropdown) {
_tabDropdown.remove();
_tabDropdown = null;
}
if (_tabDropdownCloseHandler) {
document.removeEventListener("mousedown", _tabDropdownCloseHandler);
document.removeEventListener("keydown", _tabDropdownCloseHandler);
_tabDropdownCloseHandler = null;
}
if (_tabDropdownTrigger) {
_tabDropdownTrigger.setAttribute("aria-expanded", "false");
if (document.contains(_tabDropdownTrigger)) {
_tabDropdownTrigger.focus();
}
_tabDropdownTrigger = null;
}
}
// ===========================================================================
// 4. Global state
// ===========================================================================
@@ -2278,6 +2455,7 @@ var tabList = document.getElementById("tab-list");
var newTabBtn = document.getElementById("new-tab-btn");
function renderTabBar() {
closeTabDropdown();
tabList.querySelectorAll(".ws-tab").forEach(function (t) {
t.remove();
});
@@ -2292,7 +2470,7 @@ function renderTabBar() {
tab.setAttribute("tabindex", "0");
tab.setAttribute("aria-selected", wsId === currentWsId ? "true" : "false");
tab.onclick = function (e) {
if (e.target.classList.contains("tab-close")) return;
if (e.target.classList.contains("tab-chevron")) return;
switchTab(wsId);
};
tab.onkeydown = function (e) {
@@ -2318,23 +2496,28 @@ function renderTabBar() {
wsidBadge.textContent = wsId.substring(0, 7);
tab.appendChild(wsidBadge);
var close = document.createElement("button");
close.className = "tab-close";
close.innerHTML = "&times;";
close.title = "Close workstream";
close.setAttribute(
var chevron = document.createElement("button");
chevron.className = "tab-chevron";
chevron.textContent = "\u25BE";
chevron.title = "Workstream actions";
chevron.setAttribute(
"aria-label",
"Close " + (ws.name || wsId.substring(0, 6)),
"Actions for " + (ws.name || wsId.substring(0, 6)),
);
close.onclick = function (e) {
chevron.setAttribute("aria-haspopup", "menu");
chevron.setAttribute("aria-expanded", "false");
chevron.onclick = function (e) {
e.stopPropagation();
closeWorkstream(wsId);
if (_tabDropdown && _tabDropdownTrigger === chevron) {
closeTabDropdown();
} else {
showTabDropdown(chevron, wsId);
}
};
tab.appendChild(close);
tab.appendChild(chevron);
tabList.appendChild(tab);
});
updateWsActionButtons();
}
function updateTabIndicator(wsId, state, extra) {
@@ -2387,6 +2570,7 @@ function updateTabIndicator(wsId, state, extra) {
}
function switchTab(wsId) {
closeTabDropdown();
var pane = getFocusedPane();
if (!pane) return;
if (wsId === pane.wsId && !dashboardVisible) return;
@@ -2416,8 +2600,6 @@ function switchTab(wsId) {
pane.updateWsName();
renderTabBar();
pane.connectSSE(wsId);
updateWsActionButtons();
_applyTitleButtonState();
if (!_historyNavigation) {
history.pushState({ turnstone: "workstream", wsId: wsId }, "");
@@ -2809,7 +2991,6 @@ function closeWorkstream(wsId) {
loadDashboard();
showDashboard();
}
updateWsActionButtons();
} else if (data.error) {
showToast(data.error, "warning");
}
@@ -3328,12 +3509,10 @@ function confirmWsDelete() {
var _lastActiveWsId = null;
function refreshWorkstreamTitle() {
var wsId = getCurrentWsId();
function refreshWorkstreamTitle(optWsId) {
var wsId = optWsId || getCurrentWsId();
if (!wsId) return;
_setTitleState(wsId, "refreshing");
var url =
"/v1/api/workstreams/" + encodeURIComponent(wsId) + "/refresh-title";
@@ -3348,47 +3527,13 @@ function refreshWorkstreamTitle() {
})
.catch(function (err) {
showToast(err.message || "Failed to refresh title", "error");
_setTitleState(wsId, "idle");
});
}
// --- Per-workstream title state tracking ---
var _wsTitleState = {}; // { wsId: "idle" | "refreshing" | "error" }
function _setTitleState(wsId, state) {
if (state === "idle" || state === "error") delete _wsTitleState[wsId];
else _wsTitleState[wsId] = state;
_applyTitleButtonState();
}
function _applyTitleButtonState() {
var btn = document.getElementById("refresh-title-btn");
if (!btn) return;
var wsId = getCurrentWsId();
var state = _wsTitleState[wsId] || "idle";
if (state === "refreshing") {
btn.innerHTML = "&#x23f3;";
btn.disabled = true;
} else if (state === "error") {
btn.innerHTML = "&#x2717;";
btn.disabled = false;
btn.onclick = function () {
_setTitleState(wsId, "idle");
refreshWorkstreamTitle();
};
return;
} else {
btn.innerHTML = "&#x21bb;";
btn.disabled = false;
btn.onclick = refreshWorkstreamTitle;
}
}
var _editTitleTrap = null;
function editWorkstreamTitle() {
var wsId = getCurrentWsId();
function editWorkstreamTitle(optWsId) {
var wsId = optWsId || getCurrentWsId();
if (!wsId) return;
var currentTitle = "";
var tabEl = document.querySelector(
@@ -3440,8 +3585,8 @@ function cancelEditTitle() {
document.removeEventListener("keydown", _editTitleTrap);
_editTitleTrap = null;
}
var btn = document.getElementById("edit-title-btn");
if (btn) btn.focus();
var chevron = document.querySelector(".ws-tab.active .tab-chevron");
if (chevron) chevron.focus();
}
function submitEditTitle() {
@@ -3487,9 +3632,10 @@ function submitEditTitle() {
var _pendingDeleteWsId = null;
var _deleteWsTrap = null;
function confirmDeleteWorkstream() {
var wsId = getCurrentWsId();
function confirmDeleteWorkstream(optWsId) {
var wsId = optWsId || getCurrentWsId();
if (!wsId) return;
if (Object.keys(workstreams).length <= 1) return;
var tabEl = document.querySelector(
'.ws-tab[data-ws-id="' + wsId + '"] .tab-name',
);
@@ -3536,9 +3682,9 @@ function cancelDeleteWs() {
document.removeEventListener("keydown", _deleteWsTrap);
_deleteWsTrap = null;
}
var btn = document.getElementById("delete-ws-btn");
if (btn && btn.offsetParent !== null) {
btn.focus();
var chevron = document.querySelector(".ws-tab.active .tab-chevron");
if (chevron) {
chevron.focus();
} else {
var fallback = document.getElementById("new-tab-btn");
if (fallback) fallback.focus();
@@ -3568,7 +3714,6 @@ function executeDeleteWs() {
loadDashboard();
showDashboard();
}
updateWsActionButtons();
showToast("Workstream deleted", "success");
})
.catch(function (err) {
@@ -3582,15 +3727,8 @@ function getCurrentWsId() {
return "";
}
function updateWsActionButtons() {
var group = document.getElementById("ws-action-group");
if (group) {
group.classList.toggle("hidden", !getCurrentWsId());
}
}
function forkWorkstream() {
var wsId = getCurrentWsId();
function forkWorkstream(optWsId) {
var wsId = optWsId || getCurrentWsId();
if (!wsId) return;
showNewWsModal(wsId);
}
@@ -3742,8 +3880,6 @@ function connectGlobalSSE() {
for (var id in panes) {
if (panes[id].wsId === data.ws_id) panes[id].updateWsName();
}
// Title generation completed — reset state
_setTitleState(data.ws_id, "idle");
} else if (data.type === "ws_created") {
workstreams[data.ws_id] = workstreams[data.ws_id] || {};
workstreams[data.ws_id].name = data.name || data.ws_id.slice(0, 6);
@@ -4548,13 +4684,9 @@ document.addEventListener("keydown", function (e) {
// is active, so native browser shortcuts (e.g. Ctrl+Shift+R hard reload)
// still work when no workstream is focused.
if (e.ctrlKey && e.shiftKey) {
closeTabDropdown();
var wsActionKey = e.key.toLowerCase();
var activeWsId = !dashboardVisible && getCurrentWsId();
if (wsActionKey === "r" && activeWsId) {
e.preventDefault();
refreshWorkstreamTitle();
return;
}
if (wsActionKey === "e" && activeWsId) {
e.preventDefault();
editWorkstreamTitle();
@@ -4566,7 +4698,11 @@ document.addEventListener("keydown", function (e) {
return;
}
// X not D — D conflicts with Chrome DevTools
if (wsActionKey === "x" && activeWsId) {
if (
wsActionKey === "x" &&
activeWsId &&
Object.keys(workstreams).length > 1
) {
e.preventDefault();
confirmDeleteWorkstream();
return;
@@ -4574,6 +4710,7 @@ document.addEventListener("keydown", function (e) {
}
// Ctrl+W: close current workstream tab
if (e.ctrlKey && !e.shiftKey && e.key === "w") {
closeTabDropdown();
if (Object.keys(workstreams).length > 1) {
e.preventDefault();
closeWorkstream(currentWsId);
-8
View File
@@ -22,14 +22,6 @@
<div id="tab-bar" role="toolbar" aria-label="Workstreams">
<div id="tab-list" role="tablist"></div>
<div id="ws-action-group" class="ws-action-group hidden">
<span class="tab-bar-sep"></span>
<button id="refresh-title-btn" class="tab-bar-btn ws-action-btn" onclick="refreshWorkstreamTitle()" aria-label="Regenerate title" title="Regenerate title">&#x21bb;</button>
<button id="edit-title-btn" class="tab-bar-btn ws-action-btn" onclick="editWorkstreamTitle()" aria-label="Edit title" title="Edit title">&#x270E;</button>
<button id="fork-ws-btn" class="tab-bar-btn ws-action-btn" onclick="forkWorkstream()" aria-label="Fork workstream" title="Fork workstream">&#x2442;</button>
<button id="delete-ws-btn" class="tab-bar-btn ws-action-btn ws-action-btn-danger" onclick="confirmDeleteWorkstream()" aria-label="Delete workstream" title="Delete workstream"><span aria-hidden="true">&#x1f5d1;</span></button>
<span class="tab-bar-sep"></span>
</div>
<button id="new-tab-btn" onclick="newWorkstream()" title="New workstream (Ctrl+T)" aria-label="New workstream" aria-keyshortcuts="Control+t">+</button>
<button id="split-btn" onclick="splitFocusedPane()" title="Split pane (Ctrl+\)" aria-label="Split pane" aria-keyshortcuts="Control+Backslash">&#x29C9;</button>
</div>
+61 -28
View File
@@ -51,10 +51,9 @@
Mobile overrides
========================================================================== */
@media (max-width: 600px) {
.ws-tab .tab-close { opacity: 1; padding: 4px 6px; font-size: 16px; }
.ws-tab .tab-chevron { opacity: 1; padding: 8px 10px; font-size: 14px; min-width: 36px; min-height: 36px; }
.ws-tab-dropdown-item.mobile-hide { display: none; }
#split-btn { display: none; }
#refresh-title-btn { display: none !important; }
.ws-action-btn { padding: 4px 6px; margin: 0 1px; font-size: 13px; }
.tab-wsid { display: none; }
}
@@ -111,19 +110,23 @@
.ws-tab .tab-indicator[data-state="attention"] { background: var(--yellow); border-radius: 1px; transform: rotate(45deg); box-shadow: 0 0 6px var(--yellow-glow); animation: pulse 1s ease-in-out infinite; will-change: opacity; }
.ws-tab .tab-indicator[data-state="error"] { background: var(--red); box-shadow: 0 0 4px var(--red-glow); }
.ws-tab .tab-close {
.ws-tab .tab-chevron {
background: none;
border: none;
color: var(--fg-dim);
font-size: 14px;
font-size: 11px;
cursor: pointer;
padding: 0 2px;
padding: 4px 6px;
line-height: 1;
opacity: 0;
transition: opacity 0.15s, color 0.1s;
transition: opacity 0.15s, color 0.1s, background 0.1s;
border-radius: var(--radius-sm);
margin-right: -4px;
}
.ws-tab:hover .tab-close, .ws-tab:focus-within .tab-close, .ws-tab .tab-close:focus-visible { opacity: 1; }
.ws-tab .tab-close:hover { color: var(--red); }
.ws-tab:hover .tab-chevron, .ws-tab:focus-within .tab-chevron, .ws-tab .tab-chevron:focus-visible { opacity: 1; }
.ws-tab.active .tab-chevron { opacity: 0.7; }
.ws-tab .tab-chevron[aria-expanded="true"] { opacity: 1; color: var(--fg-bright); }
.ws-tab .tab-chevron:hover { color: var(--fg-bright); background: rgba(255, 255, 255, 0.06); }
/* Subtle ws_id badge in tabs */
.tab-wsid {
@@ -181,24 +184,53 @@
#split-btn:hover { background: var(--bg-highlight); color: var(--accent); border-color: var(--accent); }
#split-btn.hidden { display: none; }
.ws-action-btn {
background: none;
border: 1px solid var(--border);
color: var(--fg-dim);
border-radius: var(--radius-sm);
padding: 4px 8px;
cursor: pointer;
font-size: 14px;
line-height: 1;
transition: color 0.15s, border-color 0.15s, background 0.15s;
margin: 0 2px;
/* Tab dropdown menu */
@keyframes dropdown-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
.ws-tab-dropdown {
position: fixed;
background: var(--bg-surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
min-width: 160px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
z-index: 300;
overflow: hidden;
padding: 4px 0;
animation: dropdown-in 0.1s ease-out;
}
.ws-action-btn:hover { background: var(--bg-highlight); color: var(--fg-bright); border-color: var(--accent); }
.ws-action-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.ws-action-btn-danger:hover { color: var(--red); border-color: var(--red); }
.ws-action-group { display: flex; align-items: center; flex-shrink: 0; }
.ws-action-group.hidden { display: none; }
.tab-bar-sep { width: 1px; height: 16px; background: var(--border-strong); margin: 0 4px; flex-shrink: 0; }
[data-theme="light"] .ws-tab-dropdown { box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); }
.ws-tab-dropdown-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
padding: 7px 14px;
background: none;
border: none;
color: var(--fg);
font: inherit;
font-family: var(--font-display);
font-size: 13px;
cursor: pointer;
text-align: left;
white-space: nowrap;
transition: background 0.1s;
}
.ws-tab-dropdown-item:hover:not([aria-disabled="true"]) { background: var(--bg-highlight); color: var(--fg-bright); }
.ws-tab-dropdown-item:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.ws-tab-dropdown-item[aria-disabled="true"] { color: var(--fg-dim); opacity: 0.55; cursor: not-allowed; }
.ws-tab-dropdown-item.destructive:hover:not([aria-disabled="true"]),
.ws-tab-dropdown-item.destructive:focus-visible:not([aria-disabled="true"]) { color: var(--red); background: rgba(248, 113, 113, 0.08); }
.ws-tab-dropdown-item.destructive:focus-visible:not([aria-disabled="true"]) { outline-color: var(--red); }
.ws-tab-dropdown-label { flex: 1; }
.ws-tab-dropdown-key {
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-dim);
flex-shrink: 0;
}
.ws-tab-dropdown-sep { height: 1px; background: var(--border-strong); margin: 6px 0; }
.header-spacer { flex: 1; }
/* Edit title & delete modals */
@@ -1807,7 +1839,7 @@ audio.media-player {
.tool-output-stream { animation: none; border-left-color: var(--accent); }
.judge-spinner-dot { animation: none; opacity: 1; }
.thinking-indicator::after { animation: none; content: '...'; }
.ws-tab, .ws-tab .tab-close, #new-tab-btn, #split-btn,
.ws-tab, .ws-tab .tab-chevron, #new-tab-btn, #split-btn,
.dashboard-card,
.approval-btn, .approval-feedback-input,
#plan-buttons button, .pane-input-area button,
@@ -1819,5 +1851,6 @@ audio.media-player {
#new-ws-cancel, #new-ws-submit,
#new-ws-box input, #new-ws-box select,
.split-handle, .pane-action-btn,
.pane-ctx-item { transition: none; }
.pane-ctx-item, .ws-tab-dropdown-item { transition: none; }
.ws-tab-dropdown { animation: none; }
}
Generated
+1 -1
View File
@@ -2496,7 +2496,7 @@ wheels = [
[[package]]
name = "turnstone"
version = "1.2.0a4"
version = "1.2.0a5"
source = { editable = "." }
dependencies = [
{ name = "alembic" },