diff --git a/package-lock.json b/package-lock.json index e9fa34c..a7e8edf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "robin-sidekick", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "robin-sidekick", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { "@fontsource/capriola": "^5.2.7", diff --git a/package.json b/package.json index be19021..3e7a4e6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "robin-sidekick", "productName": "Robin", - "version": "0.3.0", + "version": "0.4.0", "description": "A mac-first menu bar sidekick for web-grounded chat and local AI.", "main": ".webpack/main", "scripts": { diff --git a/src/main/providerService.ts b/src/main/providerService.ts index 385f39d..cf4adc8 100644 --- a/src/main/providerService.ts +++ b/src/main/providerService.ts @@ -27,8 +27,11 @@ import { TodoContextProvider } from "./context/todoProvider"; import { NotesContextProvider } from "./context/notesProvider"; import { buildSystemPrompt } from "./context/assembler"; import { + extractUrls, + isWeatherQuery, parseActions, prepareMessagesForAPI, + requiresLiveWebSearch, truncateContext } from "./providerServiceUtils"; import { ToolRound, StreamReplyResult } from "./tools/types"; @@ -99,6 +102,8 @@ const MODEL_SETUP_WARNING = "You need to configure a model to use Robin. Download a local model or add a cloud provider key in Settings."; const LOCAL_IMAGE_UNSUPPORTED_WARNING = "Image input is not supported in Local mode yet. Switch to a cloud model."; +const LOCAL_LIVE_DATA_SEARCH_REQUIRED_WARNING = + "Local mode needs Web Search enabled with a Brave Search API key for live/current questions. Enable it in Settings or switch to a cloud search model."; export class ProviderService { private readonly ollama = new OllamaProvider(); @@ -529,6 +534,9 @@ export class ProviderService { ]); const toolExecutors = buildToolExecutors(braveApiKey, settings.toolToggles); const toolDefs = getToolDefinitions(toolExecutors); + const toolExecutorByName = new Map( + toolExecutors.map((executor) => [executor.definition.name, executor]) + ); const onDelta = (delta: string) => { assistantMessage.content += delta; @@ -672,6 +680,70 @@ export class ProviderService { if ((request.attachments?.length ?? 0) > 0) { throw new Error(LOCAL_IMAGE_UNSUPPORTED_WARNING); } + const localPrompt = request.prompt.trim(); + const explicitUrls = extractUrls(localPrompt); + const needsLiveData = requiresLiveWebSearch(localPrompt); + const supplementalSections: string[] = []; + + if (explicitUrls.length > 0) { + const fetchUrlExecutor = toolExecutorByName.get("fetch_url"); + if (!fetchUrlExecutor) { + throw new Error( + "Local mode cannot read shared links because Fetch URL is disabled in Settings." + ); + } + + emitToolStatus("fetch_url", "calling"); + const fetchedContent = await fetchUrlExecutor.execute({ + url: explicitUrls[0] + }); + emitToolStatus("fetch_url", "complete"); + + if (fetchedContent.startsWith("Error:")) { + throw new Error(fetchedContent.replace(/^Error:\s*/i, "")); + } + + supplementalSections.push( + [ + `## Source Content (${explicitUrls[0]})`, + "Use this fetched page content when answering the user's question.", + fetchedContent + ].join("\n") + ); + } + + if (needsLiveData) { + const webSearchExecutor = toolExecutorByName.get("web_search"); + if (!webSearchExecutor) { + throw new Error(LOCAL_LIVE_DATA_SEARCH_REQUIRED_WARNING); + } + + emitToolStatus("web_search", "calling"); + const searchResults = await webSearchExecutor.execute({ + query: localPrompt, + count: isWeatherQuery(localPrompt) ? 3 : 5 + }); + emitToolStatus("web_search", "complete"); + + if (searchResults.startsWith("Error:")) { + throw new Error(searchResults.replace(/^Error:\s*/i, "")); + } + + supplementalSections.push( + [ + "## Live Web Context", + "Use these search results as the authoritative source for current facts.", + searchResults + ].join("\n") + ); + + if (isWeatherQuery(localPrompt)) { + supplementalSections.push( + "## Weather Guard\nWhen the local time is nighttime, do not describe current conditions as sunny. Prefer neutral wording like clear, warm, humid, cloudy, or rainy unless the source explicitly describes the current moment otherwise." + ); + } + } + const ollamaStatus = await this.ollama.detect( providers.ollama.baseUrl, providers.ollama.model || undefined @@ -699,7 +771,10 @@ export class ProviderService { (message) => message.id !== assistantMessage.id ) ), - systemPrompt: systemPrompt || undefined, + systemPrompt: + [systemPrompt, ...supplementalSections] + .filter((section) => Boolean(section && section.trim())) + .join("\n\n") || undefined, tools: toolDefs.length > 0 ? toolDefs : undefined, toolHistory: toolHistory.length > 0 ? toolHistory : undefined, onDelta @@ -760,8 +835,11 @@ export class ProviderService { }); } catch (error) { assistantMessage.status = "error"; - assistantMessage.content = - assistantMessage.content || "I hit a snag before I could finish that."; + if (assistantMessage.content.trim().length === 0) { + thread.messages = thread.messages.filter( + (message) => message.id !== assistantMessage.id + ); + } thread.updatedAt = isoNow(); await this.storage.upsertThread(thread); emit({ diff --git a/src/main/providerServiceUtils.ts b/src/main/providerServiceUtils.ts index 29d8988..ab5e321 100644 --- a/src/main/providerServiceUtils.ts +++ b/src/main/providerServiceUtils.ts @@ -76,3 +76,31 @@ export function truncateContext( return result; } + +export function extractUrls(content: string): string[] { + const matches = content.match(/https?:\/\/[^\s)]+/gi) ?? []; + return Array.from( + new Set(matches.map((entry) => entry.trim().replace(/[.,!?;:]+$/, ""))) + ); +} + +export function isWeatherQuery(content: string): boolean { + return /\b(weather|forecast|temperature|rain|humidity|wind)\b/i.test(content); +} + +export function requiresLiveWebSearch(content: string): boolean { + const normalized = content.trim().toLowerCase(); + if (!normalized) { + return false; + } + + if ( + /\b(latest|current|currently|right now|today|tonight|this morning|this evening|now|news|forecast|live score|stock price|price today)\b/.test( + normalized + ) + ) { + return true; + } + + return isWeatherQuery(normalized); +} diff --git a/src/main/providers/curatedCloudModels.ts b/src/main/providers/curatedCloudModels.ts index 6a7fe74..4208722 100644 --- a/src/main/providers/curatedCloudModels.ts +++ b/src/main/providers/curatedCloudModels.ts @@ -5,25 +5,85 @@ export const CURATED_CLOUD_MODELS: Record< CloudModelCatalogItem[] > = { openai: [ - { id: "gpt-5.2-codex", modes: ["low", "medium", "high", "xhigh"] }, - { id: "gpt-5.2", modes: ["none", "low", "medium", "high", "xhigh"] }, - { id: "gpt-5.2-mini", modes: ["none", "low", "medium", "high", "xhigh"] }, - { id: "gpt-5.2-nano", modes: ["none", "low", "medium", "high", "xhigh"] }, - { id: "gpt-5.1", modes: ["none", "low", "medium", "high"] }, - { id: "gpt-5.1-mini", modes: ["none", "low", "medium", "high"] }, - { id: "gpt-5.1-nano", modes: ["none", "low", "medium", "high"] }, - { id: "gpt-5-pro", modes: ["high"] }, - { id: "o4-mini", modes: ["low", "medium", "high"] }, - { id: "o3", modes: ["low", "medium", "high"] } + { + id: "gpt-5.2-codex", + modes: ["low", "medium", "high", "xhigh"], + capabilities: { image: true, tools: true, search: false } + }, + { + id: "gpt-5.2", + modes: ["none", "low", "medium", "high", "xhigh"], + capabilities: { image: true, tools: true, search: false } + }, + { + id: "gpt-5.2-mini", + modes: ["none", "low", "medium", "high", "xhigh"], + capabilities: { image: true, tools: true, search: false } + }, + { + id: "gpt-5.2-nano", + modes: ["none", "low", "medium", "high", "xhigh"], + capabilities: { image: true, tools: true, search: false } + }, + { + id: "gpt-5.1", + modes: ["none", "low", "medium", "high"], + capabilities: { image: true, tools: true, search: false } + }, + { + id: "gpt-5.1-mini", + modes: ["none", "low", "medium", "high"], + capabilities: { image: true, tools: true, search: false } + }, + { + id: "gpt-5.1-nano", + modes: ["none", "low", "medium", "high"], + capabilities: { image: true, tools: true, search: false } + }, + { + id: "gpt-5-pro", + modes: ["high"], + capabilities: { image: true, tools: true, search: false } + }, + { + id: "o4-mini", + modes: ["low", "medium", "high"], + capabilities: { image: true, tools: true, search: false } + }, + { + id: "o3", + modes: ["low", "medium", "high"], + capabilities: { image: true, tools: true, search: false } + } ], google: [ - { id: "gemini-2.5-pro", modes: [] }, - { id: "gemini-2.5-flash", modes: [] }, - { id: "gemini-2.5-flash-lite", modes: [] } + { + id: "gemini-2.5-pro", + modes: [], + capabilities: { image: true, tools: true, search: false } + }, + { + id: "gemini-2.5-flash", + modes: [], + capabilities: { image: true, tools: true, search: false } + }, + { + id: "gemini-2.5-flash-lite", + modes: [], + capabilities: { image: true, tools: true, search: false } + } ], perplexity: [ - { id: "sonar-pro", modes: [] }, - { id: "sonar", modes: [] } + { + id: "sonar-pro", + modes: [], + capabilities: { image: false, tools: false, search: true } + }, + { + id: "sonar", + modes: [], + capabilities: { image: false, tools: false, search: true } + } ], openrouter: [] }; diff --git a/src/main/providers/ollamaProvider.ts b/src/main/providers/ollamaProvider.ts index 569fc2c..26ee846 100644 --- a/src/main/providers/ollamaProvider.ts +++ b/src/main/providers/ollamaProvider.ts @@ -28,6 +28,18 @@ interface PullProgressChunk { error?: string; } +interface OllamaToolCallPayload { + function: { name: string; arguments: Record }; +} + +interface OllamaChatChunk { + message?: { + content?: string; + tool_calls?: OllamaToolCallPayload[]; + }; + done?: boolean; +} + function decodeHtml(input: string): string { return input .replace(/&/g, "&") @@ -104,6 +116,72 @@ function formatReachabilityError(baseUrl: string): string { return `Could not reach Ollama at ${normalizeBaseUrl(baseUrl)}. Open Ollama (or run 'ollama serve') and try again.`; } +async function formatStreamingError( + baseUrl: string, + response: Response +): Promise { + const location = normalizeBaseUrl(baseUrl); + + if (!response.ok) { + let details = ""; + try { + details = normalizeText(await response.text()); + } catch { + details = ""; + } + + const detailSuffix = details ? ` ${details}` : ""; + return `Ollama chat failed at ${location} with ${response.status} ${response.statusText}.${detailSuffix}`.trim(); + } + + return `Ollama replied from ${location}, but the response was not streamed. Check the base URL and any proxy in front of Ollama.`; +} + +function mapOllamaToolCalls(toolCalls?: OllamaToolCallPayload[]): ToolCall[] { + if (!toolCalls?.length) { + return []; + } + + return toolCalls.map((tc) => ({ + id: `ollama-${randomUUID()}`, + name: tc.function.name, + arguments: JSON.stringify(tc.function.arguments) + })); +} + +function applyChatChunk( + chunk: OllamaChatChunk, + onDelta: (delta: string) => void +): ToolCall[] { + const delta = chunk.message?.content ?? ""; + if (delta) { + onDelta(delta); + } + return mapOllamaToolCalls(chunk.message?.tool_calls); +} + +function parseBufferedChatResponse( + rawBody: string, + onDelta: (delta: string) => void +): ToolCall[] { + const trimmed = rawBody.trim(); + if (!trimmed) { + throw new Error("Ollama returned an empty response."); + } + + let responseToolCalls: ToolCall[] = []; + const lines = trimmed.split("\n").filter((line) => line.trim().length > 0); + for (const line of lines) { + const chunk = JSON.parse(line) as OllamaChatChunk; + const nextToolCalls = applyChatChunk(chunk, onDelta); + if (nextToolCalls.length > 0) { + responseToolCalls = nextToolCalls; + } + } + + return responseToolCalls; +} + function resolveSelectedModel( selectedModel: string | undefined, models: string[] @@ -269,7 +347,7 @@ export class OllamaProvider { body: JSON.stringify(requestBody) }); response = nextResponse; - if (response.ok && response.body) { + if (response.ok) { break; } } catch { @@ -294,7 +372,7 @@ export class OllamaProvider { body: JSON.stringify(requestBody) }); response = nextResponse; - if (response.ok && response.body) break; + if (response.ok) break; } catch { continue; } @@ -305,8 +383,16 @@ export class OllamaProvider { throw new Error(formatReachabilityError(input.baseUrl)); } - if (!response.ok || !response.body) { - throw new Error("Ollama did not return a streaming response."); + if (!response.ok) { + throw new Error(await formatStreamingError(input.baseUrl, response)); + } + + if (!response.body) { + const rawBody = await response.text(); + return { + citations: [], + toolCalls: parseBufferedChatResponse(rawBody, input.onDelta) + }; } const reader = response.body.getReader(); @@ -328,49 +414,19 @@ export class OllamaProvider { if (!trimmed) { continue; } - const chunk = JSON.parse(trimmed) as { - message?: { - content?: string; - tool_calls?: Array<{ - function: { name: string; arguments: Record }; - }>; - }; - done?: boolean; - }; - const delta = chunk.message?.content ?? ""; - if (delta) { - input.onDelta(delta); - } - if (chunk.message?.tool_calls?.length) { - responseToolCalls = chunk.message.tool_calls.map((tc) => ({ - id: `ollama-${randomUUID()}`, - name: tc.function.name, - arguments: JSON.stringify(tc.function.arguments) - })); + const chunk = JSON.parse(trimmed) as OllamaChatChunk; + const nextToolCalls = applyChatChunk(chunk, input.onDelta); + if (nextToolCalls.length > 0) { + responseToolCalls = nextToolCalls; } } } if (buffer.trim()) { - const chunk = JSON.parse(buffer) as { - message?: { - content?: string; - tool_calls?: Array<{ - function: { name: string; arguments: Record }; - }>; - }; - done?: boolean; - }; - const delta = chunk.message?.content ?? ""; - if (delta) { - input.onDelta(delta); - } - if (chunk.message?.tool_calls?.length) { - responseToolCalls = chunk.message.tool_calls.map((tc) => ({ - id: `ollama-${randomUUID()}`, - name: tc.function.name, - arguments: JSON.stringify(tc.function.arguments) - })); + const chunk = JSON.parse(buffer) as OllamaChatChunk; + const nextToolCalls = applyChatChunk(chunk, input.onDelta); + if (nextToolCalls.length > 0) { + responseToolCalls = nextToolCalls; } } diff --git a/src/main/providers/openaiProvider.ts b/src/main/providers/openaiProvider.ts index c9807c2..3291452 100644 --- a/src/main/providers/openaiProvider.ts +++ b/src/main/providers/openaiProvider.ts @@ -147,7 +147,12 @@ export class OpenAIProvider { return deduped.map((id) => ({ id, - modes: modesForModel(id) + modes: modesForModel(id), + capabilities: { + image: true, + tools: true, + search: false + } })); } diff --git a/src/main/settings.ts b/src/main/settings.ts index 5db4be4..8fa68cc 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -148,7 +148,12 @@ export function normalizeSettings(raw: unknown): SettingsData { .filter(Boolean) ) ) - : [] + : [], + capabilities: { + image: Boolean(item.capabilities?.image), + tools: Boolean(item.capabilities?.tools), + search: Boolean(item.capabilities?.search) + } })) .filter((item) => item.id.length > 0); diff --git a/src/main/storage.ts b/src/main/storage.ts index 1d6e0f5..48ca882 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -8,6 +8,7 @@ import { ThreadSummary, TodoItem } from "../shared/contracts"; +import { reorderTodoForCompletion } from "../shared/todoOrdering"; import { DEFAULT_SETTINGS, normalizeSettings, SettingsData } from "./settings"; interface ThreadsFile { @@ -187,12 +188,32 @@ export class AppStorage { const file = await this.readJson(this.todosPath, { todos: [] }); const todo = file.todos.find((t) => t.id === id); if (!todo) return null; + const now = new Date().toISOString(); if (changes.title !== undefined) todo.title = changes.title; - if (changes.completed !== undefined) todo.completed = changes.completed; if (changes.order !== undefined) todo.order = changes.order; - todo.updatedAt = new Date().toISOString(); + todo.updatedAt = now; + + if ( + changes.completed !== undefined && + changes.completed !== todo.completed + ) { + const nextCompleted = changes.completed; + const nextTodos = reorderTodoForCompletion( + file.todos.map((entry) => + entry.id === id + ? { ...entry, completed: nextCompleted, updatedAt: now } + : entry + ), + id, + nextCompleted + ); + file.todos = nextTodos; + } else if (changes.completed !== undefined) { + todo.completed = changes.completed; + } + await this.writeJson(this.todosPath, file); - return todo; + return file.todos.find((entry) => entry.id === id) ?? null; } async reorderTodos(orderedIds: string[]): Promise { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 14a7524..c101d48 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -12,6 +12,7 @@ import { IconChevron, IconCheck, IconClose, + IconDragHandle, IconExpand, IconImage, IconNote, @@ -23,6 +24,7 @@ import { } from "./components/icons"; import { SidebarFooter } from "./components/SidebarFooter"; import { DropdownOption, ThemedDropdown } from "./components/ThemedDropdown"; +import { TransientWarning } from "./components/TransientWarning"; import { buildCloudProviderStateMap, buildProviderDrafts, @@ -44,6 +46,11 @@ import { parseModelKey, resolveCloudProviderId } from "./lib/modelSelection"; +import { + reorderTodoForCompletion, + reorderTodoInGroup, + sortTodosForDisplay +} from "../shared/todoOrdering"; const REMARK_PLUGINS = [remarkGfm]; import { @@ -69,6 +76,12 @@ type SettingsModeTab = "cloud" | "local"; type SettingsSectionId = "models" | "shortcut" | "updates"; +const NO_MODEL_CAPABILITIES = { + image: false, + tools: false, + search: false +} as const; + const SHORTCUT_PRESETS = [ { label: "Cmd/Ctrl + Shift + Space", value: "CommandOrControl+Shift+Space" }, { label: "Cmd/Ctrl + Shift + K", value: "CommandOrControl+Shift+K" }, @@ -201,6 +214,12 @@ function getRobinBridge(): RobinBridge { } export function App() { + const [warning, setWarning] = useState<{ + id: number; + message: string; + startedAt: number; + durationMs: number; + } | null>(null); const [profileName, setProfileName] = useState("there"); const [screen, setScreen] = useState<"chat" | "settings">("chat"); const [settingsMode, setSettingsMode] = useState("cloud"); @@ -218,7 +237,6 @@ export function App() { const [editingTodoId, setEditingTodoId] = useState(null); const [editingTodoTitle, setEditingTodoTitle] = useState(""); const [draggedTodoId, setDraggedTodoId] = useState(null); - const [dragOverTodoId, setDragOverTodoId] = useState(null); const [notes, setNotes] = useState([]); const [activeNoteId, setActiveNoteId] = useState(null); const [noteTitleDraft, setNoteTitleDraft] = useState(""); @@ -273,7 +291,12 @@ export function App() { toolName: string; status: "calling" | "complete"; } | null>(null); - const [error, setError] = useState(null); + const [error, setError] = useState<{ + id: number; + message: string; + startedAt: number; + durationMs: number; + } | null>(null); const [appVersion, setAppVersion] = useState(""); const [updatesChecking, setUpdatesChecking] = useState(false); const [updateInfo, setUpdateInfo] = useState(null); @@ -282,12 +305,57 @@ export function App() { const chatEndRef = useRef(null); const imageInputRef = useRef(null); const streamWatchdogRef = useRef | null>(null); + const warningTimeoutRef = useRef | null>(null); + const errorTimeoutRef = useRef | null>(null); const streamSequenceRef = useRef(0); const activeStreamIdRef = useRef(null); const pendingDeltasRef = useRef(new Map()); const deltaFrameRef = useRef(null); + const warnedAboutLegacyToolSettingsRef = useRef(false); const messages = activeThread?.messages ?? []; + function dismissWarning() { + if (warningTimeoutRef.current) { + clearTimeout(warningTimeoutRef.current); + warningTimeoutRef.current = null; + } + setWarning(null); + } + + function showWarning(message: string, durationMs = 5000) { + const startedAt = performance.now(); + const id = startedAt; + if (warningTimeoutRef.current) { + clearTimeout(warningTimeoutRef.current); + } + setWarning({ id, message, startedAt, durationMs }); + warningTimeoutRef.current = setTimeout(() => { + setWarning((current) => (current?.id === id ? null : current)); + warningTimeoutRef.current = null; + }, durationMs); + } + + function dismissError() { + if (errorTimeoutRef.current) { + clearTimeout(errorTimeoutRef.current); + errorTimeoutRef.current = null; + } + setError(null); + } + + function showError(message: string, durationMs = 10000) { + const startedAt = performance.now(); + const id = startedAt; + if (errorTimeoutRef.current) { + clearTimeout(errorTimeoutRef.current); + } + setError({ id, message, startedAt, durationMs }); + errorTimeoutRef.current = setTimeout(() => { + setError((current) => (current?.id === id ? null : current)); + errorTimeoutRef.current = null; + }, durationMs); + } + function applyPendingDeltas() { if (pendingDeltasRef.current.size === 0) { return; @@ -354,6 +422,7 @@ export function App() { const displayName = profileName.toLowerCase().startsWith("karan") ? "Karan" : profileName; + const sortedTodos = useMemo(() => sortTodosForDisplay(todos), [todos]); const catalogForDisplay = useMemo(() => { if (localCatalog.length === 0) { @@ -435,6 +504,10 @@ export function App() { ]); const nextStatus: ProviderStatus = { ...rawStatus, + toolToggles: { + fetchUrl: rawStatus.toolToggles?.fetchUrl !== false, + webSearch: rawStatus.toolToggles?.webSearch !== false + }, cloudProviderKeys: normalizeCloudProviderKeys( rawStatus.cloudProviderKeys ), @@ -443,6 +516,12 @@ export function App() { rawStatus.selectedCloudModels ) }; + if (!rawStatus.toolToggles && !warnedAboutLegacyToolSettingsRef.current) { + warnedAboutLegacyToolSettingsRef.current = true; + showWarning( + "Some older settings were missing. URL tool preferences were reset to defaults." + ); + } setStatus(nextStatus); setProviderKeyDrafts(nextStatus.providerApiKeys); setSelectedCloudModelsDraft(nextStatus.selectedCloudModels); @@ -598,15 +677,11 @@ export function App() { } async function toggleTodo(id: string, completed: boolean) { - setTodos((current) => - current.map((t) => (t.id === id ? { ...t, completed } : t)) - ); + setTodos((current) => reorderTodoForCompletion(current, id, completed)); try { await getRobinBridge().todos.update(id, { completed }); } catch { - setTodos((current) => - current.map((t) => (t.id === id ? { ...t, completed: !completed } : t)) - ); + await loadTodos(); } } @@ -640,18 +715,11 @@ export function App() { async function handleTodoDrop(targetId: string) { if (!draggedTodoId || draggedTodoId === targetId) { setDraggedTodoId(null); - setDragOverTodoId(null); return; } - const reordered = [...todos]; - const fromIndex = reordered.findIndex((t) => t.id === draggedTodoId); - const toIndex = reordered.findIndex((t) => t.id === targetId); - if (fromIndex === -1 || toIndex === -1) return; - const [moved] = reordered.splice(fromIndex, 1); - reordered.splice(toIndex, 0, moved); + const reordered = reorderTodoInGroup(todos, draggedTodoId, targetId); setTodos(reordered); setDraggedTodoId(null); - setDragOverTodoId(null); try { await getRobinBridge().todos.reorder(reordered.map((t) => t.id)); } catch { @@ -741,13 +809,21 @@ export function App() { await Promise.all([refreshStatus(), refreshThreads()]); } catch (loadError) { if (isActive) { - setError(errorMessage(loadError, "Could not load Robin state.")); + showError(errorMessage(loadError, "Could not load Robin state.")); } } })(); return () => { isActive = false; + if (warningTimeoutRef.current) { + clearTimeout(warningTimeoutRef.current); + warningTimeoutRef.current = null; + } + if (errorTimeoutRef.current) { + clearTimeout(errorTimeoutRef.current); + errorTimeoutRef.current = null; + } }; }, []); @@ -798,16 +874,24 @@ export function App() { const selectedIds = selectedCloudModelsDraft[provider] ?? []; const providerModels = cloudModelsByProvider[provider] ?? []; - const selectableModels = + const selectableModels: CloudModelCatalogItem[] = provider === "openrouter" - ? selectedIds.map((modelId) => ({ id: modelId, modes: [] })) + ? selectedIds.map((modelId) => ({ + id: modelId, + modes: [], + capabilities: NO_MODEL_CAPABILITIES + })) : providerModels.length > 0 ? selectedIds .map((modelId) => providerModels.find((model) => model.id === modelId) ) .filter((model): model is CloudModelCatalogItem => Boolean(model)) - : selectedIds.map((modelId) => ({ id: modelId, modes: [] })); + : selectedIds.map((modelId) => ({ + id: modelId, + modes: [], + capabilities: NO_MODEL_CAPABILITIES + })); if (selectableModels.length === 0) { if (cloudModelDraft) { @@ -867,7 +951,7 @@ export function App() { try { void getRobinBridge().app.togglePanel(); } catch (bridgeError) { - setError( + showError( errorMessage( bridgeError, "Robin desktop bridge is unavailable. Please restart Robin." @@ -918,9 +1002,9 @@ export function App() { setIsStreaming(false); setToolStatus(null); if (typeof nextError === "string") { - setError(nextError); + showError(nextError); } else { - setError(null); + dismissError(); } setActiveThread((current) => current @@ -960,14 +1044,14 @@ export function App() { async function persistConfig(patch: SaveConfigInput) { try { - setError(null); + dismissError(); await getRobinBridge().providers.saveConfig({ onboardingCompleted: true, ...patch }); await refreshStatus(); } catch (saveError) { - setError(errorMessage(saveError, "Could not save settings.")); + showError(errorMessage(saveError, "Could not save settings.")); } } @@ -1021,7 +1105,7 @@ export function App() { next.model && !localModelSet.has(next.model.toLowerCase()) ) { - setError( + showWarning( `${next.model} is not downloaded yet. Download it first, then activate.` ); return; @@ -1032,7 +1116,7 @@ export function App() { (providerId) => providerId === next.model.trim().toLowerCase() ); if (!nextCloudProvider) { - setError("Select a valid cloud provider."); + showWarning("Select a valid cloud provider."); return; } const nextCloudModel = @@ -1054,7 +1138,7 @@ export function App() { async function applyComposerSelection(nextValue: string) { const parsedSelection = parseComposerValue(nextValue); if (parsedSelection.mode === "unknown") { - setError("Select a valid model."); + showWarning("Select a valid model."); return; } @@ -1192,14 +1276,14 @@ export function App() { return; } try { - setError(null); + dismissError(); const result = await getRobinBridge().app.setShortcut(shortcut); setShortcutDraft(result.shortcut); if (!result.success) { - setError("Shortcut in use."); + showWarning("Shortcut in use."); } } catch (shortcutError) { - setError(errorMessage(shortcutError, "Could not set shortcut.")); + showError(errorMessage(shortcutError, "Could not set shortcut.")); } } @@ -1234,7 +1318,7 @@ export function App() { setLocalModelNotice(null); if (ollamaStatus?.state === "not_installed") { - setError("Ollama is not installed yet. Opening download page."); + showWarning("Ollama is not installed yet. Opening download page."); try { await getRobinBridge().app.openExternal(OLLAMA_DOWNLOAD_URL); } catch { @@ -1250,7 +1334,7 @@ export function App() { } try { - setError(null); + dismissError(); setPullingModel(targetModel); await getRobinBridge().ollama.pullModel(targetModel); const robin = getRobinBridge(); @@ -1271,7 +1355,7 @@ export function App() { await refreshStatus(); await applyModelSelection(modelKey("local", targetModel)); } catch (pullError) { - setError(errorMessage(pullError, `Could not download ${targetModel}.`)); + showError(errorMessage(pullError, `Could not download ${targetModel}.`)); } finally { setPullingModel(null); } @@ -1294,7 +1378,7 @@ export function App() { } try { - setError(null); + dismissError(); setLocalModelNotice(null); setDeletingModel(targetModel); await getRobinBridge().ollama.deleteModel(targetModel); @@ -1315,7 +1399,7 @@ export function App() { } setLocalModelNotice(`${targetModel} deleted.`); } catch (deleteError) { - setError(errorMessage(deleteError, `Could not delete ${targetModel}.`)); + showError(errorMessage(deleteError, `Could not delete ${targetModel}.`)); } finally { setDeletingModel(null); } @@ -1330,13 +1414,13 @@ export function App() { file.type.toLowerCase().startsWith("image/") ); if (imageFiles.length === 0) { - setError("Only image files are supported."); + showWarning("Only image files are supported."); return; } const remainingSlots = MAX_IMAGE_ATTACHMENTS - pendingAttachments.length; if (remainingSlots <= 0) { - setError(`You can attach up to ${MAX_IMAGE_ATTACHMENTS} images.`); + showWarning(`You can attach up to ${MAX_IMAGE_ATTACHMENTS} images.`); return; } @@ -1345,7 +1429,7 @@ export function App() { for (const file of accepted) { if (file.size > MAX_IMAGE_BYTES) { - setError( + showWarning( `"${file.name}" is too large. Use images under ${Math.round(MAX_IMAGE_BYTES / (1024 * 1024))}MB.` ); continue; @@ -1359,17 +1443,19 @@ export function App() { dataUrl }); } catch { - setError(`Could not read "${file.name}".`); + showWarning(`Could not read "${file.name}".`); } } if (nextAttachments.length > 0) { - setError(null); + dismissError(); setPendingAttachments((current) => [...current, ...nextAttachments]); } if (imageFiles.length > accepted.length) { - setError(`Only ${MAX_IMAGE_ATTACHMENTS} images can be attached at once.`); + showWarning( + `Only ${MAX_IMAGE_ATTACHMENTS} images can be attached at once.` + ); } } @@ -1382,7 +1468,7 @@ export function App() { async function sendPrompt() { if (!prompt.trim() && pendingAttachments.length === 0) return; if (isStreaming) { - setError("Response in progress. Press Stop first."); + showWarning("Response in progress. Press Stop first."); return; } const parsed = parseModelKey(activeModelDraft); @@ -1394,23 +1480,23 @@ export function App() { : undefined; if (parsed.mode === "search" && !selectedCloudProvider) { - setError("Select a cloud model first."); + showWarning("Select a cloud model first."); return; } if (selectedCloudProvider) { const selectedModels = selectedCloudModelsDraft[selectedCloudProvider] ?? []; if (selectedModels.length === 0) { - setError("Select at least one cloud model in Settings first."); + showWarning("Select at least one cloud model in Settings first."); return; } if (!cloudModelDraft || !selectedModels.includes(cloudModelDraft)) { - setError("Pick one of your selected cloud models."); + showWarning("Pick one of your selected cloud models."); return; } } if (parsed.mode === "local" && ollamaStatus?.state === "not_installed") { - setError("Ollama is not installed yet. Opening download page."); + showWarning("Ollama is not installed yet. Opening download page."); try { await getRobinBridge().app.openExternal(OLLAMA_DOWNLOAD_URL); } catch { @@ -1419,23 +1505,22 @@ export function App() { return; } if (parsed.mode === "local" && !parsed.model) { - setError("Select or download a local model first."); + showWarning("Select or download a local model first."); return; } if (parsed.mode === "local" && pendingAttachments.length > 0) { - setError("Image input is not supported in Local mode yet."); + showWarning("Image input is not supported in Local mode yet."); return; } - setError(null); + dismissError(); const streamToken = streamSequenceRef.current + 1; streamSequenceRef.current = streamToken; const resetWatchdog = () => { if (streamWatchdogRef.current) clearTimeout(streamWatchdogRef.current); streamWatchdogRef.current = setTimeout(() => { if (streamToken !== streamSequenceRef.current) return; - void stopPendingResponse( - "Model response timed out. Press Stop and retry." - ); + showWarning("Model response timed out. Press Stop and retry."); + void stopPendingResponse(); }, 60000); }; resetWatchdog(); @@ -1518,7 +1603,7 @@ export function App() { setActiveThread({ ...thread }); void refreshThreads(thread.id); }, - onError: ({ message }) => { + onError: ({ message, threadId }) => { if (streamToken !== streamSequenceRef.current) { return; } @@ -1530,10 +1615,10 @@ export function App() { clearTimeout(streamWatchdogRef.current); streamWatchdogRef.current = null; } - setError( + showError( errorMessage(new Error(message), "Could not complete request.") ); - void refreshThreads(activeThread?.id); + void refreshThreads(threadId ?? activeThread?.id); } } ); @@ -1559,7 +1644,7 @@ export function App() { } setPrompt(text); setPendingAttachments(outgoingAttachments); - setError( + showError( errorMessage(streamError, "Could not start chat. Please retry.") ); } @@ -1575,7 +1660,7 @@ export function App() { await stopPendingResponse(undefined, false); } setActiveThread(null); - setError(null); + dismissError(); setPendingAttachments([]); setScreen("chat"); } @@ -1591,14 +1676,14 @@ export function App() { async function deleteThread(id: string) { if (!confirm("Delete this conversation?")) return; try { - setError(null); + dismissError(); await getRobinBridge().chat.deleteThread(id); if (activeThread?.id === id) { setActiveThread(null); } await refreshThreads(); } catch (deleteError) { - setError(errorMessage(deleteError, "Could not delete chat.")); + showError(errorMessage(deleteError, "Could not delete chat.")); } } @@ -1609,18 +1694,7 @@ export function App() { (providerId) => providerId === parsed.model.trim().toLowerCase() ) : undefined; - const isOpenAISelected = selectedCloudProvider === "openai"; const isCloudProviderSelected = Boolean(selectedCloudProvider); - - const modelCapabilities = (() => { - if (parsed.mode === "local") { - return { image: false, tools: false, search: false }; - } - if (selectedCloudProvider === "perplexity") { - return { image: false, tools: false, search: true }; - } - return { image: true, tools: true, search: false }; - })(); const selectedCloudProviderModels = selectedCloudProvider ? (cloudModelsByProvider[selectedCloudProvider] ?? []) : []; @@ -1640,12 +1714,18 @@ export function App() { const selectedCloudModel = visibleCloudModels.find( (model) => model.id === cloudModelDraft ); + const modelCapabilities = + parsed.mode === "local" + ? NO_MODEL_CAPABILITIES + : (selectedCloudModel?.capabilities ?? NO_MODEL_CAPABILITIES); const cloudModeOptions: DropdownOption[] = ( selectedCloudModel?.modes ?? [] ).map((mode) => ({ value: mode, label: mode.toUpperCase() })); + const shouldShowCloudModeSelector = + selectedCloudProvider !== undefined && cloudModeOptions.length > 0; const composerCloudModelOptions = useMemo(() => { const options: DropdownOption[] = []; @@ -1725,6 +1805,22 @@ export function App() { return ""; })(); + useEffect(() => { + if (parsed.mode !== "search") { + return; + } + if (composerSelectValue || composerCloudModelOptions.length === 0) { + return; + } + + void applyComposerSelection(composerCloudModelOptions[0].value); + }, [ + parsed.mode, + composerSelectValue, + composerCloudModelOptions, + selectedCloudModelsDraft + ]); + return (
@@ -1765,6 +1861,17 @@ export function App() {
+ {warning ? ( +
+ +
+ ) : null} +
- {todos.length > 0 ? ( - todos.map((todo) => ( + {sortedTodos.length > 0 ? ( + sortedTodos.map((todo) => (
setDraggedTodoId(todo.id)} + className={`todo-main-item${draggedTodoId === todo.id ? " todo-main-item-dragging" : ""}`} onDragOver={(e) => { e.preventDefault(); - setDragOverTodoId(todo.id); - }} - onDragLeave={() => { - if (dragOverTodoId === todo.id) setDragOverTodoId(null); + e.dataTransfer.dropEffect = "move"; }} onDrop={(e) => { e.preventDefault(); @@ -2619,9 +2726,27 @@ export function App() { }} onDragEnd={() => { setDraggedTodoId(null); - setDragOverTodoId(null); }} > +
- ) : selectedPinnedModels.length === 0 ? ( -
-

- Pick your cloud models in Settings. -

-
) : null ) : null}
diff --git a/src/renderer/components/ErrorBanner.tsx b/src/renderer/components/ErrorBanner.tsx index e094a62..b32b9d2 100644 --- a/src/renderer/components/ErrorBanner.tsx +++ b/src/renderer/components/ErrorBanner.tsx @@ -1,24 +1,27 @@ -import { IconClose } from "./icons"; +import { StatusBanner } from "./StatusBanner"; export function ErrorBanner({ message, + startedAt, + durationMs, onClose }: { message: string; + startedAt: number; + durationMs: number; onClose: () => void; }) { return ( -
-

{message}

- -
+ ); } diff --git a/src/renderer/components/StatusBanner.tsx b/src/renderer/components/StatusBanner.tsx new file mode 100644 index 0000000..f979313 --- /dev/null +++ b/src/renderer/components/StatusBanner.tsx @@ -0,0 +1,76 @@ +import { useEffect, useState } from "react"; +import { IconClose } from "./icons"; + +type StatusBannerVariant = "warning" | "error"; + +export function StatusBanner({ + variant, + label, + message, + onClose, + role, + ariaLive, + startedAt, + durationMs, + fullWidth = false +}: { + variant: StatusBannerVariant; + label: string; + message: string; + onClose: () => void; + role: "alert" | "status"; + ariaLive?: "assertive" | "polite" | "off"; + startedAt?: number; + durationMs?: number; + fullWidth?: boolean; +}) { + const [remainingMs, setRemainingMs] = useState(durationMs ?? 0); + + useEffect(() => { + if (!startedAt || !durationMs) { + setRemainingMs(0); + return; + } + + const tick = () => { + setRemainingMs(Math.max(0, startedAt + durationMs - performance.now())); + }; + + tick(); + const timer = window.setInterval(tick, 200); + return () => window.clearInterval(timer); + }, [durationMs, startedAt]); + + const seconds = + startedAt && durationMs ? Math.max(1, Math.ceil(remainingMs / 1000)) : null; + + return ( +
+
+

{label}

+

{message}

+
+ {seconds !== null ? ( + + {seconds} + + ) : null} + +
+ ); +} diff --git a/src/renderer/components/ThemedDropdown.tsx b/src/renderer/components/ThemedDropdown.tsx index 55ca612..1de4aa4 100644 --- a/src/renderer/components/ThemedDropdown.tsx +++ b/src/renderer/components/ThemedDropdown.tsx @@ -12,6 +12,7 @@ export function ThemedDropdown({ options, placeholder, onChange, + menuFooter, disabled, compact = false, borderless = false, @@ -22,6 +23,7 @@ export function ThemedDropdown({ options: DropdownOption[]; placeholder: string; onChange: (value: string) => void; + menuFooter?: string; disabled?: boolean; compact?: boolean; borderless?: boolean; @@ -121,9 +123,20 @@ export function ThemedDropdown({ role="listbox" > {renderedOptions.length > 0 ? ( - renderedOptions + <> + {renderedOptions} + {menuFooter ? ( +
{menuFooter}
+ ) : null} + ) : ( -
No options available
+
+ {menuFooter ?? "No options available"} +
)} ) : null} diff --git a/src/renderer/components/TransientWarning.tsx b/src/renderer/components/TransientWarning.tsx new file mode 100644 index 0000000..b3fe785 --- /dev/null +++ b/src/renderer/components/TransientWarning.tsx @@ -0,0 +1,26 @@ +import { StatusBanner } from "./StatusBanner"; + +export function TransientWarning({ + message, + startedAt, + durationMs, + onClose +}: { + message: string; + startedAt: number; + durationMs: number; + onClose: () => void; +}) { + return ( + + ); +} diff --git a/src/renderer/components/icons.tsx b/src/renderer/components/icons.tsx index 96e7699..50ec55b 100644 --- a/src/renderer/components/icons.tsx +++ b/src/renderer/components/icons.tsx @@ -248,3 +248,14 @@ export function IconCheck() { ); } + +export function IconDragHandle() { + return ( + + ); +} diff --git a/src/renderer/styles.css b/src/renderer/styles.css index ddb6bf4..43a9a60 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -16,6 +16,9 @@ --accent-soft: rgba(196, 164, 108, 0.08); --green: #3dd68c; --orange: #e8983a; + --warning-bg: rgba(232, 152, 58, 0.08); + --warning-border: rgba(232, 152, 58, 0.22); + --warning-text: #f3bc72; --red: #e45858; --danger-bg: rgba(228, 88, 88, 0.06); --danger-border: rgba(228, 88, 88, 0.16); @@ -532,8 +535,38 @@ button:focus-visible { background: var(--raised); } -.todo-main-item-drag-over { - border-top: 2px solid var(--accent); +.todo-main-item-dragging { + opacity: 0.42; + background: var(--raised); +} + +.todo-drag-handle { + width: 18px; + height: 18px; + flex-shrink: 0; + display: grid; + place-items: center; + border: none; + background: transparent; + color: #6d6d76; + padding: 0; + cursor: grab; + transition: color 120ms ease, transform 120ms var(--ease); +} + +.todo-drag-handle svg { + width: 14px; + height: 14px; + fill: currentColor; +} + +.todo-main-item:hover .todo-drag-handle { + color: #8a8a95; +} + +.todo-drag-handle:active { + cursor: grabbing; + transform: scale(0.96); } .todo-check { @@ -694,59 +727,128 @@ button:focus-visible { /* ── Error ──────────────────────────── */ -.error-banner { - margin: 8px 16px 0; - padding: 8px 10px 8px 14px; - font-size: 12px; - color: var(--red); - background: var(--danger-bg); - border: 1px solid var(--danger-border); - border-radius: 12px; - display: flex; +.warning-rail { + padding: 0 16px 8px; + flex-shrink: 0; +} + +.status-banner { + display: inline-flex; + align-self: flex-start; align-items: flex-start; - gap: 8px; - max-height: min(30vh, 220px); - overflow-y: auto; + gap: 10px; + margin: 6px 16px 0; + padding: 9px 10px; + max-width: min(520px, calc(100% - 32px)); + border-radius: 10px; + border: 1px solid var(--border); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.018), rgba(255, 255, 255, 0.01)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02); +} + +.status-banner-full-width { + display: flex; + align-self: stretch; + max-width: none; + width: calc(100% - 32px); +} + +.warning-rail .status-banner { + margin: 0; + max-width: 100%; +} + +.status-banner::before { + content: ""; + width: 2px; + min-width: 2px; + align-self: stretch; + border-radius: 999px; + background: currentColor; +} + +.status-banner-warning { + color: var(--warning-text); + border-color: rgba(232, 152, 58, 0.18); + background: linear-gradient(180deg, rgba(232, 152, 58, 0.09), rgba(232, 152, 58, 0.04)); } -.error-banner-text { +.status-banner-error { + color: var(--red); + border-color: rgba(228, 88, 88, 0.18); + background: linear-gradient(180deg, rgba(228, 88, 88, 0.08), rgba(228, 88, 88, 0.04)); +} + +.status-banner-copy { flex: 1; min-width: 0; - line-height: 1.45; +} + +.status-banner-countdown { + min-width: 22px; + padding-top: 1px; + color: currentColor; + font-size: 11px; + font-weight: 700; + line-height: 1; + text-align: center; + opacity: 0.92; +} + +.status-banner-label { + color: currentColor; + font-size: 9px; + font-weight: 600; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.status-banner-text { + margin-top: 2px; + color: var(--t1); + font-size: 12px; + line-height: 1.35; + max-height: min(24vh, 160px); + overflow-y: auto; +} + +.status-banner-text { white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; } -.error-banner-close { - width: 20px; - height: 20px; +.status-banner-close { + width: 16px; + height: 16px; border: none; - border-radius: 6px; + border-radius: 4px; background: transparent; - color: var(--red); + color: currentColor; display: grid; place-items: center; cursor: pointer; flex-shrink: 0; - transition: background 100ms ease, color 100ms ease; + opacity: 0.76; + transition: background 100ms ease, opacity 100ms ease; } -.error-banner-close:hover { - background: rgba(228, 88, 88, 0.12); +.status-banner-close:hover { + background: rgba(255, 255, 255, 0.06); + opacity: 1; } -.error-banner-close svg { - width: 12px; - height: 12px; +.status-banner-close svg { + width: 10px; + height: 10px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; } -.error-banner-close:focus, -.error-banner-close:focus-visible { +.status-banner-close:focus, +.status-banner-close:focus-visible { outline: none; box-shadow: none; } @@ -1547,6 +1649,15 @@ button:focus-visible { padding: 6px 8px; } +.themed-dropdown-footer { + margin-top: 4px; + padding: 8px 8px 4px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + font-size: 11px; + line-height: 1.4; + color: var(--t3); +} + .settings-cloud-dropdown { max-width: 320px; margin-bottom: 8px; @@ -2210,25 +2321,38 @@ button:focus-visible { display: flex; align-items: center; gap: 6px; - margin-left: auto; - margin-right: 8px; + flex: 0 1 auto; + min-width: 0; } .cap-badge { - display: flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - border-radius: 4px; - color: var(--text-dim); - opacity: 0.3; + position: relative; + display: grid; + place-items: center; + height: 24px; + width: 24px; + color: var(--t3); + opacity: 0.68; cursor: default; transition: opacity 0.15s, color 0.15s; } +.cap-badge svg { + width: 14px; + height: 14px; +} .cap-badge.cap-active { - opacity: 0.7; + opacity: 0.96; color: var(--accent); } +.cap-badge.cap-unsupported::after { + content: ""; + position: absolute; + width: 16px; + height: 1.5px; + border-radius: 999px; + background: currentColor; + transform: rotate(-42deg); + opacity: 0.95; +} .cap-badge:hover { opacity: 1; } diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index b7fa89c..f5868f1 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -120,6 +120,11 @@ export interface ProviderStatus { export interface CloudModelCatalogItem { id: string; modes: string[]; + capabilities: { + image: boolean; + tools: boolean; + search: boolean; + }; } export interface CloudModelCatalogResult { diff --git a/src/shared/todoOrdering.ts b/src/shared/todoOrdering.ts new file mode 100644 index 0000000..51a4e8e --- /dev/null +++ b/src/shared/todoOrdering.ts @@ -0,0 +1,62 @@ +import { TodoItem } from "./contracts"; + +export function sortTodosForDisplay(todos: TodoItem[]): TodoItem[] { + return [...todos].sort((left, right) => { + if (left.completed !== right.completed) { + return Number(left.completed) - Number(right.completed); + } + if (left.order !== right.order) { + return left.order - right.order; + } + return left.createdAt.localeCompare(right.createdAt); + }); +} + +export function reorderTodoForCompletion( + todos: TodoItem[], + id: string, + completed: boolean +): TodoItem[] { + const sorted = sortTodosForDisplay(todos); + const target = sorted.find((todo) => todo.id === id); + if (!target) { + return sorted; + } + + const updatedTarget: TodoItem = { ...target, completed }; + const remaining = sorted.filter((todo) => todo.id !== id); + const pending = remaining.filter((todo) => !todo.completed); + const done = remaining.filter((todo) => todo.completed); + + return [...pending, updatedTarget, ...done].map((todo, index) => ({ + ...todo, + order: index + })); +} + +export function reorderTodoInGroup( + todos: TodoItem[], + draggedId: string, + targetId: string +): TodoItem[] { + const sorted = sortTodosForDisplay(todos); + const fromIndex = sorted.findIndex((todo) => todo.id === draggedId); + const toIndex = sorted.findIndex((todo) => todo.id === targetId); + + if (fromIndex === -1 || toIndex === -1) { + return sorted; + } + + if (sorted[fromIndex].completed !== sorted[toIndex].completed) { + return sorted; + } + + const reordered = [...sorted]; + const [moved] = reordered.splice(fromIndex, 1); + reordered.splice(toIndex, 0, moved); + + return reordered.map((todo, index) => ({ + ...todo, + order: index + })); +} diff --git a/test/main/ollamaProvider.test.ts b/test/main/ollamaProvider.test.ts new file mode 100644 index 0000000..c24dc2b --- /dev/null +++ b/test/main/ollamaProvider.test.ts @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { OllamaProvider } from "../../src/main/providers/ollamaProvider"; + +const ORIGINAL_FETCH = globalThis.fetch; + +function createStreamResponse(chunks: string[]): Response { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)); + } + controller.close(); + } + }); + + return new Response(stream, { + status: 200, + headers: { "Content-Type": "application/x-ndjson" } + }); +} + +test.afterEach(() => { + globalThis.fetch = ORIGINAL_FETCH; +}); + +test("ollama provider accepts buffered JSON chat replies", async () => { + globalThis.fetch = async () => + new Response( + JSON.stringify({ + message: { content: "hello from ollama" }, + done: true + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + + const provider = new OllamaProvider(); + const deltas: string[] = []; + const result = await provider.streamReply({ + baseUrl: "http://localhost:11434", + model: "llama3.2", + messages: [{ id: "1", role: "user", content: "hi", createdAt: "now" }], + onDelta: (delta) => { + deltas.push(delta); + } + }); + + assert.deepEqual(deltas, ["hello from ollama"]); + assert.deepEqual(result.toolCalls, []); +}); + +test("ollama provider accepts streaming ndjson chat replies", async () => { + globalThis.fetch = async () => + createStreamResponse([ + JSON.stringify({ + message: { content: "hello " }, + done: false + }) + "\n", + JSON.stringify({ + message: { content: "world" }, + done: true + }) + ]); + + const provider = new OllamaProvider(); + const deltas: string[] = []; + const result = await provider.streamReply({ + baseUrl: "http://localhost:11434", + model: "llama3.2", + messages: [{ id: "1", role: "user", content: "hi", createdAt: "now" }], + onDelta: (delta) => { + deltas.push(delta); + } + }); + + assert.deepEqual(deltas, ["hello ", "world"]); + assert.deepEqual(result.toolCalls, []); +}); diff --git a/test/main/providerServiceUtils.test.ts b/test/main/providerServiceUtils.test.ts index ceb5aa0..eb2296d 100644 --- a/test/main/providerServiceUtils.test.ts +++ b/test/main/providerServiceUtils.test.ts @@ -2,8 +2,11 @@ import assert from "node:assert/strict"; import test from "node:test"; import { ChatMessage } from "../../src/shared/contracts"; import { + extractUrls, + isWeatherQuery, parseActions, prepareMessagesForAPI, + requiresLiveWebSearch, truncateContext } from "../../src/main/providerServiceUtils"; @@ -77,3 +80,26 @@ test("truncateContext drops oldest messages but preserves at least four", () => ["3", "4", "5", "6"] ); }); + +test("extractUrls returns unique cleaned urls", () => { + assert.deepEqual( + extractUrls( + "Read https://example.com/foo, then compare with https://example.com/foo." + ), + ["https://example.com/foo"] + ); +}); + +test("requiresLiveWebSearch detects live/current-info prompts", () => { + assert.equal( + requiresLiveWebSearch("What is the weather in Calcutta right now?"), + true + ); + assert.equal(requiresLiveWebSearch("Give me the latest Apple news"), true); + assert.equal(requiresLiveWebSearch("Rewrite this paragraph"), false); +}); + +test("isWeatherQuery detects weather prompts only", () => { + assert.equal(isWeatherQuery("What is the temperature in Delhi?"), true); + assert.equal(isWeatherQuery("Summarize my meeting notes"), false); +}); diff --git a/test/shared/todoOrdering.test.ts b/test/shared/todoOrdering.test.ts new file mode 100644 index 0000000..61c552f --- /dev/null +++ b/test/shared/todoOrdering.test.ts @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { TodoItem } from "../../src/shared/contracts"; +import { + reorderTodoForCompletion, + reorderTodoInGroup, + sortTodosForDisplay +} from "../../src/shared/todoOrdering"; + +function todo( + id: string, + title: string, + order: number, + completed = false +): TodoItem { + return { + id, + title, + completed, + order, + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString() + }; +} + +test("sortTodosForDisplay keeps pending todos before completed todos", () => { + const sorted = sortTodosForDisplay([ + todo("done-1", "done", 0, true), + todo("todo-1", "todo", 1, false) + ]); + + assert.deepEqual( + sorted.map((item) => item.id), + ["todo-1", "done-1"] + ); +}); + +test("reorderTodoForCompletion moves a newly completed todo to the top of the done pile", () => { + const reordered = reorderTodoForCompletion( + [ + todo("todo-1", "first", 0, false), + todo("todo-2", "second", 1, false), + todo("done-1", "done", 2, true) + ], + "todo-1", + true + ); + + assert.deepEqual( + reordered.map((item) => item.id), + ["todo-2", "todo-1", "done-1"] + ); +}); + +test("reorderTodoInGroup reorders items within the same completion section", () => { + const reordered = reorderTodoInGroup( + [ + todo("todo-1", "first", 0, false), + todo("todo-2", "second", 1, false), + todo("done-1", "done", 2, true) + ], + "todo-2", + "todo-1" + ); + + assert.deepEqual( + reordered.map((item) => item.id), + ["todo-2", "todo-1", "done-1"] + ); +}); + +test("reorderTodoInGroup ignores drops across pending and completed sections", () => { + const reordered = reorderTodoInGroup( + [todo("todo-1", "first", 0, false), todo("done-1", "done", 1, true)], + "todo-1", + "done-1" + ); + + assert.deepEqual( + reordered.map((item) => item.id), + ["todo-1", "done-1"] + ); +});