From 80147c9900bb8ea275d1405ffa4163eccfa784ae Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 5 Mar 2026 18:57:07 +0200 Subject: [PATCH] feat: update package version and add linting dependency - Bumped package version from 0.1.0 to 1.0.0 to reflect significant updates. - Added @codemirror/lint dependency to enhance code linting capabilities. - Updated pnpm-lock.yaml to include the new linting dependency version. --- package.json | 3 +- pnpm-lock.yaml | 11 +- .../services/infrastructure/UpdaterService.ts | 2 + .../services/team/TeamProvisioningService.ts | 58 ++- .../components/chat/DisplayItemList.tsx | 1 + .../components/chat/items/BaseItem.tsx | 2 +- .../components/chat/items/LinkedToolItem.tsx | 12 +- .../components/chat/items/TextItem.tsx | 8 +- .../components/chat/items/ThinkingItem.tsx | 8 +- .../components/chat/searchHighlightUtils.ts | 17 +- .../chat/viewers/MarkdownViewer.tsx | 4 + .../components/common/WarningBanner.tsx | 25 ++ .../components/layout/SidebarHeader.tsx | 8 +- .../components/layout/SortableTab.tsx | 17 +- src/renderer/components/layout/TabBar.tsx | 7 +- .../components/TriggerPreview.tsx | 9 +- .../settings/sections/AdvancedSection.tsx | 23 +- .../settings/sections/CliStatusSection.tsx | 17 +- .../settings/sections/ConfigEditorDialog.tsx | 402 ++++++++++++++++++ .../settings/sections/GeneralSection.tsx | 17 +- .../components/team/CliLogsRichView.tsx | 7 +- .../team/ProvisioningProgressBlock.tsx | 8 +- .../components/team/TeamDetailView.tsx | 19 +- src/renderer/components/team/TeamListView.tsx | 4 + .../team/TeamProvisioningBanner.tsx | 12 +- .../attachments/AttachmentPreviewList.tsx | 9 +- .../team/dialogs/CreateTaskDialog.tsx | 13 +- .../team/dialogs/CreateTeamDialog.tsx | 30 +- .../team/dialogs/ExtendedContextCheckbox.tsx | 15 +- .../team/dialogs/LaunchTeamDialog.tsx | 34 +- .../team/dialogs/ProjectPathSelector.tsx | 2 +- .../team/editor/ProjectEditorOverlay.tsx | 9 +- .../team/messages/MessageComposer.tsx | 2 +- src/renderer/index.css | 28 ++ src/renderer/store/slices/updateSlice.ts | 1 + 35 files changed, 757 insertions(+), 87 deletions(-) create mode 100644 src/renderer/components/common/WarningBanner.tsx create mode 100644 src/renderer/components/settings/sections/ConfigEditorDialog.tsx diff --git a/package.json b/package.json index 99fe397f..4db5dd7d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "claude-agent-teams-ui", "type": "module", - "version": "0.1.0", + "version": "1.0.0", "description": "Desktop app that visualizes Claude Code session execution — explore conversations, track context usage, and analyze tool calls", "license": "AGPL-3.0", "author": { @@ -80,6 +80,7 @@ "@codemirror/lang-yaml": "^6.1.2", "@codemirror/language": "^6.12.1", "@codemirror/language-data": "^6.5.2", + "@codemirror/lint": "^6.9.5", "@codemirror/merge": "^6.12.0", "@codemirror/search": "^6.6.0", "@codemirror/state": "^6.5.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f916640..147ae6f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: '@codemirror/language-data': specifier: ^6.5.2 version: 6.5.2 + '@codemirror/lint': + specifier: ^6.9.5 + version: 6.9.5 '@codemirror/merge': specifier: ^6.12.0 version: 6.12.0 @@ -576,8 +579,8 @@ packages: '@codemirror/legacy-modes@6.5.2': resolution: {integrity: sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==} - '@codemirror/lint@6.9.4': - resolution: {integrity: sha512-ABc9vJ8DEmvOWuH26P3i8FpMWPQkduD9Rvba5iwb6O3hxASgclm3T3krGo8NASXkHCidz6b++LWlzWIUfEPSWw==} + '@codemirror/lint@6.9.5': + resolution: {integrity: sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==} '@codemirror/merge@6.12.0': resolution: {integrity: sha512-o+36bbapcEHf4Ux75pZ4CKjMBUd14parA0uozvWVlacaT+uxaA3DDefEvWYjngsKU+qsrDe/HOOfsw0Q72pLjA==} @@ -6449,7 +6452,7 @@ snapshots: dependencies: '@codemirror/autocomplete': 6.20.0 '@codemirror/language': 6.12.1 - '@codemirror/lint': 6.9.4 + '@codemirror/lint': 6.9.5 '@codemirror/state': 6.5.4 '@codemirror/view': 6.39.15 '@lezer/common': 1.5.1 @@ -6609,7 +6612,7 @@ snapshots: dependencies: '@codemirror/language': 6.12.1 - '@codemirror/lint@6.9.4': + '@codemirror/lint@6.9.5': dependencies: '@codemirror/state': 6.5.4 '@codemirror/view': 6.39.15 diff --git a/src/main/services/infrastructure/UpdaterService.ts b/src/main/services/infrastructure/UpdaterService.ts index ec018951..9fb74ee4 100644 --- a/src/main/services/infrastructure/UpdaterService.ts +++ b/src/main/services/infrastructure/UpdaterService.ts @@ -41,6 +41,7 @@ export class UpdaterService { await autoUpdater.checkForUpdates(); } catch (error) { logger.error('Check for updates failed:', getErrorMessage(error)); + this.sendStatus({ type: 'error', error: getErrorMessage(error) }); } } @@ -52,6 +53,7 @@ export class UpdaterService { await autoUpdater.downloadUpdate(); } catch (error) { logger.error('Download update failed:', getErrorMessage(error)); + this.sendStatus({ type: 'error', error: getErrorMessage(error) }); } } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index f664304c..71a7055a 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -161,6 +161,8 @@ interface ProvisioningRun { * request triggered by the UI. We suppress any lead→user echo for that turn. */ silentUserDmForward: { target: string; startedAt: string } | null; + /** Safety valve: clears silentUserDmForward if turn never completes. */ + silentUserDmForwardClearHandle: NodeJS.Timeout | null; /** Accumulates assistant text during provisioning phase for live UI preview. */ provisioningOutputParts: string[]; /** Session ID detected from stream-json output (result.session_id or message.session_id). */ @@ -659,8 +661,14 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string { ` - CRITICAL: Do NOT start working on the tasks now. Provisioning is ONLY for setting up the team structure.\n` + ` - The tasks will be executed after the team is launched separately.` : `3) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked — create tasks on the team board. - - Prefer fewer, broader tasks over many micro-tasks. - - Avoid duplicate notifications for the same assignment. + - PRIORITY (delegation-first): your default behavior is to translate user requests into a task plan, create the tasks, and delegate them to teammates. + - Do NOT start executing/implementing tasks yourself in this turn. + - Do NOT “block” on doing the work before creating/assigning tasks — keep this turn fast so the user can send more instructions. + - Exception: only if the team is truly SOLO (no teammates) may you execute tasks yourself. This is NOT the case here. + - Decompose the request into a small set of clear, outcome-based tasks (prefer fewer, broader tasks over many micro-tasks). + - Assign each created task to an appropriate teammate as owner (NOT to yourself), based on role/workflow and current load. + - If ownership is unclear, pick the best default owner and note assumptions in the task description or a task comment. + - Avoid duplicate notifications for the same assignment (one message per member per topic is enough). - When tasks have natural ordering (e.g. setup → implementation → testing), use --blocked-by. - If a task is blocked (uses --blocked-by), it MUST be created as pending (use --status pending). Do NOT mark blocked tasks in_progress. - Review guidance: @@ -713,6 +721,7 @@ Constraints: - NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough. - Keep the task board high-signal: avoid creating tasks for trivial micro-items. - Use the team task board for assigned/substantial work. +- DELEGATION-FIRST (behavior rule for ALL future turns): When "user" gives you work, your top priority is to (a) decompose into tasks, (b) create tasks on the team board, (c) assign them to teammates, and (d) SendMessage "user" a short confirmation (task IDs + owners). Do NOT start implementing yourself unless the team is truly in SOLO MODE (no teammates). - TaskCreate is optional for private planning only; do NOT use it for team-board tasks. - When messaging "user" (the human): NEVER mention teamctl.js, internal scripts, CLI commands, or file paths under ~/.claude/. The user sees messages in the UI — write plain human language. If a task needs a status update, do it yourself via Bash; never ask the user to run a command.${soloConstraint} @@ -875,6 +884,7 @@ Constraints: - NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough. - Keep the task board high-signal: avoid creating tasks for trivial micro-items. - Use the team task board for assigned/substantial work. +- DELEGATION-FIRST (behavior rule for ALL future turns): When "user" gives you work, your top priority is to (a) decompose into tasks, (b) create tasks on the team board, (c) assign them to teammates, and (d) SendMessage "user" a short confirmation (task IDs + owners). Do NOT start implementing yourself unless the team is truly in SOLO MODE (no teammates). - TaskCreate is optional for private planning only; do NOT use it for team-board tasks. - When messaging "user" (the human): NEVER mention teamctl.js, internal scripts, CLI commands, or file paths under ~/.claude/. The user sees messages in the UI — write plain human language. If a task needs a status update, do it yourself via Bash; never ask the user to run a command.${soloConstraint} @@ -1684,6 +1694,7 @@ export class TeamProvisioningService { leadRelayCapture: null, directReplyParts: [], silentUserDmForward: null, + silentUserDmForwardClearHandle: null, provisioningOutputParts: [], detectedSessionId: null, leadActivityState: 'active', @@ -1974,6 +1985,7 @@ export class TeamProvisioningService { leadRelayCapture: null, directReplyParts: [], silentUserDmForward: null, + silentUserDmForwardClearHandle: null, provisioningOutputParts: [], detectedSessionId: null, leadActivityState: 'active', @@ -2243,20 +2255,34 @@ export class TeamProvisioningService { } run.silentUserDmForward = { target: teammateName, startedAt: nowIso() }; + if (run.silentUserDmForwardClearHandle) { + clearTimeout(run.silentUserDmForwardClearHandle); + run.silentUserDmForwardClearHandle = null; + } + // Safety valve: if the CLI never emits a result message, don't stay in "silent" mode forever. + run.silentUserDmForwardClearHandle = setTimeout(() => { + run.silentUserDmForward = null; + run.silentUserDmForwardClearHandle = null; + }, 60_000); + run.silentUserDmForwardClearHandle.unref(); const summaryLine = userSummary?.trim() ? `Summary: ${userSummary.trim()}` : null; + const internal = wrapInAgentBlock( + [ + `UI relay request — forward a direct message to teammate "${teammateName}".`, + `MUST: use the SendMessage tool with recipient="${teammateName}".`, + `MUST: ask the teammate to reply back to recipient "user" (short answer).`, + `CRITICAL: Do NOT send any message to recipient "user" for this turn.`, + ].join('\n') + ); const message = [ - `INTERNAL: The human user sent a direct message to teammate "${teammateName}" via the UI.`, - `Action: forward it to that teammate using the SendMessage tool.`, - `IMPORTANT: Do NOT reply to the human user for this turn.`, - `In the forwarded message, ask the teammate to reply to recipient "user" with a short answer.`, + `User DM relay (internal).`, + internal, ``, - `User message:`, + `Message to forward:`, ...(summaryLine ? [summaryLine] : []), userText, - ] - .filter(Boolean) - .join('\n'); + ].join('\n'); await this.sendMessageToTeam(teamName, message); } @@ -2986,6 +3012,10 @@ export class TeamProvisioningService { } // Clear silent relay flag after any successful turn. run.silentUserDmForward = null; + if (run.silentUserDmForwardClearHandle) { + clearTimeout(run.silentUserDmForwardClearHandle); + run.silentUserDmForwardClearHandle = null; + } if (!run.provisioningComplete && !run.cancelRequested) { void this.handleProvisioningTurnComplete(run); } @@ -2998,6 +3028,10 @@ export class TeamProvisioningService { } // Clear silent relay flag after any errored turn. run.silentUserDmForward = null; + if (run.silentUserDmForwardClearHandle) { + clearTimeout(run.silentUserDmForwardClearHandle); + run.silentUserDmForwardClearHandle = null; + } if (!run.provisioningComplete && !run.cancelRequested) { const progress = updateProgress( run, @@ -3238,6 +3272,10 @@ export class TeamProvisioningService { clearTimeout(run.timeoutHandle); run.timeoutHandle = null; } + if (run.silentUserDmForwardClearHandle) { + clearTimeout(run.silentUserDmForwardClearHandle); + run.silentUserDmForwardClearHandle = null; + } this.stopFilesystemMonitor(run); // Remove stream listeners to prevent data handlers firing on a cleaned-up run if (run.child) { diff --git a/src/renderer/components/chat/DisplayItemList.tsx b/src/renderer/components/chat/DisplayItemList.tsx index ecd5a649..59dd25ae 100644 --- a/src/renderer/components/chat/DisplayItemList.tsx +++ b/src/renderer/components/chat/DisplayItemList.tsx @@ -162,6 +162,7 @@ export const DisplayItemList = ({ linkedTool={item.tool} onClick={() => onItemClick(itemKey)} isExpanded={expandedItemIds.has(itemKey)} + searchQueryOverride={searchQueryOverride} isHighlighted={highlightToolUseId === item.tool.id} highlightColor={highlightColor} notificationDotColor={notificationColorMap?.get(item.tool.id)} diff --git a/src/renderer/components/chat/items/BaseItem.tsx b/src/renderer/components/chat/items/BaseItem.tsx index 66e772f7..e1a20ec0 100644 --- a/src/renderer/components/chat/items/BaseItem.tsx +++ b/src/renderer/components/chat/items/BaseItem.tsx @@ -18,7 +18,7 @@ interface BaseItemProps { /** Primary label (e.g., "Thinking", "Output", tool name) */ label: string; /** Summary text shown after the label */ - summary?: string; + summary?: React.ReactNode; /** Token count to display */ tokenCount?: number; /** Label for tokens (default: "tokens") */ diff --git a/src/renderer/components/chat/items/LinkedToolItem.tsx b/src/renderer/components/chat/items/LinkedToolItem.tsx index c11798d1..2dabb345 100644 --- a/src/renderer/components/chat/items/LinkedToolItem.tsx +++ b/src/renderer/components/chat/items/LinkedToolItem.tsx @@ -38,6 +38,7 @@ import { ToolErrorDisplay, WriteToolViewer, } from './linkedTool'; +import { highlightQueryInText } from '../searchHighlightUtils'; import type { LinkedToolItem as LinkedToolItemType } from '@renderer/types/groups'; @@ -45,6 +46,8 @@ interface LinkedToolItemProps { linkedTool: LinkedToolItemType; onClick: () => void; isExpanded: boolean; + /** Optional local search query override for inline highlighting */ + searchQueryOverride?: string; /** Whether this item should be highlighted for error deep linking */ isHighlighted?: boolean; /** Custom highlight color from trigger */ @@ -59,6 +62,7 @@ export const LinkedToolItem: React.FC = ({ linkedTool, onClick, isExpanded, + searchQueryOverride, isHighlighted, highlightColor, notificationDotColor, @@ -66,6 +70,12 @@ export const LinkedToolItem: React.FC = ({ }) => { const status = getToolStatus(linkedTool); const summary = getToolSummary(linkedTool.name, linkedTool.input); + const summaryNode = + searchQueryOverride && searchQueryOverride.trim().length > 0 + ? highlightQueryInText(summary, searchQueryOverride, `${linkedTool.id ?? linkedTool.name}:summary`, { + forceAllActive: true, + }) + : summary; const elementRef = useRef(null); // Combined ref callback - handles both internal ref and external registration @@ -155,7 +165,7 @@ export const LinkedToolItem: React.FC = ({ /> } label={linkedTool.name} - summary={summary} + summary={summaryNode} tokenCount={getToolContextTokens(linkedTool)} status={status} durationMs={linkedTool.durationMs} diff --git a/src/renderer/components/chat/items/TextItem.tsx b/src/renderer/components/chat/items/TextItem.tsx index ea0cba41..d9f9ab5d 100644 --- a/src/renderer/components/chat/items/TextItem.tsx +++ b/src/renderer/components/chat/items/TextItem.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { MessageSquare } from 'lucide-react'; import { MarkdownViewer } from '../viewers'; +import { highlightQueryInText } from '../searchHighlightUtils'; import { BaseItem } from './BaseItem'; import { truncateText } from './baseItemHelpers'; @@ -40,6 +41,11 @@ export const TextItem: React.FC = ({ }) => { const fullContent = step.content.outputText ?? preview; const truncatedPreview = truncateText(preview, 60); + const summary = searchQueryOverride + ? highlightQueryInText(truncatedPreview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, { + forceAllActive: true, + }) + : truncatedPreview; // Get token count from step.tokens.output or step.content.tokenCount const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0; @@ -48,7 +54,7 @@ export const TextItem: React.FC = ({ } label="Output" - summary={truncatedPreview} + summary={summary} tokenCount={tokenCount} onClick={onClick} isExpanded={isExpanded} diff --git a/src/renderer/components/chat/items/ThinkingItem.tsx b/src/renderer/components/chat/items/ThinkingItem.tsx index 34a6f50d..c3cdafad 100644 --- a/src/renderer/components/chat/items/ThinkingItem.tsx +++ b/src/renderer/components/chat/items/ThinkingItem.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { Brain } from 'lucide-react'; import { MarkdownViewer } from '../viewers'; +import { highlightQueryInText } from '../searchHighlightUtils'; import { BaseItem } from './BaseItem'; import { truncateText } from './baseItemHelpers'; @@ -40,6 +41,11 @@ export const ThinkingItem: React.FC = ({ }) => { const fullContent = step.content.thinkingText ?? preview; const truncatedPreview = truncateText(preview, 60); + const summary = searchQueryOverride + ? highlightQueryInText(truncatedPreview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, { + forceAllActive: true, + }) + : truncatedPreview; // Get token count from step.tokens.output or step.content.tokenCount const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0; @@ -48,7 +54,7 @@ export const ThinkingItem: React.FC = ({ } label="Thinking" - summary={truncatedPreview} + summary={summary} tokenCount={tokenCount} onClick={onClick} isExpanded={isExpanded} diff --git a/src/renderer/components/chat/searchHighlightUtils.ts b/src/renderer/components/chat/searchHighlightUtils.ts index 873351b4..12ec37e2 100644 --- a/src/renderer/components/chat/searchHighlightUtils.ts +++ b/src/renderer/components/chat/searchHighlightUtils.ts @@ -35,6 +35,8 @@ export interface SearchContext { matchCounter: { current: number }; isCurrentItem: boolean; currentMatchIndexInItem: number | null; + /** When true, render all matches using the "current" highlight style */ + forceAllActive?: boolean; } /** @@ -79,7 +81,8 @@ function highlightSearchText(text: string, ctx: SearchContext): React.ReactNode } const isCurrentResult = - ctx.isCurrentItem && ctx.currentMatchIndexInItem === ctx.matchCounter.current; + ctx.forceAllActive === true || + (ctx.isCurrentItem && ctx.currentMatchIndexInItem === ctx.matchCounter.current); parts.push( React.createElement( @@ -109,6 +112,18 @@ function highlightSearchText(text: string, ctx: SearchContext): React.ReactNode return parts; } +export function highlightQueryInText( + text: string, + query: string, + itemId: string, + options?: { forceAllActive?: boolean } +): React.ReactNode { + const ctx = createSearchContext(query, itemId, [], -1); + if (!ctx) return text; + if (options?.forceAllActive) ctx.forceAllActive = true; + return highlightSearchInChildren(text, ctx); +} + /** * Recursively process React children to highlight search terms in text nodes. * Preserves the React element tree structure (markdown components, etc.) diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index fc5d4d41..25923608 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -600,6 +600,10 @@ export const MarkdownViewer: React.FC = ({ effectiveQuery && itemId ? createSearchContext(effectiveQuery, itemId, effectiveMatches, effectiveIndex) : null; + // Local search (Claude logs): use bright highlight for all matches (no "current result" concept). + if (searchCtx && searchQueryOverride) { + searchCtx.forceAllActive = true; + } // Create markdown components with optional search highlighting // When search is active, create fresh each render (match counter is stateful and must start at 0) diff --git a/src/renderer/components/common/WarningBanner.tsx b/src/renderer/components/common/WarningBanner.tsx new file mode 100644 index 00000000..5bc540ac --- /dev/null +++ b/src/renderer/components/common/WarningBanner.tsx @@ -0,0 +1,25 @@ +import { AlertTriangle } from 'lucide-react'; + +interface WarningBannerProps { + children: React.ReactNode; + className?: string; + icon?: React.ReactNode; +} + +export const WarningBanner = ({ + children, + className = '', + icon, +}: WarningBannerProps): React.JSX.Element => ( +
+ {icon ?? } +
{children}
+
+); diff --git a/src/renderer/components/layout/SidebarHeader.tsx b/src/renderer/components/layout/SidebarHeader.tsx index b9629c89..4b631c7f 100644 --- a/src/renderer/components/layout/SidebarHeader.tsx +++ b/src/renderer/components/layout/SidebarHeader.tsx @@ -44,9 +44,11 @@ export const SidebarHeader = (): React.JSX.Element => { } as React.CSSProperties } > -
- -
+ {isMacElectron && ( +
+ +
+ )}
+ + setConfigEditorOpen(false)} + onConfigSaved={() => { + // Config saved via editor — settings page will pick up changes on next render + }} + /> ); }; diff --git a/src/renderer/components/settings/sections/CliStatusSection.tsx b/src/renderer/components/settings/sections/CliStatusSection.tsx index 2e7647ca..aadc0c9c 100644 --- a/src/renderer/components/settings/sections/CliStatusSection.tsx +++ b/src/renderer/components/settings/sections/CliStatusSection.tsx @@ -27,6 +27,7 @@ export const CliStatusSection = (): React.JSX.Element | null => { fetchCliStatus, installCli, isBusy, + cliStatusLoading, } = useCliInstaller(); useEffect(() => { @@ -129,14 +130,24 @@ export const CliStatusSection = (): React.JSX.Element | null => { {cliStatus.installed && !cliStatus.updateAvailable && ( )} diff --git a/src/renderer/components/settings/sections/ConfigEditorDialog.tsx b/src/renderer/components/settings/sections/ConfigEditorDialog.tsx new file mode 100644 index 00000000..02872b4e --- /dev/null +++ b/src/renderer/components/settings/sections/ConfigEditorDialog.tsx @@ -0,0 +1,402 @@ +/** + * ConfigEditorDialog — inline JSON config editor powered by CodeMirror. + * + * Opens as a dialog, shows the full app config as formatted JSON. + * Auto-saves on changes with debounce. Shows validation errors for malformed JSON. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; +import { json } from '@codemirror/lang-json'; +import { bracketMatching, foldGutter, foldKeymap, indentOnInput, syntaxHighlighting } from '@codemirror/language'; +import { lintGutter, linter, type Diagnostic } from '@codemirror/lint'; +import { search, searchKeymap } from '@codemirror/search'; +import { EditorState } from '@codemirror/state'; +import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark'; +import { + EditorView, + highlightActiveLine, + highlightActiveLineGutter, + keymap, + lineNumbers, +} from '@codemirror/view'; +import { api } from '@renderer/api'; +import { useStore } from '@renderer/store'; +import { baseEditorTheme } from '@renderer/utils/codemirrorTheme'; +import { AlertTriangle, Check, Loader2, X } from 'lucide-react'; + +import type { AppConfig } from '@renderer/types/data'; + +// ============================================================================= +// Constants +// ============================================================================= + +const SAVE_DEBOUNCE_MS = 800; + +// ============================================================================= +// JSON Linter +// ============================================================================= + +const jsonLinter = linter((view: EditorView) => { + const diagnostics: Diagnostic[] = []; + const text = view.state.doc.toString(); + try { + JSON.parse(text); + } catch (e) { + if (e instanceof SyntaxError) { + const match = e.message.match(/position (\d+)/); + const pos = match ? parseInt(match[1], 10) : 0; + const safePos = Math.min(pos, text.length); + diagnostics.push({ + from: safePos, + to: Math.min(safePos + 1, text.length), + severity: 'error', + message: e.message, + }); + } + } + return diagnostics; +}); + +// ============================================================================= +// Types +// ============================================================================= + +interface ConfigEditorDialogProps { + open: boolean; + onClose: () => void; + onConfigSaved: (config: AppConfig) => void; +} + +type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'; + +// ============================================================================= +// Component +// ============================================================================= + +export const ConfigEditorDialog = ({ + open, + onClose, + onConfigSaved, +}: ConfigEditorDialogProps): React.JSX.Element | null => { + const editorRef = useRef(null); + const viewRef = useRef(null); + const saveTimerRef = useRef>(); + const savedRevertTimerRef = useRef>(); + const [saveStatus, setSaveStatus] = useState('idle'); + const [jsonError, setJsonError] = useState(null); + const [loading, setLoading] = useState(true); + const initialConfigRef = useRef(''); + + const saveConfig = useCallback( + async (jsonText: string) => { + try { + const parsed = JSON.parse(jsonText) as AppConfig; + setJsonError(null); + setSaveStatus('saving'); + + // Save each section separately via existing API + if (parsed.general) { + await api.config.update('general', parsed.general); + } + if (parsed.notifications) { + await api.config.update('notifications', parsed.notifications); + } + if (parsed.display) { + await api.config.update('display', parsed.display); + } + if (parsed.sessions) { + await api.config.update('sessions', parsed.sessions); + } + + // Re-fetch to get the canonical saved state + const fresh = await api.config.get(); + onConfigSaved(fresh); + useStore.setState({ appConfig: fresh }); + initialConfigRef.current = JSON.stringify(fresh, null, 2); + + setSaveStatus('saved'); + if (savedRevertTimerRef.current) clearTimeout(savedRevertTimerRef.current); + savedRevertTimerRef.current = setTimeout(() => setSaveStatus('idle'), 2000); + } catch (e) { + if (e instanceof SyntaxError) { + setJsonError(e.message); + setSaveStatus('idle'); + } else { + setSaveStatus('error'); + setJsonError(e instanceof Error ? e.message : 'Failed to save config'); + if (savedRevertTimerRef.current) clearTimeout(savedRevertTimerRef.current); + savedRevertTimerRef.current = setTimeout(() => { + setSaveStatus('idle'); + setJsonError(null); + }, 4000); + } + } + }, + [onConfigSaved] + ); + + const scheduleSave = useCallback( + (jsonText: string) => { + // Validate JSON before scheduling save + try { + JSON.parse(jsonText); + setJsonError(null); + } catch (e) { + if (e instanceof SyntaxError) { + setJsonError(e.message); + } + return; + } + + if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + saveTimerRef.current = setTimeout(() => { + void saveConfig(jsonText); + }, SAVE_DEBOUNCE_MS); + }, + [saveConfig] + ); + + // Initialize CodeMirror when dialog opens + useEffect(() => { + if (!open) return; + + let destroyed = false; + setLoading(true); + setSaveStatus('idle'); + setJsonError(null); + + const init = async (): Promise => { + const config = await api.config.get(); + if (destroyed) return; + + const jsonText = JSON.stringify(config, null, 2); + initialConfigRef.current = jsonText; + setLoading(false); + + // Wait for DOM render + requestAnimationFrame(() => { + if (destroyed || !editorRef.current) return; + + // Clean up existing view + if (viewRef.current) { + viewRef.current.destroy(); + viewRef.current = null; + } + + const state = EditorState.create({ + doc: jsonText, + extensions: [ + lineNumbers(), + highlightActiveLineGutter(), + highlightActiveLine(), + history(), + foldGutter(), + indentOnInput(), + bracketMatching(), + json(), + syntaxHighlighting(oneDarkHighlightStyle), + jsonLinter, + lintGutter(), + search(), + keymap.of([...defaultKeymap, ...historyKeymap, ...foldKeymap, ...searchKeymap]), + baseEditorTheme, + configEditorTheme, + EditorView.updateListener.of((update) => { + if (update.docChanged) { + const text = update.state.doc.toString(); + scheduleSave(text); + } + }), + ], + }); + + const view = new EditorView({ + state, + parent: editorRef.current, + }); + viewRef.current = view; + }); + }; + + void init(); + + return () => { + destroyed = true; + if (viewRef.current) { + viewRef.current.destroy(); + viewRef.current = null; + } + if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + if (savedRevertTimerRef.current) clearTimeout(savedRevertTimerRef.current); + }; + }, [open, scheduleSave]); + + // Escape key handler + useEffect(() => { + if (!open) return; + const handleKeyDown = (e: KeyboardEvent): void => { + if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [open, onClose]); + + if (!open) return null; + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+ {/* Header */} +
+
+

+ Edit Configuration +

+ +
+ +
+ + {/* Editor */} +
+ {loading ? ( +
+ + Loading config... +
+ ) : ( +
+ )} +
+ + {/* Footer */} +
+

+ Changes auto-save after editing +

+
+ + Esc + + + to close + +
+
+
+
+ ); +}; + +// ============================================================================= +// Save Status Badge +// ============================================================================= + +const SaveStatusBadge = ({ + status, + error, +}: { + status: SaveStatus; + error: string | null; +}): React.JSX.Element | null => { + if (status === 'idle' && !error) return null; + + if (error && status !== 'saving') { + return ( + + + {status === 'error' ? 'Save failed' : 'Invalid JSON'} + + ); + } + + if (status === 'saving') { + return ( + + + Saving... + + ); + } + + if (status === 'saved') { + return ( + + + Saved + + ); + } + + return null; +}; + +// ============================================================================= +// Editor Theme Override +// ============================================================================= + +const configEditorTheme = EditorView.theme({ + '&': { + height: '100%', + maxHeight: 'calc(85vh - 100px)', + }, + '.cm-scroller': { + overflow: 'auto', + padding: '8px 0', + }, + '.cm-content': { + padding: '0 8px', + }, + '.cm-gutters': { + paddingLeft: '4px', + }, +}); diff --git a/src/renderer/components/settings/sections/GeneralSection.tsx b/src/renderer/components/settings/sections/GeneralSection.tsx index 16471af6..c78078fa 100644 --- a/src/renderer/components/settings/sections/GeneralSection.tsx +++ b/src/renderer/components/settings/sections/GeneralSection.tsx @@ -366,11 +366,16 @@ export const GeneralSection = ({ confirmLabel: 'Restart', }); if (shouldRelaunch) { - onGeneralToggle('useNativeTitleBar', v); - // Small delay to let config persist before relaunch - setTimeout(() => { - void window.electronAPI?.windowControls?.relaunch(); - }, 200); + // Await config write before relaunch to avoid race condition on Windows + // (antivirus/NTFS can delay file writes beyond a fixed timeout) + try { + await api.config.update('general', { useNativeTitleBar: v }); + } catch { + // If save fails, still try to toggle via the normal path + onGeneralToggle('useNativeTitleBar', v); + await new Promise((r) => setTimeout(r, 500)); + } + void window.electronAPI?.windowControls?.relaunch(); } }} disabled={saving} @@ -503,7 +508,7 @@ export const GeneralSection = ({ {candidate.path}

