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
11 changes: 9 additions & 2 deletions src/traffic/PanelHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { commonText } from "@/i18n/common.i18n";
import { cn } from "@/lib/utils";
import { Button } from "@/ui/button";
import { Skeleton } from "@/ui/skeleton";
import { Tip } from "@/ui/tip";

/**
* 右侧浮层(请求详情、会话详情)顶上那一块:标题一行,下面一行要点,可选的一排
Expand All @@ -17,7 +18,7 @@ import { Skeleton } from "@/ui/skeleton";
* right-3` 的方块,和这一行的基线对不上;放进来之后它跟着标题走。
*
* 标题截断、关闭钮 `shrink-0`:标题一长,浏览器会去挤按钮,按钮挤无可挤就把
* 「关闭」折成两行。
* 「关闭」折成两行。截断了的标题(请求详情的标题是模型名)悬停看全。
*
* `tabs`:一排 `variant="line"` 的标签,贴着底边那条线 —— 和页头的标签同一个
* 做法(`PageHeader`),选中的那条下划线压在分隔线上。
Expand Down Expand Up @@ -47,7 +48,13 @@ export function PanelHeader({
className={cn("shrink-0 border-b border-border px-4 pt-3", tabs ? "pb-0" : "pb-3", className)}
>
<div className="flex min-h-7 items-center gap-2">
<h2 className="min-w-0 truncate tw-title text-foreground">{title}</h2>
{typeof title === "string" ? (
<Tip clip text={title}>
<h2 className="min-w-0 truncate tw-title text-foreground">{title}</h2>
</Tip>
) : (
<h2 className="min-w-0 truncate tw-title text-foreground">{title}</h2>
)}
{meta && <span className="shrink-0 tw-num tw-label whitespace-nowrap text-muted-foreground">{meta}</span>}
<span className="flex-1" />
<Button
Expand Down
76 changes: 60 additions & 16 deletions src/traffic/RequestTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,18 @@ import { sessionsText } from "./Sessions.i18n";
import { RowActions, TableMenus, type MenuTarget } from "./TableMenus";
import { trafficText } from "./Traffic.i18n";
import { useViaConfig } from "./useModelVia";
import { indexAt, itemsOf, offsetsOf, textWidth, widest, type Col, type Item, type Widths } from "./virtual";
import {
capped,
indexAt,
itemsOf,
NAME_PX,
offsetsOf,
textWidth,
widest,
type Col,
type Item,
type Widths,
} from "./virtual";

type Text = (typeof trafficText)["zh"];

Expand Down Expand Up @@ -296,7 +307,8 @@ function Th({
* 哪几格最宽是按字数估的(`useSizers`),每列取前三名,估差一点也落在里面;画的是和
* 行里一样的那一格(同一个组件),量出来的宽度就是真的。组头的字是中粗,请求行是常规;
* 组里的请求行第一格往里缩了 22px(`pl-7`),这里照样缩。上游那一格会折行,量它最窄
* 能收到多窄时也要能折。
* 能收到多窄时也要能折。名字类的几格自己封了顶(`NAME_PX`),垫在这里也一样:一个
* 长名字撑开的最多是上限那么宽。
*/
function Sizer({
col,
Expand Down Expand Up @@ -375,14 +387,19 @@ function useSizers(items: Item[], t: Text, ts: (typeof sessionsText)["zh"], toda
}, [items, cache, t, ts, today, via]);
}

