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.
This commit is contained in:
iliya 2026-03-05 18:57:07 +02:00
parent c963c8a409
commit 80147c9900
35 changed files with 757 additions and 87 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "claude-agent-teams-ui", "name": "claude-agent-teams-ui",
"type": "module", "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", "description": "Desktop app that visualizes Claude Code session execution — explore conversations, track context usage, and analyze tool calls",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"author": { "author": {
@ -80,6 +80,7 @@
"@codemirror/lang-yaml": "^6.1.2", "@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.12.1", "@codemirror/language": "^6.12.1",
"@codemirror/language-data": "^6.5.2", "@codemirror/language-data": "^6.5.2",
"@codemirror/lint": "^6.9.5",
"@codemirror/merge": "^6.12.0", "@codemirror/merge": "^6.12.0",
"@codemirror/search": "^6.6.0", "@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.5.4", "@codemirror/state": "^6.5.4",

View file

@ -68,6 +68,9 @@ importers:
'@codemirror/language-data': '@codemirror/language-data':
specifier: ^6.5.2 specifier: ^6.5.2
version: 6.5.2 version: 6.5.2
'@codemirror/lint':
specifier: ^6.9.5
version: 6.9.5
'@codemirror/merge': '@codemirror/merge':
specifier: ^6.12.0 specifier: ^6.12.0
version: 6.12.0 version: 6.12.0
@ -576,8 +579,8 @@ packages:
'@codemirror/legacy-modes@6.5.2': '@codemirror/legacy-modes@6.5.2':
resolution: {integrity: sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==} resolution: {integrity: sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==}
'@codemirror/lint@6.9.4': '@codemirror/lint@6.9.5':
resolution: {integrity: sha512-ABc9vJ8DEmvOWuH26P3i8FpMWPQkduD9Rvba5iwb6O3hxASgclm3T3krGo8NASXkHCidz6b++LWlzWIUfEPSWw==} resolution: {integrity: sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==}
'@codemirror/merge@6.12.0': '@codemirror/merge@6.12.0':
resolution: {integrity: sha512-o+36bbapcEHf4Ux75pZ4CKjMBUd14parA0uozvWVlacaT+uxaA3DDefEvWYjngsKU+qsrDe/HOOfsw0Q72pLjA==} resolution: {integrity: sha512-o+36bbapcEHf4Ux75pZ4CKjMBUd14parA0uozvWVlacaT+uxaA3DDefEvWYjngsKU+qsrDe/HOOfsw0Q72pLjA==}
@ -6449,7 +6452,7 @@ snapshots:
dependencies: dependencies:
'@codemirror/autocomplete': 6.20.0 '@codemirror/autocomplete': 6.20.0
'@codemirror/language': 6.12.1 '@codemirror/language': 6.12.1
'@codemirror/lint': 6.9.4 '@codemirror/lint': 6.9.5
'@codemirror/state': 6.5.4 '@codemirror/state': 6.5.4
'@codemirror/view': 6.39.15 '@codemirror/view': 6.39.15
'@lezer/common': 1.5.1 '@lezer/common': 1.5.1
@ -6609,7 +6612,7 @@ snapshots:
dependencies: dependencies:
'@codemirror/language': 6.12.1 '@codemirror/language': 6.12.1
'@codemirror/lint@6.9.4': '@codemirror/lint@6.9.5':
dependencies: dependencies:
'@codemirror/state': 6.5.4 '@codemirror/state': 6.5.4
'@codemirror/view': 6.39.15 '@codemirror/view': 6.39.15

View file

@ -41,6 +41,7 @@ export class UpdaterService {
await autoUpdater.checkForUpdates(); await autoUpdater.checkForUpdates();
} catch (error) { } catch (error) {
logger.error('Check for updates failed:', getErrorMessage(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(); await autoUpdater.downloadUpdate();
} catch (error) { } catch (error) {
logger.error('Download update failed:', getErrorMessage(error)); logger.error('Download update failed:', getErrorMessage(error));
this.sendStatus({ type: 'error', error: getErrorMessage(error) });
} }
} }

View file

@ -161,6 +161,8 @@ interface ProvisioningRun {
* request triggered by the UI. We suppress any leaduser echo for that turn. * request triggered by the UI. We suppress any leaduser echo for that turn.
*/ */
silentUserDmForward: { target: string; startedAt: string } | null; 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. */ /** Accumulates assistant text during provisioning phase for live UI preview. */
provisioningOutputParts: string[]; provisioningOutputParts: string[];
/** Session ID detected from stream-json output (result.session_id or message.session_id). */ /** 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` + ` - 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.` ` - 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. : `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. - PRIORITY (delegation-first): your default behavior is to translate user requests into a task plan, create the tasks, and delegate them to teammates.
- Avoid duplicate notifications for the same assignment. - 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. - 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. - 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: - Review guidance:
@ -713,6 +721,7 @@ Constraints:
- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough. - 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. - Keep the task board high-signal: avoid creating tasks for trivial micro-items.
- Use the team task board for assigned/substantial work. - 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. - 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} - 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. - 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. - Keep the task board high-signal: avoid creating tasks for trivial micro-items.
- Use the team task board for assigned/substantial work. - 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. - 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} - 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, leadRelayCapture: null,
directReplyParts: [], directReplyParts: [],
silentUserDmForward: null, silentUserDmForward: null,
silentUserDmForwardClearHandle: null,
provisioningOutputParts: [], provisioningOutputParts: [],
detectedSessionId: null, detectedSessionId: null,
leadActivityState: 'active', leadActivityState: 'active',
@ -1974,6 +1985,7 @@ export class TeamProvisioningService {
leadRelayCapture: null, leadRelayCapture: null,
directReplyParts: [], directReplyParts: [],
silentUserDmForward: null, silentUserDmForward: null,
silentUserDmForwardClearHandle: null,
provisioningOutputParts: [], provisioningOutputParts: [],
detectedSessionId: null, detectedSessionId: null,
leadActivityState: 'active', leadActivityState: 'active',
@ -2243,20 +2255,34 @@ export class TeamProvisioningService {
} }
run.silentUserDmForward = { target: teammateName, startedAt: nowIso() }; 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 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 = [ const message = [
`INTERNAL: The human user sent a direct message to teammate "${teammateName}" via the UI.`, `User DM relay (internal).`,
`Action: forward it to that teammate using the SendMessage tool.`, internal,
`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 message:`, `Message to forward:`,
...(summaryLine ? [summaryLine] : []), ...(summaryLine ? [summaryLine] : []),
userText, userText,
] ].join('\n');
.filter(Boolean)
.join('\n');
await this.sendMessageToTeam(teamName, message); await this.sendMessageToTeam(teamName, message);
} }
@ -2986,6 +3012,10 @@ export class TeamProvisioningService {
} }
// Clear silent relay flag after any successful turn. // Clear silent relay flag after any successful turn.
run.silentUserDmForward = null; run.silentUserDmForward = null;
if (run.silentUserDmForwardClearHandle) {
clearTimeout(run.silentUserDmForwardClearHandle);
run.silentUserDmForwardClearHandle = null;
}
if (!run.provisioningComplete && !run.cancelRequested) { if (!run.provisioningComplete && !run.cancelRequested) {
void this.handleProvisioningTurnComplete(run); void this.handleProvisioningTurnComplete(run);
} }
@ -2998,6 +3028,10 @@ export class TeamProvisioningService {
} }
// Clear silent relay flag after any errored turn. // Clear silent relay flag after any errored turn.
run.silentUserDmForward = null; run.silentUserDmForward = null;
if (run.silentUserDmForwardClearHandle) {
clearTimeout(run.silentUserDmForwardClearHandle);
run.silentUserDmForwardClearHandle = null;
}
if (!run.provisioningComplete && !run.cancelRequested) { if (!run.provisioningComplete && !run.cancelRequested) {
const progress = updateProgress( const progress = updateProgress(
run, run,
@ -3238,6 +3272,10 @@ export class TeamProvisioningService {
clearTimeout(run.timeoutHandle); clearTimeout(run.timeoutHandle);
run.timeoutHandle = null; run.timeoutHandle = null;
} }
if (run.silentUserDmForwardClearHandle) {
clearTimeout(run.silentUserDmForwardClearHandle);
run.silentUserDmForwardClearHandle = null;
}
this.stopFilesystemMonitor(run); this.stopFilesystemMonitor(run);
// Remove stream listeners to prevent data handlers firing on a cleaned-up run // Remove stream listeners to prevent data handlers firing on a cleaned-up run
if (run.child) { if (run.child) {

View file

@ -162,6 +162,7 @@ export const DisplayItemList = ({
linkedTool={item.tool} linkedTool={item.tool}
onClick={() => onItemClick(itemKey)} onClick={() => onItemClick(itemKey)}
isExpanded={expandedItemIds.has(itemKey)} isExpanded={expandedItemIds.has(itemKey)}
searchQueryOverride={searchQueryOverride}
isHighlighted={highlightToolUseId === item.tool.id} isHighlighted={highlightToolUseId === item.tool.id}
highlightColor={highlightColor} highlightColor={highlightColor}
notificationDotColor={notificationColorMap?.get(item.tool.id)} notificationDotColor={notificationColorMap?.get(item.tool.id)}

View file

@ -18,7 +18,7 @@ interface BaseItemProps {
/** Primary label (e.g., "Thinking", "Output", tool name) */ /** Primary label (e.g., "Thinking", "Output", tool name) */
label: string; label: string;
/** Summary text shown after the label */ /** Summary text shown after the label */
summary?: string; summary?: React.ReactNode;
/** Token count to display */ /** Token count to display */
tokenCount?: number; tokenCount?: number;
/** Label for tokens (default: "tokens") */ /** Label for tokens (default: "tokens") */

View file

@ -38,6 +38,7 @@ import {
ToolErrorDisplay, ToolErrorDisplay,
WriteToolViewer, WriteToolViewer,
} from './linkedTool'; } from './linkedTool';
import { highlightQueryInText } from '../searchHighlightUtils';
import type { LinkedToolItem as LinkedToolItemType } from '@renderer/types/groups'; import type { LinkedToolItem as LinkedToolItemType } from '@renderer/types/groups';
@ -45,6 +46,8 @@ interface LinkedToolItemProps {
linkedTool: LinkedToolItemType; linkedTool: LinkedToolItemType;
onClick: () => void; onClick: () => void;
isExpanded: boolean; isExpanded: boolean;
/** Optional local search query override for inline highlighting */
searchQueryOverride?: string;
/** Whether this item should be highlighted for error deep linking */ /** Whether this item should be highlighted for error deep linking */
isHighlighted?: boolean; isHighlighted?: boolean;
/** Custom highlight color from trigger */ /** Custom highlight color from trigger */
@ -59,6 +62,7 @@ export const LinkedToolItem: React.FC<LinkedToolItemProps> = ({
linkedTool, linkedTool,
onClick, onClick,
isExpanded, isExpanded,
searchQueryOverride,
isHighlighted, isHighlighted,
highlightColor, highlightColor,
notificationDotColor, notificationDotColor,
@ -66,6 +70,12 @@ export const LinkedToolItem: React.FC<LinkedToolItemProps> = ({
}) => { }) => {
const status = getToolStatus(linkedTool); const status = getToolStatus(linkedTool);
const summary = getToolSummary(linkedTool.name, linkedTool.input); 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<HTMLDivElement>(null); const elementRef = useRef<HTMLDivElement>(null);
// Combined ref callback - handles both internal ref and external registration // Combined ref callback - handles both internal ref and external registration
@ -155,7 +165,7 @@ export const LinkedToolItem: React.FC<LinkedToolItemProps> = ({
/> />
} }
label={linkedTool.name} label={linkedTool.name}
summary={summary} summary={summaryNode}
tokenCount={getToolContextTokens(linkedTool)} tokenCount={getToolContextTokens(linkedTool)}
status={status} status={status}
durationMs={linkedTool.durationMs} durationMs={linkedTool.durationMs}

View file

@ -3,6 +3,7 @@ import React from 'react';
import { MessageSquare } from 'lucide-react'; import { MessageSquare } from 'lucide-react';
import { MarkdownViewer } from '../viewers'; import { MarkdownViewer } from '../viewers';
import { highlightQueryInText } from '../searchHighlightUtils';
import { BaseItem } from './BaseItem'; import { BaseItem } from './BaseItem';
import { truncateText } from './baseItemHelpers'; import { truncateText } from './baseItemHelpers';
@ -40,6 +41,11 @@ export const TextItem: React.FC<TextItemProps> = ({
}) => { }) => {
const fullContent = step.content.outputText ?? preview; const fullContent = step.content.outputText ?? preview;
const truncatedPreview = truncateText(preview, 60); 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 // Get token count from step.tokens.output or step.content.tokenCount
const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0; const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0;
@ -48,7 +54,7 @@ export const TextItem: React.FC<TextItemProps> = ({
<BaseItem <BaseItem
icon={<MessageSquare className="size-4" />} icon={<MessageSquare className="size-4" />}
label="Output" label="Output"
summary={truncatedPreview} summary={summary}
tokenCount={tokenCount} tokenCount={tokenCount}
onClick={onClick} onClick={onClick}
isExpanded={isExpanded} isExpanded={isExpanded}

View file

@ -3,6 +3,7 @@ import React from 'react';
import { Brain } from 'lucide-react'; import { Brain } from 'lucide-react';
import { MarkdownViewer } from '../viewers'; import { MarkdownViewer } from '../viewers';
import { highlightQueryInText } from '../searchHighlightUtils';
import { BaseItem } from './BaseItem'; import { BaseItem } from './BaseItem';
import { truncateText } from './baseItemHelpers'; import { truncateText } from './baseItemHelpers';
@ -40,6 +41,11 @@ export const ThinkingItem: React.FC<ThinkingItemProps> = ({
}) => { }) => {
const fullContent = step.content.thinkingText ?? preview; const fullContent = step.content.thinkingText ?? preview;
const truncatedPreview = truncateText(preview, 60); 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 // Get token count from step.tokens.output or step.content.tokenCount
const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0; const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0;
@ -48,7 +54,7 @@ export const ThinkingItem: React.FC<ThinkingItemProps> = ({
<BaseItem <BaseItem
icon={<Brain className="size-4" />} icon={<Brain className="size-4" />}
label="Thinking" label="Thinking"
summary={truncatedPreview} summary={summary}
tokenCount={tokenCount} tokenCount={tokenCount}
onClick={onClick} onClick={onClick}
isExpanded={isExpanded} isExpanded={isExpanded}

View file

@ -35,6 +35,8 @@ export interface SearchContext {
matchCounter: { current: number }; matchCounter: { current: number };
isCurrentItem: boolean; isCurrentItem: boolean;
currentMatchIndexInItem: number | null; 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 = const isCurrentResult =
ctx.isCurrentItem && ctx.currentMatchIndexInItem === ctx.matchCounter.current; ctx.forceAllActive === true ||
(ctx.isCurrentItem && ctx.currentMatchIndexInItem === ctx.matchCounter.current);
parts.push( parts.push(
React.createElement( React.createElement(
@ -109,6 +112,18 @@ function highlightSearchText(text: string, ctx: SearchContext): React.ReactNode
return parts; 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. * Recursively process React children to highlight search terms in text nodes.
* Preserves the React element tree structure (markdown components, etc.) * Preserves the React element tree structure (markdown components, etc.)

View file

@ -600,6 +600,10 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
effectiveQuery && itemId effectiveQuery && itemId
? createSearchContext(effectiveQuery, itemId, effectiveMatches, effectiveIndex) ? createSearchContext(effectiveQuery, itemId, effectiveMatches, effectiveIndex)
: null; : 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 // Create markdown components with optional search highlighting
// When search is active, create fresh each render (match counter is stateful and must start at 0) // When search is active, create fresh each render (match counter is stateful and must start at 0)

View file

@ -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 => (
<div
className={`flex items-start gap-2 rounded-md border px-3 py-2 text-xs ${className}`}
style={{
backgroundColor: 'var(--warning-bg)',
borderColor: 'var(--warning-border)',
color: 'var(--warning-text)',
}}
>
{icon ?? <AlertTriangle size={14} className="mt-0.5 shrink-0" />}
<div className="min-w-0 flex-1">{children}</div>
</div>
);

View file

@ -44,9 +44,11 @@ export const SidebarHeader = (): React.JSX.Element => {
} as React.CSSProperties } as React.CSSProperties
} }
> >
<div style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}> {isMacElectron && (
<AppLogo size={22} className="shrink-0" /> <div style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
</div> <AppLogo size={22} className="shrink-0" />
</div>
)}
<div className="flex-1" /> <div className="flex-1" />
<button <button
onClick={toggleSidebar} onClick={toggleSidebar}

View file

@ -7,6 +7,7 @@ import { useCallback, useState } from 'react';
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { import {
Activity, Activity,
@ -66,6 +67,13 @@ export const SortableTab = ({
) )
); );
const teamColor = useStore((s) => {
if (tab.type !== 'team' || !tab.teamName) return null;
const team = s.teamByName[tab.teamName];
return team?.color ?? null;
});
const teamColorSet = teamColor ? getTeamColorSet(teamColor) : null;
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: tab.id, id: tab.id,
data: { data: {
@ -81,13 +89,18 @@ export const SortableTab = ({
transition: isDragging ? 'none' : transition, transition: isDragging ? 'none' : transition,
opacity: isDragging ? 0.3 : 1, opacity: isDragging ? 0.3 : 1,
backgroundColor: isActive backgroundColor: isActive
? 'var(--color-surface-raised)' ? teamColorSet
? teamColorSet.badge
: 'var(--color-surface-raised)'
: isHovered : isHovered
? 'var(--color-surface-overlay)' ? teamColorSet
? teamColorSet.badge
: 'var(--color-surface-overlay)'
: 'transparent', : 'transparent',
color: isActive || isHovered ? 'var(--color-text)' : 'var(--color-text-muted)', color: isActive || isHovered ? 'var(--color-text)' : 'var(--color-text-muted)',
outline: isSelected ? '1px solid var(--color-border-emphasis)' : 'none', outline: isSelected ? '1px solid var(--color-border-emphasis)' : 'none',
outlineOffset: '-1px', outlineOffset: '-1px',
borderLeft: isActive && teamColorSet ? `2px solid ${teamColorSet.border}` : undefined,
}; };
const Icon = TAB_ICONS[tab.type]; const Icon = TAB_ICONS[tab.type];

View file

@ -302,13 +302,14 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
scrollContainerRef.current = el; scrollContainerRef.current = el;
setDroppableRef(el); setDroppableRef(el);
}} }}
className="scrollbar-none flex min-w-0 shrink items-center gap-1 overflow-x-auto" className="scrollbar-none flex min-w-0 flex-1 items-center gap-1"
style={ style={
{ {
maxWidth: '75%',
WebkitAppRegion: 'no-drag', WebkitAppRegion: 'no-drag',
outline: isDroppableOver ? '1px dashed var(--color-accent, #6366f1)' : 'none', outline: isDroppableOver ? '1px dashed var(--color-accent, #6366f1)' : 'none',
outlineOffset: '-1px', outlineOffset: '-1px',
overflowX: 'auto',
overflowY: 'clip',
} as React.CSSProperties } as React.CSSProperties
} }
> >
@ -351,7 +352,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
Gives users a reliable window-drag target regardless of how many tabs are open. Gives users a reliable window-drag target regardless of how many tabs are open.
Only applied on the leftmost pane in Electron to match the TabBar drag region logic. */} Only applied on the leftmost pane in Electron to match the TabBar drag region logic. */}
<div <div
className="flex-1 self-stretch" className="min-w-[48px] flex-1 self-stretch"
style={ style={
{ {
WebkitAppRegion: isElectronMode() && isLeftmostPane ? 'drag' : undefined, WebkitAppRegion: isElectronMode() && isLeftmostPane ? 'drag' : undefined,

View file

@ -63,7 +63,14 @@ export const TriggerPreview = ({
{/* Truncation warning - only shown when timeout or count limit hit */} {/* Truncation warning - only shown when timeout or count limit hit */}
{previewResult.truncated && ( {previewResult.truncated && (
<div className="flex items-center gap-2 rounded border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-400"> <div
className="flex items-center gap-2 rounded border px-3 py-2 text-xs"
style={{
backgroundColor: 'var(--warning-bg)',
borderColor: 'var(--warning-border)',
color: 'var(--warning-text)',
}}
>
<AlertTriangle className="size-4 shrink-0" /> <AlertTriangle className="size-4 shrink-0" />
<span> <span>
Search stopped early (timeout or count limit). Actual matches may be higher. Search stopped early (timeout or count limit). Actual matches may be higher.

View file

@ -7,11 +7,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api, isElectronMode } from '@renderer/api'; import { api, isElectronMode } from '@renderer/api';
import appIcon from '@renderer/favicon.png'; import appIcon from '@renderer/favicon.png';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { CheckCircle, Code2, Download, Loader2, RefreshCw, Upload } from 'lucide-react'; import { CheckCircle, Code2, Download, FileEdit, Loader2, RefreshCw, Upload } from 'lucide-react';
import { SettingsSectionHeader } from '../components'; import { SettingsSectionHeader } from '../components';
import { CliStatusSection } from './CliStatusSection'; import { CliStatusSection } from './CliStatusSection';
import { ConfigEditorDialog } from './ConfigEditorDialog';
interface AdvancedSectionProps { interface AdvancedSectionProps {
readonly saving: boolean; readonly saving: boolean;
@ -30,6 +31,7 @@ export const AdvancedSection = ({
}: AdvancedSectionProps): React.JSX.Element => { }: AdvancedSectionProps): React.JSX.Element => {
const isElectron = useMemo(() => isElectronMode(), []); const isElectron = useMemo(() => isElectronMode(), []);
const [version, setVersion] = useState<string>(''); const [version, setVersion] = useState<string>('');
const [configEditorOpen, setConfigEditorOpen] = useState(false);
const updateStatus = useStore((s) => s.updateStatus); const updateStatus = useStore((s) => s.updateStatus);
const availableVersion = useStore((s) => s.availableVersion); const availableVersion = useStore((s) => s.availableVersion);
const checkForUpdates = useStore((s) => s.checkForUpdates); const checkForUpdates = useStore((s) => s.checkForUpdates);
@ -95,6 +97,17 @@ export const AdvancedSection = ({
<div> <div>
<SettingsSectionHeader title="Configuration" /> <SettingsSectionHeader title="Configuration" />
<div className="space-y-2 py-2"> <div className="space-y-2 py-2">
<button
onClick={() => setConfigEditorOpen(true)}
className="flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2.5 text-sm font-medium transition-all duration-150 hover:bg-white/5"
style={{
borderColor: 'var(--color-border)',
color: 'var(--color-text)',
}}
>
<FileEdit className="size-4" />
Edit Config
</button>
<button <button
onClick={onResetToDefaults} onClick={onResetToDefaults}
disabled={saving} disabled={saving}
@ -195,6 +208,14 @@ export const AdvancedSection = ({
</p> </p>
</div> </div>
</div> </div>
<ConfigEditorDialog
open={configEditorOpen}
onClose={() => setConfigEditorOpen(false)}
onConfigSaved={() => {
// Config saved via editor — settings page will pick up changes on next render
}}
/>
</div> </div>
); );
}; };

View file

@ -27,6 +27,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
fetchCliStatus, fetchCliStatus,
installCli, installCli,
isBusy, isBusy,
cliStatusLoading,
} = useCliInstaller(); } = useCliInstaller();
useEffect(() => { useEffect(() => {
@ -129,14 +130,24 @@ export const CliStatusSection = (): React.JSX.Element | null => {
{cliStatus.installed && !cliStatus.updateAvailable && ( {cliStatus.installed && !cliStatus.updateAvailable && (
<button <button
onClick={handleRefresh} onClick={handleRefresh}
className="flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/5" disabled={cliStatusLoading}
className="flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
style={{ style={{
borderColor: 'var(--color-border)', borderColor: 'var(--color-border)',
color: 'var(--color-text-secondary)', color: 'var(--color-text-secondary)',
}} }}
> >
<RefreshCw className="size-3.5" /> {cliStatusLoading ? (
Check for Updates <>
<Loader2 className="size-3.5 animate-spin" />
Checking...
</>
) : (
<>
<RefreshCw className="size-3.5" />
Check for Updates
</>
)}
</button> </button>
)} )}
</div> </div>

View file

@ -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<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const saveTimerRef = useRef<ReturnType<typeof setTimeout>>();
const savedRevertTimerRef = useRef<ReturnType<typeof setTimeout>>();
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle');
const [jsonError, setJsonError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const initialConfigRef = useRef<string>('');
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<void> => {
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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ backgroundColor: 'rgba(0, 0, 0, 0.6)' }}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
className="flex max-h-[85vh] w-full max-w-3xl flex-col overflow-hidden rounded-xl border shadow-2xl"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-border-emphasis)',
}}
>
{/* Header */}
<div
className="flex items-center justify-between border-b px-4 py-3"
style={{ borderColor: 'var(--color-border)' }}
>
<div className="flex items-center gap-3">
<h2 className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
Edit Configuration
</h2>
<SaveStatusBadge status={saveStatus} error={jsonError} />
</div>
<button
onClick={onClose}
className="rounded-md p-1 transition-colors hover:bg-white/10"
style={{ color: 'var(--color-text-muted)' }}
>
<X className="size-4" />
</button>
</div>
{/* Editor */}
<div className="relative min-h-0 flex-1">
{loading ? (
<div
className="flex h-96 items-center justify-center gap-2 text-sm"
style={{ color: 'var(--color-text-muted)' }}
>
<Loader2 className="size-4 animate-spin" />
Loading config...
</div>
) : (
<div ref={editorRef} className="config-editor-container h-full min-h-[400px]" />
)}
</div>
{/* Footer */}
<div
className="flex items-center justify-between border-t px-4 py-2.5"
style={{ borderColor: 'var(--color-border)' }}
>
<p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
Changes auto-save after editing
</p>
<div className="flex items-center gap-2">
<kbd
className="rounded px-1.5 py-0.5 text-[10px]"
style={{
backgroundColor: 'var(--color-surface-raised)',
color: 'var(--color-text-muted)',
border: '1px solid var(--color-border)',
}}
>
Esc
</kbd>
<span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
to close
</span>
</div>
</div>
</div>
</div>
);
};
// =============================================================================
// 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 (
<span
className="flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px]"
style={{ backgroundColor: 'rgba(248, 113, 113, 0.15)', color: '#f87171' }}
title={error}
>
<AlertTriangle className="size-3" />
{status === 'error' ? 'Save failed' : 'Invalid JSON'}
</span>
);
}
if (status === 'saving') {
return (
<span
className="flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px]"
style={{ backgroundColor: 'rgba(96, 165, 250, 0.15)', color: '#60a5fa' }}
>
<Loader2 className="size-3 animate-spin" />
Saving...
</span>
);
}
if (status === 'saved') {
return (
<span
className="flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px]"
style={{ backgroundColor: 'rgba(74, 222, 128, 0.15)', color: '#4ade80' }}
>
<Check className="size-3" />
Saved
</span>
);
}
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',
},
});

View file

@ -366,11 +366,16 @@ export const GeneralSection = ({
confirmLabel: 'Restart', confirmLabel: 'Restart',
}); });
if (shouldRelaunch) { if (shouldRelaunch) {
onGeneralToggle('useNativeTitleBar', v); // Await config write before relaunch to avoid race condition on Windows
// Small delay to let config persist before relaunch // (antivirus/NTFS can delay file writes beyond a fixed timeout)
setTimeout(() => { try {
void window.electronAPI?.windowControls?.relaunch(); await api.config.update('general', { useNativeTitleBar: v });
}, 200); } 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} disabled={saving}
@ -503,7 +508,7 @@ export const GeneralSection = ({
{candidate.path} {candidate.path}
</p> </p>
{!candidate.hasProjectsDir && ( {!candidate.hasProjectsDir && (
<p className="text-[11px] text-amber-400"> <p className="text-[11px]" style={{ color: 'var(--warning-text)' }}>
No projects directory detected No projects directory detected
</p> </p>
)} )}

View file

@ -10,6 +10,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { DisplayItemList } from '@renderer/components/chat/DisplayItemList'; import { DisplayItemList } from '@renderer/components/chat/DisplayItemList';
import { highlightQueryInText } from '@renderer/components/chat/searchHighlightUtils';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { parseStreamJsonToGroups } from '@renderer/utils/streamJsonParser'; import { parseStreamJsonToGroups } from '@renderer/utils/streamJsonParser';
import { Bot, ChevronRight } from 'lucide-react'; import { Bot, ChevronRight } from 'lucide-react';
@ -119,7 +120,11 @@ const StreamGroup = ({
/> />
<Bot size={13} className="shrink-0 text-[var(--color-text-muted)]" /> <Bot size={13} className="shrink-0 text-[var(--color-text-muted)]" />
<span className="min-w-0 truncate text-[11px] text-[var(--color-text-secondary)]"> <span className="min-w-0 truncate text-[11px] text-[var(--color-text-secondary)]">
{group.summary} {searchQueryOverride && searchQueryOverride.trim().length > 0
? highlightQueryInText(group.summary, searchQueryOverride, `${group.id}:group-summary`, {
forceAllActive: true,
})
: group.summary}
</span> </span>
</button> </button>
{isExpanded && ( {isExpanded && (

View file

@ -185,7 +185,7 @@ export const ProvisioningProgressBlock = ({
<p <p
className={cn( className={cn(
'mt-1.5 text-xs', 'mt-1.5 text-xs',
isError ? 'text-red-200' : 'text-[var(--color-text-muted)]' isError ? 'text-[var(--step-error-text)]' : 'text-[var(--color-text-muted)]'
)} )}
> >
{message} {message}
@ -201,9 +201,9 @@ export const ProvisioningProgressBlock = ({
variant="secondary" variant="secondary"
className={cn( className={cn(
'whitespace-nowrap px-2 py-0.5 text-[11px] font-normal', '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 && 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)]'
)} )}
> >
<span className="mr-1 inline-flex size-4 items-center justify-center rounded-full border border-current text-[10px]"> <span className="mr-1 inline-flex size-4 items-center justify-center rounded-full border border-current text-[10px]">
@ -241,7 +241,7 @@ export const ProvisioningProgressBlock = ({
<p <p
className={cn( className={cn(
'text-[11px]', 'text-[11px]',
isError ? 'text-red-200/80' : 'text-[var(--color-text-muted)]' isError ? 'text-[var(--step-error-text-dim)]' : 'text-[var(--color-text-muted)]'
)} )}
> >
No output captured yet. No output captured yet.

View file

@ -1081,15 +1081,22 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
</div> </div>
{!data.isAlive && !isTeamProvisioning ? ( {!data.isAlive && !isTeamProvisioning ? (
<div className="mb-3 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2"> <div
<span className="flex items-center gap-1.5 text-xs text-amber-200"> className="mb-3 flex items-center justify-between gap-3 rounded-md border px-3 py-2"
<AlertTriangle size={14} className="shrink-0 text-amber-400" /> style={{
backgroundColor: 'var(--warning-bg)',
borderColor: 'var(--warning-border)',
color: 'var(--warning-text)',
}}
>
<span className="flex items-center gap-1.5 text-xs">
<AlertTriangle size={14} className="shrink-0" />
Team is offline Team is offline
</span> </span>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-7 shrink-0 gap-1 px-2 text-xs text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" className="h-7 shrink-0 gap-1 px-2 text-xs text-[var(--step-done-text)] hover:bg-[var(--step-done-bg)]"
onClick={() => setLaunchDialogOpen(true)} onClick={() => setLaunchDialogOpen(true)}
> >
<Play size={12} /> <Play size={12} />
@ -1103,12 +1110,12 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
</div> </div>
{data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? ( {data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? (
<div className="mb-3 rounded-md border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-200"> <div className="mb-3 rounded-md border border-[var(--step-warning-border)] bg-[var(--step-warning-bg)] px-3 py-2 text-xs text-[var(--step-warning-text)]">
Failed to fully load kanban. Displaying safe data. Failed to fully load kanban. Displaying safe data.
</div> </div>
) : null} ) : null}
{reviewActionError ? ( {reviewActionError ? (
<div className="mb-3 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs text-red-200"> <div className="mb-3 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs text-[var(--step-error-text)]">
{reviewActionError} {reviewActionError}
</div> </div>
) : null} ) : null}

View file

@ -556,10 +556,14 @@ export const TeamListView = (): React.JSX.Element => {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
disabled={teamsLoading}
onClick={() => { onClick={() => {
void fetchTeams(); void fetchTeams();
}} }}
> >
{teamsLoading ? (
<RotateCcw className="size-3.5 animate-spin" />
) : null}
Refresh Refresh
</Button> </Button>
</div> </div>

View file

@ -78,11 +78,11 @@ export const TeamProvisioningBanner = ({
return ( return (
<div className="mb-3"> <div className="mb-3">
<div className="mb-2 flex items-center gap-2 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2"> <div className="mb-2 flex items-center gap-2 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2">
<p className="flex-1 text-xs text-red-200">{progress.message}</p> <p className="flex-1 text-xs text-[var(--step-error-text)]">{progress.message}</p>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="h-6 shrink-0 border-red-500/40 px-2 text-xs text-red-300 hover:bg-red-500/10 hover:text-red-200" className="h-6 shrink-0 border-red-500/40 px-2 text-xs text-[var(--step-error-text)] hover:bg-red-500/10"
onClick={() => setDismissed(true)} onClick={() => setDismissed(true)}
> >
<X size={12} /> <X size={12} />
@ -108,13 +108,13 @@ export const TeamProvisioningBanner = ({
if (isReady) { if (isReady) {
return ( return (
<div className="mb-3"> <div className="mb-3">
<div className="mb-2 flex items-center gap-2 rounded-md border border-emerald-500/40 bg-emerald-500/10 px-3 py-2"> <div className="mb-2 flex items-center gap-2 rounded-md border border-[var(--step-done-border)] bg-[var(--step-done-bg)] px-3 py-2">
<CheckCircle2 size={14} className="shrink-0 text-emerald-400" /> <CheckCircle2 size={14} className="shrink-0 text-[var(--step-done-text)]" />
<p className="flex-1 text-xs text-emerald-200">Team launched process alive</p> <p className="flex-1 text-xs text-[var(--step-success-text)]">Team launched process alive</p>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="h-6 shrink-0 border-emerald-500/40 px-2 text-xs text-emerald-300 hover:bg-emerald-500/10 hover:text-emerald-200" className="h-6 shrink-0 border-[var(--step-done-border)] px-2 text-xs text-[var(--step-done-text)] hover:bg-[var(--step-done-bg)]"
onClick={() => setDismissed(true)} onClick={() => setDismissed(true)}
> >
<X size={12} /> <X size={12} />

View file

@ -38,9 +38,12 @@ export const AttachmentPreviewList = ({
</div> </div>
) : null} ) : null}
{disabled && disabledHint && attachments.length > 0 ? ( {disabled && disabledHint && attachments.length > 0 ? (
<div className="flex items-center gap-1.5 rounded-md bg-amber-500/10 px-2.5 py-1.5"> <div
<AlertCircle size={13} className="shrink-0 text-amber-400" /> className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5"
<p className="text-[11px] text-amber-400">{disabledHint}</p> style={{ backgroundColor: 'var(--warning-bg)', color: 'var(--warning-text)' }}
>
<AlertCircle size={13} className="shrink-0" />
<p className="text-[11px]">{disabledHint}</p>
</div> </div>
) : null} ) : null}
{error ? ( {error ? (

View file

@ -241,9 +241,16 @@ export const CreateTaskDialog = ({
</DialogHeader> </DialogHeader>
{!isTeamAlive ? ( {!isTeamAlive ? (
<div className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2"> <div
<AlertTriangle size={14} className="mt-0.5 shrink-0 text-amber-400" /> className="flex items-start gap-2 rounded-md border px-3 py-2"
<p className="text-xs leading-relaxed text-amber-300"> style={{
backgroundColor: 'var(--warning-bg)',
borderColor: 'var(--warning-border)',
color: 'var(--warning-text)',
}}
>
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
<p className="text-xs leading-relaxed">
Team is offline. The task will be added to <strong>TODO</strong> &mdash; launch the Team is offline. The task will be added to <strong>TODO</strong> &mdash; launch the
team to start execution. team to start execution.
</p> </p>

View file

@ -588,25 +588,32 @@ export const CreateTeamDialog = ({
</DialogHeader> </DialogHeader>
{conflictingTeam && !conflictDismissed ? ( {conflictingTeam && !conflictDismissed ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-xs"> <div
className="rounded-md border p-3 text-xs"
style={{
backgroundColor: 'var(--warning-bg)',
borderColor: 'var(--warning-border)',
color: 'var(--warning-text)',
}}
>
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" /> <AlertTriangle className="mt-0.5 size-4 shrink-0" />
<div className="min-w-0 flex-1 space-y-1"> <div className="min-w-0 flex-1 space-y-1">
<p className="font-medium text-amber-300"> <p className="font-medium">
Another team &ldquo;{conflictingTeam.displayName}&rdquo; is already running for Another team &ldquo;{conflictingTeam.displayName}&rdquo; is already running for
this working directory this working directory
</p> </p>
<p className="text-amber-300/80"> <p className="opacity-80">
Running two teams in the same directory is risky they may conflict editing the 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. same files. Consider using a different directory or a git worktree for isolation.
</p> </p>
<p className="text-[11px] text-amber-300/70"> <p className="text-[11px] opacity-70">
Working directory: <span className="font-mono">{effectiveCwd}</span> Working directory: <span className="font-mono">{effectiveCwd}</span>
</p> </p>
</div> </div>
<button <button
type="button" type="button"
className="shrink-0 rounded p-0.5 text-amber-400/60 transition-colors hover:text-amber-300" className="shrink-0 rounded p-0.5 opacity-60 transition-colors hover:opacity-100"
onClick={() => setConflictDismissed(true)} onClick={() => setConflictDismissed(true)}
> >
<X className="size-3.5" /> <X className="size-3.5" />
@ -629,7 +636,7 @@ export const CreateTeamDialog = ({
{prepareWarnings.length > 0 ? ( {prepareWarnings.length > 0 ? (
<div className="space-y-0.5"> <div className="space-y-0.5">
{prepareWarnings.map((warning) => ( {prepareWarnings.map((warning) => (
<p key={warning} className="text-[11px] text-amber-300"> <p key={warning} className="text-[11px]" style={{ color: 'var(--warning-text)' }}>
{warning} {warning}
</p> </p>
))} ))}
@ -645,7 +652,14 @@ export const CreateTeamDialog = ({
) : null} ) : null}
{!canCreate ? ( {!canCreate ? (
<p className="rounded border border-amber-500/40 bg-amber-500/10 p-2 text-xs text-amber-300"> <p
className="rounded border p-2 text-xs"
style={{
backgroundColor: 'var(--warning-bg)',
borderColor: 'var(--warning-border)',
color: 'var(--warning-text)',
}}
>
Available only in local Electron mode. Available only in local Electron mode.
</p> </p>
) : null} ) : null}

View file

@ -36,10 +36,17 @@ export const ExtendedContextCheckbox: React.FC<ExtendedContextCheckboxProps> = (
</Label> </Label>
</div> </div>
{checked && ( {checked && (
<div className="mt-1.5 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs"> <div
className="mt-1.5 rounded-md border px-3 py-2 text-xs"
style={{
backgroundColor: 'var(--warning-bg)',
borderColor: 'var(--warning-border)',
color: 'var(--warning-text)',
}}
>
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0 text-amber-400" /> <AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<div className="space-y-1 text-amber-300/90"> <div className="space-y-1">
<p> <p>
Beyond 200K tokens, premium pricing applies: 2x input cost, 1.5x output cost. For Beyond 200K tokens, premium pricing applies: 2x input cost, 1.5x output cost. For
subscribers, extra usage is billed separately. subscribers, extra usage is billed separately.
@ -48,7 +55,7 @@ export const ExtendedContextCheckbox: React.FC<ExtendedContextCheckboxProps> = (
Requires API tier 4+ or extra usage enabled.{' '} Requires API tier 4+ or extra usage enabled.{' '}
<button <button
type="button" type="button"
className="underline underline-offset-2 hover:text-amber-200" className="underline underline-offset-2 hover:opacity-80"
onClick={() => onClick={() =>
window.electronAPI.openExternal( window.electronAPI.openExternal(
'https://platform.claude.com/docs/en/build-with-claude/context-windows#1m-token-context-window' 'https://platform.claude.com/docs/en/build-with-claude/context-windows#1m-token-context-window'

View file

@ -314,25 +314,32 @@ export const LaunchTeamDialog = ({
</DialogHeader> </DialogHeader>
{conflictingTeam && !conflictDismissed ? ( {conflictingTeam && !conflictDismissed ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-xs"> <div
className="rounded-md border p-3 text-xs"
style={{
backgroundColor: 'var(--warning-bg)',
borderColor: 'var(--warning-border)',
color: 'var(--warning-text)',
}}
>
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" /> <AlertTriangle className="mt-0.5 size-4 shrink-0" />
<div className="min-w-0 flex-1 space-y-1"> <div className="min-w-0 flex-1 space-y-1">
<p className="font-medium text-amber-300"> <p className="font-medium">
Another team &ldquo;{conflictingTeam.displayName}&rdquo; is already running for Another team &ldquo;{conflictingTeam.displayName}&rdquo; is already running for
this working directory this working directory
</p> </p>
<p className="text-amber-300/80"> <p className="opacity-80">
Running two teams in the same directory is risky they may conflict editing the 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. same files. Consider using a different directory or a git worktree for isolation.
</p> </p>
<p className="text-[11px] text-amber-300/70"> <p className="text-[11px] opacity-70">
Working directory: <span className="font-mono">{effectiveCwd}</span> Working directory: <span className="font-mono">{effectiveCwd}</span>
</p> </p>
</div> </div>
<button <button
type="button" type="button"
className="shrink-0 rounded p-0.5 text-amber-400/60 transition-colors hover:text-amber-300" className="shrink-0 rounded p-0.5 opacity-60 transition-colors hover:opacity-100"
onClick={() => setConflictDismissed(true)} onClick={() => setConflictDismissed(true)}
> >
<X className="size-3.5" /> <X className="size-3.5" />
@ -355,7 +362,7 @@ export const LaunchTeamDialog = ({
{prepareWarnings.length > 0 ? ( {prepareWarnings.length > 0 ? (
<div className="space-y-0.5"> <div className="space-y-0.5">
{prepareWarnings.map((warning) => ( {prepareWarnings.map((warning) => (
<p key={warning} className="text-[11px] text-amber-300"> <p key={warning} className="text-[11px]" style={{ color: 'var(--warning-text)' }}>
{warning} {warning}
</p> </p>
))} ))}
@ -438,10 +445,17 @@ export const LaunchTeamDialog = ({
</Label> </Label>
</div> </div>
{clearContext && ( {clearContext && (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs"> <div
className="rounded-md border px-3 py-2 text-xs"
style={{
backgroundColor: 'var(--warning-bg)',
borderColor: 'var(--warning-border)',
color: 'var(--warning-text)',
}}
>
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0 text-amber-400" /> <AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<p className="text-amber-300/90"> <p>
The team lead will start a new session without resuming previous context. All The team lead will start a new session without resuming previous context. All
accumulated session memory and conversation history will not be available. accumulated session memory and conversation history will not be available.
</p> </p>

View file

@ -137,7 +137,7 @@ export const ProjectPathSelector = ({
) : null} ) : null}
{projectsError ? <p className="text-[11px] text-red-300">{projectsError}</p> : null} {projectsError ? <p className="text-[11px] text-red-300">{projectsError}</p> : null}
{!projectsLoading && projects.length === 0 ? ( {!projectsLoading && projects.length === 0 ? (
<p className="text-[11px] text-amber-300">No projects found, switch to custom path.</p> <p className="text-[11px]" style={{ color: 'var(--warning-text)' }}>No projects found, switch to custom path.</p>
) : null} ) : null}
</div> </div>
) : ( ) : (

View file

@ -678,7 +678,14 @@ export const ProjectEditorOverlay = ({
{/* Draft recovery banner */} {/* Draft recovery banner */}
{draftRecoveredFile && activeTabId === draftRecoveredFile && ( {draftRecoveredFile && activeTabId === draftRecoveredFile && (
<div className="flex shrink-0 items-center gap-2 border-b border-amber-500/30 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-300"> <div
className="flex shrink-0 items-center gap-2 border-b px-3 py-1.5 text-xs"
style={{
backgroundColor: 'var(--warning-bg)',
borderColor: 'var(--warning-border)',
color: 'var(--warning-text)',
}}
>
<RotateCcw className="size-3.5 shrink-0" /> <RotateCcw className="size-3.5 shrink-0" />
<span>Recovered unsaved changes from a previous session.</span> <span>Recovered unsaved changes from a previous session.</span>
<Button <Button

View file

@ -392,7 +392,7 @@ export const MessageComposer = ({
) : null} ) : null}
{!isTeamAlive ? ( {!isTeamAlive ? (
<span className="ml-auto text-[10px] text-amber-400">Team offline</span> <span className="ml-auto text-[10px]" style={{ color: 'var(--warning-text)' }}>Team offline</span>
) : null} ) : null}
</div> </div>

View file

@ -198,6 +198,20 @@
--skeleton-base: #1a1c28; --skeleton-base: #1a1c28;
--skeleton-base-light: #23252f; --skeleton-base-light: #23252f;
--skeleton-base-dim: rgba(26, 28, 40, 0.6); --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 */ /* File icon glow — halo so dark icons stay visible on dark backgrounds */
@ -400,6 +414,20 @@
--skeleton-base: #d6d8de; --skeleton-base: #d6d8de;
--skeleton-base-light: #cdd0d7; --skeleton-base-light: #cdd0d7;
--skeleton-base-dim: rgba(205, 208, 215, 0.6); --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 */ /* rehype-highlight (highlight.js) — map hljs classes to app theme variables */

View file

@ -57,6 +57,7 @@ export const createUpdateSlice: StateCreator<AppState, [], [], UpdateSlice> = (s
set({ updateStatus: 'checking', updateError: null }); set({ updateStatus: 'checking', updateError: null });
api.updater.check().catch((error) => { api.updater.check().catch((error) => {
logger.error('Failed to check for updates:', error); logger.error('Failed to check for updates:', error);
set({ updateStatus: 'error', updateError: error instanceof Error ? error.message : 'Check failed' });
}); });
}, },