{!candidate.hasProjectsDir && ( -

+

No projects directory detected

)} diff --git a/src/renderer/components/team/CliLogsRichView.tsx b/src/renderer/components/team/CliLogsRichView.tsx index 0b8cd8a5..971faecd 100644 --- a/src/renderer/components/team/CliLogsRichView.tsx +++ b/src/renderer/components/team/CliLogsRichView.tsx @@ -10,6 +10,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { DisplayItemList } from '@renderer/components/chat/DisplayItemList'; +import { highlightQueryInText } from '@renderer/components/chat/searchHighlightUtils'; import { cn } from '@renderer/lib/utils'; import { parseStreamJsonToGroups } from '@renderer/utils/streamJsonParser'; import { Bot, ChevronRight } from 'lucide-react'; @@ -119,7 +120,11 @@ const StreamGroup = ({ /> - {group.summary} + {searchQueryOverride && searchQueryOverride.trim().length > 0 + ? highlightQueryInText(group.summary, searchQueryOverride, `${group.id}:group-summary`, { + forceAllActive: true, + }) + : group.summary} {isExpanded && ( diff --git a/src/renderer/components/team/ProvisioningProgressBlock.tsx b/src/renderer/components/team/ProvisioningProgressBlock.tsx index 64fd0789..59efa480 100644 --- a/src/renderer/components/team/ProvisioningProgressBlock.tsx +++ b/src/renderer/components/team/ProvisioningProgressBlock.tsx @@ -185,7 +185,7 @@ export const ProvisioningProgressBlock = ({

{message} @@ -201,9 +201,9 @@ export const ProvisioningProgressBlock = ({ variant="secondary" className={cn( 'whitespace-nowrap px-2 py-0.5 text-[11px] font-normal', - isDone && 'border-emerald-400/60 bg-emerald-500/10 text-emerald-200', + isDone && 'border-[var(--step-done-border)] bg-[var(--step-done-bg)] text-[var(--step-done-text)]', isCurrent && - 'border-[var(--color-accent)]/70 bg-[var(--color-accent)]/15 text-[var(--color-text)]' + 'border-[var(--step-current-border)] bg-[var(--step-current-bg)] text-[var(--step-current-text)]' )} > @@ -241,7 +241,7 @@ export const ProvisioningProgressBlock = ({

No output captured yet. diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 251538e0..64ab3f9c 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -1081,15 +1081,22 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele

{!data.isAlive && !isTeamProvisioning ? ( -
- - +
+ + Team is offline
{data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? ( -
+
Failed to fully load kanban. Displaying safe data.
) : null} {reviewActionError ? ( -
+
{reviewActionError}
) : null} diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index 3b508855..7524b49d 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -556,10 +556,14 @@ export const TeamListView = (): React.JSX.Element => {
diff --git a/src/renderer/components/team/TeamProvisioningBanner.tsx b/src/renderer/components/team/TeamProvisioningBanner.tsx index c96ba6f6..20006dae 100644 --- a/src/renderer/components/team/TeamProvisioningBanner.tsx +++ b/src/renderer/components/team/TeamProvisioningBanner.tsx @@ -78,11 +78,11 @@ export const TeamProvisioningBanner = ({ return (
-

{progress.message}

+

{progress.message}

) : null} {disabled && disabledHint && attachments.length > 0 ? ( -
- -

{disabledHint}

+
+ +

{disabledHint}

) : null} {error ? ( diff --git a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx index d650bef7..6f0f0fc2 100644 --- a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx @@ -241,9 +241,16 @@ export const CreateTaskDialog = ({ {!isTeamAlive ? ( -
- -

+

+ +

Team is offline. The task will be added to TODO — launch the team to start execution.

diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 41768955..f87509bc 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -588,25 +588,32 @@ export const CreateTeamDialog = ({ {conflictingTeam && !conflictDismissed ? ( -
+
- +
-

+

Another team “{conflictingTeam.displayName}” is already running for this working directory

-

+

Running two teams in the same directory is risky — they may conflict editing the same files. Consider using a different directory or a git worktree for isolation.

-

+

Working directory: {effectiveCwd}

diff --git a/src/renderer/index.css b/src/renderer/index.css index 60d9387e..1e18c1ca 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -198,6 +198,20 @@ --skeleton-base: #1a1c28; --skeleton-base-light: #23252f; --skeleton-base-dim: rgba(26, 28, 40, 0.6); + + /* Provisioning step badges */ + --step-done-bg: rgba(16, 185, 129, 0.1); + --step-done-border: rgba(52, 211, 153, 0.6); + --step-done-text: #6ee7b7; + --step-current-bg: rgba(99, 102, 241, 0.15); + --step-current-border: rgba(129, 140, 248, 0.7); + --step-current-text: #f1f5f9; + --step-error-text: #fca5a5; + --step-error-text-dim: rgba(252, 165, 165, 0.8); + --step-success-text: #6ee7b7; + --step-warning-text: #fde68a; + --step-warning-border: rgba(245, 158, 11, 0.4); + --step-warning-bg: rgba(245, 158, 11, 0.1); } /* File icon glow — halo so dark icons stay visible on dark backgrounds */ @@ -400,6 +414,20 @@ --skeleton-base: #d6d8de; --skeleton-base-light: #cdd0d7; --skeleton-base-dim: rgba(205, 208, 215, 0.6); + + /* Provisioning step badges — dark enough for light backgrounds */ + --step-done-bg: rgba(16, 185, 129, 0.12); + --step-done-border: rgba(5, 150, 105, 0.5); + --step-done-text: #047857; + --step-current-bg: rgba(79, 70, 229, 0.1); + --step-current-border: rgba(79, 70, 229, 0.5); + --step-current-text: #1c1b19; + --step-error-text: #dc2626; + --step-error-text-dim: rgba(220, 38, 38, 0.7); + --step-success-text: #047857; + --step-warning-text: #b45309; + --step-warning-border: rgba(180, 83, 9, 0.4); + --step-warning-bg: rgba(245, 158, 11, 0.1); } /* rehype-highlight (highlight.js) — map hljs classes to app theme variables */ diff --git a/src/renderer/store/slices/updateSlice.ts b/src/renderer/store/slices/updateSlice.ts index aa0852c8..5490acc6 100644 --- a/src/renderer/store/slices/updateSlice.ts +++ b/src/renderer/store/slices/updateSlice.ts @@ -57,6 +57,7 @@ export const createUpdateSlice: StateCreator = (s set({ updateStatus: 'checking', updateError: null }); api.updater.check().catch((error) => { logger.error('Failed to check for updates:', error); + set({ updateStatus: 'error', updateError: error instanceof Error ? error.message : 'Check failed' }); }); },