diff --git a/src/main/ipc/terminal.ts b/src/main/ipc/terminal.ts
index 1d2a69b7..784959c7 100644
--- a/src/main/ipc/terminal.ts
+++ b/src/main/ipc/terminal.ts
@@ -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');
}
diff --git a/src/main/services/team/TeamAgentToolsInstaller.ts b/src/main/services/team/TeamAgentToolsInstaller.ts
index 739bc6f3..eb0a0f01 100644
--- a/src/main/services/team/TeamAgentToolsInstaller.ts
+++ b/src/main/services/team/TeamAgentToolsInstaller.ts
@@ -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;
}
diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx
index a460024d..dd79755e 100644
--- a/src/renderer/components/team/activity/ActivityTimeline.tsx
+++ b/src/renderer/components/team/activity/ActivityTimeline.tsx
@@ -212,22 +212,47 @@ export const ActivityTimeline = ({
);
})}
{hiddenCount > 0 && (
-
-
+{hiddenCount} older
-
)}
diff --git a/src/renderer/components/team/dialogs/AddMemberDialog.tsx b/src/renderer/components/team/dialogs/AddMemberDialog.tsx
index 6c457aee..395fc016 100644
--- a/src/renderer/components/team/dialogs/AddMemberDialog.tsx
+++ b/src/renderer/components/team/dialogs/AddMemberDialog.tsx
@@ -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 {
diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx
index 6a8d65fa..f60aaa07 100644
--- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx
+++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx
@@ -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',
diff --git a/src/renderer/components/team/members/MemberRoleEditor.tsx b/src/renderer/components/team/members/MemberRoleEditor.tsx
index c200ab49..e3c7e181 100644
--- a/src/renderer/components/team/members/MemberRoleEditor.tsx
+++ b/src/renderer/components/team/members/MemberRoleEditor.tsx
@@ -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;
@@ -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(
!currentRole ? NO_ROLE : isPreset ? currentRole : CUSTOM_ROLE
);
@@ -75,7 +71,7 @@ export const MemberRoleEditor = ({
No role
- {ROLE_PRESETS.map((r) => (
+ {PRESET_ROLES.map((r) => (
{r}
diff --git a/src/renderer/components/team/review/CodeMirrorDiffView.tsx b/src/renderer/components/team/review/CodeMirrorDiffView.tsx
index 67809bac..fed829fb 100644
--- a/src/renderer/components/team/review/CodeMirrorDiffView.tsx
+++ b/src/renderer/components/team/review/CodeMirrorDiffView.tsx
@@ -48,6 +48,8 @@ interface CodeMirrorDiffViewProps {
onFullyViewed?: () => void;
/** Ref to expose the EditorView for external navigation */
editorViewRef?: React.RefObject;
+ /** 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>();
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).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).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(() => {
diff --git a/src/renderer/components/team/review/FileSectionDiff.tsx b/src/renderer/components/team/review/FileSectionDiff.tsx
index df77e1eb..701c3b68 100644
--- a/src/renderer/components/team/review/FileSectionDiff.tsx
+++ b/src/renderer/components/team/review/FileSectionDiff.tsx
@@ -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(null);
const sentinelRef = useRef(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}
/>
diff --git a/src/renderer/constants/teamRoles.ts b/src/renderer/constants/teamRoles.ts
new file mode 100644
index 00000000..2e74c76f
--- /dev/null
+++ b/src/renderer/constants/teamRoles.ts
@@ -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']);