Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ next-env.d.ts
data/
logs/*

# analysis directories (generated, not tracked)
.analysis/
antigravity-manager-analysis/

# docs (allow specific tracked files)
docs/*
!docs/ARCHITECTURE.md
Expand All @@ -56,6 +60,9 @@ docs/*
!docs/TASK_NEBIUS_BACKEND_ENABLEMENT.md
!docs/frontend-backend-provider-gap-report.md
!docs/openapi.yaml
!docs/PLANO-IMPLANTACAO.md
!docs/TASKS.md
!docs/FASE-*.md
!docs/adr/
!docs/cli-tools/
!docs/planning/
Expand Down
97 changes: 97 additions & 0 deletions src/app/global-error.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"use client";

/**
* Global Error Page — FASE-04 Error Handling
*
* Root-level error boundary for unrecoverable errors.
* This is the last resort — catches errors that the per-page
* error.js boundaries don't handle.
*/

export default function GlobalError({ error, reset }) {
return (
<html>
<body
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
padding: "24px",
background: "#0a0a0f",
color: "#e0e0e0",
fontFamily: "system-ui, -apple-system, sans-serif",
textAlign: "center",
margin: 0,
}}
>
<div
style={{
fontSize: "64px",
marginBottom: "16px",
}}
>
⚠️
</div>
<h1
style={{
fontSize: "28px",
fontWeight: 700,
marginBottom: "8px",
}}
>
Something went wrong
</h1>
<p
style={{
fontSize: "15px",
color: "#888",
maxWidth: "400px",
lineHeight: 1.5,
marginBottom: "24px",
}}
>
An unexpected error occurred. This has been logged and our team will investigate.
</p>
{process.env.NODE_ENV === "development" && error?.message && (
<pre
style={{
padding: "16px",
borderRadius: "8px",
background: "rgba(239, 68, 68, 0.1)",
border: "1px solid rgba(239, 68, 68, 0.3)",
color: "#ef4444",
fontSize: "12px",
maxWidth: "600px",
overflow: "auto",
textAlign: "left",
marginBottom: "24px",
}}
>
{error.message}
</pre>
)}
<button
onClick={reset}
style={{
padding: "12px 32px",
borderRadius: "10px",
background: "linear-gradient(135deg, #6366f1, #8b5cf6)",
color: "#fff",
border: "none",
fontSize: "14px",
fontWeight: 600,
cursor: "pointer",
transition: "transform 0.2s",
boxShadow: "0 4px 16px rgba(99, 102, 241, 0.3)",
}}
onMouseEnter={(e) => (e.target.style.transform = "translateY(-2px)")}
onMouseLeave={(e) => (e.target.style.transform = "translateY(0)")}
Comment on lines +89 to +90

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Directly manipulating e.target.style for hover effects is not a recommended practice in React. It bypasses React's virtual DOM and can make component state harder to track. A more idiomatic approach is to use component state to manage dynamic styles.

Since this pattern is used in a few new components in this PR (Breadcrumbs.js, EmptyState.js), you might consider a broader refactor. Here's an example of how you could implement this using the useState hook:

  1. Add import { useState } from 'react';
  2. Inside the component, declare state: const [isHovered, setIsHovered] = useState(false);
  3. Update the button to use this state:
<button
  // ...other props
  style={{
    // ...other styles
    transform: isHovered ? 'translateY(-2px)' : 'translateY(0)',
  }}
  onMouseEnter={() => setIsHovered(true)}
  onMouseLeave={() => setIsHovered(false)}
>
  Try Again
</button>

>
Try Again
</button>
</body>
</html>
);
}
77 changes: 77 additions & 0 deletions src/app/not-found.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"use client";

Comment on lines +1 to +2

Copilot AI Feb 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not-found.js doesn’t use client-only APIs, but adding "use client" forces it into the client bundle. Consider removing the directive so this page can render as a server component (smaller client JS) unless there’s a specific client-side need.

Suggested change
"use client";

Copilot uses AI. Check for mistakes.
/**
* Custom Not Found Page — FASE-04 Error Handling
*
* Displayed when a user navigates to a non-existent route.
*/

