v1.12.1: rich status indicator + server-side workspace pane sync

Status indicator (StatusDot): drops the flat amber pulse for a richer set
of states — orbiting amber for streaming, spinning sky ring for tool_running,
static violet for waiting_for_input, plus the existing idle/error. Backend
chat_status frame widens from 'working|idle|error' to discriminate streaming
vs tool execution vs paused for user input.

Workspace pane sync: pane layout moves from per-device localStorage to
server-side sessions.workspace_panes jsonb. PATCH /api/sessions/:id/workspace
broadcasts session_workspace_updated on the user channel for cross-device live
sync. Echo dedup via JSON comparison so the round-trip frame doesn't loop.
Legacy localStorage seeds the server on first hydrate, then is deleted.
Deprecated session_panes table dropped.

Resilience: startup sweep marks any stale 'streaming' message older than
5 minutes as 'failed' so v1.12.0-style hung rows clear on container restart.
useWorkspacePanes gains validatePanes() to prune dead chatId references from
saved pane state when the chat list lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 20:32:02 +00:00
parent d58d553503
commit 48ee63a286
15 changed files with 371 additions and 180 deletions

View File

@@ -49,6 +49,18 @@ async function main() {
await applySchema(sql);
app.log.info('database schema applied');
const swept = await sql<{ count: string }[]>`
WITH swept AS (
UPDATE messages SET status = 'failed'
WHERE status = 'streaming' AND created_at < NOW() - INTERVAL '5 minutes'
RETURNING id
) SELECT count(*)::text AS count FROM swept
`;
const sweptCount = Number(swept[0]?.count ?? 0);
if (sweptCount > 0) {
app.log.info({ sweptCount }, 'swept stale streaming messages to failed');
}
// v1.11.3: tell the model-context cache where llama-swap lives. Cache
// lookups go to ${LLAMA_SWAP_URL}/upstream/<model>/props to read
// default_generation_settings.n_ctx — the value persisted as messages.ctx_max.

View File

@@ -13,6 +13,18 @@ const CreateBody = z.object({
agent_id: z.string().min(1).max(200).nullable().optional(),
});
const WorkspacePaneZ = z.object({
id: z.string().min(1).max(200),
kind: z.enum(['chat', 'terminal', 'agent', 'empty', 'settings']),
chatId: z.string().min(1).max(200).optional(),
chatIds: z.array(z.string().min(1).max(200)).max(50),
activeChatIdx: z.number().int(),
});
const WorkspacePanesBody = z.object({
workspace_panes: z.array(WorkspacePaneZ).max(10),
});
const PatchBody = z.object({
name: z.string().min(1).max(200).optional(),
model: z.string().min(1).max(200).optional(),
@@ -44,7 +56,7 @@ export function registerSessionRoutes(
}
const status = req.query.status === 'archived' ? 'archived' : 'open';
const rows = await sql<Session[]>`
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes
FROM sessions
WHERE project_id = ${req.params.id} AND status = ${status}
ORDER BY updated_at DESC
@@ -92,7 +104,7 @@ export function registerSessionRoutes(
const [session] = await tx<Session[]>`
INSERT INTO sessions (project_id, name, model, system_prompt, agent_id)
VALUES (${req.params.id}, ${name}, ${model}, ${systemPrompt}, ${agentId})
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes
`;
await tx`
INSERT INTO chats (session_id, name, status)
@@ -112,7 +124,7 @@ export function registerSessionRoutes(
app.get<{ Params: { id: string } }>('/api/sessions/:id', async (req, reply) => {
const rows = await sql<Session[]>`
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes
FROM sessions WHERE id = ${req.params.id}
`;
if (rows.length === 0) {
@@ -158,7 +170,7 @@ export function registerSessionRoutes(
updated_at = clock_timestamp()
WHERE id = ${req.params.id}
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
agent_id, web_search_enabled
agent_id, web_search_enabled, workspace_panes
`;
if (rows.length === 0) {
reply.code(404);
@@ -187,6 +199,36 @@ export function registerSessionRoutes(
}
);
app.patch<{ Params: { id: string } }>(
'/api/sessions/:id/workspace',
async (req, reply) => {
const parsed = WorkspacePanesBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const rows = await sql<Session[]>`
UPDATE sessions
SET workspace_panes = ${sql.json(parsed.data.workspace_panes as never)},
updated_at = clock_timestamp()
WHERE id = ${req.params.id}
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
agent_id, web_search_enabled, workspace_panes
`;
if (rows.length === 0) {
reply.code(404);
return { error: 'session not found' };
}
const session = rows[0]!;
broker.publishUser('default', {
type: 'session_workspace_updated',
session_id: session.id,
workspace_panes: session.workspace_panes,
});
return session;
}
);
// v1.9: bulk-archive every open session in a project. Mirrors the
// single-archive shape (same broker frame type) so the existing useSidebar
// reducer cases handle it without changes — just N frames instead of 1.
@@ -263,7 +305,7 @@ export function registerSessionRoutes(
const rows = await sql<Session[]>`
UPDATE sessions SET status = 'open', updated_at = clock_timestamp()
WHERE id = ${req.params.id} AND status = 'archived'
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes
`;
if (rows.length === 0) {
reply.code(404);

View File

@@ -47,22 +47,14 @@ CREATE TABLE IF NOT EXISTS settings (
INSERT INTO settings (key, value) VALUES ('default_model', '"qwen3.6-35b-a3b-mxfp4"') ON CONFLICT (key) DO NOTHING;
-- DEPRECATED: client-side pane state as of v1.2-batch4. Table retained per
-- additive schema rule; no writes. Drop in a future destructive migration.
CREATE TABLE IF NOT EXISTS session_panes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('chat', 'file_browser', 'terminal')),
state JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
UNIQUE (session_id, position)
);
CREATE INDEX IF NOT EXISTS idx_session_panes_session ON session_panes (session_id);
-- v1.12.1: deprecated session_panes table removed. Workspace pane state now
-- lives in sessions.workspace_panes (jsonb), see below.
DROP TABLE IF EXISTS session_panes;
-- v1.4: backfill removed. Pane layout is client-side (localStorage) since v1.2-batch4.
-- The CREATE TABLE above is retained for additive-schema discipline; drop is a
-- future destructive migration.
-- v1.12.1: server-side workspace pane layout, replaces localStorage so every
-- device sees the same panes for a given session. Shape matches
-- WorkspacePane[] from apps/server/src/types/api.ts.
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS workspace_panes JSONB NOT NULL DEFAULT '[]'::jsonb;
-- v1.2: sessions.status (open | archived)
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open';

View File

@@ -39,6 +39,19 @@ export interface Session {
// project.default_web_search_enabled. Plumbed but inert in v1.9 — the
// actual web_search tool ships in Batch 8.
web_search_enabled: boolean | null;
// v1.12.1: server-side workspace pane layout. Replaces per-device
// localStorage so all devices viewing the session see the same panes.
workspace_panes: WorkspacePane[];
}
export type WorkspacePaneKind = 'chat' | 'terminal' | 'agent' | 'empty' | 'settings';
export interface WorkspacePane {
id: string;
kind: WorkspacePaneKind;
chatId?: string;
chatIds: string[];
activeChatIdx: number;
}
// v1.8.1: agents come from two sources. 'global' = /data/AGENTS.md (always
@@ -273,6 +286,11 @@ export interface SessionRenamedFrame {
session_id: string;
name: string;
}
export interface SessionWorkspaceUpdatedFrame {
type: 'session_workspace_updated';
session_id: string;
workspace_panes: WorkspacePane[];
}
export interface SessionArchivedFrame {
type: 'session_archived';
session_id: string;
@@ -324,7 +342,7 @@ export interface ProjectUpdatedFrame {
export interface ChatStatusFrame {
type: 'chat_status';
chat_id: string;
status: 'working' | 'idle' | 'error';
status: 'streaming' | 'tool_running' | 'waiting_for_input' | 'idle' | 'error';
at: string;
reason?: ErrorReason;
}
@@ -335,6 +353,7 @@ export type UserStreamFrame =
| SessionDeletedFrame
| SessionUpdatedFrame
| SessionRenamedFrame
| SessionWorkspaceUpdatedFrame
| SessionArchivedFrame
| ChatCreatedFrame
| ChatUpdatedFrame