Laboratório ao vivo "agentes-swimlane" do curso Frontends com Vibecoding (capítulo bônus). Código-fonte completo, copiado de src/components/course/blocks/live-labs/ por scripts/export-fvc-labs-txt.mjs. Licença de uso: a mesma do repositório do site. Cada arquivo abaixo começa com uma linha '==== nome ===='. ==== AgentesSwimlaneLab.tsx ==== "use client"; import { useEffect, useId, useMemo, useRef, useState } from "react"; import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/react"; import type { LiveLabMeta, LiveLabProps } from "./registry"; import styles from "./AgentesSwimlane.module.css"; /** * Laboratório "agentes-swimlane": vários agentes trabalhando em paralelo e * trocando mensagens, no padrão de raias (swimlane) que os painéis de traces * usam. O pacote motion (importado de "motion/react") anima os cartões de * mensagem voando de uma raia para a outra; as barras de execução crescem com * o relógio do modelo. * * O roteiro é determinístico e fictício (cenário ilustrativo): quatro raias, * 60 s de tempo do modelo, 1 s do modelo = 60 ms de animação. Ele carrega os * dois pontos didáticos do capítulo: a resposta sai no tempo da raia mais * lenta, e não na soma das barras; e a mensagem de volta da Revisão para a * Redação é o harness em ação, uma reprovação que refaz antes de entregar. * * Regras da casa: cor só por token do tema, alvo de toque de 44 px, foco * visível, uma única região role="status", linha do tempo que rola dentro do * próprio contêiner no celular. Com movimento reduzido ativo, nada reproduz * sozinho: o controle deslizante percorre o tempo e os estados trocam sem voo. */ type RaiaId = "principal" | "pesquisa" | "redator" | "revisor"; type Tom = "executando" | "refazendo" | "montando"; interface Raia { id: RaiaId; nome: string; pastaIsolada: boolean; } interface Barra { raia: RaiaId; inicio: number; fim: number; rotulo: string; tom: Tom; } interface Mensagem { id: string; t: number; de: RaiaId; para: RaiaId; texto: string; reprovacao?: boolean; } const TEMPO_MAX = 60; const MS_POR_SEGUNDO = 60; const PX_POR_SEGUNDO = 14; const ALTURA_RAIA = 72; const LARGURA_ROTULOS = 118; const DURACAO_MENSAGEM = 4; const RAIAS: Raia[] = [ { id: "principal", nome: "Agente principal", pastaIsolada: false }, { id: "pesquisa", nome: "Pesquisa", pastaIsolada: true }, { id: "redator", nome: "Redação", pastaIsolada: true }, { id: "revisor", nome: "Revisão", pastaIsolada: false }, ]; /* Cenário ilustrativo: nenhum destes tempos vem de medição. */ const BARRAS: Barra[] = [ { raia: "principal", inicio: 0, fim: 3, rotulo: "parte o pedido", tom: "executando" }, { raia: "pesquisa", inicio: 3, fim: 28, rotulo: "pesquisa fontes", tom: "executando" }, { raia: "redator", inicio: 3, fim: 20, rotulo: "redige", tom: "executando" }, { raia: "revisor", inicio: 29, fim: 45, rotulo: "confere as entregas", tom: "executando" }, { raia: "redator", inicio: 38, fim: 44, rotulo: "refaz o trecho", tom: "refazendo" }, { raia: "revisor", inicio: 45, fim: 52, rotulo: "aprova", tom: "executando" }, { raia: "principal", inicio: 52, fim: 58, rotulo: "monta a resposta", tom: "montando" }, ]; const MENSAGENS: Mensagem[] = [ { id: "m1", t: 0.5, de: "principal", para: "pesquisa", texto: "tarefa: levantar fontes" }, { id: "m2", t: 0.5, de: "principal", para: "redator", texto: "tarefa: redigir o texto" }, { id: "m3", t: 12, de: "redator", para: "principal", texto: "pede um dado" }, { id: "m4", t: 14, de: "principal", para: "redator", texto: "responde o dado" }, { id: "m5", t: 20, de: "redator", para: "revisor", texto: "entrega o texto" }, { id: "m6", t: 28, de: "pesquisa", para: "revisor", texto: "entrega as fontes" }, { id: "m7", t: 38, de: "revisor", para: "redator", texto: "reprovado: refazer um trecho", reprovacao: true }, { id: "m8", t: 44, de: "redator", para: "revisor", texto: "trecho refeito" }, { id: "m9", t: 52, de: "revisor", para: "principal", texto: "aprovado" }, ]; const SOMA_BARRAS = BARRAS.reduce((soma, b) => soma + (b.fim - b.inicio), 0); const FIM_DA_RESPOSTA = Math.max(...BARRAS.map((b) => b.fim)); const TICKS = [0, 10, 20, 30, 40, 50, 60]; const indiceDaRaia = (id: RaiaId) => RAIAS.findIndex((r) => r.id === id); const centroDaRaia = (id: RaiaId) => indiceDaRaia(id) * ALTURA_RAIA + ALTURA_RAIA / 2; const formatarSegundos = (s: number) => `${Math.round(s)} s`; export default function AgentesSwimlaneLab(_props: LiveLabProps) { const uid = useId(); const reduzido = useReducedMotion() === true; const [tempo, setTempo] = useState(0); const [rodando, setRodando] = useState(false); const [mostrarMensagens, setMostrarMensagens] = useState(true); const trilho = useRef(null); const quadro = useRef(null); const marcaAnterior = useRef(null); /* Relógio por requestAnimationFrame, limpo no unmount e ao pausar. */ useEffect(() => { if (!rodando) { marcaAnterior.current = null; return; } const passo = (agora: number) => { if (marcaAnterior.current === null) marcaAnterior.current = agora; const delta = (agora - marcaAnterior.current) / MS_POR_SEGUNDO; marcaAnterior.current = agora; setTempo((t) => { const seguinte = t + delta; if (seguinte >= TEMPO_MAX) { setRodando(false); return TEMPO_MAX; } return seguinte; }); quadro.current = window.requestAnimationFrame(passo); }; quadro.current = window.requestAnimationFrame(passo); return () => { if (quadro.current !== null) window.cancelAnimationFrame(quadro.current); quadro.current = null; }; }, [rodando]); /* Mantém o cursor do tempo à vista enquanto reproduz, sem roubar a rolagem da página. */ useEffect(() => { if (!rodando || !trilho.current) return; const alvo = LARGURA_ROTULOS + tempo * PX_POR_SEGUNDO - trilho.current.clientWidth / 2; if (alvo > trilho.current.scrollLeft + 40 || alvo < trilho.current.scrollLeft - 40) { trilho.current.scrollLeft = Math.max(0, alvo); } }, [tempo, rodando]); const ativas = useMemo( () => RAIAS.filter((r) => BARRAS.some((b) => b.raia === r.id && tempo >= b.inicio && tempo < b.fim)), [tempo], ); const trocadas = useMemo(() => MENSAGENS.filter((m) => tempo >= m.t).length, [tempo]); const visiveis = useMemo( () => (mostrarMensagens ? MENSAGENS.filter((m) => tempo >= m.t && tempo < m.t + DURACAO_MENSAGEM) : []), [tempo, mostrarMensagens], ); const reproduzir = () => { if (reduzido) return; if (tempo >= TEMPO_MAX) setTempo(0); setRodando(true); }; const pausar = () => setRodando(false); const reiniciar = () => { setRodando(false); setTempo(0); if (trilho.current) trilho.current.scrollLeft = 0; }; const leituraAtivas = ativas.length === 0 ? "nenhum agente executando" : `${ativas.length} ${ativas.length === 1 ? "agente executando" : "agentes executando"} (${ativas.map((r) => r.nome).join(" e ")})`; const leituraParede = tempo >= FIM_DA_RESPOSTA ? `A resposta saiu aos ${FIM_DA_RESPOSTA} s de parede; em sequência, as mesmas barras custariam ${SOMA_BARRAS} s.` : `Se tudo corresse em sequência, custaria ${SOMA_BARRAS} s; em paralelo a resposta sai aos ${FIM_DA_RESPOSTA} s.`; const larguraTrilho = LARGURA_ROTULOS + TEMPO_MAX * PX_POR_SEGUNDO + 24; const transicaoVoo = reduzido ? { duration: 0 } : { type: "spring" as const, stiffness: 260, damping: 26 }; return (
{"Raias de agentes"}

