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
3 changes: 3 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6116,6 +6116,9 @@ function ChatViewContent(props: ChatViewProps) {
availableEditors={availableEditors}
rightPanelOpen={rightPanelOpen}
gitCwd={gitCwd}
isPreparingWorktree={isPreparingWorktreeUi}
activeThreadDriverKind={activeThreadModelPresentation?.driverKind ?? null}
activeThreadModel={activeThread.modelSelection.model}
onNewThreadInProject={handleStartNewThread}
onRunProjectScript={runProjectScript}
onAddProjectScript={saveProjectScript}
Expand Down
34 changes: 33 additions & 1 deletion apps/web/src/components/SidebarV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,11 @@ import {
type SnoozePreset,
} from "./Sidebar.snooze";
import { ProjectFavicon } from "./ProjectFavicon";
import { AiUsageStats } from "./chat/AiUsageStats";
import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";
import { resolveDriverUsage, usageDotFillClass, usageDotRingColor } from "../aiUsageState";
import { useAiUsageSnapshot } from "../hooks/useAiUsageSnapshot";
import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances";
import { primaryServerProvidersAtom } from "../state/server";
import { stackedThreadToast, toastManager } from "./ui/toast";
Expand Down Expand Up @@ -271,6 +274,9 @@ function SidebarV2ThreadTooltip({
modelInstanceId,
modelLabel,
branchMismatch,
usageDotClass,
usageRingColor,
threadUsage,
}: {
thread: SidebarThreadSummary;
projectTitle: string | null;
Expand All @@ -283,6 +289,9 @@ function SidebarV2ThreadTooltip({
threadBranch: string;
currentBranch: string;
} | null;
usageDotClass: string | undefined;
usageRingColor: string | undefined;
threadUsage: ReturnType<typeof resolveDriverUsage>;
}) {
return (
<TooltipPopup
Expand Down Expand Up @@ -333,10 +342,17 @@ function SidebarV2ThreadTooltip({
driverKind={driverKind}
displayName={thread.session?.providerName ?? modelInstanceId}
iconClassName="size-3 shrink-0 grayscale opacity-60"
{...(usageDotClass ? { statusDotClassName: usageDotClass } : {})}
{...(usageRingColor ? { statusDotRingColor: usageRingColor } : {})}
/>
<div className="min-w-0 truncate text-foreground/75">{modelLabel}</div>
</div>
) : null}
{threadUsage ? (
<div className="min-w-0 text-foreground/75">
<AiUsageStats item={threadUsage.item} compact className="min-w-0" />
</div>
) : null}
{thread.session?.lastError ? (
<div className="flex min-w-0 items-center gap-2 text-red-600 dark:text-red-400">
<CircleAlertIcon className="size-3 shrink-0 stroke-current" />
Expand Down Expand Up @@ -626,6 +642,13 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
const modelLabel = selectedModel
? getTriggerDisplayModelLabel(selectedModel)
: thread.modelSelection.model;
const aiUsageSnapshot = useAiUsageSnapshot(thread.environmentId);
const threadUsage = useMemo(
() => resolveDriverUsage(aiUsageSnapshot, driverKind, thread.modelSelection.model),
[aiUsageSnapshot, driverKind, thread.modelSelection.model],
);
const usageDotClass = threadUsage ? usageDotFillClass(threadUsage.marker) : undefined;
const usageRingColor = threadUsage ? usageDotRingColor(threadUsage.marker) : undefined;

const isRemote =
props.currentEnvironmentId !== null && thread.environmentId !== props.currentEnvironmentId;
Expand All @@ -646,6 +669,9 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
modelInstanceId={modelInstanceId}
modelLabel={modelLabel}
branchMismatch={branchMismatch}
usageDotClass={usageDotClass}
usageRingColor={usageRingColor}
threadUsage={threadUsage}
/>
);

Expand Down Expand Up @@ -1118,11 +1144,17 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
</span>
) : null}
{driverKind ? (
<span className="inline-flex shrink-0 items-center opacity-60">
<span
className="inline-flex shrink-0 items-center opacity-60"
{...(usageDotClass ? { "aria-label": "provider usage status" } : {})}
>
<ProviderInstanceIcon
driverKind={driverKind}
displayName={thread.session?.providerName ?? modelInstanceId}
iconClassName="size-3.5"
indicatorBackground="var(--sidebar, var(--background))"
{...(usageDotClass ? { statusDotClassName: usageDotClass } : {})}
{...(usageRingColor ? { statusDotRingColor: usageRingColor } : {})}
/>
</span>
) : null}
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/components/chat/ChatHeader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,3 +220,19 @@ describe("ChatHeader remote Open in VS Code surface (anti stack-drop)", () => {
expect(chatHeaderSource).toContain("VisualStudioCode");
});
});

describe("ChatHeader AI usage + host resources surface (anti stack-drop)", () => {
it("still wires conversation-level usage status and host resource gauges", () => {
// Stack product rejoins have previously re-landed pure helpers + settings
// HostResourceStatus while dropping the thread-header surfaces. Keep the
// JSX markers co-located with the pure gates so that cannot pass silently.
expect(chatHeaderSource).toContain("useAiUsageSnapshot");
expect(chatHeaderSource).toContain("resolveDriverUsage");
expect(chatHeaderSource).toContain('aria-label="provider usage status"');
expect(chatHeaderSource).toContain("AiUsageStats");
expect(chatHeaderSource).toContain("HostResourceStatus");
expect(chatHeaderSource).toContain("isLocalConnectionTarget");
expect(chatHeaderSource).toContain("activeThreadDriverKind");
expect(chatHeaderSource).toContain("activeThreadModel");
});
});
63 changes: 63 additions & 0 deletions apps/web/src/components/chat/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
type EnvironmentId,
type EditorId,
type ProjectScript,
type ProviderDriverKind,
type ResolvedKeybindingsConfig,
type ThreadId,
} from "@t3tools/contracts";
Expand All @@ -12,6 +13,7 @@ import { memo, useCallback, useMemo } from "react";
import GitActionsControl from "../GitActionsControl";
import { type DraftId } from "~/composerDraftStore";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { AiUsageStats } from "./AiUsageStats";
import ProjectScriptsControl, {
type NewProjectScriptInput,
type ProjectScriptActionResult,
Expand All @@ -24,6 +26,10 @@ import { cn } from "~/lib/utils";
import { Button } from "../ui/button";
import { VisualStudioCode } from "../Icons";
import { readLocalApi } from "~/localApi";
import { useAiUsageSnapshot } from "../../hooks/useAiUsageSnapshot";
import { resolveDriverUsage, usageDotFillClass, usageDotRingColor } from "../../aiUsageState";
import { HostResourceStatus } from "../HostResourceStatus";
import { isLocalConnectionTarget } from "~/connection/desktopLocal";

interface ChatHeaderProps {
activeThreadEnvironmentId: EnvironmentId;
Expand All @@ -39,6 +45,10 @@ interface ChatHeaderProps {
availableEditors: ReadonlyArray<EditorId>;
rightPanelOpen: boolean;
gitCwd: string | null;
isPreparingWorktree?: boolean;
/** For showing usage dot on the active thread's model at conversation level. */
activeThreadDriverKind?: ProviderDriverKind | null;
activeThreadModel?: string | null;
onNewThreadInProject: () => void;
onRunProjectScript: (script: ProjectScript) => void;
onAddProjectScript: (input: NewProjectScriptInput) => Promise<ProjectScriptActionResult>;
Expand Down Expand Up @@ -130,6 +140,9 @@ export const ChatHeader = memo(function ChatHeader({
availableEditors,
rightPanelOpen,
gitCwd,
isPreparingWorktree = false,
activeThreadDriverKind = null,
activeThreadModel = null,
onNewThreadInProject,
onRunProjectScript,
onAddProjectScript,
Expand Down Expand Up @@ -161,6 +174,15 @@ export const ChatHeader = memo(function ChatHeader({
if (!remoteVscodeTarget) return;
void readLocalApi()?.shell.openExternal(remoteVscodeTarget.uri);
}, [remoteVscodeTarget]);

const aiUsageSnapshot = useAiUsageSnapshot(activeThreadEnvironmentId);
const headerUsage = useMemo(
() => resolveDriverUsage(aiUsageSnapshot, activeThreadDriverKind, activeThreadModel),
[aiUsageSnapshot, activeThreadDriverKind, activeThreadModel],
);
const headerDotClass = headerUsage ? usageDotFillClass(headerUsage.marker) : undefined;
const headerRingColor = headerUsage ? usageDotRingColor(headerUsage.marker) : undefined;

return (
<div className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden sm:gap-3">
Expand Down Expand Up @@ -207,6 +229,46 @@ export const ChatHeader = memo(function ChatHeader({
/>
<TooltipPopup side="top">{activeThreadTitle}</TooltipPopup>
</Tooltip>
{headerDotClass && headerUsage ? (
<Tooltip>
<TooltipTrigger
render={
<span
className={`inline-block size-2 shrink-0 rounded-full ${headerDotClass} cursor-help`}
style={
headerRingColor
? { boxShadow: `0 0 0 1.5px ${headerRingColor}, 0 0 0 3px var(--card)` }
: undefined
}
aria-label="provider usage status"
/>
}
/>
<TooltipPopup side="bottom" className="p-2 text-xs">
<AiUsageStats item={headerUsage.item} />
</TooltipPopup>
</Tooltip>
) : headerDotClass ? (
<span
className={`inline-block size-2 shrink-0 rounded-full ${headerDotClass}`}
style={
headerRingColor
? { boxShadow: `0 0 0 1.5px ${headerRingColor}, 0 0 0 3px var(--card)` }
: undefined
}
aria-label="provider usage status"
title="Usage status for current model"
/>
) : null}
<HostResourceStatus
environmentId={activeThreadEnvironmentId}
environmentLabel={activeEnvironment?.label ?? "Active environment"}
connected={activeEnvironment?.connection.phase === "connected"}
remote={
activeEnvironment ? !isLocalConnectionTarget(activeEnvironment.entry.target) : false
}
className="hidden @2xl/header-actions:flex"
/>
</div>
<div
data-chat-header-actions
Expand Down Expand Up @@ -261,6 +323,7 @@ export const ChatHeader = memo(function ChatHeader({
<GitActionsControl
gitCwd={gitCwd}
activeThreadRef={scopeThreadRef(activeThreadEnvironmentId, activeThreadId)}
isPreparingWorktree={isPreparingWorktree}
{...(draftId ? { draftId } : {})}
/>
)}
Expand Down
44 changes: 44 additions & 0 deletions apps/web/src/forkSurfaceExistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,50 @@ describe("fork surface existence (anti stack-drop)", () => {
expect(header).toContain("shell.openExternal");
});

it("chat header keeps AI usage status and host resource gauges", () => {
const header = readSrc("components/chat/ChatHeader.tsx");
expect(header).toContain("HostResourceStatus");
expect(header).toContain('aria-label="provider usage status"');
expect(header).toContain("AiUsageStats");
expect(header).toContain("useAiUsageSnapshot");
const chatView = readSrc("components/ChatView.tsx");
expect(chatView).toContain("activeThreadDriverKind=");
expect(chatView).toContain("activeThreadModel=");
expect(chatView).toContain("isPreparingWorktree=");
});

it("model picker keeps provider usage dots and selection-box stats", () => {
const providerPicker = readSrc("components/chat/ProviderModelPicker.tsx");
expect(providerPicker).toContain("usageSnapshot");
expect(providerPicker).toContain("statusDotClassName");
expect(providerPicker).toContain("AiUsageStats");
const modelPickerContent = readSrc("components/chat/ModelPickerContent.tsx");
expect(modelPickerContent).toContain("resolveDriverUsages");
expect(modelPickerContent).toContain("AiUsageStats");
const modelPickerSidebar = readSrc("components/chat/ModelPickerSidebar.tsx");
expect(modelPickerSidebar).toContain("statusDotClassName");
expect(modelPickerSidebar).toContain("usageDotFillClass");
});

it("classic sidebar thread rows keep provider usage dots + stats", () => {
const sidebar = readSrc("components/Sidebar.tsx");
expect(sidebar).toContain("useAiUsageSnapshot");
expect(sidebar).toContain("resolveDriverUsage");
expect(sidebar).toContain("usageDotFillClass");
expect(sidebar).toContain("AiUsageStats");
expect(sidebar).toContain("hasUsageMarker");
});

it("Sidebar V2 thread rows keep provider usage dots + stats", () => {
const sidebarV2 = readSrc("components/SidebarV2.tsx");
expect(sidebarV2).toContain("useAiUsageSnapshot");
expect(sidebarV2).toContain("resolveDriverUsage");
expect(sidebarV2).toContain("usageDotFillClass");
expect(sidebarV2).toContain("statusDotClassName");
expect(sidebarV2).toContain("AiUsageStats");
expect(sidebarV2).toContain('aria-label": "provider usage status"');
});

it("queued message chips keep edit + steer labels", () => {
const chips = readSrc("components/chat/QueuedMessageChips.tsx");
expect(chips).toContain('aria-label="Edit queued message"');
Expand Down
Loading