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 - - {hiddenCount > MESSAGES_PAGE_SIZE && ( + + +{hiddenCount} older + + - )} + {hiddenCount > MESSAGES_PAGE_SIZE && ( + <> + + + + )} +
)} 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']);