import Link from "next/link";

export default function NotFound() {
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
padding: "24px",
background: "var(--bg-primary, #0a0a0f)",
color: "var(--text-primary, #e0e0e0)",
textAlign: "center",
}}
>
<div
style={{
fontSize: "96px",
fontWeight: 800,
background: "linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a855f7 100%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
lineHeight: 1,
marginBottom: "8px",
}}
>
404
</div>
<h1
style={{
fontSize: "24px",
fontWeight: 600,
marginBottom: "8px",
}}
>
Page not found
</h1>
<p
style={{
fontSize: "15px",
color: "var(--text-secondary, #888)",
maxWidth: "400px",
lineHeight: 1.5,
marginBottom: "32px",
}}
>
The page you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
<Link
href="/dashboard"
style={{
padding: "12px 32px",
borderRadius: "10px",
background: "linear-gradient(135deg, #6366f1, #8b5cf6)",
color: "#fff",
fontSize: "14px",
fontWeight: 600,
textDecoration: "none",
transition: "all 0.2s",
boxShadow: "0 4px 16px rgba(99, 102, 241, 0.3)",
}}
>
Go to Dashboard
</Link>
Comment on lines +59 to +74

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The 'Go to Dashboard' link is styled like a button and includes a transition property, which suggests a hover effect is intended. However, no hover effect is currently implemented. For better user experience and consistency with other buttons in this PR, you should add one. A simple transform effect on hover would work well here.

      <Link
        href="/dashboard"
        style={{
          padding: "12px 32px",
          borderRadius: "10px",
          background: "linear-gradient(135deg, #6366f1, #8b5cf6)",
          color: "#fff",
          fontSize: "14px",
          fontWeight: 600,
          textDecoration: "none",
          transition: "all 0.2s",
          boxShadow: "0 4px 16px rgba(99, 102, 241, 0.3)",
          display: "inline-block",
          transform: "translateY(0)",
        }}
        onMouseEnter={(e) => (e.target.style.transform = "translateY(-2px)")}
        onMouseLeave={(e) => (e.target.style.transform = "translateY(0)")}
      >
        Go to Dashboard
      </Link>

</div>
);
}
93 changes: 93 additions & 0 deletions src/domain/comboResolver.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* Combo Resolver — FASE-09 Domain Extraction (T-46)
*
* Extracts combo resolution logic from handleChat into a dedicated
* domain service. Handles model selection based on combo strategy
* (priority, round-robin, random, least-used).
*
* @module domain/comboResolver
*/

/**
* @typedef {import('./types.js').Combo} Combo
*/

