From d97e1fc0957a94bce1d2a1a285a1976145624de3 Mon Sep 17 00:00:00 2001 From: Marc McIntosh Date: Tue, 15 Jul 2025 16:08:02 +0200 Subject: [PATCH 01/15] ci: trigger build --- refact-agent/gui/remove.txt | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 refact-agent/gui/remove.txt diff --git a/refact-agent/gui/remove.txt b/refact-agent/gui/remove.txt new file mode 100644 index 000000000..e69de29bb From 5a931a3aefb46ccd5d477274457fb2c5c71ce342 Mon Sep 17 00:00:00 2001 From: Marc McIntosh Date: Tue, 15 Jul 2025 16:08:30 +0200 Subject: [PATCH 02/15] ci: remove file added to trigger build --- refact-agent/gui/remove.txt | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 refact-agent/gui/remove.txt diff --git a/refact-agent/gui/remove.txt b/refact-agent/gui/remove.txt deleted file mode 100644 index e69de29bb..000000000 From 3c8a61481fc529ce20140ccda8f345d0f0832d15 Mon Sep 17 00:00:00 2001 From: Kirill Starkov Date: Tue, 15 Jul 2025 17:17:18 +0300 Subject: [PATCH 03/15] add workflow_dispatch to gui workflow --- .github/workflows/agent_gui_build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/agent_gui_build.yml b/.github/workflows/agent_gui_build.yml index 9901e0e6c..29fa46e02 100644 --- a/.github/workflows/agent_gui_build.yml +++ b/.github/workflows/agent_gui_build.yml @@ -10,6 +10,7 @@ on: paths: - "refact-agent/gui/**" - ".github/workflows/agent_gui_*" + workflow_dispatch: defaults: run: From ff07d9c56e59e3a8bbb9cae237c3ba4f2b2ed8e2 Mon Sep 17 00:00:00 2001 From: JegernOUTT Date: Thu, 27 Nov 2025 15:58:55 +1030 Subject: [PATCH 04/15] if hasError it doesn't block chat history --- .../src/components/Sidebar/GroupTree/GroupTree.tsx | 5 +++++ .../components/Sidebar/GroupTree/useGroupTree.ts | 6 ++++++ .../gui/src/components/Sidebar/Sidebar.tsx | 14 +++++--------- refact-agent/gui/src/hooks/useActiveTeamsGroup.ts | 6 +----- 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/refact-agent/gui/src/components/Sidebar/GroupTree/GroupTree.tsx b/refact-agent/gui/src/components/Sidebar/GroupTree/GroupTree.tsx index 6d7a8a654..604f38289 100644 --- a/refact-agent/gui/src/components/Sidebar/GroupTree/GroupTree.tsx +++ b/refact-agent/gui/src/components/Sidebar/GroupTree/GroupTree.tsx @@ -33,8 +33,13 @@ export const GroupTree: React.FC = () => { onWorkspaceSelection, availableWorkspaces, treeHeight, + hasError, } = useGroupTree(); + if (hasError) { + return null; + } + return ( diff --git a/refact-agent/gui/src/components/Sidebar/GroupTree/useGroupTree.ts b/refact-agent/gui/src/components/Sidebar/GroupTree/useGroupTree.ts index fe012b2b8..e12a0b363 100644 --- a/refact-agent/gui/src/components/Sidebar/GroupTree/useGroupTree.ts +++ b/refact-agent/gui/src/components/Sidebar/GroupTree/useGroupTree.ts @@ -200,6 +200,9 @@ export function useGroupTree() { return []; }, [teamsWorkspaces.data?.query_basic_stuff.workspaces]); + const hasError = teamsWorkspaces.error !== undefined; + const isLoading = teamsWorkspaces.fetching; + return { // Refs treeParentRef, @@ -222,5 +225,8 @@ export function useGroupTree() { setCurrentTeamsWorkspace, setGroupTreeData, setCurrentSelectedTeamsGroupNode, + // Status + hasError, + isLoading, }; } diff --git a/refact-agent/gui/src/components/Sidebar/Sidebar.tsx b/refact-agent/gui/src/components/Sidebar/Sidebar.tsx index a7f25f5b0..6a07f5d58 100644 --- a/refact-agent/gui/src/components/Sidebar/Sidebar.tsx +++ b/refact-agent/gui/src/components/Sidebar/Sidebar.tsx @@ -64,15 +64,11 @@ export const Sidebar: React.FC = ({ takingNotes, style }) => { - {!groupSelectionEnabled ? ( - - ) : ( - - )} + {/* TODO: duplicated */} {globalError && ( { - if (isKnowledgeFeatureAvailable) { - return !!maybeActiveTeamsGroup; - } - return true; - }, [maybeActiveTeamsGroup, isKnowledgeFeatureAvailable]); + }, []); return { groupSelectionEnabled, From 2e476cca504984aa78e14d8bc8a57cdd9fe44c53 Mon Sep 17 00:00:00 2001 From: JegernOUTT Date: Thu, 27 Nov 2025 16:29:29 +1030 Subject: [PATCH 05/15] postProcessMessagesAfterStreaming --- .../gui/src/features/Chat/Thread/reducer.ts | 4 +- .../src/features/Chat/Thread/utils.test.ts | 116 +++++++++++++++++- .../gui/src/features/Chat/Thread/utils.ts | 47 +++++++ 3 files changed, 165 insertions(+), 2 deletions(-) diff --git a/refact-agent/gui/src/features/Chat/Thread/reducer.ts b/refact-agent/gui/src/features/Chat/Thread/reducer.ts index db0b07459..6ca05c567 100644 --- a/refact-agent/gui/src/features/Chat/Thread/reducer.ts +++ b/refact-agent/gui/src/features/Chat/Thread/reducer.ts @@ -42,7 +42,7 @@ import { setAreFollowUpsEnabled, setIsTitleGenerationEnabled, } from "./actions"; -import { formatChatResponse } from "./utils"; +import { formatChatResponse, postProcessMessagesAfterStreaming } from "./utils"; import { ChatMessages, commandsApi, @@ -241,6 +241,7 @@ export const chatReducer = createReducer(initialState, (builder) => { state.streaming = false; state.waiting_for_response = false; state.thread.read = true; + state.thread.messages = postProcessMessagesAfterStreaming(state.thread.messages); }); builder.addCase(setAutomaticPatch, (state, action) => { @@ -323,6 +324,7 @@ export const chatReducer = createReducer(initialState, (builder) => { new_chat_suggested: { wasSuggested: false }, ...mostUptoDateThread, }; + state.thread.messages = postProcessMessagesAfterStreaming(state.thread.messages); state.thread.tool_use = state.thread.tool_use ?? state.tool_use; if (action.payload.mode && !isLspChatMode(action.payload.mode)) { state.thread.mode = "AGENT"; diff --git a/refact-agent/gui/src/features/Chat/Thread/utils.test.ts b/refact-agent/gui/src/features/Chat/Thread/utils.test.ts index 8ed8383df..a24645fa4 100644 --- a/refact-agent/gui/src/features/Chat/Thread/utils.test.ts +++ b/refact-agent/gui/src/features/Chat/Thread/utils.test.ts @@ -8,7 +8,7 @@ import { UserMessageResponse, type ToolCall, } from "../../../services/refact"; -import { mergeToolCalls, formatChatResponse, consumeStream } from "./utils"; +import { mergeToolCalls, formatChatResponse, consumeStream, postProcessMessagesAfterStreaming } from "./utils"; describe("formatChatResponse", () => { test("it should replace the last user message", () => { @@ -1731,3 +1731,117 @@ describe("consumeStream", () => { }); }); }); + +describe("postProcessMessagesAfterStreaming", () => { + test("should filter out web_search tool calls and append to message", () => { + const messages: ChatMessages = [ + { + role: "assistant", + content: "I'll search for the weather.", + tool_calls: [ + { + id: "call_123", + index: 0, + function: { + name: "web_search", + arguments: '{"query": "weather in Adelaide"}', + }, + }, + { + id: "call_456", + index: 1, + function: { + name: "str_replace", + arguments: '{"old": "a", "new": "b"}', + }, + }, + ], + }, + ]; + + const result = postProcessMessagesAfterStreaming(messages); + + expect(result).toHaveLength(1); + expect(result[0].role).toBe("assistant"); + if ("tool_calls" in result[0] && "content" in result[0]) { + expect(result[0].tool_calls).toHaveLength(1); + expect(result[0].tool_calls?.[0].function.name).toBe("str_replace"); + expect(result[0].content).toBe( + 'I\'ll search for the weather.\n\n---\n\n☁️ **web_search**`({"query": "weather in Adelaide"})` was called on the cloud' + ); + } + }); + + test("should remove tool_calls when all are filtered and append info", () => { + const messages: ChatMessages = [ + { + role: "assistant", + content: "Searching for information.", + tool_calls: [ + { + id: "call_123", + index: 0, + function: { + name: "web_search", + arguments: '{"query": "test"}', + }, + }, + ], + }, + ]; + + const result = postProcessMessagesAfterStreaming(messages); + + expect(result).toHaveLength(1); + expect(result[0].role).toBe("assistant"); + if ("content" in result[0]) { + expect(result[0].content).toBe( + 'Searching for information.\n\n---\n\n☁️ **web_search**`({"query": "test"})` was called on the cloud' + ); + } + if ("tool_calls" in result[0]) { + expect(result[0].tool_calls).toBeUndefined(); + } + }); + + test("should not modify messages without tool_calls", () => { + const messages: ChatMessages = [ + { + role: "user", + content: "Hello", + checkpoints: [], + }, + { + role: "assistant", + content: "Hi there!", + }, + ]; + + const result = postProcessMessagesAfterStreaming(messages); + + expect(result).toEqual(messages); + }); + + test("should not modify messages with non-filtered tools", () => { + const messages: ChatMessages = [ + { + role: "assistant", + content: "I'll replace that for you.", + tool_calls: [ + { + id: "call_456", + index: 0, + function: { + name: "str_replace", + arguments: '{"old": "a", "new": "b"}', + }, + }, + ], + }, + ]; + + const result = postProcessMessagesAfterStreaming(messages); + + expect(result).toEqual(messages); + }); +}); diff --git a/refact-agent/gui/src/features/Chat/Thread/utils.ts b/refact-agent/gui/src/features/Chat/Thread/utils.ts index e97b9d29d..59691b1e3 100644 --- a/refact-agent/gui/src/features/Chat/Thread/utils.ts +++ b/refact-agent/gui/src/features/Chat/Thread/utils.ts @@ -41,6 +41,53 @@ import { parseOrElse } from "../../../utils"; import { type LspChatMessage } from "../../../services/refact"; import { checkForDetailMessage } from "./types"; +const external_ignored_tools = ["web_search"]; + +export function postProcessMessagesAfterStreaming(messages: ChatMessages): ChatMessages { + return messages.map((message) => { + if (!isAssistantMessage(message) || !message.tool_calls) { + return message; + } + + const ignoredTools: ToolCall[] = []; + const keptTools: ToolCall[] = []; + + message.tool_calls.forEach((tool) => { + if (external_ignored_tools.includes(tool.function.name)) { + ignoredTools.push(tool); + } else { + keptTools.push(tool); + } + }); + + if (ignoredTools.length === 0) { + return message; + } + + const ignoredText = ignoredTools.map((tool) => { + let args = tool.function.arguments + .replace(/\}\{+\}/g, '}') + .replace(/\{+\)/g, ')') + .replace(/\}\{/g, '}') + .trim(); + + if (args.endsWith('{')) { + args = args.slice(0, -1) + '}'; + } + + return `\n---\n\n☁️ **${tool.function.name}**\`(${args})\` was called on the cloud`; + }).join(""); + + const updatedContent = message.content + "\n" + ignoredText; + + return { + ...message, + content: updatedContent, + tool_calls: keptTools.length > 0 ? keptTools : undefined, + }; + }); +} + // export const TAKE_NOTE_MESSAGE = [ // 'How many times user has corrected or directed you? Write "Number of correction points N".', // 'Then start each one with "---\n", describe what you (the assistant) did wrong, write "Mistake: ..."', From ca17acea53dcb5ad25f58c9fdde2400c5740f739 Mon Sep 17 00:00:00 2001 From: JegernOUTT Date: Thu, 27 Nov 2025 20:56:31 +1030 Subject: [PATCH 06/15] deduplicateToolCalls --- .../src/features/Chat/Thread/utils.test.ts | 101 ++++++++++++++++++ .../gui/src/features/Chat/Thread/utils.ts | 28 ++++- 2 files changed, 126 insertions(+), 3 deletions(-) diff --git a/refact-agent/gui/src/features/Chat/Thread/utils.test.ts b/refact-agent/gui/src/features/Chat/Thread/utils.test.ts index a24645fa4..20937a4f7 100644 --- a/refact-agent/gui/src/features/Chat/Thread/utils.test.ts +++ b/refact-agent/gui/src/features/Chat/Thread/utils.test.ts @@ -1844,4 +1844,105 @@ describe("postProcessMessagesAfterStreaming", () => { expect(result).toEqual(messages); }); + + test("should deduplicate tool calls with same ID, keeping the one with arguments", () => { + const messages: ChatMessages = [ + { + role: "assistant", + content: "Processing your request.", + tool_calls: [ + { + id: "call_123", + index: 0, + function: { + name: "tree", + arguments: "", + }, + }, + { + id: "call_123", + index: 1, + function: { + name: "tree", + arguments: '{"path": "/src"}', + }, + }, + { + id: "call_456", + index: 2, + function: { + name: "cat", + arguments: '{"file": "test.js"}', + }, + }, + ], + }, + ]; + + const result = postProcessMessagesAfterStreaming(messages); + + expect(result).toHaveLength(1); + expect(result[0].role).toBe("assistant"); + if ("tool_calls" in result[0]) { + expect(result[0].tool_calls).toHaveLength(2); + expect(result[0].tool_calls?.[0].id).toBe("call_123"); + expect(result[0].tool_calls?.[0].function.arguments).toBe('{"path": "/src"}'); + expect(result[0].tool_calls?.[1].id).toBe("call_456"); + } + }); + + test("should handle deduplication and filtering together", () => { + const messages: ChatMessages = [ + { + role: "assistant", + content: "Let me search and check the files.", + tool_calls: [ + { + id: "call_123", + index: 0, + function: { + name: "web_search", + arguments: "", + }, + }, + { + id: "call_123", + index: 1, + function: { + name: "web_search", + arguments: '{"query": "test search"}', + }, + }, + { + id: "call_456", + index: 2, + function: { + name: "tree", + arguments: "", + }, + }, + { + id: "call_456", + index: 3, + function: { + name: "tree", + arguments: '{"path": "/"}', + }, + }, + ], + }, + ]; + + const result = postProcessMessagesAfterStreaming(messages); + + expect(result).toHaveLength(1); + if ("tool_calls" in result[0] && "content" in result[0]) { + expect(result[0].tool_calls).toHaveLength(1); + expect(result[0].tool_calls?.[0].id).toBe("call_456"); + expect(result[0].tool_calls?.[0].function.name).toBe("tree"); + expect(result[0].tool_calls?.[0].function.arguments).toBe('{"path": "/"}'); + expect(result[0].content).toContain('web_search'); + expect(result[0].content).toContain('{"query": "test search"}'); + } + }); }); diff --git a/refact-agent/gui/src/features/Chat/Thread/utils.ts b/refact-agent/gui/src/features/Chat/Thread/utils.ts index 59691b1e3..111c2c7ad 100644 --- a/refact-agent/gui/src/features/Chat/Thread/utils.ts +++ b/refact-agent/gui/src/features/Chat/Thread/utils.ts @@ -49,10 +49,11 @@ export function postProcessMessagesAfterStreaming(messages: ChatMessages): ChatM return message; } + const deduplicatedTools = deduplicateToolCalls(message.tool_calls); const ignoredTools: ToolCall[] = []; const keptTools: ToolCall[] = []; - message.tool_calls.forEach((tool) => { + deduplicatedTools.forEach((tool) => { if (external_ignored_tools.includes(tool.function.name)) { ignoredTools.push(tool); } else { @@ -60,7 +61,7 @@ export function postProcessMessagesAfterStreaming(messages: ChatMessages): ChatM } }); - if (ignoredTools.length === 0) { + if (ignoredTools.length === 0 && deduplicatedTools.length === message.tool_calls.length) { return message; } @@ -78,7 +79,7 @@ export function postProcessMessagesAfterStreaming(messages: ChatMessages): ChatM return `\n---\n\n☁️ **${tool.function.name}**\`(${args})\` was called on the cloud`; }).join(""); - const updatedContent = message.content + "\n" + ignoredText; + const updatedContent = ignoredText ? message.content + "\n" + ignoredText : message.content; return { ...message, @@ -88,6 +89,27 @@ export function postProcessMessagesAfterStreaming(messages: ChatMessages): ChatM }); } +function deduplicateToolCalls(toolCalls: ToolCall[]): ToolCall[] { + const toolCallMap = new Map(); + + toolCalls.forEach((tool) => { + const existingTool = toolCallMap.get(tool.id); + + if (!existingTool) { + toolCallMap.set(tool.id, tool); + } else { + const existingHasArgs = existingTool.function.arguments && existingTool.function.arguments.trim() !== ""; + const newHasArgs = tool.function.arguments && tool.function.arguments.trim() !== ""; + + if (!existingHasArgs && newHasArgs) { + toolCallMap.set(tool.id, tool); + } + } + }); + + return Array.from(toolCallMap.values()); +} + // export const TAKE_NOTE_MESSAGE = [ // 'How many times user has corrected or directed you? Write "Number of correction points N".', // 'Then start each one with "---\n", describe what you (the assistant) did wrong, write "Mistake: ..."', From 3fced8707baa2a12eb5c6af38787aeedf50c18a5 Mon Sep 17 00:00:00 2001 From: JegernOUTT Date: Fri, 28 Nov 2025 00:35:10 +1030 Subject: [PATCH 07/15] update_textdoc_by_lines --- .../gui/src/components/Tools/Textdoc.tsx | 40 +++++++++++++++++++ .../gui/src/components/Tools/types.ts | 31 +++++++++++++- .../gui/src/hooks/useSendChatRequest.ts | 1 + 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/refact-agent/gui/src/components/Tools/Textdoc.tsx b/refact-agent/gui/src/components/Tools/Textdoc.tsx index f59195d3e..80a2a1d82 100644 --- a/refact-agent/gui/src/components/Tools/Textdoc.tsx +++ b/refact-agent/gui/src/components/Tools/Textdoc.tsx @@ -12,10 +12,12 @@ import { TextDocToolCall, UpdateRegexTextDocToolCall, UpdateTextDocToolCall, + UpdateTextDocByLinesToolCall, isCreateTextDocToolCall, isReplaceTextDocToolCall, isUpdateRegexTextDocToolCall, isUpdateTextDocToolCall, + isUpdateTextDocByLinesToolCall, parseRawTextDocToolCall, } from "./types"; import { Box, Card, Flex, Button } from "@radix-ui/themes"; @@ -58,6 +60,10 @@ export const TextDocTool: React.FC<{ return ; } + if (isUpdateTextDocByLinesToolCall(maybeTextDocToolCall)) { + return ; + } + return false; }; @@ -94,6 +100,8 @@ const TextDocHeader = forwardRef( return toolCall.function.arguments.content; if (isUpdateTextDocToolCall(toolCall)) return toolCall.function.arguments.replacement; + if (isUpdateTextDocByLinesToolCall(toolCall)) + return toolCall.function.arguments.content; return null; }, [toolCall]); @@ -306,6 +314,38 @@ const UpdateTextDoc: React.FC<{ ); }; +const UpdateTextDocByLines: React.FC<{ + toolCall: UpdateTextDocByLinesToolCall; +}> = ({ toolCall }) => { + const copyToClipBoard = useCopyToClipboard(); + const ref = useRef(null); + const handleClose = useHideScroll(ref); + const handleCopy = useCallback(() => { + copyToClipBoard(toolCall.function.arguments.content); + }, [copyToClipBoard, toolCall.function.arguments.content]); + + const className = useMemo(() => { + const extension = getFileExtension(toolCall.function.arguments.path); + return `language-${extension}`; + }, [toolCall.function.arguments.path]); + + const lineCount = useMemo( + () => toolCall.function.arguments.content.split("\n").length, + [toolCall.function.arguments.content], + ); + + return ( + + + + + {toolCall.function.arguments.content} + + + + ); +}; + function getFileExtension(filePath: string): string { const fileName = filename(filePath); if (fileName.toLocaleLowerCase().startsWith("dockerfile")) diff --git a/refact-agent/gui/src/components/Tools/types.ts b/refact-agent/gui/src/components/Tools/types.ts index 5e10eef15..547b07a7a 100644 --- a/refact-agent/gui/src/components/Tools/types.ts +++ b/refact-agent/gui/src/components/Tools/types.ts @@ -6,6 +6,7 @@ export const TEXTDOC_TOOL_NAMES = [ "update_textdoc", "replace_textdoc", "update_textdoc_regex", + "update_textdoc_by_lines", ]; type TextDocToolNames = (typeof TEXTDOC_TOOL_NAMES)[number]; @@ -151,11 +152,36 @@ export const isReplaceTextDocToolCall = ( return true; }; +export interface UpdateTextDocByLinesToolCall extends ParsedRawTextDocToolCall { + function: { + name: string; + arguments: { + path: string; + content: string; + ranges: string; + }; + }; +} + +export const isUpdateTextDocByLinesToolCall = ( + toolCall: ParsedRawTextDocToolCall, +): toolCall is UpdateTextDocByLinesToolCall => { + if (toolCall.function.name !== "update_textdoc_by_lines") return false; + if (!("path" in toolCall.function.arguments)) return false; + if (typeof toolCall.function.arguments.path !== "string") return false; + if (!("content" in toolCall.function.arguments)) return false; + if (typeof toolCall.function.arguments.content !== "string") return false; + if (!("ranges" in toolCall.function.arguments)) return false; + if (typeof toolCall.function.arguments.ranges !== "string") return false; + return true; +}; + export type TextDocToolCall = | CreateTextDocToolCall | UpdateTextDocToolCall | ReplaceTextDocToolCall - | UpdateRegexTextDocToolCall; + | UpdateRegexTextDocToolCall + | UpdateTextDocByLinesToolCall; function isTextDocToolCall( toolCall: ParsedRawTextDocToolCall, @@ -164,7 +190,8 @@ function isTextDocToolCall( if (isUpdateTextDocToolCall(toolCall)) return true; if (isReplaceTextDocToolCall(toolCall)) return true; if (isUpdateRegexTextDocToolCall(toolCall)) return true; - return true; + if (isUpdateTextDocByLinesToolCall(toolCall)) return true; + return false; } export function parseRawTextDocToolCall( diff --git a/refact-agent/gui/src/hooks/useSendChatRequest.ts b/refact-agent/gui/src/hooks/useSendChatRequest.ts index 4d3e2afbd..fd96e5789 100644 --- a/refact-agent/gui/src/hooks/useSendChatRequest.ts +++ b/refact-agent/gui/src/hooks/useSendChatRequest.ts @@ -85,6 +85,7 @@ export const PATCH_LIKE_FUNCTIONS = [ "update_textdoc", "replace_textdoc", "update_textdoc_regex", + "update_textdoc_by_lines", ]; export const useSendChatRequest = () => { From 3056051eca772c1d8b408f17a32aec3fa215d8b7 Mon Sep 17 00:00:00 2001 From: JegernOUTT Date: Fri, 28 Nov 2025 01:14:02 +1030 Subject: [PATCH 08/15] model selector in the middle of a thread --- refact-agent/gui/src/components/Chat/Chat.tsx | 12 ++-- .../gui/src/components/Chat/ModelSelector.tsx | 64 +++++++++++++++++++ .../gui/src/components/Chat/index.tsx | 2 + .../gui/src/components/Toolbar/Dropdown.tsx | 2 +- refact-agent/gui/urqlProvider.tsx | 2 +- 5 files changed, 75 insertions(+), 7 deletions(-) create mode 100644 refact-agent/gui/src/components/Chat/ModelSelector.tsx diff --git a/refact-agent/gui/src/components/Chat/Chat.tsx b/refact-agent/gui/src/components/Chat/Chat.tsx index e383a9c00..d00db397b 100644 --- a/refact-agent/gui/src/components/Chat/Chat.tsx +++ b/refact-agent/gui/src/components/Chat/Chat.tsx @@ -7,7 +7,6 @@ import { useAppDispatch, useSendChatRequest, useAutoSend, - useCapsForToolUse, } from "../../hooks"; import { type Config } from "../../features/Config/configSlice"; import { @@ -25,6 +24,7 @@ import { DropzoneProvider } from "../Dropzone"; import { useCheckpoints } from "../../hooks/useCheckpoints"; import { Checkpoints } from "../../features/Checkpoints"; import { SuggestNewChat } from "../ChatForm/SuggestNewChat"; +import { ModelSelector } from "./ModelSelector"; export type ChatProps = { host: Config["host"]; @@ -51,7 +51,6 @@ export const Chat: React.FC = ({ const chatToolUse = useAppSelector(getSelectedToolUse); const threadNewChatSuggested = useAppSelector(selectThreadNewChatSuggested); const messages = useAppSelector(selectMessages); - const capsForToolUse = useCapsForToolUse(); const { shouldCheckpointsPopupBeShown } = useCheckpoints(); @@ -124,13 +123,16 @@ export const Chat: React.FC = ({ {/* Two flexboxes are left for the future UI element on the right side */} {messages.length > 0 && ( - - model: {capsForToolUse.currentModel} •{" "} + + + setIsDebugChatHistoryVisible((prev) => !prev)} + style={{ cursor: "pointer" }} > - mode: {chatToolUse}{" "} + mode: {chatToolUse} {messages.length !== 0 && diff --git a/refact-agent/gui/src/components/Chat/ModelSelector.tsx b/refact-agent/gui/src/components/Chat/ModelSelector.tsx new file mode 100644 index 000000000..31ada64d2 --- /dev/null +++ b/refact-agent/gui/src/components/Chat/ModelSelector.tsx @@ -0,0 +1,64 @@ +import React, { useMemo } from "react"; +import { Select, Text, Flex } from "@radix-ui/themes"; +import { useCapsForToolUse } from "../../hooks"; + +export type ModelSelectorProps = { + disabled?: boolean; +}; + +export const ModelSelector: React.FC = ({ disabled }) => { + const capsForToolUse = useCapsForToolUse(); + + const modelOptions = useMemo(() => { + return capsForToolUse.usableModelsForPlan.map((model) => ({ + value: model.value, + label: model.textValue, + disabled: model.disabled, + })); + }, [capsForToolUse.usableModelsForPlan]); + + if (!capsForToolUse.data || modelOptions.length === 0) { + return ( + + model: {capsForToolUse.currentModel} + + ); + } + + return ( + + + model: + + + + + {modelOptions.map((option) => ( + + {option.label} + + ))} + + + + ); +}; diff --git a/refact-agent/gui/src/components/Chat/index.tsx b/refact-agent/gui/src/components/Chat/index.tsx index 2d56b5039..d37aa7530 100644 --- a/refact-agent/gui/src/components/Chat/index.tsx +++ b/refact-agent/gui/src/components/Chat/index.tsx @@ -1,2 +1,4 @@ export { Chat } from "./Chat"; export type { ChatProps } from "./Chat"; +export { ModelSelector } from "./ModelSelector"; +export type { ModelSelectorProps } from "./ModelSelector"; diff --git a/refact-agent/gui/src/components/Toolbar/Dropdown.tsx b/refact-agent/gui/src/components/Toolbar/Dropdown.tsx index e9446f3c8..a195a930f 100644 --- a/refact-agent/gui/src/components/Toolbar/Dropdown.tsx +++ b/refact-agent/gui/src/components/Toolbar/Dropdown.tsx @@ -263,7 +263,7 @@ export const Dropdown: React.FC = ({ {isKnowledgeFeatureAvailable && ( openUrl("https://test-teams.smallcloud.ai/")} + onSelect={() => openUrl("https://flexus.team/")} > Manage Knowledge diff --git a/refact-agent/gui/urqlProvider.tsx b/refact-agent/gui/urqlProvider.tsx index 952ca63c9..e6c318c28 100644 --- a/refact-agent/gui/urqlProvider.tsx +++ b/refact-agent/gui/urqlProvider.tsx @@ -15,7 +15,7 @@ export const UrqlProvider: React.FC<{ children: React.ReactNode }> = ({ children, }) => { const apiKey = useAppSelector(selectConfig).apiKey; - const baseUrl = "app.refact.ai/v1/graphql"; + const baseUrl = "flexus.team/v1/graphql"; const protocol = "https"; const wsProtocol = "wss"; From 555f90fb741596f1d080880bc2529973c2180ffc Mon Sep 17 00:00:00 2001 From: JegernOUTT Date: Fri, 28 Nov 2025 03:07:10 +1030 Subject: [PATCH 09/15] fixed all npm errors --- refact-agent/gui/src/components/Sidebar/Sidebar.tsx | 6 ++---- refact-agent/gui/src/features/Chat/Thread/utils.ts | 3 ++- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/refact-agent/gui/src/components/Sidebar/Sidebar.tsx b/refact-agent/gui/src/components/Sidebar/Sidebar.tsx index 6a07f5d58..eed3c07ca 100644 --- a/refact-agent/gui/src/components/Sidebar/Sidebar.tsx +++ b/refact-agent/gui/src/components/Sidebar/Sidebar.tsx @@ -9,13 +9,13 @@ import { import { push } from "../../features/Pages/pagesSlice"; import { restoreChat } from "../../features/Chat/Thread"; import { FeatureMenu } from "../../features/Config/FeatureMenu"; -import { GroupTree } from "./GroupTree/"; + import { ErrorCallout } from "../Callout"; import { getErrorMessage, clearError } from "../../features/Errors/errorsSlice"; import classNames from "classnames"; import { selectHost } from "../../features/Config/configSlice"; import styles from "./Sidebar.module.css"; -import { useActiveTeamsGroup } from "../../hooks/useActiveTeamsGroup"; + export type SidebarProps = { takingNotes: boolean; @@ -40,8 +40,6 @@ export const Sidebar: React.FC = ({ takingNotes, style }) => { devModeChecks: { stabilityCheck: "never" }, }); - const { groupSelectionEnabled } = useActiveTeamsGroup(); - const onDeleteHistoryItem = useCallback( (id: string) => dispatch(deleteChatById(id)), [dispatch], diff --git a/refact-agent/gui/src/features/Chat/Thread/utils.ts b/refact-agent/gui/src/features/Chat/Thread/utils.ts index 111c2c7ad..863d49db3 100644 --- a/refact-agent/gui/src/features/Chat/Thread/utils.ts +++ b/refact-agent/gui/src/features/Chat/Thread/utils.ts @@ -54,7 +54,7 @@ export function postProcessMessagesAfterStreaming(messages: ChatMessages): ChatM const keptTools: ToolCall[] = []; deduplicatedTools.forEach((tool) => { - if (external_ignored_tools.includes(tool.function.name)) { + if (tool.function.name && external_ignored_tools.includes(tool.function.name)) { ignoredTools.push(tool); } else { keptTools.push(tool); @@ -93,6 +93,7 @@ function deduplicateToolCalls(toolCalls: ToolCall[]): ToolCall[] { const toolCallMap = new Map(); toolCalls.forEach((tool) => { + if (!tool.id) return; // Skip tools without an id const existingTool = toolCallMap.get(tool.id); if (!existingTool) { From 33b80aa3f8749b7dbe6ab5275555e5765572c905 Mon Sep 17 00:00:00 2001 From: JegernOUTT Date: Wed, 3 Dec 2025 23:52:42 +1030 Subject: [PATCH 10/15] Fix CI build: use npm install instead of npm ci The package-lock.json is missing platform-specific optional dependencies (like fsevents, @parcel/watcher-*, @esbuild/* packages) because it was generated on Linux. npm ci is strict and fails when these are missing. Using npm install --prefer-offline instead allows the build to proceed while still respecting the lock file for version resolution. --- .github/workflows/agent_gui_build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/agent_gui_build.yml b/.github/workflows/agent_gui_build.yml index 29fa46e02..a055644ed 100644 --- a/.github/workflows/agent_gui_build.yml +++ b/.github/workflows/agent_gui_build.yml @@ -34,13 +34,13 @@ jobs: cache: "npm" cache-dependency-path: refact-agent/gui/package-lock.json - # Disable Husky install during npm ci + # Disable Husky install during npm install --prefer-offline - name: Install dependencies run: | sudo apt update sudo apt install -y libcairo2-dev libjpeg-dev libpango1.0-dev libgif-dev librsvg2-dev npm pkg delete scripts.prepare - npm ci + npm install --prefer-offline - run: npm run test - run: npm run format:check From 032805c29fa55b009a36ac13e6f1de947ec0b306 Mon Sep 17 00:00:00 2001 From: JegernOUTT Date: Wed, 3 Dec 2025 23:56:39 +1030 Subject: [PATCH 11/15] Fix localStorage tests: add undefined check localStorage is not available in Node.js test environment. Added checks to prevent accessing localStorage when it's undefined, which fixes the 'localStorage.getItem is not a function' test errors. --- refact-agent/gui/src/app/storage.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/refact-agent/gui/src/app/storage.ts b/refact-agent/gui/src/app/storage.ts index 05a2515eb..a5d00dadb 100644 --- a/refact-agent/gui/src/app/storage.ts +++ b/refact-agent/gui/src/app/storage.ts @@ -52,12 +52,13 @@ function pruneHistory(key: string, item: string) { } function removeOldEntry(key: string) { - if (localStorage.getItem(key)) { + if (typeof localStorage !== "undefined" && localStorage.getItem(key)) { localStorage.removeItem(key); } } function cleanOldEntries() { + if (typeof localStorage === "undefined") return; removeOldEntry("tour"); removeOldEntry("tipOfTheDay"); removeOldEntry("chatHistory"); From bbc3204b7d1c60f2d08bd8e644e461902a41d485 Mon Sep 17 00:00:00 2001 From: JegernOUTT Date: Thu, 4 Dec 2025 00:01:59 +1030 Subject: [PATCH 12/15] Add localStorage mock for tests Mock localStorage in test setup to fix 'localStorage.getItem is not a function' errors. The happy-dom environment doesn't provide a fully functional localStorage by default. --- refact-agent/gui/src/utils/test-setup.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/refact-agent/gui/src/utils/test-setup.ts b/refact-agent/gui/src/utils/test-setup.ts index 05639525b..573702c5a 100644 --- a/refact-agent/gui/src/utils/test-setup.ts +++ b/refact-agent/gui/src/utils/test-setup.ts @@ -12,6 +12,15 @@ beforeAll(() => { stubResizeObserver(); stubIntersectionObserver(); Element.prototype.scrollIntoView = vi.fn(); + + // Mock localStorage for tests + const localStorageMock = { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + }; + global.localStorage = localStorageMock as Storage; }); afterEach(() => { From 38774b8acf6427be439bcdf59d16d8b1b7ed8dd9 Mon Sep 17 00:00:00 2001 From: JegernOUTT Date: Thu, 4 Dec 2025 00:08:31 +1030 Subject: [PATCH 13/15] Fix localStorage function check in storage.ts Add typeof check for localStorage.getItem to handle cases where localStorage exists but doesn't have the getItem method (can happen during test setup timing). --- refact-agent/gui/src/app/storage.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/refact-agent/gui/src/app/storage.ts b/refact-agent/gui/src/app/storage.ts index a5d00dadb..3e4d18558 100644 --- a/refact-agent/gui/src/app/storage.ts +++ b/refact-agent/gui/src/app/storage.ts @@ -52,7 +52,11 @@ function pruneHistory(key: string, item: string) { } function removeOldEntry(key: string) { - if (typeof localStorage !== "undefined" && localStorage.getItem(key)) { + if ( + typeof localStorage !== "undefined" && + typeof localStorage.getItem === "function" && + localStorage.getItem(key) + ) { localStorage.removeItem(key); } } From e3429565c9d4dfe240ab2c6286111f91a44c920a Mon Sep 17 00:00:00 2001 From: JegernOUTT Date: Thu, 4 Dec 2025 00:13:44 +1030 Subject: [PATCH 14/15] Fix code formatting with Prettier --- refact-agent/gui/src/components/Chat/Chat.tsx | 4 +- .../gui/src/components/Chat/ModelSelector.tsx | 6 +- .../gui/src/components/Sidebar/Sidebar.tsx | 1 - .../gui/src/features/Chat/Thread/reducer.ts | 8 +- .../src/features/Chat/Thread/utils.test.ts | 33 ++++++--- .../gui/src/features/Chat/Thread/utils.ts | 73 +++++++++++-------- refact-agent/gui/src/utils/test-setup.ts | 2 +- 7 files changed, 80 insertions(+), 47 deletions(-) diff --git a/refact-agent/gui/src/components/Chat/Chat.tsx b/refact-agent/gui/src/components/Chat/Chat.tsx index d00db397b..658bd6f96 100644 --- a/refact-agent/gui/src/components/Chat/Chat.tsx +++ b/refact-agent/gui/src/components/Chat/Chat.tsx @@ -125,7 +125,9 @@ export const Chat: React.FC = ({ - + + • + = ({ disabled }) => { > { state.streaming = false; state.waiting_for_response = false; state.thread.read = true; - state.thread.messages = postProcessMessagesAfterStreaming(state.thread.messages); + state.thread.messages = postProcessMessagesAfterStreaming( + state.thread.messages, + ); }); builder.addCase(setAutomaticPatch, (state, action) => { @@ -324,7 +326,9 @@ export const chatReducer = createReducer(initialState, (builder) => { new_chat_suggested: { wasSuggested: false }, ...mostUptoDateThread, }; - state.thread.messages = postProcessMessagesAfterStreaming(state.thread.messages); + state.thread.messages = postProcessMessagesAfterStreaming( + state.thread.messages, + ); state.thread.tool_use = state.thread.tool_use ?? state.tool_use; if (action.payload.mode && !isLspChatMode(action.payload.mode)) { state.thread.mode = "AGENT"; diff --git a/refact-agent/gui/src/features/Chat/Thread/utils.test.ts b/refact-agent/gui/src/features/Chat/Thread/utils.test.ts index 20937a4f7..9e0449262 100644 --- a/refact-agent/gui/src/features/Chat/Thread/utils.test.ts +++ b/refact-agent/gui/src/features/Chat/Thread/utils.test.ts @@ -8,7 +8,12 @@ import { UserMessageResponse, type ToolCall, } from "../../../services/refact"; -import { mergeToolCalls, formatChatResponse, consumeStream, postProcessMessagesAfterStreaming } from "./utils"; +import { + mergeToolCalls, + formatChatResponse, + consumeStream, + postProcessMessagesAfterStreaming, +} from "./utils"; describe("formatChatResponse", () => { test("it should replace the last user message", () => { @@ -1760,14 +1765,14 @@ describe("postProcessMessagesAfterStreaming", () => { ]; const result = postProcessMessagesAfterStreaming(messages); - + expect(result).toHaveLength(1); expect(result[0].role).toBe("assistant"); if ("tool_calls" in result[0] && "content" in result[0]) { expect(result[0].tool_calls).toHaveLength(1); expect(result[0].tool_calls?.[0].function.name).toBe("str_replace"); expect(result[0].content).toBe( - 'I\'ll search for the weather.\n\n---\n\n☁️ **web_search**`({"query": "weather in Adelaide"})` was called on the cloud' + 'I\'ll search for the weather.\n\n---\n\n☁️ **web_search**`({"query": "weather in Adelaide"})` was called on the cloud', ); } }); @@ -1791,12 +1796,12 @@ describe("postProcessMessagesAfterStreaming", () => { ]; const result = postProcessMessagesAfterStreaming(messages); - + expect(result).toHaveLength(1); expect(result[0].role).toBe("assistant"); if ("content" in result[0]) { expect(result[0].content).toBe( - 'Searching for information.\n\n---\n\n☁️ **web_search**`({"query": "test"})` was called on the cloud' + 'Searching for information.\n\n---\n\n☁️ **web_search**`({"query": "test"})` was called on the cloud', ); } if ("tool_calls" in result[0]) { @@ -1818,7 +1823,7 @@ describe("postProcessMessagesAfterStreaming", () => { ]; const result = postProcessMessagesAfterStreaming(messages); - + expect(result).toEqual(messages); }); @@ -1841,7 +1846,7 @@ describe("postProcessMessagesAfterStreaming", () => { ]; const result = postProcessMessagesAfterStreaming(messages); - + expect(result).toEqual(messages); }); @@ -1880,13 +1885,15 @@ describe("postProcessMessagesAfterStreaming", () => { ]; const result = postProcessMessagesAfterStreaming(messages); - + expect(result).toHaveLength(1); expect(result[0].role).toBe("assistant"); if ("tool_calls" in result[0]) { expect(result[0].tool_calls).toHaveLength(2); expect(result[0].tool_calls?.[0].id).toBe("call_123"); - expect(result[0].tool_calls?.[0].function.arguments).toBe('{"path": "/src"}'); + expect(result[0].tool_calls?.[0].function.arguments).toBe( + '{"path": "/src"}', + ); expect(result[0].tool_calls?.[1].id).toBe("call_456"); } }); @@ -1934,14 +1941,16 @@ describe("postProcessMessagesAfterStreaming", () => { ]; const result = postProcessMessagesAfterStreaming(messages); - + expect(result).toHaveLength(1); if ("tool_calls" in result[0] && "content" in result[0]) { expect(result[0].tool_calls).toHaveLength(1); expect(result[0].tool_calls?.[0].id).toBe("call_456"); expect(result[0].tool_calls?.[0].function.name).toBe("tree"); - expect(result[0].tool_calls?.[0].function.arguments).toBe('{"path": "/"}'); - expect(result[0].content).toContain('web_search'); + expect(result[0].tool_calls?.[0].function.arguments).toBe( + '{"path": "/"}', + ); + expect(result[0].content).toContain("web_search"); expect(result[0].content).toContain('{"query": "test search"}'); } }); diff --git a/refact-agent/gui/src/features/Chat/Thread/utils.ts b/refact-agent/gui/src/features/Chat/Thread/utils.ts index 863d49db3..8cb29a6b4 100644 --- a/refact-agent/gui/src/features/Chat/Thread/utils.ts +++ b/refact-agent/gui/src/features/Chat/Thread/utils.ts @@ -43,44 +43,56 @@ import { checkForDetailMessage } from "./types"; const external_ignored_tools = ["web_search"]; -export function postProcessMessagesAfterStreaming(messages: ChatMessages): ChatMessages { +export function postProcessMessagesAfterStreaming( + messages: ChatMessages, +): ChatMessages { return messages.map((message) => { if (!isAssistantMessage(message) || !message.tool_calls) { return message; } - + const deduplicatedTools = deduplicateToolCalls(message.tool_calls); const ignoredTools: ToolCall[] = []; const keptTools: ToolCall[] = []; - + deduplicatedTools.forEach((tool) => { - if (tool.function.name && external_ignored_tools.includes(tool.function.name)) { + if ( + tool.function.name && + external_ignored_tools.includes(tool.function.name) + ) { ignoredTools.push(tool); } else { keptTools.push(tool); } }); - - if (ignoredTools.length === 0 && deduplicatedTools.length === message.tool_calls.length) { + + if ( + ignoredTools.length === 0 && + deduplicatedTools.length === message.tool_calls.length + ) { return message; } - - const ignoredText = ignoredTools.map((tool) => { - let args = tool.function.arguments - .replace(/\}\{+\}/g, '}') - .replace(/\{+\)/g, ')') - .replace(/\}\{/g, '}') - .trim(); - - if (args.endsWith('{')) { - args = args.slice(0, -1) + '}'; - } - - return `\n---\n\n☁️ **${tool.function.name}**\`(${args})\` was called on the cloud`; - }).join(""); - - const updatedContent = ignoredText ? message.content + "\n" + ignoredText : message.content; - + + const ignoredText = ignoredTools + .map((tool) => { + let args = tool.function.arguments + .replace(/\}\{+\}/g, "}") + .replace(/\{+\)/g, ")") + .replace(/\}\{/g, "}") + .trim(); + + if (args.endsWith("{")) { + args = args.slice(0, -1) + "}"; + } + + return `\n---\n\n☁️ **${tool.function.name}**\`(${args})\` was called on the cloud`; + }) + .join(""); + + const updatedContent = ignoredText + ? message.content + "\n" + ignoredText + : message.content; + return { ...message, content: updatedContent, @@ -91,23 +103,26 @@ export function postProcessMessagesAfterStreaming(messages: ChatMessages): ChatM function deduplicateToolCalls(toolCalls: ToolCall[]): ToolCall[] { const toolCallMap = new Map(); - + toolCalls.forEach((tool) => { if (!tool.id) return; // Skip tools without an id const existingTool = toolCallMap.get(tool.id); - + if (!existingTool) { toolCallMap.set(tool.id, tool); } else { - const existingHasArgs = existingTool.function.arguments && existingTool.function.arguments.trim() !== ""; - const newHasArgs = tool.function.arguments && tool.function.arguments.trim() !== ""; - + const existingHasArgs = + existingTool.function.arguments && + existingTool.function.arguments.trim() !== ""; + const newHasArgs = + tool.function.arguments && tool.function.arguments.trim() !== ""; + if (!existingHasArgs && newHasArgs) { toolCallMap.set(tool.id, tool); } } }); - + return Array.from(toolCallMap.values()); } diff --git a/refact-agent/gui/src/utils/test-setup.ts b/refact-agent/gui/src/utils/test-setup.ts index 573702c5a..3c020f5dd 100644 --- a/refact-agent/gui/src/utils/test-setup.ts +++ b/refact-agent/gui/src/utils/test-setup.ts @@ -12,7 +12,7 @@ beforeAll(() => { stubResizeObserver(); stubIntersectionObserver(); Element.prototype.scrollIntoView = vi.fn(); - + // Mock localStorage for tests const localStorageMock = { getItem: vi.fn(), From 55c70e97b1e5a093a94d8ac328458f0fcc336522 Mon Sep 17 00:00:00 2001 From: JegernOUTT Date: Thu, 4 Dec 2025 00:18:26 +1030 Subject: [PATCH 15/15] Fix TypeScript error in localStorage mock Add missing Storage interface properties (key, length) to make the mock fully compatible with the Storage type. --- refact-agent/gui/src/utils/test-setup.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/refact-agent/gui/src/utils/test-setup.ts b/refact-agent/gui/src/utils/test-setup.ts index 3c020f5dd..4bbf02fbe 100644 --- a/refact-agent/gui/src/utils/test-setup.ts +++ b/refact-agent/gui/src/utils/test-setup.ts @@ -14,13 +14,15 @@ beforeAll(() => { Element.prototype.scrollIntoView = vi.fn(); // Mock localStorage for tests - const localStorageMock = { - getItem: vi.fn(), + const localStorageMock: Storage = { + getItem: vi.fn(() => null), setItem: vi.fn(), removeItem: vi.fn(), clear: vi.fn(), + key: vi.fn(() => null), + length: 0, }; - global.localStorage = localStorageMock as Storage; + global.localStorage = localStorageMock; }); afterEach(() => {