mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
feat: prompt template tech debt — tests, read-only endpoints, double-… (#67)
* feat: prompt template tech debt — tests, read-only endpoints, double-load fix, server creation modal Close test coverage gaps for prompt templates: - Resume with deleted template: verifies graceful degradation (template_content=None, warning logged) - Threading safety: concurrent set_template/init_system_messages with no race conditions - Factory passthrough: template kwarg propagation through WorkstreamManager.create() Add read-only template listing endpoints (read scope, no content exposed): - GET /v1/api/templates — prompt template summaries (name, category, is_default, origin) - GET /v1/api/ws-templates — enabled workstream template summaries (name, description, model) - Available on both server and console; Python + TypeScript SDK methods added - Console creation modal switched from admin endpoint to read-scope endpoint Eliminate double-load inefficiency in workstream creation: - Template validation moved before mgr.create() (no create-then-rollback on invalid template) - template kwarg plumbed through WorkstreamManager.create() and session factory - _SessionFactory Protocol added for proper mypy typing Add workstream creation modal to server web UI: - Name, model, template dropdown, ws_template/profile dropdown - Instrument panel aesthetic: gradient top border, blur backdrop, amber accent - Focus trap, Escape/Enter keyboard handling, loading state, error display - WCAG AA contrast compliance, reduced-motion support * fix: add list_ws_templates SDK methods + regenerate OpenAPI snapshots Add list_ws_templates() to Python SDK (async + sync) and listWsTemplates() to TypeScript SDK for the new GET /v1/api/ws-templates server endpoint. Add WsTemplateSummary + ListWsTemplateSummaryResponse TypeScript types. Regenerate openapi-server.json and openapi-console.json snapshots. Addresses Copilot review feedback on PR #67. * fix: skip template pre-validation when resuming a workstream When resume_ws is set, the request's template field is irrelevant — resume() restores the template from workstream_config. Pre-validating a stale template name would incorrectly return 400 before the resume even runs. Addresses Copilot review feedback on PR #67.
This commit is contained in:
@@ -618,6 +618,67 @@ Each saved workstream object:
|
||||
|
||||
---
|
||||
|
||||
### `GET /v1/api/templates`
|
||||
|
||||
Returns a summary list of all available prompt templates. This is a read-only
|
||||
endpoint (requires `read` scope) that exposes template names and categories
|
||||
without revealing template content. Useful for populating template selectors
|
||||
in UIs or discovering available templates before creating a workstream.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"templates": [
|
||||
{"name": "safety-guidelines", "category": "safety", "is_default": true, "origin": "manual"},
|
||||
{"name": "mcp__server__code", "category": "", "is_default": false, "origin": "mcp"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Each template summary:
|
||||
|
||||
| Field | Type | Description |
|
||||
|--------------|--------|------------------------------------------------------|
|
||||
| `name` | string | Template name (used in `template` field on creation) |
|
||||
| `category` | string | Template category |
|
||||
| `is_default` | bool | Whether template is auto-applied to all sessions |
|
||||
| `origin` | string | Template origin: `manual` or `mcp` |
|
||||
|
||||
> **Note:** For full template management (create, update, delete, view content),
|
||||
> use the admin endpoints at `GET /v1/api/admin/templates` (requires `admin.templates` permission).
|
||||
|
||||
---
|
||||
|
||||
### `GET /v1/api/ws-templates`
|
||||
|
||||
Returns a summary list of enabled workstream templates. This is a read-only
|
||||
endpoint (requires `read` scope) for populating template selectors in UIs.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ws_templates": [
|
||||
{"name": "code-review", "description": "Code review profile", "model": "gpt-5"},
|
||||
{"name": "ops-triage", "description": "On-call triage", "model": ""}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Each workstream template summary:
|
||||
|
||||
| Field | Type | Description |
|
||||
|---------------|--------|-------------------------------------------------|
|
||||
| `name` | string | Template name (used in `ws_template` on creation)|
|
||||
| `description` | string | Human-readable description |
|
||||
| `model` | string | Model alias override (empty = use default) |
|
||||
|
||||
> **Note:** For full workstream template management, use the admin endpoints at
|
||||
> `GET /v1/api/admin/ws-templates` (requires `admin.templates` permission).
|
||||
|
||||
---
|
||||
|
||||
### `POST /v1/api/send`
|
||||
|
||||
Sends a user message to a workstream. Spawns a daemon worker thread that calls
|
||||
|
||||
@@ -85,6 +85,12 @@ tload --> trender : template content
|
||||
trender --> tsys : rendered content
|
||||
tset --> tload : name or None
|
||||
|
||||
note right of pt_db
|
||||
Read-only listing:
|
||||
GET /v1/api/templates
|
||||
(read scope, summary only)
|
||||
end note
|
||||
|
||||
govjs --> wt_db : /v1/api/admin/ws-templates
|
||||
wtr --> wt_db : get_ws_template_by_name()
|
||||
wtr --> wta : template settings
|
||||
|
||||
@@ -48,6 +48,14 @@ end note
|
||||
Admin -> Server : GET /v1/api/admin/ws-templates
|
||||
Server -> Storage : list_ws_templates()
|
||||
|
||||
Server <-- Server : GET /v1/api/ws-templates\n(read scope, summary only)
|
||||
note right
|
||||
**Read-only listing:**
|
||||
name, description, model.
|
||||
Used by creation UI dropdowns.
|
||||
Available on both server + console.
|
||||
end note
|
||||
|
||||
Admin -> Server : DELETE /v1/api/admin/ws-templates/{id}
|
||||
Server -> Storage : delete_ws_template(id)
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f4dac4948d928b4705936d73b4d159aa1e89315ec0397616ca914bbf19e7a1ce
|
||||
size 206479
|
||||
oid sha256:3aaca1ae4c6c255dc9569f59e3ccc24f8b3bab0ac2a9b08c85e2af72d6a400c7
|
||||
size 218575
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c06d7086d7965eb9fe333396f027133d42507cf120bfe8dc851c009a8768ec48
|
||||
size 284926
|
||||
oid sha256:fadf5b07f8230ecf97805a86b308eaa9eb30516dd26900e5c9f70e6fb7562bab
|
||||
size 296339
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "turnstone Console API",
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.2",
|
||||
"description": "Cluster-wide visibility and control across all turnstone nodes."
|
||||
},
|
||||
"paths": {
|
||||
@@ -2052,6 +2052,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/templates": {
|
||||
"get": {
|
||||
"summary": "List available prompt templates (summary)",
|
||||
"operationId": "v1_api_templates_get",
|
||||
"tags": [
|
||||
"Templates"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ListPromptTemplateSummaryResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/admin/usage": {
|
||||
"get": {
|
||||
"summary": "Aggregated usage data",
|
||||
@@ -5947,6 +5968,54 @@
|
||||
},
|
||||
"title": "McpReloadResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"PromptTemplateSummary": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"description": "Template name",
|
||||
"title": "Name",
|
||||
"type": "string"
|
||||
},
|
||||
"category": {
|
||||
"default": "",
|
||||
"description": "Template category",
|
||||
"title": "Category",
|
||||
"type": "string"
|
||||
},
|
||||
"is_default": {
|
||||
"default": false,
|
||||
"description": "Whether this template is applied by default",
|
||||
"title": "Is Default",
|
||||
"type": "boolean"
|
||||
},
|
||||
"origin": {
|
||||
"default": "manual",
|
||||
"description": "Template origin: manual or mcp",
|
||||
"title": "Origin",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"title": "PromptTemplateSummary",
|
||||
"type": "object"
|
||||
},
|
||||
"ListPromptTemplateSummaryResponse": {
|
||||
"properties": {
|
||||
"templates": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PromptTemplateSummary"
|
||||
},
|
||||
"title": "Templates",
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"templates"
|
||||
],
|
||||
"title": "ListPromptTemplateSummaryResponse",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "turnstone Server API",
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.2",
|
||||
"description": "Single-node workstream management, chat interaction, and real-time streaming."
|
||||
},
|
||||
"paths": {
|
||||
@@ -437,6 +437,48 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/templates": {
|
||||
"get": {
|
||||
"summary": "List available prompt templates (summary)",
|
||||
"operationId": "v1_api_templates_get",
|
||||
"tags": [
|
||||
"Templates"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ListPromptTemplateSummaryResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/ws-templates": {
|
||||
"get": {
|
||||
"summary": "List enabled workstream templates (summary)",
|
||||
"operationId": "v1_api_ws-templates_get",
|
||||
"tags": [
|
||||
"Templates"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ListWsTemplateSummaryResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/auth/login": {
|
||||
"post": {
|
||||
"summary": "Authenticate with a token",
|
||||
@@ -1777,6 +1819,93 @@
|
||||
],
|
||||
"title": "SearchMemoriesRequest",
|
||||
"type": "object"
|
||||
},
|
||||
"PromptTemplateSummary": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"description": "Template name",
|
||||
"title": "Name",
|
||||
"type": "string"
|
||||
},
|
||||
"category": {
|
||||
"default": "",
|
||||
"description": "Template category",
|
||||
"title": "Category",
|
||||
"type": "string"
|
||||
},
|
||||
"is_default": {
|
||||
"default": false,
|
||||
"description": "Whether this template is applied by default",
|
||||
"title": "Is Default",
|
||||
"type": "boolean"
|
||||
},
|
||||
"origin": {
|
||||
"default": "manual",
|
||||
"description": "Template origin: manual or mcp",
|
||||
"title": "Origin",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"title": "PromptTemplateSummary",
|
||||
"type": "object"
|
||||
},
|
||||
"ListPromptTemplateSummaryResponse": {
|
||||
"properties": {
|
||||
"templates": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PromptTemplateSummary"
|
||||
},
|
||||
"title": "Templates",
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"templates"
|
||||
],
|
||||
"title": "ListPromptTemplateSummaryResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"WsTemplateSummary": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"title": "Name",
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"title": "Description",
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"title": "Model",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"description",
|
||||
"model"
|
||||
],
|
||||
"title": "WsTemplateSummary",
|
||||
"type": "object"
|
||||
},
|
||||
"ListWsTemplateSummaryResponse": {
|
||||
"properties": {
|
||||
"ws_templates": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/WsTemplateSummary"
|
||||
},
|
||||
"title": "Ws Templates",
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ws_templates"
|
||||
],
|
||||
"title": "ListWsTemplateSummaryResponse",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ import type {
|
||||
HealthResponse,
|
||||
ListMemoriesOptions,
|
||||
ListMemoriesResponse,
|
||||
ListPromptTemplateSummaryResponse,
|
||||
ListSavedWorkstreamsResponse,
|
||||
ListWsTemplateSummaryResponse,
|
||||
ListWorkstreamsResponse,
|
||||
MemoryInfo,
|
||||
SaveMemoryRequest,
|
||||
@@ -196,6 +198,16 @@ export class TurnstoneServer extends BaseClient {
|
||||
return this.request("GET", "/v1/api/workstreams/saved");
|
||||
}
|
||||
|
||||
// -- Templates --------------------------------------------------------------
|
||||
|
||||
async listTemplates(): Promise<ListPromptTemplateSummaryResponse> {
|
||||
return this.request("GET", "/v1/api/templates");
|
||||
}
|
||||
|
||||
async listWsTemplates(): Promise<ListWsTemplateSummaryResponse> {
|
||||
return this.request("GET", "/v1/api/ws-templates");
|
||||
}
|
||||
|
||||
// -- Memories -------------------------------------------------------------
|
||||
|
||||
async listMemories(
|
||||
|
||||
@@ -143,6 +143,35 @@ export interface ListSavedWorkstreamsResponse {
|
||||
workstreams: SavedWorkstreamInfo[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server API — Prompt templates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PromptTemplateSummary {
|
||||
name: string;
|
||||
category: string;
|
||||
is_default: boolean;
|
||||
origin: string;
|
||||
}
|
||||
|
||||
export interface ListPromptTemplateSummaryResponse {
|
||||
templates: PromptTemplateSummary[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server API — Workstream templates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface WsTemplateSummary {
|
||||
name: string;
|
||||
description: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface ListWsTemplateSummaryResponse {
|
||||
ws_templates: WsTemplateSummary[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server API — Health
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -530,7 +530,9 @@ class TestWorkstreamModelParam:
|
||||
|
||||
captured_alias = None
|
||||
|
||||
def factory(ui: Any, model_alias: str | None = None, ws_id: str | None = None) -> Any:
|
||||
def factory(
|
||||
ui: Any, model_alias: str | None = None, ws_id: str | None = None, **kwargs: Any
|
||||
) -> Any:
|
||||
nonlocal captured_alias
|
||||
captured_alias = model_alias
|
||||
mock_session = MagicMock()
|
||||
@@ -544,7 +546,9 @@ class TestWorkstreamModelParam:
|
||||
def test_create_without_model(self) -> None:
|
||||
captured_alias = None
|
||||
|
||||
def factory(ui: Any, model_alias: str | None = None, ws_id: str | None = None) -> Any:
|
||||
def factory(
|
||||
ui: Any, model_alias: str | None = None, ws_id: str | None = None, **kwargs: Any
|
||||
) -> Any:
|
||||
nonlocal captured_alias
|
||||
captured_alias = model_alias
|
||||
mock_session = MagicMock()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from turnstone.core.session import ChatSession, _render_template
|
||||
@@ -391,3 +392,131 @@ class TestMCPTemplates:
|
||||
session = _make_session(template="mcp__server__code")
|
||||
content = _sys_content(session)
|
||||
assert "MCP_EXPLICIT" in content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resume with deleted template
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResumeDeletedTemplate:
|
||||
def test_resume_with_deleted_template_degrades_gracefully(self, tmp_db, capsys):
|
||||
from turnstone.core.memory import save_message
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
db = get_storage()
|
||||
_create_template(db, "t1", "ephemeral-tpl", "EPHEMERAL_CONTENT", is_default=False)
|
||||
|
||||
# Create session with template, save a message so resume has history
|
||||
session1 = _make_session(template="ephemeral-tpl")
|
||||
ws_id = session1.ws_id
|
||||
save_message(ws_id, "user", "hello")
|
||||
assert "EPHEMERAL_CONTENT" in _sys_content(session1)
|
||||
|
||||
# Delete the template from storage
|
||||
db.delete_prompt_template("t1")
|
||||
|
||||
# Resume into a new session
|
||||
session2 = _make_session()
|
||||
resumed = session2.resume(ws_id)
|
||||
|
||||
assert resumed
|
||||
assert session2._template_name == "ephemeral-tpl"
|
||||
assert session2._template_content is None
|
||||
# System message should not contain the deleted template content
|
||||
content = _sys_content(session2)
|
||||
assert "EPHEMERAL_CONTENT" not in content
|
||||
# Warning should be logged via structlog
|
||||
captured = capsys.readouterr()
|
||||
assert "not_found" in captured.out or "not_found" in captured.err
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Threading safety
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTemplateFactoryPassthrough:
|
||||
def test_template_passed_through_workstream_create(self, tmp_db):
|
||||
"""WorkstreamManager.create(template=...) propagates to session factory."""
|
||||
from turnstone.core.storage import get_storage
|
||||
from turnstone.core.workstream import WorkstreamManager
|
||||
|
||||
db = get_storage()
|
||||
_create_template(db, "t1", "factory-tpl", "FACTORY_CONTENT", is_default=False)
|
||||
|
||||
captured_template = None
|
||||
|
||||
def factory(ui, model_alias=None, ws_id=None, *, template=None):
|
||||
nonlocal captured_template
|
||||
captured_template = template
|
||||
return _make_session(template=template)
|
||||
|
||||
mgr = WorkstreamManager(factory)
|
||||
ws = mgr.create(name="test", template="factory-tpl")
|
||||
assert captured_template == "factory-tpl"
|
||||
assert ws.session is not None
|
||||
assert ws.session._template_name == "factory-tpl"
|
||||
assert "FACTORY_CONTENT" in _sys_content(ws.session)
|
||||
|
||||
def test_template_none_uses_defaults(self, tmp_db):
|
||||
"""WorkstreamManager.create() without template passes None."""
|
||||
captured_template = "sentinel"
|
||||
|
||||
def factory(ui, model_alias=None, ws_id=None, *, template=None):
|
||||
nonlocal captured_template
|
||||
captured_template = template
|
||||
return _make_session(template=template)
|
||||
|
||||
from turnstone.core.workstream import WorkstreamManager
|
||||
|
||||
mgr = WorkstreamManager(factory)
|
||||
mgr.create(name="test")
|
||||
assert captured_template is None
|
||||
|
||||
|
||||
class TestTemplateThreadSafety:
|
||||
def test_concurrent_template_and_system_message_init(self, tmp_db):
|
||||
from turnstone.core.storage import get_storage
|
||||
|
||||
db = get_storage()
|
||||
_create_template(db, "t1", "thread-tpl", "THREAD_TEMPLATE", is_default=False)
|
||||
|
||||
session = _make_session(template="thread-tpl")
|
||||
errors: list[Exception] = []
|
||||
stop = threading.Event()
|
||||
iterations = 200
|
||||
|
||||
def init_loop():
|
||||
"""Simulate MCP callback repeatedly calling _init_system_messages."""
|
||||
try:
|
||||
for _ in range(iterations):
|
||||
if stop.is_set():
|
||||
break
|
||||
session._init_system_messages()
|
||||
# system_messages must always be a valid list
|
||||
msgs = session.system_messages
|
||||
assert isinstance(msgs, list)
|
||||
assert len(msgs) > 0
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
|
||||
t = threading.Thread(target=init_loop, daemon=True)
|
||||
t.start()
|
||||
|
||||
# Main thread toggles template on/off
|
||||
try:
|
||||
for i in range(iterations):
|
||||
if i % 2 == 0:
|
||||
session.set_template("thread-tpl")
|
||||
else:
|
||||
session.set_template(None)
|
||||
finally:
|
||||
stop.set()
|
||||
t.join(timeout=5)
|
||||
|
||||
assert not errors, f"Thread raised: {errors}"
|
||||
# Final state: system_messages is a valid list
|
||||
msgs = session.system_messages
|
||||
assert isinstance(msgs, list)
|
||||
assert len(msgs) > 0
|
||||
|
||||
@@ -20,7 +20,7 @@ class FakeSession:
|
||||
self.messages = []
|
||||
|
||||
|
||||
def _fake_factory(ui, model_alias=None, ws_id=None):
|
||||
def _fake_factory(ui, model_alias=None, ws_id=None, **kwargs):
|
||||
return FakeSession()
|
||||
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ from turnstone.api.schemas import (
|
||||
UpdateScheduleRequest,
|
||||
UserInfo,
|
||||
)
|
||||
from turnstone.api.server_schemas import ListPromptTemplateSummaryResponse, PromptTemplateSummary
|
||||
|
||||
CONSOLE_ENDPOINTS: list[EndpointSpec] = [
|
||||
# --- Cluster ---
|
||||
@@ -532,6 +533,14 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
|
||||
response_model=ListWsTemplateSummaryResponse,
|
||||
tags=["Workstreams"],
|
||||
),
|
||||
# --- Prompt templates ---
|
||||
EndpointSpec(
|
||||
"/v1/api/templates",
|
||||
"GET",
|
||||
"List available prompt templates (summary)",
|
||||
response_model=ListPromptTemplateSummaryResponse,
|
||||
tags=["Templates"],
|
||||
),
|
||||
# --- Governance: Usage & Audit ---
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/usage",
|
||||
@@ -807,6 +816,8 @@ _ALL_MODELS: list[type[BaseModel]] = [
|
||||
ImportMcpConfigRequest,
|
||||
ImportMcpConfigResponse,
|
||||
McpReloadResponse,
|
||||
PromptTemplateSummary,
|
||||
ListPromptTemplateSummaryResponse,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -214,3 +214,21 @@ class SearchMemoriesRequest(BaseModel):
|
||||
scope: MemoryScopeFilter = Field(default="", description="Filter by scope")
|
||||
scope_id: str = Field(default="", description="Filter by scope_id")
|
||||
limit: int = Field(default=20, description="Max results (1-50)", ge=1, le=50)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prompt templates (read-only listing)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PromptTemplateSummary(BaseModel):
|
||||
name: str = Field(description="Template name")
|
||||
category: str = Field(default="", description="Template category")
|
||||
is_default: bool = Field(
|
||||
default=False, description="Whether this template is applied by default"
|
||||
)
|
||||
origin: str = Field(default="manual", description="Template origin: manual or mcp")
|
||||
|
||||
|
||||
class ListPromptTemplateSummaryResponse(BaseModel):
|
||||
templates: list[PromptTemplateSummary]
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from turnstone.api.console_schemas import ListWsTemplateSummaryResponse, WsTemplateSummary
|
||||
from turnstone.api.openapi import EndpointSpec, QueryParam, build_openapi
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -27,10 +28,12 @@ from turnstone.api.server_schemas import (
|
||||
DashboardResponse,
|
||||
HealthResponse,
|
||||
ListMemoriesResponse,
|
||||
ListPromptTemplateSummaryResponse,
|
||||
ListSavedWorkstreamsResponse,
|
||||
ListWorkstreamsResponse,
|
||||
MemoryInfo,
|
||||
PlanFeedbackRequest,
|
||||
PromptTemplateSummary,
|
||||
SaveMemoryRequest,
|
||||
SearchMemoriesRequest,
|
||||
SendRequest,
|
||||
@@ -144,6 +147,22 @@ SERVER_ENDPOINTS: list[EndpointSpec] = [
|
||||
response_model=ListSavedWorkstreamsResponse,
|
||||
tags=["Workstreams"],
|
||||
),
|
||||
# --- Prompt templates ---
|
||||
EndpointSpec(
|
||||
"/v1/api/templates",
|
||||
"GET",
|
||||
"List available prompt templates (summary)",
|
||||
response_model=ListPromptTemplateSummaryResponse,
|
||||
tags=["Templates"],
|
||||
),
|
||||
# --- Workstream templates ---
|
||||
EndpointSpec(
|
||||
"/v1/api/ws-templates",
|
||||
"GET",
|
||||
"List enabled workstream templates (summary)",
|
||||
response_model=ListWsTemplateSummaryResponse,
|
||||
tags=["Templates"],
|
||||
),
|
||||
# --- Auth ---
|
||||
EndpointSpec(
|
||||
"/v1/api/auth/login",
|
||||
@@ -257,6 +276,10 @@ _ALL_MODELS: list[type[BaseModel]] = [
|
||||
MemoryInfo,
|
||||
ListMemoriesResponse,
|
||||
SearchMemoriesRequest,
|
||||
PromptTemplateSummary,
|
||||
ListPromptTemplateSummaryResponse,
|
||||
WsTemplateSummary,
|
||||
ListWsTemplateSummaryResponse,
|
||||
]
|
||||
|
||||
|
||||
|
||||
+6
-2
@@ -1016,7 +1016,11 @@ def main() -> None:
|
||||
|
||||
# ChatSession factory — captures shared config for creating workstreams
|
||||
def session_factory(
|
||||
ui: SessionUI | None, model_alias: str | None = None, ws_id: str | None = None
|
||||
ui: SessionUI | None,
|
||||
model_alias: str | None = None,
|
||||
ws_id: str | None = None,
|
||||
*,
|
||||
template: str | None = None,
|
||||
) -> ChatSession:
|
||||
assert ui is not None, "session_factory requires a non-None UI"
|
||||
r_client, r_model, r_cfg = registry.resolve(model_alias)
|
||||
@@ -1040,7 +1044,7 @@ def main() -> None:
|
||||
tool_search=args.tool_search,
|
||||
tool_search_threshold=args.tool_search_threshold,
|
||||
tool_search_max_results=args.tool_search_max_results,
|
||||
template=args.template,
|
||||
template=template if template is not None else args.template,
|
||||
)
|
||||
|
||||
# Create workstream manager and initial workstream
|
||||
|
||||
@@ -2480,6 +2480,26 @@ async def list_ws_templates_summary(request: Request) -> JSONResponse:
|
||||
return JSONResponse({"ws_templates": summary})
|
||||
|
||||
|
||||
async def list_templates_summary(request: Request) -> JSONResponse:
|
||||
"""GET /v1/api/templates — list available prompt templates (read scope)."""
|
||||
from turnstone.core.web_helpers import require_storage_or_503
|
||||
|
||||
storage, err = require_storage_or_503(request)
|
||||
if err:
|
||||
return err
|
||||
templates = storage.list_prompt_templates()
|
||||
summaries = [
|
||||
{
|
||||
"name": t["name"],
|
||||
"category": t.get("category", ""),
|
||||
"is_default": bool(t.get("is_default")),
|
||||
"origin": t.get("origin", "manual"),
|
||||
}
|
||||
for t in templates
|
||||
]
|
||||
return JSONResponse({"templates": summaries})
|
||||
|
||||
|
||||
async def admin_usage(request: Request) -> JSONResponse:
|
||||
"""GET /v1/api/admin/usage — query usage data."""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
@@ -3532,6 +3552,7 @@ def create_app(
|
||||
Route("/api/cluster/snapshot", cluster_snapshot),
|
||||
Route("/api/cluster/events", cluster_events_sse),
|
||||
Route("/api/ws-templates", list_ws_templates_summary),
|
||||
Route("/api/templates", list_templates_summary),
|
||||
Route("/api/auth/login", auth_login, methods=["POST"]),
|
||||
Route("/api/auth/logout", auth_logout, methods=["POST"]),
|
||||
Route("/api/auth/status", auth_status),
|
||||
|
||||
@@ -1263,7 +1263,7 @@ function showNewWsModal() {
|
||||
// Populate template dropdown
|
||||
var tplSelect = document.getElementById("new-ws-template");
|
||||
tplSelect.innerHTML = '<option value="">Use defaults</option>';
|
||||
authFetch("/v1/api/admin/templates")
|
||||
authFetch("/v1/api/templates")
|
||||
.then(function (r) {
|
||||
return r.json();
|
||||
})
|
||||
|
||||
@@ -16,9 +16,20 @@ from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from typing import Protocol
|
||||
|
||||
from turnstone.core.session import ChatSession, SessionUI
|
||||
|
||||
class _SessionFactory(Protocol):
|
||||
def __call__(
|
||||
self,
|
||||
ui: SessionUI | None,
|
||||
model_alias: str | None = ...,
|
||||
ws_id: str | None = ...,
|
||||
*,
|
||||
template: str | None = ...,
|
||||
) -> ChatSession: ...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State enum
|
||||
@@ -65,14 +76,14 @@ class WorkstreamManager:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_factory: Callable[[SessionUI | None, str | None, str | None], ChatSession],
|
||||
session_factory: _SessionFactory,
|
||||
*,
|
||||
max_workstreams: int = 10,
|
||||
node_id: str | None = None,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
session_factory: callable(ui, model_alias, ws_id) -> ChatSession.
|
||||
session_factory: callable(ui, model_alias, ws_id, *, template) -> ChatSession.
|
||||
Captures shared config (registry, temperature, …) so the
|
||||
manager can create ChatSession instances without knowing
|
||||
those details. *model_alias* selects a model from the
|
||||
@@ -85,9 +96,7 @@ class WorkstreamManager:
|
||||
"""
|
||||
if max_workstreams < 1:
|
||||
raise ValueError(f"max_workstreams must be >= 1, got {max_workstreams}")
|
||||
self._session_factory: Callable[[SessionUI | None, str | None, str | None], ChatSession] = (
|
||||
session_factory
|
||||
)
|
||||
self._session_factory: _SessionFactory = session_factory
|
||||
self._node_id = node_id
|
||||
self._max_workstreams: int = max_workstreams
|
||||
self._workstreams: dict[str, Workstream] = {}
|
||||
@@ -115,6 +124,7 @@ class WorkstreamManager:
|
||||
name: str = "",
|
||||
ui_factory: Callable[..., SessionUI] | None = None,
|
||||
model: str | None = None,
|
||||
template: str | None = None,
|
||||
) -> Workstream:
|
||||
"""Create a new workstream. Returns the new ws.
|
||||
|
||||
@@ -125,6 +135,8 @@ class WorkstreamManager:
|
||||
Args:
|
||||
model: Optional model alias from the registry. ``None`` uses the
|
||||
default model.
|
||||
template: Optional prompt template name passed through to session
|
||||
factory.
|
||||
"""
|
||||
# Fast-fail capacity check (avoids expensive ChatSession creation when full).
|
||||
first_evicted: Workstream | None = None
|
||||
@@ -147,7 +159,7 @@ class WorkstreamManager:
|
||||
ws = Workstream(name=name)
|
||||
if ui_factory:
|
||||
ws.ui = ui_factory(ws.id)
|
||||
ws.session = self._session_factory(ws.ui, model, ws.id)
|
||||
ws.session = self._session_factory(ws.ui, model, ws.id, template=template)
|
||||
|
||||
# Authoritative insert under lock with re-check (another thread may
|
||||
# have filled capacity while we were unlocked).
|
||||
|
||||
@@ -16,6 +16,7 @@ import asyncio
|
||||
import contextlib
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from turnstone.api.console_schemas import ListWsTemplateSummaryResponse
|
||||
from turnstone.api.schemas import (
|
||||
AuthLoginResponse,
|
||||
AuthSetupResponse,
|
||||
@@ -27,6 +28,7 @@ from turnstone.api.server_schemas import (
|
||||
DashboardResponse,
|
||||
HealthResponse,
|
||||
ListMemoriesResponse,
|
||||
ListPromptTemplateSummaryResponse,
|
||||
ListSavedWorkstreamsResponse,
|
||||
ListWorkstreamsResponse,
|
||||
MemoryInfo,
|
||||
@@ -237,6 +239,18 @@ class AsyncTurnstoneServer(_BaseClient):
|
||||
"GET", "/v1/api/workstreams/saved", response_model=ListSavedWorkstreamsResponse
|
||||
)
|
||||
|
||||
# -- templates -----------------------------------------------------------
|
||||
|
||||
async def list_templates(self) -> ListPromptTemplateSummaryResponse:
|
||||
return await self._request(
|
||||
"GET", "/v1/api/templates", response_model=ListPromptTemplateSummaryResponse
|
||||
)
|
||||
|
||||
async def list_ws_templates(self) -> ListWsTemplateSummaryResponse:
|
||||
return await self._request(
|
||||
"GET", "/v1/api/ws-templates", response_model=ListWsTemplateSummaryResponse
|
||||
)
|
||||
|
||||
# -- memories ------------------------------------------------------------
|
||||
|
||||
async def list_memories(
|
||||
@@ -481,6 +495,14 @@ class TurnstoneServer:
|
||||
def list_saved_workstreams(self) -> ListSavedWorkstreamsResponse:
|
||||
return self._runner.run(self._async.list_saved_workstreams())
|
||||
|
||||
# -- templates -----------------------------------------------------------
|
||||
|
||||
def list_templates(self) -> ListPromptTemplateSummaryResponse:
|
||||
return self._runner.run(self._async.list_templates())
|
||||
|
||||
def list_ws_templates(self) -> ListWsTemplateSummaryResponse:
|
||||
return self._runner.run(self._async.list_ws_templates())
|
||||
|
||||
# -- memories ------------------------------------------------------------
|
||||
|
||||
def list_memories(
|
||||
|
||||
+58
-19
@@ -890,6 +890,43 @@ async def list_saved_workstreams(request: Request) -> JSONResponse:
|
||||
return JSONResponse({"workstreams": result})
|
||||
|
||||
|
||||
async def list_templates_summary(request: Request) -> JSONResponse:
|
||||
"""GET /v1/api/templates — list available prompt templates (read scope)."""
|
||||
from turnstone.core.storage._registry import get_storage
|
||||
|
||||
try:
|
||||
storage = get_storage()
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Storage not available"}, status_code=503)
|
||||
templates = storage.list_prompt_templates()
|
||||
summaries = [
|
||||
{
|
||||
"name": t["name"],
|
||||
"category": t.get("category", ""),
|
||||
"is_default": bool(t.get("is_default")),
|
||||
"origin": t.get("origin", "manual"),
|
||||
}
|
||||
for t in templates
|
||||
]
|
||||
return JSONResponse({"templates": summaries})
|
||||
|
||||
|
||||
async def list_ws_templates_summary(request: Request) -> JSONResponse:
|
||||
"""GET /v1/api/ws-templates — enabled workstream templates summary (read scope)."""
|
||||
from turnstone.core.storage._registry import get_storage
|
||||
|
||||
try:
|
||||
storage = get_storage()
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Storage not available"}, status_code=503)
|
||||
templates = storage.list_ws_templates(enabled_only=True)
|
||||
summaries = [
|
||||
{"name": t["name"], "description": t.get("description", ""), "model": t.get("model", "")}
|
||||
for t in templates
|
||||
]
|
||||
return JSONResponse({"ws_templates": summaries})
|
||||
|
||||
|
||||
def _count_ws_states(wss: list[Workstream]) -> dict[str, int]:
|
||||
"""Count workstream states for health/metrics endpoints."""
|
||||
counts = dict.fromkeys(("idle", "thinking", "running", "attention", "error"), 0)
|
||||
@@ -1178,11 +1215,27 @@ async def create_workstream(request: Request) -> JSONResponse:
|
||||
resolved_model = body.get("model") or None
|
||||
if ws_tpl and ws_tpl.get("model"):
|
||||
resolved_model = ws_tpl["model"]
|
||||
# Pre-validate prompt template before creating the workstream.
|
||||
# This avoids the create-then-rollback pattern when template is invalid.
|
||||
# Skip when resuming — resumed workstreams restore their own template
|
||||
# from workstream_config, so the request's template is irrelevant.
|
||||
ws_tpl_overrides_prompt = bool(
|
||||
ws_tpl and (ws_tpl["system_prompt"] or ws_tpl["prompt_template"])
|
||||
)
|
||||
resume_ws_id = body.get("resume_ws", "")
|
||||
resolved_template: str | None = None
|
||||
if body_template and not ws_tpl_overrides_prompt and not resume_ws_id:
|
||||
from turnstone.core.memory import get_prompt_template_by_name
|
||||
|
||||
if not get_prompt_template_by_name(body_template):
|
||||
return JSONResponse({"error": f"Template not found: {body_template}"}, status_code=400)
|
||||
resolved_template = body_template
|
||||
try:
|
||||
ws = mgr.create(
|
||||
name=body.get("name", ""),
|
||||
ui_factory=lambda wid: WebUI(ws_id=wid, user_id=uid),
|
||||
model=resolved_model,
|
||||
template=resolved_template,
|
||||
)
|
||||
assert isinstance(ws.ui, WebUI)
|
||||
if skip or body.get("auto_approve", False):
|
||||
@@ -1209,7 +1262,6 @@ async def create_workstream(request: Request) -> JSONResponse:
|
||||
# Atomic workstream resume during creation.
|
||||
resumed = False
|
||||
message_count = 0
|
||||
resume_ws_id = body.get("resume_ws", "")
|
||||
if resume_ws_id and ws.session is not None:
|
||||
from turnstone.core.memory import get_workstream_display_name, resolve_workstream
|
||||
|
||||
@@ -1225,23 +1277,6 @@ async def create_workstream(request: Request) -> JSONResponse:
|
||||
if history:
|
||||
ui._enqueue({"type": "history", "messages": history})
|
||||
|
||||
# Per-workstream template override — only when not resumed (resumed
|
||||
# workstreams restore their own template from workstream_config).
|
||||
# Skip validation when the ws_template will override the prompt anyway.
|
||||
ws_tpl_overrides_prompt = bool(
|
||||
ws_tpl and (ws_tpl["system_prompt"] or ws_tpl["prompt_template"])
|
||||
)
|
||||
if body_template and not resumed and ws.session and not ws_tpl_overrides_prompt:
|
||||
from turnstone.core.memory import get_prompt_template_by_name
|
||||
|
||||
if not get_prompt_template_by_name(body_template):
|
||||
# Workstream already created — close it and return error
|
||||
mgr.close(ws.id)
|
||||
return JSONResponse(
|
||||
{"error": f"Template not found: {body_template}"}, status_code=400
|
||||
)
|
||||
ws.session.set_template(body_template)
|
||||
|
||||
# Apply workstream template settings (only for new workstreams)
|
||||
if ws_tpl and not resumed and ws.session:
|
||||
sess = ws.session
|
||||
@@ -1735,6 +1770,8 @@ def create_app(
|
||||
Route("/api/workstreams", list_workstreams),
|
||||
Route("/api/dashboard", dashboard),
|
||||
Route("/api/workstreams/saved", list_saved_workstreams),
|
||||
Route("/api/templates", list_templates_summary),
|
||||
Route("/api/ws-templates", list_ws_templates_summary),
|
||||
Route("/api/send", send_message, methods=["POST"]),
|
||||
Route("/api/approve", approve, methods=["POST"]),
|
||||
Route("/api/plan", plan_feedback, methods=["POST"]),
|
||||
@@ -2045,6 +2082,8 @@ def main() -> None:
|
||||
ui: SessionUI | None,
|
||||
model_alias: str | None = None,
|
||||
ws_id: str | None = None,
|
||||
*,
|
||||
template: str | None = None,
|
||||
) -> ChatSession:
|
||||
assert ui is not None
|
||||
r_client, r_model, r_cfg = registry.resolve(model_alias)
|
||||
@@ -2077,7 +2116,7 @@ def main() -> None:
|
||||
tool_search=config_store.get("tools.search"),
|
||||
tool_search_threshold=config_store.get("tools.search_threshold"),
|
||||
tool_search_max_results=config_store.get("tools.search_max_results"),
|
||||
template=args.template,
|
||||
template=template if template is not None else args.template,
|
||||
judge_config=live_judge_config,
|
||||
user_id=uid,
|
||||
memory_config=live_memory_config,
|
||||
|
||||
+169
-1
@@ -378,20 +378,185 @@ function switchTab(wsId) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// New workstream modal
|
||||
// ---------------------------------------------------------------------------
|
||||
var _newWsTrapHandler = null;
|
||||
|
||||
function newWorkstream() {
|
||||
showNewWsModal();
|
||||
}
|
||||
|
||||
function showNewWsModal() {
|
||||
var overlay = document.getElementById("new-ws-overlay");
|
||||
overlay.style.display = "flex";
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
// Backdrop click to dismiss
|
||||
overlay.onclick = function (e) {
|
||||
if (e.target === overlay) hideNewWsModal();
|
||||
};
|
||||
|
||||
// Populate model placeholder from header
|
||||
var curModel = document.getElementById("model-name").textContent;
|
||||
var modelInput = document.getElementById("new-ws-model");
|
||||
modelInput.placeholder = curModel || "Default model";
|
||||
modelInput.value = "";
|
||||
|
||||
// Populate template dropdown
|
||||
var tplSelect = document.getElementById("new-ws-template");
|
||||
tplSelect.innerHTML = '<option value="">Use defaults</option>';
|
||||
authFetch("/v1/api/templates")
|
||||
.then(function (r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
(data.templates || []).forEach(function (t) {
|
||||
var opt = document.createElement("option");
|
||||
opt.value = t.name;
|
||||
var label = t.name;
|
||||
if (t.is_default) label += " (default)";
|
||||
if (t.origin === "mcp") label += " [MCP]";
|
||||
opt.textContent = label;
|
||||
tplSelect.appendChild(opt);
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
/* ignore — defaults still work */
|
||||
});
|
||||
|
||||
// Populate profile (WS template) dropdown
|
||||
var profSelect = document.getElementById("new-ws-profile");
|
||||
profSelect.innerHTML = '<option value="">None</option>';
|
||||
authFetch("/v1/api/ws-templates")
|
||||
.then(function (r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
(data.ws_templates || []).forEach(function (t) {
|
||||
var opt = document.createElement("option");
|
||||
opt.value = t.name;
|
||||
var label = t.name;
|
||||
if (t.model) label += " (" + t.model + ")";
|
||||
opt.textContent = label;
|
||||
profSelect.appendChild(opt);
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
/* ignore — profiles optional */
|
||||
});
|
||||
|
||||
// Reset form
|
||||
document.getElementById("new-ws-name").value = "";
|
||||
var errEl = document.getElementById("new-ws-error");
|
||||
errEl.style.display = "none";
|
||||
errEl.textContent = "";
|
||||
var submitBtn = document.getElementById("new-ws-submit");
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = "Create";
|
||||
|
||||
// Wire buttons
|
||||
document.getElementById("new-ws-cancel").onclick = hideNewWsModal;
|
||||
submitBtn.onclick = submitNewWs;
|
||||
|
||||
// Focus trap
|
||||
_newWsTrapHandler = function (e) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
hideNewWsModal();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
e.target.tagName !== "TEXTAREA" &&
|
||||
e.target.tagName !== "SELECT"
|
||||
) {
|
||||
e.preventDefault();
|
||||
submitNewWs();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab") return;
|
||||
var box = document.getElementById("new-ws-box");
|
||||
var focusable = box.querySelectorAll(
|
||||
'input, select, button, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
if (!focusable.length) return;
|
||||
var first = focusable[0],
|
||||
last = focusable[focusable.length - 1];
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", _newWsTrapHandler);
|
||||
setTimeout(function () {
|
||||
document.getElementById("new-ws-name").focus();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function hideNewWsModal() {
|
||||
document.getElementById("new-ws-overlay").style.display = "none";
|
||||
document.body.style.overflow = "";
|
||||
if (_newWsTrapHandler) {
|
||||
document.removeEventListener("keydown", _newWsTrapHandler);
|
||||
_newWsTrapHandler = null;
|
||||
}
|
||||
document.getElementById("new-tab-btn").focus();
|
||||
}
|
||||
|
||||
function submitNewWs() {
|
||||
var submitBtn = document.getElementById("new-ws-submit");
|
||||
if (submitBtn.disabled) return;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = "Creating\u2026";
|
||||
|
||||
var body = {};
|
||||
var name = document.getElementById("new-ws-name").value.trim();
|
||||
var model = document.getElementById("new-ws-model").value.trim();
|
||||
var template = document.getElementById("new-ws-template").value;
|
||||
var profile = document.getElementById("new-ws-profile").value;
|
||||
if (name) body.name = name;
|
||||
if (model) body.model = model;
|
||||
if (template) body.template = template;
|
||||
if (profile) body.ws_template = profile;
|
||||
|
||||
var errEl = document.getElementById("new-ws-error");
|
||||
errEl.style.display = "none";
|
||||
|
||||
authFetch("/v1/api/workstreams/new", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
.then(function (r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
if (data.error) {
|
||||
errEl.textContent = data.error;
|
||||
errEl.style.display = "block";
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = "Create";
|
||||
return;
|
||||
}
|
||||
if (data.ws_id) {
|
||||
workstreams[data.ws_id] = { name: data.name, state: "idle" };
|
||||
hideNewWsModal();
|
||||
switchTab(data.ws_id);
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
errEl.textContent = "Failed to create workstream";
|
||||
errEl.style.display = "block";
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = "Create";
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1832,6 +1997,9 @@ document
|
||||
|
||||
// Keyboard shortcuts for inline approval + plan dialog + tabs
|
||||
document.addEventListener("keydown", function (e) {
|
||||
// Defer to modal's own keydown handler when new-ws modal is open
|
||||
var nwsOverlay = document.getElementById("new-ws-overlay");
|
||||
if (nwsOverlay && nwsOverlay.style.display !== "none") return;
|
||||
// Escape: close hamburger first, then dashboard
|
||||
if (
|
||||
e.key === "Escape" &&
|
||||
|
||||
@@ -73,6 +73,26 @@
|
||||
|
||||
<div id="messages" role="log" aria-live="polite" aria-label="Chat messages"></div>
|
||||
|
||||
<!-- New workstream modal -->
|
||||
<div id="new-ws-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="new-ws-title">
|
||||
<div id="new-ws-box">
|
||||
<h3 id="new-ws-title">New Workstream</h3>
|
||||
<div id="new-ws-error" role="alert" aria-live="assertive"></div>
|
||||
<label for="new-ws-name">Name <span class="nws-hint">optional</span></label>
|
||||
<input id="new-ws-name" type="text" placeholder="Auto-generated if empty" autocomplete="off">
|
||||
<label for="new-ws-model">Model <span class="nws-hint">optional</span></label>
|
||||
<input id="new-ws-model" type="text" placeholder="Default model" autocomplete="off">
|
||||
<label for="new-ws-template">Template <span class="nws-hint">optional</span></label>
|
||||
<select id="new-ws-template"><option value="">Use defaults</option></select>
|
||||
<label for="new-ws-profile">Profile <span class="nws-hint">optional — workstream template</span></label>
|
||||
<select id="new-ws-profile"><option value="">None</option></select>
|
||||
<div id="new-ws-buttons">
|
||||
<button id="new-ws-cancel" type="button">Cancel</button>
|
||||
<button id="new-ws-submit" type="button">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plan review dialog -->
|
||||
<div id="plan-overlay">
|
||||
<div id="plan-dialog" role="dialog" aria-modal="true" aria-labelledby="plan-dialog-title">
|
||||
|
||||
@@ -931,6 +931,141 @@ body { position: static; }
|
||||
border-color: var(--yellow);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
New workstream modal
|
||||
========================================================================== */
|
||||
#new-ws-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
#new-ws-box {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
padding: 28px 32px 24px;
|
||||
width: 380px;
|
||||
max-width: 90vw;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 24px 48px -12px rgba(0, 0, 0, 0.5),
|
||||
0 0 80px -20px var(--accent-dim);
|
||||
position: relative;
|
||||
}
|
||||
#new-ws-box::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: 20%;
|
||||
right: 20%;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent), transparent);
|
||||
border-radius: 1px;
|
||||
}
|
||||
#new-ws-box h3 {
|
||||
font-family: var(--font-display);
|
||||
color: var(--accent);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
#new-ws-box label {
|
||||
display: block;
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--fg-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin-top: 14px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
#new-ws-box label:first-of-type { margin-top: 0; }
|
||||
.nws-hint {
|
||||
font-weight: 400;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
#new-ws-box input[type="text"],
|
||||
#new-ws-box select {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
#new-ws-box input[type="text"]:focus,
|
||||
#new-ws-box select:focus {
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--accent-dim);
|
||||
}
|
||||
#new-ws-box input[type="text"]::placeholder { color: var(--fg-dim); }
|
||||
#new-ws-box select {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%238a93ad' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
padding-right: 32px;
|
||||
}
|
||||
#new-ws-error {
|
||||
color: var(--red);
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
display: none;
|
||||
}
|
||||
#new-ws-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
#new-ws-cancel {
|
||||
padding: 9px 20px;
|
||||
background: var(--bg-highlight);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-display);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
#new-ws-cancel:hover { background: var(--bg-elevated); }
|
||||
#new-ws-cancel:focus-visible { outline: 2px solid var(--fg-bright); outline-offset: 2px; }
|
||||
#new-ws-submit {
|
||||
padding: 9px 20px;
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-display);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: filter 0.15s;
|
||||
}
|
||||
#new-ws-submit:hover { filter: brightness(1.1); }
|
||||
#new-ws-submit:disabled { opacity: 0.4; cursor: not-allowed; filter: none; }
|
||||
#new-ws-submit:focus-visible { outline: 2px solid var(--fg-bright); outline-offset: 2px; }
|
||||
|
||||
/* ==========================================================================
|
||||
Reduced motion — page-specific
|
||||
========================================================================== */
|
||||
@@ -948,5 +1083,7 @@ body { position: static; }
|
||||
.dashboard-new-btn, .dashboard-input,
|
||||
#health-indicator, #hamburger-btn,
|
||||
#mcp-status, .msg-assistant tbody tr,
|
||||
.msg-assistant .img-placeholder { transition: none; }
|
||||
.msg-assistant .img-placeholder,
|
||||
#new-ws-cancel, #new-ws-submit,
|
||||
#new-ws-box input, #new-ws-box select { transition: none; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user