Compare commits
5 Commits
12d31a81a0
...
v2.6.5-pan
| Author | SHA1 | Date | |
|---|---|---|---|
| 5527e7a5e8 | |||
| 08d6a8fa40 | |||
| 2fd7e5bf97 | |||
| d05f73be26 | |||
| e857815d79 |
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
|
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
|
||||||
|
|
||||||
|
## v2.6.5-panes-tabs-composer — 2026-05-31
|
||||||
|
|
||||||
|
A workspace UX batch across BooChat panes, tabs, and the composer, plus the persistence model that backs them. **Panes & tabs:** a chat can be opened in a fresh pane (the ChatTabBar tab context menu's "Open in new pane", and the fork button — which now lands the fork beside the original via a new `open_chat_in_new_pane` event instead of replacing the active pane); the per-pane "+" became a New BooChat/BooTerm/BooCode menu; closing a chat pane relocates its tabs (in order) into the oldest chat/empty pane instead of discarding them, and reopen strips the restored chatIds from every live pane first so a relocated-then-reopened pane never duplicates a tab (no stack-shape change); each tab carries a stable session-scoped number assigned on open and retired on close (never reused), rendered map-keyed rather than positional. The per-message "Open in pane" artifact button was removed, and the empty/landing pane became a real session history — the session's open chats plus separately-fetched archived chats, click to open or restore-and-open. **Persistence:** `sessions.workspace_panes` was widened from a bare `WorkspacePane[]` to a `WorkspaceState` envelope (`panes` + `tabNumbers`/`nextTabNumber` + `closedPaneStack`) so tab numbers and the reopen stack survive reload; the PATCH validator accepts the legacy array or the envelope (zod union) and migrates on write, and the `session_workspace_updated` WS-frame schema was widened on both web and server (byte-identical, parity test green) — the same schema-drift class as `v2.6.4-agent-sessions-fk`. **Composer:** the send button morphs Send → Stop → Queue with generation state (BooCoder keys on `sending || activeTaskId`, which also corrected its queue gates and added `cancelTask`), the standalone "Stop generating" pill was folded into it, and pasted chips now trail the typed text so a leading slash command stays first. **Tooling:** adds the read-only `read_tab_by_number` tool — resolves a session-scoped tab number to its chat via the persisted `tabNumbers` map and returns that chat's transcript; tools gained an optional `ToolExecCtx` (`{ sql, sessionId }`) on `execute` to support DB-reading tools. Builds on `v2.6.4-agent-sessions-fk`.
|
||||||
|
|
||||||
## v2.6.4-agent-sessions-fk — 2026-05-31
|
## v2.6.4-agent-sessions-fk — 2026-05-31
|
||||||
|
|
||||||
Follow-up to `v2.6.3-chatkey-and-skills` (P1.5-b): the live `agent_sessions.session_id` foreign key is converged from `ON DELETE CASCADE` to `ON DELETE SET NULL`, matching the schema's stated intent. The P1.5-b re-key block re-adds `session_id_fkey` as `SET NULL`, but the whole block is guarded on `chat_id_fkey`'s absence — so a database already re-keyed to `(chat_id, agent)` while `session_id_fkey` was still `CASCADE` never re-enters it, leaving the live FK at `CASCADE` and diverging from both `worktree_id` (already `SET NULL`) and the `v2.6.3` changelog's own claim that `session_id` is informational `SET NULL`. The fix adds a standalone `confdeltype`-guarded `DO` block (mirroring the `session_worktrees` defang) that flips `session_id_fkey` `CASCADE → SET NULL` independently of the re-key gate; it is idempotent — fires only while the FK is still `'c'`, a no-op on a fresh deploy (already `'n'`) and on every re-run. The live DB was converged by hand with the identical statements, so `applySchema` and the hand-applied state match (`\d agent_sessions` now shows `session_id ... ON DELETE SET NULL`). Also bundles a CLAUDE.md doc-sync (committed separately): per-session SSE (P1.5-a) and the `(chat_id, agent)` re-key reflected in the engineering notes, the stale root `AGENTS.md` navigation pointer dropped, and new conventions for `data/AGENTS.md` parsing and the `data/skills/<vendor>/` layout.
|
Follow-up to `v2.6.3-chatkey-and-skills` (P1.5-b): the live `agent_sessions.session_id` foreign key is converged from `ON DELETE CASCADE` to `ON DELETE SET NULL`, matching the schema's stated intent. The P1.5-b re-key block re-adds `session_id_fkey` as `SET NULL`, but the whole block is guarded on `chat_id_fkey`'s absence — so a database already re-keyed to `(chat_id, agent)` while `session_id_fkey` was still `CASCADE` never re-enters it, leaving the live FK at `CASCADE` and diverging from both `worktree_id` (already `SET NULL`) and the `v2.6.3` changelog's own claim that `session_id` is informational `SET NULL`. The fix adds a standalone `confdeltype`-guarded `DO` block (mirroring the `session_worktrees` defang) that flips `session_id_fkey` `CASCADE → SET NULL` independently of the re-key gate; it is idempotent — fires only while the FK is still `'c'`, a no-op on a fresh deploy (already `'n'`) and on every re-run. The live DB was converged by hand with the identical statements, so `applySchema` and the hand-applied state match (`\d agent_sessions` now shows `session_id ... ON DELETE SET NULL`). Also bundles a CLAUDE.md doc-sync (committed separately): per-session SSE (P1.5-a) and the `(chat_id, agent)` re-key reflected in the engineering notes, the stale root `AGENTS.md` navigation pointer dropped, and new conventions for `data/AGENTS.md` parsing and the `data/skills/<vendor>/` layout.
|
||||||
|
|||||||
@@ -28,18 +28,20 @@ const HtmlArtifactStateZ = z.object({
|
|||||||
title: z.string().max(500),
|
title: z.string().max(500),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const PaneKindZ = z.enum([
|
||||||
|
'chat',
|
||||||
|
'terminal',
|
||||||
|
'coder',
|
||||||
|
'agent', // legacy alias — normalized to coder on write
|
||||||
|
'empty',
|
||||||
|
'settings',
|
||||||
|
'markdown_artifact',
|
||||||
|
'html_artifact',
|
||||||
|
]);
|
||||||
|
|
||||||
const WorkspacePaneZ = z.object({
|
const WorkspacePaneZ = z.object({
|
||||||
id: z.string().min(1).max(200),
|
id: z.string().min(1).max(200),
|
||||||
kind: z.enum([
|
kind: PaneKindZ,
|
||||||
'chat',
|
|
||||||
'terminal',
|
|
||||||
'coder',
|
|
||||||
'agent', // legacy alias — normalized to coder on write
|
|
||||||
'empty',
|
|
||||||
'settings',
|
|
||||||
'markdown_artifact',
|
|
||||||
'html_artifact',
|
|
||||||
]),
|
|
||||||
chatId: z.string().min(1).max(200).optional(),
|
chatId: z.string().min(1).max(200).optional(),
|
||||||
chatIds: z.array(z.string().min(1).max(200)).max(50),
|
chatIds: z.array(z.string().min(1).max(200)).max(50),
|
||||||
activeChatIdx: z.number().int(),
|
activeChatIdx: z.number().int(),
|
||||||
@@ -47,8 +49,27 @@ const WorkspacePaneZ = z.object({
|
|||||||
html_artifact_state: HtmlArtifactStateZ.optional(),
|
html_artifact_state: HtmlArtifactStateZ.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v2.6.x: workspace_panes column widened from a bare WorkspacePane[] to a
|
||||||
|
// WorkspaceState envelope (panes + stable session-scoped tab numbering +
|
||||||
|
// reopen stack). closedPaneStack entries are lighter than full panes — just
|
||||||
|
// the kind + chat ids needed to recreate a closed pane on reopen.
|
||||||
|
const ClosedPaneEntryZ = z.object({
|
||||||
|
kind: PaneKindZ,
|
||||||
|
chatIds: z.array(z.string().min(1).max(200)).max(50),
|
||||||
|
activeChatIdx: z.number().int(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const WorkspaceStateZ = z.object({
|
||||||
|
panes: z.array(WorkspacePaneZ).max(10),
|
||||||
|
tabNumbers: z.record(z.string(), z.number().int()).default({}),
|
||||||
|
nextTabNumber: z.number().int().default(1),
|
||||||
|
closedPaneStack: z.array(ClosedPaneEntryZ).max(10).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Accept either the legacy bare array OR the envelope. The handler normalizes
|
||||||
|
// to a full envelope before storing (see MIGRATION rule in the PATCH handler).
|
||||||
const WorkspacePanesBody = z.object({
|
const WorkspacePanesBody = z.object({
|
||||||
workspace_panes: z.array(WorkspacePaneZ).max(10),
|
workspace_panes: z.union([z.array(WorkspacePaneZ).max(10), WorkspaceStateZ]),
|
||||||
});
|
});
|
||||||
|
|
||||||
const PatchBody = z.object({
|
const PatchBody = z.object({
|
||||||
@@ -308,12 +329,20 @@ export function registerSessionRoutes(
|
|||||||
reply.code(400);
|
reply.code(400);
|
||||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||||
}
|
}
|
||||||
const workspacePanes = parsed.data.workspace_panes.map((pane) =>
|
// v2.6.x MIGRATION: the body is either a legacy bare WorkspacePane[] or
|
||||||
|
// the WorkspaceState envelope. Normalize to a full envelope so the column
|
||||||
|
// always stores the envelope shape going forward.
|
||||||
|
const body = parsed.data.workspace_panes;
|
||||||
|
const envelope = Array.isArray(body)
|
||||||
|
? { panes: body, tabNumbers: {}, nextTabNumber: 1, closedPaneStack: [] }
|
||||||
|
: body;
|
||||||
|
// agent → coder normalization on the panes array (unchanged write rule).
|
||||||
|
envelope.panes = envelope.panes.map((pane) =>
|
||||||
pane.kind === 'agent' ? { ...pane, kind: 'coder' as const } : pane,
|
pane.kind === 'agent' ? { ...pane, kind: 'coder' as const } : pane,
|
||||||
);
|
);
|
||||||
const rows = await sql<Session[]>`
|
const rows = await sql<Session[]>`
|
||||||
UPDATE sessions
|
UPDATE sessions
|
||||||
SET workspace_panes = ${sql.json(workspacePanes as never)},
|
SET workspace_panes = ${sql.json(envelope as never)},
|
||||||
updated_at = clock_timestamp()
|
updated_at = clock_timestamp()
|
||||||
WHERE id = ${req.params.id}
|
WHERE id = ${req.params.id}
|
||||||
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
|
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Agent, Session, ToolCall } from '../../types/api.js';
|
|||||||
import * as modelContext from '../model-context.js';
|
import * as modelContext from '../model-context.js';
|
||||||
import { PathScopeError } from '../path_guard.js';
|
import { PathScopeError } from '../path_guard.js';
|
||||||
import { TOOLS_BY_NAME } from '../tools.js';
|
import { TOOLS_BY_NAME } from '../tools.js';
|
||||||
|
import type { ToolExecCtx } from '../tools.js';
|
||||||
import { matchToolGlob } from '../agents.js';
|
import { matchToolGlob } from '../agents.js';
|
||||||
import { maybeFlagForCompaction } from './payload.js';
|
import { maybeFlagForCompaction } from './payload.js';
|
||||||
import { insertParts, partsFromAssistantMessage, partsFromToolMessage } from './parts.js';
|
import { insertParts, partsFromAssistantMessage, partsFromToolMessage } from './parts.js';
|
||||||
@@ -31,6 +32,7 @@ async function executeToolCall(
|
|||||||
projectRoot: string,
|
projectRoot: string,
|
||||||
toolCall: ToolCall,
|
toolCall: ToolCall,
|
||||||
extraRoots: readonly string[],
|
extraRoots: readonly string[],
|
||||||
|
toolCtx?: ToolExecCtx,
|
||||||
): Promise<{ output: unknown; truncated: boolean; error?: string }> {
|
): Promise<{ output: unknown; truncated: boolean; error?: string }> {
|
||||||
const tool = TOOLS_BY_NAME[toolCall.name];
|
const tool = TOOLS_BY_NAME[toolCall.name];
|
||||||
if (!tool) {
|
if (!tool) {
|
||||||
@@ -65,7 +67,7 @@ async function executeToolCall(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const output = await tool.execute(parsed.data, projectRoot, extraRoots);
|
const output = await tool.execute(parsed.data, projectRoot, extraRoots, toolCtx);
|
||||||
const truncated =
|
const truncated =
|
||||||
typeof output === 'object' && output !== null && 'truncated' in output
|
typeof output === 'object' && output !== null && 'truncated' in output
|
||||||
? Boolean((output as { truncated: unknown }).truncated)
|
? Boolean((output as { truncated: unknown }).truncated)
|
||||||
@@ -289,7 +291,10 @@ export async function executeToolPhase(
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tres = await executeToolCall(projectRoot, tc, session.allowed_read_paths);
|
const tres = await executeToolCall(projectRoot, tc, session.allowed_read_paths, {
|
||||||
|
sql: ctx.sql,
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
if (SYNTHESIS_TOOLS.has(tc.name)) {
|
if (SYNTHESIS_TOOLS.has(tc.name)) {
|
||||||
synthEntries.push({ tc, output: tres.output, ...(tres.error ? { error: tres.error } : {}) });
|
synthEntries.push({ tc, output: tres.output, ...(tres.error ? { error: tres.error } : {}) });
|
||||||
}
|
}
|
||||||
|
|||||||
142
apps/server/src/services/read_tab_by_number.ts
Normal file
142
apps/server/src/services/read_tab_by_number.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
// v2.6.x: read_tab_by_number tool. Reads the conversation transcript of the
|
||||||
|
// chat that occupies a given session-scoped tab number. Stable tab numbers are
|
||||||
|
// stored in the session's workspace_panes envelope (WorkspaceState.tabNumbers),
|
||||||
|
// keyed by chat id. Lives in its own file (not appended to tools.ts) so tests
|
||||||
|
// can import the executor directly without dragging in the whole tool registry.
|
||||||
|
// Registered in tools.ts ALL_TOOLS + READ_ONLY_TOOL_NAMES.
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
|
// type-only import to dodge the runtime cycle (tools.ts re-exports this tool
|
||||||
|
// via ALL_TOOLS; importing ToolDef/ToolExecCtx at type level keeps the dep
|
||||||
|
// one-way).
|
||||||
|
import type { ToolDef, ToolExecCtx } from './tools.js';
|
||||||
|
|
||||||
|
const ReadTabByNumberInput = z.object({
|
||||||
|
number: z.number().int().positive(),
|
||||||
|
});
|
||||||
|
export type ReadTabByNumberInputT = z.infer<typeof ReadTabByNumberInput>;
|
||||||
|
|
||||||
|
// Cap total transcript size so a long conversation can't blow the context
|
||||||
|
// window. The model gets a clear truncation marker when the cap is hit.
|
||||||
|
const MAX_TRANSCRIPT_CHARS = 20_000;
|
||||||
|
|
||||||
|
// WorkspaceState envelope shape (panes omitted — we only need tabNumbers here).
|
||||||
|
interface WorkspaceStateLike {
|
||||||
|
panes?: unknown;
|
||||||
|
tabNumbers?: Record<string, number>;
|
||||||
|
nextTabNumber?: number;
|
||||||
|
closedPaneStack?: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// MIGRATION: the stored workspace_panes value may be the legacy bare
|
||||||
|
// WorkspacePane[] OR the WorkspaceState envelope. Normalize to an envelope so
|
||||||
|
// tabNumbers is always available (empty for the legacy shape — no tab numbers
|
||||||
|
// were tracked before the envelope landed).
|
||||||
|
function normalizeWorkspaceState(v: unknown): {
|
||||||
|
tabNumbers: Record<string, number>;
|
||||||
|
} {
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
return { tabNumbers: {} };
|
||||||
|
}
|
||||||
|
if (v && typeof v === 'object' && Array.isArray((v as WorkspaceStateLike).panes)) {
|
||||||
|
const env = v as WorkspaceStateLike;
|
||||||
|
return { tabNumbers: env.tabNumbers ?? {} };
|
||||||
|
}
|
||||||
|
return { tabNumbers: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure executor split out from the ToolDef wrapper so tests can call it with a
|
||||||
|
// mocked Sql. Returns a transcript string (read-only — never writes).
|
||||||
|
export async function executeReadTabByNumber(
|
||||||
|
input: ReadTabByNumberInputT,
|
||||||
|
sql: Sql,
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const sessionRows = await sql<{ workspace_panes: unknown }[]>`
|
||||||
|
SELECT workspace_panes FROM sessions WHERE id = ${sessionId}
|
||||||
|
`;
|
||||||
|
if (sessionRows.length === 0) {
|
||||||
|
return `Session not found.`;
|
||||||
|
}
|
||||||
|
const { tabNumbers } = normalizeWorkspaceState(sessionRows[0]!.workspace_panes);
|
||||||
|
|
||||||
|
// Reverse-lookup: find the chat id whose stable tab number equals the input.
|
||||||
|
let chatId: string | null = null;
|
||||||
|
for (const [cid, num] of Object.entries(tabNumbers)) {
|
||||||
|
if (num === input.number) {
|
||||||
|
chatId = cid;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (chatId === null) {
|
||||||
|
return `No tab is numbered ${input.number} in this session.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the conversation: skip system sentinels (role='system') and empty
|
||||||
|
// content rows. Oldest first.
|
||||||
|
const messages = await sql<{ role: string; content: string }[]>`
|
||||||
|
SELECT role, content
|
||||||
|
FROM messages
|
||||||
|
WHERE chat_id = ${chatId}
|
||||||
|
AND role <> 'system'
|
||||||
|
AND content <> ''
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
`;
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return `Tab ${input.number} (chat ${chatId}) has no messages yet.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format a compact transcript, capping total output size.
|
||||||
|
const parts: string[] = [];
|
||||||
|
let total = 0;
|
||||||
|
let truncated = false;
|
||||||
|
for (const m of messages) {
|
||||||
|
const block = `### ${m.role}\n${m.content}`;
|
||||||
|
// +2 accounts for the "\n\n" joiner between blocks.
|
||||||
|
if (total + block.length + 2 > MAX_TRANSCRIPT_CHARS) {
|
||||||
|
truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
parts.push(block);
|
||||||
|
total += block.length + 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
let out = parts.join('\n\n');
|
||||||
|
if (truncated) {
|
||||||
|
out += `\n\n[transcript truncated at ${MAX_TRANSCRIPT_CHARS} chars]`;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const readTabByNumber: ToolDef<ReadTabByNumberInputT> = {
|
||||||
|
name: 'read_tab_by_number',
|
||||||
|
description:
|
||||||
|
'Read the conversation transcript of the tab with the given session-scoped tab number. Tab numbers are stable per session (shown in the workspace tab strip). Returns the messages of that tab oldest-first as a compact transcript. Read-only.',
|
||||||
|
inputSchema: ReadTabByNumberInput,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'read_tab_by_number',
|
||||||
|
description:
|
||||||
|
'Read the conversation transcript of the tab with the given session-scoped tab number. Read-only.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
number: {
|
||||||
|
type: 'integer',
|
||||||
|
description: 'The session-scoped tab number (positive integer).',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['number'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(input, _projectRoot, _extraRoots, toolCtx?: ToolExecCtx) {
|
||||||
|
if (!toolCtx) {
|
||||||
|
return 'read_tab_by_number unavailable: no session context';
|
||||||
|
}
|
||||||
|
return await executeReadTabByNumber(input, toolCtx.sql, toolCtx.sessionId);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { readFile, readdir, stat } from 'node:fs/promises';
|
import { readFile, readdir, stat } from 'node:fs/promises';
|
||||||
import { resolve, basename, relative } from 'node:path';
|
import { resolve, basename, relative } from 'node:path';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
import { pathGuard, PathScopeError } from './path_guard.js';
|
import { pathGuard, PathScopeError } from './path_guard.js';
|
||||||
import { isSecretPath, SecretBlockedError, filterSecretEntries } from './secret_guard.js';
|
import { isSecretPath, SecretBlockedError, filterSecretEntries } from './secret_guard.js';
|
||||||
import { grep as fileOpsGrep, findFiles as fileOpsFindFiles } from './file_ops.js';
|
import { grep as fileOpsGrep, findFiles as fileOpsFindFiles } from './file_ops.js';
|
||||||
@@ -30,6 +31,9 @@ import {
|
|||||||
// with the pause-on-pending-grant branch in inference/tool-phase.ts and the
|
// with the pause-on-pending-grant branch in inference/tool-phase.ts and the
|
||||||
// POST /api/chats/:id/grant_read_access endpoint in routes/messages.ts.
|
// POST /api/chats/:id/grant_read_access endpoint in routes/messages.ts.
|
||||||
import { requestReadAccess } from './request_read_access.js';
|
import { requestReadAccess } from './request_read_access.js';
|
||||||
|
// v2.6.x: read-only tool that reads a tab's transcript by its session-scoped
|
||||||
|
// tab number. Needs DB/session context (ToolExecCtx 4th arg).
|
||||||
|
import { readTabByNumber } from './read_tab_by_number.js';
|
||||||
|
|
||||||
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
||||||
const DEFAULT_VIEW_LINES = 200;
|
const DEFAULT_VIEW_LINES = 200;
|
||||||
@@ -48,6 +52,16 @@ export interface ToolJsonSchema {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v2.6.x: optional DB/session context threaded into a tool's execute(). Only
|
||||||
|
// tools that need to read session-scoped DB state (e.g. read_tab_by_number)
|
||||||
|
// use it; every other tool ignores the 4th arg. Kept optional so existing
|
||||||
|
// 3-arg execute() implementations stay assignable (apps/coder consumes this
|
||||||
|
// type from the compiled dist — the optional param keeps it backward-compatible).
|
||||||
|
export interface ToolExecCtx {
|
||||||
|
sql: Sql;
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ToolDef<TInput> {
|
export interface ToolDef<TInput> {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -59,7 +73,15 @@ export interface ToolDef<TInput> {
|
|||||||
// view_truncated_output) forward it to pathGuard; other tools accept the
|
// view_truncated_output) forward it to pathGuard; other tools accept the
|
||||||
// arg and ignore it. The execute signature stays compatible with
|
// arg and ignore it. The execute signature stays compatible with
|
||||||
// pre-v1.13.17 callsites because the parameter is optional.
|
// pre-v1.13.17 callsites because the parameter is optional.
|
||||||
execute(input: TInput, projectRoot: string, extraRoots?: readonly string[]): Promise<unknown>;
|
// v2.6.x: optional 4th param toolCtx carries DB/session context for tools
|
||||||
|
// that read session-scoped state (read_tab_by_number). Optional so 3-arg
|
||||||
|
// implementations remain assignable.
|
||||||
|
execute(
|
||||||
|
input: TInput,
|
||||||
|
projectRoot: string,
|
||||||
|
extraRoots?: readonly string[],
|
||||||
|
toolCtx?: ToolExecCtx,
|
||||||
|
): Promise<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ViewFileInput = z.object({
|
const ViewFileInput = z.object({
|
||||||
@@ -694,6 +716,9 @@ export let ALL_TOOLS: ToolDef<unknown>[] = [
|
|||||||
// state change is appending to sessions.allowed_read_paths via the
|
// state change is appending to sessions.allowed_read_paths via the
|
||||||
// grant endpoint, gated by user consent.
|
// grant endpoint, gated by user consent.
|
||||||
requestReadAccess as ToolDef<unknown>,
|
requestReadAccess as ToolDef<unknown>,
|
||||||
|
// v2.6.x: read a tab's transcript by its session-scoped tab number.
|
||||||
|
// Read-only; uses the ToolExecCtx 4th arg for DB/session access.
|
||||||
|
readTabByNumber as ToolDef<unknown>,
|
||||||
].sort((a, b) => a.name.localeCompare(b.name));
|
].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
// v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is
|
// v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is
|
||||||
@@ -734,6 +759,9 @@ export const READ_ONLY_TOOL_NAMES = [
|
|||||||
// state directly (the grant endpoint appends to sessions.allowed_read_paths
|
// state directly (the grant endpoint appends to sessions.allowed_read_paths
|
||||||
// only with user consent). Belongs in the read-only budget tier.
|
// only with user consent). Belongs in the read-only budget tier.
|
||||||
'request_read_access',
|
'request_read_access',
|
||||||
|
// v2.6.x: reads a tab's transcript from session-scoped DB state; never
|
||||||
|
// writes. Belongs in the read-only budget tier.
|
||||||
|
'read_tab_by_number',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export let TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(
|
export let TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(
|
||||||
|
|||||||
@@ -203,7 +203,12 @@ export const SessionDeletedFrame = z.object({
|
|||||||
export const SessionWorkspaceUpdatedFrame = z.object({
|
export const SessionWorkspaceUpdatedFrame = z.object({
|
||||||
type: z.literal('session_workspace_updated'),
|
type: z.literal('session_workspace_updated'),
|
||||||
session_id: Uuid,
|
session_id: Uuid,
|
||||||
workspace_panes: z.array(OpaqueObject),
|
// v2.6.x: widened from z.array — the payload is now either the legacy bare
|
||||||
|
// WorkspacePane[] OR the WorkspaceState envelope object (panes + tabNumbers +
|
||||||
|
// nextTabNumber + closedPaneStack). z.array alone would fail-closed and drop
|
||||||
|
// every envelope frame at validation. MUST be mirrored in the server's
|
||||||
|
// byte-identical copy (parity test).
|
||||||
|
workspace_panes: z.union([z.array(OpaqueObject), z.record(z.unknown())]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ChatCreatedFrame = z.object({
|
export const ChatCreatedFrame = z.object({
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import type {
|
|||||||
CoderTaskDetail,
|
CoderTaskDetail,
|
||||||
PermissionPrompt,
|
PermissionPrompt,
|
||||||
AgentCommand,
|
AgentCommand,
|
||||||
|
WorkspaceState,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
@@ -175,10 +176,10 @@ export const api = {
|
|||||||
),
|
),
|
||||||
openChatsCount: (id: string) =>
|
openChatsCount: (id: string) =>
|
||||||
request<{ count: number }>(`/api/sessions/${id}/chats/open-count`),
|
request<{ count: number }>(`/api/sessions/${id}/chats/open-count`),
|
||||||
updateWorkspacePanes: (id: string, panes: Session['workspace_panes']) =>
|
updateWorkspacePanes: (id: string, state: WorkspaceState) =>
|
||||||
request<Session>(`/api/sessions/${id}/workspace`, {
|
request<Session>(`/api/sessions/${id}/workspace`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify({ workspace_panes: panes }),
|
body: JSON.stringify({ workspace_panes: state }),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -354,6 +355,10 @@ export const api = {
|
|||||||
request<{ taskId: string; commands: AgentCommand[] }>(`/api/coder/tasks/${taskId}/commands`),
|
request<{ taskId: string; commands: AgentCommand[] }>(`/api/coder/tasks/${taskId}/commands`),
|
||||||
getTask: (taskId: string) =>
|
getTask: (taskId: string) =>
|
||||||
request<CoderTaskDetail>(`/api/coder/tasks/${taskId}`),
|
request<CoderTaskDetail>(`/api/coder/tasks/${taskId}`),
|
||||||
|
// Cancel a pending/running coder task (cancels permission wait + inference;
|
||||||
|
// server sets state='cancelled'). Used by CoderPane's stop button.
|
||||||
|
cancelTask: (taskId: string) =>
|
||||||
|
request<{ cancelled: boolean }>(`/api/coder/tasks/${taskId}/cancel`, { method: 'POST' }),
|
||||||
listMessages: (sessionId: string, chatId?: string) =>
|
listMessages: (sessionId: string, chatId?: string) =>
|
||||||
request<CoderMessageWire[]>(
|
request<CoderMessageWire[]>(
|
||||||
`/api/coder/sessions/${sessionId}/messages${chatId ? `?chat_id=${encodeURIComponent(chatId)}` : ''}`,
|
`/api/coder/sessions/${sessionId}/messages${chatId ? `?chat_id=${encodeURIComponent(chatId)}` : ''}`,
|
||||||
|
|||||||
@@ -60,7 +60,10 @@ export interface Session {
|
|||||||
// v1.9: null = inherit from project.default_web_search_enabled.
|
// v1.9: null = inherit from project.default_web_search_enabled.
|
||||||
web_search_enabled: boolean | null;
|
web_search_enabled: boolean | null;
|
||||||
// v1.12.1: server-authoritative pane layout, replaces localStorage.
|
// v1.12.1: server-authoritative pane layout, replaces localStorage.
|
||||||
workspace_panes: WorkspacePane[];
|
// A value may be the legacy bare WorkspacePane[] (older rows) OR the new
|
||||||
|
// WorkspaceState envelope (panes + tab numbering + reopen stack). Normalize
|
||||||
|
// on read via useWorkspacePanes' toWorkspaceState.
|
||||||
|
workspace_panes: WorkspacePane[] | WorkspaceState;
|
||||||
// v1.13.17: paths the agent has been granted read access to via the
|
// v1.13.17: paths the agent has been granted read access to via the
|
||||||
// request_read_access tool. Empty by default. Settings UI surfaces the
|
// request_read_access tool. Empty by default. Settings UI surfaces the
|
||||||
// list with per-row revoke; the grant flow itself appends through the
|
// list with per-row revoke; the grant flow itself appends through the
|
||||||
@@ -511,6 +514,30 @@ export interface WorkspacePane {
|
|||||||
html_artifact_state?: HtmlArtifactState;
|
html_artifact_state?: HtmlArtifactState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reopen LIFO stack entry. Shape unchanged from the prior module-level stack;
|
||||||
|
// now persisted inside the WorkspaceState envelope so the reopen-pane stack
|
||||||
|
// survives a reload / cross-device sync.
|
||||||
|
export interface ClosedPaneEntry {
|
||||||
|
kind: WorkspacePane['kind'];
|
||||||
|
chatIds: string[];
|
||||||
|
activeChatIdx: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Envelope persisted to sessions.workspace_panes. Supersedes the bare
|
||||||
|
// WorkspacePane[] shape (still accepted on read for legacy rows — see the
|
||||||
|
// migration in useWorkspacePanes.toWorkspaceState). The server accepts either
|
||||||
|
// shape; the frontend always emits this envelope going forward.
|
||||||
|
export interface WorkspaceState {
|
||||||
|
panes: WorkspacePane[];
|
||||||
|
// Stable, session-scoped tab number per chat id. Numbers only ever increase
|
||||||
|
// and are never reused (retired entries are pruned on tab close).
|
||||||
|
tabNumbers: { [chatId: string]: number };
|
||||||
|
// Next number to hand out; starts at 1; ONLY increments.
|
||||||
|
nextTabNumber: number;
|
||||||
|
// Reopen LIFO stack, max 10, most-recent last.
|
||||||
|
closedPaneStack: ClosedPaneEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
export type WsFrame =
|
export type WsFrame =
|
||||||
| { type: 'snapshot'; messages: Message[] }
|
| { type: 'snapshot'; messages: Message[] }
|
||||||
| { type: 'message_started'; message_id: string; chat_id?: string; role: MessageRole }
|
| { type: 'message_started'; message_id: string; chat_id?: string; role: MessageRole }
|
||||||
|
|||||||
@@ -203,7 +203,12 @@ export const SessionDeletedFrame = z.object({
|
|||||||
export const SessionWorkspaceUpdatedFrame = z.object({
|
export const SessionWorkspaceUpdatedFrame = z.object({
|
||||||
type: z.literal('session_workspace_updated'),
|
type: z.literal('session_workspace_updated'),
|
||||||
session_id: Uuid,
|
session_id: Uuid,
|
||||||
workspace_panes: z.array(OpaqueObject),
|
// v2.6.x: widened from z.array — the payload is now either the legacy bare
|
||||||
|
// WorkspacePane[] OR the WorkspaceState envelope object (panes + tabNumbers +
|
||||||
|
// nextTabNumber + closedPaneStack). z.array alone would fail-closed and drop
|
||||||
|
// every envelope frame at validation. MUST be mirrored in the server's
|
||||||
|
// byte-identical copy (parity test).
|
||||||
|
workspace_panes: z.union([z.array(OpaqueObject), z.record(z.unknown())]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ChatCreatedFrame = z.object({
|
export const ChatCreatedFrame = z.object({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent } from 'react';
|
||||||
import { Check, Plus, Send } from 'lucide-react';
|
import { Check, ListPlus, Plus, Send, Square } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -51,6 +51,11 @@ interface Props {
|
|||||||
webSearchEnabled?: boolean | null;
|
webSearchEnabled?: boolean | null;
|
||||||
onSend: (content: string) => void | Promise<void>;
|
onSend: (content: string) => void | Promise<void>;
|
||||||
onForceSend?: (content: string) => void | Promise<void>;
|
onForceSend?: (content: string) => void | Promise<void>;
|
||||||
|
// When the assistant/agent is generating, the send button morphs: empty draft
|
||||||
|
// → Stop (calls onStop); non-empty draft → Queue (submits, which the caller
|
||||||
|
// queues while busy). Omitting onStop falls back to a (disabled) Send button.
|
||||||
|
generating?: boolean;
|
||||||
|
onStop?: () => void | Promise<void>;
|
||||||
// Batch 9.6: slash-command dispatch. When the input parses to a known skill,
|
// Batch 9.6: slash-command dispatch. When the input parses to a known skill,
|
||||||
// ChatInput calls this with the skill name + the post-name args (possibly
|
// ChatInput calls this with the skill name + the post-name args (possibly
|
||||||
// empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
|
// empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
|
||||||
@@ -78,7 +83,7 @@ interface Props {
|
|||||||
modelContextLimit?: number | null;
|
modelContextLimit?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, onSlashCommand, slashGroups, chatId, chatLabel, messages, modelContextLimit }: Props) {
|
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, generating, onStop, onSlashCommand, slashGroups, chatId, chatLabel, messages, modelContextLimit }: Props) {
|
||||||
const { isMobile } = useViewport();
|
const { isMobile } = useViewport();
|
||||||
const [value, setValue] = useState('');
|
const [value, setValue] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -651,14 +656,38 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
|||||||
rows={3}
|
rows={3}
|
||||||
className="resize-none min-h-[68px] max-h-[240px]"
|
className="resize-none min-h-[68px] max-h-[240px]"
|
||||||
/>
|
/>
|
||||||
<Button
|
{(() => {
|
||||||
onClick={() => void submit()}
|
const hasContent = value.trim().length > 0 || attachments.length > 0;
|
||||||
disabled={disabled || busy || (!value.trim() && attachments.length === 0)}
|
// While generating with an empty draft, the button stops generation.
|
||||||
size="icon-lg"
|
if (generating && onStop && !hasContent) {
|
||||||
aria-label="Send"
|
return (
|
||||||
>
|
<Button
|
||||||
<Send />
|
onClick={() => void onStop()}
|
||||||
</Button>
|
size="icon-lg"
|
||||||
|
variant="outline"
|
||||||
|
aria-label="Stop generating"
|
||||||
|
title="Stop generating"
|
||||||
|
>
|
||||||
|
<Square className="fill-current size-3.5" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// With a draft, submit. While generating the caller queues it, so the
|
||||||
|
// button reads as Queue; otherwise it's a normal Send.
|
||||||
|
const queueing = !!generating && hasContent;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={() => void submit()}
|
||||||
|
disabled={disabled || busy || !hasContent}
|
||||||
|
size="icon-lg"
|
||||||
|
variant={queueing ? 'secondary' : 'default'}
|
||||||
|
aria-label={queueing ? 'Queue message' : 'Send'}
|
||||||
|
title={queueing ? 'Queue message' : 'Send'}
|
||||||
|
>
|
||||||
|
{queueing ? <ListPlus /> : <Send />}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AttachmentPreviewModal
|
<AttachmentPreviewModal
|
||||||
|
|||||||
@@ -16,11 +16,15 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { useLongPress } from '@/hooks/useLongPress';
|
import { useLongPress } from '@/hooks/useLongPress';
|
||||||
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
pane: WorkspacePane;
|
pane: WorkspacePane;
|
||||||
tabs: Chat[];
|
tabs: Chat[];
|
||||||
|
// v2.6.x (Batch 3a): stable session-scoped tab number per chat id. Keyed by
|
||||||
|
// chat.id, NEVER by tab position.
|
||||||
|
tabNumbers: Record<string, number>;
|
||||||
onSwitchTab: (tabIdx: number) => void;
|
onSwitchTab: (tabIdx: number) => void;
|
||||||
onRemoveTab: (chatId: string) => void;
|
onRemoveTab: (chatId: string) => void;
|
||||||
onCloseOthers: (chatId: string) => void;
|
onCloseOthers: (chatId: string) => void;
|
||||||
@@ -37,6 +41,7 @@ interface Props {
|
|||||||
export function ChatTabBar({
|
export function ChatTabBar({
|
||||||
pane,
|
pane,
|
||||||
tabs,
|
tabs,
|
||||||
|
tabNumbers,
|
||||||
onSwitchTab,
|
onSwitchTab,
|
||||||
onRemoveTab,
|
onRemoveTab,
|
||||||
onCloseOthers,
|
onCloseOthers,
|
||||||
@@ -83,6 +88,9 @@ export function ChatTabBar({
|
|||||||
const isLast = tabIdx === tabs.length - 1;
|
const isLast = tabIdx === tabs.length - 1;
|
||||||
const onlyTab = tabs.length === 1;
|
const onlyTab = tabs.length === 1;
|
||||||
const label = chat.name ?? 'New chat';
|
const label = chat.name ?? 'New chat';
|
||||||
|
// v2.6.x: stable tab number keyed by chat.id (NOT tab position).
|
||||||
|
// Omit gracefully when not yet assigned.
|
||||||
|
const tabNumber = tabNumbers[chat.id];
|
||||||
return (
|
return (
|
||||||
<ContextMenu key={chat.id}>
|
<ContextMenu key={chat.id}>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
@@ -117,8 +125,11 @@ export function ChatTabBar({
|
|||||||
className="bg-transparent border-b border-border text-xs outline-none w-28"
|
className="bg-transparent border-b border-border text-xs outline-none w-28"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="truncate max-w-[140px]" title={label}>
|
<span
|
||||||
{label}
|
className="truncate max-w-[140px]"
|
||||||
|
title={tabNumber !== undefined ? `${tabNumber} · ${label}` : label}
|
||||||
|
>
|
||||||
|
{tabNumber !== undefined ? `${tabNumber} · ${label}` : label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -138,6 +149,13 @@ export function ChatTabBar({
|
|||||||
<ContextMenuItem onSelect={onNewTab}>
|
<ContextMenuItem onSelect={onNewTab}>
|
||||||
New chat
|
New chat
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
onSelect={() =>
|
||||||
|
sessionEvents.emit({ type: 'open_chat_in_new_pane', chat_id: chat.id })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Open in new pane
|
||||||
|
</ContextMenuItem>
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
<ContextMenuItem onSelect={() => startRename(chat.id, chat.name)}>
|
<ContextMenuItem onSelect={() => startRename(chat.id, chat.name)}>
|
||||||
Rename
|
Rename
|
||||||
@@ -174,15 +192,31 @@ export function ChatTabBar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center ml-auto gap-0.5 px-1 shrink-0">
|
<div className="flex items-center ml-auto gap-0.5 px-1 shrink-0">
|
||||||
<button
|
<DropdownMenu>
|
||||||
type="button"
|
<DropdownMenuTrigger asChild>
|
||||||
onClick={onNewTab}
|
<button
|
||||||
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
type="button"
|
||||||
aria-label="New tab"
|
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||||
title="New tab"
|
aria-label="New chat, terminal, or coder"
|
||||||
>
|
title="New chat / terminal / coder"
|
||||||
<Plus size={12} />
|
>
|
||||||
</button>
|
<Plus size={12} />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-fit">
|
||||||
|
{/* New BooChat opens a tab in THIS pane; terminal/coder can't be
|
||||||
|
tabs, so they split into a new pane (matches the Split menu). */}
|
||||||
|
<DropdownMenuItem onSelect={onNewTab}>
|
||||||
|
<MessageSquare size={14} /> New BooChat
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={() => onSplitPane('terminal')}>
|
||||||
|
<Terminal size={14} /> New BooTerm
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={() => onSplitPane('coder')}>
|
||||||
|
<Code size={14} /> New BooCode
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2, PanelRightOpen, Brain } from 'lucide-react';
|
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2, Brain } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import type { Chat, ErrorReason, Message } from '@/api/types';
|
import type { Chat, ErrorReason, Message } from '@/api/types';
|
||||||
import { api, ApiError } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
import { sendToTerminal, terminalsRegistry, type TerminalRegistration } from '@/lib/events';
|
import { sendToTerminal, terminalsRegistry, type TerminalRegistration } from '@/lib/events';
|
||||||
import { CapHitSentinel } from './CapHitSentinel';
|
import { CapHitSentinel } from './CapHitSentinel';
|
||||||
@@ -105,18 +105,6 @@ const ERROR_REASON_LABELS: Record<ErrorReason, string> = {
|
|||||||
// moved to apps/web/src/components/MarkdownRenderer.tsx so the new artifact
|
// moved to apps/web/src/components/MarkdownRenderer.tsx so the new artifact
|
||||||
// panes can render assistant content with the same Shiki + remark-gfm setup.
|
// panes can render assistant content with the same Shiki + remark-gfm setup.
|
||||||
|
|
||||||
// Pane-header title derivation for a markdown artifact. Order matches the
|
|
||||||
// server slug logic in services/artifacts.ts: first `# ` heading → first 6
|
|
||||||
// words of the body → 'Markdown artifact'. Truncated to keep the pane header
|
|
||||||
// readable.
|
|
||||||
function deriveMarkdownTitle(content: string): string {
|
|
||||||
const headingMatch = content.match(/^\s*#\s+(.+?)\s*$/m);
|
|
||||||
if (headingMatch && headingMatch[1]) return headingMatch[1].slice(0, 80);
|
|
||||||
const words = content.trim().split(/\s+/).slice(0, 6).join(' ');
|
|
||||||
if (words) return words.slice(0, 80);
|
|
||||||
return 'Markdown artifact';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MessageActions {
|
export interface MessageActions {
|
||||||
onRegenerate?: (chatId: string, messageId: string) => Promise<void>;
|
onRegenerate?: (chatId: string, messageId: string) => Promise<void>;
|
||||||
onResend?: (chatId: string, content: string) => Promise<void>;
|
onResend?: (chatId: string, content: string) => Promise<void>;
|
||||||
@@ -129,8 +117,8 @@ interface Props {
|
|||||||
sessionChats?: Chat[];
|
sessionChats?: Chat[];
|
||||||
capHitInfo?: { position: number; isLatest: boolean };
|
capHitInfo?: { position: number; isLatest: boolean };
|
||||||
actions?: MessageActions;
|
actions?: MessageActions;
|
||||||
/** Hide actions that don't apply (fork, delete, open-in-pane). */
|
/** Hide actions that don't apply (fork, delete). */
|
||||||
hideActions?: ('fork' | 'delete' | 'openInPane')[];
|
hideActions?: ('fork' | 'delete')[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatsLine({ message }: { message: Message }) {
|
function StatsLine({ message }: { message: Message }) {
|
||||||
@@ -226,7 +214,7 @@ function ActionRow({
|
|||||||
} else {
|
} else {
|
||||||
const chat = await api.chats.fork(message.chat_id, { messageId: message.id });
|
const chat = await api.chats.fork(message.chat_id, { messageId: message.id });
|
||||||
sessionEvents.emit({ type: 'refetch_messages' });
|
sessionEvents.emit({ type: 'refetch_messages' });
|
||||||
sessionEvents.emit({ type: 'open_chat_in_active_pane', chat_id: chat.id });
|
sessionEvents.emit({ type: 'open_chat_in_new_pane', chat_id: chat.id });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'fork failed');
|
toast.error(err instanceof Error ? err.message : 'fork failed');
|
||||||
@@ -258,54 +246,6 @@ function ActionRow({
|
|||||||
const canResend = isUser && message.status === 'complete' && !!message.content?.trim();
|
const canResend = isUser && message.status === 'complete' && !!message.content?.trim();
|
||||||
const canFork = message.status === 'complete';
|
const canFork = message.status === 'complete';
|
||||||
const canDelete = message.status !== 'streaming';
|
const canDelete = message.status !== 'streaming';
|
||||||
const [openingPane, setOpeningPane] = useState(false);
|
|
||||||
|
|
||||||
// v1.14.x-html-artifact-panes: probe for an html_artifact part. If present,
|
|
||||||
// open the HTML pane variant; otherwise fall back to the markdown variant.
|
|
||||||
// Title derivation for markdown: first `# ` heading → first 6 words of the
|
|
||||||
// body → 'Markdown artifact' (mirrors the slug logic in
|
|
||||||
// services/artifacts.ts).
|
|
||||||
async function openInPane() {
|
|
||||||
if (openingPane || message.status === 'streaming') return;
|
|
||||||
setOpeningPane(true);
|
|
||||||
try {
|
|
||||||
try {
|
|
||||||
const payload = await api.messages.getHtmlArtifact(
|
|
||||||
message.chat_id,
|
|
||||||
message.id,
|
|
||||||
);
|
|
||||||
sessionEvents.emit({
|
|
||||||
type: 'open_html_artifact_pane',
|
|
||||||
state: {
|
|
||||||
chat_id: message.chat_id,
|
|
||||||
message_id: message.id,
|
|
||||||
title: payload.title,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
} catch (err) {
|
|
||||||
// 404 (no html_artifact part) is the expected fall-through path —
|
|
||||||
// markdown variant opens below. Any other error (network, 500) is
|
|
||||||
// a real failure; toast and bail rather than masquerading as markdown.
|
|
||||||
const status = err instanceof ApiError ? err.status : null;
|
|
||||||
if (status !== 404) {
|
|
||||||
toast.error(err instanceof Error ? err.message : 'open in pane failed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const title = deriveMarkdownTitle(message.content);
|
|
||||||
sessionEvents.emit({
|
|
||||||
type: 'open_markdown_artifact_pane',
|
|
||||||
state: {
|
|
||||||
chat_id: message.chat_id,
|
|
||||||
message_id: message.id,
|
|
||||||
title,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setOpeningPane(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -330,18 +270,6 @@ function ActionRow({
|
|||||||
<RefreshCw className="size-3" />
|
<RefreshCw className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{isAssistant && !hiddenSet.has('openInPane') && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void openInPane()}
|
|
||||||
disabled={openingPane || message.status === 'streaming'}
|
|
||||||
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
|
|
||||||
aria-label="Open in pane"
|
|
||||||
title="Open in pane"
|
|
||||||
>
|
|
||||||
<PanelRightOpen className="size-3" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{isAssistant && (
|
{isAssistant && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Archive, MessageSquare, RotateCcw } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { ChatInput } from '@/components/ChatInput';
|
import { ChatInput } from '@/components/ChatInput';
|
||||||
|
import { api } from '@/api/client';
|
||||||
|
import type { Chat } from '@/api/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -13,6 +16,30 @@ interface Props {
|
|||||||
// the skill — same transition the text send uses. See useSessionChats.
|
// the skill — same transition the text send uses. See useSessionChats.
|
||||||
onSkillInvoke: (skillName: string, userMessage: string | null) => void;
|
onSkillInvoke: (skillName: string, userMessage: string | null) => void;
|
||||||
createChat: () => Promise<{ id: string }>;
|
createChat: () => Promise<{ id: string }>;
|
||||||
|
// Session history: the session's open chats (live), and callbacks to open one
|
||||||
|
// in THIS pane / restore an archived one. Archived chats are fetched here
|
||||||
|
// (the default open-only list excludes them).
|
||||||
|
chats: Chat[];
|
||||||
|
onOpenChat: (chatId: string) => void;
|
||||||
|
onUnarchiveChat: (chatId: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelative(iso: string): string {
|
||||||
|
const then = new Date(iso).getTime();
|
||||||
|
if (Number.isNaN(then)) return '';
|
||||||
|
const s = Math.max(0, Math.round((Date.now() - then) / 1000));
|
||||||
|
if (s < 60) return 'just now';
|
||||||
|
const m = Math.round(s / 60);
|
||||||
|
if (m < 60) return `${m}m ago`;
|
||||||
|
const h = Math.round(m / 60);
|
||||||
|
if (h < 24) return `${h}h ago`;
|
||||||
|
const d = Math.round(h / 24);
|
||||||
|
if (d < 7) return `${d}d ago`;
|
||||||
|
return new Date(iso).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function byRecent(a: Chat, b: Chat): number {
|
||||||
|
return (b.updated_at ?? '').localeCompare(a.updated_at ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SessionLandingPage({
|
export function SessionLandingPage({
|
||||||
@@ -23,8 +50,24 @@ export function SessionLandingPage({
|
|||||||
onSend,
|
onSend,
|
||||||
onSkillInvoke,
|
onSkillInvoke,
|
||||||
createChat,
|
createChat,
|
||||||
|
chats,
|
||||||
|
onOpenChat,
|
||||||
|
onUnarchiveChat,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [chatId, setChatId] = useState<string | null>(null);
|
const [chatId, setChatId] = useState<string | null>(null);
|
||||||
|
const [archived, setArchived] = useState<Chat[]>([]);
|
||||||
|
|
||||||
|
// Archived chats aren't in the default (open-only) list, so fetch them. One
|
||||||
|
// shot on session change — the history view is transient (pick a chat and
|
||||||
|
// it's gone), so slight staleness is fine; reopening the pane refetches.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
api.chats
|
||||||
|
.listForSession(sessionId, { status: 'archived' })
|
||||||
|
.then((list) => { if (!cancelled) setArchived(list); })
|
||||||
|
.catch(() => {});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
const ensureChat = useCallback(async (): Promise<string> => {
|
const ensureChat = useCallback(async (): Promise<string> => {
|
||||||
if (chatId) return chatId;
|
if (chatId) return chatId;
|
||||||
@@ -57,12 +100,87 @@ export function SessionLandingPage({
|
|||||||
onSkillInvoke(skillName, userMessage.length > 0 ? userMessage : null);
|
onSkillInvoke(skillName, userMessage.length > 0 ? userMessage : null);
|
||||||
}, [onSkillInvoke]);
|
}, [onSkillInvoke]);
|
||||||
|
|
||||||
|
const restoreAndOpen = useCallback(async (id: string) => {
|
||||||
|
try {
|
||||||
|
await onUnarchiveChat(id);
|
||||||
|
onOpenChat(id);
|
||||||
|
} catch {
|
||||||
|
// onUnarchiveChat surfaces its own toast.
|
||||||
|
}
|
||||||
|
}, [onUnarchiveChat, onOpenChat]);
|
||||||
|
|
||||||
|
const openChats = [...chats.filter((c) => c.status === 'open')].sort(byRecent);
|
||||||
|
const openIds = new Set(openChats.map((c) => c.id));
|
||||||
|
const archivedChats = archived.filter((c) => !openIds.has(c.id)).sort(byRecent);
|
||||||
|
const isEmpty = openChats.length === 0 && archivedChats.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0">
|
<div className="flex flex-col h-full min-h-0">
|
||||||
<div className="flex-1 flex items-center justify-center px-6">
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||||
<p className="text-sm text-muted-foreground">
|
<div className="max-w-[760px] mx-auto w-full px-4 py-4">
|
||||||
Send a message to start.
|
{isEmpty ? (
|
||||||
</p>
|
<p className="text-sm text-muted-foreground text-center py-8">
|
||||||
|
No conversations yet. Send a message to start.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{openChats.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground px-1 mb-1.5">
|
||||||
|
Conversations
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-0.5 mb-4">
|
||||||
|
{openChats.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenChat(c.id)}
|
||||||
|
className="w-full flex items-center gap-2 text-left px-2 py-1.5 rounded hover:bg-muted text-sm max-md:min-h-[44px]"
|
||||||
|
>
|
||||||
|
<MessageSquare size={14} className="shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate shrink-0 max-w-[45%]">{c.name ?? 'New chat'}</span>
|
||||||
|
{c.last_message_preview && (
|
||||||
|
<span className="truncate flex-1 text-xs text-muted-foreground hidden sm:block">
|
||||||
|
{c.last_message_preview}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="shrink-0 ml-auto text-xs text-muted-foreground">
|
||||||
|
{formatRelative(c.updated_at)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{archivedChats.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground px-1 mb-1.5">
|
||||||
|
Archived
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{archivedChats.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void restoreAndOpen(c.id)}
|
||||||
|
title="Restore and open"
|
||||||
|
className="group/arch w-full flex items-center gap-2 text-left px-2 py-1.5 rounded hover:bg-muted text-sm text-muted-foreground max-md:min-h-[44px]"
|
||||||
|
>
|
||||||
|
<Archive size={14} className="shrink-0" />
|
||||||
|
<span className="truncate flex-1">{c.name ?? 'New chat'}</span>
|
||||||
|
<span className="shrink-0 text-xs">{formatRelative(c.updated_at)}</span>
|
||||||
|
<RotateCcw
|
||||||
|
size={13}
|
||||||
|
className="shrink-0 opacity-0 group-hover/arch:opacity-100"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ChatInput
|
<ChatInput
|
||||||
disabled={false}
|
disabled={false}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export function Workspace({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const {
|
const {
|
||||||
panes,
|
panes,
|
||||||
|
tabNumbers,
|
||||||
activePaneIdx,
|
activePaneIdx,
|
||||||
setActivePaneIdx,
|
setActivePaneIdx,
|
||||||
openChatInPane,
|
openChatInPane,
|
||||||
@@ -204,6 +205,7 @@ export function Workspace({
|
|||||||
<ChatTabBar
|
<ChatTabBar
|
||||||
pane={pane}
|
pane={pane}
|
||||||
tabs={chatsForPane(pane)}
|
tabs={chatsForPane(pane)}
|
||||||
|
tabNumbers={tabNumbers}
|
||||||
onSwitchTab={(tabIdx) => switchTab(idx, tabIdx)}
|
onSwitchTab={(tabIdx) => switchTab(idx, tabIdx)}
|
||||||
onRemoveTab={(chatId) => removeTab(idx, chatId)}
|
onRemoveTab={(chatId) => removeTab(idx, chatId)}
|
||||||
onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
|
onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
|
||||||
@@ -390,6 +392,9 @@ export function Workspace({
|
|||||||
createChat={() => api.chats.create(sessionId)}
|
createChat={() => api.chats.create(sessionId)}
|
||||||
onSend={(content) => void handleLandingSend(idx, content)}
|
onSend={(content) => void handleLandingSend(idx, content)}
|
||||||
onSkillInvoke={(skillName, userMessage) => void handleLandingSkill(idx, skillName, userMessage)}
|
onSkillInvoke={(skillName, userMessage) => void handleLandingSkill(idx, skillName, userMessage)}
|
||||||
|
chats={chats}
|
||||||
|
onOpenChat={(chatId) => openChatInPane(idx, chatId)}
|
||||||
|
onUnarchiveChat={unarchiveChat}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Pencil, Send, Square, X } from 'lucide-react';
|
import { Pencil, Send, X } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import { useSessionStream } from '@/hooks/useSessionStream';
|
import { useSessionStream } from '@/hooks/useSessionStream';
|
||||||
@@ -248,22 +248,6 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stop button when streaming */}
|
|
||||||
{streaming && (
|
|
||||||
<div className="border-t py-1">
|
|
||||||
<div className="max-w-[1000px] mx-auto w-full flex justify-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleStop()}
|
|
||||||
className="flex items-center gap-1.5 text-xs px-3 py-1 rounded-full border hover:bg-muted text-muted-foreground hover:text-foreground max-md:min-h-[44px] max-md:px-5"
|
|
||||||
>
|
|
||||||
<Square size={10} className="fill-current" />
|
|
||||||
Stop generating
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{stale && streamingId && (
|
{stale && streamingId && (
|
||||||
<StaleStreamBanner
|
<StaleStreamBanner
|
||||||
onRetry={() => void handleRetryStale()}
|
onRetry={() => void handleRetryStale()}
|
||||||
@@ -280,6 +264,8 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
|
|||||||
webSearchEnabled={webSearchEnabled}
|
webSearchEnabled={webSearchEnabled}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
onForceSend={streaming ? handleForceSend : undefined}
|
onForceSend={streaming ? handleForceSend : undefined}
|
||||||
|
generating={streaming}
|
||||||
|
onStop={handleStop}
|
||||||
onSlashCommand={handleSlashCommand}
|
onSlashCommand={handleSlashCommand}
|
||||||
chatId={chatId}
|
chatId={chatId}
|
||||||
chatLabel={sessionChats?.find((c) => c.id === chatId)?.name ?? 'Chat'}
|
chatLabel={sessionChats?.find((c) => c.id === chatId)?.name ?? 'Chat'}
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ interface Props {
|
|||||||
actions?: MessageActions;
|
actions?: MessageActions;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CODER_HIDDEN_ACTIONS: ('fork' | 'delete' | 'openInPane')[] = ['fork', 'openInPane'];
|
const CODER_HIDDEN_ACTIONS: ('fork' | 'delete')[] = ['fork'];
|
||||||
|
|
||||||
export function CoderMessageList({ messages, chatId, footer, actions }: Props) {
|
export function CoderMessageList({ messages, chatId, footer, actions }: Props) {
|
||||||
const endRef = useRef<HTMLDivElement>(null);
|
const endRef = useRef<HTMLDivElement>(null);
|
||||||
|
|||||||
@@ -581,6 +581,10 @@ export function CoderPane({
|
|||||||
const [queue, setQueue] = useState<string[]>([]);
|
const [queue, setQueue] = useState<string[]>([]);
|
||||||
const queueProcessing = useRef(false);
|
const queueProcessing = useRef(false);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
// The agent is "generating" during the dispatch POST (sending) AND while its
|
||||||
|
// task runs (activeTaskId). sending alone is too brief — it clears the moment
|
||||||
|
// dispatch returns — so queueing/stop must key on this combined signal.
|
||||||
|
const generating = sending || activeTaskId !== null;
|
||||||
|
|
||||||
// Refresh pending changes when a message_complete arrives
|
// Refresh pending changes when a message_complete arrives
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -760,24 +764,35 @@ export function CoderPane({
|
|||||||
}
|
}
|
||||||
}, [sessionId, paneId, chatId, agentConfig, setMessages]);
|
}, [sessionId, paneId, chatId, agentConfig, setMessages]);
|
||||||
|
|
||||||
// Drain queue when not busy
|
// Drain queue once the agent is idle (not just past the dispatch POST).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sending || queue.length === 0 || queueProcessing.current) return;
|
if (generating || queue.length === 0 || queueProcessing.current) return;
|
||||||
queueProcessing.current = true;
|
queueProcessing.current = true;
|
||||||
const next = queue[0]!;
|
const next = queue[0]!;
|
||||||
setQueue((prev) => prev.slice(1));
|
setQueue((prev) => prev.slice(1));
|
||||||
sendOneMessage(next).finally(() => { queueProcessing.current = false; });
|
sendOneMessage(next).finally(() => { queueProcessing.current = false; });
|
||||||
}, [sending, queue, sendOneMessage]);
|
}, [generating, queue, sendOneMessage]);
|
||||||
|
|
||||||
const handleChatInputSend = useCallback(async (content: string) => {
|
const handleChatInputSend = useCallback(async (content: string) => {
|
||||||
const text = content.trim();
|
const text = content.trim();
|
||||||
if (!text || !chatId) return;
|
if (!text || !chatId) return;
|
||||||
if (sending) {
|
if (generating) {
|
||||||
setQueue((prev) => [...prev, text]);
|
setQueue((prev) => [...prev, text]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await sendOneMessage(text);
|
await sendOneMessage(text);
|
||||||
}, [sending, chatId, sendOneMessage]);
|
}, [generating, chatId, sendOneMessage]);
|
||||||
|
|
||||||
|
const handleStop = useCallback(async () => {
|
||||||
|
const taskId = activeTaskId;
|
||||||
|
if (!taskId) return;
|
||||||
|
try {
|
||||||
|
await api.coder.cancelTask(taskId);
|
||||||
|
setActiveTaskId(null); // optimistic; WS/poll terminal-state also clears it
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'stop failed');
|
||||||
|
}
|
||||||
|
}, [activeTaskId]);
|
||||||
|
|
||||||
const handleChatInputSlash = useCallback(async (skillName: string, userMessage: string) => {
|
const handleChatInputSlash = useCallback(async (skillName: string, userMessage: string) => {
|
||||||
if (!chatId) return;
|
if (!chatId) return;
|
||||||
@@ -867,9 +882,11 @@ export function CoderPane({
|
|||||||
{/* Composer + input */}
|
{/* Composer + input */}
|
||||||
<div className="shrink-0 border-t border-border">
|
<div className="shrink-0 border-t border-border">
|
||||||
<ChatInput
|
<ChatInput
|
||||||
disabled={sending || !chatId || chatPending}
|
disabled={!chatId || chatPending}
|
||||||
projectId={projectPath ?? ''}
|
projectId={projectPath ?? ''}
|
||||||
onSend={handleChatInputSend}
|
onSend={handleChatInputSend}
|
||||||
|
generating={generating}
|
||||||
|
onStop={handleStop}
|
||||||
onSlashCommand={handleChatInputSlash}
|
onSlashCommand={handleChatInputSlash}
|
||||||
slashGroups={slashGroups}
|
slashGroups={slashGroups}
|
||||||
chatId={chatId ?? undefined}
|
chatId={chatId ?? undefined}
|
||||||
|
|||||||
@@ -51,7 +51,11 @@ export interface SessionUpdatedEvent {
|
|||||||
export interface SessionWorkspaceUpdatedEvent {
|
export interface SessionWorkspaceUpdatedEvent {
|
||||||
type: 'session_workspace_updated';
|
type: 'session_workspace_updated';
|
||||||
session_id: string;
|
session_id: string;
|
||||||
workspace_panes: import('@/api/types').WorkspacePane[];
|
// Legacy bare array OR the new envelope — useWorkspacePanes normalizes both
|
||||||
|
// via toWorkspaceState.
|
||||||
|
workspace_panes:
|
||||||
|
| import('@/api/types').WorkspacePane[]
|
||||||
|
| import('@/api/types').WorkspaceState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionLoadedEvent {
|
export interface SessionLoadedEvent {
|
||||||
@@ -75,6 +79,14 @@ export interface OpenChatInActivePaneEvent {
|
|||||||
chat_id: string;
|
chat_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Open a whole chat in a fresh split pane (vs the active pane). Emitted by the
|
||||||
|
// ChatTabBar tab context menu ("Open in new pane") and by MessageBubble.fork()
|
||||||
|
// so a fork lands beside the original. useWorkspacePanes subscribes.
|
||||||
|
export interface OpenChatInNewPaneEvent {
|
||||||
|
type: 'open_chat_in_new_pane';
|
||||||
|
chat_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
// v1.14.x-html-artifact-panes: ActionRow's "Open in pane" button emits one of
|
// v1.14.x-html-artifact-panes: ActionRow's "Open in pane" button emits one of
|
||||||
// these; useWorkspacePanes subscribes and inserts the corresponding artifact
|
// these; useWorkspacePanes subscribes and inserts the corresponding artifact
|
||||||
// pane (or focuses an existing one keyed by message_id).
|
// pane (or focuses an existing one keyed by message_id).
|
||||||
@@ -178,6 +190,7 @@ export type SessionEvent =
|
|||||||
| OpenFileInBrowserEvent
|
| OpenFileInBrowserEvent
|
||||||
| AttachChatFileEvent
|
| AttachChatFileEvent
|
||||||
| OpenChatInActivePaneEvent
|
| OpenChatInActivePaneEvent
|
||||||
|
| OpenChatInNewPaneEvent
|
||||||
| OpenMarkdownArtifactPaneEvent
|
| OpenMarkdownArtifactPaneEvent
|
||||||
| OpenHtmlArtifactPaneEvent
|
| OpenHtmlArtifactPaneEvent
|
||||||
| OpenSettingsPaneEvent
|
| OpenSettingsPaneEvent
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
|
|||||||
case 'attach_chat_file':
|
case 'attach_chat_file':
|
||||||
return prev;
|
return prev;
|
||||||
case 'open_chat_in_active_pane':
|
case 'open_chat_in_active_pane':
|
||||||
|
case 'open_chat_in_new_pane':
|
||||||
// Consumed by Workspace; sidebar has no business with pane state.
|
// Consumed by Workspace; sidebar has no business with pane state.
|
||||||
return prev;
|
return prev;
|
||||||
case 'open_markdown_artifact_pane':
|
case 'open_markdown_artifact_pane':
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import type { DragEvent } from 'react';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import type {
|
import type {
|
||||||
|
ClosedPaneEntry,
|
||||||
HtmlArtifactState,
|
HtmlArtifactState,
|
||||||
MarkdownArtifactState,
|
MarkdownArtifactState,
|
||||||
WorkspacePane,
|
WorkspacePane,
|
||||||
|
WorkspaceState,
|
||||||
} from '@/api/types';
|
} from '@/api/types';
|
||||||
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
|
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
@@ -32,19 +34,35 @@ function chatPane(chatId: string): WorkspacePane {
|
|||||||
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
|
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClosedPaneEntry {
|
// v2.6.x: reopen stack cap. The stack now lives in React state (persisted in
|
||||||
kind: WorkspacePane['kind'];
|
// the WorkspaceState envelope), not a module-level array. `appendClosed` is the
|
||||||
chatIds: string[];
|
// pure state-updater helper.
|
||||||
activeChatIdx: number;
|
|
||||||
}
|
|
||||||
const MAX_CLOSED = 10;
|
const MAX_CLOSED = 10;
|
||||||
const closedPaneStack: ClosedPaneEntry[] = [];
|
|
||||||
|
|
||||||
function pushClosed(pane: WorkspacePane): void {
|
// Pure helper: append a closed-pane entry derived from `pane` to `stack`,
|
||||||
if (pane.kind === 'empty' || pane.kind === 'settings') return;
|
// capped at MAX_CLOSED (most-recent last). Returns the SAME reference when the
|
||||||
if (pane.chatIds.length === 0) return;
|
// pane is not eligible (empty/settings/no chats) so callers can skip setState.
|
||||||
closedPaneStack.push({ kind: pane.kind, chatIds: [...pane.chatIds], activeChatIdx: pane.activeChatIdx });
|
function appendClosed(stack: ClosedPaneEntry[], pane: WorkspacePane): ClosedPaneEntry[] {
|
||||||
if (closedPaneStack.length > MAX_CLOSED) closedPaneStack.shift();
|
if (pane.kind === 'empty' || pane.kind === 'settings') return stack;
|
||||||
|
if (pane.chatIds.length === 0) return stack;
|
||||||
|
const entry = { kind: pane.kind, chatIds: [...pane.chatIds], activeChatIdx: pane.activeChatIdx };
|
||||||
|
// Dedupe a value-identical top entry. This is called via setClosedPaneStack
|
||||||
|
// inside the setPanes updater in removePane; React StrictMode double-invokes
|
||||||
|
// that updater in dev, which would otherwise push two identical entries.
|
||||||
|
// Real closes never collide (one chat lives in at most one pane).
|
||||||
|
const top = stack[stack.length - 1];
|
||||||
|
if (
|
||||||
|
top &&
|
||||||
|
top.kind === entry.kind &&
|
||||||
|
top.activeChatIdx === entry.activeChatIdx &&
|
||||||
|
top.chatIds.length === entry.chatIds.length &&
|
||||||
|
top.chatIds.every((id, i) => id === entry.chatIds[i])
|
||||||
|
) {
|
||||||
|
return stack;
|
||||||
|
}
|
||||||
|
const next = [...stack, entry];
|
||||||
|
if (next.length > MAX_CLOSED) next.splice(0, next.length - MAX_CLOSED);
|
||||||
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
function chatNameForPaneKind(kind: 'coder' | 'terminal'): string {
|
function chatNameForPaneKind(kind: 'coder' | 'terminal'): string {
|
||||||
@@ -110,6 +128,26 @@ function persistablePanes(panes: WorkspacePane[]): WorkspacePane[] {
|
|||||||
return normalizePanes(panes).filter((p) => p.kind !== 'settings');
|
return normalizePanes(panes).filter((p) => p.kind !== 'settings');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v2.6.x: LOCKED migration — a value read from session.workspace_panes (or the
|
||||||
|
// session_workspace_updated frame) may be EITHER the legacy bare
|
||||||
|
// WorkspacePane[] OR the new WorkspaceState envelope. Normalize to the
|
||||||
|
// envelope. Must match the server's normalization byte-for-byte.
|
||||||
|
function toWorkspaceState(raw: unknown): WorkspaceState {
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
return { panes: raw as WorkspacePane[], tabNumbers: {}, nextTabNumber: 1, closedPaneStack: [] };
|
||||||
|
}
|
||||||
|
if (raw && typeof raw === 'object' && Array.isArray((raw as WorkspaceState).panes)) {
|
||||||
|
const env = raw as WorkspaceState;
|
||||||
|
return {
|
||||||
|
panes: env.panes,
|
||||||
|
tabNumbers: env.tabNumbers ?? {},
|
||||||
|
nextTabNumber: env.nextTabNumber ?? 1,
|
||||||
|
closedPaneStack: env.closedPaneStack ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { panes: [], tabNumbers: {}, nextTabNumber: 1, closedPaneStack: [] };
|
||||||
|
}
|
||||||
|
|
||||||
// v1.9: per recon decision (c), settings panes don't count toward MAX_PANES.
|
// v1.9: per recon decision (c), settings panes don't count toward MAX_PANES.
|
||||||
// Helper used at every pane-insertion site so the rule lives in one place.
|
// Helper used at every pane-insertion site so the rule lives in one place.
|
||||||
function nonSettingsCount(panes: WorkspacePane[]): number {
|
function nonSettingsCount(panes: WorkspacePane[]): number {
|
||||||
@@ -132,6 +170,9 @@ function readLegacyPanes(sessionId: string): WorkspacePane[] | null {
|
|||||||
|
|
||||||
export interface UseWorkspacePanesResult {
|
export interface UseWorkspacePanesResult {
|
||||||
panes: WorkspacePane[];
|
panes: WorkspacePane[];
|
||||||
|
// v2.6.x: stable session-scoped tab number per chat id (Batch 3a). Keyed by
|
||||||
|
// chat.id, NEVER by tab position.
|
||||||
|
tabNumbers: Record<string, number>;
|
||||||
activePaneIdx: number;
|
activePaneIdx: number;
|
||||||
setActivePaneIdx: React.Dispatch<React.SetStateAction<number>>;
|
setActivePaneIdx: React.Dispatch<React.SetStateAction<number>>;
|
||||||
activePaneIdxRef: React.MutableRefObject<number>;
|
activePaneIdxRef: React.MutableRefObject<number>;
|
||||||
@@ -171,6 +212,12 @@ export interface UseWorkspacePanesResult {
|
|||||||
export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||||
const [panes, setPanes] = useState<WorkspacePane[]>(() => [emptyPane()]);
|
const [panes, setPanes] = useState<WorkspacePane[]>(() => [emptyPane()]);
|
||||||
const [activePaneIdx, setActivePaneIdx] = useState(0);
|
const [activePaneIdx, setActivePaneIdx] = useState(0);
|
||||||
|
// v2.6.x envelope state. Persisted alongside `panes` in the WorkspaceState
|
||||||
|
// envelope. `tabNumbers` is the stable session-scoped tab number per chat id;
|
||||||
|
// `nextTabNumber` only ever increments; `closedPaneStack` is the reopen LIFO.
|
||||||
|
const [tabNumbers, setTabNumbers] = useState<Record<string, number>>({});
|
||||||
|
const [nextTabNumber, setNextTabNumber] = useState(1);
|
||||||
|
const [closedPaneStack, setClosedPaneStack] = useState<ClosedPaneEntry[]>([]);
|
||||||
const draggingIdxRef = useRef<number | null>(null);
|
const draggingIdxRef = useRef<number | null>(null);
|
||||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||||
// v1.12.1: skip PATCH while hydrating from the server. Without this, the
|
// v1.12.1: skip PATCH while hydrating from the server. Without this, the
|
||||||
@@ -237,27 +284,42 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
try {
|
try {
|
||||||
const session = await api.sessions.get(sessionId);
|
const session = await api.sessions.get(sessionId);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
let initial: WorkspacePane[] = Array.isArray(session.workspace_panes)
|
let env = toWorkspaceState(session.workspace_panes);
|
||||||
? normalizePanes(session.workspace_panes)
|
let initial: WorkspacePane[] = normalizePanes(env.panes);
|
||||||
: [];
|
|
||||||
// One-time migration: if server is empty but legacy localStorage has
|
// One-time migration: if server is empty but legacy localStorage has
|
||||||
// a layout, seed the server and delete the local key.
|
// a layout, seed the server (as an envelope) and delete the local key.
|
||||||
if (initial.length === 0) {
|
if (initial.length === 0) {
|
||||||
const legacy = readLegacyPanes(sessionId);
|
const legacy = readLegacyPanes(sessionId);
|
||||||
if (legacy && legacy.length > 0) {
|
if (legacy && legacy.length > 0) {
|
||||||
try {
|
try {
|
||||||
const updated = await api.sessions.updateWorkspacePanes(sessionId, legacy);
|
const seedState: WorkspaceState = {
|
||||||
|
panes: persistablePanes(legacy),
|
||||||
|
tabNumbers: {},
|
||||||
|
nextTabNumber: 1,
|
||||||
|
closedPaneStack: [],
|
||||||
|
};
|
||||||
|
const updated = await api.sessions.updateWorkspacePanes(sessionId, seedState);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
initial = updated.workspace_panes;
|
env = toWorkspaceState(updated.workspace_panes);
|
||||||
|
initial = normalizePanes(env.panes);
|
||||||
localStorage.removeItem(`${LEGACY_STORAGE_KEY}.${sessionId}`);
|
localStorage.removeItem(`${LEGACY_STORAGE_KEY}.${sessionId}`);
|
||||||
} catch {
|
} catch {
|
||||||
initial = legacy;
|
env = { ...env, panes: legacy };
|
||||||
|
initial = normalizePanes(legacy);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const next = initial.length > 0 ? initial : [emptyPane()];
|
const next = initial.length > 0 ? initial : [emptyPane()];
|
||||||
lastRemoteJsonRef.current = JSON.stringify(persistablePanes(next));
|
lastRemoteJsonRef.current = JSON.stringify({
|
||||||
|
panes: persistablePanes(next),
|
||||||
|
tabNumbers: env.tabNumbers,
|
||||||
|
nextTabNumber: env.nextTabNumber,
|
||||||
|
closedPaneStack: env.closedPaneStack,
|
||||||
|
});
|
||||||
setPanes(next);
|
setPanes(next);
|
||||||
|
setTabNumbers(env.tabNumbers);
|
||||||
|
setNextTabNumber(env.nextTabNumber);
|
||||||
|
setClosedPaneStack(env.closedPaneStack);
|
||||||
setActivePaneIdx(0);
|
setActivePaneIdx(0);
|
||||||
seedEmptyScopedPanes(next);
|
seedEmptyScopedPanes(next);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -273,15 +335,25 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
return sessionEvents.subscribe((ev) => {
|
return sessionEvents.subscribe((ev) => {
|
||||||
if (ev.type !== 'session_workspace_updated') return;
|
if (ev.type !== 'session_workspace_updated') return;
|
||||||
if (ev.session_id !== sessionId) return;
|
if (ev.session_id !== sessionId) return;
|
||||||
const incoming = normalizePanes(
|
const env = toWorkspaceState(ev.workspace_panes);
|
||||||
Array.isArray(ev.workspace_panes) ? ev.workspace_panes : [],
|
const incoming = normalizePanes(env.panes);
|
||||||
);
|
// Echo-dedup on the FULL envelope so tabNumber / stack-only changes are
|
||||||
const json = JSON.stringify(incoming);
|
// not mistaken for our own write echo.
|
||||||
|
const json = JSON.stringify({
|
||||||
|
panes: persistablePanes(incoming),
|
||||||
|
tabNumbers: env.tabNumbers,
|
||||||
|
nextTabNumber: env.nextTabNumber,
|
||||||
|
closedPaneStack: env.closedPaneStack,
|
||||||
|
});
|
||||||
if (json === lastRemoteJsonRef.current) return;
|
if (json === lastRemoteJsonRef.current) return;
|
||||||
lastRemoteJsonRef.current = json;
|
lastRemoteJsonRef.current = json;
|
||||||
setPanes(incoming.length > 0 ? incoming : [emptyPane()]);
|
const nextPanes = incoming.length > 0 ? incoming : [emptyPane()];
|
||||||
|
setPanes(nextPanes);
|
||||||
|
setTabNumbers(env.tabNumbers);
|
||||||
|
setNextTabNumber(env.nextTabNumber);
|
||||||
|
setClosedPaneStack(env.closedPaneStack);
|
||||||
setActivePaneIdx((prev) => Math.min(prev, Math.max(0, incoming.length - 1)));
|
setActivePaneIdx((prev) => Math.min(prev, Math.max(0, incoming.length - 1)));
|
||||||
seedEmptyScopedPanes(incoming.length > 0 ? incoming : [emptyPane()]);
|
seedEmptyScopedPanes(nextPanes);
|
||||||
});
|
});
|
||||||
}, [sessionId, seedEmptyScopedPanes]);
|
}, [sessionId, seedEmptyScopedPanes]);
|
||||||
|
|
||||||
@@ -333,18 +405,75 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
// before saving (ephemeral per v1.9).
|
// before saving (ephemeral per v1.9).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hydratedRef.current) return;
|
if (!hydratedRef.current) return;
|
||||||
const payload = persistablePanes(panes);
|
// v2.6.x: persist the full WorkspaceState envelope. The dedup ref compares
|
||||||
const json = JSON.stringify(payload);
|
// the whole envelope so tabNumber / reopen-stack changes also persist.
|
||||||
|
const envelope: WorkspaceState = {
|
||||||
|
panes: persistablePanes(panes),
|
||||||
|
tabNumbers,
|
||||||
|
nextTabNumber,
|
||||||
|
closedPaneStack,
|
||||||
|
};
|
||||||
|
const json = JSON.stringify(envelope);
|
||||||
if (json === lastRemoteJsonRef.current) return;
|
if (json === lastRemoteJsonRef.current) return;
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
lastRemoteJsonRef.current = json;
|
lastRemoteJsonRef.current = json;
|
||||||
api.sessions.updateWorkspacePanes(sessionId, payload).catch(() => {
|
api.sessions.updateWorkspacePanes(sessionId, envelope).catch(() => {
|
||||||
// Non-fatal: next change retries. Persistent failures surface via
|
// Non-fatal: next change retries. Persistent failures surface via
|
||||||
// the network layer's existing reconnect toast.
|
// the network layer's existing reconnect toast.
|
||||||
});
|
});
|
||||||
}, SAVE_DEBOUNCE_MS);
|
}, SAVE_DEBOUNCE_MS);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [sessionId, panes]);
|
}, [sessionId, panes, tabNumbers, nextTabNumber, closedPaneStack]);
|
||||||
|
|
||||||
|
// v2.6.x (Batch 3a): maintain stable, session-scoped tab numbers. Collect the
|
||||||
|
// chat ids that appear in CHAT-kind panes in deterministic order (pane index,
|
||||||
|
// then tab index). Assign numbers to any without one (global per session,
|
||||||
|
// only ever increasing, never reused) and prune entries whose chat is no
|
||||||
|
// longer in any chat-kind pane. Guarded against render loops: only setState
|
||||||
|
// when something actually changed.
|
||||||
|
useEffect(() => {
|
||||||
|
const liveChatIds: string[] = [];
|
||||||
|
const liveSet = new Set<string>();
|
||||||
|
for (const pane of panes) {
|
||||||
|
if (pane.kind !== 'chat') continue;
|
||||||
|
for (const id of pane.chatIds) {
|
||||||
|
if (!liveSet.has(id)) {
|
||||||
|
liveSet.add(id);
|
||||||
|
liveChatIds.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign: walk live ids in deterministic order, handing out numbers.
|
||||||
|
let counter = nextTabNumber;
|
||||||
|
const additions: Record<string, number> = {};
|
||||||
|
for (const id of liveChatIds) {
|
||||||
|
if (tabNumbers[id] === undefined && additions[id] === undefined) {
|
||||||
|
additions[id] = counter;
|
||||||
|
counter += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune: retire numbers for chats no longer in any chat-kind pane.
|
||||||
|
const removals: string[] = [];
|
||||||
|
for (const id of Object.keys(tabNumbers)) {
|
||||||
|
if (!liveSet.has(id)) removals.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAdditions = Object.keys(additions).length > 0;
|
||||||
|
const hasRemovals = removals.length > 0;
|
||||||
|
if (!hasAdditions && !hasRemovals) return;
|
||||||
|
|
||||||
|
setTabNumbers((prev) => {
|
||||||
|
const next: Record<string, number> = {};
|
||||||
|
for (const [id, n] of Object.entries(prev)) {
|
||||||
|
if (!removals.includes(id)) next[id] = n;
|
||||||
|
}
|
||||||
|
Object.assign(next, additions);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
if (hasAdditions) setNextTabNumber(counter);
|
||||||
|
}, [panes, tabNumbers, nextTabNumber]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const active = panes[activePaneIdx];
|
const active = panes[activePaneIdx];
|
||||||
@@ -391,6 +520,37 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
setActivePaneIdx(paneIdx);
|
setActivePaneIdx(paneIdx);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Open a whole chat in its own fresh pane (focused). Detaches the chat from
|
||||||
|
// any pane currently showing it so it lives in exactly one pane (preserves
|
||||||
|
// the one-chat-per-pane model), dropping a source pane left with no tabs. For
|
||||||
|
// fork the chat isn't in any pane yet, so the detach is a no-op (pure append).
|
||||||
|
const openChatInNewPane = useCallback((chatId: string) => {
|
||||||
|
setPanes((prev) => {
|
||||||
|
const detached = prev.flatMap((p) => {
|
||||||
|
if (!p.chatIds.includes(chatId)) return [p];
|
||||||
|
const nextIds = p.chatIds.filter((id) => id !== chatId);
|
||||||
|
if (nextIds.length === 0) return [];
|
||||||
|
const ai = Math.min(p.activeChatIdx, nextIds.length - 1);
|
||||||
|
return [{ ...p, kind: 'chat' as const, chatId: nextIds[ai], chatIds: nextIds, activeChatIdx: ai }];
|
||||||
|
});
|
||||||
|
if (nonSettingsCount(detached) >= MAX_PANES) {
|
||||||
|
toast.error(`Maximum ${MAX_PANES} panes`);
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = [...detached, chatPane(chatId)];
|
||||||
|
setActivePaneIdx(next.length - 1);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ChatTabBar's "Open in new pane" + MessageBubble.fork() emit this.
|
||||||
|
useEffect(() => {
|
||||||
|
return sessionEvents.subscribe((ev) => {
|
||||||
|
if (ev.type !== 'open_chat_in_new_pane') return;
|
||||||
|
openChatInNewPane(ev.chat_id);
|
||||||
|
});
|
||||||
|
}, [openChatInNewPane]);
|
||||||
|
|
||||||
const switchTab = useCallback((paneIdx: number, tabIdx: number) => {
|
const switchTab = useCallback((paneIdx: number, tabIdx: number) => {
|
||||||
setPanes((prev) => {
|
setPanes((prev) => {
|
||||||
const next = [...prev];
|
const next = [...prev];
|
||||||
@@ -411,7 +571,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
if (next.length > 1) {
|
if (next.length > 1) {
|
||||||
// Last tab closed and other panes exist — remove the whole pane
|
// Last tab closed and other panes exist — remove the whole pane
|
||||||
// instead of leaving an orphaned empty panel.
|
// instead of leaving an orphaned empty panel.
|
||||||
pushClosed(pane); setHasClosedPanes(true);
|
setClosedPaneStack((stack) => appendClosed(stack, pane));
|
||||||
const spliced = next.filter((_, i) => i !== paneIdx);
|
const spliced = next.filter((_, i) => i !== paneIdx);
|
||||||
setActivePaneIdx((ai) => Math.min(ai, spliced.length - 1));
|
setActivePaneIdx((ai) => Math.min(ai, spliced.length - 1));
|
||||||
return spliced;
|
return spliced;
|
||||||
@@ -547,7 +707,8 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
setPanes((prev) => {
|
setPanes((prev) => {
|
||||||
if (prev.length <= 1) {
|
if (prev.length <= 1) {
|
||||||
// Settings is the only kind that can be the last pane and still need
|
// Settings is the only kind that can be the last pane and still need
|
||||||
// closing (X / Esc / sidebar toggle). Fall back to empty.
|
// closing (X / Esc / sidebar toggle). Fall back to empty. One-pane
|
||||||
|
// edge: no relocation — there is no other pane.
|
||||||
if (prev[idx]?.kind === 'settings') {
|
if (prev[idx]?.kind === 'settings') {
|
||||||
setActivePaneIdx(0);
|
setActivePaneIdx(0);
|
||||||
return [emptyPane()];
|
return [emptyPane()];
|
||||||
@@ -559,35 +720,101 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
// The endpoint is idempotent (404 on missing session) so a strict-mode
|
// The endpoint is idempotent (404 on missing session) so a strict-mode
|
||||||
// double-invoke of the updater is safe.
|
// double-invoke of the updater is safe.
|
||||||
const removed = prev[idx];
|
const removed = prev[idx];
|
||||||
if (removed) { pushClosed(removed); setHasClosedPanes(true); }
|
// Push the original pane (with its chatIds intact) to the reopen stack.
|
||||||
|
if (removed) setClosedPaneStack((stack) => appendClosed(stack, removed));
|
||||||
if (removed?.kind === 'terminal') {
|
if (removed?.kind === 'terminal') {
|
||||||
api.terminals.kill(sessionId, removed.id).catch(() => { /* non-fatal */ });
|
api.terminals.kill(sessionId, removed.id).catch(() => { /* non-fatal */ });
|
||||||
}
|
}
|
||||||
const next = prev.filter((_, i) => i !== idx);
|
|
||||||
|
// v2.6.x (Batch 1): relocate a closing CHAT pane's tabs to the oldest
|
||||||
|
// remaining pane that can host chat tabs, so chats aren't lost on close.
|
||||||
|
// Only chat panes relocate — terminal/coder panes own a scoped chat bound
|
||||||
|
// to the pane, so those close exactly as before (no relocation).
|
||||||
|
let working = prev;
|
||||||
|
if (removed && removed.kind === 'chat' && removed.chatIds.length > 0) {
|
||||||
|
// "Oldest remaining": lowest index, excluding `idx`, that is a chat or
|
||||||
|
// empty pane (the only kinds that can host arbitrary chat tabs). Skip
|
||||||
|
// terminal/coder/settings/artifact panes.
|
||||||
|
let targetIdx = -1;
|
||||||
|
for (let i = 0; i < prev.length; i += 1) {
|
||||||
|
if (i === idx) continue;
|
||||||
|
const p = prev[i]!;
|
||||||
|
if (p.kind === 'chat' || p.kind === 'empty') {
|
||||||
|
targetIdx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetIdx >= 0) {
|
||||||
|
working = prev.map((p, i) => {
|
||||||
|
if (i !== targetIdx) return p;
|
||||||
|
const mergedIds = [...p.chatIds, ...removed.chatIds];
|
||||||
|
// Preserve the target's existing focus — append, don't force-focus
|
||||||
|
// the moved tabs. Clamp only when the target had no active tab.
|
||||||
|
const ai = p.activeChatIdx >= 0 ? p.activeChatIdx : 0;
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
kind: 'chat' as const,
|
||||||
|
chatIds: mergedIds,
|
||||||
|
activeChatIdx: ai,
|
||||||
|
chatId: mergedIds[ai],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = working.filter((_, i) => i !== idx);
|
||||||
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
|
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
const [hasClosedPanes, setHasClosedPanes] = useState(closedPaneStack.length > 0);
|
const hasClosedPanes = closedPaneStack.length > 0;
|
||||||
|
|
||||||
const reopenPane = useCallback(() => {
|
const reopenPane = useCallback(() => {
|
||||||
const entry = closedPaneStack.pop();
|
// Read the top entry from the current render's stack (not inside the
|
||||||
setHasClosedPanes(closedPaneStack.length > 0);
|
// updater) so a StrictMode double-invoke can't pop two entries. The pop
|
||||||
if (!entry) return;
|
// setState is idempotent: filtering by reference removes exactly this entry.
|
||||||
|
const e = closedPaneStack[closedPaneStack.length - 1];
|
||||||
|
if (!e) return;
|
||||||
|
setClosedPaneStack((stack) => (stack[stack.length - 1] === e ? stack.slice(0, -1) : stack));
|
||||||
setPanes((prev) => {
|
setPanes((prev) => {
|
||||||
|
// v2.6.x (Batch 4): reversible reopen. The closed tabs may have been
|
||||||
|
// relocated into another pane on close (Batch 1). Strip e.chatIds from
|
||||||
|
// every existing pane first so reopening never duplicates a tab —
|
||||||
|
// whether or not it was relocated (a no-op strip when it wasn't). Mirror
|
||||||
|
// removeTab's emptiness handling: a chat pane emptied by the strip is
|
||||||
|
// dropped when other panes remain, else turned empty.
|
||||||
|
const stripped: WorkspacePane[] = [];
|
||||||
|
for (const p of prev) {
|
||||||
|
const idxs = p.chatIds.filter((id) => !e.chatIds.includes(id));
|
||||||
|
if (idxs.length === p.chatIds.length) {
|
||||||
|
stripped.push(p);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (idxs.length === 0) {
|
||||||
|
if (p.kind === 'chat') {
|
||||||
|
// Drop the now-empty chat pane (we still have the restored pane plus
|
||||||
|
// possibly others). If it would leave zero panes, turn it empty.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
stripped.push({ ...p, chatId: undefined, chatIds: [], activeChatIdx: -1 });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const ai = Math.min(p.activeChatIdx, idxs.length - 1);
|
||||||
|
stripped.push({ ...p, chatIds: idxs, activeChatIdx: ai < 0 ? 0 : ai, chatId: idxs[ai < 0 ? 0 : ai] });
|
||||||
|
}
|
||||||
const restored: WorkspacePane = {
|
const restored: WorkspacePane = {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
kind: entry.kind,
|
kind: e.kind,
|
||||||
chatId: entry.chatIds[entry.activeChatIdx] ?? entry.chatIds[0],
|
chatId: e.chatIds[e.activeChatIdx] ?? e.chatIds[0],
|
||||||
chatIds: entry.chatIds,
|
chatIds: e.chatIds,
|
||||||
activeChatIdx: Math.min(entry.activeChatIdx, entry.chatIds.length - 1),
|
activeChatIdx: Math.min(e.activeChatIdx, e.chatIds.length - 1),
|
||||||
};
|
};
|
||||||
const next = [...prev, restored];
|
const next = [...stripped, restored];
|
||||||
setActivePaneIdx(next.length - 1);
|
setActivePaneIdx(next.length - 1);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}, [closedPaneStack]);
|
||||||
|
|
||||||
// Replaces a single empty default pane with a chat pane. Used by the initial
|
// Replaces a single empty default pane with a chat pane. Used by the initial
|
||||||
// chat fetch to land on the most-recent open chat if no saved pane state.
|
// chat fetch to land on the most-recent open chat if no saved pane state.
|
||||||
@@ -705,6 +932,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
panes,
|
panes,
|
||||||
|
tabNumbers,
|
||||||
activePaneIdx,
|
activePaneIdx,
|
||||||
setActivePaneIdx,
|
setActivePaneIdx,
|
||||||
activePaneIdxRef,
|
activePaneIdxRef,
|
||||||
|
|||||||
@@ -56,19 +56,26 @@ export function inferLanguage(filename: string): string | null {
|
|||||||
|
|
||||||
export function flattenToMessage(attachments: Attachment[], text: string): string {
|
export function flattenToMessage(attachments: Attachment[], text: string): string {
|
||||||
if (attachments.length === 0) return text;
|
if (attachments.length === 0) return text;
|
||||||
const blocks = attachments.map(a => {
|
// Pasted text is raw context, not code from a file — insert it verbatim with no
|
||||||
// Pasted text is raw context, not code from a file — insert it verbatim with
|
// ``` fence or provenance header. It trails the typed text with a leading space
|
||||||
// no ``` fence or provenance header. The chip only exists to keep the textarea
|
// so a leading slash command / prompt stays first and the paste reads as its
|
||||||
// tidy while composing; on send it should be exactly what the user pasted.
|
// continuation. File/line chips stay fenced provenance blocks, appended after.
|
||||||
|
const pasteBlocks: string[] = [];
|
||||||
|
const fencedBlocks: string[] = [];
|
||||||
|
for (const a of attachments) {
|
||||||
if (a.kind === 'paste') {
|
if (a.kind === 'paste') {
|
||||||
return a.content;
|
pasteBlocks.push(a.content);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
const fence = '```' + (a.language ?? '');
|
const fence = '```' + (a.language ?? '');
|
||||||
const header =
|
const header =
|
||||||
a.kind === 'lines'
|
a.kind === 'lines'
|
||||||
? `// from: ${a.filename}:${a.range?.[0] ?? '?'}-${a.range?.[1] ?? '?'}`
|
? `// from: ${a.filename}:${a.range?.[0] ?? '?'}-${a.range?.[1] ?? '?'}`
|
||||||
: `// from: ${a.filename}`;
|
: `// from: ${a.filename}`;
|
||||||
return `${fence}\n${header}\n${a.content}\n\`\`\``;
|
fencedBlocks.push(`${fence}\n${header}\n${a.content}\n\`\`\``);
|
||||||
});
|
}
|
||||||
return [...blocks, text].filter(Boolean).join('\n\n');
|
// Typed text + pasted content on the same logical line (space-joined), then
|
||||||
|
// any fenced file blocks as separate paragraphs.
|
||||||
|
const lead = [text, ...pasteBlocks].filter(Boolean).join(' ');
|
||||||
|
return [lead, ...fencedBlocks].filter(Boolean).join('\n\n');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user