/** 请求行各列大约多宽:和 `requestCell` 写的是同样的字;图标、徽标的边距按一两个字宽算 */
/**
* 请求行各列大约多宽:和 `requestCell` 写的是同样的字;图标、徽标的边距按一两个字宽算。
* 名字按画出来的宽度封顶(`capped`,上限见 `NAME_PX`)
*/
function requestWidths(r: RequestRow, t: Text, today: number, via: ViaConfig | null): Widths {
const sent = notSent(r);
const mark = rowMark(r, via);
const phrase = r.local || sent;
const text = sent ? notSentText(sent) : r.local || r.provider ? upstreamText(r) : "—";
const name = textWidth(text) + 3;
// 本地应答、没有发往上游的那一句可以在词间折行(见 `UpstreamCell`):最窄是最长的那个词
const nameMin = r.local || sent ? Math.max(...text.split(/\s+/).map(textWidth)) + 3 : name;
// 上游的名字和标志一起封顶;本地应答、没有发往上游的那一句是说明,不截断
const name = (phrase ? textWidth(text) : capped(textWidth(text), NAME_PX.upstream, 22)) + 3;
// 那一句可以在词间折行(见 `UpstreamCell`):最窄是最长的那个词
const nameMin = phrase ? Math.max(...text.split(/\s+/).map(textWidth)) + 3 : name;
const badges = [
...(mark ? [textWidth(t.pinned) + 1] : []),
...(r.secrets && r.secrets.items.length > 0
Expand All @@ -398,8 +415,9 @@ function requestWidths(r: RequestRow, t: Text, today: number, via: ViaConfig | n
return {
status: 2 + textWidth(statusText(r, t)),
time: textWidth(when(r.atMs, today)),
client: Math.min(textWidth(r.client), 24) + (r.peer ? 2 : 0),
model: Math.min(textWidth(r.model ?? "—"), 32),
// 来源的记号在同一个限宽的块里(见 `KeyCell`)
client: capped(textWidth(r.client), NAME_PX.client, r.peer ? 18 : 0) + (r.peer ? 3 : 0),
model: capped(textWidth(r.model ?? "—"), NAME_PX.model),
upstream: badges.reduce((a, b) => a + b + 1, name),
upstreamMin: Math.max(nameMin, ...badges),
// 在跑的:首 token 加上一个时钟(「0:42」)
Expand Down Expand Up @@ -837,12 +855,22 @@ function requestItems(
// 没有发往任何上游的那几行上游是空的,没有可筛的
...(r.provider
? ([
{ kind: "item", label: t.onlyUpstream(r.provider), onSelect: () => onFilter((f) => ({ ...f, provider: r.provider })) },
{
kind: "item",
label: t.onlyUpstream(r.provider),
name: r.provider,
onSelect: () => onFilter((f) => ({ ...f, provider: r.provider })),
},
] as const)
: []),
...(showClient
? ([
{ kind: "item", label: t.onlyClient(r.client), onSelect: () => onFilter((f) => ({ ...f, client: r.client })) },
{
kind: "item",
label: t.onlyClient(r.client),
name: r.client,
onSelect: () => onFilter((f) => ({ ...f, client: r.client })),
},
] as const)
: []),
{ kind: "sep" },
Expand Down Expand Up @@ -887,9 +915,11 @@ function requestCell(col: Col, r: RequestRow, c: { hints: boolean; via: ViaConfi
case "model":
return (
// 截断要套在里面一层:`max-width` 加在 td 上会被表格自己的列宽算法吃掉,长名字照样把这一列撑开
<div className="max-w-[13rem] truncate" title={r.model}>
{r.model ?? "—"}
</div>
<Tip clip text={r.model}>
<div className="truncate" style={{ maxWidth: NAME_PX.model }}>
{r.model ?? "—"}
</div>
</Tip>
);
case "upstream":
return <UpstreamCell r={r} via={c.via} />;
Expand Down Expand Up @@ -1129,8 +1159,14 @@ function UpstreamCell({ r, via }: { r: RequestRow; via: ViaConfig | null }) {
const mark = rowMark(r, via);
return (
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5">
{/* 本地应答的那一句(英文两个词)可以在词间折行:它不是一个名字,别让它定这一列的最小宽度 */}
<span className={cn("flex items-center gap-1.5", !r.local && !sent && "whitespace-nowrap")}>
{/*
本地应答的那一句(英文两个词)可以在词间折行:它不是一个名字,别让它定这一列的最小宽度。
上游的名字不折行,**连同标志封顶**(`NAME_PX`):再长的截断,悬停看全
*/}
<span
className={cn("flex items-center gap-1.5", !r.local && !sent && "whitespace-nowrap")}
style={r.local || sent ? undefined : { maxWidth: NAME_PX.upstream }}
>
{/* 被规则拒绝、选中的上游一个都接不了的请求没有发往任何上游:上游是空的,
这一格说是哪一种,图形和路由图上的拒绝一致。和本地应答一样是一句话、不是
名字,可以在词间折行 */}
Expand All @@ -1146,7 +1182,15 @@ function UpstreamCell({ r, via }: { r: RequestRow; via: ViaConfig | null }) {
) : (
<UpstreamLogo name={r.provider} className="opacity-70" />
)}
{r.local || r.provider ? <span>{upstreamText(r)}</span> : <span className="text-muted-foreground">—</span>}
{r.local ? (
<span>{upstreamText(r)}</span>
) : r.provider ? (
<Tip clip text={r.provider}>
<span className="min-w-0 truncate">{r.provider}</span>
</Tip>
) : (
<span className="text-muted-foreground">—</span>
)}
</>
)}
</span>
Expand Down
19 changes: 15 additions & 4 deletions src/traffic/SessionPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,14 +162,23 @@ export function SessionPanel({
</TabsList>
}
>
{/* 名字太长时截断(这一行宽不过浮层),悬停看全 */}
{providers.length > 0 && (
<span className="inline-flex min-w-0 items-center gap-1.5">
<UpstreamLogo name={providers[0] ?? ""} className="opacity-70" />
<span className="truncate">{providers.join(" · ")}</span>
<Tip clip text={providers.join(" · ")}>
<span className="truncate">{providers.join(" · ")}</span>
</Tip>
</span>
)}
{client && <span className="min-w-0 truncate">{client}</span>}
<span className="min-w-0 truncate">{n.models.join(t.modelSep)}</span>
{client && (
<Tip clip text={client}>
<span className="min-w-0 truncate">{client}</span>
</Tip>
)}
<Tip clip text={n.models.join(t.modelSep)}>
<span className="min-w-0 truncate">{n.models.join(t.modelSep)}</span>
</Tip>
</PanelHeader>
<div className="relative min-h-0 flex-1">
<TabsContent value="summary" forceMount className={PANE}>
Expand Down Expand Up @@ -391,7 +400,9 @@ function Waterfall({ steps, onOpen }: { steps: Step[]; onOpen: (id: number) => v
onClick={() => onOpen(x.id)}
>
<span className="w-6 shrink-0 text-right tw-label text-muted-foreground">{i + 1}</span>
<span className="w-32 shrink-0 truncate text-left text-muted-foreground">{x.model}</span>
<Tip clip text={x.model}>
<span className="w-32 shrink-0 truncate text-left text-muted-foreground">{x.model}</span>
</Tip>
<Meter value={x.cost ?? 0} max={max} color="var(--chart-2)" className="min-w-8 flex-1" />
{/* 两格的宽度按最长的那个词定:英文的「In progress」「Canceled」,Windows 上字大 1px 也放得下 */}
<span className="w-24 shrink-0 text-right">
Expand Down
36 changes: 20 additions & 16 deletions src/traffic/SessionRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { sessionsText } from "./Sessions.i18n";
import { compact as short, span as dur, whenMinute as when } from "@/format";
import { RowActions } from "./TableMenus";
import { tallyOf, type Cursor, type Group, type Tally } from "./grouping";
import { textWidth, type Col, type Widths } from "./virtual";
import { capped, NAME_PX, textWidth, type Col, type Widths } from "./virtual";

type Text = (typeof sessionsText)["zh"];

Expand Down Expand Up @@ -212,10 +212,11 @@ function sessionCell(col: Col, f: Facts, { t, open, selected, hints, onToggle }:
/>
);
case "model":
return <Names items={n.models} sep={t.modelSep} />;
return <Names items={n.models} sep={t.modelSep} max={NAME_PX.model} />;
// 标志和名字一起封顶,和请求行的上游那一格一样
case "upstream":
return (
<span className="flex items-center gap-1.5">
<span className="flex items-center gap-1.5" style={{ maxWidth: NAME_PX.upstream }}>
{providers[0] !== undefined && !first?.local && <UpstreamLogo name={providers[0]} className="opacity-70" />}
<Names items={providers} sep=" · " />
</span>
Expand Down Expand Up @@ -244,17 +245,18 @@ export function SessionSizerCell({ col, g, hints }: { col: Col; g: Group; hints:

/**
* 组头各列大约多宽,挑表头里垫哪几格用(见 `widest`)。和 `sessionCell` 写的是同样的字;
* 图标、展开钮按两三个字宽算。
* 图标、展开钮按两三个字宽算。名字和请求行一样按画出来的宽度封顶(`NAME_PX`)。
*/
export function sessionWidths(g: Group, t: Text): Widths {
const { s, first, n, providers } = sessionFacts(g);
const names = (xs: string[]) => (xs[0] === undefined ? 0 : Math.min(textWidth(xs[0]), 32) + (xs.length > 1 ? 2 : 0));
const upstream = names(providers) + 3;
// 第一个名字,有其余的再加上留给它们的一个省略号(`Names`)
const names = (xs: string[]) => (xs[0] === undefined ? 0 : textWidth(xs[0]) + (xs.length > 1 ? 2 : 0));
const upstream = capped(names(providers), NAME_PX.upstream, 22) + 3;
return {
status: 3 + textWidth(t.turnCount(n.turns)) + (n.failed > 0 ? 2 + String(n.failed).length : 0),
time: textWidth(when(n.started)) + (n.running > 0 ? 2 : 0),
client: Math.min(textWidth(s?.client ?? first?.client ?? ""), 24) + (first?.peer ? 2 : 0),
model: names(n.models),
client: capped(textWidth(s?.client ?? first?.client ?? ""), NAME_PX.client, first?.peer ? 18 : 0) + (first?.peer ? 3 : 0),
model: capped(names(n.models), NAME_PX.model),
upstream,
upstreamMin: upstream,
latency: textWidth(dur(n.ended - n.started)),
Expand All @@ -267,24 +269,26 @@ export function sessionWidths(g: Group, t: Text): Widths {
* 组头里的一串名字(模型、上游)。**决定列宽的只有第一项。**
*
* 一次任务用过三个模型、走过四个上游很常见,连起来写有两三百像素 ——
* 让它撑列宽,费用就被挤出视野。所以第一项完整显示(和请求行的模型
* 一样最多 13rem);分隔符和其余的那段宽度记作 0(`w-0 flex-1`),这一列
* 有多宽就显示多少,最少留出一个省略号:窄的时候是「claude-sonnet-5…」,
* 宽的时候整串都在。悬停看全。
* 让它撑列宽,费用就被挤出视野。所以第一项完整显示;分隔符和其余的那段
* 宽度记作 0(`w-0 flex-1`),这一列有多宽就显示多少,最少留出一个省略号:
* 窄的时候是「claude-sonnet-5…」,宽的时候整串都在。截断了的悬停看全。
*
* **整块和请求行同一个上限**(`max`,或者外面那一块的,见 `NAME_PX`):第一项
* 本身就超长时,它让出最后那个省略号的位置,组头不比请求行宽。
*
* **分隔符跟着后面那段,不跟着第一项。**跟着第一项的话,第一个模型正好是
* 全表最长的那个时,组头比请求行宽出一个分隔符,归组之后整张表被撑出视野。
*
* `whitespace-pre` 是为了留住「 · 」两边的空格 —— 两段各是一个块,
* 行尾和行首的空格会被吞掉。
*/
function Names({ items, sep }: { items: string[]; sep: string }) {
function Names({ items, sep, max }: { items: string[]; sep: string; max?: number }) {
const [first, ...rest] = items;
if (first === undefined) return null;
return (
<Tip lazy text={items.join(sep)}>
<div className="flex min-w-0 flex-1">
<span className="max-w-[13rem] shrink-0 overflow-hidden text-ellipsis whitespace-pre">{first}</span>
<Tip clip text={items.join(sep)}>
<div className="flex min-w-0 flex-1" style={max === undefined ? undefined : { maxWidth: max }}>
<span className="min-w-0 overflow-hidden text-ellipsis whitespace-pre">{first}</span>
{rest.length > 0 && (
<span className="w-0 min-w-3 flex-1 overflow-hidden text-ellipsis whitespace-pre">
{sep + rest.join(sep)}
Expand Down
6 changes: 5 additions & 1 deletion src/traffic/cells.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ClientLogo } from "@/ui/logos";
import { Tip } from "@/ui/tip";
import { coreNow, onTick, stopwatch } from "./clock";
import { trafficText } from "./Traffic.i18n";
import { NAME_PX } from "./virtual";

/**
* 请求行和组头共用的几样:行的底色、压暗的格子、密钥那一格、悬停里的几行字、复制、
Expand Down Expand Up @@ -40,6 +41,9 @@ export const MENU_REVEAL =
*
* `hints`:表里有哪一行带着推测出的应用。有的话没有应用的行也留出标志的位置,
* 名字才对得齐。
*
* **整块封顶**(`NAME_PX`):名字再长也只截断名字,标志和来源记号留着;悬停第一行就是
* 完整的名字(连同打码的值)。
*/
export function KeyCell({
client,
Expand All @@ -62,7 +66,7 @@ export function KeyCell({
];
return (
<Tip lazy text={<Lines lines={tip} />}>
<span className="flex max-w-40 items-center gap-1.5">
<span className="flex items-center gap-1.5" style={{ maxWidth: NAME_PX.client }}>
{hints &&
(hint ? (
<ClientLogo id={hint} name={appLabel(hint)} className="opacity-70" />
Expand Down
16 changes: 11 additions & 5 deletions src/traffic/drawer/RequestDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import { Skeleton } from "@/ui/skeleton";
import { ErrorState } from "@/ui/states";
import { StatusLabel, type StatusTone } from "@/ui/status-dot";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/ui/tabs";
import { KeyLabel } from "@/KeyLabel";
import { Tip } from "@/ui/tip";
import { KeyLabel, keyText } from "@/KeyLabel";
import { notSentText, probeLabel } from "@/labels";
import { notSent } from "@/requestRouting";
import type { CoreEvent, HistoryRow, RequestDetail } from "@/types";
Expand Down Expand Up @@ -188,13 +189,18 @@ function Detail({ id, onClose }: { id: number; onClose: () => void }) {
(r.local || r.provider) && (
<span className="inline-flex min-w-0 items-center gap-1.5">
{!r.local && <UpstreamLogo name={r.provider} className="opacity-70" />}
<span className="truncate">{r.local ? t.answeredLocally : r.provider}</span>
{/* 名字太长时截断(这一行宽不过浮层),悬停看全 */}
<Tip clip text={r.local ? t.answeredLocally : r.provider}>
<span className="truncate">{r.local ? t.answeredLocally : r.provider}</span>
</Tip>
</span>
)
)}
<span className="min-w-0 truncate">
<KeyLabel name={r.client} masked={r.key_masked} />
</span>
<Tip clip text={keyText(r.client, r.key_masked)}>
<span className="min-w-0 truncate">
<KeyLabel name={r.client} masked={r.key_masked} />
</span>
</Tip>
</PanelHeader>

<div className="min-h-0 flex-1 overflow-y-auto px-4 pt-4 pb-6 tw-body">
Expand Down
Loading
Loading