{ "Cada raia é um agente com a própria pasta de trabalho. As barras crescem com o tempo do modelo, e os cartões são as mensagens trocadas entre as raias. Cenário ilustrativo: nenhum tempo aqui vem de medição." }

{reduzido ? "Movimento reduzido ativo no seu aparelho: nada reproduz sozinho, os cartões trocam de raia sem voar e o controle deslizante percorre o tempo." : "Movimento completo ativo. Ligue o movimento reduzido no sistema e recarregue para ver as mensagens trocarem de raia sem voo."}

{rodando ? ( ) : ( )}
{TICKS.map((t) => ( {`${t} s`} ))}
{RAIAS.map((raia, i) => (
{raia.nome} {raia.pastaIsolada ? {"pasta isolada"} : null}
{BARRAS.filter((b) => b.raia === raia.id && tempo >= b.inicio).map((b) => { const fimVisivel = Math.min(tempo, b.fim); const entregue = tempo >= b.fim; return (
{entregue ? `${b.rotulo}: entregue` : b.rotulo}
); })}
))} {visiveis.map((m) => { const yDe = 28 + centroDaRaia(m.de) - 18; const yPara = 28 + centroDaRaia(m.para) - 18; return ( {`${RAIAS[indiceDaRaia(m.de)].nome} para ${RAIAS[indiceDaRaia(m.para)].nome}`} {m.texto} ); })}