/**
* Resolve which model to use from a combo based on its strategy.
*
* @param {Combo} combo - The combo configuration
* @param {{ modelUsageCounts?: Record<string, number> }} [context] - Optional context
* @returns {{ model: string, index: number }}
* @throws {Error} If combo has no models
*/
export function resolveComboModel(combo, context = {}) {
const models = combo.models || [];
if (models.length === 0) {
throw new Error(`Combo "${combo.name}" has no models configured`);
}

// Normalize models to { model, weight } format
const normalized = models.map((m) =>
typeof m === "string" ? { model: m, weight: 1 } : m
);

const strategy = combo.strategy || "priority";

switch (strategy) {
case "priority":
return { model: normalized[0].model, index: 0 };

case "round-robin": {
// Use a simple counter based on current time + combo id hash

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The comment here is misleading. It mentions using a 'combo id hash', but the implementation only uses Date.now(). This approach is a very simple form of stateless load distribution but isn't a true round-robin and can result in uneven distribution if many requests arrive in the same millisecond. Please update the comment to accurately describe the current implementation.

Suggested change
// Use a simple counter based on current time + combo id hash
// Use a simple time-based tick for stateless round-robin. Note: this is not perfectly distributed.

const tick = Date.now() % normalized.length;
return { model: normalized[tick].model, index: tick };
}
Comment on lines +40 to +44

Copilot AI Feb 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

round-robin selection currently uses Date.now() % normalized.length, which does not actually “cycle through” models per request (it will repeat/skew based on request timing and won’t be stable under concurrency). If you need true round-robin, keep a per-combo counter (in-memory map keyed by combo.id/name) and increment modulo length on each resolve.

Copilot uses AI. Check for mistakes.

case "random": {
// Weighted random selection
const totalWeight = normalized.reduce((sum, m) => sum + (m.weight || 1), 0);
let rand = Math.random() * totalWeight;

for (let i = 0; i < normalized.length; i++) {
rand -= normalized[i].weight || 1;
Comment on lines +48 to +52

Copilot AI Feb 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The weighted-random calculation treats weight: 0 as 1 because it uses m.weight || 1. That makes it impossible to explicitly set a model’s weight to 0 (effectively disabling it). Use nullish coalescing (e.g., m.weight ?? 1) and decide how 0 should behave (usually exclude from selection).

Suggested change
const totalWeight = normalized.reduce((sum, m) => sum + (m.weight || 1), 0);
let rand = Math.random() * totalWeight;
for (let i = 0; i < normalized.length; i++) {
rand -= normalized[i].weight || 1;
const totalWeight = normalized.reduce((sum, m) => {
const weight = m.weight ?? 1;
return sum + (weight > 0 ? weight : 0);
}, 0);
let rand = Math.random() * totalWeight;
for (let i = 0; i < normalized.length; i++) {
const weight = normalized[i].weight ?? 1;
if (weight <= 0) {
continue;
}
rand -= weight;

Copilot uses AI. Check for mistakes.
if (rand <= 0) {
return { model: normalized[i].model, index: i };
}
}
return { model: normalized[0].model, index: 0 };
}

case "least-used": {
const usageCounts = context.modelUsageCounts || {};
let minUsage = Infinity;
let minIndex = 0;

for (let i = 0; i < normalized.length; i++) {
const usage = usageCounts[normalized[i].model] || 0;
if (usage < minUsage) {
minUsage = usage;
minIndex = i;
}
}

return { model: normalized[minIndex].model, index: minIndex };
}

default:
return { model: normalized[0].model, index: 0 };
}
Comment on lines +34 to +78

Copilot AI Feb 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The app already creates combos with strategy: "weighted" (and model entries containing { model, weight }). This resolver doesn’t handle the weighted strategy at all (it will fall through to default and always pick the first model), which will break weighted combos if/when this module is used. Add explicit weighted handling (and align supported strategy names with the rest of the codebase).

Copilot uses AI. Check for mistakes.
}

/**
* Get the fallback models for a combo (all models except the primary).
*
* @param {Combo} combo
* @param {number} primaryIndex - Index of the primary model
* @returns {string[]} Remaining models in order
*/
export function getComboFallbacks(combo, primaryIndex) {
const models = (combo.models || []).map((m) =>
typeof m === "string" ? m : m.model
);
return [...models.slice(primaryIndex + 1), ...models.slice(0, primaryIndex)];

Copilot AI Feb 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getComboFallbacks doesn’t validate primaryIndex. If callers pass -1 or an index >= models.length, the returned fallback order becomes surprising (e.g., -1 returns the full list, >=len also returns full list). Consider normalizing primaryIndex into range (modulo) or throwing for invalid indices so fallback ordering is predictable.

Suggested change
return [...models.slice(primaryIndex + 1), ...models.slice(0, primaryIndex)];
const modelCount = models.length;
if (modelCount === 0) {
// No models configured; no fallbacks available.
return [];
}
// Ensure primaryIndex refers to a valid primary model so fallback ordering
// is predictable (all models except the primary, in order).
if (
!Number.isInteger(primaryIndex) ||
primaryIndex < 0 ||
primaryIndex >= modelCount
) {
throw new RangeError(
`Invalid primaryIndex ${primaryIndex} for combo "${combo.name}" with ${modelCount} models`
);
}
return [
...models.slice(primaryIndex + 1),
...models.slice(0, primaryIndex),
];

Copilot uses AI. Check for mistakes.
}
Loading