Skip to content
Closed
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
72 changes: 64 additions & 8 deletions studio/src/app/workspace/chat/_components/chat-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
CirclePlus,
Ellipsis,
FileText,
FoldVertical,
Loader2,
MessageCircle,
PanelLeftClose,
Expand All @@ -17,7 +18,7 @@ import {
Trash2,
Wrench,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
Expand All @@ -37,20 +38,28 @@ import type {
ApprovalRequest,
Attachment,
ClarificationRequest,
ToolCallInfo,
} from "@/features/agent";
import { formatTokens } from "@/lib/formatters";
import type { SessionListSide } from "@/lib/profile-preferences";
import {
type SessionListSide,
useShowToolCalls,
} from "@/lib/profile-preferences";
import { cn } from "@/lib/utils";
import { ChatInput } from "../../_components/chat-input";
import { ApprovalPanel } from "./approval-panel";
import { ClarificationPanel } from "./clarification-panel";
import { ContextMeter } from "./context-meter";
import { FilePreview } from "./file-preview";
import { MessageBubble } from "./message-bubble";
import { MockProviderNotice } from "./mock-provider-notice";
import { SidePanel } from "./side-panel";
import { ToolCallPanel } from "./tool-call-panel";

/** The single right-hand panel: exactly one kind is open at a time, or none. */
type ActivePanel = { kind: "attachment"; attachment: Attachment };
type ActivePanel =
| { kind: "attachment"; attachment: Attachment }
| { kind: "toolcall"; call: ToolCallInfo };

/**
* Bottom-of-transcript activity line while a turn is running: three
Expand Down Expand Up @@ -287,6 +296,8 @@ export function ChatView({
onSidePanelOpenChange,
initialDraft,
onInitialDraftConsumed,
onCompact,
contextInfo,
}: {
session: AgentSession;
messages: AgentMessage[];
Expand Down Expand Up @@ -315,6 +326,12 @@ export function ChatView({
chip that seeds the message without sending it). */
initialDraft?: string | null;
onInitialDraftConsumed?: () => void;
/** Manually compacts the conversation (B1.2); present only when the
daemon's manual_compaction capability is on. Disabled while streaming. */
onCompact?: () => void;
/** The session's effective model + context window (B1.1): feeds the slim
approximate context meter near the composer. */
contextInfo?: { modelLabel: string; contextWindow: number } | null;
}) {
const messagesEndRef = useRef<HTMLDivElement>(null);
// Whether the transcript is scrolled to (near) the bottom; when it isn't,
Expand All @@ -324,7 +341,8 @@ export function ChatView({
const [atBottom, setAtBottom] = useState(true);
const atBottomRef = useRef(true);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const [showActivity, setShowActivity] = useState(false);
// The global Show Tools preference (persisted; shared with thread panels).
const { showToolCalls: showActivity, setShowToolCalls } = useShowToolCalls();
// The single right-hand panel — a discriminated union makes "one panel at a
// time" structural rather than something to coordinate by hand.
const [panel, setPanel] = useState<ActivePanel | null>(null);
Expand Down Expand Up @@ -363,6 +381,25 @@ export function ChatView({
[releasePreviewUrl],
);

// Drill-down from a row in the inline activity list to the call's full
// untruncated input/output in the side panel.
const handleOpenToolCall = useCallback(
(call: ToolCallInfo) => setPanel({ kind: "toolcall", call }),
[],
);

// The tool-call panel tracks the LIVE call: the stream replaces call
// objects as results land, so re-resolve by callId each render — the
// clicked snapshot would otherwise read "running" forever.
const activePanel = useMemo<ActivePanel | null>(() => {
if (panel?.kind !== "toolcall") return panel;
for (const msg of messages) {
const live = msg.toolCalls?.find((c) => c.callId === panel.call.callId);
if (live) return { kind: "toolcall", call: live };
}
return panel;
}, [panel, messages]);

// Jump to the latest message whenever the active chat changes so users
// always land at the bottom (most-recent) of the conversation.
// biome-ignore lint/correctness/useExhaustiveDependencies: scrolling is intentionally driven by session.id changes
Expand Down Expand Up @@ -394,7 +431,7 @@ export function ChatView({
// With the list docked right, an open side panel occupies its slot — the
// toggle then means "give me the list back": close the panel, and the
// workspace restores the sidebar to its pre-panel state.
const panelHoldsSidebarSlot = sidebarSide === "right" && panel !== null;
const panelHoldsSidebarSlot = sidebarSide === "right" && activePanel !== null;
const sidebarToggle = (
<Tooltip>
<TooltipTrigger asChild>
Expand Down Expand Up @@ -478,10 +515,16 @@ export function ChatView({
{/* Token usage as the daemon reported it (was a header pill;
it lives in the menu now). Hidden until any lands. */}
<UsageMenuRow usage={usage} />
<DropdownMenuItem onClick={() => setShowActivity((v) => !v)}>
<DropdownMenuItem onClick={() => setShowToolCalls(!showActivity)}>
<Wrench className="size-4 mr-2 text-muted-foreground" />
{showActivity ? "Hide Tools" : "Show Tools"}
</DropdownMenuItem>
{onCompact && (
<DropdownMenuItem disabled={isStreaming} onClick={onCompact}>
<FoldVertical className="size-4 mr-2 text-muted-foreground" />
Compact conversation
</DropdownMenuItem>
)}
{onRename && (
<DropdownMenuItem onClick={onRename}>
<Pencil className="size-4 mr-2 text-muted-foreground" />
Expand Down Expand Up @@ -522,6 +565,7 @@ export function ChatView({
onOpenAttachment={(attachment) =>
setPanel({ kind: "attachment", attachment })
}
onOpenToolCall={handleOpenToolCall}
botName={botName}
showActivity={showActivity}
/>
Expand Down Expand Up @@ -562,6 +606,16 @@ export function ChatView({
</div>
)}
<div className="max-w-[768px] space-y-1.5 max-[499px]:max-w-none">
{/* B1.1: effective model + approximate context utilisation,
visible only when the window is known and tokens counted. */}
{contextInfo && contextInfo.contextWindow > 0 && (
<ContextMeter
modelLabel={contextInfo.modelLabel}
contextWindow={contextInfo.contextWindow}
inputTokens={usage?.inputTokens ?? 0}
outputTokens={usage?.outputTokens ?? 0}
/>
)}
{error && (
<div className="flex items-center gap-2 rounded-lg border border-destructive/40 bg-background bg-gradient-to-b from-destructive/5 to-destructive/5 px-3 py-2">
<AlertCircle className="size-4 shrink-0 text-destructive" />
Expand Down Expand Up @@ -604,9 +658,9 @@ export function ChatView({
</div>
</div>
</div>
{panel !== null && (
{activePanel !== null && (
<SidePanelForKind
panel={panel}
panel={activePanel}
onClose={closeSidePanel}
maximized={panelMaximized}
onToggleMaximize={toggleMaximize}
Expand All @@ -632,5 +686,7 @@ function SidePanelForKind({
switch (panel.kind) {
case "attachment":
return <AttachmentPanel attachment={panel.attachment} {...shared} />;
case "toolcall":
return <ToolCallPanel call={panel.call} {...shared} />;
}
}
64 changes: 64 additions & 0 deletions studio/src/app/workspace/chat/_components/chat-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { Loader2, PanelLeft, PanelRight, SquarePen } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Tooltip,
Expand All @@ -16,11 +17,17 @@ import {
useAgentRoster,
useAgentSessions,
} from "@/features/agent";
import { useRuntimeStatus } from "@/features/agent/runtime-status";
import { useConfirm } from "@/hooks/use-confirm";
import { useIsCompact } from "@/hooks/use-mobile";
import { useNavReopenSidebar } from "@/hooks/use-nav-reopen-sidebar";
import { usePanelWidth } from "@/hooks/use-panel-width";
import { usePrompt } from "@/hooks/use-prompt";
import {
compactHarnessSession,
fetchHarnessSessionDetail,
type HarnessResolvedModel,
} from "@/lib/harness/client";
import {
type SessionListSide,
useAgentDisplayName,
Expand Down Expand Up @@ -277,6 +284,12 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) {
// Selection is URL-driven; the state mirror keeps it in sync while also
// allowing an optimistic update before the client navigation settles.
const [selectedId, setSelectedIdState] = useState(sessionId ?? "");
// Mirror for effects that must read the current selection without
// re-firing on every selection change.
const selectedIdRef = useRef(selectedId);
useEffect(() => {
selectedIdRef.current = selectedId;
}, [selectedId]);
// The id minted for a draft on first send. While the URL settles on that id
// the chat hook keeps its draft binding (it already owns the live stream);
// re-keying it would wipe the in-flight messages with a transcript refetch.
Expand Down Expand Up @@ -365,6 +378,7 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) {
harnessLive,
sendMessage,
retryLast,
refreshTranscript,
pendingApproval,
respondToApproval,
pendingClarification,
Expand All @@ -374,6 +388,47 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) {
onSessionCreated: handleSessionCreated,
});

// The daemon's operator-enabled capabilities (A3 caches /v1/compatibility).
const { connected, serverCapabilities } = useRuntimeStatus();

// The session's effective model + context window (B1.1): GET-session's
// resolved_model echo. Per selected chat; a fetch failure just hides the
// meter (it is an approximation, never load-bearing).
const [resolvedModel, setResolvedModel] =
useState<HarnessResolvedModel | null>(null);
useEffect(() => {
setResolvedModel(null);
if (!selectedId || !connected) return;
const controller = new AbortController();
void fetchHarnessSessionDetail(selectedId, controller.signal)
.then((detail) => {
if (!controller.signal.aborted) setResolvedModel(detail.resolvedModel);
})
.catch(() => undefined);
return () => controller.abort();
}, [selectedId, connected]);

// Manual compaction (B1.2-B1.4): gated on the daemon's manual_compaction
// capability (the compatibility document is the live source; the GET-session
// echo is its per-session sibling once daemons stamp it).
const compactSupported = serverCapabilities.manual_compaction === true;
const handleCompact = useCallback(async () => {
const id = selectedIdRef.current;
if (!id) return;
try {
const compacted = await compactHarnessSession(id);
toast.success(
compacted ? "Conversation compacted" : "Nothing to compact",
);
if (compacted) {
// The model history was rewritten; the transcript must refetch.
await refreshTranscript();
}
} catch (caught) {
toast.error(caught instanceof Error ? caught.message : String(caught));
}
}, [refreshTranscript]);

useNavReopenSidebar(setSidebarOpen);

// Seed for the draft composer, set when a starter prompt is picked.
Expand Down Expand Up @@ -531,6 +586,15 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) {
error={turnError}
onRetry={retryLast}
onSend={sendMessage}
onCompact={compactSupported ? handleCompact : undefined}
contextInfo={
resolvedModel && resolvedModel.contextWindow > 0
? {
modelLabel: resolvedModel.modelId,
contextWindow: resolvedModel.contextWindow,
}
: null
}
botName={agentName}
sidebarOpen={open}
sidebarSide={sidebarSide}
Expand Down
28 changes: 28 additions & 0 deletions studio/src/app/workspace/chat/_components/context-meter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { contextUtilisation } from "./context-meter";

/**
* Pins the context-meter math (B1.1): counted input+output tokens over the
* resolved model's context window, clamped, and honestly null when the window
* is unknown — the meter must never render against a made-up denominator.
*/
describe("contextUtilisation", () => {
it("computes the fraction of the window the counted tokens occupy", () => {
expect(contextUtilisation(30_000, 10_000, 400_000)).toBeCloseTo(0.1);
expect(contextUtilisation(0, 0, 400_000)).toBe(0);
});

it("clamps overshoot to 1 (the daemon compacts before the client's approximation catches up)", () => {
expect(contextUtilisation(500_000, 100_000, 400_000)).toBe(1);
});

it("returns null when the window is unknown", () => {
expect(contextUtilisation(1_000, 1_000, 0)).toBeNull();
expect(contextUtilisation(1_000, 1_000, -1)).toBeNull();
expect(contextUtilisation(1_000, 1_000, Number.NaN)).toBeNull();
});

it("ignores negative token figures rather than going below zero", () => {
expect(contextUtilisation(-5, 100, 1_000)).toBeCloseTo(0.1);
});
});
70 changes: 70 additions & 0 deletions studio/src/app/workspace/chat/_components/context-meter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"use client";

/**
* The slim context strip near the composer (B1.1): the session's effective
* model and an APPROXIMATE context-utilisation meter — cumulative input+output
* tokens this visit counted (summed from the runs' terminal usage frames) vs
* the model's `resolved_model.context_window`. Deliberately labeled
* approximate: the daemon's true compaction trigger also counts the system
* prompt and tool schemas, which the client never sees, and a page reload
* loses the visit's running total.
*/

import { cn } from "@/lib/utils";

/**
* Fraction of the context window the counted tokens occupy, clamped to
* [0, 1]. Null when the window is unknown (<= 0) — the meter must not render
* against a made-up denominator.
*/
export function contextUtilisation(
inputTokens: number,
outputTokens: number,
contextWindow: number,
): number | null {
if (!Number.isFinite(contextWindow) || contextWindow <= 0) return null;
const used = Math.max(0, inputTokens) + Math.max(0, outputTokens);
return Math.min(1, used / contextWindow);
}

export function ContextMeter({
modelLabel,
contextWindow,
inputTokens,
outputTokens,
}: {
/** The effective model (resolved_model.model_id). */
modelLabel: string;
contextWindow: number;
inputTokens: number;
outputTokens: number;
}) {
const fraction = contextUtilisation(inputTokens, outputTokens, contextWindow);
// No window, or nothing counted yet this visit: showing "0%" on a chat
// whose history the daemon still carries would be a lie, so stay quiet.
if (fraction === null || inputTokens + outputTokens <= 0) return null;
const percent = Math.round(fraction * 100);
return (
<div className="flex items-center gap-2 px-2 text-[11px] text-muted-foreground/80">
{modelLabel && <span className="truncate font-medium">{modelLabel}</span>}
<span
className="h-1 w-16 shrink-0 overflow-hidden rounded-full bg-border"
aria-hidden="true"
>
<span
className={cn(
"block h-full rounded-full",
fraction >= 0.85 ? "bg-warning" : "bg-brand/60",
)}
style={{ width: `${Math.max(2, percent)}%` }}
/>
</span>
<span className="whitespace-nowrap tabular-nums">
~{percent}% of context
</span>
<span className="whitespace-nowrap text-muted-foreground/60">
approximate
</span>
</div>
);
}
Loading
Loading