🚀 RELEASE: chat start stop

This commit is contained in:
kumarabhirup 2026-02-15 23:42:13 -08:00
parent 170231a54f
commit 4d2fb1e2a0
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
17 changed files with 621 additions and 148 deletions

View 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() });
}

View File

@ -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] });
}

View File

@ -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>
);
},

View File

@ -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: " " },

View File

@ -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(

View File

@ -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>
);
},

View File

@ -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>

View File

@ -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

View File

@ -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>

View File

@ -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);

View File

@ -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;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@ -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
}