🚀 RELEASE: chat start stop
This commit is contained in:
parent
170231a54f
commit
4d2fb1e2a0
13
apps/web/app/api/chat/active/route.ts
Normal file
13
apps/web/app/api/chat/active/route.ts
Normal file
@ -0,0 +1,13 @@
|
||||
/**
|
||||
* GET /api/chat/active
|
||||
*
|
||||
* Returns the session IDs of all currently running agent sessions.
|
||||
* Used by the sidebar to show streaming indicators.
|
||||
*/
|
||||
import { getRunningSessionIds } from "@/lib/active-runs";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export function GET() {
|
||||
return Response.json({ sessionIds: getRunningSessionIds() });
|
||||
}
|
||||
@ -1,7 +1,13 @@
|
||||
import { readdirSync, type Dirent } from "node:fs";
|
||||
import { readdirSync, readFileSync, existsSync, type Dirent } from "node:fs";
|
||||
import { join, dirname, resolve, basename } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { resolveWorkspaceRoot } from "@/lib/workspace";
|
||||
import {
|
||||
resolveWorkspaceRoot,
|
||||
duckdbQueryAllAsync,
|
||||
discoverDuckDBPaths,
|
||||
duckdbQueryOnFileAsync,
|
||||
parseSimpleYaml,
|
||||
} from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
@ -9,7 +15,13 @@ export const runtime = "nodejs";
|
||||
type SuggestItem = {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "folder" | "file" | "document" | "database";
|
||||
type: "folder" | "file" | "document" | "database" | "object" | "entry";
|
||||
/** Icon hint (emoji) for objects/entries */
|
||||
icon?: string;
|
||||
/** Object name that owns this entry */
|
||||
objectName?: string;
|
||||
/** DB entry ID */
|
||||
entryId?: string;
|
||||
};
|
||||
|
||||
const SKIP_DIRS = new Set([
|
||||
@ -182,28 +194,226 @@ function resolvePath(
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DuckDB object & entry search
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ObjectRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
display_field?: string;
|
||||
};
|
||||
|
||||
type FieldRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
sort_order?: number;
|
||||
};
|
||||
|
||||
function sqlEscape(s: string): string {
|
||||
return s.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
function resolveDisplayField(obj: ObjectRow, fields: FieldRow[]): string {
|
||||
if (obj.display_field) {return obj.display_field;}
|
||||
const nameField = fields.find(
|
||||
(f) => /\bname\b/i.test(f.name) || /\btitle\b/i.test(f.name),
|
||||
);
|
||||
if (nameField) {return nameField.name;}
|
||||
const textField = fields.find((f) => f.type === "text");
|
||||
if (textField) {return textField.name;}
|
||||
return fields[0]?.name ?? "id";
|
||||
}
|
||||
|
||||
/** Read icon from .object.yaml if present. */
|
||||
function readObjectIcon(workspaceRoot: string, objName: string): string | undefined {
|
||||
// Walk workspace to find a folder matching objName that has .object.yaml
|
||||
function walk(dir: string, depth: number): string | undefined {
|
||||
if (depth > 4) {return undefined;}
|
||||
try {
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith(".")) {continue;}
|
||||
if (entry.name === objName) {
|
||||
const yamlPath = join(dir, entry.name, ".object.yaml");
|
||||
if (existsSync(yamlPath)) {
|
||||
const parsed = parseSimpleYaml(readFileSync(yamlPath, "utf-8"));
|
||||
if (parsed.icon) {return String(parsed.icon);}
|
||||
}
|
||||
}
|
||||
const found = walk(join(dir, entry.name), depth + 1);
|
||||
if (found) {return found;}
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
return undefined;
|
||||
}
|
||||
return walk(workspaceRoot, 0);
|
||||
}
|
||||
|
||||
/** Search objects by name (case-insensitive substring). */
|
||||
async function searchObjects(
|
||||
query: string,
|
||||
workspaceRoot: string,
|
||||
max: number,
|
||||
): Promise<SuggestItem[]> {
|
||||
const sql = query
|
||||
? `SELECT * FROM objects WHERE LOWER(name) LIKE LOWER('%${sqlEscape(query)}%') ORDER BY name LIMIT ${max}`
|
||||
: `SELECT * FROM objects ORDER BY name LIMIT ${max}`;
|
||||
const objects = await duckdbQueryAllAsync<ObjectRow>(sql, "name");
|
||||
|
||||
const items: SuggestItem[] = [];
|
||||
for (const obj of objects) {
|
||||
const yamlIcon = readObjectIcon(workspaceRoot, obj.name);
|
||||
items.push({
|
||||
name: obj.name,
|
||||
path: `workspace:object:${obj.name}`,
|
||||
type: "object",
|
||||
icon: yamlIcon ?? obj.icon,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Safely convert an unknown DB value to a display string. */
|
||||
function dbStr(val: unknown): string {
|
||||
if (val == null) {return "";}
|
||||
if (typeof val === "object") {return JSON.stringify(val);}
|
||||
return String(val as string | number | boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search entries across all objects using a single UNION ALL query per DB.
|
||||
* Each object's pivot view (v_<name>) is searched by display field with ILIKE.
|
||||
* This avoids spawning N DuckDB CLI processes per object.
|
||||
*/
|
||||
async function searchEntries(
|
||||
query: string,
|
||||
max: number,
|
||||
): Promise<SuggestItem[]> {
|
||||
const dbPaths = discoverDuckDBPaths();
|
||||
if (dbPaths.length === 0 || !query) {return [];}
|
||||
|
||||
const items: SuggestItem[] = [];
|
||||
const seenObjects = new Set<string>();
|
||||
const likePattern = `%${sqlEscape(query)}%`;
|
||||
|
||||
for (const dbPath of dbPaths) {
|
||||
if (items.length >= max) {break;}
|
||||
|
||||
// Step 1: get objects + display fields in a single query
|
||||
type ObjFieldRow = ObjectRow & { field_name: string; field_type: string };
|
||||
const objFields = await duckdbQueryOnFileAsync<ObjFieldRow>(
|
||||
dbPath,
|
||||
`SELECT o.*, f.name as field_name, f.type as field_type
|
||||
FROM objects o
|
||||
LEFT JOIN fields f ON f.object_id = o.id
|
||||
ORDER BY o.name, f.sort_order`,
|
||||
);
|
||||
|
||||
// Group fields by object and resolve display fields
|
||||
const objectMap = new Map<string, { obj: ObjectRow; displayField: string }>();
|
||||
const fieldsByObj = new Map<string, FieldRow[]>();
|
||||
for (const row of objFields) {
|
||||
if (seenObjects.has(row.name)) {continue;}
|
||||
if (!fieldsByObj.has(row.id)) {fieldsByObj.set(row.id, []);}
|
||||
if (row.field_name) {
|
||||
fieldsByObj.get(row.id)!.push({
|
||||
id: row.id,
|
||||
name: row.field_name,
|
||||
type: row.field_type,
|
||||
});
|
||||
}
|
||||
if (!objectMap.has(row.name)) {
|
||||
const fields = fieldsByObj.get(row.id) ?? [];
|
||||
objectMap.set(row.name, {
|
||||
obj: row,
|
||||
displayField: resolveDisplayField(row, fields),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Re-resolve display fields now that all fields are collected
|
||||
for (const [name, entry] of objectMap) {
|
||||
const fields = fieldsByObj.get(entry.obj.id) ?? [];
|
||||
entry.displayField = resolveDisplayField(entry.obj, fields);
|
||||
seenObjects.add(name);
|
||||
}
|
||||
|
||||
if (objectMap.size === 0) {continue;}
|
||||
|
||||
// Step 2: build a single UNION ALL query searching all pivot views
|
||||
// Wrap each SELECT in parens so per-view LIMIT is valid DuckDB syntax
|
||||
const unionParts: string[] = [];
|
||||
for (const [name, { displayField }] of objectMap) {
|
||||
const safeDisplay = sqlEscape(displayField);
|
||||
unionParts.push(
|
||||
`(SELECT '${sqlEscape(name)}' as _obj_name, entry_id, "${safeDisplay}" as _display
|
||||
FROM v_${name}
|
||||
WHERE LOWER(CAST("${safeDisplay}" AS VARCHAR)) LIKE LOWER('${likePattern}')
|
||||
LIMIT ${max})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (unionParts.length === 0) {continue;}
|
||||
|
||||
type EntryHit = { _obj_name: string; entry_id: string; _display: string };
|
||||
const hits = await duckdbQueryOnFileAsync<EntryHit>(
|
||||
dbPath,
|
||||
`${unionParts.join(" UNION ALL ")} LIMIT ${max}`,
|
||||
);
|
||||
|
||||
for (const hit of hits) {
|
||||
if (items.length >= max) {return items;}
|
||||
if (!hit.entry_id || !hit._display) {continue;}
|
||||
const objInfo = objectMap.get(hit._obj_name);
|
||||
items.push({
|
||||
name: String(hit._display),
|
||||
path: `workspace:entry:${hit._obj_name}:${hit.entry_id}`,
|
||||
type: "entry",
|
||||
icon: objInfo?.obj.icon,
|
||||
objectName: hit._obj_name,
|
||||
entryId: hit.entry_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const pathQuery = url.searchParams.get("path");
|
||||
const searchQuery = url.searchParams.get("q");
|
||||
const workspaceRoot = resolveWorkspaceRoot() ?? homedir();
|
||||
|
||||
// Search mode: find files by name
|
||||
// Search mode: find files, objects, and entries by name
|
||||
if (searchQuery) {
|
||||
const results: SuggestItem[] = [];
|
||||
searchFiles(workspaceRoot, searchQuery, results, 20);
|
||||
// Also search home dir if workspace didn't yield enough
|
||||
if (results.length < 20) {
|
||||
searchFiles(homedir(), searchQuery, results, 20);
|
||||
}
|
||||
return Response.json({ items: results });
|
||||
// File search: workspace only (skip expensive home dir traversal)
|
||||
const fileResults: SuggestItem[] = [];
|
||||
searchFiles(workspaceRoot, searchQuery, fileResults, 15);
|
||||
|
||||
// DuckDB search: objects and entries (sequential to avoid lock contention)
|
||||
const objectResults = await searchObjects(searchQuery, workspaceRoot, 10);
|
||||
const entryResults = await searchEntries(searchQuery, 15);
|
||||
|
||||
// Deduplicate: if an object matches, remove the duplicate folder
|
||||
const objectNames = new Set(objectResults.map((o) => o.name));
|
||||
const dedupedFiles = fileResults.filter(
|
||||
(f) => !(f.type === "folder" && objectNames.has(f.name)),
|
||||
);
|
||||
|
||||
// Merge: objects first, then entries, then files
|
||||
const items = [...objectResults, ...entryResults, ...dedupedFiles].slice(0, 30);
|
||||
return Response.json({ items });
|
||||
}
|
||||
|
||||
// Browse mode: resolve path and list directory
|
||||
if (pathQuery) {
|
||||
const resolved = resolvePath(pathQuery, workspaceRoot);
|
||||
if (!resolved) {
|
||||
// Treat as filename search
|
||||
const results: SuggestItem[] = [];
|
||||
searchFiles(workspaceRoot, pathQuery, results, 20);
|
||||
return Response.json({ items: results });
|
||||
@ -212,7 +422,13 @@ export async function GET(req: Request) {
|
||||
return Response.json({ items });
|
||||
}
|
||||
|
||||
// Default: list workspace root
|
||||
const items = listDir(workspaceRoot);
|
||||
return Response.json({ items });
|
||||
// Default: list workspace root + all objects
|
||||
const fileItems = listDir(workspaceRoot);
|
||||
const objectItems = await searchObjects("", workspaceRoot, 20);
|
||||
// Deduplicate: if an object also appears as a folder, keep the object version
|
||||
const objectNames = new Set(objectItems.map((o) => o.name));
|
||||
const dedupedFiles = fileItems.filter(
|
||||
(f) => !(f.type === "folder" && objectNames.has(f.name)),
|
||||
);
|
||||
return Response.json({ items: [...objectItems, ...dedupedFiles] });
|
||||
}
|
||||
|
||||
@ -456,6 +456,8 @@ type ChatPanelProps = {
|
||||
compact?: boolean;
|
||||
/** Override the header title when a session is active (e.g. show the session's actual title). */
|
||||
sessionTitle?: string;
|
||||
/** Session ID to auto-load on mount (for non-file panels that remount after navigation). */
|
||||
initialSessionId?: string;
|
||||
/** Called when file content may have changed after agent edits. */
|
||||
onFileChanged?: (newContent: string) => void;
|
||||
/** Called when active session changes (for external sidebar highlighting). */
|
||||
@ -470,6 +472,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
fileContext,
|
||||
compact,
|
||||
sessionTitle,
|
||||
initialSessionId,
|
||||
onFileChanged,
|
||||
onActiveSessionChange,
|
||||
onSessionsChange,
|
||||
@ -619,6 +622,14 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
return false; // No active run
|
||||
}
|
||||
|
||||
// If the run already completed (still in the grace
|
||||
// period), skip the expensive SSE replay -- the
|
||||
// persisted messages we already loaded are final.
|
||||
if (res.headers.get("X-Run-Active") === "false") {
|
||||
res.body.cancel();
|
||||
return false;
|
||||
}
|
||||
|
||||
setIsReconnecting(true);
|
||||
|
||||
const parser = createStreamParser();
|
||||
@ -629,6 +640,10 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
let frameRequested = false;
|
||||
|
||||
const updateUI = () => {
|
||||
// Guard: if the session was switched while a
|
||||
// rAF was pending, don't overwrite the new
|
||||
// session's messages with stale data.
|
||||
if (abort.signal.aborted) {return;}
|
||||
const assistantMsg = {
|
||||
id: reconnectMsgId,
|
||||
role: "assistant" as const,
|
||||
@ -685,10 +700,12 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
updateUI();
|
||||
|
||||
// Mark all messages as saved (server persisted them)
|
||||
for (const m of baseMessages) {
|
||||
savedMessageIdsRef.current.add(m.id);
|
||||
if (!abort.signal.aborted) {
|
||||
for (const m of baseMessages) {
|
||||
savedMessageIdsRef.current.add(m.id);
|
||||
}
|
||||
savedMessageIdsRef.current.add(reconnectMsgId);
|
||||
}
|
||||
savedMessageIdsRef.current.add(reconnectMsgId);
|
||||
|
||||
setIsReconnecting(false);
|
||||
reconnectAbortRef.current = null;
|
||||
@ -800,13 +817,15 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
};
|
||||
},
|
||||
);
|
||||
if (!cancelled) {
|
||||
setMessages(uiMessages);
|
||||
}
|
||||
if (!cancelled) {
|
||||
setMessages(uiMessages);
|
||||
}
|
||||
|
||||
// If there was a streaming message, try to
|
||||
// reconnect to the active agent run.
|
||||
if (hasStreaming && !cancelled) {
|
||||
// Always try to reconnect to a potentially
|
||||
// active agent run. The stream endpoint returns
|
||||
// 404 gracefully if no run exists, avoiding the
|
||||
// 2-second persistence timing gap for _streaming.
|
||||
if (!cancelled) {
|
||||
await attemptReconnect(
|
||||
latest.id,
|
||||
uiMessages,
|
||||
@ -824,6 +843,20 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- stable setters
|
||||
}, [filePath, attemptReconnect]);
|
||||
|
||||
// ── Non-file panel: auto-restore session on mount ──
|
||||
// When the main ChatPanel remounts after navigation (e.g. user viewed
|
||||
// a file then returned to chat), re-load the previously active session
|
||||
// and reconnect to any active stream.
|
||||
const initialSessionHandled = useRef(false);
|
||||
useEffect(() => {
|
||||
if (filePath || !initialSessionId || initialSessionHandled.current) {
|
||||
return;
|
||||
}
|
||||
initialSessionHandled.current = true;
|
||||
void handleSessionSelect(initialSessionId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- run once on mount
|
||||
}, []);
|
||||
|
||||
// ── Post-stream side-effects (file-reload, session refresh) ──
|
||||
// Message persistence is handled server-side by ActiveRunManager,
|
||||
// so we only refresh the file sessions list and notify the parent
|
||||
@ -1063,13 +1096,11 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
|
||||
setMessages(uiMessages);
|
||||
|
||||
// Reconnect to active stream if one exists.
|
||||
if (hasStreaming) {
|
||||
await attemptReconnect(
|
||||
sessionId,
|
||||
uiMessages,
|
||||
);
|
||||
}
|
||||
// Always try to reconnect -- the stream endpoint
|
||||
// returns 404 gracefully if no active run exists,
|
||||
// and this avoids missing runs whose _streaming
|
||||
// flag hasn't been persisted yet.
|
||||
await attemptReconnect(sessionId, uiMessages);
|
||||
} catch (err) {
|
||||
console.error("Error loading session:", err);
|
||||
} finally {
|
||||
@ -1485,7 +1516,8 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
className={compact ? "" : "max-w-[720px] mx-auto"}
|
||||
>
|
||||
<div
|
||||
className="rounded-3xl overflow-hidden"
|
||||
data-chat-drop-target=""
|
||||
className="rounded-3xl overflow-hidden chat-input-drop-target"
|
||||
style={{
|
||||
background:
|
||||
"var(--color-chat-input-bg)",
|
||||
@ -1744,6 +1776,17 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
}
|
||||
onSelect={handleFilesSelected}
|
||||
/>
|
||||
|
||||
{/* Drop highlight for sidebar drag-and-drop */}
|
||||
<style>{`
|
||||
.chat-input-drop-target[data-drag-hover] {
|
||||
outline: 2px dashed var(--color-accent) !important;
|
||||
outline-offset: -2px;
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-accent) 15%, transparent),
|
||||
0 0 32px rgba(0,0,0,0.07) !important;
|
||||
transition: outline 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@ -86,8 +86,10 @@ function shortenPath(path: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the editor content to plain text with file mention markers.
|
||||
* Serialize the editor content to plain text with mention markers.
|
||||
* Returns { text, mentionedFiles }.
|
||||
* Objects serialize as `[object: name]`, entries as `[entry: objectName/label]`,
|
||||
* and files as `[file: path]`.
|
||||
*/
|
||||
function serializeContent(editor: ReturnType<typeof useEditor>): {
|
||||
text: string;
|
||||
@ -102,15 +104,24 @@ function serializeContent(editor: ReturnType<typeof useEditor>): {
|
||||
if (node.type.name === "chatFileMention") {
|
||||
const label = node.attrs.label as string;
|
||||
const path = node.attrs.path as string;
|
||||
const mType = node.attrs.mentionType as string;
|
||||
const objectName = node.attrs.objectName as string;
|
||||
|
||||
mentionedFiles.push({ name: label, path });
|
||||
parts.push(`[file: ${path}]`);
|
||||
|
||||
if (mType === "object") {
|
||||
parts.push(`[object: ${label}]`);
|
||||
} else if (mType === "entry") {
|
||||
parts.push(`[entry: ${objectName ? `${objectName}/` : ""}${label}]`);
|
||||
} else {
|
||||
parts.push(`[file: ${path}]`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (node.isText && node.text) {
|
||||
parts.push(node.text);
|
||||
}
|
||||
if (node.type.name === "paragraph" && parts.length > 0) {
|
||||
// Add newline between paragraphs (except the first)
|
||||
const lastPart = parts[parts.length - 1];
|
||||
if (lastPart !== undefined && lastPart !== "\n") {
|
||||
parts.push("\n");
|
||||
@ -147,7 +158,6 @@ function createChatFileMentionSuggestion() {
|
||||
}) => {
|
||||
// For folders: update the query text to navigate into the folder
|
||||
if (props.type === "folder") {
|
||||
// Replace the current @query with @folderpath/
|
||||
const shortPath = shortenPath(props.path);
|
||||
editor
|
||||
.chain()
|
||||
@ -158,7 +168,12 @@ function createChatFileMentionSuggestion() {
|
||||
return;
|
||||
}
|
||||
|
||||
// For files: insert mention node + trailing space
|
||||
// Determine mention type for objects/entries
|
||||
const mentionType =
|
||||
props.type === "object" ? "object"
|
||||
: props.type === "entry" ? "entry"
|
||||
: "file";
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
@ -169,6 +184,8 @@ function createChatFileMentionSuggestion() {
|
||||
attrs: {
|
||||
label: props.name,
|
||||
path: props.path,
|
||||
mentionType,
|
||||
objectName: props.objectName ?? "",
|
||||
},
|
||||
},
|
||||
{ type: "text", text: " " },
|
||||
|
||||
@ -7,10 +7,16 @@ export const chatFileMentionPluginKey = new PluginKey("chatFileMention");
|
||||
export type FileMentionAttrs = {
|
||||
label: string;
|
||||
path: string;
|
||||
/** Distinguish between file, object, and entry mentions */
|
||||
mentionType?: "file" | "object" | "entry";
|
||||
/** Object name for entry mentions */
|
||||
objectName?: string;
|
||||
};
|
||||
|
||||
/** Resolve mention pill colors from the filename extension. */
|
||||
function mentionColors(label: string): { bg: string; fg: string } {
|
||||
/** Resolve mention pill colors from the mention type or filename extension. */
|
||||
function mentionColors(label: string, mentionType?: string): { bg: string; fg: string } {
|
||||
if (mentionType === "object") {return { bg: "rgba(14,165,233,0.15)", fg: "#0ea5e9" };}
|
||||
if (mentionType === "entry") {return { bg: "rgba(34,197,94,0.15)", fg: "#22c55e" };}
|
||||
const ext = label.split(".").pop()?.toLowerCase() ?? "";
|
||||
if (
|
||||
["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico", "tiff", "heic"].includes(ext)
|
||||
@ -53,6 +59,8 @@ export const FileMentionNode = Node.create({
|
||||
return {
|
||||
label: { default: "" },
|
||||
path: { default: "" },
|
||||
mentionType: { default: "file" },
|
||||
objectName: { default: "" },
|
||||
};
|
||||
},
|
||||
|
||||
@ -62,7 +70,8 @@ export const FileMentionNode = Node.create({
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const label = (HTMLAttributes.label as string) || "file";
|
||||
const colors = mentionColors(label);
|
||||
const mType = HTMLAttributes.mentionType as string | undefined;
|
||||
const colors = mentionColors(label, mType);
|
||||
return [
|
||||
"span",
|
||||
mergeAttributes(
|
||||
|
||||
@ -16,7 +16,10 @@ import { createPortal } from "react-dom";
|
||||
type SuggestItem = {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "folder" | "file" | "document" | "database";
|
||||
type: "folder" | "file" | "document" | "database" | "object" | "entry";
|
||||
icon?: string;
|
||||
objectName?: string;
|
||||
entryId?: string;
|
||||
};
|
||||
|
||||
export type FileMentionListRef = {
|
||||
@ -31,12 +34,15 @@ type FileMentionListProps = {
|
||||
|
||||
// ── File type helpers ──
|
||||
|
||||
function getFileCategory(
|
||||
name: string,
|
||||
type: string,
|
||||
): "folder" | "image" | "video" | "audio" | "pdf" | "code" | "document" | "database" | "other" {
|
||||
type FileCategory =
|
||||
| "folder" | "image" | "video" | "audio" | "pdf" | "code"
|
||||
| "document" | "database" | "object" | "entry" | "other";
|
||||
|
||||
function getFileCategory(name: string, type: string): FileCategory {
|
||||
if (type === "folder") {return "folder";}
|
||||
if (type === "database") {return "database";}
|
||||
if (type === "object") {return "object";}
|
||||
if (type === "entry") {return "entry";}
|
||||
const ext = name.split(".").pop()?.toLowerCase() ?? "";
|
||||
if (
|
||||
["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico", "tiff", "heic"].includes(ext)
|
||||
@ -66,6 +72,8 @@ const categoryColors: Record<string, { bg: string; fg: string }> = {
|
||||
code: { bg: "rgba(59, 130, 246, 0.12)", fg: "#3b82f6" },
|
||||
document: { bg: "rgba(107, 114, 128, 0.12)", fg: "#6b7280" },
|
||||
database: { bg: "rgba(168, 85, 247, 0.12)", fg: "#a855f7" },
|
||||
object: { bg: "rgba(14, 165, 233, 0.12)", fg: "#0ea5e9" },
|
||||
entry: { bg: "rgba(34, 197, 94, 0.12)", fg: "#22c55e" },
|
||||
other: { bg: "rgba(107, 114, 128, 0.08)", fg: "#9ca3af" },
|
||||
};
|
||||
|
||||
@ -134,6 +142,24 @@ function MiniIcon({ category }: { category: string }) {
|
||||
<path d="M3 12A9 3 0 0 0 21 12" />
|
||||
</svg>
|
||||
);
|
||||
case "object":
|
||||
return (
|
||||
<svg {...props}>
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
case "entry":
|
||||
return (
|
||||
<svg {...props}>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg {...props}>
|
||||
@ -218,8 +244,8 @@ const FileMentionList = forwardRef<FileMentionListRef, FileMentionListProps>(
|
||||
className="text-[12px]"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Searching files...
|
||||
</span>
|
||||
Searching...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -240,7 +266,7 @@ const FileMentionList = forwardRef<FileMentionListRef, FileMentionListProps>(
|
||||
className="text-[12px]"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
No files found
|
||||
No results found
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@ -259,64 +285,85 @@ const FileMentionList = forwardRef<FileMentionListRef, FileMentionListProps>(
|
||||
maxHeight: 300,
|
||||
}}
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
const category = getFileCategory(item.name, item.type);
|
||||
const colors = categoryColors[category] ?? categoryColors.other;
|
||||
const short = shortenPath(item.path);
|
||||
{items.map((item, index) => {
|
||||
const category = getFileCategory(item.name, item.type);
|
||||
const colors = categoryColors[category] ?? categoryColors.other;
|
||||
const hasEmoji = item.icon && /\p{Emoji_Presentation}/u.test(item.icon);
|
||||
const isDbItem = item.type === "object" || item.type === "entry";
|
||||
const sublabel = item.type === "entry" && item.objectName
|
||||
? item.objectName
|
||||
: isDbItem
|
||||
? item.type
|
||||
: shortenPath(item.path);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.path}
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left transition-colors"
|
||||
style={{
|
||||
background:
|
||||
index === selectedIndex
|
||||
? "var(--color-surface-hover)"
|
||||
: "transparent",
|
||||
}}
|
||||
onClick={() => selectItem(index)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
return (
|
||||
<button
|
||||
key={item.path}
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left transition-colors"
|
||||
style={{
|
||||
background:
|
||||
index === selectedIndex
|
||||
? "var(--color-surface-hover)"
|
||||
: "transparent",
|
||||
}}
|
||||
onClick={() => selectItem(index)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
<div
|
||||
className="w-6 h-6 rounded-md flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: colors.bg, color: colors.fg }}
|
||||
>
|
||||
<div
|
||||
className="w-6 h-6 rounded-md flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: colors.bg, color: colors.fg }}
|
||||
>
|
||||
{hasEmoji ? (
|
||||
<span className="text-[13px] leading-none">{item.icon}</span>
|
||||
) : (
|
||||
<MiniIcon category={category} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className="text-[12px] font-medium truncate"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{item.name}
|
||||
</div>
|
||||
<div
|
||||
className="text-[10px] truncate"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title={item.path}
|
||||
>
|
||||
{short}
|
||||
</div>
|
||||
</div>
|
||||
{item.type === "folder" && (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="flex-shrink-0 opacity-40"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className="text-[12px] font-medium truncate"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{item.name}
|
||||
</div>
|
||||
<div
|
||||
className="text-[10px] truncate flex items-center gap-1"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
title={isDbItem ? sublabel : item.path}
|
||||
>
|
||||
{isDbItem && (
|
||||
<span
|
||||
className="inline-block rounded px-1 py-px text-[9px] font-medium leading-tight"
|
||||
style={{
|
||||
background: colors.bg,
|
||||
color: colors.fg,
|
||||
}}
|
||||
>
|
||||
{item.type}
|
||||
</span>
|
||||
)}
|
||||
{sublabel}
|
||||
</div>
|
||||
</div>
|
||||
{item.type === "folder" && (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="flex-shrink-0 opacity-40"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@ -15,6 +15,8 @@ type ChatSessionsSidebarProps = {
|
||||
activeSessionId: string | null;
|
||||
/** Title of the currently active session (shown in the header). */
|
||||
activeSessionTitle?: string;
|
||||
/** Session IDs with an actively running agent stream. */
|
||||
streamingSessionIds?: Set<string>;
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
onNewSession: () => void;
|
||||
};
|
||||
@ -75,6 +77,7 @@ export function ChatSessionsSidebar({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
activeSessionTitle: _activeSessionTitle,
|
||||
streamingSessionIds,
|
||||
onSelectSession,
|
||||
onNewSession,
|
||||
}: ChatSessionsSidebarProps) {
|
||||
@ -158,25 +161,34 @@ export function ChatSessionsSidebar({
|
||||
>
|
||||
{group.label}
|
||||
</div>
|
||||
{group.sessions.map((session) => {
|
||||
const isActive = session.id === activeSessionId;
|
||||
const isHovered = session.id === hoveredId;
|
||||
return (
|
||||
<button
|
||||
key={session.id}
|
||||
type="button"
|
||||
onClick={() => handleSelect(session.id)}
|
||||
onMouseEnter={() => setHoveredId(session.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
className="w-full text-left px-2 py-2 rounded-lg transition-colors cursor-pointer"
|
||||
style={{
|
||||
background: isActive
|
||||
? "var(--color-accent-light)"
|
||||
: isHovered
|
||||
? "var(--color-surface-hover)"
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
{group.sessions.map((session) => {
|
||||
const isActive = session.id === activeSessionId;
|
||||
const isHovered = session.id === hoveredId;
|
||||
const isStreamingSession = streamingSessionIds?.has(session.id) ?? false;
|
||||
return (
|
||||
<button
|
||||
key={session.id}
|
||||
type="button"
|
||||
onClick={() => handleSelect(session.id)}
|
||||
onMouseEnter={() => setHoveredId(session.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
className="w-full text-left px-2 py-2 rounded-lg transition-colors cursor-pointer"
|
||||
style={{
|
||||
background: isActive
|
||||
? "var(--color-accent-light)"
|
||||
: isHovered
|
||||
? "var(--color-surface-hover)"
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isStreamingSession && (
|
||||
<span
|
||||
className="inline-block w-1.5 h-1.5 rounded-full flex-shrink-0 animate-pulse"
|
||||
style={{ background: "var(--color-accent)" }}
|
||||
title="Agent is running"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="text-xs font-medium truncate"
|
||||
style={{
|
||||
@ -187,23 +199,32 @@ export function ChatSessionsSidebar({
|
||||
>
|
||||
{session.title || "Untitled chat"}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5" style={{ paddingLeft: isStreamingSession ? "calc(0.375rem + 6px)" : undefined }}>
|
||||
{isStreamingSession && (
|
||||
<span
|
||||
className="text-[10px] font-medium"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
>
|
||||
Streaming
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="text-[10px]"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{timeAgo(session.updatedAt)}
|
||||
</span>
|
||||
{session.messageCount > 0 && (
|
||||
<span
|
||||
className="text-[10px]"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{timeAgo(session.updatedAt)}
|
||||
{session.messageCount} msg{session.messageCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
{session.messageCount > 0 && (
|
||||
<span
|
||||
className="text-[10px]"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{session.messageCount} msg{session.messageCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@ -52,6 +52,8 @@ type FileManagerTreeProps = {
|
||||
browseDir?: string | null;
|
||||
/** Absolute path of the workspace root. Nodes matching this path are rendered as a special non-collapsible workspace entry point. */
|
||||
workspaceRoot?: string | null;
|
||||
/** Called when a node is dragged and dropped outside the tree onto an external drop target (e.g. chat input). */
|
||||
onExternalDrop?: (node: TreeNode) => void;
|
||||
};
|
||||
|
||||
// --- System file detection (client-side mirror) ---
|
||||
@ -700,13 +702,42 @@ function flattenVisible(tree: TreeNode[], expanded: Set<string>): TreeNode[] {
|
||||
|
||||
// --- Main Exported Component ---
|
||||
|
||||
export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact, parentDir, onNavigateUp, browseDir: _browseDir, workspaceRoot }: FileManagerTreeProps) {
|
||||
export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact, parentDir, onNavigateUp, browseDir: _browseDir, workspaceRoot, onExternalDrop }: FileManagerTreeProps) {
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set());
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [renamingPath, setRenamingPath] = useState<string | null>(null);
|
||||
const [dragOverPath, setDragOverPath] = useState<string | null>(null);
|
||||
const [activeNode, setActiveNode] = useState<TreeNode | null>(null);
|
||||
|
||||
// Track pointer position during @dnd-kit drags for cross-component drops.
|
||||
// Capture-phase listener on window works even when @dnd-kit has pointer capture.
|
||||
const pointerPosRef = useRef({ x: 0, y: 0 });
|
||||
useEffect(() => {
|
||||
if (!activeNode) {return;}
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
pointerPosRef.current = { x: e.clientX, y: e.clientY };
|
||||
|
||||
// Toggle visual drop indicator on external chat drop target
|
||||
const el = document.elementFromPoint(e.clientX, e.clientY);
|
||||
const target = el?.closest("[data-chat-drop-target]") as HTMLElement | null;
|
||||
const prev = document.querySelector("[data-drag-hover]");
|
||||
if (target && !target.hasAttribute("data-drag-hover")) {
|
||||
target.setAttribute("data-drag-hover", "");
|
||||
}
|
||||
if (prev && prev !== target) {
|
||||
prev.removeAttribute("data-drag-hover");
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", onPointerMove, true);
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", onPointerMove, true);
|
||||
// Clean up any lingering highlight
|
||||
document.querySelector("[data-drag-hover]")?.removeAttribute("data-drag-hover");
|
||||
};
|
||||
}, [activeNode]);
|
||||
|
||||
// Context menu state
|
||||
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; target: ContextMenuTarget } | null>(null);
|
||||
|
||||
@ -799,7 +830,17 @@ export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact
|
||||
return;
|
||||
}
|
||||
|
||||
if (!overData?.node) {return;}
|
||||
// No @dnd-kit droppable: check for external drop targets (e.g. chat input)
|
||||
if (!overData?.node) {
|
||||
if (onExternalDrop) {
|
||||
const { x, y } = pointerPosRef.current;
|
||||
const el = document.elementFromPoint(x, y);
|
||||
if (el?.closest("[data-chat-drop-target]")) {
|
||||
onExternalDrop(source);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const target = overData.node;
|
||||
|
||||
@ -817,12 +858,13 @@ export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact
|
||||
onRefresh();
|
||||
}
|
||||
},
|
||||
[onRefresh],
|
||||
[onRefresh, onExternalDrop],
|
||||
);
|
||||
|
||||
const handleDragCancel = useCallback(() => {
|
||||
setActiveNode(null);
|
||||
setDragOverPath(null);
|
||||
document.querySelector("[data-drag-hover]")?.removeAttribute("data-drag-hover");
|
||||
}, []);
|
||||
|
||||
// Context menu handlers
|
||||
|
||||
@ -31,6 +31,8 @@ type WorkspaceSidebarProps = {
|
||||
workspaceRoot?: string | null;
|
||||
/** Navigate to the main chat / home panel. */
|
||||
onGoToChat?: () => void;
|
||||
/** Called when a tree node is dragged and dropped onto an external target (e.g. chat input). */
|
||||
onExternalDrop?: (node: TreeNode) => void;
|
||||
};
|
||||
|
||||
function WorkspaceLogo() {
|
||||
@ -404,6 +406,7 @@ export function WorkspaceSidebar({
|
||||
onFileSearchSelect,
|
||||
workspaceRoot,
|
||||
onGoToChat,
|
||||
onExternalDrop,
|
||||
}: WorkspaceSidebarProps) {
|
||||
const isBrowsing = browseDir != null;
|
||||
|
||||
@ -521,16 +524,17 @@ export function WorkspaceSidebar({
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<FileManagerTree
|
||||
tree={tree}
|
||||
activePath={activePath}
|
||||
onSelect={onSelect}
|
||||
onRefresh={onRefresh}
|
||||
parentDir={parentDir}
|
||||
onNavigateUp={onNavigateUp}
|
||||
browseDir={browseDir}
|
||||
workspaceRoot={workspaceRoot}
|
||||
/>
|
||||
<FileManagerTree
|
||||
tree={tree}
|
||||
activePath={activePath}
|
||||
onSelect={onSelect}
|
||||
onRefresh={onRefresh}
|
||||
parentDir={parentDir}
|
||||
onNavigateUp={onNavigateUp}
|
||||
browseDir={browseDir}
|
||||
workspaceRoot={workspaceRoot}
|
||||
onExternalDrop={onExternalDrop}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@ -237,6 +237,7 @@ function WorkspacePageInner() {
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||
const [sessions, setSessions] = useState<WebSession[]>([]);
|
||||
const [sidebarRefreshKey, setSidebarRefreshKey] = useState(0);
|
||||
const [streamingSessionIds, setStreamingSessionIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Cron jobs state
|
||||
const [cronJobs, setCronJobs] = useState<CronJob[]>([]);
|
||||
@ -304,6 +305,29 @@ function WorkspacePageInner() {
|
||||
setSidebarRefreshKey((k) => k + 1);
|
||||
}, []);
|
||||
|
||||
// Poll for active (streaming) agent runs so the sidebar can show indicators.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/chat/active");
|
||||
if (cancelled) {return;}
|
||||
const data = await res.json();
|
||||
const ids: string[] = data.sessionIds ?? [];
|
||||
setStreamingSessionIds((prev) => {
|
||||
// Only update state if the set actually changed (avoid re-renders).
|
||||
if (prev.size === ids.length && ids.every((id) => prev.has(id))) {return prev;}
|
||||
return new Set(ids);
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
const id = setInterval(poll, 3_000);
|
||||
return () => { cancelled = true; clearInterval(id); };
|
||||
}, []);
|
||||
|
||||
// Fetch cron jobs for sidebar
|
||||
const fetchCronJobs = useCallback(async () => {
|
||||
try {
|
||||
@ -541,6 +565,11 @@ function WorkspacePageInner() {
|
||||
router.replace("/workspace", { scroll: false });
|
||||
}, [router]);
|
||||
|
||||
// Insert a file mention into the chat editor when a sidebar item is dropped on the chat input
|
||||
const handleSidebarExternalDrop = useCallback((node: TreeNode) => {
|
||||
chatRef.current?.insertFileMention?.(node.name, node.path);
|
||||
}, []);
|
||||
|
||||
// Handle file search selection: navigate sidebar to the file's location and open it
|
||||
const handleFileSearchSelect = useCallback(
|
||||
(item: { name: string; path: string; type: string }) => {
|
||||
@ -819,6 +848,7 @@ function WorkspacePageInner() {
|
||||
onFileSearchSelect={handleFileSearchSelect}
|
||||
workspaceRoot={workspaceRoot}
|
||||
onGoToChat={handleGoToChat}
|
||||
onExternalDrop={handleSidebarExternalDrop}
|
||||
/>
|
||||
|
||||
{/* Main content */}
|
||||
@ -880,6 +910,7 @@ function WorkspacePageInner() {
|
||||
<ChatPanel
|
||||
ref={chatRef}
|
||||
sessionTitle={activeSessionTitle}
|
||||
initialSessionId={activeSessionId ?? undefined}
|
||||
onActiveSessionChange={(id) => {
|
||||
setActiveSessionId(id);
|
||||
}}
|
||||
@ -890,6 +921,7 @@ function WorkspacePageInner() {
|
||||
sessions={sessions}
|
||||
activeSessionId={activeSessionId}
|
||||
activeSessionTitle={activeSessionTitle}
|
||||
streamingSessionIds={streamingSessionIds}
|
||||
onSelectSession={(sessionId) => {
|
||||
setActiveSessionId(sessionId);
|
||||
void chatRef.current?.loadSession(sessionId);
|
||||
|
||||
@ -104,6 +104,17 @@ export function hasActiveRun(sessionId: string): boolean {
|
||||
return run !== undefined && run.status === "running";
|
||||
}
|
||||
|
||||
/** Return the session IDs of all currently running agent runs. */
|
||||
export function getRunningSessionIds(): string[] {
|
||||
const ids: string[] = [];
|
||||
for (const [sessionId, run] of activeRuns) {
|
||||
if (run.status === "running") {
|
||||
ids.push(sessionId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to an active run's SSE events.
|
||||
*
|
||||
@ -148,6 +159,19 @@ export function abortRun(sessionId: string): boolean {
|
||||
if (!run || run.status !== "running") {return false;}
|
||||
run.abortController.abort();
|
||||
run.childProcess.kill("SIGTERM");
|
||||
|
||||
// Fallback: if the child doesn't exit within 5 seconds after
|
||||
// SIGTERM (e.g. the CLI's best-effort chat.abort RPC hangs),
|
||||
// send SIGKILL to force-terminate.
|
||||
const killTimer = setTimeout(() => {
|
||||
try {
|
||||
if (run.status === "running") {
|
||||
run.childProcess.kill("SIGKILL");
|
||||
}
|
||||
} catch { /* already dead */ }
|
||||
}, 5_000);
|
||||
run.childProcess.once("close", () => clearTimeout(killTimer));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
BIN
apps/web/public/fonts/Bookerly-Bold.ttf
Normal file
BIN
apps/web/public/fonts/Bookerly-Bold.ttf
Normal file
Binary file not shown.
BIN
apps/web/public/fonts/Bookerly-BoldItalic.ttf
Normal file
BIN
apps/web/public/fonts/Bookerly-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
apps/web/public/fonts/Bookerly-Regular.ttf
Normal file
BIN
apps/web/public/fonts/Bookerly-Regular.ttf
Normal file
Binary file not shown.
BIN
apps/web/public/fonts/Bookerly-RegularItalic.ttf
Normal file
BIN
apps/web/public/fonts/Bookerly-RegularItalic.ttf
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
@ -328,7 +328,12 @@ export async function callGateway<T = Record<string, unknown>>(
|
||||
const doAbort = async () => {
|
||||
if (opts.onAbort) {
|
||||
try {
|
||||
await opts.onAbort(client);
|
||||
// Cap the best-effort abort RPC at 3 seconds so a slow
|
||||
// or broken gateway can't hang the process indefinitely.
|
||||
await Promise.race([
|
||||
opts.onAbort(client),
|
||||
new Promise<void>((r) => setTimeout(r, 3_000)),
|
||||
]);
|
||||
} catch {
|
||||
// best-effort; swallow errors
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user