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:
parent
c963c8a409
commit
80147c9900
35 changed files with 757 additions and 87 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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") */
|
||||
|
|
|
|||
|
|
@ -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<LinkedToolItemProps> = ({
|
|||
linkedTool,
|
||||
onClick,
|
||||
isExpanded,
|
||||
searchQueryOverride,
|
||||
isHighlighted,
|
||||
highlightColor,
|
||||
notificationDotColor,
|
||||
|
|
@ -66,6 +70,12 @@ export const LinkedToolItem: React.FC<LinkedToolItemProps> = ({
|
|||
}) => {
|
||||
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<HTMLDivElement>(null);
|
||||
|
||||
// Combined ref callback - handles both internal ref and external registration
|
||||
|
|
@ -155,7 +165,7 @@ export const LinkedToolItem: React.FC<LinkedToolItemProps> = ({
|
|||
/>
|
||||
}
|
||||
label={linkedTool.name}
|
||||
summary={summary}
|
||||
summary={summaryNode}
|
||||
tokenCount={getToolContextTokens(linkedTool)}
|
||||
status={status}
|
||||
durationMs={linkedTool.durationMs}
|
||||
|
|
|
|||
|
|
@ -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<TextItemProps> = ({
|
|||
}) => {
|
||||
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<TextItemProps> = ({
|
|||
<BaseItem
|
||||
icon={<MessageSquare className="size-4" />}
|
||||
label="Output"
|
||||
summary={truncatedPreview}
|
||||
summary={summary}
|
||||
tokenCount={tokenCount}
|
||||
onClick={onClick}
|
||||
isExpanded={isExpanded}
|
||||
|
|
|
|||
|
|
@ -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<ThinkingItemProps> = ({
|
|||
}) => {
|
||||
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<ThinkingItemProps> = ({
|
|||
<BaseItem
|
||||
icon={<Brain className="size-4" />}
|
||||
label="Thinking"
|
||||
summary={truncatedPreview}
|
||||
summary={summary}
|
||||
tokenCount={tokenCount}
|
||||
onClick={onClick}
|
||||
isExpanded={isExpanded}
|
||||
|
|
|
|||
|
|
@ -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.)
|
||||
|
|
|
|||
|
|
@ -600,6 +600,10 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
|||
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)
|
||||
|
|
|
|||
25
src/renderer/components/common/WarningBanner.tsx
Normal file
25
src/renderer/components/common/WarningBanner.tsx
Normal 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>
|
||||
);
|
||||
|
|
@ -44,9 +44,11 @@ export const SidebarHeader = (): React.JSX.Element => {
|
|||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
|
||||
<AppLogo size={22} className="shrink-0" />
|
||||
</div>
|
||||
{isMacElectron && (
|
||||
<div style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
|
||||
<AppLogo size={22} className="shrink-0" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useCallback, useState } from 'react';
|
|||
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useStore } from '@renderer/store';
|
||||
import {
|
||||
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({
|
||||
id: tab.id,
|
||||
data: {
|
||||
|
|
@ -81,13 +89,18 @@ export const SortableTab = ({
|
|||
transition: isDragging ? 'none' : transition,
|
||||
opacity: isDragging ? 0.3 : 1,
|
||||
backgroundColor: isActive
|
||||
? 'var(--color-surface-raised)'
|
||||
? teamColorSet
|
||||
? teamColorSet.badge
|
||||
: 'var(--color-surface-raised)'
|
||||
: isHovered
|
||||
? 'var(--color-surface-overlay)'
|
||||
? teamColorSet
|
||||
? teamColorSet.badge
|
||||
: 'var(--color-surface-overlay)'
|
||||
: 'transparent',
|
||||
color: isActive || isHovered ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
outline: isSelected ? '1px solid var(--color-border-emphasis)' : 'none',
|
||||
outlineOffset: '-1px',
|
||||
borderLeft: isActive && teamColorSet ? `2px solid ${teamColorSet.border}` : undefined,
|
||||
};
|
||||
|
||||
const Icon = TAB_ICONS[tab.type];
|
||||
|
|
|
|||
|
|
@ -302,13 +302,14 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
|
|||
scrollContainerRef.current = 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={
|
||||
{
|
||||
maxWidth: '75%',
|
||||
WebkitAppRegion: 'no-drag',
|
||||
outline: isDroppableOver ? '1px dashed var(--color-accent, #6366f1)' : 'none',
|
||||
outlineOffset: '-1px',
|
||||
overflowX: 'auto',
|
||||
overflowY: 'clip',
|
||||
} 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.
|
||||
Only applied on the leftmost pane in Electron to match the TabBar drag region logic. */}
|
||||
<div
|
||||
className="flex-1 self-stretch"
|
||||
className="min-w-[48px] flex-1 self-stretch"
|
||||
style={
|
||||
{
|
||||
WebkitAppRegion: isElectronMode() && isLeftmostPane ? 'drag' : undefined,
|
||||
|
|
|
|||
|
|
@ -63,7 +63,14 @@ export const TriggerPreview = ({
|
|||
|
||||
{/* Truncation warning - only shown when timeout or count limit hit */}
|
||||
{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" />
|
||||
<span>
|
||||
Search stopped early (timeout or count limit). Actual matches may be higher.
|
||||
|
|
|
|||
|
|
@ -7,11 +7,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||
import { api, isElectronMode } from '@renderer/api';
|
||||
import appIcon from '@renderer/favicon.png';
|
||||
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 { CliStatusSection } from './CliStatusSection';
|
||||
import { ConfigEditorDialog } from './ConfigEditorDialog';
|
||||
|
||||
interface AdvancedSectionProps {
|
||||
readonly saving: boolean;
|
||||
|
|
@ -30,6 +31,7 @@ export const AdvancedSection = ({
|
|||
}: AdvancedSectionProps): React.JSX.Element => {
|
||||
const isElectron = useMemo(() => isElectronMode(), []);
|
||||
const [version, setVersion] = useState<string>('');
|
||||
const [configEditorOpen, setConfigEditorOpen] = useState(false);
|
||||
const updateStatus = useStore((s) => s.updateStatus);
|
||||
const availableVersion = useStore((s) => s.availableVersion);
|
||||
const checkForUpdates = useStore((s) => s.checkForUpdates);
|
||||
|
|
@ -95,6 +97,17 @@ export const AdvancedSection = ({
|
|||
<div>
|
||||
<SettingsSectionHeader title="Configuration" />
|
||||
<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
|
||||
onClick={onResetToDefaults}
|
||||
disabled={saving}
|
||||
|
|
@ -195,6 +208,14 @@ export const AdvancedSection = ({
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfigEditorDialog
|
||||
open={configEditorOpen}
|
||||
onClose={() => setConfigEditorOpen(false)}
|
||||
onConfigSaved={() => {
|
||||
// Config saved via editor — settings page will pick up changes on next render
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<button
|
||||
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={{
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
Check for Updates
|
||||
{cliStatusLoading ? (
|
||||
<>
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Checking...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="size-3.5" />
|
||||
Check for Updates
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
402
src/renderer/components/settings/sections/ConfigEditorDialog.tsx
Normal file
402
src/renderer/components/settings/sections/ConfigEditorDialog.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
|
|
@ -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}
|
||||
</p>
|
||||
{!candidate.hasProjectsDir && (
|
||||
<p className="text-[11px] text-amber-400">
|
||||
<p className="text-[11px]" style={{ color: 'var(--warning-text)' }}>
|
||||
No projects directory detected
|
||||
</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
|||
/>
|
||||
<Bot size={13} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<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>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
|
|
|
|||
|
|
@ -185,7 +185,7 @@ export const ProvisioningProgressBlock = ({
|
|||
<p
|
||||
className={cn(
|
||||
'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}
|
||||
|
|
@ -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)]'
|
||||
)}
|
||||
>
|
||||
<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
|
||||
className={cn(
|
||||
'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.
|
||||
|
|
|
|||
|
|
@ -1081,15 +1081,22 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
</div>
|
||||
|
||||
{!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">
|
||||
<span className="flex items-center gap-1.5 text-xs text-amber-200">
|
||||
<AlertTriangle size={14} className="shrink-0 text-amber-400" />
|
||||
<div
|
||||
className="mb-3 flex items-center justify-between gap-3 rounded-md border px-3 py-2"
|
||||
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
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
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)}
|
||||
>
|
||||
<Play size={12} />
|
||||
|
|
@ -1103,12 +1110,12 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
</div>
|
||||
|
||||
{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.
|
||||
</div>
|
||||
) : null}
|
||||
{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}
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -556,10 +556,14 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={teamsLoading}
|
||||
onClick={() => {
|
||||
void fetchTeams();
|
||||
}}
|
||||
>
|
||||
{teamsLoading ? (
|
||||
<RotateCcw className="size-3.5 animate-spin" />
|
||||
) : null}
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -78,11 +78,11 @@ export const TeamProvisioningBanner = ({
|
|||
return (
|
||||
<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">
|
||||
<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
|
||||
variant="outline"
|
||||
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)}
|
||||
>
|
||||
<X size={12} />
|
||||
|
|
@ -108,13 +108,13 @@ export const TeamProvisioningBanner = ({
|
|||
if (isReady) {
|
||||
return (
|
||||
<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">
|
||||
<CheckCircle2 size={14} className="shrink-0 text-emerald-400" />
|
||||
<p className="flex-1 text-xs text-emerald-200">Team launched — process alive</p>
|
||||
<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-[var(--step-done-text)]" />
|
||||
<p className="flex-1 text-xs text-[var(--step-success-text)]">Team launched — process alive</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
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)}
|
||||
>
|
||||
<X size={12} />
|
||||
|
|
|
|||
|
|
@ -38,9 +38,12 @@ export const AttachmentPreviewList = ({
|
|||
</div>
|
||||
) : null}
|
||||
{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">
|
||||
<AlertCircle size={13} className="shrink-0 text-amber-400" />
|
||||
<p className="text-[11px] text-amber-400">{disabledHint}</p>
|
||||
<div
|
||||
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5"
|
||||
style={{ backgroundColor: 'var(--warning-bg)', color: 'var(--warning-text)' }}
|
||||
>
|
||||
<AlertCircle size={13} className="shrink-0" />
|
||||
<p className="text-[11px]">{disabledHint}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
|
|
|
|||
|
|
@ -241,9 +241,16 @@ export const CreateTaskDialog = ({
|
|||
</DialogHeader>
|
||||
|
||||
{!isTeamAlive ? (
|
||||
<div className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2">
|
||||
<AlertTriangle size={14} className="mt-0.5 shrink-0 text-amber-400" />
|
||||
<p className="text-xs leading-relaxed text-amber-300">
|
||||
<div
|
||||
className="flex items-start gap-2 rounded-md border px-3 py-2"
|
||||
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> — launch the
|
||||
team to start execution.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -588,25 +588,32 @@ export const CreateTeamDialog = ({
|
|||
</DialogHeader>
|
||||
|
||||
{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">
|
||||
<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">
|
||||
<p className="font-medium text-amber-300">
|
||||
<p className="font-medium">
|
||||
Another team “{conflictingTeam.displayName}” is already running for
|
||||
this working directory
|
||||
</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
|
||||
same files. Consider using a different directory or a git worktree for isolation.
|
||||
</p>
|
||||
<p className="text-[11px] text-amber-300/70">
|
||||
<p className="text-[11px] opacity-70">
|
||||
Working directory: <span className="font-mono">{effectiveCwd}</span>
|
||||
</p>
|
||||
</div>
|
||||
<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)}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
|
|
@ -629,7 +636,7 @@ export const CreateTeamDialog = ({
|
|||
{prepareWarnings.length > 0 ? (
|
||||
<div className="space-y-0.5">
|
||||
{prepareWarnings.map((warning) => (
|
||||
<p key={warning} className="text-[11px] text-amber-300">
|
||||
<p key={warning} className="text-[11px]" style={{ color: 'var(--warning-text)' }}>
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
|
|
@ -645,7 +652,14 @@ export const CreateTeamDialog = ({
|
|||
) : null}
|
||||
|
||||
{!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.
|
||||
</p>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -36,10 +36,17 @@ export const ExtendedContextCheckbox: React.FC<ExtendedContextCheckboxProps> = (
|
|||
</Label>
|
||||
</div>
|
||||
{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">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0 text-amber-400" />
|
||||
<div className="space-y-1 text-amber-300/90">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p>
|
||||
Beyond 200K tokens, premium pricing applies: 2x input cost, 1.5x output cost. For
|
||||
subscribers, extra usage is billed separately.
|
||||
|
|
@ -48,7 +55,7 @@ export const ExtendedContextCheckbox: React.FC<ExtendedContextCheckboxProps> = (
|
|||
Requires API tier 4+ or extra usage enabled.{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="underline underline-offset-2 hover:text-amber-200"
|
||||
className="underline underline-offset-2 hover:opacity-80"
|
||||
onClick={() =>
|
||||
window.electronAPI.openExternal(
|
||||
'https://platform.claude.com/docs/en/build-with-claude/context-windows#1m-token-context-window'
|
||||
|
|
|
|||
|
|
@ -314,25 +314,32 @@ export const LaunchTeamDialog = ({
|
|||
</DialogHeader>
|
||||
|
||||
{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">
|
||||
<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">
|
||||
<p className="font-medium text-amber-300">
|
||||
<p className="font-medium">
|
||||
Another team “{conflictingTeam.displayName}” is already running for
|
||||
this working directory
|
||||
</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
|
||||
same files. Consider using a different directory or a git worktree for isolation.
|
||||
</p>
|
||||
<p className="text-[11px] text-amber-300/70">
|
||||
<p className="text-[11px] opacity-70">
|
||||
Working directory: <span className="font-mono">{effectiveCwd}</span>
|
||||
</p>
|
||||
</div>
|
||||
<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)}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
|
|
@ -355,7 +362,7 @@ export const LaunchTeamDialog = ({
|
|||
{prepareWarnings.length > 0 ? (
|
||||
<div className="space-y-0.5">
|
||||
{prepareWarnings.map((warning) => (
|
||||
<p key={warning} className="text-[11px] text-amber-300">
|
||||
<p key={warning} className="text-[11px]" style={{ color: 'var(--warning-text)' }}>
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
|
|
@ -438,10 +445,17 @@ export const LaunchTeamDialog = ({
|
|||
</Label>
|
||||
</div>
|
||||
{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">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0 text-amber-400" />
|
||||
<p className="text-amber-300/90">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<p>
|
||||
The team lead will start a new session without resuming previous context. All
|
||||
accumulated session memory and conversation history will not be available.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ export const ProjectPathSelector = ({
|
|||
) : null}
|
||||
{projectsError ? <p className="text-[11px] text-red-300">{projectsError}</p> : null}
|
||||
{!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}
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -678,7 +678,14 @@ export const ProjectEditorOverlay = ({
|
|||
|
||||
{/* Draft recovery banner */}
|
||||
{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" />
|
||||
<span>Recovered unsaved changes from a previous session.</span>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -392,7 +392,7 @@ export const MessageComposer = ({
|
|||
) : null}
|
||||
|
||||
{!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}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ export const createUpdateSlice: StateCreator<AppState, [], [], UpdateSlice> = (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' });
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue