diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 7a859b2a..f77dd762 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -838,7 +838,7 @@ export class TeamDataService { // Skip inbox notification when lead starts their own task (solo teams) if (!this.isLeadOwner(task.owner, leadName)) { - const parts = [`Task ${this.getTaskLabel(task)} "${task.subject}" has been started.`]; + const parts = [`**started task** ${this.getTaskLabel(task)} "${task.subject}"`]; if (task.description?.trim()) { parts.push(`\nDetails:\n${task.description.trim()}`); } @@ -908,7 +908,7 @@ export class TeamDataService { await this.sendMessage(teamName, { member: leadName, from: last.actor, - text: `Task ${this.getTaskLabel(task)} "${task.subject}" has been started by ${last.actor}.`, + text: `@${last.actor} **started task** ${this.getTaskLabel(task)} "${task.subject}"`, summary: `Task ${this.getTaskLabel(task)} started`, source: 'system_notification', }); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 12916af7..78639bca 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1152,7 +1152,10 @@ function updateProgress( run: ProvisioningRun, state: Exclude, message: string, - extras?: Pick + extras?: Pick< + TeamProvisioningProgress, + 'pid' | 'error' | 'warnings' | 'cliLogsTail' | 'configReady' + > ): TeamProvisioningProgress { const assistantOutput = run.provisioningOutputParts.length > 0 @@ -1168,6 +1171,7 @@ function updateProgress( warnings: extras?.warnings, cliLogsTail: extras?.cliLogsTail ?? run.progress.cliLogsTail, assistantOutput, + configReady: extras?.configReady ?? run.progress.configReady, }; return run.progress; } @@ -5471,7 +5475,8 @@ export class TeamProvisioningService { const progress = updateProgress( run, 'monitoring', - 'Team config created, waiting for members' + 'Team config created, waiting for members', + { configReady: true } ); run.onProgress(progress); } diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index 74fbbb4a..d018b704 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -932,10 +932,7 @@ const LeadThoughtsGroupRowComponent = ({ ) : null} {isBodyVisible && !expanded && needsTruncation ? ( -
+
) : null} @@ -492,9 +491,7 @@ export const KanbanTaskCard = ({ ) : null} - {!isReviewManual ? ( -
{metaActions}
- ) : null} +
{metaActions}
); diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 84385a19..c9421668 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -81,6 +81,7 @@ export const MessageComposer = ({ const fileInputRef = useRef(null); const [imageRestrictionError, setImageRestrictionError] = useState(null); const imageRestrictionTimerRef = useRef(0); + const dismissMentionsRef = useRef<(() => void) | null>(null); // Cross-team state const [selectedTeam, setSelectedTeam] = useState(null); @@ -246,6 +247,7 @@ export const MessageComposer = ({ const handleSend = useCallback(() => { if (!canSend) return; + dismissMentionsRef.current?.(); pendingSendRef.current = true; const taskRefs = extractTaskRefsFromText(draft.text, taskSuggestions); const serialized = serializeChipsWithText(trimmed, draft.chips); @@ -833,6 +835,7 @@ export const MessageComposer = ({ projectPath={projectPath} onFileChipInsert={draft.addChip} onModEnter={handleSend} + dismissMentionsRef={dismissMentionsRef} minRows={2} maxRows={6} maxLength={MAX_TEXT_LENGTH} diff --git a/src/renderer/components/team/review/ChangesLoadingAnimation.tsx b/src/renderer/components/team/review/ChangesLoadingAnimation.tsx index 4ccfd09e..7ac1f2ca 100644 --- a/src/renderer/components/team/review/ChangesLoadingAnimation.tsx +++ b/src/renderer/components/team/review/ChangesLoadingAnimation.tsx @@ -1,106 +1,196 @@ -import { useEffect, useState } from 'react'; -import { FileCode, FileDiff, FileText, GitBranch, GitCommit, RefreshCw } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Check, FileCode, FileDiff, FileText, GitBranch, GitCommit, Search } from 'lucide-react'; -const orbitIcons = [ - { Icon: FileText, orbitRadius: 52, speed: 12, startAngle: 0, size: 15 }, - { Icon: FileDiff, orbitRadius: 52, speed: 12, startAngle: 72, size: 14 }, - { Icon: FileCode, orbitRadius: 52, speed: 12, startAngle: 144, size: 15 }, - { Icon: GitCommit, orbitRadius: 52, speed: 12, startAngle: 216, size: 13 }, - { Icon: GitBranch, orbitRadius: 52, speed: 12, startAngle: 288, size: 14 }, +/* ── Fake diff lines for the mini-terminal ─────────────────────────── */ +const diffLines = [ + { type: '+', text: 'export function resolveHunk(ctx)' }, + { type: '-', text: 'const legacy = parseDiff(raw)' }, + { type: ' ', text: ' const chunks = split(input)' }, + { type: '+', text: ' return mergeResults(a, b)' }, + { type: '-', text: ' if (old) return fallback()' }, + { type: '+', text: 'interface DiffRange { start: number }' }, + { type: ' ', text: ' for (const h of hunks) {' }, + { type: '+', text: ' yield computeDelta(h)' }, + { type: '-', text: ' emit("change", raw)' }, + { type: ' ', text: ' const meta = getFileInfo(p)' }, + { type: '+', text: ' await writePatched(output)' }, + { type: '-', text: ' delete cache[staleKey]' }, + { type: '+', text: 'type HunkMeta = { offset: number }' }, + { type: ' ', text: ' return ctx.finalize()' }, + { type: '+', text: ' const diff = computeLineDiff(a, b)' }, + { type: '-', text: ' throw new Error("parse failed")' }, ]; -const particles = Array.from({ length: 8 }, (_, i) => ({ - id: i, - delay: i * 0.6, - duration: 3 + (i % 3) * 0.8, - startAngle: i * 45, - radius: 34 + (i % 3) * 14, -})); +/* ── Phases ─────────────────────────────────────────────────────────── */ +const phases = [ + { icon: Search, label: 'Scanning repository…', accent: 'rgba(147,197,253,0.7)' }, + { icon: FileDiff, label: 'Computing diffs…', accent: 'rgba(253,186,116,0.7)' }, + { icon: GitBranch, label: 'Resolving branches…', accent: 'rgba(167,139,250,0.7)' }, + { icon: FileCode, label: 'Analyzing hunks…', accent: 'rgba(110,231,183,0.7)' }, +]; -const messages = ['Analyzing files…', 'Computing diffs…', 'Loading changes…', 'Resolving hunks…']; +/* ── Orbiting icons ─────────────────────────────────────────────────── */ +const orbitItems = [ + { Icon: FileText, angle: 0, r: 76, size: 13, speed: 18 }, + { Icon: FileDiff, angle: 60, r: 76, size: 14, speed: 18 }, + { Icon: FileCode, angle: 120, r: 76, size: 13, speed: 18 }, + { Icon: GitCommit, angle: 180, r: 76, size: 12, speed: 18 }, + { Icon: GitBranch, angle: 240, r: 76, size: 13, speed: 18 }, + { Icon: Check, angle: 300, r: 76, size: 12, speed: 18 }, +]; -export const ChangesLoadingAnimation = (): React.JSX.Element => { - const [msgIndex, setMsgIndex] = useState(0); - const [isFading, setIsFading] = useState(false); +/* ── Spark particles ────────────────────────────────────────────────── */ +const SPARK_COUNT = 12; + +const useSparks = () => { + const [sparks, setSparks] = useState<{ id: number; x: number; y: number; size: number }[]>([]); + const nextId = useRef(0); useEffect(() => { const interval = setInterval(() => { - setIsFading(true); - setTimeout(() => { - setMsgIndex((prev) => (prev + 1) % messages.length); - setIsFading(false); - }, 300); - }, 2400); + const angle = Math.random() * Math.PI * 2; + const dist = 30 + Math.random() * 50; + setSparks((prev) => { + const next = [ + ...prev.slice(-(SPARK_COUNT - 1)), + { + id: nextId.current++, + x: Math.cos(angle) * dist, + y: Math.sin(angle) * dist, + size: 1.5 + Math.random() * 2, + }, + ]; + return next; + }); + }, 400); return () => clearInterval(interval); }, []); + return sparks; +}; + +/* ── Fake file counter ──────────────────────────────────────────────── */ +const useFileCounter = () => { + const [count, setCount] = useState(0); + useEffect(() => { + const interval = setInterval( + () => { + setCount((prev) => { + const step = Math.floor(Math.random() * 3) + 1; + return prev + step; + }); + }, + 600 + Math.random() * 400 + ); + return () => clearInterval(interval); + }, []); + return count; +}; + +/* ── Component ──────────────────────────────────────────────────────── */ +export const ChangesLoadingAnimation = (): React.JSX.Element => { + const [phaseIdx, setPhaseIdx] = useState(0); + const [phaseFading, setPhaseFading] = useState(false); + const [visibleLines, setVisibleLines] = useState([]); + const linePointer = useRef(0); + const sparks = useSparks(); + const fileCount = useFileCounter(); + + /* Phase rotation */ + useEffect(() => { + const interval = setInterval(() => { + setPhaseFading(true); + setTimeout(() => { + setPhaseIdx((prev) => (prev + 1) % phases.length); + setPhaseFading(false); + }, 400); + }, 3500); + return () => clearInterval(interval); + }, []); + + /* Diff lines streaming */ + const addLine = useCallback(() => { + setVisibleLines((prev) => { + const next = [...prev, linePointer.current % diffLines.length]; + linePointer.current++; + return next.length > 5 ? next.slice(-5) : next; + }); + }, []); + + useEffect(() => { + const interval = setInterval(addLine, 900); + return () => clearInterval(interval); + }, [addLine]); + + const phase = phases[phaseIdx]; + const PhaseIcon = phase.icon; + return ( -
- {/* Main animation container */} -
- {/* Outer rotating ring */} - - +
+ {/* ── Main scene ─────────────────────────────────────────── */} +
+ {/* Faint radial grid */} + + {[40, 60, 80].map((r) => ( + + ))} + {[0, 45, 90, 135].map((deg) => ( + + ))} - {/* Inner rotating ring (counter) */} - + {/* Rotating dashed orbit ring */} + - {/* Particles on inner orbit */} - {particles.map((p) => ( -
- ))} - {/* Orbiting icons */} - {orbitIcons.map(({ Icon, orbitRadius, speed, startAngle, size }, i) => ( + {orbitItems.map(({ Icon, angle, r, size, speed }, i) => (
@@ -109,135 +199,207 @@ export const ChangesLoadingAnimation = (): React.JSX.Element => {
))} - {/* Glow pulse behind center */} -
+ {/* Spark particles */} + {sparks.map((s) => ( +
+ ))} + + {/* Glow behind center */}
- {/* Center icon block */} -
- + {/* ── Center card: mini diff terminal ──────────────────── */} +
+ {/* Title bar */} +
+
+ + + +
+ + DIFF + +
+ + {/* Diff lines */} +
+ {visibleLines.map((lineIdx, i) => { + const line = diffLines[lineIdx]; + const isNew = i === visibleLines.length - 1; + return ( +
+ {line.type} + {line.text} +
+ ); + })} + {/* Blinking cursor line */} +
+   + +
+
- {/* Scanning beam */} -
-
+ {/* Scanning beam (horizontal) */} +
+
- {/* Segmented progress */} -
- {[0, 1, 2, 3, 4].map((i) => ( + {/* ── Phase indicator ────────────────────────────────────── */} +
+ {phases.map((p, i) => (
-
))}
- {/* Rotating message */} -

- {messages[msgIndex]} -

+ {/* ── Bottom text ────────────────────────────────────────── */} +
+

+ + {phase.label} +

+

+ {fileCount} objects processed +

+
+ {/* ── Keyframes ──────────────────────────────────────────── */}
diff --git a/src/renderer/components/ui/MentionableTextarea.tsx b/src/renderer/components/ui/MentionableTextarea.tsx index 8ca88169..1056237d 100644 --- a/src/renderer/components/ui/MentionableTextarea.tsx +++ b/src/renderer/components/ui/MentionableTextarea.tsx @@ -324,6 +324,8 @@ interface MentionableTextareaProps extends Omit< taskSuggestions?: MentionSuggestion[]; /** Called when Enter (without Shift) is pressed. */ onModEnter?: () => void; + /** Ref that receives the dismiss callback to close mention dropdown from outside */ + dismissMentionsRef?: React.MutableRefObject<(() => void) | null>; } export const MentionableTextarea = React.forwardRef( @@ -344,6 +346,7 @@ export const MentionableTextarea = React.forwardRef { + if (dismissMentionsRef) dismissMentionsRef.current = dismiss; + }, [dismiss, dismissMentionsRef]); + // --- File suggestions --- const { suggestions: fileSuggestions, loading: filesLoading } = useFileSuggestions( enableFiles ? projectPath : null, @@ -818,6 +826,7 @@ export const MentionableTextarea = React.forwardRef void; openTeamTab: (teamName: string, projectPath?: string, taskId?: string) => void; clearKanbanFilter: () => void; - selectTeam: (teamName: string, opts?: { skipProjectAutoSelect?: boolean }) => Promise; + selectTeam: ( + teamName: string, + opts?: { skipProjectAutoSelect?: boolean; allowReloadWhileProvisioning?: boolean } + ) => Promise; refreshTeamData: (teamName: string) => Promise; sendTeamMessage: (teamName: string, request: SendMessageRequest) => Promise; crossTeamTargets: { @@ -596,6 +600,7 @@ export const createTeamSlice: StateCreator = (set, selectedTeamName: null, selectedTeamData: null, selectedTeamLoading: false, + selectedTeamLoadNonce: 0, selectedTeamError: null, sendingMessage: false, sendMessageError: null, @@ -875,11 +880,17 @@ export const createTeamSlice: StateCreator = (set, }, selectTeam: async (teamName: string, opts) => { + const allowReloadWhileProvisioning = opts?.allowReloadWhileProvisioning === true; // Guard: prevent duplicate in-flight fetches for the same team. // GlobalTaskDetailDialog + tab navigation can call selectTeam() in quick succession. - if (get().selectedTeamLoading && get().selectedTeamName === teamName) { + if ( + get().selectedTeamLoading && + get().selectedTeamName === teamName && + !allowReloadWhileProvisioning + ) { return; } + const requestNonce = get().selectedTeamLoadNonce + 1; const previousSelectedTeamName = get().selectedTeamName; const previousData = previousSelectedTeamName === teamName ? get().selectedTeamData : null; @@ -889,6 +900,7 @@ export const createTeamSlice: StateCreator = (set, set({ selectedTeamName: teamName, selectedTeamLoading: true, + selectedTeamLoadNonce: requestNonce, selectedTeamError: null, reviewActionError: null, }); @@ -900,7 +912,7 @@ export const createTeamSlice: StateCreator = (set, `team:getData(${teamName})` ); // Stale check: user may have switched to another team during the async call - if (get().selectedTeamName !== teamName) { + if (get().selectedTeamName !== teamName || get().selectedTeamLoadNonce !== requestNonce) { return; } // Eagerly patch teamByName with color/displayName from detailed data @@ -988,7 +1000,14 @@ export const createTeamSlice: StateCreator = (set, } catch (error) { // If provisioning is in progress for this team, stay in loading state; // file watcher / progress callback will refresh once config is written. - const isProvisioning = isTeamProvisioningActive(get(), teamName); + const currentState = get(); + if ( + currentState.selectedTeamName !== teamName || + currentState.selectedTeamLoadNonce !== requestNonce + ) { + return; + } + const isProvisioning = isTeamProvisioningActive(currentState, teamName); const msg = error instanceof Error ? error.message : String(error); // IPC can report provisioning state explicitly. @@ -1665,6 +1684,8 @@ export const createTeamSlice: StateCreator = (set, const currentRunId = get().currentProvisioningRunIdByTeam[progress.teamName]; const existingProgress = get().provisioningRuns[progress.runId]; + const becameConfigReady = + progress.configReady === true && existingProgress?.configReady !== true; const isDuplicateProgress = existingProgress?.updatedAt === progress.updatedAt && existingProgress?.state === progress.state && @@ -1731,6 +1752,13 @@ export const createTeamSlice: StateCreator = (set, const isCanonicalRun = get().currentProvisioningRunIdByTeam[progress.teamName] === progress.runId; + if (isCanonicalRun && becameConfigReady) { + const state = get(); + if (state.selectedTeamName === progress.teamName && state.selectedTeamData == null) { + void state.selectTeam(progress.teamName, { allowReloadWhileProvisioning: true }); + } + } + if (isCanonicalRun && TERMINAL_PROVISIONING_STATES.has(progress.state)) { // Clear spawn statuses — provisioning is complete, members now tracked via normal status set((prev) => { diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index af5c04ea..bf73edb4 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -564,6 +564,8 @@ export interface TeamProvisioningProgress { cliLogsTail?: string; /** Accumulated assistant text output during provisioning (for live preview). */ assistantOutput?: string; + /** True once provisioning has written a readable config.json for this team. */ + configReady?: boolean; } export interface TeamRuntimeState {