{`Tempo do modelo: ${formatarSegundos(tempo)}. Agora, ${leituraAtivas}. Mensagens trocadas até aqui: ${trocadas} de ${MENSAGENS.length}. ${leituraParede}`}

  • {"executando"}
  • {"entregue"}
  • {"reprovado e refeito"}
  • {"mensagem entre raias"}

{ "Pasta isolada: cada agente edita a própria cópia dos arquivos, e o principal junta tudo no fim; é o que os agentes de código chamam de worktree. Cenário ilustrativo, sem relação com projeto real." }

); } export const meta: LiveLabMeta = { biblioteca: "Motion", pacote: "motion", versao: "^13.1.0", docsUrl: "https://motion.dev/docs/react", npmUrl: "https://www.npmjs.com/package/motion", trechoLinguagem: "tsx", trecho: `import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/react"; // Cada raia é um agente; cada mensagem tem o tempo do modelo em que sai e a raia de destino. const MENSAGENS = [ { id: "m1", t: 0.5, de: "principal", para: "redator", texto: "tarefa: redigir o texto" }, { id: "m7", t: 38, de: "revisor", para: "redator", texto: "reprovado: refazer um trecho", reprovacao: true }, ]; function Raias({ tempo, yDaRaia }) { const reduzido = useReducedMotion() === true; const visiveis = MENSAGENS.filter((m) => tempo >= m.t && tempo < m.t + 4); return ( {visiveis.map((m) => ( {m.texto} ))} ); }`, observar: [ "A resposta sai aos 58 s de parede enquanto as barras somam 80 s: o paralelo termina na raia mais lenta, não na soma.", "A mensagem vermelha da Revisão para a Redação é o harness em ação: um trecho reprovado volta e é refeito antes de a resposta ser montada.", "Desligue Mostrar mensagens e compare: só com as barras, ninguém entende quem pediu o que a quem.", "Com movimento reduzido no sistema, nada reproduz sozinho e os cartões trocam de raia sem voar; o controle deslizante continua percorrendo o tempo.", ], }; ==== AgentesSwimlane.module.css ==== /* Laboratório agentes-swimlane (curso Frontends com Vibecoding). Cor só por token do tema: contraste AA nos temas claro e escuro sem cor fixa. Alvo de toque de 44 px em todo controle e foco visível pelo acento do curso. */ .lab { display: grid; gap: 0.9rem; color: var(--text); } .topo { display: grid; gap: 0.5rem; } .selo { display: inline-flex; align-items: center; width: fit-content; min-height: 28px; padding: 0 0.7rem; border: 1px solid var(--course-accent, var(--accent)); border-radius: 999px; background: var(--bg-white); color: var(--course-accent, var(--accent)); font-size: 13px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; } .regra, .nota, .leitura { margin: 0; font-size: 14px; line-height: 1.55; color: var(--text); } .nota { font-size: 13px; color: var(--text-muted); } .modo { margin: 0; padding: 0.6rem 0.8rem; border-left: 3px solid var(--warning); border-radius: var(--radius, 8px); background: var(--bg-muted); font-size: 13px; line-height: 1.5; color: var(--text); } .modo[data-reduzido="true"] { border-left-color: var(--success); } .controles { display: flex; flex-wrap: wrap; gap: 0.6rem 0.9rem; align-items: center; } .botao { min-height: 44px; min-width: 44px; padding: 0 1rem; border: 1px solid var(--border); border-radius: var(--radius, 8px); background: var(--bg-white); color: var(--text); font: inherit; font-size: 14px; font-weight: 600; cursor: pointer; } .botao:hover { border-color: var(--course-accent, var(--accent)); } .botao:disabled { cursor: not-allowed; opacity: 0.55; } .botao:focus-visible, .faixa input:focus-visible, .interruptor input:focus-visible, .trilho:focus-visible { outline: 2px solid var(--course-accent, var(--accent)); outline-offset: 2px; } .faixa { display: grid; gap: 0.3rem; flex: 1 1 220px; font-size: 13px; font-weight: 600; color: var(--text); } .faixa input { width: 100%; min-height: 44px; margin: 0; accent-color: var(--course-accent, var(--accent)); cursor: pointer; } .interruptor { display: inline-flex; align-items: center; gap: 0.5rem; min-height: 44px; font-size: 14px; font-weight: 600; color: var(--text); cursor: pointer; } .interruptor input { width: 22px; height: 22px; margin: 0; accent-color: var(--course-accent, var(--accent)); } .trilho { overflow-x: auto; overscroll-behavior-x: contain; border: 1px solid var(--border); border-radius: var(--radius, 8px); background: var(--bg-white); } .palco { position: relative; min-width: 100%; } .regua { position: absolute; top: 0; right: 0; height: 28px; border-bottom: 1px solid var(--border); } .tick { position: absolute; top: 6px; transform: translateX(-50%); font-size: 13px; font-variant-numeric: tabular-nums; color: var(--text-muted); } .tick:first-child { transform: none; } .raia { position: absolute; left: 0; right: 0; border-bottom: 1px solid var(--border); } .raia:last-child { border-bottom: 0; } .rotuloRaia { position: sticky; left: 0; z-index: 2; display: flex; flex-direction: column; justify-content: center; gap: 0.2rem; height: 100%; padding: 0 0.6rem; border-right: 1px solid var(--border); background: var(--bg-muted); } .nomeRaia { font-size: 14px; font-weight: 700; line-height: 1.2; color: var(--text); } .pasta { width: fit-content; padding: 1px 6px; border: 1px dashed var(--border); border-radius: 999px; font-size: 13px; line-height: 1.3; color: var(--text-muted); } .pista { position: absolute; top: 0; right: 0; bottom: 0; } .barra { position: absolute; top: 18px; height: 36px; display: flex; align-items: center; overflow: hidden; padding: 0 8px; border: 1px solid var(--course-accent, var(--accent)); border-radius: 6px; background: var(--accent-lighter, var(--bg-muted)); color: var(--text); white-space: nowrap; } .barra[data-tom="refazendo"] { border-color: var(--danger); border-style: dashed; } .barra[data-tom="montando"] { border-color: var(--course-accent, var(--accent)); border-width: 2px; } .barra[data-entregue="true"] { border-color: var(--success); border-style: solid; } .rotuloBarra { font-size: 13px; font-weight: 600; line-height: 1; } .mensagem { position: absolute; top: 0; z-index: 3; display: grid; gap: 1px; max-width: 190px; padding: 4px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg-white); color: var(--text); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); } .mensagem[data-reprovacao="true"] { border-color: var(--danger); border-width: 2px; } .origem { font-size: 13px; line-height: 1.2; color: var(--text-muted); } .textoMensagem { font-size: 13px; font-weight: 600; line-height: 1.25; } .mensagem[data-reprovacao="true"] .textoMensagem { color: var(--danger); } .cursor { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--course-accent, var(--accent)); opacity: 0.8; pointer-events: none; } .legenda { display: flex; flex-wrap: wrap; gap: 0.4rem 1rem; margin: 0; padding: 0; list-style: none; font-size: 13px; color: var(--text); } .legenda li { display: inline-flex; align-items: center; gap: 0.4rem; min-height: 24px; } .amostra { display: inline-block; width: 18px; height: 12px; border: 1px solid var(--course-accent, var(--accent)); border-radius: 3px; background: var(--accent-lighter, var(--bg-muted)); } .amostra[data-tom="entregue"] { border-color: var(--success); } .amostra[data-tom="refazendo"] { border-color: var(--danger); border-style: dashed; } .amostra[data-tom="mensagem"] { border-color: var(--border); background: var(--bg-white); } @media (prefers-reduced-motion: no-preference) { .botao, .barra { transition: border-color 160ms ease; } }