diff --git a/agents.md b/agents.md
index 0ed7d0c..b27a2d5 100644
--- a/agents.md
+++ b/agents.md
@@ -167,5 +167,48 @@ bun run build:bun
- `docs/DEPLOY-RUNBOOK.md` — runbook do Swarm.
- `docs/admin-inventory-ui.md`, `docs/plano-app-desktop-maquinas.md` — detalhes do inventário/agente.
+## Regras de Codigo
+
+### Tooltips Nativos do Navegador
+
+**NAO use o atributo `title` em elementos HTML** (button, span, a, div, etc).
+
+O atributo `title` causa tooltips nativos do navegador que sao inconsistentes visualmente e nao seguem o design system da aplicacao.
+
+```tsx
+// ERRADO - causa tooltip nativo do navegador
+
+
+// CORRETO - sem tooltip nativo
+
+
+// CORRETO - se precisar de tooltip, use o componente Tooltip do shadcn/ui
+
+
+
+
+ Remover item
+
+```
+
+**Excecoes:**
+- Props `title` de componentes customizados (CardTitle, DialogTitle, etc) sao permitidas pois nao geram tooltips nativos.
+
+### Acessibilidade
+
+Para manter acessibilidade em botoes apenas com icone, prefira usar `aria-label`:
+
+```tsx
+
+```
+
---
-_Última atualização: 10/12/2025 (Next.js 16, build padrão com Turbopack e fallback webpack documentado)._
+_Última atualização: 15/12/2025 (Next.js 16, build padrão com Turbopack e fallback webpack documentado)._
diff --git a/convex/tickets.ts b/convex/tickets.ts
index 22d7619..2bf1bc3 100644
--- a/convex/tickets.ts
+++ b/convex/tickets.ts
@@ -2661,7 +2661,7 @@ export const setChecklistItemAnswer = mutation({
ticketId: v.id("tickets"),
actorId: v.id("users"),
itemId: v.string(),
- answer: v.string(),
+ answer: v.optional(v.string()),
},
handler: async (ctx, { ticketId, actorId, itemId, answer }) => {
const ticket = await ctx.db.get(ticketId);
@@ -2683,7 +2683,7 @@ export const setChecklistItemAnswer = mutation({
}
const now = Date.now();
- const normalizedAnswer = answer.trim();
+ const normalizedAnswer = answer?.trim() ?? "";
const isDone = normalizedAnswer.length > 0;
const nextChecklist = checklist.map((it) => {
diff --git a/convex/users.ts b/convex/users.ts
index 852baa5..ab24ef5 100644
--- a/convex/users.ts
+++ b/convex/users.ts
@@ -300,10 +300,23 @@ export const updateAvatar = mutation({
return { status: "not_found" }
}
- // Atualiza o avatar do usuário
+ // Atualiza o avatar do usuário - usa undefined para remover o campo
const normalizedAvatarUrl = avatarUrl ?? undefined
await ctx.db.patch(user._id, { avatarUrl: normalizedAvatarUrl })
+ // Cria snapshot base sem avatarUrl se for undefined
+ // Isso garante que o campo seja realmente removido do snapshot
+ const baseSnapshot: { name: string; email: string; avatarUrl?: string; teams?: string[] } = {
+ name: user.name,
+ email: user.email,
+ }
+ if (normalizedAvatarUrl !== undefined) {
+ baseSnapshot.avatarUrl = normalizedAvatarUrl
+ }
+ if (user.teams && user.teams.length > 0) {
+ baseSnapshot.teams = user.teams
+ }
+
// Atualiza snapshots em comentários
const comments = await ctx.db
.query("ticketComments")
@@ -311,15 +324,9 @@ export const updateAvatar = mutation({
.take(10000)
if (comments.length > 0) {
- const authorSnapshot = {
- name: user.name,
- email: user.email,
- avatarUrl: normalizedAvatarUrl,
- teams: user.teams ?? undefined,
- }
await Promise.all(
comments.map(async (comment) => {
- await ctx.db.patch(comment._id, { authorSnapshot })
+ await ctx.db.patch(comment._id, { authorSnapshot: baseSnapshot })
}),
)
}
@@ -331,14 +338,8 @@ export const updateAvatar = mutation({
.take(10000)
if (requesterTickets.length > 0) {
- const requesterSnapshot = {
- name: user.name,
- email: user.email,
- avatarUrl: normalizedAvatarUrl,
- teams: user.teams ?? undefined,
- }
for (const t of requesterTickets) {
- await ctx.db.patch(t._id, { requesterSnapshot })
+ await ctx.db.patch(t._id, { requesterSnapshot: baseSnapshot })
}
}
@@ -349,14 +350,8 @@ export const updateAvatar = mutation({
.take(10000)
if (assigneeTickets.length > 0) {
- const assigneeSnapshot = {
- name: user.name,
- email: user.email,
- avatarUrl: normalizedAvatarUrl,
- teams: user.teams ?? undefined,
- }
for (const t of assigneeTickets) {
- await ctx.db.patch(t._id, { assigneeSnapshot })
+ await ctx.db.patch(t._id, { assigneeSnapshot: baseSnapshot })
}
}
diff --git a/src/app/api/profile/avatar/route.ts b/src/app/api/profile/avatar/route.ts
index e265688..07faa20 100644
--- a/src/app/api/profile/avatar/route.ts
+++ b/src/app/api/profile/avatar/route.ts
@@ -137,3 +137,45 @@ export async function DELETE() {
return NextResponse.json({ error: "Erro interno do servidor" }, { status: 500 })
}
}
+
+/**
+ * PATCH - Força sincronização do avatar atual do Prisma para o Convex
+ * Útil quando a sincronização automática falhou
+ */
+export async function PATCH() {
+ try {
+ const session = await getServerSession()
+
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
+ }
+
+ // Busca o avatar atual no Prisma
+ const user = await prisma.authUser.findUnique({
+ where: { id: session.user.id },
+ select: { avatarUrl: true },
+ })
+
+ // Sincroniza com o Convex
+ const convex = createConvexClient()
+ const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
+
+ const result = await convex.mutation(api.users.updateAvatar, {
+ tenantId,
+ email: session.user.email,
+ avatarUrl: user?.avatarUrl ?? null,
+ })
+
+ console.log("[profile/avatar] Sincronização forçada:", result)
+
+ return NextResponse.json({
+ success: true,
+ message: "Avatar sincronizado com sucesso",
+ avatarUrl: user?.avatarUrl ?? null,
+ convexResult: result,
+ })
+ } catch (error) {
+ console.error("[profile/avatar] Erro na sincronização:", error)
+ return NextResponse.json({ error: "Erro ao sincronizar avatar" }, { status: 500 })
+ }
+}
diff --git a/src/components/admin/devices/admin-devices-overview.tsx b/src/components/admin/devices/admin-devices-overview.tsx
index 70e4916..a6177ff 100644
--- a/src/components/admin/devices/admin-devices-overview.tsx
+++ b/src/components/admin/devices/admin-devices-overview.tsx
@@ -4089,7 +4089,6 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
size="sm"
className="h-7 border border-transparent px-2 text-slate-600 transition-colors hover:border-slate-300 hover:bg-slate-100 hover:text-slate-900"
onClick={() => handleCopyRemoteIdentifier(entry.identifier)}
- title="Copiar ID"
aria-label="Copiar ID"
>
@@ -4109,7 +4108,6 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
size="sm"
className="h-7 border border-transparent px-2 text-slate-600 hover:border-slate-300 hover:bg-slate-100 hover:text-slate-900"
onClick={() => handleCopyRemoteCredential(entry.username, "Usuário do acesso remoto")}
- title="Copiar usuário"
aria-label="Copiar usuário"
>
@@ -4119,7 +4117,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
{entry.password ? (
Senha
-
+
{secretVisible ? entry.password : "••••••••"}
@@ -1074,7 +1073,6 @@ export function AutomationEditorDialog({
size="icon"
onClick={() => handleRemoveAction(a.id)}
className="mt-6 h-8 w-8 text-slate-500 hover:bg-red-50 hover:text-red-700"
- title="Remover"
>
diff --git a/src/components/chat/chat-session-item.tsx b/src/components/chat/chat-session-item.tsx
index 9abd94c..cb526cb 100644
--- a/src/components/chat/chat-session-item.tsx
+++ b/src/components/chat/chat-session-item.tsx
@@ -72,9 +72,9 @@ export function ChatSessionItem({ session, isActive, onClick }: ChatSessionItemP
{/* Indicador online/offline */}
{session.machineOnline !== undefined && (
session.machineOnline ? (
-
+
) : (
-
+
)
)}
diff --git a/src/components/chat/chat-session-list.tsx b/src/components/chat/chat-session-list.tsx
index 478525c..dea0750 100644
--- a/src/components/chat/chat-session-list.tsx
+++ b/src/components/chat/chat-session-list.tsx
@@ -68,7 +68,6 @@ export function ChatSessionList({