fix: enhance terminal IPC handlers with error handling
- Wrapped terminal IPC handler functions (write, resize, kill) in try/catch blocks to log errors from node-pty, improving robustness against PTY failures. - Updated the TeamAgentToolsInstaller to increment TOOL_VERSION to 10, reflecting changes in the tool's versioning. - Refactored task comment handling in TeamAgentToolsInstaller to include retry logic for improved reliability during task updates. - Consolidated role constants into a new teamRoles.ts file for better organization and reuse across components.
This commit is contained in:
parent
69f7cd333a
commit
a9e0416251
9 changed files with 163 additions and 87 deletions
|
|
@ -44,11 +44,28 @@ export function registerTerminalHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle(TERMINAL_SPAWN, handleSpawn);
|
||||
|
||||
// write, resize, kill are fire-and-forget (hot path, latency-sensitive)
|
||||
ipcMain.on(TERMINAL_WRITE, (_event, ptyId: string, data: string) => service.write(ptyId, data));
|
||||
ipcMain.on(TERMINAL_RESIZE, (_event, ptyId: string, cols: number, rows: number) =>
|
||||
service.resize(ptyId, cols, rows)
|
||||
);
|
||||
ipcMain.on(TERMINAL_KILL, (_event, ptyId: string) => service.kill(ptyId));
|
||||
// Wrapped in try/catch: node-pty can throw if the PTY dies between Map.get() and .write()
|
||||
ipcMain.on(TERMINAL_WRITE, (_event, ptyId: string, data: string) => {
|
||||
try {
|
||||
service.write(ptyId, data);
|
||||
} catch (err) {
|
||||
logger.warn('terminal:write error:', getErrorMessage(err));
|
||||
}
|
||||
});
|
||||
ipcMain.on(TERMINAL_RESIZE, (_event, ptyId: string, cols: number, rows: number) => {
|
||||
try {
|
||||
service.resize(ptyId, cols, rows);
|
||||
} catch (err) {
|
||||
logger.warn('terminal:resize error:', getErrorMessage(err));
|
||||
}
|
||||
});
|
||||
ipcMain.on(TERMINAL_KILL, (_event, ptyId: string) => {
|
||||
try {
|
||||
service.kill(ptyId);
|
||||
} catch (err) {
|
||||
logger.warn('terminal:kill error:', getErrorMessage(err));
|
||||
}
|
||||
});
|
||||
|
||||
logger.info('Terminal handlers registered');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import * as path from 'path';
|
|||
import { atomicWriteAsync } from './atomicWrite';
|
||||
|
||||
const TOOL_FILE_NAME = 'teamctl.js';
|
||||
const TOOL_VERSION = 9;
|
||||
const TOOL_VERSION = 10;
|
||||
|
||||
function buildTeamCtlScript(): string {
|
||||
const script = String.raw`#!/usr/bin/env node
|
||||
|
|
@ -217,29 +217,43 @@ function addTaskComment(paths, taskId, flags) {
|
|||
if (!text) die('Missing --text');
|
||||
var from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'agent';
|
||||
|
||||
var ref = readTask(paths, taskId);
|
||||
var task = ref.task;
|
||||
var taskPath = ref.taskPath;
|
||||
var ref;
|
||||
var task;
|
||||
var taskPath;
|
||||
var commentId;
|
||||
var comment;
|
||||
var existing;
|
||||
var lastErr;
|
||||
for (var attempt = 0; attempt < 8; attempt++) {
|
||||
try {
|
||||
ref = readTask(paths, taskId);
|
||||
task = ref.task;
|
||||
taskPath = ref.taskPath;
|
||||
|
||||
// Auto-clear needsClarification: "lead" when someone other than the task owner comments
|
||||
if (task.needsClarification === 'lead' && from !== task.owner) {
|
||||
delete task.needsClarification;
|
||||
if (task.needsClarification === 'lead' && from !== task.owner) {
|
||||
delete task.needsClarification;
|
||||
}
|
||||
|
||||
existing = Array.isArray(task.comments) ? task.comments : [];
|
||||
commentId = crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: String(Date.now()) + '-' + String(Math.random());
|
||||
comment = {
|
||||
id: commentId,
|
||||
author: from,
|
||||
text: text,
|
||||
createdAt: nowIso(),
|
||||
};
|
||||
task.comments = existing.concat([comment]);
|
||||
writeTask(taskPath, task);
|
||||
|
||||
return { commentId: commentId, taskId: String(taskId), subject: task.subject, owner: task.owner };
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
if (attempt === 7) throw e;
|
||||
}
|
||||
}
|
||||
|
||||
var existing = Array.isArray(task.comments) ? task.comments : [];
|
||||
var commentId = crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: String(Date.now()) + '-' + String(Math.random());
|
||||
var comment = {
|
||||
id: commentId,
|
||||
author: from,
|
||||
text: text,
|
||||
createdAt: nowIso(),
|
||||
};
|
||||
task.comments = existing.concat([comment]);
|
||||
writeTask(taskPath, task);
|
||||
|
||||
return { commentId: commentId, taskId: String(taskId), subject: task.subject, owner: task.owner };
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
function setNeedsClarification(paths, taskId, value) {
|
||||
|
|
@ -309,25 +323,36 @@ function createTask(paths, flags) {
|
|||
: undefined;
|
||||
|
||||
ensureDir(paths.tasksDir);
|
||||
const nextId = getNextTaskId(paths);
|
||||
const taskPath = path.join(paths.tasksDir, String(nextId) + '.json');
|
||||
if (fs.existsSync(taskPath)) die('Task already exists: ' + String(nextId));
|
||||
|
||||
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : undefined;
|
||||
|
||||
const task = {
|
||||
id: nextId,
|
||||
subject,
|
||||
description: String(description || subject),
|
||||
activeForm: activeForm ? String(activeForm) : undefined,
|
||||
owner,
|
||||
createdBy: from,
|
||||
status,
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
};
|
||||
|
||||
writeTask(taskPath, task);
|
||||
let nextId;
|
||||
let task;
|
||||
let taskPath;
|
||||
while (true) {
|
||||
nextId = getNextTaskId(paths);
|
||||
taskPath = path.join(paths.tasksDir, String(nextId) + '.json');
|
||||
task = {
|
||||
id: nextId,
|
||||
subject,
|
||||
description: String(description || subject),
|
||||
activeForm: activeForm ? String(activeForm) : undefined,
|
||||
owner,
|
||||
createdBy: from,
|
||||
status,
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
};
|
||||
try {
|
||||
const fd = fs.openSync(taskPath, 'wx');
|
||||
fs.closeSync(fd);
|
||||
atomicWrite(taskPath, JSON.stringify(task, null, 2));
|
||||
const verify = readJson(taskPath, null);
|
||||
if (!verify) die('Task write verification failed');
|
||||
break;
|
||||
} catch (e) {
|
||||
if (e && e.code === 'EEXIST') continue;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
updateHighwatermark(paths, nextId);
|
||||
return task;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -212,22 +212,47 @@ export const ActivityTimeline = ({
|
|||
);
|
||||
})}
|
||||
{hiddenCount > 0 && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5">
|
||||
<span className="text-[11px] text-[var(--color-text-muted)]">+{hiddenCount} older</span>
|
||||
<button
|
||||
onClick={handleShowMore}
|
||||
className="rounded px-2 py-0.5 text-[11px] font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
|
||||
<div className="relative flex justify-center pb-3 pt-1">
|
||||
{/* Bottom-up shadow gradient: darkest at bottom edge, fades upward */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 -top-24"
|
||||
style={{
|
||||
bottom: '-1.6rem',
|
||||
background:
|
||||
'linear-gradient(to top, rgba(0, 0, 0, 0.4) 0%, rgba(0, 0, 0, 0.25) 25%, rgba(0, 0, 0, 0.1) 50%, rgba(0, 0, 0, 0.03) 75%, transparent 100%)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="relative z-[1] flex items-center gap-3 rounded-full px-4 py-1.5"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
boxShadow:
|
||||
'0 0 12px 4px rgba(0, 0, 0, 0.3), 0 1px 3px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.04)',
|
||||
border: '1px solid var(--color-border-emphasis)',
|
||||
}}
|
||||
>
|
||||
Show {Math.min(MESSAGES_PAGE_SIZE, hiddenCount)} more
|
||||
</button>
|
||||
{hiddenCount > MESSAGES_PAGE_SIZE && (
|
||||
<span className="text-[11px] tabular-nums text-[var(--color-text-muted)]">
|
||||
+{hiddenCount} older
|
||||
</span>
|
||||
<span className="h-3 w-px bg-[var(--color-border-emphasis)]" />
|
||||
<button
|
||||
onClick={handleShowAll}
|
||||
className="rounded px-2 py-0.5 text-[11px] text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={handleShowMore}
|
||||
className="rounded-full px-2.5 py-0.5 text-[11px] font-medium text-[var(--color-text-secondary)] transition-all hover:bg-[rgba(255,255,255,0.08)] hover:text-[var(--color-text)]"
|
||||
>
|
||||
Show all
|
||||
Show {Math.min(MESSAGES_PAGE_SIZE, hiddenCount)} more
|
||||
</button>
|
||||
)}
|
||||
{hiddenCount > MESSAGES_PAGE_SIZE && (
|
||||
<>
|
||||
<span className="h-3 w-px bg-[var(--color-border-emphasis)]" />
|
||||
<button
|
||||
onClick={handleShowAll}
|
||||
className="rounded-full px-2.5 py-0.5 text-[11px] text-[var(--color-text-muted)] transition-all hover:bg-[rgba(255,255,255,0.08)] hover:text-[var(--color-text-secondary)]"
|
||||
>
|
||||
Show all
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -18,12 +18,9 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@renderer/components/ui/select';
|
||||
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
const PRESET_ROLES = ['lead', 'reviewer', 'developer', 'qa', 'researcher'] as const;
|
||||
const CUSTOM_ROLE = '__custom__';
|
||||
const NO_ROLE = '__none__';
|
||||
|
||||
const NAME_REGEX = /^[a-z0-9][a-z0-9-]*$/;
|
||||
|
||||
interface AddMemberDialogProps {
|
||||
|
|
|
|||
|
|
@ -86,9 +86,7 @@ interface ValidationResult {
|
|||
};
|
||||
}
|
||||
|
||||
const PRESET_ROLES = ['lead', 'reviewer', 'developer', 'qa', 'researcher'] as const;
|
||||
const CUSTOM_ROLE = '__custom__';
|
||||
const NO_ROLE = '__none__';
|
||||
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
||||
const DEV_DEFAULT_TEAM = {
|
||||
teamName: 'team-alpha',
|
||||
description: 'Dev test team for provisioning flow',
|
||||
|
|
|
|||
|
|
@ -9,13 +9,9 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@renderer/components/ui/select';
|
||||
import { CUSTOM_ROLE, FORBIDDEN_ROLES, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
||||
import { Check, Loader2, X } from 'lucide-react';
|
||||
|
||||
const ROLE_PRESETS = ['reviewer', 'developer', 'qa', 'researcher'] as const;
|
||||
const FORBIDDEN_ROLES = new Set(['lead', 'team-lead']);
|
||||
const NO_ROLE = '__none__';
|
||||
const CUSTOM_ROLE = '__custom__';
|
||||
|
||||
interface MemberRoleEditorProps {
|
||||
currentRole: string | undefined;
|
||||
onSave: (role: string | undefined) => Promise<void> | void;
|
||||
|
|
@ -29,7 +25,7 @@ export const MemberRoleEditor = ({
|
|||
onCancel,
|
||||
saving,
|
||||
}: MemberRoleEditorProps): React.JSX.Element => {
|
||||
const isPreset = currentRole && (ROLE_PRESETS as readonly string[]).includes(currentRole);
|
||||
const isPreset = currentRole && (PRESET_ROLES as readonly string[]).includes(currentRole);
|
||||
const [selectValue, setSelectValue] = useState<string>(
|
||||
!currentRole ? NO_ROLE : isPreset ? currentRole : CUSTOM_ROLE
|
||||
);
|
||||
|
|
@ -75,7 +71,7 @@ export const MemberRoleEditor = ({
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_ROLE}>No role</SelectItem>
|
||||
{ROLE_PRESETS.map((r) => (
|
||||
{PRESET_ROLES.map((r) => (
|
||||
<SelectItem key={r} value={r}>
|
||||
{r}
|
||||
</SelectItem>
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ interface CodeMirrorDiffViewProps {
|
|||
onFullyViewed?: () => void;
|
||||
/** Ref to expose the EditorView for external navigation */
|
||||
editorViewRef?: React.RefObject<EditorView | null>;
|
||||
/** Called whenever the internal EditorView is created or destroyed */
|
||||
onViewChange?: (view: EditorView | null) => void;
|
||||
/** Called when editor content changes (debounced, only when readOnly=false) */
|
||||
onContentChanged?: (content: string) => void;
|
||||
/** Cached EditorState to restore (preserves undo history between file switches) */
|
||||
|
|
@ -293,6 +295,7 @@ export const CodeMirrorDiffView = ({
|
|||
onHunkRejected,
|
||||
onFullyViewed,
|
||||
editorViewRef: externalViewRef,
|
||||
onViewChange,
|
||||
onContentChanged,
|
||||
initialState,
|
||||
usePortionCollapse = false,
|
||||
|
|
@ -308,13 +311,15 @@ export const CodeMirrorDiffView = ({
|
|||
const onAcceptRef = useRef(onHunkAccepted);
|
||||
const onRejectRef = useRef(onHunkRejected);
|
||||
const onContentChangedRef = useRef(onContentChanged);
|
||||
const onViewChangeRef = useRef(onViewChange);
|
||||
const debounceTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
useEffect(() => {
|
||||
onAcceptRef.current = onHunkAccepted;
|
||||
onRejectRef.current = onHunkRejected;
|
||||
onContentChangedRef.current = onContentChanged;
|
||||
onViewChangeRef.current = onViewChange;
|
||||
externalViewRefHolder.current = externalViewRef;
|
||||
}, [onHunkAccepted, onHunkRejected, onContentChanged, externalViewRef]);
|
||||
}, [onHunkAccepted, onHunkRejected, onContentChanged, onViewChange, externalViewRef]);
|
||||
|
||||
// Auto-scroll to next chunk after accept/reject (deferred to let CM recalculate)
|
||||
const scrollToNextChunk = useCallback(() => {
|
||||
|
|
@ -705,6 +710,8 @@ export const CodeMirrorDiffView = ({
|
|||
if (extRef) {
|
||||
(extRef as React.MutableRefObject<EditorView | null>).current = view;
|
||||
}
|
||||
// Notify parent that a new EditorView was created
|
||||
onViewChangeRef.current?.(view);
|
||||
|
||||
return () => {
|
||||
clearTimeout(debounceTimer.current);
|
||||
|
|
@ -713,6 +720,8 @@ export const CodeMirrorDiffView = ({
|
|||
if (extRef) {
|
||||
(extRef as React.MutableRefObject<EditorView | null>).current = null;
|
||||
}
|
||||
// Notify parent that the EditorView was destroyed
|
||||
onViewChangeRef.current?.(null);
|
||||
};
|
||||
// We intentionally rebuild the entire editor when key props change
|
||||
}, [original, modified, buildExtensions, initialState]);
|
||||
|
|
@ -747,7 +756,7 @@ export const CodeMirrorDiffView = ({
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [fileName, buildExtensions, initialState]);
|
||||
}, [fileName, buildExtensions, initialState, original, modified]);
|
||||
|
||||
// Dynamic collapse toggle — reconfigure compartments in-place, preserving undo history
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { CodeMirrorDiffView } from './CodeMirrorDiffView';
|
||||
import { DiffErrorBoundary } from './DiffErrorBoundary';
|
||||
|
|
@ -41,19 +41,16 @@ export const FileSectionDiff = ({
|
|||
const localEditorViewRef = useRef<EditorView | null>(null);
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Register/unregister EditorView with parent Map
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
onEditorViewReady(file.filePath, null);
|
||||
};
|
||||
}, [file.filePath, onEditorViewReady]);
|
||||
|
||||
// Sync EditorView ref to parent after CM creates the view
|
||||
useEffect(() => {
|
||||
if (localEditorViewRef.current) {
|
||||
onEditorViewReady(file.filePath, localEditorViewRef.current);
|
||||
}
|
||||
}, [file.filePath, onEditorViewReady, discardCounter]);
|
||||
// Notify parent whenever CodeMirrorDiffView creates or destroys its EditorView.
|
||||
// This fires on every editor lifecycle event: initial mount, key-change remount,
|
||||
// and internal recreation (e.g. when `modified` prop changes after Save).
|
||||
const handleViewChange = useCallback(
|
||||
(view: EditorView | null) => {
|
||||
localEditorViewRef.current = view;
|
||||
onEditorViewReady(file.filePath, view);
|
||||
},
|
||||
[file.filePath, onEditorViewReady]
|
||||
);
|
||||
|
||||
// Auto-viewed sentinel observer
|
||||
useEffect(() => {
|
||||
|
|
@ -124,6 +121,7 @@ export const FileSectionDiff = ({
|
|||
onHunkRejected={(idx) => onHunkRejected(file.filePath, idx)}
|
||||
onContentChanged={(content) => onContentChanged(file.filePath, content)}
|
||||
editorViewRef={localEditorViewRef}
|
||||
onViewChange={handleViewChange}
|
||||
/>
|
||||
</DiffErrorBoundary>
|
||||
<div ref={sentinelRef} className="h-1 shrink-0" />
|
||||
|
|
|
|||
11
src/renderer/constants/teamRoles.ts
Normal file
11
src/renderer/constants/teamRoles.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/** Preset role options shown in role selectors (Add Member, Create Team, Role Editor). */
|
||||
export const PRESET_ROLES = ['reviewer', 'developer', 'qa', 'researcher'] as const;
|
||||
|
||||
/** Sentinel value for "custom role" in select dropdowns. */
|
||||
export const CUSTOM_ROLE = '__custom__';
|
||||
|
||||
/** Sentinel value for "no role" in select dropdowns. */
|
||||
export const NO_ROLE = '__none__';
|
||||
|
||||
/** Roles that cannot be assigned manually (reserved for system use). */
|
||||
export const FORBIDDEN_ROLES = new Set(['lead', 'team-lead']);
|
||||
Loading…
Reference in a new issue