From 00fe460ea5b0adc183d066a805c68325c9f3a0e0 Mon Sep 17 00:00:00 2001 From: janole Date: Sat, 23 Aug 2025 15:37:25 +0200 Subject: [PATCH 001/110] feat: add ApiClient for documents and links APIs with SSE support --- src/ai/session/chat-server-storage.ts | 297 ++++++++++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 src/ai/session/chat-server-storage.ts diff --git a/src/ai/session/chat-server-storage.ts b/src/ai/session/chat-server-storage.ts new file mode 100644 index 0000000..4eb5dd5 --- /dev/null +++ b/src/ai/session/chat-server-storage.ts @@ -0,0 +1,297 @@ +// A simple, configurable client library for the documents and links APIs. + +export interface Document +{ + id: string; // Internal UUID + external_id: string | null; + user_id: string; + schema_id: string | null; + data: Record; + created_at: string; + updated_at: string; +} + +export interface Link +{ + id: string; + user_id: string; + from_id: string; + to_id: string; + type: string; + created_at: string; +} + +// Type for creating a new document, with optional external_id and links +export interface NewDocument +{ + data: Record; + external_id?: string; + schema_id?: string | null; + links?: Array<{ to_id: string; type?: string } | { from_id: string; type?: string }>; +} + +export type NewLink = { + from_id: string; + to_id: string; + type?: string; +}; + +export interface ApiClientOptions +{ + baseUrl?: string; + token?: string; +} + +export type SSESubscription = { + close: () => void; +}; + +class ApiClient +{ + private baseUrl: string; + private token?: string; + + constructor(options: ApiClientOptions = {}) + { + this.baseUrl = options.baseUrl || ""; + this.token = options.token; + } + + private async _request(path: string, options: RequestInit): Promise + { + const url = new URL(path, this.baseUrl).href; + const headers = new Headers(options.headers || {}); + headers.set("Content-Type", "application/json"); + + if (this.token) + { + headers.set("Authorization", `Bearer ${this.token}`); + } + + const response = await fetch(url, { ...options, headers }); + + if (!response.ok) + { + const errorBody = await response.json().catch(() => ({ error: `Request failed with status ${response.status}` })); + throw new Error(errorBody.error || "An unknown error occurred"); + } + + const text = await response.text(); + return text ? JSON.parse(text) as T : {} as T; + } + + // --- Documents API --- + + listDocuments(): Promise + { + return this._request("api/documents", { method: "GET" }); + } + + // --- Document access by internal UUID --- + + getDocumentById(id: string): Promise + { + return this._request(`api/documents?id=${id}`, { method: "GET" }); + } + + updateDocumentById(id: string, updates: { data?: Record, schema_id?: string | null }): Promise + { + return this._request("api/documents", { + method: "PUT", + body: JSON.stringify({ id, ...updates }), + }); + } + + deleteDocumentById(id: string): Promise + { + return this._request(`api/documents?id=${id}`, { method: "DELETE" }); + } + + // --- Document access by external_id --- + + getDocumentByExternalId(externalId: string): Promise + { + return this._request(`api/documents/external/${externalId}`, { method: "GET" }); + } + + /** + * Creates a document if it doesn't exist, or updates it if it does. + * @param externalId The external ID of the document to upsert. + * @param documentData The data for the document. + */ + upsertDocument(externalId: string, documentData: { data: Record, schema_id?: string | null }): Promise + { + return this._request(`api/documents/external/${externalId}`, { + method: "PUT", + body: JSON.stringify(documentData), + }); + } + + deleteDocumentByExternalId(externalId: string): Promise + { + return this._request(`api/documents/external/${externalId}`, { method: "DELETE" }); + } + + // --- Document Creation --- + + createDocument(doc: NewDocument): Promise + { + return this._request("api/documents", { + method: "POST", + body: JSON.stringify(doc), + }); + } + + // --- Links API --- + + createLinks(links: NewLink[]): Promise + { + return this._request("api/links", { + method: "POST", + body: JSON.stringify(links), + }); + } + + deleteLinkById(id: string): Promise + { + return this._request(`api/links?id=${id}`, { method: "DELETE" }); + } + + deleteLinkByFromTo(params: { from_id: string; to_id: string; type?: string }): Promise + { + const searchParams = new URLSearchParams({ from_id: params.from_id, to_id: params.to_id }); + if (params.type) + { + searchParams.set("type", params.type); + } + return this._request(`api/links?${searchParams.toString()}`, { method: "DELETE" }); + } + + // --- Realtime (SSE) consumer for documents --- + /** + * Subscribes to server-sent events for document changes. + * The server SSE endpoint is expected at `api/documents/realtime`. + * + * Usage: + * const sub = await apiClient.subscribeDocuments((payload) => { ... }); + * // later: sub.close(); + * + * Notes: + * - In browsers EventSource does not support custom headers; the token will be passed + * via the `token` query parameter when running in the browser. + * - In Node, this will try to dynamically import the `eventsource` package and set + * the Authorization header. + */ + async subscribeDocuments( + onPayload: (payload: any) => void, + opts?: { onOpen?: () => void; onError?: (err: any) => void; eventName?: string }, + ): Promise + { + const path = "api/documents/realtime"; + const urlObj = new URL(path, this.baseUrl); + + let es: any = null; + const eventName = opts?.eventName ?? "document"; + + // Browser: use native EventSource. Native EventSource cannot set headers, so pass token in query. + if (typeof window !== "undefined" && (window as any).EventSource) + { + if (this.token) + { + urlObj.searchParams.set("token", this.token); + } + es = new (window as any).EventSource(urlObj.href); + } + else + { + // Node environment: try to dynamically load `eventsource` package (or `eventsource` polyfill) + try + { + // prefer dynamic import + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const mod = await import("eventsource"); + const EventSourceImpl = mod.default ?? mod; + + const headers: Record = {}; + if (this.token) + { + headers["authorization"] = `Bearer ${this.token}`; + } + + es = new EventSourceImpl(urlObj.href, { headers }); + } + catch + { + throw new Error("EventSource is not available in this environment. Install the `eventsource` package in Node to use SSE."); + } + } + + const onMessage = (evt: any) => + { + try + { + console.log("EVT", evt); + // evt.data may contain comments like ': ping' which should be ignored + if (!evt?.data) { return; } + // Some SSE libraries deliver comments as data starting with ':'. Ignore those. + if (typeof evt.data === "string" && evt.data.trim().startsWith(":")) { return; } + + // The server uses named events like `document`; we listen to the specific event below. + // For `message` events, parse JSON if possible and forward. + let parsed = null; + try + { + parsed = JSON.parse(evt.data); + } + catch + { + parsed = evt.data; + } + onPayload(parsed); + } + catch (err) + { + // swallow user handler errors + console.error("Error in SSE onMessage handler:", err); + } + }; + + const onError = (err: any) => + { + if (opts?.onError) { opts.onError(err); } + }; + + const onOpen = () => + { + if (opts?.onOpen) { opts.onOpen(); } + }; + + // Attach listeners + es.addEventListener("message", onMessage); + es.addEventListener(eventName, onMessage); + es.addEventListener("open", onOpen); + es.addEventListener("error", onError); + + return { + close: () => + { + try + { + es.removeEventListener("message", onMessage); + es.removeEventListener(eventName, onMessage); + es.removeEventListener("open", onOpen); + es.removeEventListener("error", onError); + } + catch + { + // ignore + } + try { es.close(); } + catch { /* ignore */ } + }, + }; + } +} + +export { ApiClient }; From 5dc6d76841ef8d4af265e648f7916bd74c58ef7c Mon Sep 17 00:00:00 2001 From: janole Date: Sat, 23 Aug 2025 15:39:57 +0200 Subject: [PATCH 002/110] refactor: move chat-server-storage.ts to chat-server subdirectory to improve project structure --- src/ai/session/{ => chat-server}/chat-server-storage.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/ai/session/{ => chat-server}/chat-server-storage.ts (100%) diff --git a/src/ai/session/chat-server-storage.ts b/src/ai/session/chat-server/chat-server-storage.ts similarity index 100% rename from src/ai/session/chat-server-storage.ts rename to src/ai/session/chat-server/chat-server-storage.ts From 98d5de69d39098aa10c3865d1438ec56cb677a1f Mon Sep 17 00:00:00 2001 From: janole Date: Sat, 23 Aug 2025 15:40:38 +0200 Subject: [PATCH 003/110] refactor: rename chat-server-storage.ts to api-client.ts for clarity --- .../session/chat-server/{chat-server-storage.ts => api-client.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/ai/session/chat-server/{chat-server-storage.ts => api-client.ts} (100%) diff --git a/src/ai/session/chat-server/chat-server-storage.ts b/src/ai/session/chat-server/api-client.ts similarity index 100% rename from src/ai/session/chat-server/chat-server-storage.ts rename to src/ai/session/chat-server/api-client.ts From f85d0376c6733ac01373db775d30c7e9d0f2f142 Mon Sep 17 00:00:00 2001 From: janole Date: Sat, 23 Aug 2025 15:45:34 +0200 Subject: [PATCH 004/110] refactor: remove unnecessary eslint disable comment for dynamic import in api-client.ts --- src/ai/session/chat-server/api-client.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/ai/session/chat-server/api-client.ts b/src/ai/session/chat-server/api-client.ts index 4eb5dd5..73667db 100644 --- a/src/ai/session/chat-server/api-client.ts +++ b/src/ai/session/chat-server/api-client.ts @@ -207,8 +207,7 @@ class ApiClient // Node environment: try to dynamically load `eventsource` package (or `eventsource` polyfill) try { - // prefer dynamic import - // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // prefer dynamic import // @ts-ignore const mod = await import("eventsource"); const EventSourceImpl = mod.default ?? mod; From ef70e8c879bdb5405858095e344b64e9644d8bd4 Mon Sep 17 00:00:00 2001 From: janole Date: Sat, 23 Aug 2025 15:45:42 +0200 Subject: [PATCH 005/110] feat: sync session data to chat server on save --- src/ai/session/file-session-storage.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/ai/session/file-session-storage.ts b/src/ai/session/file-session-storage.ts index ab9b63e..6dc6d24 100644 --- a/src/ai/session/file-session-storage.ts +++ b/src/ai/session/file-session-storage.ts @@ -3,6 +3,7 @@ import { homedir } from "os"; import { join } from "path"; import writeFileAtomic from "write-file-atomic"; +import { chatServerClient } from "./chat-server/chat-server-client.js"; import { ChatSession, IChatSession, ICreateChatSession, ISessionStorage, mapSessionDataToSession, mapSessionToSessionData } from "./session.js"; export class FileSessionStorage implements ISessionStorage @@ -42,6 +43,11 @@ export class FileSessionStorage implements ISessionStorage const sessionData = mapSessionToSessionData(session); await writeFileAtomic(filePath, JSON.stringify(sessionData, null, 2), "utf-8"); + + if (chatServerClient) + { + await chatServerClient.upsertDocument(session.id, { data: session }); + } } private getSessionFilePath(sessionId: string): string From 83a2d37be15553c7b4e30a62994f8ee805d86dfc Mon Sep 17 00:00:00 2001 From: janole Date: Sat, 23 Aug 2025 15:45:55 +0200 Subject: [PATCH 006/110] feat: add chat server client for API communication with environment variable configuration --- .../session/chat-server/chat-server-client.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 src/ai/session/chat-server/chat-server-client.ts diff --git a/src/ai/session/chat-server/chat-server-client.ts b/src/ai/session/chat-server/chat-server-client.ts new file mode 100644 index 0000000..34be696 --- /dev/null +++ b/src/ai/session/chat-server/chat-server-client.ts @@ -0,0 +1,19 @@ +import { ApiClient } from "./api-client.js"; + +const URL = process.env["CHAT_SERVER_URL"]; +const PAT = process.env["CHAT_SERVER_PAT"]; + +const chatServerClient = URL && PAT ? new ApiClient({ baseUrl: URL, token: PAT }) : undefined; + +// chatServerClient?.subscribeDocuments(payload => +// { +// console.log("P", payload); +// }, { +// onOpen: () => console.log("OPEN"), +// onError: (err) => console.error("ERR", err), +// }); + +export +{ + chatServerClient, +}; From e0ca89dde05d45de969d741e483e40e77fdb81a4 Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 26 Aug 2025 11:57:29 +0200 Subject: [PATCH 007/110] fix: update environment variables and enable subscription logging in chat server client --- .../session/chat-server/chat-server-client.ts | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/ai/session/chat-server/chat-server-client.ts b/src/ai/session/chat-server/chat-server-client.ts index 34be696..457d5b5 100644 --- a/src/ai/session/chat-server/chat-server-client.ts +++ b/src/ai/session/chat-server/chat-server-client.ts @@ -1,19 +1,20 @@ import { ApiClient } from "./api-client.js"; -const URL = process.env["CHAT_SERVER_URL"]; -const PAT = process.env["CHAT_SERVER_PAT"]; +const URL = process.env["CODE_BANDIT_SERVER_URL"]; +const PAT = process.env["CODE_BANDIT_SERVER_PAT"]; const chatServerClient = URL && PAT ? new ApiClient({ baseUrl: URL, token: PAT }) : undefined; -// chatServerClient?.subscribeDocuments(payload => -// { -// console.log("P", payload); -// }, { -// onOpen: () => console.log("OPEN"), -// onError: (err) => console.error("ERR", err), -// }); +chatServerClient?.subscribeDocuments(payload => +{ + console.log("P", payload); +}, { + onOpen: () => console.log("OPEN"), + onError: (err) => console.error("ERR", err), +}); export { - chatServerClient, + chatServerClient }; + From 4b4040b03cf9dca87a2a713ea7d1aa0e63808af3 Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 26 Aug 2025 18:50:16 +0200 Subject: [PATCH 008/110] feat: add methods to list documents linking to a target and to list children documents; remove debug log from SSE event handler --- src/ai/session/chat-server/api-client.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/ai/session/chat-server/api-client.ts b/src/ai/session/chat-server/api-client.ts index 73667db..ad389bf 100644 --- a/src/ai/session/chat-server/api-client.ts +++ b/src/ai/session/chat-server/api-client.ts @@ -87,6 +87,27 @@ class ApiClient return this._request("api/documents", { method: "GET" }); } + /** + * List documents that directly link to the given parent/target document. + * i.e., rows in `links` where links.to_id = targetDocumentId. + * Optionally filter by link `type`. + * + * Note: The server route `GET /api/documents` must support the + * `linked_to` (and optional `type`) query parameters. + */ + listDocumentsLinkingTo(targetDocumentId: string, opts?: { type?: string }): Promise + { + const searchParams = new URLSearchParams({ linked_to: targetDocumentId }); + if (opts?.type) { searchParams.set("type", opts.type); } + return this._request(`api/documents?${searchParams.toString()}`, { method: "GET" }); + } + + /** Convenience alias for readability when treating the target as a parent node. */ + listChildrenOf(parentDocumentId: string, opts?: { type?: string }): Promise + { + return this.listDocumentsLinkingTo(parentDocumentId, opts); + } + // --- Document access by internal UUID --- getDocumentById(id: string): Promise @@ -230,7 +251,6 @@ class ApiClient { try { - console.log("EVT", evt); // evt.data may contain comments like ': ping' which should be ignored if (!evt?.data) { return; } // Some SSE libraries deliver comments as data starting with ':'. Ignore those. From 5f3f8ece666d8c0b42111c9b3974b0950f8c0fa4 Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 26 Aug 2025 18:50:29 +0200 Subject: [PATCH 009/110] feat: add command listener management to chat server client for handling document insert events --- .../session/chat-server/chat-server-client.ts | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src/ai/session/chat-server/chat-server-client.ts b/src/ai/session/chat-server/chat-server-client.ts index 457d5b5..93ad6d3 100644 --- a/src/ai/session/chat-server/chat-server-client.ts +++ b/src/ai/session/chat-server/chat-server-client.ts @@ -5,9 +5,39 @@ const PAT = process.env["CODE_BANDIT_SERVER_PAT"]; const chatServerClient = URL && PAT ? new ApiClient({ baseUrl: URL, token: PAT }) : undefined; +type TCommandListenerFunc = (payload: any) => void; + +const commandListeners: TCommandListenerFunc[] = []; + +const addCommandListener = (f: TCommandListenerFunc) => +{ + if (!commandListeners.includes(f)) + { + commandListeners.push(f); + } +}; + +const removeCommandListener = (f: TCommandListenerFunc) => +{ + const i = commandListeners.indexOf(f); + + if (i !== -1) + { + commandListeners.splice(i, 1); + } +}; + chatServerClient?.subscribeDocuments(payload => { console.log("P", payload); + + for (const f of commandListeners) + { + if (payload.table === "documents" && payload.eventType === "INSERT" && payload.new?.data?.external_id) + { + f(payload.new.data); + } + } }, { onOpen: () => console.log("OPEN"), onError: (err) => console.error("ERR", err), @@ -15,6 +45,8 @@ chatServerClient?.subscribeDocuments(payload => export { - chatServerClient + addCommandListener, + chatServerClient, + removeCommandListener }; From 3e8f6a393eb34b431a5e98355f0d7857149c7a13 Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 26 Aug 2025 18:50:47 +0200 Subject: [PATCH 010/110] feat: add command listener to process external chat commands and send messages automatically --- src/ui/chat-controller.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/ui/chat-controller.ts b/src/ui/chat-controller.ts index 3374364..defe32b 100644 --- a/src/ui/chat-controller.ts +++ b/src/ui/chat-controller.ts @@ -2,8 +2,10 @@ import clipboard from "clipboardy"; import { Key, useApp } from "ink"; import { useCallback, useEffect, useState } from "react"; +import { HumanMessage } from "@langchain/core/messages"; import { ChatService } from "../ai/chat-service.js"; import { ErrorMessage, TMessage, ToolProgressMessage } from "../ai/custom-messages.js"; +import { addCommandListener, removeCommandListener } from "../ai/session/chat-server/chat-server-client.js"; import { ChatSession } from "../ai/session/session.js"; import { countTokens, lastMessageFromAI, TTokenUsage } from "../ai/tokens.js"; import { getGitBranch } from "../ai/tools/git-tools.js"; @@ -208,6 +210,32 @@ export function useChatController(props: UseChatControllerProps) session, ]); + useEffect(() => + { + const commandListener = (command: any) => + { + console.log("COMMAND", command, session.id); + + if (command.external_id === session.id) + { + const message = command.message?.trim(); + + if (message && !working) + { + const messages = [...chatHistory.messages, new HumanMessage(message)]; + handleSendHistory(messages, messages.length); + } + } + }; + + addCommandListener(commandListener); + + return () => { removeCommandListener(commandListener); }; + }, [ + session, + working, + ]); + // TODO: refactor const action = ctrlC ? working From 000a95faf1329481c83ee0ea4f249514b60fe010 Mon Sep 17 00:00:00 2001 From: janole Date: Wed, 27 Aug 2025 17:55:32 +0200 Subject: [PATCH 011/110] feat: add Supabase realtime client with presence and document insert event handling in ApiClient --- src/ai/session/chat-server/api-client.ts | 127 ++++++++++++++++++++++- 1 file changed, 126 insertions(+), 1 deletion(-) diff --git a/src/ai/session/chat-server/api-client.ts b/src/ai/session/chat-server/api-client.ts index ad389bf..b6b1a7b 100644 --- a/src/ai/session/chat-server/api-client.ts +++ b/src/ai/session/chat-server/api-client.ts @@ -1,5 +1,16 @@ // A simple, configurable client library for the documents and links APIs. +import { createClient, RealtimeChannel, SupabaseClient } from "@supabase/supabase-js"; + +export interface AuthData +{ + user_id: string; + access_token: string; + api_url: string; + api_key: string; + channel: string; +} + export interface Document { id: string; // Internal UUID @@ -46,11 +57,18 @@ export type SSESubscription = { close: () => void; }; +type TCommandListenerFunc = (payload: any) => void; + class ApiClient { private baseUrl: string; private token?: string; + private supabase?: SupabaseClient; + private channel?: RealtimeChannel; + + private commandListeners: TCommandListenerFunc[] = []; + constructor(options: ApiClientOptions = {}) { this.baseUrl = options.baseUrl || ""; @@ -80,6 +98,112 @@ class ApiClient return text ? JSON.parse(text) as T : {} as T; } + // --- ---- + addCommandListener(f: TCommandListenerFunc) + { + if (!this.commandListeners.includes(f)) + { + this.commandListeners.push(f); + } + }; + + removeCommandListener(f: TCommandListenerFunc) + { + const i = this.commandListeners.indexOf(f); + + if (i !== -1) + { + this.commandListeners.splice(i, 1); + } + }; + + private pushCommand(payload: any) + { + console.log("PAYLOAD", payload); + + for (const f of this.commandListeners) + { + if (payload.table === "documents" && payload.eventType === "INSERT" && payload.new?.data?.external_id) + { + f(payload.new.data); + } + } + } + + // --- Auth API --- + + getAuthData(): Promise + { + return this._request("api/auth/access-token", { method: "POST" }); + } + + // --- Realtime API --- + private async ensureSupabaseClient() + { + if (this.supabase) + { + return this.supabase; + } + + const authData = await this.getAuthData(); + + console.log(authData); + + const supabase = createClient(authData.api_url, authData.api_key, { + accessToken: async () => + { + console.log("GET ACCESS-TOKEN"); + return authData.access_token; + }, + }); + + this.supabase = supabase; + + this.channel = await supabase.channel(authData.channel) + .on( + "postgres_changes", + { + event: "*", + schema: "public", + table: "documents", + filter: `user_id=eq.${authData.user_id}`, + }, + (payload: any) => this.pushCommand(payload), + ) + .on("presence", { event: "sync" }, () => this.pushCommand({ presenceState: this.channel?.presenceState() })) + .on("presence", { event: "join" }, (args: any) => this.pushCommand({ args, presenceState: this.channel?.presenceState() })) + .on("presence", { event: "leave" }, (args: any) => this.pushCommand({ args, presenceState: this.channel?.presenceState() })); + + this.channel.subscribe(async (status: any) => + { + if (status !== "SUBSCRIBED") + { + return; + } + + const presenceTrackStatus = await this.channel?.track({ userId: authData.user_id, type: "super-client" }); + console.log(presenceTrackStatus); + }); + + // await this.channel.track(currentStatus); + + return this.supabase; + } + + async start() + { + await this.ensureSupabaseClient(); + } + + async setStatus(status: string) + { + await this.ensureSupabaseClient(); + + this.channel?.track({ + status, + }); + } + // --- Documents API --- listDocuments(): Promise @@ -228,7 +352,8 @@ class ApiClient // Node environment: try to dynamically load `eventsource` package (or `eventsource` polyfill) try { - // prefer dynamic import + // prefer dynamic import + // @ts-ignore const mod = await import("eventsource"); const EventSourceImpl = mod.default ?? mod; From 0e1bbd466b5d02d89328047fb7c4abb2fa63177c Mon Sep 17 00:00:00 2001 From: janole Date: Wed, 27 Aug 2025 17:55:48 +0200 Subject: [PATCH 012/110] refactor: add ClientStatus interface and update setStatus method; remove deprecated SSE document subscription code and comments from ApiClient --- src/ai/session/chat-server/api-client.ts | 154 +++-------------------- 1 file changed, 19 insertions(+), 135 deletions(-) diff --git a/src/ai/session/chat-server/api-client.ts b/src/ai/session/chat-server/api-client.ts index b6b1a7b..15f56f3 100644 --- a/src/ai/session/chat-server/api-client.ts +++ b/src/ai/session/chat-server/api-client.ts @@ -11,6 +11,14 @@ export interface AuthData channel: string; } +export interface ClientStatus +{ + type: "consumer-client" | "super-client"; + user_id: string; + external_id: string; + status: "idle" | "working"; +} + export interface Document { id: string; // Internal UUID @@ -138,6 +146,7 @@ class ApiClient } // --- Realtime API --- + private async ensureSupabaseClient() { if (this.supabase) @@ -176,13 +185,15 @@ class ApiClient this.channel.subscribe(async (status: any) => { - if (status !== "SUBSCRIBED") - { - return; - } + console.log("SUBSCRIBE", status); + + // if (status !== "SUBSCRIBED") + // { + // return; + // } - const presenceTrackStatus = await this.channel?.track({ userId: authData.user_id, type: "super-client" }); - console.log(presenceTrackStatus); + // const presenceTrackStatus = await this.channel?.track({ userId: authData.user_id, type: "super-client" }); + // console.log(presenceTrackStatus); }); // await this.channel.track(currentStatus); @@ -195,13 +206,11 @@ class ApiClient await this.ensureSupabaseClient(); } - async setStatus(status: string) + async setStatus(status: ClientStatus) { await this.ensureSupabaseClient(); - this.channel?.track({ - status, - }); + await this.channel?.track(status); } // --- Documents API --- @@ -311,131 +320,6 @@ class ApiClient } return this._request(`api/links?${searchParams.toString()}`, { method: "DELETE" }); } - - // --- Realtime (SSE) consumer for documents --- - /** - * Subscribes to server-sent events for document changes. - * The server SSE endpoint is expected at `api/documents/realtime`. - * - * Usage: - * const sub = await apiClient.subscribeDocuments((payload) => { ... }); - * // later: sub.close(); - * - * Notes: - * - In browsers EventSource does not support custom headers; the token will be passed - * via the `token` query parameter when running in the browser. - * - In Node, this will try to dynamically import the `eventsource` package and set - * the Authorization header. - */ - async subscribeDocuments( - onPayload: (payload: any) => void, - opts?: { onOpen?: () => void; onError?: (err: any) => void; eventName?: string }, - ): Promise - { - const path = "api/documents/realtime"; - const urlObj = new URL(path, this.baseUrl); - - let es: any = null; - const eventName = opts?.eventName ?? "document"; - - // Browser: use native EventSource. Native EventSource cannot set headers, so pass token in query. - if (typeof window !== "undefined" && (window as any).EventSource) - { - if (this.token) - { - urlObj.searchParams.set("token", this.token); - } - es = new (window as any).EventSource(urlObj.href); - } - else - { - // Node environment: try to dynamically load `eventsource` package (or `eventsource` polyfill) - try - { - // prefer dynamic import - - // @ts-ignore - const mod = await import("eventsource"); - const EventSourceImpl = mod.default ?? mod; - - const headers: Record = {}; - if (this.token) - { - headers["authorization"] = `Bearer ${this.token}`; - } - - es = new EventSourceImpl(urlObj.href, { headers }); - } - catch - { - throw new Error("EventSource is not available in this environment. Install the `eventsource` package in Node to use SSE."); - } - } - - const onMessage = (evt: any) => - { - try - { - // evt.data may contain comments like ': ping' which should be ignored - if (!evt?.data) { return; } - // Some SSE libraries deliver comments as data starting with ':'. Ignore those. - if (typeof evt.data === "string" && evt.data.trim().startsWith(":")) { return; } - - // The server uses named events like `document`; we listen to the specific event below. - // For `message` events, parse JSON if possible and forward. - let parsed = null; - try - { - parsed = JSON.parse(evt.data); - } - catch - { - parsed = evt.data; - } - onPayload(parsed); - } - catch (err) - { - // swallow user handler errors - console.error("Error in SSE onMessage handler:", err); - } - }; - - const onError = (err: any) => - { - if (opts?.onError) { opts.onError(err); } - }; - - const onOpen = () => - { - if (opts?.onOpen) { opts.onOpen(); } - }; - - // Attach listeners - es.addEventListener("message", onMessage); - es.addEventListener(eventName, onMessage); - es.addEventListener("open", onOpen); - es.addEventListener("error", onError); - - return { - close: () => - { - try - { - es.removeEventListener("message", onMessage); - es.removeEventListener(eventName, onMessage); - es.removeEventListener("open", onOpen); - es.removeEventListener("error", onError); - } - catch - { - // ignore - } - try { es.close(); } - catch { /* ignore */ } - }, - }; - } } export { ApiClient }; From 8781764a0ce1a6150000b17588d46295ff6614f9 Mon Sep 17 00:00:00 2001 From: janole Date: Wed, 27 Aug 2025 18:00:23 +0200 Subject: [PATCH 013/110] refactor: remove unused command listener logic and simplify chat server client initialization to start only --- .../session/chat-server/chat-server-client.ts | 43 +------------------ 1 file changed, 2 insertions(+), 41 deletions(-) diff --git a/src/ai/session/chat-server/chat-server-client.ts b/src/ai/session/chat-server/chat-server-client.ts index 93ad6d3..01f2d3a 100644 --- a/src/ai/session/chat-server/chat-server-client.ts +++ b/src/ai/session/chat-server/chat-server-client.ts @@ -5,48 +5,9 @@ const PAT = process.env["CODE_BANDIT_SERVER_PAT"]; const chatServerClient = URL && PAT ? new ApiClient({ baseUrl: URL, token: PAT }) : undefined; -type TCommandListenerFunc = (payload: any) => void; - -const commandListeners: TCommandListenerFunc[] = []; - -const addCommandListener = (f: TCommandListenerFunc) => -{ - if (!commandListeners.includes(f)) - { - commandListeners.push(f); - } -}; - -const removeCommandListener = (f: TCommandListenerFunc) => -{ - const i = commandListeners.indexOf(f); - - if (i !== -1) - { - commandListeners.splice(i, 1); - } -}; - -chatServerClient?.subscribeDocuments(payload => -{ - console.log("P", payload); - - for (const f of commandListeners) - { - if (payload.table === "documents" && payload.eventType === "INSERT" && payload.new?.data?.external_id) - { - f(payload.new.data); - } - } -}, { - onOpen: () => console.log("OPEN"), - onError: (err) => console.error("ERR", err), -}); +chatServerClient?.start(); export { - addCommandListener, - chatServerClient, - removeCommandListener + chatServerClient }; - From d7c919a115db55ccfeef00f5dfa8aa13d0af5895 Mon Sep 17 00:00:00 2001 From: janole Date: Wed, 27 Aug 2025 18:00:35 +0200 Subject: [PATCH 014/110] refactor: replace addCommandListener with chatServerClient methods and update session messages in chat-controller --- src/ui/chat-controller.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/ui/chat-controller.ts b/src/ui/chat-controller.ts index defe32b..4a3e475 100644 --- a/src/ui/chat-controller.ts +++ b/src/ui/chat-controller.ts @@ -1,11 +1,11 @@ +import { HumanMessage } from "@langchain/core/messages"; import clipboard from "clipboardy"; import { Key, useApp } from "ink"; import { useCallback, useEffect, useState } from "react"; -import { HumanMessage } from "@langchain/core/messages"; import { ChatService } from "../ai/chat-service.js"; import { ErrorMessage, TMessage, ToolProgressMessage } from "../ai/custom-messages.js"; -import { addCommandListener, removeCommandListener } from "../ai/session/chat-server/chat-server-client.js"; +import { chatServerClient } from "../ai/session/chat-server/chat-server-client.js"; import { ChatSession } from "../ai/session/session.js"; import { countTokens, lastMessageFromAI, TTokenUsage } from "../ai/tokens.js"; import { getGitBranch } from "../ai/tools/git-tools.js"; @@ -223,17 +223,19 @@ export function useChatController(props: UseChatControllerProps) if (message && !working) { const messages = [...chatHistory.messages, new HumanMessage(message)]; + session.setMessages(messages, messages.length); handleSendHistory(messages, messages.length); } } }; - addCommandListener(commandListener); + chatServerClient?.addCommandListener(commandListener); - return () => { removeCommandListener(commandListener); }; + return () => { chatServerClient?.removeCommandListener(commandListener); }; }, [ session, working, + chatHistory, ]); // TODO: refactor From 0f5e58d92d8cf0705da1d54c0e4fdeb368ce3949 Mon Sep 17 00:00:00 2001 From: janole Date: Wed, 27 Aug 2025 18:20:57 +0200 Subject: [PATCH 015/110] fix: fix authData usage and update setStatus method parameters and payload structure --- src/ai/session/chat-server/api-client.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/ai/session/chat-server/api-client.ts b/src/ai/session/chat-server/api-client.ts index 15f56f3..fbe7ccf 100644 --- a/src/ai/session/chat-server/api-client.ts +++ b/src/ai/session/chat-server/api-client.ts @@ -72,6 +72,8 @@ class ApiClient private baseUrl: string; private token?: string; + private authData?: AuthData; + private supabase?: SupabaseClient; private channel?: RealtimeChannel; @@ -156,7 +158,7 @@ class ApiClient const authData = await this.getAuthData(); - console.log(authData); + console.log(this.authData); const supabase = createClient(authData.api_url, authData.api_key, { accessToken: async () => @@ -167,6 +169,7 @@ class ApiClient }); this.supabase = supabase; + this.authData = authData; this.channel = await supabase.channel(authData.channel) .on( @@ -206,11 +209,16 @@ class ApiClient await this.ensureSupabaseClient(); } - async setStatus(status: ClientStatus) + async setStatus(status: ClientStatus["status"], external_id: ClientStatus["external_id"]) { await this.ensureSupabaseClient(); - await this.channel?.track(status); + await this.channel?.track({ + user_id: this.authData!.user_id, + type: "super-client", + external_id, + status, + } satisfies ClientStatus); } // --- Documents API --- From e25fc42196b6961ac356ce979daf763d35361fe6 Mon Sep 17 00:00:00 2001 From: janole Date: Wed, 27 Aug 2025 21:01:04 +0200 Subject: [PATCH 016/110] chore: add @supabase/supabase-js dependency at version 2.56.0 in package.json and package-lock.json --- package-lock.json | 90 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 91 insertions(+) diff --git a/package-lock.json b/package-lock.json index d1902fa..6d65f3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@langchain/groq": "^0.2.3", "@langchain/ollama": "^0.2.3", "@langchain/openai": "^0.6.2", + "@supabase/supabase-js": "^2.56.0", "chalk": "^5.4.1", "clipboardy": "^4.0.0", "commander": "^14.0.0", @@ -1552,6 +1553,80 @@ "eslint": ">=9.0.0" } }, + "node_modules/@supabase/auth-js": { + "version": "2.71.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.71.1.tgz", + "integrity": "sha512-mMIQHBRc+SKpZFRB2qtupuzulaUhFYupNyxqDj5Jp/LyPvcWvjaJzZzObv6URtL/O6lPxkanASnotGtNpS3H2Q==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.5.tgz", + "integrity": "sha512-v5GSqb9zbosquTo6gBwIiq7W9eQ7rE5QazsK/ezNiQXdCbY+bH8D9qEaBIkhVvX4ZRW5rP03gEfw5yw9tiq4EQ==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/node-fetch": { + "version": "2.6.15", + "resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz", + "integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "1.21.3", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.21.3.tgz", + "integrity": "sha512-rg3DmmZQKEVCreXq6Am29hMVe1CzemXyIWVYyyua69y6XubfP+DzGfLxME/1uvdgwqdoaPbtjBDpEBhqxq1ZwA==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.15.1.tgz", + "integrity": "sha512-edRFa2IrQw50kNntvUyS38hsL7t2d/psah6om6aNTLLcWem0R6bOUq7sk7DsGeSlNfuwEwWn57FdYSva6VddYw==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.13", + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "ws": "^8.18.2" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.11.0.tgz", + "integrity": "sha512-Y+kx/wDgd4oasAgoAq0bsbQojwQ+ejIif8uczZ9qufRHWFLMU5cODT+ApHsSrDufqUcVKt+eyxtOXSkeh2v9ww==", + "license": "MIT", + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.56.0", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.56.0.tgz", + "integrity": "sha512-XqwhHSyVnkjdliPN61CmXsmFGnFHTX2WDdwjG3Ukvdzuu3Trix+dXupYOQ3BueIyYp7B6t0yYpdQtJP2hIInyg==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.71.1", + "@supabase/functions-js": "2.4.5", + "@supabase/node-fetch": "2.6.15", + "@supabase/postgrest-js": "1.21.3", + "@supabase/realtime-js": "2.15.1", + "@supabase/storage-js": "^2.10.4" + } + }, "node_modules/@swc-node/core": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@swc-node/core/-/core-1.14.1.tgz", @@ -1922,6 +1997,12 @@ "form-data": "^4.0.4" } }, + "node_modules/@types/phoenix": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", + "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.1.12", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz", @@ -1954,6 +2035,15 @@ "@types/node": "*" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.42.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.42.0.tgz", diff --git a/package.json b/package.json index bfa3652..9326aff 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "@langchain/groq": "^0.2.3", "@langchain/ollama": "^0.2.3", "@langchain/openai": "^0.6.2", + "@supabase/supabase-js": "^2.56.0", "chalk": "^5.4.1", "clipboardy": "^4.0.0", "commander": "^14.0.0", From 181cf97f0eb1cd58cde75e3a940949ec0dbeed57 Mon Sep 17 00:00:00 2001 From: janole Date: Wed, 27 Aug 2025 21:01:36 +0200 Subject: [PATCH 017/110] style: add trailing comma to export list --- src/ai/session/chat-server/chat-server-client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ai/session/chat-server/chat-server-client.ts b/src/ai/session/chat-server/chat-server-client.ts index 01f2d3a..8e4f89a 100644 --- a/src/ai/session/chat-server/chat-server-client.ts +++ b/src/ai/session/chat-server/chat-server-client.ts @@ -9,5 +9,5 @@ chatServerClient?.start(); export { - chatServerClient + chatServerClient, }; From ab48d7b7d22c36a988b79b7b5263ee89718b1687 Mon Sep 17 00:00:00 2001 From: janole Date: Sat, 30 Aug 2025 16:09:05 +0200 Subject: [PATCH 018/110] fix: update chat server client status based on working state --- src/ui/chat-controller.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/ui/chat-controller.ts b/src/ui/chat-controller.ts index 4a3e475..a46ddd0 100644 --- a/src/ui/chat-controller.ts +++ b/src/ui/chat-controller.ts @@ -203,6 +203,12 @@ export function useChatController(props: UseChatControllerProps) setTokenUsage(countTokens(chatHistory.messages)); getGitBranch().then(branch => setCurrentGitBranch(branch)); + + chatServerClient?.setStatus("idle", session.id); + } + else + { + chatServerClient?.setStatus("working", session.id); } }, [ working, From befde84ed6fe412f6d2f82174d8f0ce94792f629 Mon Sep 17 00:00:00 2001 From: janole Date: Thu, 4 Sep 2025 00:02:53 +0200 Subject: [PATCH 019/110] fix: remove debug console log from api-client.ts --- src/ai/session/chat-server/api-client.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/ai/session/chat-server/api-client.ts b/src/ai/session/chat-server/api-client.ts index fbe7ccf..719d278 100644 --- a/src/ai/session/chat-server/api-client.ts +++ b/src/ai/session/chat-server/api-client.ts @@ -158,8 +158,6 @@ class ApiClient const authData = await this.getAuthData(); - console.log(this.authData); - const supabase = createClient(authData.api_url, authData.api_key, { accessToken: async () => { From 1e67a8e6552181040dca0ae381d5521fc57a9f31 Mon Sep 17 00:00:00 2001 From: janole Date: Thu, 4 Sep 2025 00:03:07 +0200 Subject: [PATCH 020/110] fix: handle broadcast command events by pushing commands with args --- src/ai/session/chat-server/api-client.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ai/session/chat-server/api-client.ts b/src/ai/session/chat-server/api-client.ts index 719d278..3889016 100644 --- a/src/ai/session/chat-server/api-client.ts +++ b/src/ai/session/chat-server/api-client.ts @@ -180,6 +180,7 @@ class ApiClient }, (payload: any) => this.pushCommand(payload), ) + .on("broadcast", { event: "command" }, (args: any) => this.pushCommand({ args })) .on("presence", { event: "sync" }, () => this.pushCommand({ presenceState: this.channel?.presenceState() })) .on("presence", { event: "join" }, (args: any) => this.pushCommand({ args, presenceState: this.channel?.presenceState() })) .on("presence", { event: "leave" }, (args: any) => this.pushCommand({ args, presenceState: this.channel?.presenceState() })); From f5ede6e3fe0bdbb3ccacd94f352d3ff433bc4286 Mon Sep 17 00:00:00 2001 From: janole Date: Thu, 4 Sep 2025 00:22:27 +0200 Subject: [PATCH 021/110] chore: add async-mutex dependency to package.json and package-lock.json --- package-lock.json | 11 ++++++++++- package.json | 1 + 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 6d65f3b..127815c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@langchain/ollama": "^0.2.3", "@langchain/openai": "^0.6.2", "@supabase/supabase-js": "^2.56.0", + "async-mutex": "^0.5.0", "chalk": "^5.4.1", "clipboardy": "^4.0.0", "commander": "^14.0.0", @@ -2504,6 +2505,15 @@ "node": ">= 0.4" } }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -8130,7 +8140,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/type-check": { diff --git a/package.json b/package.json index 9326aff..613dbf1 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "@langchain/ollama": "^0.2.3", "@langchain/openai": "^0.6.2", "@supabase/supabase-js": "^2.56.0", + "async-mutex": "^0.5.0", "chalk": "^5.4.1", "clipboardy": "^4.0.0", "commander": "^14.0.0", From 61bd30107690fc790d945ccfd0f3d25dc722023a Mon Sep 17 00:00:00 2001 From: janole Date: Thu, 4 Sep 2025 00:23:13 +0200 Subject: [PATCH 022/110] fix: remove debug log from chat controller command listener --- src/ui/chat-controller.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/ui/chat-controller.ts b/src/ui/chat-controller.ts index a46ddd0..7c829a1 100644 --- a/src/ui/chat-controller.ts +++ b/src/ui/chat-controller.ts @@ -220,8 +220,6 @@ export function useChatController(props: UseChatControllerProps) { const commandListener = (command: any) => { - console.log("COMMAND", command, session.id); - if (command.external_id === session.id) { const message = command.message?.trim(); From 413838d7d5f63d3241c750c89d67e5379b0a0057 Mon Sep 17 00:00:00 2001 From: janole Date: Thu, 4 Sep 2025 00:23:36 +0200 Subject: [PATCH 023/110] chore: add commented-out Mutex class for potential future use in api-client.ts --- src/ai/session/chat-server/api-client.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/ai/session/chat-server/api-client.ts b/src/ai/session/chat-server/api-client.ts index 3889016..8abfc56 100644 --- a/src/ai/session/chat-server/api-client.ts +++ b/src/ai/session/chat-server/api-client.ts @@ -67,6 +67,29 @@ export type SSESubscription = { type TCommandListenerFunc = (payload: any) => void; +// export class Mutex +// { +// private _locked = false; +// private _queue: (() => void)[] = []; + +// async acquire(): Promise<() => void> +// { +// while (this._locked) +// { +// await new Promise(resolve => this._queue.push(resolve)); +// } + +// this._locked = true; + +// return () => +// { +// this._locked = false; +// // resume the next waiter (if any) +// this._queue.shift()?.(); +// }; +// } +// } + class ApiClient { private baseUrl: string; From 249f7926b470cb95b2b1ab9520c96286c0d9f8e6 Mon Sep 17 00:00:00 2001 From: janole Date: Thu, 4 Sep 2025 00:23:56 +0200 Subject: [PATCH 024/110] chore: remove commented-out Mutex class from api-client.ts --- src/ai/session/chat-server/api-client.ts | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/src/ai/session/chat-server/api-client.ts b/src/ai/session/chat-server/api-client.ts index 8abfc56..3889016 100644 --- a/src/ai/session/chat-server/api-client.ts +++ b/src/ai/session/chat-server/api-client.ts @@ -67,29 +67,6 @@ export type SSESubscription = { type TCommandListenerFunc = (payload: any) => void; -// export class Mutex -// { -// private _locked = false; -// private _queue: (() => void)[] = []; - -// async acquire(): Promise<() => void> -// { -// while (this._locked) -// { -// await new Promise(resolve => this._queue.push(resolve)); -// } - -// this._locked = true; - -// return () => -// { -// this._locked = false; -// // resume the next waiter (if any) -// this._queue.shift()?.(); -// }; -// } -// } - class ApiClient { private baseUrl: string; From f6a3bf436bf2e96e8335b7dd8f1835bbe46270ea Mon Sep 17 00:00:00 2001 From: janole Date: Thu, 4 Sep 2025 00:24:25 +0200 Subject: [PATCH 025/110] refactor: add mutex to ensureSupabaseClient for safe initialization and separate client creation logic into createSupabaseClient method --- src/ai/session/chat-server/api-client.ts | 41 ++++++++++++++---------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/src/ai/session/chat-server/api-client.ts b/src/ai/session/chat-server/api-client.ts index 3889016..10d4d43 100644 --- a/src/ai/session/chat-server/api-client.ts +++ b/src/ai/session/chat-server/api-client.ts @@ -1,6 +1,7 @@ // A simple, configurable client library for the documents and links APIs. import { createClient, RealtimeChannel, SupabaseClient } from "@supabase/supabase-js"; +import { Mutex } from "async-mutex"; export interface AuthData { @@ -149,12 +150,9 @@ class ApiClient // --- Realtime API --- - private async ensureSupabaseClient() + private async createSupabaseClient() { - if (this.supabase) - { - return this.supabase; - } + console.log("createSupabaseClient"); const authData = await this.getAuthData(); @@ -185,22 +183,31 @@ class ApiClient .on("presence", { event: "join" }, (args: any) => this.pushCommand({ args, presenceState: this.channel?.presenceState() })) .on("presence", { event: "leave" }, (args: any) => this.pushCommand({ args, presenceState: this.channel?.presenceState() })); - this.channel.subscribe(async (status: any) => - { - console.log("SUBSCRIBE", status); + this.channel.subscribe(); - // if (status !== "SUBSCRIBED") - // { - // return; - // } + return this.supabase; + } - // const presenceTrackStatus = await this.channel?.track({ userId: authData.user_id, type: "super-client" }); - // console.log(presenceTrackStatus); - }); + private _mutex = new Mutex(); - // await this.channel.track(currentStatus); + private async ensureSupabaseClient() + { + if (!this.supabase) + { + const release = await this._mutex.acquire(); - return this.supabase; + try + { + if (!this.supabase) + { + this.supabase = await this.createSupabaseClient(); + } + } + finally + { + release(); + } + } } async start() From 5a257e0e8998e0e78ecbd6bbeae34e9dc6b4b981 Mon Sep 17 00:00:00 2001 From: janole Date: Thu, 4 Sep 2025 00:24:45 +0200 Subject: [PATCH 026/110] fix: update pushCommand to handle command event payload structure and remove debug log --- src/ai/session/chat-server/api-client.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/ai/session/chat-server/api-client.ts b/src/ai/session/chat-server/api-client.ts index 10d4d43..87c0774 100644 --- a/src/ai/session/chat-server/api-client.ts +++ b/src/ai/session/chat-server/api-client.ts @@ -130,13 +130,14 @@ class ApiClient private pushCommand(payload: any) { - console.log("PAYLOAD", payload); - for (const f of this.commandListeners) { - if (payload.table === "documents" && payload.eventType === "INSERT" && payload.new?.data?.external_id) + if (payload.args?.event === "command" && payload.args.payload) { - f(payload.new.data); + f({ + external_id: payload.args.payload?.external_id, + message: payload.args.payload?.message, + }); } } } From 08865ff0e3d187d8833637270d5af7bfb30700f1 Mon Sep 17 00:00:00 2001 From: janole Date: Thu, 4 Sep 2025 00:28:50 +0200 Subject: [PATCH 027/110] refactor: prefix interface names with 'I' in api-client.ts and update related type annotations accordingly --- src/ai/session/chat-server/api-client.ts | 47 ++++++++++-------------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/src/ai/session/chat-server/api-client.ts b/src/ai/session/chat-server/api-client.ts index 87c0774..4901036 100644 --- a/src/ai/session/chat-server/api-client.ts +++ b/src/ai/session/chat-server/api-client.ts @@ -3,7 +3,7 @@ import { createClient, RealtimeChannel, SupabaseClient } from "@supabase/supabase-js"; import { Mutex } from "async-mutex"; -export interface AuthData +export interface IAuthData { user_id: string; access_token: string; @@ -12,7 +12,7 @@ export interface AuthData channel: string; } -export interface ClientStatus +export interface IClientStatus { type: "consumer-client" | "super-client"; user_id: string; @@ -20,7 +20,7 @@ export interface ClientStatus status: "idle" | "working"; } -export interface Document +export interface IDocument { id: string; // Internal UUID external_id: string | null; @@ -31,7 +31,7 @@ export interface Document updated_at: string; } -export interface Link +export interface ILink { id: string; user_id: string; @@ -42,7 +42,7 @@ export interface Link } // Type for creating a new document, with optional external_id and links -export interface NewDocument +export interface INewDocument { data: Record; external_id?: string; @@ -50,22 +50,18 @@ export interface NewDocument links?: Array<{ to_id: string; type?: string } | { from_id: string; type?: string }>; } -export type NewLink = { +export type INewLink = { from_id: string; to_id: string; type?: string; }; -export interface ApiClientOptions +export interface IApiClientOptions { baseUrl?: string; token?: string; } -export type SSESubscription = { - close: () => void; -}; - type TCommandListenerFunc = (payload: any) => void; class ApiClient @@ -73,14 +69,14 @@ class ApiClient private baseUrl: string; private token?: string; - private authData?: AuthData; + private authData?: IAuthData; private supabase?: SupabaseClient; private channel?: RealtimeChannel; private commandListeners: TCommandListenerFunc[] = []; - constructor(options: ApiClientOptions = {}) + constructor(options: IApiClientOptions = {}) { this.baseUrl = options.baseUrl || ""; this.token = options.token; @@ -144,23 +140,20 @@ class ApiClient // --- Auth API --- - getAuthData(): Promise + getAuthData(): Promise { - return this._request("api/auth/access-token", { method: "POST" }); + return this._request("api/auth/access-token", { method: "POST" }); } // --- Realtime API --- private async createSupabaseClient() { - console.log("createSupabaseClient"); - const authData = await this.getAuthData(); const supabase = createClient(authData.api_url, authData.api_key, { accessToken: async () => { - console.log("GET ACCESS-TOKEN"); return authData.access_token; }, }); @@ -216,7 +209,7 @@ class ApiClient await this.ensureSupabaseClient(); } - async setStatus(status: ClientStatus["status"], external_id: ClientStatus["external_id"]) + async setStatus(status: IClientStatus["status"], external_id: IClientStatus["external_id"]) { await this.ensureSupabaseClient(); @@ -225,7 +218,7 @@ class ApiClient type: "super-client", external_id, status, - } satisfies ClientStatus); + } satisfies IClientStatus); } // --- Documents API --- @@ -303,7 +296,7 @@ class ApiClient // --- Document Creation --- - createDocument(doc: NewDocument): Promise + createDocument(doc: INewDocument): Promise { return this._request("api/documents", { method: "POST", @@ -313,27 +306,27 @@ class ApiClient // --- Links API --- - createLinks(links: NewLink[]): Promise + createLinks(links: INewLink[]): Promise { - return this._request("api/links", { + return this._request("api/links", { method: "POST", body: JSON.stringify(links), }); } - deleteLinkById(id: string): Promise + deleteLinkById(id: string): Promise { - return this._request(`api/links?id=${id}`, { method: "DELETE" }); + return this._request(`api/links?id=${id}`, { method: "DELETE" }); } - deleteLinkByFromTo(params: { from_id: string; to_id: string; type?: string }): Promise + deleteLinkByFromTo(params: { from_id: string; to_id: string; type?: string }): Promise { const searchParams = new URLSearchParams({ from_id: params.from_id, to_id: params.to_id }); if (params.type) { searchParams.set("type", params.type); } - return this._request(`api/links?${searchParams.toString()}`, { method: "DELETE" }); + return this._request(`api/links?${searchParams.toString()}`, { method: "DELETE" }); } } From 7aeab3c78810e6c0a18d9211c2ed13a953eab852 Mon Sep 17 00:00:00 2001 From: janole Date: Thu, 4 Sep 2025 10:34:01 +0200 Subject: [PATCH 028/110] fix: update API client methods to use IDocument interface instead of Document type --- src/ai/session/chat-server/api-client.ts | 38 ++++++++++++------------ 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/ai/session/chat-server/api-client.ts b/src/ai/session/chat-server/api-client.ts index 4901036..7f2ec49 100644 --- a/src/ai/session/chat-server/api-client.ts +++ b/src/ai/session/chat-server/api-client.ts @@ -223,9 +223,9 @@ class ApiClient // --- Documents API --- - listDocuments(): Promise + listDocuments(): Promise { - return this._request("api/documents", { method: "GET" }); + return this._request("api/documents", { method: "GET" }); } /** @@ -236,44 +236,44 @@ class ApiClient * Note: The server route `GET /api/documents` must support the * `linked_to` (and optional `type`) query parameters. */ - listDocumentsLinkingTo(targetDocumentId: string, opts?: { type?: string }): Promise + listDocumentsLinkingTo(targetDocumentId: string, opts?: { type?: string }): Promise { const searchParams = new URLSearchParams({ linked_to: targetDocumentId }); if (opts?.type) { searchParams.set("type", opts.type); } - return this._request(`api/documents?${searchParams.toString()}`, { method: "GET" }); + return this._request(`api/documents?${searchParams.toString()}`, { method: "GET" }); } /** Convenience alias for readability when treating the target as a parent node. */ - listChildrenOf(parentDocumentId: string, opts?: { type?: string }): Promise + listChildrenOf(parentDocumentId: string, opts?: { type?: string }): Promise { return this.listDocumentsLinkingTo(parentDocumentId, opts); } // --- Document access by internal UUID --- - getDocumentById(id: string): Promise + getDocumentById(id: string): Promise { - return this._request(`api/documents?id=${id}`, { method: "GET" }); + return this._request(`api/documents?id=${id}`, { method: "GET" }); } - updateDocumentById(id: string, updates: { data?: Record, schema_id?: string | null }): Promise + updateDocumentById(id: string, updates: { data?: Record, schema_id?: string | null }): Promise { - return this._request("api/documents", { + return this._request("api/documents", { method: "PUT", body: JSON.stringify({ id, ...updates }), }); } - deleteDocumentById(id: string): Promise + deleteDocumentById(id: string): Promise { - return this._request(`api/documents?id=${id}`, { method: "DELETE" }); + return this._request(`api/documents?id=${id}`, { method: "DELETE" }); } // --- Document access by external_id --- - getDocumentByExternalId(externalId: string): Promise + getDocumentByExternalId(externalId: string): Promise { - return this._request(`api/documents/external/${externalId}`, { method: "GET" }); + return this._request(`api/documents/external/${externalId}`, { method: "GET" }); } /** @@ -281,24 +281,24 @@ class ApiClient * @param externalId The external ID of the document to upsert. * @param documentData The data for the document. */ - upsertDocument(externalId: string, documentData: { data: Record, schema_id?: string | null }): Promise + upsertDocument(externalId: string, documentData: { data: Record, schema_id?: string | null }): Promise { - return this._request(`api/documents/external/${externalId}`, { + return this._request(`api/documents/external/${externalId}`, { method: "PUT", body: JSON.stringify(documentData), }); } - deleteDocumentByExternalId(externalId: string): Promise + deleteDocumentByExternalId(externalId: string): Promise { - return this._request(`api/documents/external/${externalId}`, { method: "DELETE" }); + return this._request(`api/documents/external/${externalId}`, { method: "DELETE" }); } // --- Document Creation --- - createDocument(doc: INewDocument): Promise + createDocument(doc: INewDocument): Promise { - return this._request("api/documents", { + return this._request("api/documents", { method: "POST", body: JSON.stringify(doc), }); From bd85528f2775be9a5e6448fba45aeaded81c51cc Mon Sep 17 00:00:00 2001 From: janole Date: Thu, 4 Sep 2025 16:32:53 +0200 Subject: [PATCH 029/110] fix: prevent redundant status updates by tracking status with ref in chat controller hook --- src/ui/chat-controller.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/ui/chat-controller.ts b/src/ui/chat-controller.ts index 7c829a1..468cbb9 100644 --- a/src/ui/chat-controller.ts +++ b/src/ui/chat-controller.ts @@ -194,6 +194,8 @@ export function useChatController(props: UseChatControllerProps) working, ]); + const statusRef = useRef<"idle" | "working">(null); + useEffect(() => { if (!working) @@ -204,11 +206,14 @@ export function useChatController(props: UseChatControllerProps) getGitBranch().then(branch => setCurrentGitBranch(branch)); - chatServerClient?.setStatus("idle", session.id); + if (statusRef.current !== "idle") + { + chatServerClient?.setStatus(statusRef.current = "idle", session.id); + } } - else + else if (statusRef.current !== "working") { - chatServerClient?.setStatus("working", session.id); + chatServerClient?.setStatus(statusRef.current = "working", session.id); } }, [ working, From 733716dd427d3facee03da6b8823f951324eb03a Mon Sep 17 00:00:00 2001 From: janole Date: Thu, 4 Sep 2025 16:33:27 +0200 Subject: [PATCH 030/110] fix: include handleSendHistory in dependencies array of useChatController hook --- src/ui/chat-controller.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ui/chat-controller.ts b/src/ui/chat-controller.ts index 468cbb9..121c447 100644 --- a/src/ui/chat-controller.ts +++ b/src/ui/chat-controller.ts @@ -245,6 +245,7 @@ export function useChatController(props: UseChatControllerProps) session, working, chatHistory, + handleSendHistory, ]); // TODO: refactor From 6453bcbfa7d9e5edf24050db2621579846a3f82c Mon Sep 17 00:00:00 2001 From: janole Date: Thu, 4 Sep 2025 16:45:22 +0200 Subject: [PATCH 031/110] chore: remove eventsource dependency and its types from package files --- package-lock.json | 101 +--------------------------------------------- package.json | 2 - 2 files changed, 1 insertion(+), 102 deletions(-) diff --git a/package-lock.json b/package-lock.json index 127815c..d1902fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,8 +15,6 @@ "@langchain/groq": "^0.2.3", "@langchain/ollama": "^0.2.3", "@langchain/openai": "^0.6.2", - "@supabase/supabase-js": "^2.56.0", - "async-mutex": "^0.5.0", "chalk": "^5.4.1", "clipboardy": "^4.0.0", "commander": "^14.0.0", @@ -1554,80 +1552,6 @@ "eslint": ">=9.0.0" } }, - "node_modules/@supabase/auth-js": { - "version": "2.71.1", - "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.71.1.tgz", - "integrity": "sha512-mMIQHBRc+SKpZFRB2qtupuzulaUhFYupNyxqDj5Jp/LyPvcWvjaJzZzObv6URtL/O6lPxkanASnotGtNpS3H2Q==", - "license": "MIT", - "dependencies": { - "@supabase/node-fetch": "^2.6.14" - } - }, - "node_modules/@supabase/functions-js": { - "version": "2.4.5", - "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.5.tgz", - "integrity": "sha512-v5GSqb9zbosquTo6gBwIiq7W9eQ7rE5QazsK/ezNiQXdCbY+bH8D9qEaBIkhVvX4ZRW5rP03gEfw5yw9tiq4EQ==", - "license": "MIT", - "dependencies": { - "@supabase/node-fetch": "^2.6.14" - } - }, - "node_modules/@supabase/node-fetch": { - "version": "2.6.15", - "resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz", - "integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - } - }, - "node_modules/@supabase/postgrest-js": { - "version": "1.21.3", - "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.21.3.tgz", - "integrity": "sha512-rg3DmmZQKEVCreXq6Am29hMVe1CzemXyIWVYyyua69y6XubfP+DzGfLxME/1uvdgwqdoaPbtjBDpEBhqxq1ZwA==", - "license": "MIT", - "dependencies": { - "@supabase/node-fetch": "^2.6.14" - } - }, - "node_modules/@supabase/realtime-js": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.15.1.tgz", - "integrity": "sha512-edRFa2IrQw50kNntvUyS38hsL7t2d/psah6om6aNTLLcWem0R6bOUq7sk7DsGeSlNfuwEwWn57FdYSva6VddYw==", - "license": "MIT", - "dependencies": { - "@supabase/node-fetch": "^2.6.13", - "@types/phoenix": "^1.6.6", - "@types/ws": "^8.18.1", - "ws": "^8.18.2" - } - }, - "node_modules/@supabase/storage-js": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.11.0.tgz", - "integrity": "sha512-Y+kx/wDgd4oasAgoAq0bsbQojwQ+ejIif8uczZ9qufRHWFLMU5cODT+ApHsSrDufqUcVKt+eyxtOXSkeh2v9ww==", - "license": "MIT", - "dependencies": { - "@supabase/node-fetch": "^2.6.14" - } - }, - "node_modules/@supabase/supabase-js": { - "version": "2.56.0", - "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.56.0.tgz", - "integrity": "sha512-XqwhHSyVnkjdliPN61CmXsmFGnFHTX2WDdwjG3Ukvdzuu3Trix+dXupYOQ3BueIyYp7B6t0yYpdQtJP2hIInyg==", - "license": "MIT", - "dependencies": { - "@supabase/auth-js": "2.71.1", - "@supabase/functions-js": "2.4.5", - "@supabase/node-fetch": "2.6.15", - "@supabase/postgrest-js": "1.21.3", - "@supabase/realtime-js": "2.15.1", - "@supabase/storage-js": "^2.10.4" - } - }, "node_modules/@swc-node/core": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@swc-node/core/-/core-1.14.1.tgz", @@ -1998,12 +1922,6 @@ "form-data": "^4.0.4" } }, - "node_modules/@types/phoenix": { - "version": "1.6.6", - "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", - "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==", - "license": "MIT" - }, "node_modules/@types/react": { "version": "19.1.12", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz", @@ -2036,15 +1954,6 @@ "@types/node": "*" } }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.42.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.42.0.tgz", @@ -2505,15 +2414,6 @@ "node": ">= 0.4" } }, - "node_modules/async-mutex": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", - "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -8140,6 +8040,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, "license": "0BSD" }, "node_modules/type-check": { diff --git a/package.json b/package.json index 613dbf1..bfa3652 100644 --- a/package.json +++ b/package.json @@ -63,8 +63,6 @@ "@langchain/groq": "^0.2.3", "@langchain/ollama": "^0.2.3", "@langchain/openai": "^0.6.2", - "@supabase/supabase-js": "^2.56.0", - "async-mutex": "^0.5.0", "chalk": "^5.4.1", "clipboardy": "^4.0.0", "commander": "^14.0.0", From a5ae7da7ceb6def33011c93065a25ff336a1fb7e Mon Sep 17 00:00:00 2001 From: janole Date: Fri, 5 Sep 2025 11:46:57 +0200 Subject: [PATCH 032/110] fix: queue async message updates in chat controller to avoid concurrency issues --- src/ui/chat-controller.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ui/chat-controller.ts b/src/ui/chat-controller.ts index 121c447..917fec3 100644 --- a/src/ui/chat-controller.ts +++ b/src/ui/chat-controller.ts @@ -36,6 +36,8 @@ export function useChatController(props: UseChatControllerProps) finished: session.finished || 0, }); + const messagesQueueRef = useRef>(Promise.resolve()); + const [tokenUsage, setTokenUsage] = useState(); const [currentGitBranch, setCurrentGitBranch] = useState(); From 4ea773da576e1d071dfbb29cb65cc2ba7670f575 Mon Sep 17 00:00:00 2001 From: janole Date: Fri, 5 Sep 2025 11:47:50 +0200 Subject: [PATCH 033/110] fix: update effect dependency from session to session.id to avoid unnecessary re-renders --- src/ui/chat-controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/chat-controller.ts b/src/ui/chat-controller.ts index 917fec3..605f7e8 100644 --- a/src/ui/chat-controller.ts +++ b/src/ui/chat-controller.ts @@ -244,7 +244,7 @@ export function useChatController(props: UseChatControllerProps) return () => { chatServerClient?.removeCommandListener(commandListener); }; }, [ - session, + session.id, working, chatHistory, handleSendHistory, From b21c956591ecfb0467e4568909ed08567704fff1 Mon Sep 17 00:00:00 2001 From: janole Date: Fri, 5 Sep 2025 11:48:08 +0200 Subject: [PATCH 034/110] refactor: change session messages handling to inline update in useChatController --- src/ui/chat-controller.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ui/chat-controller.ts b/src/ui/chat-controller.ts index 605f7e8..29221eb 100644 --- a/src/ui/chat-controller.ts +++ b/src/ui/chat-controller.ts @@ -57,7 +57,8 @@ export function useChatController(props: UseChatControllerProps) work({ chatService, - session, + // TODO: refactor session.messages and setMessage/setState handling + session: { ...session, messages, finished: messages.length }, streaming, send: (messages: TMessage[]) => session.setMessages(messages, session.finished), signal: abortController.signal, From 26cc8ee3f19729f5be359d192024b7eae6b561c0 Mon Sep 17 00:00:00 2001 From: janole Date: Fri, 5 Sep 2025 11:48:26 +0200 Subject: [PATCH 035/110] refactor: remove redundant session.setMessages call in chat controller to streamline message handling --- src/ui/chat-controller.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ui/chat-controller.ts b/src/ui/chat-controller.ts index 29221eb..b6f9cd9 100644 --- a/src/ui/chat-controller.ts +++ b/src/ui/chat-controller.ts @@ -235,7 +235,6 @@ export function useChatController(props: UseChatControllerProps) if (message && !working) { const messages = [...chatHistory.messages, new HumanMessage(message)]; - session.setMessages(messages, messages.length); handleSendHistory(messages, messages.length); } } From 907bcf443025b78cb11cb2f54a3338f2d7b97c34 Mon Sep 17 00:00:00 2001 From: janole Date: Sun, 7 Sep 2025 01:13:55 +0200 Subject: [PATCH 036/110] feat: add chat server type definitions for models, messages, and chat sessions including state and configuration interfaces --- src/ai/session/chat-server/types.ts | 205 ++++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 src/ai/session/chat-server/types.ts diff --git a/src/ai/session/chat-server/types.ts b/src/ai/session/chat-server/types.ts new file mode 100644 index 0000000..999c40a --- /dev/null +++ b/src/ai/session/chat-server/types.ts @@ -0,0 +1,205 @@ +type TChatModelProvider = + | "node-llama-cpp" + | "ollama" + | "openai" + | "googleai" + | "groq" + | "xai" + | "openrouter" + | "langchain-js" + | "vercel-ai-sdk" + | string + ; + +export interface IChatModelFeatures +{ + tools?: boolean; + vision?: boolean; + options?: { + temperature?: boolean; + num_ctx?: boolean; + top_k?: boolean; + top_p?: boolean; + min_p?: boolean; + integratedWebSearch?: boolean; + }; +} + +interface IChatModelState +{ + ready?: boolean; + downloadable?: boolean; + removable?: boolean; + hasLocalModelFile?: boolean; +} + +export interface IChatModelOptions +{ + temperature?: number; + num_ctx?: number; + top_k?: number; + top_p?: number; + min_p?: number; + integratedWebSearch?: boolean; +} + +export interface IChatModelConfig +{ + favorite?: boolean; + hidden?: boolean; + options?: IChatModelOptions; +}; + +export interface IChatModel +{ + id: string; + name: string; + provider: TChatModelProvider; + + account: { id: string; name: string; remote?: boolean; removable?: boolean; }; + + displayName?: string; + description?: string; + + modelUri?: string; + modelFile?: string; + + state: IChatModelState; + + contextLength?: number; + size?: number; + parameterSize?: string; + quantizationLevel?: string; + features?: IChatModelFeatures; + + config?: IChatModelConfig; +} + +export class ChatModel implements IChatModel +{ + id: string; + name: string; + provider: TChatModelProvider; + + account: { id: string; name: string; remote?: boolean; removable?: boolean; }; + + displayName?: string; + description?: string; + + modelUri?: string; + modelFile?: string; + + state: IChatModelState; + + contextLength?: number; + size?: number; + parameterSize?: string; + quantizationLevel?: string; + features?: IChatModelFeatures; + + config?: IChatModelConfig; + + constructor(data: IChatModel) + { + this.id = data.id; + this.name = data.name; + this.provider = data.provider; + this.account = structuredClone(data.account); + this.displayName = data.displayName; + this.description = data.description; + this.modelUri = data.modelUri; + this.modelFile = data.modelFile; + this.state = structuredClone(data.state); + this.contextLength = data.contextLength; + this.parameterSize = data.parameterSize; + this.quantizationLevel = data.quantizationLevel; + this.features = structuredClone(data.features); + this.config = structuredClone(data.config); + } +} + +export interface IChatModelInfo +{ + model: Pick; + options?: IChatModelOptions; +} + +export type TChatMessageRole = + | "system" + | "user" + | "assistant" + | "tool" + | "tool-progress" + | "info" + | "error" + | "private" + ; + +type TConfirmState = "no" | "yes" | "none" | "all"; + +export interface IChatMessageBase +{ + content: string; + images?: string[]; + + toolCall?: { + name: string; + args: Record; + + description?: string; + fileName?: string; + + id?: string; + + status: "pending" | "pending-confirmation" | "confirmed" | "declined" | "success" | "error"; + confirmState?: TConfirmState; + }; + + readonly createdAt: number; + readonly info?: IChatModelInfo; + + showThinking?: boolean; + showDetails?: boolean; + + privateData?: any; +} + +export interface IChatMessage extends IChatMessageBase +{ + role: TChatMessageRole; + hidden?: boolean; + + state?: TChatState; + + history: IChatMessageBase[]; + historyIndex: number; +} + +export interface IWorkspace +{ + workDir?: string; +} + +export type TChatState = "working" | "done" | "stopped"; + +export type TToolMode = "confirm" | "read-only" | "yolo"; + +export interface IChat +{ + id: string; + sourceId?: string; + model: IChatModel; + title?: string; + generatedSummary?: string; + workspace?: IWorkspace; + messages: IChatMessage[]; + currentPrompt?: { content?: string; images?: string[]; messageIndex?: number; }; + state?: TChatState; + toolMode?: TToolMode; + scrollPos?: number; + favorite?: boolean; + useSystemPrompt?: boolean; + showSettings?: boolean; + updatedAt?: number; + deletedAt?: number | "deleted"; +} From 2d5abcafae20f9e4f099f1156be3409563f83fac Mon Sep 17 00:00:00 2001 From: janole Date: Sun, 7 Sep 2025 01:26:08 +0200 Subject: [PATCH 037/110] feat: add types for managing chat models and accounts including add/remove actions and provider info --- src/ai/session/chat-server/types.ts | 35 +++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/ai/session/chat-server/types.ts b/src/ai/session/chat-server/types.ts index 999c40a..5f3f6dc 100644 --- a/src/ai/session/chat-server/types.ts +++ b/src/ai/session/chat-server/types.ts @@ -124,6 +124,39 @@ export interface IChatModelInfo options?: IChatModelOptions; } +export type TAddChatModel = { provider: TChatModelProvider; modelUri: string; startDownload?: boolean; }; + +export type TRemoveChatModel = { provider: TChatModelProvider; modelUri: string; }; + +export interface IAddAccountOpenAI +{ + name: string; + provider: "openai"; + type: "openai"; + apiKey: string; + baseURL?: string; +} + +export interface IAddAccountOpenRouter extends Omit +{ + type: "openrouter"; +} + +interface IAddAccountGoogleAI +{ + name: string; + provider: "googleai"; + apiKey: string; +} + +export type TAddAccount = IAddAccountOpenAI | IAddAccountOpenRouter | IAddAccountGoogleAI; + +export interface TRemoveAccount +{ + provider: TChatModelProvider; + id: string; +} + export type TChatMessageRole = | "system" | "user" @@ -203,3 +236,5 @@ export interface IChat updatedAt?: number; deletedAt?: number | "deleted"; } + +export type TProviderInfo = { [key: string]: any }; From abbbfa51e947cc9b436af4c78fbdf2b0c912f29d Mon Sep 17 00:00:00 2001 From: janole Date: Sun, 7 Sep 2025 01:26:17 +0200 Subject: [PATCH 038/110] refactor: define TConfirmState using a constant array for better type safety --- src/ai/session/chat-server/types.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ai/session/chat-server/types.ts b/src/ai/session/chat-server/types.ts index 5f3f6dc..926ee13 100644 --- a/src/ai/session/chat-server/types.ts +++ b/src/ai/session/chat-server/types.ts @@ -168,7 +168,9 @@ export type TChatMessageRole = | "private" ; -type TConfirmState = "no" | "yes" | "none" | "all"; +export const CONFIRM_STATES = ["no", "yes", "none", "all"] as const; + +export type TConfirmState = typeof CONFIRM_STATES[number]; export interface IChatMessageBase { From 812771a406ba387538bc502e93cc4167ebf79766 Mon Sep 17 00:00:00 2001 From: janole Date: Sun, 7 Sep 2025 16:35:19 +0200 Subject: [PATCH 039/110] feat: add functions to map messages and chat sessions for chat-server-client integration --- .../session/chat-server/chat-server-client.ts | 112 +++++++++++++++++- 1 file changed, 111 insertions(+), 1 deletion(-) diff --git a/src/ai/session/chat-server/chat-server-client.ts b/src/ai/session/chat-server/chat-server-client.ts index 8e4f89a..bc6b4ac 100644 --- a/src/ai/session/chat-server/chat-server-client.ts +++ b/src/ai/session/chat-server/chat-server-client.ts @@ -1,4 +1,9 @@ +import { AIMessage, BaseMessage, HumanMessage } from "@langchain/core/messages"; + +import { ErrorMessage, TMessage, TMessageType, ToolProgressMessage } from "../../custom-messages.js"; +import { IChatSession } from "../session.js"; import { ApiClient } from "./api-client.js"; +import { IChat, IChatMessage } from "./types.js"; const URL = process.env["CODE_BANDIT_SERVER_URL"]; const PAT = process.env["CODE_BANDIT_SERVER_PAT"]; @@ -7,7 +12,112 @@ const chatServerClient = URL && PAT ? new ApiClient({ baseUrl: URL, token: PAT } chatServerClient?.start(); +function mapMessage(message: TMessage): IChatMessage +{ + const type: TMessageType = message.getType(); + + const base = { + history: [], + historyIndex: 0, + createdAt: Date.now(), + }; + + if (type === "tool-progress") + { + const toolProgressMessage = message as ToolProgressMessage; + + return { + ...base, + role: "tool-progress", + content: toolProgressMessage.content ?? "", + toolCall: { + ...toolProgressMessage.toolCall, + status: toolProgressMessage.status, + confirmState: toolProgressMessage.confirmState, + }, + }; + } + + if (type === "error") + { + const errorMessage = message as ErrorMessage; + + return { + ...base, + role: type, + content: errorMessage.content, + privateData: errorMessage, + }; + } + + if (type === "human") + { + const userMessage = message as HumanMessage; + + return { + ...base, + role: "user", + content: userMessage.text, + }; + } + + if (type === "ai") + { + const assistantMessage = message as AIMessage; + + return { + ...base, + hidden: assistantMessage.text.length === 0, + role: "assistant", + content: assistantMessage.text, + }; + } + + if (type === "system") + { + return { + ...base, + role: type, + content: (message as BaseMessage).text, + }; + } + + return { + ...base, + role: "private", + hidden: true, + content: (message as BaseMessage).text, + privateData: message, + }; +} + +function mapSessionToChat(session: IChatSession): IChat +{ + const chat: IChat = { + id: session.id, + model: { + id: "langchain", + account: { + id: "langchain", + name: "langchain", + }, + name: session.chatServiceOptions.model, + provider: "langchain", + state: { ready: true }, + }, + workspace: { + workDir: session.workDir, + }, + toolMode: session.toolMode, + messages: session.messages.map(mapMessage), + state: "done", + }; + + return chat; +} + export { - chatServerClient, + chatServerClient, mapMessage, mapSessionToChat, }; + From d982f7ee72243df03fe0d3f01e7ec1c85ad29336 Mon Sep 17 00:00:00 2001 From: janole Date: Sun, 7 Sep 2025 22:25:12 +0200 Subject: [PATCH 040/110] fix: serialize session.setMessages calls to avoid race conditions and update send function accordingly --- src/ui/chat-controller.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/ui/chat-controller.ts b/src/ui/chat-controller.ts index b6f9cd9..eaac05e 100644 --- a/src/ui/chat-controller.ts +++ b/src/ui/chat-controller.ts @@ -55,6 +55,10 @@ export function useChatController(props: UseChatControllerProps) const abortController = new AbortController(); setAbortController(abortController); + messagesQueueRef.current = messagesQueueRef.current + .then(() => session.setMessages(messages, session.finished)) + .catch(() => { }); // keep the chain alive + work({ chatService, // TODO: refactor session.messages and setMessage/setState handling From 06013916ad7e4fc2c5baaadf3845a8c558105db6 Mon Sep 17 00:00:00 2001 From: janole Date: Sun, 7 Sep 2025 22:25:48 +0200 Subject: [PATCH 041/110] fix: update session data mapping when syncing with chat server client in file session storage --- src/ai/session/file-session-storage.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ai/session/file-session-storage.ts b/src/ai/session/file-session-storage.ts index 6dc6d24..08458ec 100644 --- a/src/ai/session/file-session-storage.ts +++ b/src/ai/session/file-session-storage.ts @@ -3,7 +3,7 @@ import { homedir } from "os"; import { join } from "path"; import writeFileAtomic from "write-file-atomic"; -import { chatServerClient } from "./chat-server/chat-server-client.js"; +import { chatServerClient, mapSessionToChat } from "./chat-server/chat-server-client.js"; import { ChatSession, IChatSession, ICreateChatSession, ISessionStorage, mapSessionDataToSession, mapSessionToSessionData } from "./session.js"; export class FileSessionStorage implements ISessionStorage @@ -46,7 +46,7 @@ export class FileSessionStorage implements ISessionStorage if (chatServerClient) { - await chatServerClient.upsertDocument(session.id, { data: session }); + await chatServerClient.upsertDocument(session.id, { data: mapSessionToChat(session) }); } } From 51d934827160681eadafa596fc9ef7a66f572c2f Mon Sep 17 00:00:00 2001 From: janole Date: Sun, 7 Sep 2025 22:26:30 +0200 Subject: [PATCH 042/110] fix: save session before rendering chat app to ensure state persistence --- src/commands/chat.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/commands/chat.tsx b/src/commands/chat.tsx index 78c045f..cac16db 100644 --- a/src/commands/chat.tsx +++ b/src/commands/chat.tsx @@ -67,6 +67,8 @@ async function chat(options: any) debug: options.debug, }; + await session.save(); + render(, { exitOnCtrlC: false }); } From 172f411cc5fa9297d5ee8395f7f4f7a85e0581cf Mon Sep 17 00:00:00 2001 From: janole Date: Sun, 7 Sep 2025 22:27:36 +0200 Subject: [PATCH 043/110] feat: add streaming messages upload to chat server and update send to async with inStream flag --- src/ai/work.ts | 40 +++++++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/src/ai/work.ts b/src/ai/work.ts index 955b8af..46483db 100644 --- a/src/ai/work.ts +++ b/src/ai/work.ts @@ -4,13 +4,14 @@ import { concat } from "@langchain/core/utils/stream"; import tryCatch from "../utils/try-catch.js"; import { ChatService } from "./chat-service.js"; import { ErrorMessage, TMessage, ToolProgressMessage } from "./custom-messages.js"; +import { chatServerClient } from "./session/chat-server/chat-server-client.js"; import { IChatSession } from "./session/session.js"; interface WorkProps { chatService: ChatService; session: IChatSession; - send?: (messages: TMessage[]) => void; + send?: (messages: TMessage[], inStream: boolean) => Promise; streaming?: boolean; signal?: AbortSignal; } @@ -39,13 +40,15 @@ async function work(props: WorkProps) return messages; } + let chunkNumber = 0; + let throttle = Date.now(); for await (const chunk of stream) { aiMessage = aiMessage !== undefined ? concat(aiMessage, chunk) : chunk; if (!aiMessage?.tool_calls?.length && !aiMessage?.tool_call_chunks?.length) { - send?.([...messages, aiMessage]); // TODO: check for race conditions + await send?.([...messages, aiMessage], true); // TODO: check for race conditions } else { @@ -53,9 +56,36 @@ async function work(props: WorkProps) if (toolProgressMessages.length) { - send?.([...messages, aiMessage, ...toolProgressMessages]); + await send?.([...messages, aiMessage, ...toolProgressMessages], true); } } + + if (chatServerClient && aiMessage.text) + { + if (Date.now() > throttle + 100) + { + await chatServerClient?.upsertDocument2(session.id + "/rt", { + data: { + type: "chunk", + external_id: session.id, + chat_id: session.id, + message_number: messages.length, + index: chunkNumber++, + content: aiMessage.text, + }, + }); + throttle = Date.now(); + } + + // await chatServerClient?.addDoc(session.id, { + // type: "chunk", + // external_id: session.id, + // chat_id: session.id, + // message_number: messages.length, + // index: chunkNumber++, + // content: chunk.text, + // }); + } } } else @@ -145,7 +175,7 @@ async function workTools(props: Pick(selectedTool.invoke(toolCall, { metadata })); @@ -165,7 +195,7 @@ async function workTools(props: Pick Date: Sun, 7 Sep 2025 22:38:13 +0200 Subject: [PATCH 044/110] chore: reset to pre-branch state --- src/ai/session/session.ts | 42 ++++++++------------------------------- 1 file changed, 8 insertions(+), 34 deletions(-) diff --git a/src/ai/session/session.ts b/src/ai/session/session.ts index df6adef..1f0c772 100644 --- a/src/ai/session/session.ts +++ b/src/ai/session/session.ts @@ -84,8 +84,6 @@ export interface ICreateChatSession extends Omit void; - export class ChatSession implements IChatSession { id: string; @@ -100,7 +98,6 @@ export class ChatSession implements IChatSession finished: number = 0; storage?: ISessionStorage; - private onUpdateListeners: TUpdateListener[] = []; private constructor(props: IChatSession, storage: ISessionStorage | undefined) { @@ -133,32 +130,19 @@ export class ChatSession implements IChatSession return this; } - private notifyListeners(): void - { - const props = { messages: [...this.messages], finished: this.finished }; - this.onUpdateListeners.forEach(listener => listener(props)); - } - - public onUpdate(listener: TUpdateListener): () => void + async setMessages(messages: TMessage[], finished: number, autoSave: boolean = true): Promise { - this.onUpdateListeners.push(listener); + const empty = messages.length === 0 && this.messages.length === 0; - return () => - { - this.onUpdateListeners = this.onUpdateListeners.filter(l => l !== listener); - }; - } - - async setMessages(messages: TMessage[], finished: number): Promise - { this.messages = messages; - this.finished = Math.min(finished, this.messages.length); + this.finished = finished; - this.notifyListeners(); + if (autoSave && !empty) + { + return this.save(); + } } - private saveQueue: Promise = Promise.resolve(); - async save(): Promise { if (!this.storage) @@ -166,16 +150,6 @@ export class ChatSession implements IChatSession throw new Error("No storage configured!"); } - this.saveQueue = this.saveQueue - .then(async () => - { - await this.storage!.saveSession(this); - }) - .catch((error) => - { - console.error("ERROR: Session save failed!", error); - }); - - return this.saveQueue; + return this.storage.saveSession(this); } } From 663cd02f2e3b5fcb168561a8fdb70a3a21a48260 Mon Sep 17 00:00:00 2001 From: janole Date: Sun, 7 Sep 2025 22:38:58 +0200 Subject: [PATCH 045/110] chore: reset to pre-branch state --- src/ai/custom-messages.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/ai/custom-messages.ts b/src/ai/custom-messages.ts index 8ef84c0..6556ff0 100644 --- a/src/ai/custom-messages.ts +++ b/src/ai/custom-messages.ts @@ -16,7 +16,7 @@ export class CustomMessage this._type = type; } - getType(): TCustomMessageType + getType(): string { return this._type; } @@ -154,4 +154,3 @@ export ErrorMessage, ToolProgressMessage, }; - From 1bc84b90737b16c2634a5a0d7e3b9863a5369d4f Mon Sep 17 00:00:00 2001 From: janole Date: Sun, 7 Sep 2025 22:42:51 +0200 Subject: [PATCH 046/110] feat: add broadcast and document management methods to ApiClient including addDoc and upsertDocument2 functions --- src/ai/session/chat-server/api-client.ts | 57 ++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/src/ai/session/chat-server/api-client.ts b/src/ai/session/chat-server/api-client.ts index 7f2ec49..a5191a5 100644 --- a/src/ai/session/chat-server/api-client.ts +++ b/src/ai/session/chat-server/api-client.ts @@ -328,6 +328,63 @@ class ApiClient } return this._request(`api/links?${searchParams.toString()}`, { method: "DELETE" }); } + + // --- Broadcast API --- + + broadcast(event: string, payload: { [key: string]: any }) + { + return this.channel?.send({ + type: "broadcast", + event, + payload, + }); + } + + // --- Test API --- + + private documentId: string | undefined; + + async addDoc(externalId: string, data: any) + { + if (!this.documentId) + { + const document = await this.getDocumentByExternalId(externalId); + + if (!document) + { + return; + } + + this.documentId = document.id; + } + + // Prepare the parameters for the RPC call + const rpcParams: { + data: any; + external_id?: string | null; + schema_id?: string | null; + links?: any[]; + } = { + data, + links: [{ + to_id: this.documentId, + type: "llm-chunk", + }], + }; + + await this.supabase?.rpc("create_document_with_links", rpcParams).select().single(); + } + + async upsertDocument2(externalId: string, documentData: { data: Record, schema_id?: string | null }) + { + const rpcParams = { + p_external_id: externalId, + p_data: documentData.data, + p_schema_id: documentData.schema_id, + } as const; + + await this.supabase?.rpc("upsert_document", rpcParams).select().single(); + } } export { ApiClient }; From 4fd0fa1fa64d393116042eac2528f9a0c3cf0806 Mon Sep 17 00:00:00 2001 From: janole Date: Sun, 7 Sep 2025 22:46:17 +0200 Subject: [PATCH 047/110] chore: add console log for upsert operation in file session storage --- src/ai/session/file-session-storage.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ai/session/file-session-storage.ts b/src/ai/session/file-session-storage.ts index 08458ec..c694b2e 100644 --- a/src/ai/session/file-session-storage.ts +++ b/src/ai/session/file-session-storage.ts @@ -46,6 +46,7 @@ export class FileSessionStorage implements ISessionStorage if (chatServerClient) { + console.log("UPSERT", session.messages.length, session.finished); await chatServerClient.upsertDocument(session.id, { data: mapSessionToChat(session) }); } } From 4026a2855adafea61f1b35b815cd426309fb1b38 Mon Sep 17 00:00:00 2001 From: janole Date: Sun, 7 Sep 2025 22:47:32 +0200 Subject: [PATCH 048/110] chore: reset to pre-branch state --- src/ai/session/file-session-storage.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/ai/session/file-session-storage.ts b/src/ai/session/file-session-storage.ts index c694b2e..ab9b63e 100644 --- a/src/ai/session/file-session-storage.ts +++ b/src/ai/session/file-session-storage.ts @@ -3,7 +3,6 @@ import { homedir } from "os"; import { join } from "path"; import writeFileAtomic from "write-file-atomic"; -import { chatServerClient, mapSessionToChat } from "./chat-server/chat-server-client.js"; import { ChatSession, IChatSession, ICreateChatSession, ISessionStorage, mapSessionDataToSession, mapSessionToSessionData } from "./session.js"; export class FileSessionStorage implements ISessionStorage @@ -43,12 +42,6 @@ export class FileSessionStorage implements ISessionStorage const sessionData = mapSessionToSessionData(session); await writeFileAtomic(filePath, JSON.stringify(sessionData, null, 2), "utf-8"); - - if (chatServerClient) - { - console.log("UPSERT", session.messages.length, session.finished); - await chatServerClient.upsertDocument(session.id, { data: mapSessionToChat(session) }); - } } private getSessionFilePath(sessionId: string): string From 78815bd0bc608852346fd5d4c369510be1f3c1df Mon Sep 17 00:00:00 2001 From: janole Date: Sun, 7 Sep 2025 22:48:50 +0200 Subject: [PATCH 049/110] chore: reset to pre-branch state --- src/ai/work.ts | 40 +++++----------------------------------- 1 file changed, 5 insertions(+), 35 deletions(-) diff --git a/src/ai/work.ts b/src/ai/work.ts index 46483db..955b8af 100644 --- a/src/ai/work.ts +++ b/src/ai/work.ts @@ -4,14 +4,13 @@ import { concat } from "@langchain/core/utils/stream"; import tryCatch from "../utils/try-catch.js"; import { ChatService } from "./chat-service.js"; import { ErrorMessage, TMessage, ToolProgressMessage } from "./custom-messages.js"; -import { chatServerClient } from "./session/chat-server/chat-server-client.js"; import { IChatSession } from "./session/session.js"; interface WorkProps { chatService: ChatService; session: IChatSession; - send?: (messages: TMessage[], inStream: boolean) => Promise; + send?: (messages: TMessage[]) => void; streaming?: boolean; signal?: AbortSignal; } @@ -40,15 +39,13 @@ async function work(props: WorkProps) return messages; } - let chunkNumber = 0; - let throttle = Date.now(); for await (const chunk of stream) { aiMessage = aiMessage !== undefined ? concat(aiMessage, chunk) : chunk; if (!aiMessage?.tool_calls?.length && !aiMessage?.tool_call_chunks?.length) { - await send?.([...messages, aiMessage], true); // TODO: check for race conditions + send?.([...messages, aiMessage]); // TODO: check for race conditions } else { @@ -56,36 +53,9 @@ async function work(props: WorkProps) if (toolProgressMessages.length) { - await send?.([...messages, aiMessage, ...toolProgressMessages], true); + send?.([...messages, aiMessage, ...toolProgressMessages]); } } - - if (chatServerClient && aiMessage.text) - { - if (Date.now() > throttle + 100) - { - await chatServerClient?.upsertDocument2(session.id + "/rt", { - data: { - type: "chunk", - external_id: session.id, - chat_id: session.id, - message_number: messages.length, - index: chunkNumber++, - content: aiMessage.text, - }, - }); - throttle = Date.now(); - } - - // await chatServerClient?.addDoc(session.id, { - // type: "chunk", - // external_id: session.id, - // chat_id: session.id, - // message_number: messages.length, - // index: chunkNumber++, - // content: chunk.text, - // }); - } } } else @@ -175,7 +145,7 @@ async function workTools(props: Pick(selectedTool.invoke(toolCall, { metadata })); @@ -195,7 +165,7 @@ async function workTools(props: Pick Date: Sun, 7 Sep 2025 22:51:38 +0200 Subject: [PATCH 050/110] chore: reset to pre-branch state --- src/ui/chat-controller.ts | 57 ++++----------------------------------- 1 file changed, 5 insertions(+), 52 deletions(-) diff --git a/src/ui/chat-controller.ts b/src/ui/chat-controller.ts index eaac05e..faa7f92 100644 --- a/src/ui/chat-controller.ts +++ b/src/ui/chat-controller.ts @@ -1,11 +1,9 @@ -import { HumanMessage } from "@langchain/core/messages"; import clipboard from "clipboardy"; import { Key, useApp } from "ink"; import { useCallback, useEffect, useState } from "react"; import { ChatService } from "../ai/chat-service.js"; import { ErrorMessage, TMessage, ToolProgressMessage } from "../ai/custom-messages.js"; -import { chatServerClient } from "../ai/session/chat-server/chat-server-client.js"; import { ChatSession } from "../ai/session/session.js"; import { countTokens, lastMessageFromAI, TTokenUsage } from "../ai/tokens.js"; import { getGitBranch } from "../ai/tools/git-tools.js"; @@ -36,8 +34,6 @@ export function useChatController(props: UseChatControllerProps) finished: session.finished || 0, }); - const messagesQueueRef = useRef>(Promise.resolve()); - const [tokenUsage, setTokenUsage] = useState(); const [currentGitBranch, setCurrentGitBranch] = useState(); @@ -55,14 +51,12 @@ export function useChatController(props: UseChatControllerProps) const abortController = new AbortController(); setAbortController(abortController); - messagesQueueRef.current = messagesQueueRef.current - .then(() => session.setMessages(messages, session.finished)) - .catch(() => { }); // keep the chain alive + // TODO: refactor session.messages and setMessage/setState handling + session.setMessages(messages, messages.length, false); work({ chatService, - // TODO: refactor session.messages and setMessage/setState handling - session: { ...session, messages, finished: messages.length }, + session, streaming, send: (messages: TMessage[]) => session.setMessages(messages, session.finished), signal: abortController.signal, @@ -91,13 +85,9 @@ export function useChatController(props: UseChatControllerProps) { setWorking(false); }); - }, [ - chatService, - session, - streaming, - ]); + }; - const handleInput = useCallback((input: string, key: Key): boolean => + const handleInput = (input: string, key: Key): boolean => { if (key.ctrl && input === "c") { @@ -201,8 +191,6 @@ export function useChatController(props: UseChatControllerProps) working, ]); - const statusRef = useRef<"idle" | "working">(null); - useEffect(() => { if (!working) @@ -212,15 +200,6 @@ export function useChatController(props: UseChatControllerProps) setTokenUsage(countTokens(chatHistory.messages)); getGitBranch().then(branch => setCurrentGitBranch(branch)); - - if (statusRef.current !== "idle") - { - chatServerClient?.setStatus(statusRef.current = "idle", session.id); - } - } - else if (statusRef.current !== "working") - { - chatServerClient?.setStatus(statusRef.current = "working", session.id); } }, [ working, @@ -228,32 +207,6 @@ export function useChatController(props: UseChatControllerProps) session, ]); - useEffect(() => - { - const commandListener = (command: any) => - { - if (command.external_id === session.id) - { - const message = command.message?.trim(); - - if (message && !working) - { - const messages = [...chatHistory.messages, new HumanMessage(message)]; - handleSendHistory(messages, messages.length); - } - } - }; - - chatServerClient?.addCommandListener(commandListener); - - return () => { chatServerClient?.removeCommandListener(commandListener); }; - }, [ - session.id, - working, - chatHistory, - handleSendHistory, - ]); - // TODO: refactor const action = ctrlC ? working From 1611f65dc296e3f3dc7b1ddbc828bd3064bcc87b Mon Sep 17 00:00:00 2001 From: janole Date: Sun, 7 Sep 2025 23:01:57 +0200 Subject: [PATCH 051/110] chore: revert to main --- src/ai/custom-messages.ts | 3 ++- src/ai/session/session.ts | 42 +++++++++++++++++++++++++++++++-------- src/ui/chat-controller.ts | 11 +++++----- 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/src/ai/custom-messages.ts b/src/ai/custom-messages.ts index 6556ff0..8ef84c0 100644 --- a/src/ai/custom-messages.ts +++ b/src/ai/custom-messages.ts @@ -16,7 +16,7 @@ export class CustomMessage this._type = type; } - getType(): string + getType(): TCustomMessageType { return this._type; } @@ -154,3 +154,4 @@ export ErrorMessage, ToolProgressMessage, }; + diff --git a/src/ai/session/session.ts b/src/ai/session/session.ts index 1f0c772..df6adef 100644 --- a/src/ai/session/session.ts +++ b/src/ai/session/session.ts @@ -84,6 +84,8 @@ export interface ICreateChatSession extends Omit void; + export class ChatSession implements IChatSession { id: string; @@ -98,6 +100,7 @@ export class ChatSession implements IChatSession finished: number = 0; storage?: ISessionStorage; + private onUpdateListeners: TUpdateListener[] = []; private constructor(props: IChatSession, storage: ISessionStorage | undefined) { @@ -130,19 +133,32 @@ export class ChatSession implements IChatSession return this; } - async setMessages(messages: TMessage[], finished: number, autoSave: boolean = true): Promise + private notifyListeners(): void { - const empty = messages.length === 0 && this.messages.length === 0; + const props = { messages: [...this.messages], finished: this.finished }; + this.onUpdateListeners.forEach(listener => listener(props)); + } - this.messages = messages; - this.finished = finished; + public onUpdate(listener: TUpdateListener): () => void + { + this.onUpdateListeners.push(listener); - if (autoSave && !empty) + return () => { - return this.save(); - } + this.onUpdateListeners = this.onUpdateListeners.filter(l => l !== listener); + }; } + async setMessages(messages: TMessage[], finished: number): Promise + { + this.messages = messages; + this.finished = Math.min(finished, this.messages.length); + + this.notifyListeners(); + } + + private saveQueue: Promise = Promise.resolve(); + async save(): Promise { if (!this.storage) @@ -150,6 +166,16 @@ export class ChatSession implements IChatSession throw new Error("No storage configured!"); } - return this.storage.saveSession(this); + this.saveQueue = this.saveQueue + .then(async () => + { + await this.storage!.saveSession(this); + }) + .catch((error) => + { + console.error("ERROR: Session save failed!", error); + }); + + return this.saveQueue; } } diff --git a/src/ui/chat-controller.ts b/src/ui/chat-controller.ts index faa7f92..3374364 100644 --- a/src/ui/chat-controller.ts +++ b/src/ui/chat-controller.ts @@ -51,9 +51,6 @@ export function useChatController(props: UseChatControllerProps) const abortController = new AbortController(); setAbortController(abortController); - // TODO: refactor session.messages and setMessage/setState handling - session.setMessages(messages, messages.length, false); - work({ chatService, session, @@ -85,9 +82,13 @@ export function useChatController(props: UseChatControllerProps) { setWorking(false); }); - }; + }, [ + chatService, + session, + streaming, + ]); - const handleInput = (input: string, key: Key): boolean => + const handleInput = useCallback((input: string, key: Key): boolean => { if (key.ctrl && input === "c") { From a9e898ee58876c415490871ba0ec822ef24240df Mon Sep 17 00:00:00 2001 From: janole Date: Mon, 8 Sep 2025 15:30:48 +0200 Subject: [PATCH 052/110] refactor: replace send callback with session.setMessages to manage message updates and improve streaming state handling in work and workTools functions --- src/ai/work.ts | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/ai/work.ts b/src/ai/work.ts index 955b8af..7bd49e4 100644 --- a/src/ai/work.ts +++ b/src/ai/work.ts @@ -3,21 +3,20 @@ import { concat } from "@langchain/core/utils/stream"; import tryCatch from "../utils/try-catch.js"; import { ChatService } from "./chat-service.js"; -import { ErrorMessage, TMessage, ToolProgressMessage } from "./custom-messages.js"; -import { IChatSession } from "./session/session.js"; +import { ErrorMessage, setMessageIsStreaming, TMessage, ToolProgressMessage } from "./custom-messages.js"; +import { ChatSession } from "./session/session.js"; interface WorkProps { chatService: ChatService; - session: IChatSession; - send?: (messages: TMessage[]) => void; + session: ChatSession; streaming?: boolean; signal?: AbortSignal; } async function work(props: WorkProps) { - const { session, chatService, send, streaming = true, signal } = props; + const { session, chatService, streaming = true, signal } = props; const messages = await workTools({ ...props, session }); @@ -42,10 +41,11 @@ async function work(props: WorkProps) for await (const chunk of stream) { aiMessage = aiMessage !== undefined ? concat(aiMessage, chunk) : chunk; + setMessageIsStreaming(aiMessage, true); if (!aiMessage?.tool_calls?.length && !aiMessage?.tool_call_chunks?.length) { - send?.([...messages, aiMessage]); // TODO: check for race conditions + session.setMessages([...messages, aiMessage]); } else { @@ -53,7 +53,7 @@ async function work(props: WorkProps) if (toolProgressMessages.length) { - send?.([...messages, aiMessage, ...toolProgressMessages]); + session.setMessages([...messages, aiMessage, ...toolProgressMessages]); } } } @@ -73,6 +73,7 @@ async function work(props: WorkProps) if (aiMessage) { + setMessageIsStreaming(aiMessage, false); messages.push(aiMessage); } @@ -85,12 +86,14 @@ async function work(props: WorkProps) toolProgressMessages = aiMessage.tool_calls.map(toolCall => new ToolProgressMessage(toolCall)) || []; messages.push(...toolProgressMessages); - return work({ ...props, session: { ...session, messages } }); + session.setMessages(messages); + + return work(props); } -async function workTools(props: Pick) +async function workTools(props: Pick) { - const { session: { workDir, messages, toolMode }, chatService, send } = props; + const { session: { workDir, messages, toolMode, setMessages }, chatService } = props; const toolProgressMessages = messages.filter(m => ToolProgressMessage.isTypeOf(m) && (m.status === "pending" || m.status === "confirmed" || m.status === "declined")) as ToolProgressMessage[]; @@ -145,7 +148,7 @@ async function workTools(props: Pick(selectedTool.invoke(toolCall, { metadata })); @@ -165,12 +168,12 @@ async function workTools(props: Pick ToolProgressMessage.isTypeOf(m) && m.status === "pending-confirmation"); } From a6cb90be681d3939453d36bc5d8210a748dd28a8 Mon Sep 17 00:00:00 2001 From: janole Date: Mon, 8 Sep 2025 15:30:55 +0200 Subject: [PATCH 053/110] refactor: make CustomMessage abstract and add isMessageFinished method with streaming state helpers --- src/ai/custom-messages.ts | 59 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/src/ai/custom-messages.ts b/src/ai/custom-messages.ts index 8ef84c0..e8ded79 100644 --- a/src/ai/custom-messages.ts +++ b/src/ai/custom-messages.ts @@ -7,7 +7,7 @@ type TCustomMessageType = "error" | "tool-progress"; export type TMessageType = ReturnType | TCustomMessageType; -export class CustomMessage +export abstract class CustomMessage { _type: TCustomMessageType; @@ -21,6 +21,8 @@ export class CustomMessage return this._type; } + abstract isMessageFinished(): boolean; + static fromObject(obj: any): CustomMessage { if (obj._type === "error") @@ -61,6 +63,11 @@ class ErrorMessage extends CustomMessage }; } } + + override isMessageFinished(): boolean + { + return true; + } } class ToolProgressMessage extends CustomMessage @@ -133,6 +140,11 @@ class ToolProgressMessage extends CustomMessage this.content = content; this.confirmState = confirmState || "no"; } + + override isMessageFinished(): boolean + { + return this.status !== "pending" && this.status !== "pending-confirmation"; + } } /** @@ -149,9 +161,54 @@ export const isCustomMessage = (message: any): message is CustomMessage => ); }; +// const getMessageState = (msg: TMessage): TMessageState => +// { +// if (isCustomMessage(msg)) +// { +// return msg._state; +// } + +// return (msg as BaseMessage).additional_kwargs["_coba_message_state"] as TMessageState; +// }; + +// const setMessageState = (msg: TMessage, state: TMessageState) => +// { +// if (isCustomMessage(msg)) +// { +// msg._state = state; +// } +// else +// { +// (msg as BaseMessage).additional_kwargs["_coba_message_state"] = state; +// } +// }; + +const setMessageIsStreaming = (msg: BaseMessage, isStreaming: boolean) => +{ + msg.additional_kwargs["_coba_message_is_streaming"] = isStreaming; +}; + +const isMessageStreaming = (msg: TMessage): boolean => +{ + return (msg instanceof BaseMessage) && (msg as BaseMessage).additional_kwargs["_coba_message_is_streaming"] === true; +}; + +const isMessageFinished = (msg: TMessage): boolean => +{ + if (msg instanceof BaseMessage) + { + return !isMessageStreaming(msg); + } + + return msg.isMessageFinished(); +}; + export { ErrorMessage, + isMessageFinished, + isMessageStreaming, + setMessageIsStreaming, ToolProgressMessage, }; From c0c1a2c078796029b83d59d9612b120e5898e57b Mon Sep 17 00:00:00 2001 From: janole Date: Mon, 8 Sep 2025 15:31:03 +0200 Subject: [PATCH 054/110] refactor: clean up chat-controller by removing unnecessary params, simplifying state, and updating session handling and status reporting --- src/ui/chat-controller.ts | 57 ++++++++++++--------------------------- 1 file changed, 17 insertions(+), 40 deletions(-) diff --git a/src/ui/chat-controller.ts b/src/ui/chat-controller.ts index 3374364..5a543f5 100644 --- a/src/ui/chat-controller.ts +++ b/src/ui/chat-controller.ts @@ -4,10 +4,11 @@ import { useCallback, useEffect, useState } from "react"; import { ChatService } from "../ai/chat-service.js"; import { ErrorMessage, TMessage, ToolProgressMessage } from "../ai/custom-messages.js"; +import { chatServerClient } from "../ai/session/chat-server/chat-server-client.js"; import { ChatSession } from "../ai/session/session.js"; import { countTokens, lastMessageFromAI, TTokenUsage } from "../ai/tokens.js"; import { getGitBranch } from "../ai/tools/git-tools.js"; -import { needsToolConfirmation, work } from "../ai/work.js"; +import { work } from "../ai/work.js"; interface UseChatControllerProps { @@ -29,9 +30,8 @@ export function useChatController(props: UseChatControllerProps) const [_, setToolMode] = useState(session.toolMode); - const [chatHistory, setChatHistory] = useState<{ messages: TMessage[]; finished: number }>({ + const [chatHistory, setChatHistory] = useState<{ messages: TMessage[] }>({ messages: session.messages, - finished: session.finished || 0, }); const [tokenUsage, setTokenUsage] = useState(); @@ -43,9 +43,9 @@ export function useChatController(props: UseChatControllerProps) // This effect subscribes to the session, making it the single source of truth. useEffect(() => session.onUpdate(setChatHistory), [session]); - const handleSendHistory = useCallback((messages: TMessage[], finished?: number) => + const handleSendHistory = useCallback((messages: TMessage[]) => { - session.setMessages(messages, Math.max(finished || 0, session.finished)); + session.setMessages(messages); setWorking(true); const abortController = new AbortController(); @@ -55,20 +55,11 @@ export function useChatController(props: UseChatControllerProps) chatService, session, streaming, - send: (messages: TMessage[]) => session.setMessages(messages, session.finished), signal: abortController.signal, }) .then(messages => { - if (needsToolConfirmation(messages)) - { - // Update the session, which will trigger the UI update. - session.setMessages(messages, session.finished); - } - else - { - session.setMessages(messages, messages.length); - } + session.setMessages(messages); }) .catch(error => { @@ -76,7 +67,7 @@ export function useChatController(props: UseChatControllerProps) ...session.messages, new ErrorMessage(`ERROR: running work({...}) failed with: ${error.message || error.toString()}`, error), ]; - session.setMessages(newMessages, newMessages.length); + session.setMessages(newMessages); }) .finally(() => { @@ -120,18 +111,9 @@ export function useChatController(props: UseChatControllerProps) { if (key.leftArrow || key.rightArrow) { - const direction = key.leftArrow ? -1 : 1; - - // Instead of setChatHistory, we now update the session directly. - const newMessages = [ - ...session.messages.slice(0, selectedIndex), - (session.messages[selectedIndex] as ToolProgressMessage).toggleConfirmState({ direction }), - ...session.messages.slice(selectedIndex + 1), - ]; - session.setMessages(newMessages, session.finished); + session.toggleConfirmState(selectedIndex, key.leftArrow ? -1 : 1); } - - if (key.return) + else if (key.return) { const confirmState = (session.messages[selectedIndex] as ToolProgressMessage).confirmState; @@ -187,25 +169,20 @@ export function useChatController(props: UseChatControllerProps) { setAbortController(undefined); setCtrlC(false); - } - }, [ - working, - ]); - - useEffect(() => - { - if (!working) - { - session.save(); - setTokenUsage(countTokens(chatHistory.messages)); + setTokenUsage(countTokens(session.messages)); getGitBranch().then(branch => setCurrentGitBranch(branch)); + + chatServerClient?.setStatus("idle", session.id); + } + else + { + chatServerClient?.setStatus("working", session.id); } }, [ - working, - chatHistory.messages, session, + working, ]); // TODO: refactor From 9be21fbaec375d0cad8d4dcbd92c80ea434281ff Mon Sep 17 00:00:00 2001 From: janole Date: Mon, 8 Sep 2025 15:31:07 +0200 Subject: [PATCH 055/110] refactor: dynamically determine finished messages in chat display rendering --- src/ui/chat-app.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/ui/chat-app.tsx b/src/ui/chat-app.tsx index 03e1ec0..d8129ab 100644 --- a/src/ui/chat-app.tsx +++ b/src/ui/chat-app.tsx @@ -5,6 +5,7 @@ import { homedir } from "os"; import React, { useEffect, useState } from "react"; import { ChatService } from "../ai/chat-service.js"; +import { isMessageFinished } from "../ai/custom-messages.js"; import { ChatSession } from "../ai/session/session.js"; import useTerminalSize from "../utils/use-terminal-size.js"; import { useChatController } from "./chat-controller.js"; @@ -59,29 +60,32 @@ function ChatApp(props: ChatAppProps) if (_message.trim() && !working) { const messages = [...chatHistory.messages, new HumanMessage(_message)]; - handleSendHistory(messages, messages.length); + handleSendHistory(messages); setMessage(""); } }; const terminalSize = useTerminalSize(); + let finished = chatHistory.messages.findIndex(m => !isMessageFinished(m)); + if (finished === -1) { finished = chatHistory.messages.length; } + return ( {/* Messages Area */} - + {(message, index) => ( )} - {chatHistory.messages.slice(chatHistory.finished).map((workingItem, index) => ( + {chatHistory.messages.slice(finished).map((workingItem, index) => ( ))} From 3b5ff79ff047be7efdf7d978dac0e7aaeb98fec8 Mon Sep 17 00:00:00 2001 From: janole Date: Mon, 8 Sep 2025 15:31:18 +0200 Subject: [PATCH 056/110] refactor: remove finished property from session and update save logic to support online sync with concurrency control and message state toggling --- src/ai/session/session.ts | 62 ++++++++++++++++++++++++++++++--------- 1 file changed, 48 insertions(+), 14 deletions(-) diff --git a/src/ai/session/session.ts b/src/ai/session/session.ts index df6adef..3a76e22 100644 --- a/src/ai/session/session.ts +++ b/src/ai/session/session.ts @@ -2,7 +2,8 @@ import { mapChatMessagesToStoredMessages, mapStoredMessageToChatMessage, StoredM import { ulid } from "ulid"; import { IChatServiceOptions } from "../chat-service.js"; -import { CustomMessage, isCustomMessage, TMessage } from "../custom-messages.js"; +import { CustomMessage, isCustomMessage, isMessageStreaming, TMessage, ToolProgressMessage } from "../custom-messages.js"; +import { chatServerClient, mapSessionToChat } from "./chat-server/chat-server-client.js"; export type TToolMode = "confirm" | "read-only" | "yolo"; @@ -46,7 +47,6 @@ export function mapSessionToSessionData(session: IChatSession) chatServiceOptions: session.chatServiceOptions, systemPrompt: session.systemPrompt, messages: session.messages.map(mapMessageToObject).filter(m => m), - finished: session.finished, }; } @@ -59,7 +59,6 @@ export function mapSessionDataToSession(data: any): IChatSession chatServiceOptions: data.chatServiceOptions, systemPrompt: data.systemPrompt, messages: data.messages.map(mapObjectToMessage).filter((m: TMessage | undefined) => m), - finished: data.finished, }; } @@ -74,14 +73,12 @@ export interface IChatSession systemPrompt?: string; // TODO: extend into service or template like "%{DEFAULT}% ..." messages: TMessage[]; - finished: number; } -export interface ICreateChatSession extends Omit +export interface ICreateChatSession extends Omit { id?: IChatSession["id"]; messages?: IChatSession["messages"]; - finished?: IChatSession["finished"]; } type TUpdateListener = (props: { messages: TMessage[]; finished: number; }) => void; @@ -110,7 +107,6 @@ export class ChatSession implements IChatSession this.chatServiceOptions = props.chatServiceOptions; this.systemPrompt = props.systemPrompt; this.messages = props.messages || []; - this.finished = Math.min(props.finished || 0, this.messages.length); this.storage = storage; } @@ -121,7 +117,6 @@ export class ChatSession implements IChatSession ...props, id: props.id || ulid(), messages: props.messages || [], - finished: props.finished || 0, }, storage); return chatSession; @@ -149,24 +144,40 @@ export class ChatSession implements IChatSession }; } - async setMessages(messages: TMessage[], finished: number): Promise + async setMessages(messages: TMessage[]): Promise { - this.messages = messages; - this.finished = Math.min(finished, this.messages.length); + this.messages = [...messages]; + + this._saveOnline(); this.notifyListeners(); } - private saveQueue: Promise = Promise.resolve(); + async toggleConfirmState(messageIndex: number, direction: -1 | 1): Promise + { + if (ToolProgressMessage.isTypeOf(this.messages[messageIndex])) + { + const newMessages = [ + ...this.messages.slice(0, messageIndex), + (this.messages[messageIndex] as ToolProgressMessage).toggleConfirmState({ direction }), + ...this.messages.slice(messageIndex + 1), + ]; + this.setMessages(newMessages); + } + } + + private _saveQueue: Promise = Promise.resolve(); async save(): Promise { + this._saveOnline(); + if (!this.storage) { throw new Error("No storage configured!"); } - this.saveQueue = this.saveQueue + this._saveQueue = this._saveQueue .then(async () => { await this.storage!.saveSession(this); @@ -176,6 +187,29 @@ export class ChatSession implements IChatSession console.error("ERROR: Session save failed!", error); }); - return this.saveQueue; + return this._saveQueue; + } + + private _saveOnlineQueue: Promise = Promise.resolve(); + private _saveOnline() + { + this._saveOnlineQueue = this._saveOnlineQueue + .then(async () => + { + console.log("SAVE ONLINE?"); + if (this.messages.find(m => isMessageStreaming(m))) + { + return; + } + + await chatServerClient?.upsertDocument(this.id, { data: mapSessionToChat(this) }); + console.log("SAVED ONLINE!"); + }) + .catch((error) => + { + console.error("ERROR: Session save failed!", error); + }); + + return this._saveOnlineQueue; } } From bd4a643a743874a7080f10399a15959ab4564399 Mon Sep 17 00:00:00 2001 From: janole Date: Mon, 8 Sep 2025 15:38:26 +0200 Subject: [PATCH 057/110] refactor: convert setMessages method to arrow function for consistent this context handling --- src/ai/session/session.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ai/session/session.ts b/src/ai/session/session.ts index 3a76e22..24d93b9 100644 --- a/src/ai/session/session.ts +++ b/src/ai/session/session.ts @@ -144,7 +144,7 @@ export class ChatSession implements IChatSession }; } - async setMessages(messages: TMessage[]): Promise + setMessages = async (messages: TMessage[]): Promise => { this.messages = [...messages]; From f4638c989345ece67e899ba8c0dc506cdc4e38a5 Mon Sep 17 00:00:00 2001 From: janole Date: Mon, 8 Sep 2025 15:42:59 +0200 Subject: [PATCH 058/110] refactor: convert setStorage method to an arrow function in ChatSession class --- src/ai/session/session.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ai/session/session.ts b/src/ai/session/session.ts index 24d93b9..3aff058 100644 --- a/src/ai/session/session.ts +++ b/src/ai/session/session.ts @@ -122,11 +122,11 @@ export class ChatSession implements IChatSession return chatSession; } - setStorage(storage: ISessionStorage): ChatSession + setStorage = (storage: ISessionStorage): ChatSession => { this.storage = storage; return this; - } + }; private notifyListeners(): void { From a94fe82e3ba7427fc93e71931d777bfd8b2bdf08 Mon Sep 17 00:00:00 2001 From: janole Date: Mon, 8 Sep 2025 15:43:15 +0200 Subject: [PATCH 059/110] refactor: change notifyListeners method to public arrow function to preserve context and simplify usage --- src/ai/session/session.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ai/session/session.ts b/src/ai/session/session.ts index 3aff058..f678be5 100644 --- a/src/ai/session/session.ts +++ b/src/ai/session/session.ts @@ -128,11 +128,11 @@ export class ChatSession implements IChatSession return this; }; - private notifyListeners(): void + notifyListeners = (): void => { const props = { messages: [...this.messages], finished: this.finished }; this.onUpdateListeners.forEach(listener => listener(props)); - } + }; public onUpdate(listener: TUpdateListener): () => void { From 5882de8c2465bc99a25972557483271960014a17 Mon Sep 17 00:00:00 2001 From: janole Date: Mon, 8 Sep 2025 15:43:28 +0200 Subject: [PATCH 060/110] refactor: convert onUpdate method to arrow function and update syntax for consistency --- src/ai/session/session.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ai/session/session.ts b/src/ai/session/session.ts index f678be5..2d819d1 100644 --- a/src/ai/session/session.ts +++ b/src/ai/session/session.ts @@ -134,7 +134,7 @@ export class ChatSession implements IChatSession this.onUpdateListeners.forEach(listener => listener(props)); }; - public onUpdate(listener: TUpdateListener): () => void + onUpdate = (listener: TUpdateListener): (() => void) => { this.onUpdateListeners.push(listener); @@ -142,7 +142,7 @@ export class ChatSession implements IChatSession { this.onUpdateListeners = this.onUpdateListeners.filter(l => l !== listener); }; - } + }; setMessages = async (messages: TMessage[]): Promise => { From 575e60b3d166494ecf83dd06a4b66914cc03d5c6 Mon Sep 17 00:00:00 2001 From: janole Date: Mon, 8 Sep 2025 15:43:36 +0200 Subject: [PATCH 061/110] fix: add missing semicolon to method definition in session.ts --- src/ai/session/session.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ai/session/session.ts b/src/ai/session/session.ts index 2d819d1..90efefd 100644 --- a/src/ai/session/session.ts +++ b/src/ai/session/session.ts @@ -151,7 +151,7 @@ export class ChatSession implements IChatSession this._saveOnline(); this.notifyListeners(); - } + }; async toggleConfirmState(messageIndex: number, direction: -1 | 1): Promise { From 441c7584a7020c432dacedc774b2508780bf9f20 Mon Sep 17 00:00:00 2001 From: janole Date: Mon, 8 Sep 2025 15:44:49 +0200 Subject: [PATCH 062/110] refactor: convert toggleConfirmState method to arrow function to preserve this context --- src/ai/session/session.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/ai/session/session.ts b/src/ai/session/session.ts index 90efefd..1ef1ba5 100644 --- a/src/ai/session/session.ts +++ b/src/ai/session/session.ts @@ -153,7 +153,7 @@ export class ChatSession implements IChatSession this.notifyListeners(); }; - async toggleConfirmState(messageIndex: number, direction: -1 | 1): Promise + toggleConfirmState = async (messageIndex: number, direction: -1 | 1): Promise => { if (ToolProgressMessage.isTypeOf(this.messages[messageIndex])) { @@ -165,7 +165,8 @@ export class ChatSession implements IChatSession this.setMessages(newMessages); } - } + }; + private _saveQueue: Promise = Promise.resolve(); async save(): Promise From 4909657dd1bc29cc21ab10af026672618a386653 Mon Sep 17 00:00:00 2001 From: janole Date: Mon, 8 Sep 2025 15:45:14 +0200 Subject: [PATCH 063/110] refactor: change save method to an arrow function and update promise type for _saveQueue --- src/ai/session/session.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/ai/session/session.ts b/src/ai/session/session.ts index 1ef1ba5..e6d9b2a 100644 --- a/src/ai/session/session.ts +++ b/src/ai/session/session.ts @@ -167,9 +167,8 @@ export class ChatSession implements IChatSession } }; - - private _saveQueue: Promise = Promise.resolve(); - async save(): Promise + private _saveQueue: Promise = Promise.resolve(); + save = async (): Promise => { this._saveOnline(); @@ -189,7 +188,7 @@ export class ChatSession implements IChatSession }); return this._saveQueue; - } + }; private _saveOnlineQueue: Promise = Promise.resolve(); private _saveOnline() From 5b8f320e0853fe2c46e1d0499d7af422828c06b0 Mon Sep 17 00:00:00 2001 From: janole Date: Mon, 8 Sep 2025 15:45:30 +0200 Subject: [PATCH 064/110] refactor: update _saveOnlineQueue type and convert _saveOnline to arrow function for proper this binding --- src/ai/session/session.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ai/session/session.ts b/src/ai/session/session.ts index e6d9b2a..ed8f7af 100644 --- a/src/ai/session/session.ts +++ b/src/ai/session/session.ts @@ -190,8 +190,8 @@ export class ChatSession implements IChatSession return this._saveQueue; }; - private _saveOnlineQueue: Promise = Promise.resolve(); - private _saveOnline() + private _saveOnlineQueue: Promise = Promise.resolve(); + _saveOnline = (): Promise => { this._saveOnlineQueue = this._saveOnlineQueue .then(async () => @@ -211,5 +211,5 @@ export class ChatSession implements IChatSession }); return this._saveOnlineQueue; - } + }; } From 51b637c482952cc9a617e183d15cff8ec8363f5c Mon Sep 17 00:00:00 2001 From: janole Date: Mon, 8 Sep 2025 16:31:07 +0200 Subject: [PATCH 065/110] fix: await asynchronous setMessages call to ensure proper message update completion --- src/ai/session/session.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ai/session/session.ts b/src/ai/session/session.ts index ed8f7af..06b46d7 100644 --- a/src/ai/session/session.ts +++ b/src/ai/session/session.ts @@ -163,7 +163,7 @@ export class ChatSession implements IChatSession ...this.messages.slice(messageIndex + 1), ]; - this.setMessages(newMessages); + await this.setMessages(newMessages); } }; From 85a0009be53c8558981cb4b2465f28925f9bf7c8 Mon Sep 17 00:00:00 2001 From: janole Date: Mon, 8 Sep 2025 16:32:04 +0200 Subject: [PATCH 066/110] feat: add no-invalid-this rule to eslint configuration for TypeScript --- eslint.config.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/eslint.config.js b/eslint.config.js index 66b3f90..8495246 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -39,6 +39,8 @@ export default [ format: ["camelCase", "PascalCase"], }], + "@typescript-eslint/no-invalid-this": ["error"], + "@stylistic/brace-style": ["error", "allman", { allowSingleLine: true }], "@stylistic/quotes": ["error", "double"], "@stylistic/comma-dangle": ["error", "always-multiline"], From a2a59cea51ab9123a4234989fef002014882fef0 Mon Sep 17 00:00:00 2001 From: janole Date: Mon, 8 Sep 2025 16:58:50 +0200 Subject: [PATCH 067/110] feat: add flush method to await save queues for online and storage options in ChatSession --- src/ai/session/session.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/ai/session/session.ts b/src/ai/session/session.ts index 06b46d7..1662937 100644 --- a/src/ai/session/session.ts +++ b/src/ai/session/session.ts @@ -212,4 +212,19 @@ export class ChatSession implements IChatSession return this._saveOnlineQueue; }; + + flush = async (options: { storage?: boolean; online?: boolean } = {}): Promise => + { + // Await online queue (for chatServerClient saves) + if (options.online !== false) + { + await this._saveOnlineQueue; + } + + // Await storage queue if configured and requested + if (options.storage !== false && this.storage) + { + await this._saveQueue; + } + }; } From d6cda33db8d6732312345316689827a5a2c05d24 Mon Sep 17 00:00:00 2001 From: janole Date: Mon, 8 Sep 2025 16:59:42 +0200 Subject: [PATCH 068/110] refactor: simplify flush method by removing conditional options and always awaiting save queues --- src/ai/session/session.ts | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/src/ai/session/session.ts b/src/ai/session/session.ts index 1662937..183fe9c 100644 --- a/src/ai/session/session.ts +++ b/src/ai/session/session.ts @@ -213,18 +213,9 @@ export class ChatSession implements IChatSession return this._saveOnlineQueue; }; - flush = async (options: { storage?: boolean; online?: boolean } = {}): Promise => + flush = async (): Promise => { - // Await online queue (for chatServerClient saves) - if (options.online !== false) - { - await this._saveOnlineQueue; - } - - // Await storage queue if configured and requested - if (options.storage !== false && this.storage) - { - await this._saveQueue; - } + await this._saveOnlineQueue; + await this._saveQueue; }; } From 9482fa49e7c8689ed047f5c464e455e98dd33b49 Mon Sep 17 00:00:00 2001 From: janole Date: Mon, 8 Sep 2025 16:59:54 +0200 Subject: [PATCH 069/110] fix: return a new array copy of messages when toolProgressMessages is empty to avoid side effects --- src/ai/work.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ai/work.ts b/src/ai/work.ts index 7bd49e4..7505940 100644 --- a/src/ai/work.ts +++ b/src/ai/work.ts @@ -99,7 +99,7 @@ async function workTools(props: Pick Date: Mon, 8 Sep 2025 17:02:20 +0200 Subject: [PATCH 070/110] refactor: streamline session message handling and tool mode checks in work and workTools functions --- src/ai/work.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/ai/work.ts b/src/ai/work.ts index 7505940..8067827 100644 --- a/src/ai/work.ts +++ b/src/ai/work.ts @@ -84,25 +84,27 @@ async function work(props: WorkProps) // add pending toolCall(s) toolProgressMessages = aiMessage.tool_calls.map(toolCall => new ToolProgressMessage(toolCall)) || []; - messages.push(...toolProgressMessages); + session.setMessages([...messages, ...toolProgressMessages]); - session.setMessages(messages); + await session.flush(); return work(props); } async function workTools(props: Pick) { - const { session: { workDir, messages, toolMode, setMessages }, chatService } = props; + const { session, chatService } = props; + + const messages = [...session.messages]; const toolProgressMessages = messages.filter(m => ToolProgressMessage.isTypeOf(m) && (m.status === "pending" || m.status === "confirmed" || m.status === "declined")) as ToolProgressMessage[]; if (toolProgressMessages.length === 0) { - return [...messages]; + return messages; } - const metadata = { workDir }; + const metadata = { workDir: session.workDir }; for (const toolProgressMessage of toolProgressMessages) { @@ -121,7 +123,7 @@ async function workTools(props: Pick(selectedTool.invoke(toolCall, { metadata })); @@ -168,9 +170,9 @@ async function workTools(props: Pick Date: Mon, 8 Sep 2025 17:31:15 +0200 Subject: [PATCH 071/110] feat: add streaming state handling to chat session and improve save logic --- src/ai/session/session.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/ai/session/session.ts b/src/ai/session/session.ts index 183fe9c..8c8f915 100644 --- a/src/ai/session/session.ts +++ b/src/ai/session/session.ts @@ -144,11 +144,23 @@ export class ChatSession implements IChatSession }; }; + #isStreaming = false; + + get isStreaming() + { + return this.#isStreaming; + } + setMessages = async (messages: TMessage[]): Promise => { this.messages = [...messages]; + this.#isStreaming = !!messages.find(m => isMessageStreaming(m)); - this._saveOnline(); + if (!this.#isStreaming) + { + this._save(); + this._saveOnline(); + } this.notifyListeners(); }; @@ -168,7 +180,7 @@ export class ChatSession implements IChatSession }; private _saveQueue: Promise = Promise.resolve(); - save = async (): Promise => + private _save = async (): Promise => { this._saveOnline(); @@ -181,6 +193,7 @@ export class ChatSession implements IChatSession .then(async () => { await this.storage!.saveSession(this); + console.log("SAVED LOCALLY!"); }) .catch((error) => { @@ -196,13 +209,13 @@ export class ChatSession implements IChatSession this._saveOnlineQueue = this._saveOnlineQueue .then(async () => { - console.log("SAVE ONLINE?"); if (this.messages.find(m => isMessageStreaming(m))) { return; } await chatServerClient?.upsertDocument(this.id, { data: mapSessionToChat(this) }); + console.log("SAVED ONLINE!"); }) .catch((error) => @@ -215,6 +228,8 @@ export class ChatSession implements IChatSession flush = async (): Promise => { + this._saveOnline(); + this._save(); await this._saveOnlineQueue; await this._saveQueue; }; From 70e1c682a80ee6c84d5e057085979108f27caf0c Mon Sep 17 00:00:00 2001 From: janole Date: Mon, 8 Sep 2025 17:31:41 +0200 Subject: [PATCH 072/110] fix: replace session.save() with session.flush() to correctly persist changes before rendering chat interface --- src/commands/chat.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/chat.tsx b/src/commands/chat.tsx index cac16db..414eb11 100644 --- a/src/commands/chat.tsx +++ b/src/commands/chat.tsx @@ -67,7 +67,7 @@ async function chat(options: any) debug: options.debug, }; - await session.save(); + session.flush(); render(, { exitOnCtrlC: false }); } From 47d6562ddfad845480b0ae08c72692c875c43082 Mon Sep 17 00:00:00 2001 From: janole Date: Mon, 8 Sep 2025 18:31:54 +0200 Subject: [PATCH 073/110] fix: remove unnecessary saveOnline call and redundant console logs in session save method --- src/ai/session/session.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/ai/session/session.ts b/src/ai/session/session.ts index 8c8f915..9f352cb 100644 --- a/src/ai/session/session.ts +++ b/src/ai/session/session.ts @@ -182,18 +182,15 @@ export class ChatSession implements IChatSession private _saveQueue: Promise = Promise.resolve(); private _save = async (): Promise => { - this._saveOnline(); - if (!this.storage) { - throw new Error("No storage configured!"); + return Promise.resolve(); } this._saveQueue = this._saveQueue .then(async () => { await this.storage!.saveSession(this); - console.log("SAVED LOCALLY!"); }) .catch((error) => { @@ -215,8 +212,6 @@ export class ChatSession implements IChatSession } await chatServerClient?.upsertDocument(this.id, { data: mapSessionToChat(this) }); - - console.log("SAVED ONLINE!"); }) .catch((error) => { From c1c861acda102de812689a054062ef8855ddbef1 Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 9 Sep 2025 09:23:45 +0200 Subject: [PATCH 074/110] refactor: simplify session save logic and use syncSession for online updates --- src/ai/session/session.ts | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/src/ai/session/session.ts b/src/ai/session/session.ts index 9f352cb..bea29ad 100644 --- a/src/ai/session/session.ts +++ b/src/ai/session/session.ts @@ -3,7 +3,7 @@ import { ulid } from "ulid"; import { IChatServiceOptions } from "../chat-service.js"; import { CustomMessage, isCustomMessage, isMessageStreaming, TMessage, ToolProgressMessage } from "../custom-messages.js"; -import { chatServerClient, mapSessionToChat } from "./chat-server/chat-server-client.js"; +import { syncSession } from "./chat-server/chat-server-client.js"; export type TToolMode = "confirm" | "read-only" | "yolo"; @@ -156,11 +156,8 @@ export class ChatSession implements IChatSession this.messages = [...messages]; this.#isStreaming = !!messages.find(m => isMessageStreaming(m)); - if (!this.#isStreaming) - { - this._save(); - this._saveOnline(); - } + this._save(); + this._saveOnline(); this.notifyListeners(); }; @@ -182,7 +179,7 @@ export class ChatSession implements IChatSession private _saveQueue: Promise = Promise.resolve(); private _save = async (): Promise => { - if (!this.storage) + if (!this.storage || !this.#isStreaming) { return Promise.resolve(); } @@ -206,12 +203,7 @@ export class ChatSession implements IChatSession this._saveOnlineQueue = this._saveOnlineQueue .then(async () => { - if (this.messages.find(m => isMessageStreaming(m))) - { - return; - } - - await chatServerClient?.upsertDocument(this.id, { data: mapSessionToChat(this) }); + await syncSession(this); }) .catch((error) => { From 9c1ea704da49c1f45a1462b649f1a36c2f640896 Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 9 Sep 2025 09:24:08 +0200 Subject: [PATCH 075/110] refactor: remove addDoc method and rename upsertDocument2 to directUpsertDocument with supabase client initialization --- src/ai/session/chat-server/api-client.ts | 40 +++--------------------- 1 file changed, 4 insertions(+), 36 deletions(-) diff --git a/src/ai/session/chat-server/api-client.ts b/src/ai/session/chat-server/api-client.ts index a5191a5..bca6f31 100644 --- a/src/ai/session/chat-server/api-client.ts +++ b/src/ai/session/chat-server/api-client.ts @@ -340,42 +340,9 @@ class ApiClient }); } - // --- Test API --- + // --- Direct Supabase API --- - private documentId: string | undefined; - - async addDoc(externalId: string, data: any) - { - if (!this.documentId) - { - const document = await this.getDocumentByExternalId(externalId); - - if (!document) - { - return; - } - - this.documentId = document.id; - } - - // Prepare the parameters for the RPC call - const rpcParams: { - data: any; - external_id?: string | null; - schema_id?: string | null; - links?: any[]; - } = { - data, - links: [{ - to_id: this.documentId, - type: "llm-chunk", - }], - }; - - await this.supabase?.rpc("create_document_with_links", rpcParams).select().single(); - } - - async upsertDocument2(externalId: string, documentData: { data: Record, schema_id?: string | null }) + async directUpsertDocument(externalId: string, documentData: { data: Record, schema_id?: string | null }) { const rpcParams = { p_external_id: externalId, @@ -383,7 +350,8 @@ class ApiClient p_schema_id: documentData.schema_id, } as const; - await this.supabase?.rpc("upsert_document", rpcParams).select().single(); + await this.ensureSupabaseClient(); + await this.supabase!.rpc("upsert_document", rpcParams).select().single(); } } From 2255935bef72c708732870764703ab7966576b65 Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 9 Sep 2025 09:24:18 +0200 Subject: [PATCH 076/110] feat: add online syncing for chat sessions with support for streaming messages --- .../session/chat-server/chat-server-client.ts | 45 +++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/src/ai/session/chat-server/chat-server-client.ts b/src/ai/session/chat-server/chat-server-client.ts index bc6b4ac..a06f4d4 100644 --- a/src/ai/session/chat-server/chat-server-client.ts +++ b/src/ai/session/chat-server/chat-server-client.ts @@ -1,7 +1,7 @@ import { AIMessage, BaseMessage, HumanMessage } from "@langchain/core/messages"; -import { ErrorMessage, TMessage, TMessageType, ToolProgressMessage } from "../../custom-messages.js"; -import { IChatSession } from "../session.js"; +import { ErrorMessage, isMessageStreaming, TMessage, TMessageType, ToolProgressMessage } from "../../custom-messages.js"; +import { ChatSession, IChatSession } from "../session.js"; import { ApiClient } from "./api-client.js"; import { IChat, IChatMessage } from "./types.js"; @@ -116,8 +116,47 @@ function mapSessionToChat(session: IChatSession): IChat return chat; } +async function pushSessionOnline(session: ChatSession) +{ + await chatServerClient?.directUpsertDocument(session.id, { data: mapSessionToChat(session) }); +} + +async function pushStreamingMessageOnline(session: ChatSession, content: string) +{ + await chatServerClient?.directUpsertDocument(session.id + "/rt", { + data: { + type: "chunk", + external_id: session.id, + chat_id: session.id, + message_number: session.messages.length, + content, + }, + }); +} + +async function syncSession(session: ChatSession) +{ + const aiMessage = session.messages.find(m => isMessageStreaming(m)) as (BaseMessage | undefined); + + if (aiMessage) + { + await pushStreamingMessageOnline(session, aiMessage.text); + + console.log("STREAMED ONLINE!"); + + return; + } + + await pushSessionOnline(session); + + console.log("SAVED ONLINE!", session, session.messages); +} + export { - chatServerClient, mapMessage, mapSessionToChat, + chatServerClient, + mapMessage, + mapSessionToChat, + syncSession, }; From 9825b6d77ffbaa43734e4e3c161422fc305a6a88 Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 9 Sep 2025 09:27:51 +0200 Subject: [PATCH 077/110] chore: remove trailing newline at end of chat-server-client.ts file --- src/ai/session/chat-server/chat-server-client.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ai/session/chat-server/chat-server-client.ts b/src/ai/session/chat-server/chat-server-client.ts index a06f4d4..58d7c13 100644 --- a/src/ai/session/chat-server/chat-server-client.ts +++ b/src/ai/session/chat-server/chat-server-client.ts @@ -159,4 +159,3 @@ export mapSessionToChat, syncSession, }; - From 5b20137c9fedaa01a22a0d083b95cfffb0d6d403 Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 9 Sep 2025 10:15:16 +0200 Subject: [PATCH 078/110] feat: add streaming support and async generateResponse method to ChatSession class with error handling --- src/ai/session/session.ts | 61 +++++++++++++++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 5 deletions(-) diff --git a/src/ai/session/session.ts b/src/ai/session/session.ts index bea29ad..ab81469 100644 --- a/src/ai/session/session.ts +++ b/src/ai/session/session.ts @@ -1,8 +1,9 @@ import { mapChatMessagesToStoredMessages, mapStoredMessageToChatMessage, StoredMessage } from "@langchain/core/messages"; import { ulid } from "ulid"; -import { IChatServiceOptions } from "../chat-service.js"; -import { CustomMessage, isCustomMessage, isMessageStreaming, TMessage, ToolProgressMessage } from "../custom-messages.js"; +import { ChatService, IChatServiceOptions } from "../chat-service.js"; +import { CustomMessage, ErrorMessage, isCustomMessage, isMessageStreaming, TMessage, ToolProgressMessage } from "../custom-messages.js"; +import { work } from "../work.js"; import { syncSession } from "./chat-server/chat-server-client.js"; export type TToolMode = "confirm" | "read-only" | "yolo"; @@ -44,6 +45,7 @@ export function mapSessionToSessionData(session: IChatSession) id: session.id, workDir: session.workDir, toolMode: session.toolMode || "confirm", + streaming: session.streaming, chatServiceOptions: session.chatServiceOptions, systemPrompt: session.systemPrompt, messages: session.messages.map(mapMessageToObject).filter(m => m), @@ -56,6 +58,7 @@ export function mapSessionDataToSession(data: any): IChatSession id: data.id, workDir: data.workDir, toolMode: data.toolMode || "confirm", + streaming: data.streaming || true, chatServiceOptions: data.chatServiceOptions, systemPrompt: data.systemPrompt, messages: data.messages.map(mapObjectToMessage).filter((m: TMessage | undefined) => m), @@ -68,6 +71,7 @@ export interface IChatSession workDir: string; toolMode: TToolMode; + streaming: boolean; chatServiceOptions: IChatServiceOptions; systemPrompt?: string; // TODO: extend into service or template like "%{DEFAULT}% ..." @@ -81,7 +85,7 @@ export interface ICreateChatSession extends Omit void; +type TUpdateListener = (props: { messages: TMessage[]; working: boolean; }) => void; export class ChatSession implements IChatSession { @@ -90,11 +94,13 @@ export class ChatSession implements IChatSession workDir: string; toolMode: TToolMode; chatServiceOptions: IChatServiceOptions; + streaming: boolean; systemPrompt?: string; messages: TMessage[] = []; - finished: number = 0; + + private chatService?: ChatService; storage?: ISessionStorage; private onUpdateListeners: TUpdateListener[] = []; @@ -104,6 +110,7 @@ export class ChatSession implements IChatSession this.id = props.id; this.workDir = props.workDir; this.toolMode = props.toolMode || "confirm"; + this.streaming = props.streaming || true; this.chatServiceOptions = props.chatServiceOptions; this.systemPrompt = props.systemPrompt; this.messages = props.messages || []; @@ -122,6 +129,12 @@ export class ChatSession implements IChatSession return chatSession; } + setChatService = (chatService: ChatService): ChatSession => + { + this.chatService = chatService; + return this; + }; + setStorage = (storage: ISessionStorage): ChatSession => { this.storage = storage; @@ -130,7 +143,7 @@ export class ChatSession implements IChatSession notifyListeners = (): void => { - const props = { messages: [...this.messages], finished: this.finished }; + const props = { messages: [...this.messages], working: this.#isWorking }; this.onUpdateListeners.forEach(listener => listener(props)); }; @@ -162,6 +175,44 @@ export class ChatSession implements IChatSession this.notifyListeners(); }; + #isWorking = false; + + get isWorking() + { + return this.#isWorking; + } + + generateResponse = async (messages: TMessage[], signal?: AbortSignal) => + { + if (this.#isWorking || !this.chatService) + { + return; + } + + this.#isWorking = true; + this.setMessages(messages); + + work({ + session: this, + chatService: this.chatService, // TODO: + signal, + }) + .then(messages => + { + this.#isWorking = false; + this.setMessages(messages); + }) + .catch(error => + { + this.#isWorking = false; + const newMessages = [ + ...this.messages, + new ErrorMessage(`ERROR: running work({...}) failed with: ${error.message || error.toString()}`, error), + ]; + this.setMessages(newMessages); + }); + }; + toggleConfirmState = async (messageIndex: number, direction: -1 | 1): Promise => { if (ToolProgressMessage.isTypeOf(this.messages[messageIndex])) From c91350960a279146181cab6292a17b657f1e67cb Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 9 Sep 2025 10:15:21 +0200 Subject: [PATCH 079/110] refactor: remove streaming prop and use session.streaming instead in work function --- src/ai/work.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/ai/work.ts b/src/ai/work.ts index 8067827..4246434 100644 --- a/src/ai/work.ts +++ b/src/ai/work.ts @@ -10,13 +10,12 @@ interface WorkProps { chatService: ChatService; session: ChatSession; - streaming?: boolean; signal?: AbortSignal; } async function work(props: WorkProps) { - const { session, chatService, streaming = true, signal } = props; + const { session, chatService, signal } = props; const messages = await workTools({ ...props, session }); @@ -28,7 +27,7 @@ async function work(props: WorkProps) let aiMessage: AIMessageChunk | undefined = undefined; let toolProgressMessages: ToolProgressMessage[] = []; - if (streaming) + if (session.streaming) { const { result: stream, error } = await tryCatch(chatService.stream(session, signal)); From 9abe68fdabd6cd2bed44c14b8a6851d7a82dbd25 Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 9 Sep 2025 10:24:49 +0200 Subject: [PATCH 080/110] refactor: remove ChatService and work usage; delegate response generation to session.generateResponse and consolidate state into sessionState object in useChatController --- src/ui/chat-controller.ts | 57 ++++++++++----------------------------- 1 file changed, 14 insertions(+), 43 deletions(-) diff --git a/src/ui/chat-controller.ts b/src/ui/chat-controller.ts index 5a543f5..2c8595b 100644 --- a/src/ui/chat-controller.ts +++ b/src/ui/chat-controller.ts @@ -2,81 +2,52 @@ import clipboard from "clipboardy"; import { Key, useApp } from "ink"; import { useCallback, useEffect, useState } from "react"; -import { ChatService } from "../ai/chat-service.js"; -import { ErrorMessage, TMessage, ToolProgressMessage } from "../ai/custom-messages.js"; +import { TMessage, ToolProgressMessage } from "../ai/custom-messages.js"; import { chatServerClient } from "../ai/session/chat-server/chat-server-client.js"; import { ChatSession } from "../ai/session/session.js"; import { countTokens, lastMessageFromAI, TTokenUsage } from "../ai/tokens.js"; import { getGitBranch } from "../ai/tools/git-tools.js"; -import { work } from "../ai/work.js"; interface UseChatControllerProps { - chatService: ChatService; session: ChatSession; - streaming?: boolean; } export function useChatController(props: UseChatControllerProps) { - const { chatService, session, streaming = true } = props; + const { session } = props; const { exit } = useApp(); - const [working, setWorking] = useState(false); - const [abortController, setAbortController] = useState(); const [ctrlC, setCtrlC] = useState(false); const [_, setToolMode] = useState(session.toolMode); - const [chatHistory, setChatHistory] = useState<{ messages: TMessage[] }>({ + const [sessionState, setSessionState] = useState<{ messages: TMessage[]; working: boolean; }>({ messages: session.messages, + working: session.isWorking, }); const [tokenUsage, setTokenUsage] = useState(); const [currentGitBranch, setCurrentGitBranch] = useState(); - const selectedIndex = chatHistory.messages.findIndex(m => ToolProgressMessage.isTypeOf(m) && m.status === "pending-confirmation"); + const selectedIndex = sessionState.messages.findIndex(m => ToolProgressMessage.isTypeOf(m) && m.status === "pending-confirmation"); const confirm = selectedIndex !== -1; // This effect subscribes to the session, making it the single source of truth. - useEffect(() => session.onUpdate(setChatHistory), [session]); + useEffect(() => session.onUpdate(setSessionState), [session]); const handleSendHistory = useCallback((messages: TMessage[]) => { - session.setMessages(messages); - setWorking(true); - const abortController = new AbortController(); setAbortController(abortController); - work({ - chatService, - session, - streaming, - signal: abortController.signal, - }) - .then(messages => - { - session.setMessages(messages); - }) - .catch(error => - { - const newMessages = [ - ...session.messages, - new ErrorMessage(`ERROR: running work({...}) failed with: ${error.message || error.toString()}`, error), - ]; - session.setMessages(newMessages); - }) - .finally(() => - { - setWorking(false); - }); + session.generateResponse(messages, abortController.signal); + + // TODO: add clean up function, but how? }, [ - chatService, session, - streaming, ]); const handleInput = useCallback((input: string, key: Key): boolean => @@ -165,7 +136,7 @@ export function useChatController(props: UseChatControllerProps) useEffect(() => { - if (!working) + if (!sessionState.working) { setAbortController(undefined); setCtrlC(false); @@ -182,12 +153,12 @@ export function useChatController(props: UseChatControllerProps) } }, [ session, - working, + sessionState.working, ]); // TODO: refactor const action = ctrlC - ? working + ? sessionState.working ? abortController === null ? "Cancelled. Please wait ..." : "Cancel? Press Ctrl-C again ..." @@ -197,12 +168,12 @@ export function useChatController(props: UseChatControllerProps) : undefined; return { - working, + messages: sessionState.messages, + working: sessionState.working, handleInput, action, selected: selectedIndex, setToolMode, - chatHistory, handleSendHistory, tokenUsage, currentGitBranch, From 924c9fbf8df5286d60707077109a098ee7345d2f Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 9 Sep 2025 10:24:54 +0200 Subject: [PATCH 081/110] refactor: replace chatHistory with messages state in ChatApp component and remove unused streaming prop --- src/ui/chat-app.tsx | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/ui/chat-app.tsx b/src/ui/chat-app.tsx index d8129ab..b441916 100644 --- a/src/ui/chat-app.tsx +++ b/src/ui/chat-app.tsx @@ -20,37 +20,34 @@ interface ChatAppProps chatService: ChatService; session: ChatSession; startMessage?: string; - streaming?: boolean; debug?: boolean; } function ChatApp(props: ChatAppProps) { - const { chatService, session, startMessage, streaming = true, debug } = props; + const { session, startMessage, debug } = props; const { chatServiceOptions } = session; const [_message, setMessage] = useState(""); const { + messages, working, handleInput, action, selected, - chatHistory, handleSendHistory, tokenUsage, currentGitBranch, } = useChatController({ - chatService, session, - streaming, }); useEffect(() => { if (startMessage) { - handleSendHistory([...chatHistory.messages, new HumanMessage(startMessage)]); + handleSendHistory([...messages, new HumanMessage(startMessage)]); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -59,29 +56,28 @@ function ChatApp(props: ChatAppProps) { if (_message.trim() && !working) { - const messages = [...chatHistory.messages, new HumanMessage(_message)]; - handleSendHistory(messages); + handleSendHistory([...messages, new HumanMessage(_message)]); setMessage(""); } }; const terminalSize = useTerminalSize(); - let finished = chatHistory.messages.findIndex(m => !isMessageFinished(m)); - if (finished === -1) { finished = chatHistory.messages.length; } + let finished = messages.findIndex(m => !isMessageFinished(m)); + if (finished === -1) { finished = messages.length; } return ( {/* Messages Area */} - + {(message, index) => ( )} - {chatHistory.messages.slice(finished).map((workingItem, index) => ( + {messages.slice(finished).map((workingItem, index) => ( Date: Tue, 9 Sep 2025 10:25:02 +0200 Subject: [PATCH 082/110] fix: add streaming option support and set chat service on session initialization; disable streaming in exec command execution --- src/commands/chat.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/commands/chat.tsx b/src/commands/chat.tsx index 414eb11..c7c357c 100644 --- a/src/commands/chat.tsx +++ b/src/commands/chat.tsx @@ -40,15 +40,19 @@ async function initChatSession(options: any) const toolMode: TToolMode = options.readOnly ? "read-only" : options.writeMode ? "yolo" : "confirm"; + const streaming = options.stream !== false; + const session = options.continueSession ? await FileSessionStorage.createFromFile(options.continueSession) - : FileSessionStorage.create({ workDir, toolMode, chatServiceOptions }); + : FileSessionStorage.create({ workDir, toolMode, streaming, chatServiceOptions }); const chatService = new ChatService({ promptLoader: await PromptLoader.create(session), toolProvider: new NodeToolProvider(), }); + session.setChatService(chatService); + return { chatService, session, @@ -63,7 +67,6 @@ async function chat(options: any) chatService, session, startMessage: options.startMessage, - streaming: options.stream !== false, debug: options.debug, }; @@ -74,14 +77,13 @@ async function chat(options: any) async function exec(message: string, options: any) { - const { chatService, session } = await initChatSession(options); + const { chatService, session } = await initChatSession({ ...options, stream: false }); session.messages.push(new HumanMessage(message)); const messages = await work({ chatService, session, - streaming: false, }); const pendingConfirmation = messages.filter(m => ToolProgressMessage.isTypeOf(m)).find(m => m.status === "pending-confirmation"); @@ -179,3 +181,4 @@ export { addChatCommands, }; + From 63eb78ade538e61f822ad1bb5e5991674bc9147e Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 9 Sep 2025 10:25:27 +0200 Subject: [PATCH 083/110] refactor: update type imports and set chat state dynamically based on session working status --- src/ai/session/chat-server/chat-server-client.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ai/session/chat-server/chat-server-client.ts b/src/ai/session/chat-server/chat-server-client.ts index 58d7c13..809894e 100644 --- a/src/ai/session/chat-server/chat-server-client.ts +++ b/src/ai/session/chat-server/chat-server-client.ts @@ -1,7 +1,7 @@ import { AIMessage, BaseMessage, HumanMessage } from "@langchain/core/messages"; import { ErrorMessage, isMessageStreaming, TMessage, TMessageType, ToolProgressMessage } from "../../custom-messages.js"; -import { ChatSession, IChatSession } from "../session.js"; +import { ChatSession } from "../session.js"; import { ApiClient } from "./api-client.js"; import { IChat, IChatMessage } from "./types.js"; @@ -91,7 +91,7 @@ function mapMessage(message: TMessage): IChatMessage }; } -function mapSessionToChat(session: IChatSession): IChat +function mapSessionToChat(session: ChatSession): IChat { const chat: IChat = { id: session.id, @@ -110,7 +110,7 @@ function mapSessionToChat(session: IChatSession): IChat }, toolMode: session.toolMode, messages: session.messages.map(mapMessage), - state: "done", + state: session.isWorking ? "working" : "done", }; return chat; From 3389148533a05c4cff89b82a2a083421b1a8850b Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 9 Sep 2025 10:25:38 +0200 Subject: [PATCH 084/110] refactor: change isWorking property to have private setter and public getter --- src/ai/session/session.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/ai/session/session.ts b/src/ai/session/session.ts index ab81469..30545f1 100644 --- a/src/ai/session/session.ts +++ b/src/ai/session/session.ts @@ -177,7 +177,12 @@ export class ChatSession implements IChatSession #isWorking = false; - get isWorking() + private set isWorking(isWorking: boolean) + { + this.#isWorking = isWorking; + } + + get isWorking(): boolean { return this.#isWorking; } From 5c4fdf0ef3cabbf5399677875704cac650bd4682 Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 9 Sep 2025 13:45:14 +0200 Subject: [PATCH 085/110] feat: implement IApiClientCommandListener in ChatSession to handle incoming commands from chatServerClient and trigger response generation --- src/ai/session/session.ts | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/ai/session/session.ts b/src/ai/session/session.ts index 30545f1..f145bed 100644 --- a/src/ai/session/session.ts +++ b/src/ai/session/session.ts @@ -1,10 +1,11 @@ -import { mapChatMessagesToStoredMessages, mapStoredMessageToChatMessage, StoredMessage } from "@langchain/core/messages"; +import { HumanMessage, mapChatMessagesToStoredMessages, mapStoredMessageToChatMessage, StoredMessage } from "@langchain/core/messages"; import { ulid } from "ulid"; import { ChatService, IChatServiceOptions } from "../chat-service.js"; import { CustomMessage, ErrorMessage, isCustomMessage, isMessageStreaming, TMessage, ToolProgressMessage } from "../custom-messages.js"; import { work } from "../work.js"; -import { syncSession } from "./chat-server/chat-server-client.js"; +import { IApiClientCommandListener } from "./chat-server/api-client.js"; +import { chatServerClient, syncSession } from "./chat-server/chat-server-client.js"; export type TToolMode = "confirm" | "read-only" | "yolo"; @@ -87,7 +88,7 @@ export interface ICreateChatSession extends Omit void; -export class ChatSession implements IChatSession +export class ChatSession implements IChatSession, IApiClientCommandListener { id: string; @@ -116,6 +117,8 @@ export class ChatSession implements IChatSession this.messages = props.messages || []; this.storage = storage; + + chatServerClient?.addCommandListener(this); } static create(props: ICreateChatSession, storage?: ISessionStorage) @@ -129,6 +132,25 @@ export class ChatSession implements IChatSession return chatSession; } + handleCommand = (payload: any) => + { + if (payload.new) + { + console.log("PAYLOAD-COMMAND", payload.new.external_id, payload.eventType, JSON.stringify(payload.new.data).length); + } + + if (payload.new?.external_id === `${this.id}/cmd`) + { + if (!this.#isWorking && payload.new.data?.content?.length) + { + this.generateResponse([ + ...this.messages, + new HumanMessage(payload.new.data.content), + ]); + } + } + }; + setChatService = (chatService: ChatService): ChatSession => { this.chatService = chatService; From 8ca1b0ff0f44de2347d5482ee319a5790c7f8d4d Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 9 Sep 2025 13:45:42 +0200 Subject: [PATCH 086/110] refactor: replace command listener function with interface for better type safety and extendability --- src/ai/session/chat-server/api-client.ts | 26 +++++++++--------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/src/ai/session/chat-server/api-client.ts b/src/ai/session/chat-server/api-client.ts index bca6f31..cff21f4 100644 --- a/src/ai/session/chat-server/api-client.ts +++ b/src/ai/session/chat-server/api-client.ts @@ -62,7 +62,10 @@ export interface IApiClientOptions token?: string; } -type TCommandListenerFunc = (payload: any) => void; +export interface IApiClientCommandListener +{ + handleCommand: (payload: any) => void; +} class ApiClient { @@ -74,7 +77,7 @@ class ApiClient private supabase?: SupabaseClient; private channel?: RealtimeChannel; - private commandListeners: TCommandListenerFunc[] = []; + private commandListeners: IApiClientCommandListener[] = []; constructor(options: IApiClientOptions = {}) { @@ -106,15 +109,15 @@ class ApiClient } // --- ---- - addCommandListener(f: TCommandListenerFunc) + addCommandListener(listener: IApiClientCommandListener) { - if (!this.commandListeners.includes(f)) + if (!this.commandListeners.includes(listener)) { - this.commandListeners.push(f); + this.commandListeners.push(listener); } }; - removeCommandListener(f: TCommandListenerFunc) + removeCommandListener(f: IApiClientCommandListener) { const i = this.commandListeners.indexOf(f); @@ -126,16 +129,7 @@ class ApiClient private pushCommand(payload: any) { - for (const f of this.commandListeners) - { - if (payload.args?.event === "command" && payload.args.payload) - { - f({ - external_id: payload.args.payload?.external_id, - message: payload.args.payload?.message, - }); - } - } + this.commandListeners.forEach(listener => listener.handleCommand(payload)); } // --- Auth API --- From 59a6d872b403b3dfbd26306ad0500c49976264da Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 9 Sep 2025 13:45:48 +0200 Subject: [PATCH 087/110] fix: throttle syncSession calls to limit pushStreamingMessageOnline frequency --- src/ai/session/chat-server/chat-server-client.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/ai/session/chat-server/chat-server-client.ts b/src/ai/session/chat-server/chat-server-client.ts index 809894e..ce45fac 100644 --- a/src/ai/session/chat-server/chat-server-client.ts +++ b/src/ai/session/chat-server/chat-server-client.ts @@ -134,22 +134,25 @@ async function pushStreamingMessageOnline(session: ChatSession, content: string) }); } +let lastStreamingSync = Date.now(); + async function syncSession(session: ChatSession) { const aiMessage = session.messages.find(m => isMessageStreaming(m)) as (BaseMessage | undefined); if (aiMessage) { - await pushStreamingMessageOnline(session, aiMessage.text); + if (Date.now() > lastStreamingSync + 100) + { + lastStreamingSync = Date.now(); - console.log("STREAMED ONLINE!"); + await pushStreamingMessageOnline(session, aiMessage.text); + } return; } await pushSessionOnline(session); - - console.log("SAVED ONLINE!", session, session.messages); } export From d9814063a3e508dd6725499f2bbaffcf5beb488d Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 9 Sep 2025 16:49:30 +0200 Subject: [PATCH 088/110] refactor: simplify supabase client initialization and remove async-mutex usage --- src/ai/session/chat-server/api-client.ts | 30 ++++++++++++------------ 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/ai/session/chat-server/api-client.ts b/src/ai/session/chat-server/api-client.ts index cff21f4..65cef9e 100644 --- a/src/ai/session/chat-server/api-client.ts +++ b/src/ai/session/chat-server/api-client.ts @@ -1,7 +1,6 @@ // A simple, configurable client library for the documents and links APIs. import { createClient, RealtimeChannel, SupabaseClient } from "@supabase/supabase-js"; -import { Mutex } from "async-mutex"; export interface IAuthData { @@ -79,6 +78,8 @@ class ApiClient private commandListeners: IApiClientCommandListener[] = []; + private _initPromise?: Promise; + constructor(options: IApiClientOptions = {}) { this.baseUrl = options.baseUrl || ""; @@ -176,26 +177,25 @@ class ApiClient return this.supabase; } - private _mutex = new Mutex(); - private async ensureSupabaseClient() { - if (!this.supabase) + if (this.supabase) { - const release = await this._mutex.acquire(); + return; + } - try - { - if (!this.supabase) + if (!this._initPromise) + { + this._initPromise = this.createSupabaseClient() + .then(() => { }) + .catch(error => { - this.supabase = await this.createSupabaseClient(); - } - } - finally - { - release(); - } + console.error("Failed to create supabase client", error); + this._initPromise = undefined; + }); } + + await this._initPromise; } async start() From cd53f76a3c5f5b248ec537e121103499f4f8f3ca Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 9 Sep 2025 16:55:19 +0200 Subject: [PATCH 089/110] fix: add comment to clarify supabase client initialization then block --- src/ai/session/chat-server/api-client.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ai/session/chat-server/api-client.ts b/src/ai/session/chat-server/api-client.ts index 65cef9e..4cceac7 100644 --- a/src/ai/session/chat-server/api-client.ts +++ b/src/ai/session/chat-server/api-client.ts @@ -187,7 +187,10 @@ class ApiClient if (!this._initPromise) { this._initPromise = this.createSupabaseClient() - .then(() => { }) + .then((_supabase) => + { + // supabase should be ready now! + }) .catch(error => { console.error("Failed to create supabase client", error); From 0a9258aa86e3f773e66d7564dc82cd5a71603862 Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 9 Sep 2025 17:32:32 +0200 Subject: [PATCH 090/110] chore: add @supabase/supabase-js as an optional dependency in package.json and package-lock.json with related supabase packages and types dependencies updated --- package-lock.json | 101 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 5 ++- 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index d1902fa..f37861c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,6 +58,9 @@ }, "engines": { "node": ">=18" + }, + "optionalDependencies": { + "@supabase/supabase-js": "^2.57.2" } }, "node_modules/@alcalzone/ansi-tokenize": { @@ -1552,6 +1555,87 @@ "eslint": ">=9.0.0" } }, + "node_modules/@supabase/auth-js": { + "version": "2.71.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.71.1.tgz", + "integrity": "sha512-mMIQHBRc+SKpZFRB2qtupuzulaUhFYupNyxqDj5Jp/LyPvcWvjaJzZzObv6URtL/O6lPxkanASnotGtNpS3H2Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.6.tgz", + "integrity": "sha512-bhjZ7rmxAibjgmzTmQBxJU6ZIBCCJTc3Uwgvdi4FewueUTAGO5hxZT1Sj6tiD+0dSXf9XI87BDdJrg12z8Uaew==", + "license": "MIT", + "optional": true, + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/node-fetch": { + "version": "2.6.15", + "resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz", + "integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "1.21.4", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.21.4.tgz", + "integrity": "sha512-TxZCIjxk6/dP9abAi89VQbWWMBbybpGWyvmIzTd79OeravM13OjR/YEYeyUOPcM1C3QyvXkvPZhUfItvmhY1IQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.15.5", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.15.5.tgz", + "integrity": "sha512-/Rs5Vqu9jejRD8ZeuaWXebdkH+J7V6VySbCZ/zQM93Ta5y3mAmocjioa/nzlB6qvFmyylUgKVS1KpE212t30OA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@supabase/node-fetch": "^2.6.13", + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "ws": "^8.18.2" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.12.1.tgz", + "integrity": "sha512-QWg3HV6Db2J81VQx0PqLq0JDBn4Q8B1FYn1kYcbla8+d5WDmTdwwMr+EJAxNOSs9W4mhKMv+EYCpCrTFlTj4VQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@supabase/node-fetch": "^2.6.14" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.57.4", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.57.4.tgz", + "integrity": "sha512-LcbTzFhHYdwfQ7TRPfol0z04rLEyHabpGYANME6wkQ/kLtKNmI+Vy+WEM8HxeOZAtByUFxoUTTLwhXmrh+CcVw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@supabase/auth-js": "2.71.1", + "@supabase/functions-js": "2.4.6", + "@supabase/node-fetch": "2.6.15", + "@supabase/postgrest-js": "1.21.4", + "@supabase/realtime-js": "2.15.5", + "@supabase/storage-js": "2.12.1" + } + }, "node_modules/@swc-node/core": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@swc-node/core/-/core-1.14.1.tgz", @@ -1922,6 +2006,13 @@ "form-data": "^4.0.4" } }, + "node_modules/@types/phoenix": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", + "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==", + "license": "MIT", + "optional": true + }, "node_modules/@types/react": { "version": "19.1.12", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz", @@ -1954,6 +2045,16 @@ "@types/node": "*" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.42.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.42.0.tgz", diff --git a/package.json b/package.json index bfa3652..a8bec8f 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,9 @@ "ulid": "^3.0.1", "write-file-atomic": "^6.0.0" }, + "optionalDependencies": { + "@supabase/supabase-js": "^2.57.2" + }, "devDependencies": { "@sindresorhus/tsconfig": "^7.0.0", "@stylistic/eslint-plugin": "^5.1.0", @@ -101,4 +104,4 @@ "rimraf": "^6.0.1", "typescript": "^5.8.3" } -} +} \ No newline at end of file From 8a8a13ace7cefc1c6d60042c334cf95335c9ab7f Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 9 Sep 2025 17:41:30 +0200 Subject: [PATCH 091/110] refactor: use dynamic import for createClient from supabase-js to optimize loading and import types only where needed --- src/ai/session/chat-server/api-client.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ai/session/chat-server/api-client.ts b/src/ai/session/chat-server/api-client.ts index 4cceac7..876b58b 100644 --- a/src/ai/session/chat-server/api-client.ts +++ b/src/ai/session/chat-server/api-client.ts @@ -1,6 +1,6 @@ // A simple, configurable client library for the documents and links APIs. -import { createClient, RealtimeChannel, SupabaseClient } from "@supabase/supabase-js"; +import type { RealtimeChannel, SupabaseClient } from "@supabase/supabase-js"; export interface IAuthData { @@ -146,6 +146,8 @@ class ApiClient { const authData = await this.getAuthData(); + const { createClient } = await import("@supabase/supabase-js"); + const supabase = createClient(authData.api_url, authData.api_key, { accessToken: async () => { From 43e15c8041a6815833eb6db465884396ecb2f17a Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 9 Sep 2025 22:49:32 +0200 Subject: [PATCH 092/110] chore: remove commented out getMessageState and setMessageState functions from custom-messages.ts --- src/ai/custom-messages.ts | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/src/ai/custom-messages.ts b/src/ai/custom-messages.ts index e8ded79..e80ce97 100644 --- a/src/ai/custom-messages.ts +++ b/src/ai/custom-messages.ts @@ -161,28 +161,6 @@ export const isCustomMessage = (message: any): message is CustomMessage => ); }; -// const getMessageState = (msg: TMessage): TMessageState => -// { -// if (isCustomMessage(msg)) -// { -// return msg._state; -// } - -// return (msg as BaseMessage).additional_kwargs["_coba_message_state"] as TMessageState; -// }; - -// const setMessageState = (msg: TMessage, state: TMessageState) => -// { -// if (isCustomMessage(msg)) -// { -// msg._state = state; -// } -// else -// { -// (msg as BaseMessage).additional_kwargs["_coba_message_state"] = state; -// } -// }; - const setMessageIsStreaming = (msg: BaseMessage, isStreaming: boolean) => { msg.additional_kwargs["_coba_message_is_streaming"] = isStreaming; From 0951c517744397408c527aa83098509c06b58d6f Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 9 Sep 2025 23:02:46 +0200 Subject: [PATCH 093/110] fix: pass toolProgressMessage in metadata when invoking selected tool instead of creating unused metadata object earlier --- src/ai/work.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/ai/work.ts b/src/ai/work.ts index 4246434..70bc90d 100644 --- a/src/ai/work.ts +++ b/src/ai/work.ts @@ -103,8 +103,6 @@ async function workTools(props: Pick(selectedTool.invoke(toolCall, { metadata })); + const { result, error } = await tryCatch(selectedTool.invoke(toolCall, { + metadata: { + workDir: session.workDir, + toolProgressMessage, + }, + })); if (result) { From 204022b4b919547fe11edcf7c20c0061eaabd8eb Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 9 Sep 2025 23:03:01 +0200 Subject: [PATCH 094/110] refactor: replace handleSendHistory with session.generateResponse in ChatApp component --- src/ui/chat-app.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/ui/chat-app.tsx b/src/ui/chat-app.tsx index b441916..7672620 100644 --- a/src/ui/chat-app.tsx +++ b/src/ui/chat-app.tsx @@ -36,7 +36,6 @@ function ChatApp(props: ChatAppProps) handleInput, action, selected, - handleSendHistory, tokenUsage, currentGitBranch, } = useChatController({ @@ -47,7 +46,7 @@ function ChatApp(props: ChatAppProps) { if (startMessage) { - handleSendHistory([...messages, new HumanMessage(startMessage)]); + session.generateResponse([...messages, new HumanMessage(startMessage)]); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -56,7 +55,7 @@ function ChatApp(props: ChatAppProps) { if (_message.trim() && !working) { - handleSendHistory([...messages, new HumanMessage(_message)]); + session.generateResponse([...messages, new HumanMessage(_message)]); setMessage(""); } }; From 3ebbfcc6a7ccb405b062e1a8a7130772f94310f9 Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 9 Sep 2025 23:03:16 +0200 Subject: [PATCH 095/110] chore: add newline at end of package.json file --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a8bec8f..39ad248 100644 --- a/package.json +++ b/package.json @@ -104,4 +104,4 @@ "rimraf": "^6.0.1", "typescript": "^5.8.3" } -} \ No newline at end of file +} From 2998559c692e234d37b9cd711ddbaef350b69f5f Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 9 Sep 2025 23:03:32 +0200 Subject: [PATCH 096/110] feat: add info property to ToolProgressMessage to include additional metadata --- src/ai/custom-messages.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/ai/custom-messages.ts b/src/ai/custom-messages.ts index e80ce97..8a75a88 100644 --- a/src/ai/custom-messages.ts +++ b/src/ai/custom-messages.ts @@ -31,7 +31,7 @@ export abstract class CustomMessage } else if (obj._type === "tool-progress") { - return new ToolProgressMessage(obj.toolCall, obj.status, obj.content, obj.confirmState); + return new ToolProgressMessage(obj.toolCall, obj.status, obj.content, obj.confirmState, obj.info); } throw new Error(`Unknown custom message type: ${obj._type}`); @@ -78,12 +78,16 @@ class ToolProgressMessage extends CustomMessage status: "pending" | "pending-confirmation" | "confirmed" | "declined" | "success" | "error"; content?: string; confirmState: typeof ToolProgressMessage.CONFIRM_STATES[number]; + info: { + description?: string; + fileName?: string; + }; static isTypeOf = (m: any) => m instanceof ToolProgressMessage; clone({ status, confirmState }: Pick) { - return new ToolProgressMessage(this.toolCall, status, this.content, confirmState); + return new ToolProgressMessage(this.toolCall, status, this.content, confirmState, this.info); } toggleConfirmState({ direction }: { direction: -1 | 1 }) @@ -131,7 +135,7 @@ class ToolProgressMessage extends CustomMessage return toolCallChunks?.map(i => ToolProgressMessage.createFromChunk(i)).filter(i => !!i) || []; } - constructor(toolCall: ToolCall, status?: ToolProgressMessage["status"], content?: string, confirmState?: ToolProgressMessage["confirmState"]) + constructor(toolCall: ToolCall, status?: ToolProgressMessage["status"], content?: string, confirmState?: ToolProgressMessage["confirmState"], info?: ToolProgressMessage["info"]) { super("tool-progress"); @@ -139,6 +143,7 @@ class ToolProgressMessage extends CustomMessage this.status = status || "pending"; this.content = content; this.confirmState = confirmState || "no"; + this.info = info || {}; } override isMessageFinished(): boolean From 6c14eb46d66c028c624fca3d6ea985413437df31 Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 9 Sep 2025 23:04:17 +0200 Subject: [PATCH 097/110] feat: add setToolMode method to update tool mode and notify listeners in ChatSession --- src/ai/session/session.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/ai/session/session.ts b/src/ai/session/session.ts index f145bed..d584380 100644 --- a/src/ai/session/session.ts +++ b/src/ai/session/session.ts @@ -6,6 +6,7 @@ import { CustomMessage, ErrorMessage, isCustomMessage, isMessageStreaming, TMess import { work } from "../work.js"; import { IApiClientCommandListener } from "./chat-server/api-client.js"; import { chatServerClient, syncSession } from "./chat-server/chat-server-client.js"; +import { TConfirmState } from "./chat-server/types.js"; export type TToolMode = "confirm" | "read-only" | "yolo"; @@ -253,6 +254,11 @@ export class ChatSession implements IChatSession, IApiClientCommandListener await this.setMessages(newMessages); } }; + setToolMode = (toolMode: TToolMode) => + { + this.toolMode = toolMode; + this.notifyListeners(); + }; private _saveQueue: Promise = Promise.resolve(); private _save = async (): Promise => From 6c0d2af0f01c61a8c2b523dc568c8f45017edeb8 Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 9 Sep 2025 23:04:28 +0200 Subject: [PATCH 098/110] feat: add confirmToolUse method to handle tool usage confirmation and update toolMode accordingly --- src/ai/session/session.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/ai/session/session.ts b/src/ai/session/session.ts index d584380..e922be1 100644 --- a/src/ai/session/session.ts +++ b/src/ai/session/session.ts @@ -254,6 +254,30 @@ export class ChatSession implements IChatSession, IApiClientCommandListener await this.setMessages(newMessages); } }; + + confirmToolUse = async (messageIndex: number, confirmState: TConfirmState): Promise => + { + if (confirmState === "none") + { + this.toolMode = "read-only"; + } + else if (confirmState === "all") + { + this.toolMode = "yolo"; + } + + // TODO: reset all other pending tools when toolMode changed! + + this.generateResponse([ + ...this.messages.slice(0, messageIndex), + (this.messages[messageIndex] as ToolProgressMessage).clone({ + status: (confirmState === "yes" || confirmState === "all") ? "confirmed" : "declined", + confirmState, + }), + ...this.messages.slice(messageIndex + 1), + ]); + }; + setToolMode = (toolMode: TToolMode) => { this.toolMode = toolMode; From e86f391cef65421dcb327217fda0870870f5f331 Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 9 Sep 2025 23:05:20 +0200 Subject: [PATCH 099/110] feat: add abortController to ChatSession for aborting generateResponse calls and tracking abort state --- src/ai/session/session.ts | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/ai/session/session.ts b/src/ai/session/session.ts index e922be1..f8052a1 100644 --- a/src/ai/session/session.ts +++ b/src/ai/session/session.ts @@ -107,6 +107,8 @@ export class ChatSession implements IChatSession, IApiClientCommandListener storage?: ISessionStorage; private onUpdateListeners: TUpdateListener[] = []; + private abortController?: AbortController; + private constructor(props: IChatSession, storage: ISessionStorage | undefined) { this.id = props.id; @@ -210,7 +212,12 @@ export class ChatSession implements IChatSession, IApiClientCommandListener return this.#isWorking; } - generateResponse = async (messages: TMessage[], signal?: AbortSignal) => + get isAborted(): boolean + { + return !!this.abortController?.signal?.aborted; + } + + generateResponse = async (messages: TMessage[]) => { if (this.#isWorking || !this.chatService) { @@ -220,10 +227,12 @@ export class ChatSession implements IChatSession, IApiClientCommandListener this.#isWorking = true; this.setMessages(messages); + this.abortController = new AbortController(); + work({ session: this, - chatService: this.chatService, // TODO: - signal, + chatService: this.chatService, // TODO: can't work use session.chatService? + signal: this.abortController.signal, }) .then(messages => { @@ -238,9 +247,19 @@ export class ChatSession implements IChatSession, IApiClientCommandListener new ErrorMessage(`ERROR: running work({...}) failed with: ${error.message || error.toString()}`, error), ]; this.setMessages(newMessages); + }) + .finally(() => + { + this.abortController = undefined; }); }; + abort = (reason: any) => + { + this.abortController?.abort(reason); + this.notifyListeners(); + }; + toggleConfirmState = async (messageIndex: number, direction: -1 | 1): Promise => { if (ToolProgressMessage.isTypeOf(this.messages[messageIndex])) From 49606e56cd881eca0784cadd828815c5a7047a96 Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 9 Sep 2025 23:05:42 +0200 Subject: [PATCH 100/110] feat: add toolMode to update listener and handle confirm tool use event in chat session --- src/ai/session/session.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/ai/session/session.ts b/src/ai/session/session.ts index f8052a1..ddf6810 100644 --- a/src/ai/session/session.ts +++ b/src/ai/session/session.ts @@ -87,7 +87,7 @@ export interface ICreateChatSession extends Omit void; +type TUpdateListener = (props: { messages: TMessage[]; working: boolean; toolMode: TToolMode }) => void; export class ChatSession implements IChatSession, IApiClientCommandListener { @@ -152,6 +152,13 @@ export class ChatSession implements IChatSession, IApiClientCommandListener ]); } } + else if (payload.new?.external_id === `${this.id}/confirm`) + { + if (!this.#isWorking && payload.new.data?.message_index) + { + this.confirmToolUse(payload.new.data.message_index, payload.new.data.confirm_state); + } + } }; setChatService = (chatService: ChatService): ChatSession => @@ -168,7 +175,7 @@ export class ChatSession implements IChatSession, IApiClientCommandListener notifyListeners = (): void => { - const props = { messages: [...this.messages], working: this.#isWorking }; + const props = { messages: [...this.messages], working: this.#isWorking, toolMode: this.toolMode }; this.onUpdateListeners.forEach(listener => listener(props)); }; From 2ac7647622b871327d16b36a9fb7a6afe9d8bafc Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 9 Sep 2025 23:06:24 +0200 Subject: [PATCH 101/110] refactor: simplify chat controller by removing abort controller state and delegating session controls to session methods --- src/ui/chat-controller.ts | 52 ++++++--------------------------------- 1 file changed, 8 insertions(+), 44 deletions(-) diff --git a/src/ui/chat-controller.ts b/src/ui/chat-controller.ts index 2c8595b..8dcfc37 100644 --- a/src/ui/chat-controller.ts +++ b/src/ui/chat-controller.ts @@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from "react"; import { TMessage, ToolProgressMessage } from "../ai/custom-messages.js"; import { chatServerClient } from "../ai/session/chat-server/chat-server-client.js"; -import { ChatSession } from "../ai/session/session.js"; +import { ChatSession, TToolMode } from "../ai/session/session.js"; import { countTokens, lastMessageFromAI, TTokenUsage } from "../ai/tokens.js"; import { getGitBranch } from "../ai/tools/git-tools.js"; @@ -19,14 +19,12 @@ export function useChatController(props: UseChatControllerProps) const { exit } = useApp(); - const [abortController, setAbortController] = useState(); const [ctrlC, setCtrlC] = useState(false); - const [_, setToolMode] = useState(session.toolMode); - - const [sessionState, setSessionState] = useState<{ messages: TMessage[]; working: boolean; }>({ + const [sessionState, setSessionState] = useState<{ messages: TMessage[]; working: boolean; toolMode: TToolMode; }>({ messages: session.messages, working: session.isWorking, + toolMode: session.toolMode, }); const [tokenUsage, setTokenUsage] = useState(); @@ -38,18 +36,6 @@ export function useChatController(props: UseChatControllerProps) // This effect subscribes to the session, making it the single source of truth. useEffect(() => session.onUpdate(setSessionState), [session]); - const handleSendHistory = useCallback((messages: TMessage[]) => - { - const abortController = new AbortController(); - setAbortController(abortController); - - session.generateResponse(messages, abortController.signal); - - // TODO: add clean up function, but how? - }, [ - session, - ]); - const handleInput = useCallback((input: string, key: Key): boolean => { if (key.ctrl && input === "c") @@ -58,10 +44,9 @@ export function useChatController(props: UseChatControllerProps) { setCtrlC(true); } - else if (abortController) + else if (session.isWorking) { - abortController.abort("Ctrl-C"); - setAbortController(undefined); + session.abort("Ctrl-C"); } else { @@ -88,23 +73,7 @@ export function useChatController(props: UseChatControllerProps) { const confirmState = (session.messages[selectedIndex] as ToolProgressMessage).confirmState; - if (confirmState === "none") - { - setToolMode?.(session.toolMode = "read-only"); - } - else if (confirmState === "all") - { - setToolMode?.(session.toolMode = "yolo"); - } - - handleSendHistory([ - ...session.messages.slice(0, selectedIndex), - (session.messages[selectedIndex] as ToolProgressMessage).clone({ - status: (confirmState === "yes" || confirmState === "all") ? "confirmed" : "declined", - confirmState, - }), - ...session.messages.slice(selectedIndex + 1), - ]); + session.confirmToolUse(selectedIndex, confirmState); } return true; @@ -119,17 +88,15 @@ export function useChatController(props: UseChatControllerProps) if (key.ctrl && input === "w") { - setToolMode(toolMode => session.toolMode = (toolMode === "confirm" ? "read-only" : toolMode === "read-only" ? "yolo" : "confirm")); + session.setToolMode(session.toolMode === "confirm" ? "read-only" : session.toolMode === "read-only" ? "yolo" : "confirm"); return true; } return !!confirm; // do not allow input as long as tool-confirmation is needed }, [ - abortController, confirm, ctrlC, exit, - handleSendHistory, selectedIndex, session, ]); @@ -138,7 +105,6 @@ export function useChatController(props: UseChatControllerProps) { if (!sessionState.working) { - setAbortController(undefined); setCtrlC(false); setTokenUsage(countTokens(session.messages)); @@ -159,7 +125,7 @@ export function useChatController(props: UseChatControllerProps) // TODO: refactor const action = ctrlC ? sessionState.working - ? abortController === null + ? session.isAborted ? "Cancelled. Please wait ..." : "Cancel? Press Ctrl-C again ..." : "Quit? Press Ctrl-C again ..." @@ -173,8 +139,6 @@ export function useChatController(props: UseChatControllerProps) handleInput, action, selected: selectedIndex, - setToolMode, - handleSendHistory, tokenUsage, currentGitBranch, }; From 8110bc92e0039a3820783d2ccb3f8bea8285feb1 Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 9 Sep 2025 23:06:58 +0200 Subject: [PATCH 102/110] fix: add description and fileName to toolCall mapping in chat-server-client --- src/ai/session/chat-server/chat-server-client.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ai/session/chat-server/chat-server-client.ts b/src/ai/session/chat-server/chat-server-client.ts index ce45fac..e0e0446 100644 --- a/src/ai/session/chat-server/chat-server-client.ts +++ b/src/ai/session/chat-server/chat-server-client.ts @@ -34,6 +34,8 @@ function mapMessage(message: TMessage): IChatMessage ...toolProgressMessage.toolCall, status: toolProgressMessage.status, confirmState: toolProgressMessage.confirmState, + description: toolProgressMessage.info.description, + fileName: toolProgressMessage.info.fileName, }, }; } From 6675e459d2e99bdade2750fb6ec61ed0b4842804 Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 9 Sep 2025 23:09:11 +0200 Subject: [PATCH 103/110] feat: add progress message updates to file system tools operations --- src/ai/tools/file-system-tools.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/ai/tools/file-system-tools.ts b/src/ai/tools/file-system-tools.ts index 4b978e5..34603de 100644 --- a/src/ai/tools/file-system-tools.ts +++ b/src/ai/tools/file-system-tools.ts @@ -5,7 +5,7 @@ import { globbySync } from "globby"; import path from "path"; import { z } from "zod"; -import { createTool, resolveWithinWorkDir } from "./utils.js"; +import { createTool, resolveWithinWorkDir, setToolProgressMessageInfo } from "./utils.js"; function listDirectory({ directory = "." }: { directory: string }, config?: RunnableConfig): string { @@ -14,6 +14,8 @@ function listDirectory({ directory = "." }: { directory: string }, config?: Runn const resolvedPath = resolveWithinWorkDir(directory, config?.metadata?.["workDir"]); const combinedPath = path.join(resolvedPath, "*"); + setToolProgressMessageInfo({ config, fileName: directory }); + const files = globbySync(combinedPath, { dot: true, onlyFiles: false, @@ -41,6 +43,8 @@ function readFile({ fileName, maxLength }: { fileName: string; maxLength?: numbe { const resolvedPath = resolveWithinWorkDir(fileName, config?.metadata?.["workDir"]); + setToolProgressMessageInfo({ config, fileName }); + const content = readFileSync(resolvedPath).toString().slice(0, maxLength); return content || `The file "${fileName}" is empty.`; @@ -56,7 +60,9 @@ function writeFile({ fileName, fileData }: { fileName: string; fileData: string try { const resolvedPath = resolveWithinWorkDir(fileName, config?.metadata?.["workDir"]); - + + setToolProgressMessageInfo({ config, fileName }); + mkdirSync(path.dirname(resolvedPath), { recursive: true }); writeFileSync(resolvedPath, fileData); @@ -76,6 +82,8 @@ function moveFile({ sourceFileName, destinationFileName }: { sourceFileName: str const resolvedSourcePath = resolveWithinWorkDir(sourceFileName, config?.metadata?.["workDir"]); const resolvedDestinationPath = resolveWithinWorkDir(destinationFileName, config?.metadata?.["workDir"]); + setToolProgressMessageInfo({ config, description: `Rename ${sourceFileName} to ${destinationFileName}` }); + renameSync(resolvedSourcePath, resolvedDestinationPath); return `${sourceFileName} moved to ${destinationFileName}.`; @@ -92,6 +100,8 @@ function deleteFile({ fileName }: { fileName: string }, config: RunnableConfig): { const resolvedPath = resolveWithinWorkDir(fileName, config?.metadata?.["workDir"]); + setToolProgressMessageInfo({ config, fileName }); + unlinkSync(resolvedPath); return `${fileName} deleted.`; @@ -108,6 +118,8 @@ function createDirectory({ fileName }: { fileName: string }, config: RunnableCon { const resolvedPath = resolveWithinWorkDir(fileName, config?.metadata?.["workDir"]); + setToolProgressMessageInfo({ config, fileName }); + mkdirSync(resolvedPath, { recursive: true }); return `${fileName} created.`; @@ -125,6 +137,8 @@ function findFiles({ pattern, directory = "." }: { pattern: string; directory?: const resolvedPath = resolveWithinWorkDir(directory, config?.metadata?.["workDir"]); const combinedPattern = path.join(resolvedPath, pattern); + setToolProgressMessageInfo({ config, fileName: path.join(directory, pattern) }); + const files = globbySync(combinedPattern, { dot: true, onlyFiles: true, @@ -156,6 +170,8 @@ function searchInFiles({ pattern, glob: globPattern, directory = ".", isCaseSens const resolvedPath = resolveWithinWorkDir(directory, workDir); const combinedGlob = path.join(resolvedPath, globPattern); + setToolProgressMessageInfo({ config, description: `Search for ${pattern} in ${path.join(directory, globPattern)}` }); + const files = globbySync(combinedGlob, { dot: true, onlyFiles: true, From e1e279a7a1e316c5c370a40498fd6f16fc07c6e2 Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 9 Sep 2025 23:09:23 +0200 Subject: [PATCH 104/110] feat: add setToolProgressMessageInfo to update progress message details --- src/ai/tools/utils.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/ai/tools/utils.ts b/src/ai/tools/utils.ts index 6fe8e91..236a617 100644 --- a/src/ai/tools/utils.ts +++ b/src/ai/tools/utils.ts @@ -3,6 +3,8 @@ import { tool } from "@langchain/core/tools"; import { realpathSync } from "fs"; import path from "path"; +import { ToolProgressMessage } from "../custom-messages.js"; + export function resolveWithinWorkDir(userPath: string, workDir?: unknown): string { if (!workDir || typeof workDir !== "string") @@ -45,3 +47,14 @@ export function formatEmptyToolOutput(f: Function) { return `Tool \`${funcName(f)}\` produced no output.`; } + +export function setToolProgressMessageInfo(props: { config?: RunnableConfig; description?: string; fileName?: string; }) +{ + const toolProgressMessage = props?.config?.metadata?.["toolProgressMessage"] as (ToolProgressMessage | undefined); + + if (toolProgressMessage) + { + toolProgressMessage.info.description = props.description; + toolProgressMessage.info.fileName = props.fileName; + } +} From c35c97cfeb6cecdcaf3be5f060ab3e23d0f55522 Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 9 Sep 2025 23:37:57 +0200 Subject: [PATCH 105/110] chore: add TODO comment to relocate handleCommand method from session --- src/ai/session/session.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ai/session/session.ts b/src/ai/session/session.ts index ddf6810..bc3affa 100644 --- a/src/ai/session/session.ts +++ b/src/ai/session/session.ts @@ -135,6 +135,7 @@ export class ChatSession implements IChatSession, IApiClientCommandListener return chatSession; } + // TODO: this should not be in session! handleCommand = (payload: any) => { if (payload.new) From 97a2e4e71d2170964059a30fb050037945e4a300 Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 9 Sep 2025 23:38:10 +0200 Subject: [PATCH 106/110] fix: update abort method to check if already aborted before aborting and require reason as string --- src/ai/session/session.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/ai/session/session.ts b/src/ai/session/session.ts index bc3affa..e96fa7a 100644 --- a/src/ai/session/session.ts +++ b/src/ai/session/session.ts @@ -262,10 +262,13 @@ export class ChatSession implements IChatSession, IApiClientCommandListener }); }; - abort = (reason: any) => + abort = (reason: string) => { - this.abortController?.abort(reason); - this.notifyListeners(); + if (this.abortController && !this.abortController.signal.aborted) + { + this.abortController?.abort(reason); + this.notifyListeners(); + } }; toggleConfirmState = async (messageIndex: number, direction: -1 | 1): Promise => From 3446c6e4161e01ee297471dd384f2924fd9c89fe Mon Sep 17 00:00:00 2001 From: janole Date: Tue, 9 Sep 2025 23:38:32 +0200 Subject: [PATCH 107/110] feat: add abort handling to cancel ongoing chat session on external abort signal --- src/ai/session/session.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/ai/session/session.ts b/src/ai/session/session.ts index e96fa7a..a92b2a1 100644 --- a/src/ai/session/session.ts +++ b/src/ai/session/session.ts @@ -160,6 +160,13 @@ export class ChatSession implements IChatSession, IApiClientCommandListener this.confirmToolUse(payload.new.data.message_index, payload.new.data.confirm_state); } } + else if (payload.new?.external_id === `${this.id}/abort`) + { + if (this.#isWorking) + { + this.abort(payload.new.data?.reason || "User cancelled."); + } + } }; setChatService = (chatService: ChatService): ChatSession => From c2394508345d10edaa7690e8ae10e4ca09f2cd17 Mon Sep 17 00:00:00 2001 From: janole Date: Wed, 10 Sep 2025 00:03:43 +0200 Subject: [PATCH 108/110] fix: return the modified message object in setMessageIsStreaming function --- src/ai/custom-messages.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ai/custom-messages.ts b/src/ai/custom-messages.ts index 8a75a88..21e2d30 100644 --- a/src/ai/custom-messages.ts +++ b/src/ai/custom-messages.ts @@ -169,6 +169,7 @@ export const isCustomMessage = (message: any): message is CustomMessage => const setMessageIsStreaming = (msg: BaseMessage, isStreaming: boolean) => { msg.additional_kwargs["_coba_message_is_streaming"] = isStreaming; + return msg; }; const isMessageStreaming = (msg: TMessage): boolean => From 3bff06cd26fde68994be3f924e699f5315384963 Mon Sep 17 00:00:00 2001 From: janole Date: Wed, 10 Sep 2025 00:03:51 +0200 Subject: [PATCH 109/110] feat: add resetIsStreamingFlag function to reset streaming flag on messages --- src/ai/custom-messages.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/ai/custom-messages.ts b/src/ai/custom-messages.ts index 21e2d30..24df355 100644 --- a/src/ai/custom-messages.ts +++ b/src/ai/custom-messages.ts @@ -187,12 +187,17 @@ const isMessageFinished = (msg: TMessage): boolean => return msg.isMessageFinished(); }; +const resetIsStreamingFlag = (msg: TMessage[]): TMessage[] => +{ + return msg.map(m => (m instanceof BaseMessage) ? setMessageIsStreaming(m, false) : m); +}; + export { ErrorMessage, isMessageFinished, isMessageStreaming, + resetIsStreamingFlag, setMessageIsStreaming, ToolProgressMessage, }; - From 59ea9737044076a7ca0d66bd3431c4f28f333cb5 Mon Sep 17 00:00:00 2001 From: janole Date: Wed, 10 Sep 2025 00:04:03 +0200 Subject: [PATCH 110/110] fix: reset streaming flag on messages when handling work error in chat session --- src/ai/session/session.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ai/session/session.ts b/src/ai/session/session.ts index a92b2a1..1d5c8fa 100644 --- a/src/ai/session/session.ts +++ b/src/ai/session/session.ts @@ -2,7 +2,7 @@ import { HumanMessage, mapChatMessagesToStoredMessages, mapStoredMessageToChatMe import { ulid } from "ulid"; import { ChatService, IChatServiceOptions } from "../chat-service.js"; -import { CustomMessage, ErrorMessage, isCustomMessage, isMessageStreaming, TMessage, ToolProgressMessage } from "../custom-messages.js"; +import { CustomMessage, ErrorMessage, isCustomMessage, isMessageStreaming, resetIsStreamingFlag, TMessage, ToolProgressMessage } from "../custom-messages.js"; import { work } from "../work.js"; import { IApiClientCommandListener } from "./chat-server/api-client.js"; import { chatServerClient, syncSession } from "./chat-server/chat-server-client.js"; @@ -258,7 +258,7 @@ export class ChatSession implements IChatSession, IApiClientCommandListener { this.#isWorking = false; const newMessages = [ - ...this.messages, + ...resetIsStreamingFlag(this.messages), new ErrorMessage(`ERROR: running work({...}) failed with: ${error.message || error.toString()}`, error), ]; this.setMessages(newMessages);