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
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ describe("ThinkingEffortDisplay", () => {
expect(html).toContain("max");
expect(html).toContain("reasoningEffort.overridden");
expect(html).toContain("lucide-arrow-right");
expect(html).toContain("relative z-20");
});

test("显示 Anthropic 请求中的思考强度", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export function ThinkingEffortDisplay({ specialSettings }: ThinkingEffortDisplay
<Tooltip delayDuration={250}>
<TooltipTrigger asChild>
<span
className="inline-flex items-center gap-1 whitespace-nowrap"
className="relative z-20 inline-flex items-center gap-1 whitespace-nowrap"
data-slot="thinking-effort"
>
{effortInfo.requestedEffort && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ describe("usage-logs-table thinking effort", () => {
expect(cells[6]?.textContent).toContain("gpt-5.4");
expect(cells[7]?.textContent).toContain("low");
expect(cells[7]?.textContent).toContain("max");
expect(cells[7]?.className).toContain("overflow-hidden");
expect(cells[7]?.className).toContain("overflow-visible");
});

test("显示 Anthropic 请求的思考强度", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ export function UsageLogsTable({
</TooltipProvider>
</TableCell>
{hideReasoningEffortColumn ? null : (
<TableCell className="font-mono text-xs w-[84px] max-w-[84px] overflow-hidden">
<TableCell className="relative z-20 w-[84px] max-w-[84px] overflow-visible font-mono text-xs">
<ThinkingEffortDisplay specialSettings={log.specialSettings} />
</TableCell>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ describe("virtualized-logs-table thinking effort", () => {
const effortDisplay = container.querySelector('[data-slot="thinking-effort"]');
expect(effortDisplay?.textContent).toContain("low");
expect(effortDisplay?.textContent).toContain("max");
expect(effortDisplay?.closest(".overflow-hidden")).not.toBeNull();
expect(effortDisplay?.closest(".overflow-visible")).not.toBeNull();
});

test("显示 Anthropic 请求的思考强度", () => {
Expand Down Expand Up @@ -943,6 +943,64 @@ describe("virtualized-logs-table live chain display", () => {
expect(html).toContain("text-indigo-500");
});

test("stacks every currently connected racing provider and exposes the full list", () => {
setupLiveChainDefaults();
mockLogs = [
makeLog({
id: 1,
statusCode: null,
providerChain: null,
_liveChain: {
chain: [],
activeProviders: [
{ id: 1, name: "openai-east-with-a-long-name" },
{ id: 2, name: "anthropic-west-with-a-long-name" },
{ id: 3, name: "gemini-central-with-a-long-name" },
],
phase: "hedge_racing",
updatedAt: Date.now(),
},
}),
];

const html = renderToStaticMarkup(
<VirtualizedLogsTable filters={{}} autoRefreshEnabled={false} />
);

expect(html).toContain('data-slot="live-provider-stack"');
expect(html).toContain("openai-east-with-a-long-name");
expect(html).toContain("anthropic-west-with-a-long-name");
expect(html).toContain("gemini-central-with-a-long-name");
expect(html).toContain('data-slot="live-provider-tooltip"');
});

test("shows only the newly active provider after fallback switches", () => {
setupLiveChainDefaults();
mockLogs = [
makeLog({
id: 1,
statusCode: null,
providerChain: null,
_liveChain: {
chain: [
{ id: 1, name: "primary-provider", reason: "retry_failed" },
{ id: 2, name: "fallback-provider", reason: "initial_selection" },
],
activeProviders: [{ id: 2, name: "fallback-provider" }],
phase: "provider_selected",
updatedAt: Date.now(),
},
}),
];

const html = renderToStaticMarkup(
<VirtualizedLogsTable filters={{}} autoRefreshEnabled={false} />
);

expect(html).toContain("fallback-provider");
expect(html).not.toContain("primary-provider");
});

test("renders generic in-progress when live chain is empty", () => {
setupLiveChainDefaults();
mockLogs = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,47 @@ function StatusBadgeOnly({ statusCode }: { statusCode: number | null }) {
);
}

function LiveProviderStack({ providers }: { providers: Array<{ id: number; name: string }> }) {
const visibleProviders = providers.slice(0, 3);
const hiddenProviderCount = providers.length - visibleProviders.length;

return (
<TooltipProvider>
<Tooltip delayDuration={250}>
<TooltipTrigger asChild>
<span
className="flex min-w-0 items-center -space-x-2 cursor-help"
data-slot="live-provider-stack"
>
{visibleProviders.map((provider) => (
<span
key={provider.id}
className="relative max-w-[68px] truncate rounded-md border bg-background px-1.5 py-0.5 text-xs text-foreground shadow-sm"
>
{provider.name}
</span>
))}
{hiddenProviderCount > 0 && (
<span className="relative rounded-md border bg-muted px-1.5 py-0.5 text-xs text-muted-foreground shadow-sm">
+{hiddenProviderCount}
</span>
)}
</span>
</TooltipTrigger>
<TooltipContent className="max-w-[320px]" side="bottom" align="start">
Comment on lines +105 to +127

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate file =="
git ls-files | rg 'src/app/.*/dashboard/logs/_components/virtualized-logs-table\.tsx|package\.json|package-lock\.json|pnpm-lock\.yaml|yarn\.lock' || true

echo "== relevant source =="
if [ -f src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx ]; then
  wc -l src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx
  sed -n '1,190p' 'src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx'
fi

echo "== search LiveProviderStack/usages =="
rg -n "LiveProviderStack|visibleProviders|hiddenProviderCount|TooltipTrigger" src || true

echo "== tooltip imports/usages in file =="
rg -n "from ['\"]`@radix-ui/react-tooltip`['\"]|TooltipTrigger|TooltipContent" src/app || true

echo "== dependencies =="
if [ -f package.json ]; then
  node -e "const p=require('./package.json'); console.log(JSON.stringify({deps:p.dependencies, devDeps:p.devDependencies}, null, 2))"
fi

Repository: ding113/claude-code-hub

Length of output: 2038


🌐 Web query:

Radix UI TooltipTrigger asChild non-focusable element span keyboard accessibility button type button

💡 Result:

When using Radix UI TooltipTrigger with the asChild prop, the child element must be a focusable element (such as a button, anchor, or input) to maintain accessibility [1][2]. If you replace the default trigger with a non-focusable element like a span or div, you break the component's keyboard accessibility [1][2]. Specifically: 1. Keyboard users will be unable to focus the trigger to activate the tooltip [2]. 2. Screen readers may not correctly identify the element as an interactive trigger, and accessibility attributes like aria-describedby may not be attached or announced as expected [3][2]. 3. Radix UI documentation explicitly states that when using asChild, it is the developer's responsibility to ensure the resulting element remains accessible and functional, which requires it to be able to respond to pointer and keyboard events [1]. If you find yourself needing to wrap a non-focusable element (like text in a span) to avoid nested button issues (e.g., placing a tooltip inside another button), simply changing it to a span will cause a loss of keyboard functionality [3][4]. To maintain accessibility in such cases, you must manually ensure the element is focusable and interactive, for example by adding tabIndex={0} and appropriate keyboard event listeners to the span, although this is generally discouraged in favor of using natively focusable elements like buttons or links [5][2]. When using asChild with a properly focusable element (like your own button component), ensure your component correctly forwards refs and spreads props so that Radix can attach the necessary aria attributes and event handlers [1][6][2].

Citations:


使完整 provider 列表可通过键盘访问。

TooltipTrigger asChild 下的 <span> 不是焦点元素,键盘用户无法聚焦或激活 Tooltip。将触发器改为原生的可聚焦元素,例如 <button type="button">,并使用 next-intl 提供翻译后的 aria-label。验证 Tab 聚焦触发器后可以显示完整 provider 列表。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`[locale]/dashboard/logs/_components/virtualized-logs-table.tsx
around lines 105 - 127, Update the TooltipTrigger child in the live provider
stack to a native focusable button with type="button", preserving the existing
provider badges and tooltip behavior. Add a translated aria-label using the
component’s next-intl translation mechanism so keyboard users can identify and
activate it, and ensure the focused trigger reveals the full provider list.

Source: Coding guidelines

<div data-slot="live-provider-tooltip">
<ul className="space-y-1 text-xs whitespace-normal break-words">
{providers.map((provider) => (
<li key={provider.id}>{provider.name}</li>
))}
</ul>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

interface VirtualizedLogsTableProps {
filters: VirtualizedLogsTableFilters;
currencyCode?: CurrencyCode;
Expand Down Expand Up @@ -887,11 +928,21 @@ export function VirtualizedLogsTable({
log._liveChain ? (
<div className="flex items-center gap-1.5 min-w-0">
<Loader2 className="h-3 w-3 animate-spin text-blue-500 shrink-0" />
<span className="text-xs text-muted-foreground truncate">
{log._liveChain.chain.length > 0
? log._liveChain.chain[log._liveChain.chain.length - 1].name
: t("logs.details.inProgress")}
</span>
{log._liveChain.activeProviders ? (
log._liveChain.activeProviders.length > 0 ? (
<LiveProviderStack providers={log._liveChain.activeProviders} />
) : (
<span className="text-xs text-muted-foreground truncate">
{t("logs.details.inProgress")}
</span>
)
) : (
<span className="text-xs text-muted-foreground truncate">
{log._liveChain.chain.length > 0
? log._liveChain.chain[log._liveChain.chain.length - 1].name
: t("logs.details.inProgress")}
</span>
)}
{log._liveChain.phase === "retrying" && (
<Badge
variant="outline"
Expand Down Expand Up @@ -1016,7 +1067,7 @@ export function VirtualizedLogsTable({

{/* Thinking Effort */}
{hideReasoningEffortColumn ? null : (
<div className="flex-[0.6] min-w-[64px] overflow-hidden px-1.5 font-mono text-xs">
<div className="relative z-20 flex-[0.6] min-w-[64px] overflow-visible px-1.5 font-mono text-xs">
<ThinkingEffortDisplay specialSettings={log.specialSettings} />
</div>
)}
Expand Down
5 changes: 5 additions & 0 deletions src/app/v1/_lib/proxy/forwarder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4558,6 +4558,7 @@ export class ProxyForwarder {
attempt.thresholdTimer = null;
}
attempts.delete(attempt);
session.removeLiveActiveProvider(attempt.provider.id);

// 竞速输家计费开启:仅标记 + 记录决策链,不取消连接、不释放 agent。
// 实际的后台 drain 由 runAttempt 的 .then 流程发起(它独占 reader,避免并发读)。
Expand Down Expand Up @@ -4855,6 +4856,9 @@ export class ProxyForwarder {
};

const handleAttemptFailure = async (attempt: StreamingHedgeAttempt, error: Error) => {
if (attempt !== winnerAttempt) {
session.removeLiveActiveProvider(attempt.provider.id);
}
// 已被标记为计费输家、billing 尚未启动、却在此失败(如首块读取出错 / 赢家已提交):
// 此时 abortAttempt 已早退(未取消连接/未释放 agent),由这里兜底清理,避免 reader/agent 泄漏。
if (
Expand Down Expand Up @@ -5344,6 +5348,7 @@ export class ProxyForwarder {
};

attempts.add(attempt);
session.addLiveActiveProvider(provider);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Register the initial hedge provider only once

For the initial streaming-hedge attempt, attemptSession is the original session, and the immediately preceding attemptSession.setProvider(provider) already records the provider with count 1. This additional registration raises its count to 2, while a normal attempt failure calls removeLiveActiveProvider only once, leaving the failed provider displayed as active while fallback selection and related failure handling run. Register launches only when setProvider did not already register that attempt, or remove the count-based duplicate.

Useful? React with 👍 / 👎.

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.

[HIGH] [LOGIC-BUG] Initial hedge participant stays in the live provider stack after it fails

Why this is a problem: startAttempt(initialProvider, true) already routes through attemptSession.setProvider(provider), and the new setProvider() bookkeeping seeds liveActiveProviderCounts with 1. Adding the same provider again here bumps the initial participant to 2. When that first attempt later fails, handleAttemptFailure() / abortAttempt() only removes one count, so the failed provider remains in _liveChain.activeProviders and the logs UI continues to show it as an active upstream even though the connection is already gone.

Suggested fix:

attempts.add(attempt);
if (!useOriginalSession) {
  session.addLiveActiveProvider(provider);
}


// Record hedge participant launch in decision chain
// (first provider is already recorded via initial_selection or session_reuse)
Expand Down
52 changes: 51 additions & 1 deletion src/app/v1/_lib/proxy/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Context } from "hono";
import { logger } from "@/lib/logger";
import {
deleteLiveChain,
type LiveProviderSnapshot,
writeLiveChain,
writeLiveRoutingTrace,
} from "@/lib/redis/live-chain-store";
Expand Down Expand Up @@ -206,6 +207,8 @@ export class ProxySession {

// 上游决策链(记录尝试的供应商列表)
private providerChain: ProviderChainItem[];
private liveActiveProviders = new Map<number, LiveProviderSnapshot>();
private liveActiveProviderCounts = new Map<number, number>();
Comment on lines +210 to +211

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

修复 active provider 状态的生命周期模型。 provider 选择和运行 attempt 共用同一个引用计数。初始 Hedge attempt 被重复计数。shadow session 会重置根 session 的共享映射。

  • src/app/v1/_lib/proxy/session.ts#L210-L211: 将活动 attempt 状态设计为仅由根 session 管理的独立状态。
  • src/app/v1/_lib/proxy/session.ts#L429-L467: 不要让 setProvider 同时承担 provider 选择和 attempt 引用计数。
  • src/app/v1/_lib/proxy/session.ts#L907-L919: 只持久化经过根 session 生命周期管理的活动 attempt 快照。
  • src/app/v1/_lib/proxy/forwarder.ts#L4561-L4561: 只注销此前由根 session 注册的 attempt。
  • src/app/v1/_lib/proxy/forwarder.ts#L4859-L4861: 使失败清理与取消清理共享幂等的注销路径。
  • src/app/v1/_lib/proxy/forwarder.ts#L5351-L5351: 每个 Hedge attempt 只能注册一次,且 shadow session 不得重置根 session 的活动列表。
📍 Affects 2 files
  • src/app/v1/_lib/proxy/session.ts#L210-L211 (this comment)
  • src/app/v1/_lib/proxy/session.ts#L429-L467
  • src/app/v1/_lib/proxy/session.ts#L907-L919
  • src/app/v1/_lib/proxy/forwarder.ts#L4561-L4561
  • src/app/v1/_lib/proxy/forwarder.ts#L4859-L4861
  • src/app/v1/_lib/proxy/forwarder.ts#L5351-L5351
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/v1/_lib/proxy/session.ts` around lines 210 - 211, 修复 active provider
状态的生命周期管理:在 src/app/v1/_lib/proxy/session.ts 的 210-211 行,将活动 attempt 映射设计为仅由根
session 持有和管理的独立状态;在 session.ts 429-467 行拆分 setProvider 的 provider 选择与 attempt
引用计数职责,并在 907-919 行只持久化根 session 管理的活动 attempt 快照。更新
src/app/v1/_lib/proxy/forwarder.ts 4561 行仅注销根 session 注册的 attempt,在 4859-4861
行统一失败与取消的幂等注销路径,并在 5351 行确保每个 Hedge attempt 只注册一次且 shadow session 不重置根 session
的活动列表。


// Request-level routing observability. Discovery attempts live here rather
// than providerChain because providerChain is also a billing/retry contract.
Expand Down Expand Up @@ -423,6 +426,45 @@ export class ProxySession {
if (provider) {
this.providerType = provider.providerType as ProviderType;
}
if (!this.liveActiveProviders) {
this.liveActiveProviders = new Map<number, LiveProviderSnapshot>();
}
if (!this.liveActiveProviderCounts) {
this.liveActiveProviderCounts = new Map<number, number>();
}
this.liveActiveProviders.clear();
this.liveActiveProviderCounts.clear();
Comment on lines +435 to +436

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Avoid sharing live-provider maps with hedge shadows

When a second streaming-hedge attempt is created, createStreamingShadowSession shallow-copies the original ProxySession with Object.assign, so the shadow and original share these Map instances. The shadow's existing setProvider(provider) call now executes these clear() operations and removes the still-connected primary from the original session's live state; consequently, during an actual multi-provider race, the dashboard shows only the newly launched provider instead of all connected providers. Clone the maps for shadow sessions or restrict live-provider mutations to the owning session.

Useful? React with 👍 / 👎.

if (provider) {
this.liveActiveProviders.set(provider.id, { id: provider.id, name: provider.name });
this.liveActiveProviderCounts.set(provider.id, 1);
}
this.persistLiveChain();
}

addLiveActiveProvider(provider: Pick<Provider, "id" | "name">): void {
if (!this.liveActiveProviders) {
this.liveActiveProviders = new Map<number, LiveProviderSnapshot>();
}
if (!this.liveActiveProviderCounts) {
this.liveActiveProviderCounts = new Map<number, number>();
}
this.liveActiveProviders.set(provider.id, { id: provider.id, name: provider.name });
this.liveActiveProviderCounts.set(
provider.id,
(this.liveActiveProviderCounts.get(provider.id) ?? 0) + 1
);
this.persistLiveChain();
}

removeLiveActiveProvider(providerId: number): void {
const count = this.liveActiveProviderCounts?.get(providerId) ?? 0;
if (count <= 1) {
this.liveActiveProviderCounts?.delete(providerId);
if (this.liveActiveProviders?.delete(providerId)) this.persistLiveChain();
return;
}
this.liveActiveProviderCounts.set(providerId, count - 1);
this.persistLiveChain();
}

setSessionBindingSnapshot(snapshot: SessionBindingSnapshot | null): void {
Expand Down Expand Up @@ -862,11 +904,19 @@ export class ProxySession {
this.liveRoutingTraceDirty = false;

const chain = writeChain ? structuredClone(this.providerChain) : null;
const activeProviders = writeChain
? structuredClone([...this.liveActiveProviders.values()])
: null;
const routingTrace = writeRoutingTrace ? structuredClone(this.routingTrace) : null;
const writes: Promise<void>[] = [];
if (chain) {
writes.push(
writeLiveChain(this.sessionId as string, this.requestSequence as number, chain)
writeLiveChain(
this.sessionId as string,
this.requestSequence as number,
chain,
activeProviders ?? []
)
);
}
if (routingTrace) {
Expand Down
65 changes: 65 additions & 0 deletions src/lib/redis/live-chain-store.storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,71 @@ describe("live-chain routing trace storage", () => {
});
});

it("derives all currently connected Discovery providers from attempt lifecycle events", async () => {
const trace = makeTrace({
updatedAt: 240,
events: [
{
type: "attempt_started",
at: 200,
elapsedMs: 100,
round: 1,
attemptId: "11:1",
attemptKind: "normal",
provider: { id: 11, name: "provider-a" },
},
{
type: "attempt_started",
at: 210,
elapsedMs: 110,
round: 1,
attemptId: "12:1",
attemptKind: "normal",
provider: { id: 12, name: "provider-b" },
},
{
type: "attempt_finished",
at: 220,
elapsedMs: 120,
round: 1,
attemptId: "11:1",
attemptKind: "normal",
provider: { id: 11, name: "provider-a" },
outcome: "failed",
},
{
type: "attempt_started",
at: 230,
elapsedMs: 130,
round: 1,
attemptId: "13:1",
attemptKind: "fallback",
provider: { id: 13, name: "provider-c" },
},
],
});
await writeLiveRoutingTrace("racing", 1, trace);

await expect(readLiveChain("racing", 1)).resolves.toMatchObject({
activeProviders: [
{ id: 12, name: "provider-b" },
{ id: 13, name: "provider-c" },
],
});
});

it("switches the active legacy provider after a serial fallback", async () => {
await writeLiveChain("fallback", 1, [
{ id: 21, name: "primary", reason: "initial_selection", timestamp: 100 },
{ id: 21, name: "primary", reason: "retry_failed", timestamp: 200 },
{ id: 22, name: "fallback", reason: "initial_selection", timestamp: 210 },
]);

await expect(readLiveChain("fallback", 1)).resolves.toMatchObject({
activeProviders: [{ id: 22, name: "fallback" }],
});
});

it("returns an early trace before the provider chain snapshot exists", async () => {
const trace = makeTrace({
updatedAt: 150,
Expand Down
Loading
Loading