refactor: enhance task notifications and provisioning state management
- Updated task notification messages to include clearer formatting, emphasizing task status changes. - Enhanced TeamProvisioningService to include a new `configReady` state, improving tracking of provisioning progress. - Refactored the handling of team selection to prevent duplicate fetches during loading states, ensuring smoother user experience. - Improved UI components for better interaction and visibility of task and team states.
This commit is contained in:
parent
ccc3f55243
commit
ce28f725f9
9 changed files with 377 additions and 177 deletions
|
|
@ -838,7 +838,7 @@ export class TeamDataService {
|
||||||
|
|
||||||
// Skip inbox notification when lead starts their own task (solo teams)
|
// Skip inbox notification when lead starts their own task (solo teams)
|
||||||
if (!this.isLeadOwner(task.owner, leadName)) {
|
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()) {
|
if (task.description?.trim()) {
|
||||||
parts.push(`\nDetails:\n${task.description.trim()}`);
|
parts.push(`\nDetails:\n${task.description.trim()}`);
|
||||||
}
|
}
|
||||||
|
|
@ -908,7 +908,7 @@ export class TeamDataService {
|
||||||
await this.sendMessage(teamName, {
|
await this.sendMessage(teamName, {
|
||||||
member: leadName,
|
member: leadName,
|
||||||
from: last.actor,
|
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`,
|
summary: `Task ${this.getTaskLabel(task)} started`,
|
||||||
source: 'system_notification',
|
source: 'system_notification',
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1152,7 +1152,10 @@ function updateProgress(
|
||||||
run: ProvisioningRun,
|
run: ProvisioningRun,
|
||||||
state: Exclude<TeamProvisioningState, 'idle'>,
|
state: Exclude<TeamProvisioningState, 'idle'>,
|
||||||
message: string,
|
message: string,
|
||||||
extras?: Pick<TeamProvisioningProgress, 'pid' | 'error' | 'warnings' | 'cliLogsTail'>
|
extras?: Pick<
|
||||||
|
TeamProvisioningProgress,
|
||||||
|
'pid' | 'error' | 'warnings' | 'cliLogsTail' | 'configReady'
|
||||||
|
>
|
||||||
): TeamProvisioningProgress {
|
): TeamProvisioningProgress {
|
||||||
const assistantOutput =
|
const assistantOutput =
|
||||||
run.provisioningOutputParts.length > 0
|
run.provisioningOutputParts.length > 0
|
||||||
|
|
@ -1168,6 +1171,7 @@ function updateProgress(
|
||||||
warnings: extras?.warnings,
|
warnings: extras?.warnings,
|
||||||
cliLogsTail: extras?.cliLogsTail ?? run.progress.cliLogsTail,
|
cliLogsTail: extras?.cliLogsTail ?? run.progress.cliLogsTail,
|
||||||
assistantOutput,
|
assistantOutput,
|
||||||
|
configReady: extras?.configReady ?? run.progress.configReady,
|
||||||
};
|
};
|
||||||
return run.progress;
|
return run.progress;
|
||||||
}
|
}
|
||||||
|
|
@ -5471,7 +5475,8 @@ export class TeamProvisioningService {
|
||||||
const progress = updateProgress(
|
const progress = updateProgress(
|
||||||
run,
|
run,
|
||||||
'monitoring',
|
'monitoring',
|
||||||
'Team config created, waiting for members'
|
'Team config created, waiting for members',
|
||||||
|
{ configReady: true }
|
||||||
);
|
);
|
||||||
run.onProgress(progress);
|
run.onProgress(progress);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -932,10 +932,7 @@ const LeadThoughtsGroupRowComponent = ({
|
||||||
) : null}
|
) : null}
|
||||||
</article>
|
</article>
|
||||||
{isBodyVisible && !expanded && needsTruncation ? (
|
{isBodyVisible && !expanded && needsTruncation ? (
|
||||||
<div
|
<div className="pointer-events-none flex justify-center pt-2">
|
||||||
className="pointer-events-none flex justify-center pt-1"
|
|
||||||
style={{ transform: 'translateY(-20px)' }}
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="pointer-events-auto flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)] shadow-sm transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
|
className="pointer-events-auto flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)] shadow-sm transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
|
||||||
|
|
@ -950,10 +947,7 @@ const LeadThoughtsGroupRowComponent = ({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{isBodyVisible && expanded && needsTruncation ? (
|
{isBodyVisible && expanded && needsTruncation ? (
|
||||||
<div
|
<div className="pointer-events-none sticky bottom-0 z-10 flex justify-center pb-1 pt-2">
|
||||||
className="pointer-events-none sticky bottom-0 z-10 flex justify-center pb-1 pt-2"
|
|
||||||
style={{ transform: 'translateY(-20px)' }}
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="pointer-events-auto flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1 text-[11px] text-[var(--color-text-muted)] shadow-sm transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
|
className="pointer-events-auto flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1 text-[11px] text-[var(--color-text-muted)] shadow-sm transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
|
||||||
|
|
|
||||||
|
|
@ -474,7 +474,6 @@ export const KanbanTaskCard = ({
|
||||||
onRequestChanges(task.id);
|
onRequestChanges(task.id);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{isReviewManual ? metaActions : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -492,9 +491,7 @@ export const KanbanTaskCard = ({
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isReviewManual ? (
|
<div className="flex shrink-0 flex-nowrap items-center gap-1.5">{metaActions}</div>
|
||||||
<div className="flex shrink-0 flex-nowrap items-center gap-1.5">{metaActions}</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ export const MessageComposer = ({
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [imageRestrictionError, setImageRestrictionError] = useState<string | null>(null);
|
const [imageRestrictionError, setImageRestrictionError] = useState<string | null>(null);
|
||||||
const imageRestrictionTimerRef = useRef(0);
|
const imageRestrictionTimerRef = useRef(0);
|
||||||
|
const dismissMentionsRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
// Cross-team state
|
// Cross-team state
|
||||||
const [selectedTeam, setSelectedTeam] = useState<string | null>(null);
|
const [selectedTeam, setSelectedTeam] = useState<string | null>(null);
|
||||||
|
|
@ -246,6 +247,7 @@ export const MessageComposer = ({
|
||||||
|
|
||||||
const handleSend = useCallback(() => {
|
const handleSend = useCallback(() => {
|
||||||
if (!canSend) return;
|
if (!canSend) return;
|
||||||
|
dismissMentionsRef.current?.();
|
||||||
pendingSendRef.current = true;
|
pendingSendRef.current = true;
|
||||||
const taskRefs = extractTaskRefsFromText(draft.text, taskSuggestions);
|
const taskRefs = extractTaskRefsFromText(draft.text, taskSuggestions);
|
||||||
const serialized = serializeChipsWithText(trimmed, draft.chips);
|
const serialized = serializeChipsWithText(trimmed, draft.chips);
|
||||||
|
|
@ -833,6 +835,7 @@ export const MessageComposer = ({
|
||||||
projectPath={projectPath}
|
projectPath={projectPath}
|
||||||
onFileChipInsert={draft.addChip}
|
onFileChipInsert={draft.addChip}
|
||||||
onModEnter={handleSend}
|
onModEnter={handleSend}
|
||||||
|
dismissMentionsRef={dismissMentionsRef}
|
||||||
minRows={2}
|
minRows={2}
|
||||||
maxRows={6}
|
maxRows={6}
|
||||||
maxLength={MAX_TEXT_LENGTH}
|
maxLength={MAX_TEXT_LENGTH}
|
||||||
|
|
|
||||||
|
|
@ -1,106 +1,196 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { FileCode, FileDiff, FileText, GitBranch, GitCommit, RefreshCw } from 'lucide-react';
|
import { Check, FileCode, FileDiff, FileText, GitBranch, GitCommit, Search } from 'lucide-react';
|
||||||
|
|
||||||
const orbitIcons = [
|
/* ── Fake diff lines for the mini-terminal ─────────────────────────── */
|
||||||
{ Icon: FileText, orbitRadius: 52, speed: 12, startAngle: 0, size: 15 },
|
const diffLines = [
|
||||||
{ Icon: FileDiff, orbitRadius: 52, speed: 12, startAngle: 72, size: 14 },
|
{ type: '+', text: 'export function resolveHunk(ctx)' },
|
||||||
{ Icon: FileCode, orbitRadius: 52, speed: 12, startAngle: 144, size: 15 },
|
{ type: '-', text: 'const legacy = parseDiff(raw)' },
|
||||||
{ Icon: GitCommit, orbitRadius: 52, speed: 12, startAngle: 216, size: 13 },
|
{ type: ' ', text: ' const chunks = split(input)' },
|
||||||
{ Icon: GitBranch, orbitRadius: 52, speed: 12, startAngle: 288, size: 14 },
|
{ 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) => ({
|
/* ── Phases ─────────────────────────────────────────────────────────── */
|
||||||
id: i,
|
const phases = [
|
||||||
delay: i * 0.6,
|
{ icon: Search, label: 'Scanning repository…', accent: 'rgba(147,197,253,0.7)' },
|
||||||
duration: 3 + (i % 3) * 0.8,
|
{ icon: FileDiff, label: 'Computing diffs…', accent: 'rgba(253,186,116,0.7)' },
|
||||||
startAngle: i * 45,
|
{ icon: GitBranch, label: 'Resolving branches…', accent: 'rgba(167,139,250,0.7)' },
|
||||||
radius: 34 + (i % 3) * 14,
|
{ 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 => {
|
/* ── Spark particles ────────────────────────────────────────────────── */
|
||||||
const [msgIndex, setMsgIndex] = useState(0);
|
const SPARK_COUNT = 12;
|
||||||
const [isFading, setIsFading] = useState(false);
|
|
||||||
|
const useSparks = () => {
|
||||||
|
const [sparks, setSparks] = useState<{ id: number; x: number; y: number; size: number }[]>([]);
|
||||||
|
const nextId = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setIsFading(true);
|
const angle = Math.random() * Math.PI * 2;
|
||||||
setTimeout(() => {
|
const dist = 30 + Math.random() * 50;
|
||||||
setMsgIndex((prev) => (prev + 1) % messages.length);
|
setSparks((prev) => {
|
||||||
setIsFading(false);
|
const next = [
|
||||||
}, 300);
|
...prev.slice(-(SPARK_COUNT - 1)),
|
||||||
}, 2400);
|
{
|
||||||
|
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 () => 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<number[]>([]);
|
||||||
|
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 (
|
return (
|
||||||
<div className="flex w-full flex-col items-center justify-center gap-5">
|
<div className="flex w-full flex-col items-center justify-center gap-4">
|
||||||
{/* Main animation container */}
|
{/* ── Main scene ─────────────────────────────────────────── */}
|
||||||
<div className="relative flex size-36 items-center justify-center">
|
<div className="relative flex h-44 w-64 items-center justify-center">
|
||||||
{/* Outer rotating ring */}
|
{/* Faint radial grid */}
|
||||||
<svg className="changes-orbit-ring absolute size-36" viewBox="0 0 144 144">
|
<svg className="pointer-events-none absolute inset-0 opacity-[0.04]" viewBox="0 0 256 176">
|
||||||
<circle
|
{[40, 60, 80].map((r) => (
|
||||||
cx="72"
|
<circle
|
||||||
cy="72"
|
key={r}
|
||||||
r="52"
|
cx="128"
|
||||||
fill="none"
|
cy="88"
|
||||||
stroke="var(--color-border-emphasis)"
|
r={r}
|
||||||
strokeWidth="1"
|
fill="none"
|
||||||
strokeDasharray="6 8"
|
stroke="var(--color-text)"
|
||||||
opacity="0.5"
|
strokeWidth="0.5"
|
||||||
/>
|
/>
|
||||||
|
))}
|
||||||
|
{[0, 45, 90, 135].map((deg) => (
|
||||||
|
<line
|
||||||
|
key={deg}
|
||||||
|
x1="128"
|
||||||
|
y1="88"
|
||||||
|
x2={128 + Math.cos((deg * Math.PI) / 180) * 80}
|
||||||
|
y2={88 + Math.sin((deg * Math.PI) / 180) * 80}
|
||||||
|
stroke="var(--color-text)"
|
||||||
|
strokeWidth="0.3"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{/* Inner rotating ring (counter) */}
|
{/* Rotating dashed orbit ring */}
|
||||||
<svg className="changes-orbit-ring-reverse absolute size-28" viewBox="0 0 112 112">
|
<svg
|
||||||
|
className="clda-orbit-ring pointer-events-none absolute h-40 w-40"
|
||||||
|
viewBox="0 0 160 160"
|
||||||
|
>
|
||||||
<circle
|
<circle
|
||||||
cx="56"
|
cx="80"
|
||||||
cy="56"
|
cy="80"
|
||||||
r="34"
|
r="76"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="var(--color-border-emphasis)"
|
stroke="var(--color-border-emphasis)"
|
||||||
strokeWidth="0.5"
|
strokeWidth="0.8"
|
||||||
strokeDasharray="3 6"
|
strokeDasharray="4 7"
|
||||||
opacity="0.3"
|
opacity="0.4"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{/* Particles on inner orbit */}
|
|
||||||
{particles.map((p) => (
|
|
||||||
<div
|
|
||||||
key={p.id}
|
|
||||||
className="absolute left-1/2 top-1/2 size-1 rounded-full bg-[var(--color-text-muted)]"
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
animation: `changesParticle ${p.duration}s linear infinite ${p.delay}s`,
|
|
||||||
'--particle-radius': `${p.radius}px`,
|
|
||||||
'--particle-start': `${p.startAngle}deg`,
|
|
||||||
opacity: 0,
|
|
||||||
} as React.CSSProperties
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Orbiting icons */}
|
{/* Orbiting icons */}
|
||||||
{orbitIcons.map(({ Icon, orbitRadius, speed, startAngle, size }, i) => (
|
{orbitItems.map(({ Icon, angle, r, size, speed }, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="absolute left-1/2 top-1/2"
|
className="absolute left-1/2 top-1/2"
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
animation: `changesOrbit ${speed}s linear infinite`,
|
animation: `cldaOrbit ${speed}s linear infinite`,
|
||||||
'--orbit-radius': `${orbitRadius}px`,
|
'--o-r': `${r}px`,
|
||||||
'--start-angle': `${startAngle}deg`,
|
'--o-a': `${angle}deg`,
|
||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="changes-orbit-icon-inner text-[var(--color-text-muted)]"
|
className="text-[var(--color-text-muted)] opacity-40"
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
animation: `changesOrbitCounter ${speed}s linear infinite`,
|
animation: `cldaOrbitCounter ${speed}s linear infinite`,
|
||||||
'--start-angle': `${startAngle}deg`,
|
'--o-a': `${angle}deg`,
|
||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -109,135 +199,207 @@ export const ChangesLoadingAnimation = (): React.JSX.Element => {
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Glow pulse behind center */}
|
{/* Spark particles */}
|
||||||
<div className="changes-glow-pulse absolute size-14 rounded-2xl bg-[var(--color-text-muted)] opacity-[0.06]" />
|
{sparks.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
className="clda-spark absolute left-1/2 top-1/2 rounded-full"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
width: s.size,
|
||||||
|
height: s.size,
|
||||||
|
background: phase.accent,
|
||||||
|
'--sx': `${s.x}px`,
|
||||||
|
'--sy': `${s.y}px`,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Glow behind center */}
|
||||||
<div
|
<div
|
||||||
className="absolute size-16 rounded-2xl bg-[var(--color-text-muted)] opacity-[0.03]"
|
className="clda-glow absolute size-20 rounded-3xl"
|
||||||
style={{ animation: 'changesGlowPulse 3s ease-in-out infinite 0.5s' }}
|
style={{ background: phase.accent, opacity: 0.05 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Center icon block */}
|
{/* ── Center card: mini diff terminal ──────────────────── */}
|
||||||
<div className="changes-center-morph relative z-10 flex size-11 items-center justify-center rounded-xl border border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)]">
|
<div className="clda-center-card relative z-10 flex w-44 flex-col overflow-hidden rounded-xl border border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] shadow-2xl">
|
||||||
<RefreshCw size={20} className="changes-center-spin text-[var(--color-text-secondary)]" />
|
{/* Title bar */}
|
||||||
|
<div className="flex items-center gap-1.5 border-b border-[var(--color-border)] px-2.5 py-1.5">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<span className="block size-1.5 rounded-full bg-red-500/50" />
|
||||||
|
<span className="block size-1.5 rounded-full bg-yellow-500/50" />
|
||||||
|
<span className="block size-1.5 rounded-full bg-green-500/50" />
|
||||||
|
</div>
|
||||||
|
<span className="ml-1 text-[8px] font-medium tracking-wider text-[var(--color-text-muted)] opacity-60">
|
||||||
|
DIFF
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Diff lines */}
|
||||||
|
<div className="flex flex-col gap-px px-2 py-1.5 font-mono text-[9px] leading-[14px]">
|
||||||
|
{visibleLines.map((lineIdx, i) => {
|
||||||
|
const line = diffLines[lineIdx];
|
||||||
|
const isNew = i === visibleLines.length - 1;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${lineIdx}-${i}`}
|
||||||
|
className={`flex gap-1.5 rounded-sm px-1 ${isNew ? 'clda-line-in' : ''} ${
|
||||||
|
line.type === '+'
|
||||||
|
? 'bg-emerald-500/8 text-emerald-400/80'
|
||||||
|
: line.type === '-'
|
||||||
|
? 'bg-red-500/8 text-red-400/80'
|
||||||
|
: 'text-[var(--color-text-muted)] opacity-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="w-2 shrink-0 select-none opacity-60">{line.type}</span>
|
||||||
|
<span className="truncate">{line.text}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* Blinking cursor line */}
|
||||||
|
<div className="flex items-center gap-1.5 px-1 text-[var(--color-text-muted)] opacity-30">
|
||||||
|
<span className="w-2 shrink-0"> </span>
|
||||||
|
<span className="clda-cursor inline-block h-2.5 w-px bg-[var(--color-text-secondary)]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scanning beam */}
|
{/* Scanning beam (horizontal) */}
|
||||||
<div className="absolute inset-0 overflow-hidden rounded-full">
|
<div className="pointer-events-none absolute inset-0 overflow-hidden rounded-xl">
|
||||||
<div className="changes-scan-beam absolute left-0 top-1/2 h-px w-full bg-gradient-to-r from-transparent via-[var(--color-text-muted)] to-transparent opacity-20" />
|
<div
|
||||||
|
className="clda-scan-h absolute left-0 h-px w-full"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(to right, transparent, ${phase.accent}, transparent)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Segmented progress */}
|
{/* ── Phase indicator ────────────────────────────────────── */}
|
||||||
<div className="flex gap-1">
|
<div className="flex items-center gap-3">
|
||||||
{[0, 1, 2, 3, 4].map((i) => (
|
{phases.map((p, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="h-0.5 w-8 overflow-hidden rounded-full bg-[var(--color-surface-raised)]"
|
className={`flex size-6 items-center justify-center rounded-full border transition-all duration-500 ${
|
||||||
|
i === phaseIdx
|
||||||
|
? 'scale-110 border-[var(--color-text-secondary)] bg-[var(--color-surface-raised)]'
|
||||||
|
: i < phaseIdx
|
||||||
|
? 'border-transparent bg-[var(--color-text-muted)] opacity-20'
|
||||||
|
: 'border-[var(--color-border)] opacity-20'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<p.icon
|
||||||
className="h-full rounded-full bg-[var(--color-text-muted)]"
|
size={12}
|
||||||
style={{
|
strokeWidth={1.5}
|
||||||
animation: `changesSegment 2.5s ease-in-out infinite ${i * 0.3}s`,
|
className={
|
||||||
}}
|
i === phaseIdx ? 'text-[var(--color-text)]' : 'text-[var(--color-text-muted)]'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rotating message */}
|
{/* ── Bottom text ────────────────────────────────────────── */}
|
||||||
<p
|
<div className="flex flex-col items-center gap-1">
|
||||||
className="h-4 text-xs font-medium tracking-wide text-[var(--color-text-muted)] transition-opacity duration-300"
|
<p
|
||||||
style={{ opacity: isFading ? 0 : 0.8 }}
|
className="duration-400 text-xs font-medium tracking-wide text-[var(--color-text-secondary)] transition-all"
|
||||||
>
|
style={{
|
||||||
{messages[msgIndex]}
|
opacity: phaseFading ? 0 : 1,
|
||||||
</p>
|
transform: phaseFading ? 'translateY(4px)' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PhaseIcon size={12} className="mr-1.5 inline-block align-[-2px] opacity-60" />
|
||||||
|
{phase.label}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] tabular-nums text-[var(--color-text-muted)] opacity-50">
|
||||||
|
{fileCount} objects processed
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Keyframes ──────────────────────────────────────────── */}
|
||||||
<style>{`
|
<style>{`
|
||||||
.changes-orbit-ring {
|
.clda-orbit-ring {
|
||||||
animation: changesRingSpin 20s linear infinite;
|
animation: cldaRingSpin 25s linear infinite;
|
||||||
}
|
}
|
||||||
.changes-orbit-ring-reverse {
|
.clda-glow {
|
||||||
animation: changesRingSpin 15s linear infinite reverse;
|
animation: cldaGlow 3s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
.changes-glow-pulse {
|
.clda-center-card {
|
||||||
animation: changesGlowPulse 3s ease-in-out infinite;
|
animation: cldaCardFloat 5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
.changes-center-morph {
|
.clda-cursor {
|
||||||
animation: changesCenterMorph 4s ease-in-out infinite;
|
animation: cldaBlink 1s step-end infinite;
|
||||||
}
|
|
||||||
.changes-center-spin {
|
|
||||||
animation: changesCenterSpin 3s linear infinite;
|
|
||||||
}
|
|
||||||
.changes-scan-beam {
|
|
||||||
animation: changesScan 3s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
.changes-orbit-icon-inner {
|
|
||||||
opacity: 0.45;
|
|
||||||
transition: opacity 0.3s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes changesRingSpin {
|
.clda-spark {
|
||||||
from { transform: rotate(0deg); }
|
animation: cldaSparkLife 1.8s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clda-scan-h {
|
||||||
|
animation: cldaScanH 4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clda-line-in {
|
||||||
|
animation: cldaLineIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cldaRingSpin {
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes changesOrbit {
|
@keyframes cldaOrbit {
|
||||||
from {
|
from {
|
||||||
transform: translate(-50%, -50%) rotate(var(--start-angle)) translateX(var(--orbit-radius)) rotate(calc(-1 * var(--start-angle)));
|
transform: translate(-50%,-50%) rotate(var(--o-a)) translateX(var(--o-r)) rotate(calc(-1 * var(--o-a)));
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
transform: translate(-50%, -50%) rotate(calc(var(--start-angle) + 360deg)) translateX(var(--orbit-radius)) rotate(calc(-1 * var(--start-angle) - 360deg));
|
transform: translate(-50%,-50%) rotate(calc(var(--o-a) + 360deg)) translateX(var(--o-r)) rotate(calc(-1 * var(--o-a) - 360deg));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes changesOrbitCounter {
|
@keyframes cldaOrbitCounter {
|
||||||
from { transform: rotate(var(--start-angle)); }
|
from { transform: rotate(var(--o-a)); }
|
||||||
to { transform: rotate(calc(var(--start-angle) + 360deg)); }
|
to { transform: rotate(calc(var(--o-a) + 360deg)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes changesParticle {
|
@keyframes cldaGlow {
|
||||||
|
0%, 100% { transform: scale(1); opacity: 0.05; }
|
||||||
|
50% { transform: scale(1.3); opacity: 0.1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cldaCardFloat {
|
||||||
|
0%, 100% { transform: translateY(0px); }
|
||||||
|
30% { transform: translateY(-3px); }
|
||||||
|
70% { transform: translateY(2px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cldaBlink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cldaSparkLife {
|
||||||
0% {
|
0% {
|
||||||
transform: translate(-50%, -50%) rotate(var(--particle-start)) translateX(var(--particle-radius));
|
transform: translate(-50%,-50%) translate(0, 0) scale(1);
|
||||||
opacity: 0;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
15% { opacity: 0.6; }
|
|
||||||
85% { opacity: 0.6; }
|
|
||||||
100% {
|
100% {
|
||||||
transform: translate(-50%, -50%) rotate(calc(var(--particle-start) + 360deg)) translateX(var(--particle-radius));
|
transform: translate(-50%,-50%) translate(var(--sx), var(--sy)) scale(0);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes changesGlowPulse {
|
@keyframes cldaScanH {
|
||||||
0%, 100% { transform: scale(1); opacity: 0.06; }
|
0% { top: -2px; opacity: 0; }
|
||||||
50% { transform: scale(1.2); opacity: 0.1; }
|
10% { opacity: 0.5; }
|
||||||
|
50% { top: 100%; opacity: 0.3; }
|
||||||
|
90% { opacity: 0; }
|
||||||
|
100% { top: 100%; opacity: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes changesCenterMorph {
|
@keyframes cldaLineIn {
|
||||||
0%, 100% { transform: scale(1); border-radius: 12px; }
|
from { opacity: 0; transform: translateX(-8px); }
|
||||||
25% { transform: scale(1.05); border-radius: 14px; }
|
to { opacity: 1; transform: translateX(0); }
|
||||||
50% { transform: scale(1); border-radius: 16px; }
|
|
||||||
75% { transform: scale(1.05); border-radius: 12px; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes changesCenterSpin {
|
|
||||||
from { transform: rotate(0deg); }
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes changesScan {
|
|
||||||
0% { transform: translateY(-70px) rotate(0deg); opacity: 0; }
|
|
||||||
10% { opacity: 0.2; }
|
|
||||||
50% { transform: translateY(0px) rotate(3deg); opacity: 0.3; }
|
|
||||||
90% { opacity: 0.2; }
|
|
||||||
100% { transform: translateY(70px) rotate(-3deg); opacity: 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes changesSegment {
|
|
||||||
0%, 100% { width: 0%; opacity: 0.3; }
|
|
||||||
40% { width: 100%; opacity: 1; }
|
|
||||||
60% { width: 100%; opacity: 1; }
|
|
||||||
80% { width: 0%; opacity: 0.3; }
|
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -324,6 +324,8 @@ interface MentionableTextareaProps extends Omit<
|
||||||
taskSuggestions?: MentionSuggestion[];
|
taskSuggestions?: MentionSuggestion[];
|
||||||
/** Called when Enter (without Shift) is pressed. */
|
/** Called when Enter (without Shift) is pressed. */
|
||||||
onModEnter?: () => void;
|
onModEnter?: () => void;
|
||||||
|
/** Ref that receives the dismiss callback to close mention dropdown from outside */
|
||||||
|
dismissMentionsRef?: React.MutableRefObject<(() => void) | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, MentionableTextareaProps>(
|
export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, MentionableTextareaProps>(
|
||||||
|
|
@ -344,6 +346,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
||||||
teamSuggestions = [],
|
teamSuggestions = [],
|
||||||
taskSuggestions = [],
|
taskSuggestions = [],
|
||||||
onModEnter,
|
onModEnter,
|
||||||
|
dismissMentionsRef,
|
||||||
style,
|
style,
|
||||||
className,
|
className,
|
||||||
...textareaProps
|
...textareaProps
|
||||||
|
|
@ -396,6 +399,11 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Expose dismiss to parent via ref for external close (e.g. Send button click)
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (dismissMentionsRef) dismissMentionsRef.current = dismiss;
|
||||||
|
}, [dismiss, dismissMentionsRef]);
|
||||||
|
|
||||||
// --- File suggestions ---
|
// --- File suggestions ---
|
||||||
const { suggestions: fileSuggestions, loading: filesLoading } = useFileSuggestions(
|
const { suggestions: fileSuggestions, loading: filesLoading } = useFileSuggestions(
|
||||||
enableFiles ? projectPath : null,
|
enableFiles ? projectPath : null,
|
||||||
|
|
@ -818,6 +826,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
||||||
// Enter (without Shift) → submit; Shift+Enter → newline
|
// Enter (without Shift) → submit; Shift+Enter → newline
|
||||||
if (e.key === 'Enter' && !e.shiftKey && onModEnter) {
|
if (e.key === 'Enter' && !e.shiftKey && onModEnter) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
dismiss();
|
||||||
onModEnter();
|
onModEnter();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -346,6 +346,7 @@ export interface TeamSlice {
|
||||||
selectedTeamName: string | null;
|
selectedTeamName: string | null;
|
||||||
selectedTeamData: TeamData | null;
|
selectedTeamData: TeamData | null;
|
||||||
selectedTeamLoading: boolean;
|
selectedTeamLoading: boolean;
|
||||||
|
selectedTeamLoadNonce: number;
|
||||||
selectedTeamError: string | null;
|
selectedTeamError: string | null;
|
||||||
sendingMessage: boolean;
|
sendingMessage: boolean;
|
||||||
sendMessageError: string | null;
|
sendMessageError: string | null;
|
||||||
|
|
@ -378,7 +379,10 @@ export interface TeamSlice {
|
||||||
openTeamsTab: () => void;
|
openTeamsTab: () => void;
|
||||||
openTeamTab: (teamName: string, projectPath?: string, taskId?: string) => void;
|
openTeamTab: (teamName: string, projectPath?: string, taskId?: string) => void;
|
||||||
clearKanbanFilter: () => void;
|
clearKanbanFilter: () => void;
|
||||||
selectTeam: (teamName: string, opts?: { skipProjectAutoSelect?: boolean }) => Promise<void>;
|
selectTeam: (
|
||||||
|
teamName: string,
|
||||||
|
opts?: { skipProjectAutoSelect?: boolean; allowReloadWhileProvisioning?: boolean }
|
||||||
|
) => Promise<void>;
|
||||||
refreshTeamData: (teamName: string) => Promise<void>;
|
refreshTeamData: (teamName: string) => Promise<void>;
|
||||||
sendTeamMessage: (teamName: string, request: SendMessageRequest) => Promise<void>;
|
sendTeamMessage: (teamName: string, request: SendMessageRequest) => Promise<void>;
|
||||||
crossTeamTargets: {
|
crossTeamTargets: {
|
||||||
|
|
@ -596,6 +600,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
selectedTeamName: null,
|
selectedTeamName: null,
|
||||||
selectedTeamData: null,
|
selectedTeamData: null,
|
||||||
selectedTeamLoading: false,
|
selectedTeamLoading: false,
|
||||||
|
selectedTeamLoadNonce: 0,
|
||||||
selectedTeamError: null,
|
selectedTeamError: null,
|
||||||
sendingMessage: false,
|
sendingMessage: false,
|
||||||
sendMessageError: null,
|
sendMessageError: null,
|
||||||
|
|
@ -875,11 +880,17 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
},
|
},
|
||||||
|
|
||||||
selectTeam: async (teamName: string, opts) => {
|
selectTeam: async (teamName: string, opts) => {
|
||||||
|
const allowReloadWhileProvisioning = opts?.allowReloadWhileProvisioning === true;
|
||||||
// Guard: prevent duplicate in-flight fetches for the same team.
|
// Guard: prevent duplicate in-flight fetches for the same team.
|
||||||
// GlobalTaskDetailDialog + tab navigation can call selectTeam() in quick succession.
|
// GlobalTaskDetailDialog + tab navigation can call selectTeam() in quick succession.
|
||||||
if (get().selectedTeamLoading && get().selectedTeamName === teamName) {
|
if (
|
||||||
|
get().selectedTeamLoading &&
|
||||||
|
get().selectedTeamName === teamName &&
|
||||||
|
!allowReloadWhileProvisioning
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const requestNonce = get().selectedTeamLoadNonce + 1;
|
||||||
const previousSelectedTeamName = get().selectedTeamName;
|
const previousSelectedTeamName = get().selectedTeamName;
|
||||||
const previousData = previousSelectedTeamName === teamName ? get().selectedTeamData : null;
|
const previousData = previousSelectedTeamName === teamName ? get().selectedTeamData : null;
|
||||||
|
|
||||||
|
|
@ -889,6 +900,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
set({
|
set({
|
||||||
selectedTeamName: teamName,
|
selectedTeamName: teamName,
|
||||||
selectedTeamLoading: true,
|
selectedTeamLoading: true,
|
||||||
|
selectedTeamLoadNonce: requestNonce,
|
||||||
selectedTeamError: null,
|
selectedTeamError: null,
|
||||||
reviewActionError: null,
|
reviewActionError: null,
|
||||||
});
|
});
|
||||||
|
|
@ -900,7 +912,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
`team:getData(${teamName})`
|
`team:getData(${teamName})`
|
||||||
);
|
);
|
||||||
// Stale check: user may have switched to another team during the async call
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
// Eagerly patch teamByName with color/displayName from detailed data
|
// Eagerly patch teamByName with color/displayName from detailed data
|
||||||
|
|
@ -988,7 +1000,14 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If provisioning is in progress for this team, stay in loading state;
|
// If provisioning is in progress for this team, stay in loading state;
|
||||||
// file watcher / progress callback will refresh once config is written.
|
// 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);
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
// IPC can report provisioning state explicitly.
|
// IPC can report provisioning state explicitly.
|
||||||
|
|
@ -1665,6 +1684,8 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
|
|
||||||
const currentRunId = get().currentProvisioningRunIdByTeam[progress.teamName];
|
const currentRunId = get().currentProvisioningRunIdByTeam[progress.teamName];
|
||||||
const existingProgress = get().provisioningRuns[progress.runId];
|
const existingProgress = get().provisioningRuns[progress.runId];
|
||||||
|
const becameConfigReady =
|
||||||
|
progress.configReady === true && existingProgress?.configReady !== true;
|
||||||
const isDuplicateProgress =
|
const isDuplicateProgress =
|
||||||
existingProgress?.updatedAt === progress.updatedAt &&
|
existingProgress?.updatedAt === progress.updatedAt &&
|
||||||
existingProgress?.state === progress.state &&
|
existingProgress?.state === progress.state &&
|
||||||
|
|
@ -1731,6 +1752,13 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
const isCanonicalRun =
|
const isCanonicalRun =
|
||||||
get().currentProvisioningRunIdByTeam[progress.teamName] === progress.runId;
|
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)) {
|
if (isCanonicalRun && TERMINAL_PROVISIONING_STATES.has(progress.state)) {
|
||||||
// Clear spawn statuses — provisioning is complete, members now tracked via normal status
|
// Clear spawn statuses — provisioning is complete, members now tracked via normal status
|
||||||
set((prev) => {
|
set((prev) => {
|
||||||
|
|
|
||||||
|
|
@ -564,6 +564,8 @@ export interface TeamProvisioningProgress {
|
||||||
cliLogsTail?: string;
|
cliLogsTail?: string;
|
||||||
/** Accumulated assistant text output during provisioning (for live preview). */
|
/** Accumulated assistant text output during provisioning (for live preview). */
|
||||||
assistantOutput?: string;
|
assistantOutput?: string;
|
||||||
|
/** True once provisioning has written a readable config.json for this team. */
|
||||||
|
configReady?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamRuntimeState {
|
export interface TeamRuntimeState {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue