Skip to content

Commit d871ef5

Browse files
feat: chat context window ring with hover dialog, runtimeState persistence, and AI SDK v7 usage fix
Co-authored-by: Tempest <tempestai.dev@gmail.com>
1 parent 28c4827 commit d871ef5

9 files changed

Lines changed: 304 additions & 28 deletions

File tree

src/assets/gradient-10.png

15.5 KB
Loading

src/assets/gradient-13.png

16.9 KB
Loading

src/assets/tempest-chat.png

76 KB
Loading

src/components/ChatPane.css

Lines changed: 115 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -106,24 +106,44 @@
106106
margin: 0 auto 20px;
107107
padding: 0 24px;
108108
box-sizing: border-box;
109+
display: flex;
110+
align-items: flex-start;
111+
gap: 12px;
109112
}
110113

111-
.chat-msg-role {
112-
font-size: 10px;
113-
font-weight: 600;
114-
color: var(--tempest-fg-subtle);
115-
text-transform: uppercase;
116-
letter-spacing: 0.07em;
117-
margin-bottom: 4px;
118-
user-select: none;
114+
.chat-msg-avatar {
115+
width: 28px;
116+
height: 28px;
117+
border-radius: 6px;
118+
flex-shrink: 0;
119+
}
120+
121+
.chat-msg-avatar--user {
122+
background-image: url('../assets/gradient-13.png');
123+
background-size: cover;
124+
background-position: center;
125+
}
126+
:root[data-theme="light"] .chat-msg-avatar--user {
127+
background-image: url('../assets/gradient-10.png');
128+
}
129+
130+
.chat-msg-avatar--assistant {
131+
object-fit: cover;
132+
}
133+
:root[data-theme="light"] .chat-msg-avatar--assistant {
134+
filter: invert(1);
119135
}
120136

137+
121138
.chat-msg-body {
139+
flex: 1;
140+
min-width: 0;
122141
font-size: 13px;
123142
color: var(--tempest-fg-default);
124143
line-height: 1.65;
125144
white-space: pre-wrap;
126145
word-break: break-word;
146+
padding-top: 5px;
127147
}
128148

129149
.chat-msg-body p { margin: 0 0 8px; }
@@ -527,3 +547,90 @@
527547
text-align: center;
528548
padding: 24px 16px;
529549
}
550+
551+
/* ── Context ring ── */
552+
.chat-ctx-ring {
553+
position: relative;
554+
display: flex;
555+
align-items: center;
556+
justify-content: center;
557+
width: 20px;
558+
height: 20px;
559+
flex-shrink: 0;
560+
cursor: default;
561+
}
562+
563+
.chat-ctx-track {
564+
stroke: var(--tempest-border-default);
565+
}
566+
567+
.chat-ctx-progress {
568+
stroke: var(--tempest-fg-subtle);
569+
transition: stroke-dashoffset 0.5s ease, stroke 0.3s ease;
570+
}
571+
572+
.chat-ctx-ring--warn .chat-ctx-progress { stroke: #f59e0b; }
573+
.chat-ctx-ring--danger .chat-ctx-progress { stroke: #ef4444; }
574+
575+
/* ── Context ring popup ── */
576+
.chat-ctx-popup {
577+
position: absolute;
578+
bottom: calc(100% + 10px);
579+
right: 0;
580+
width: 188px;
581+
background: color-mix(in srgb, var(--tempest-bg-panel) 97%, transparent);
582+
backdrop-filter: blur(12px);
583+
-webkit-backdrop-filter: blur(12px);
584+
border: 1px solid var(--tempest-border-default);
585+
border-radius: 8px;
586+
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.28);
587+
padding: 10px 12px;
588+
display: flex;
589+
flex-direction: column;
590+
gap: 8px;
591+
z-index: 200;
592+
pointer-events: none;
593+
user-select: none;
594+
}
595+
596+
.chat-ctx-popup-title {
597+
font-size: 11px;
598+
font-weight: 600;
599+
color: var(--tempest-fg-muted);
600+
text-transform: uppercase;
601+
letter-spacing: 0.06em;
602+
}
603+
604+
.chat-ctx-popup-bar {
605+
height: 3px;
606+
background: var(--tempest-border-default);
607+
border-radius: 99px;
608+
overflow: hidden;
609+
}
610+
611+
.chat-ctx-popup-fill {
612+
height: 100%;
613+
border-radius: 99px;
614+
background: var(--tempest-fg-subtle);
615+
transition: width 0.4s ease, background 0.3s ease;
616+
}
617+
618+
.chat-ctx-popup-fill--warn { background: #f59e0b; }
619+
.chat-ctx-popup-fill--danger { background: #ef4444; }
620+
621+
.chat-ctx-popup-row {
622+
display: flex;
623+
align-items: center;
624+
justify-content: space-between;
625+
}
626+
627+
.chat-ctx-popup-label {
628+
font-size: 11px;
629+
color: var(--tempest-fg-subtle);
630+
}
631+
632+
.chat-ctx-popup-value {
633+
font-size: 11px;
634+
font-family: var(--font-mono);
635+
color: var(--tempest-fg-default);
636+
}

src/components/ChatPane.tsx

Lines changed: 124 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import { useState, useRef, useEffect, useCallback } from "react";
22
import { createPortal } from "react-dom";
33
import ReactMarkdown from "react-markdown";
44
import remarkGfm from "remark-gfm";
5-
import { Plus, ArrowUp, SlidersHorizontal, ChevronDown, Search, Bug, GitPullRequest } from "lucide-react";
5+
import { Plus, ArrowUp, ChevronDown, Search, Bug, GitPullRequest } from "lucide-react";
66
import { streamChat } from "../lib/chat";
77
import { getRuntimeState, setRuntimeState } from "../lib/runtimeState";
8+
import tempestChat from "../assets/tempest-chat.png";
89
import "./ChatPane.css";
910

1011
const CDN = "https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/";
@@ -142,6 +143,48 @@ const PROVIDER_MODELS: Record<string, ChatModel[]> = {
142143
],
143144
};
144145

146+
// Context window sizes per model (tokens)
147+
const MODEL_CONTEXT: Record<string, number> = {
148+
"claude-fable-5": 2_000_000,
149+
"claude-opus-4-8": 200_000,
150+
"claude-opus-4-7": 200_000,
151+
"claude-opus-4-6": 200_000,
152+
"claude-opus-4-5": 200_000,
153+
"claude-sonnet-5": 200_000,
154+
"claude-sonnet-4-6": 200_000,
155+
"claude-sonnet-4-5": 200_000,
156+
"claude-haiku-4-5-20251001": 200_000,
157+
"gpt-4.1": 1_047_576,
158+
"gpt-4.1-mini": 1_047_576,
159+
"gpt-4.1-nano": 1_047_576,
160+
"gpt-4o": 128_000,
161+
"gpt-4o-mini": 128_000,
162+
"o3": 200_000,
163+
"o3-mini": 200_000,
164+
"o4-mini": 200_000,
165+
"gemini-2.5-pro": 1_048_576,
166+
"gemini-2.5-flash": 1_048_576,
167+
"gemini-flash-latest": 1_048_576,
168+
"gemini-flash-lite-latest": 1_048_576,
169+
"mistral-large-latest": 131_072,
170+
"mistral-large-2512": 131_072,
171+
"mistral-medium-2508": 131_072,
172+
"magistral-medium-2509": 131_072,
173+
"magistral-small-2509": 131_072,
174+
"mistral-small-latest": 131_072,
175+
"mistral-small-2603": 131_072,
176+
"deepseek-v3": 163_840,
177+
"deepseek-chat": 163_840,
178+
"deepseek-reasoner": 163_840,
179+
"grok-4": 256_000,
180+
"grok-3": 131_072,
181+
"grok-3-mini": 131_072,
182+
};
183+
const DEFAULT_CONTEXT = 128_000;
184+
function getContextSize(modelId: string): number {
185+
return MODEL_CONTEXT[modelId] ?? DEFAULT_CONTEXT;
186+
}
187+
145188
interface ChatMessage {
146189
id: string;
147190
role: "user" | "assistant";
@@ -188,6 +231,14 @@ export function ChatPane({ hidden, projectPath }: Props) {
188231
const [pickerProvider, setPickerProvider] = useState(CHAT_PROVIDERS[0].id);
189232
const [search, setSearch] = useState("");
190233

234+
const [contextTokens, setContextTokens] = useState(() => {
235+
// Only restore if there's actual history — stale tokens with an empty chat makes no sense
236+
const history = loadChatHistory(projectPath);
237+
if (history.length === 0) return 0;
238+
return getRuntimeState().chatContextTokens[projectPath ?? ""] ?? 0;
239+
});
240+
const [ctxPopupOpen, setCtxPopupOpen] = useState(false);
241+
191242
const editableRef = useRef<HTMLDivElement>(null);
192243
const messagesEndRef = useRef<HTMLDivElement>(null);
193244
const pickerBtnRef = useRef<HTMLButtonElement>(null);
@@ -237,6 +288,17 @@ export function ChatPane({ hidden, projectPath }: Props) {
237288
));
238289
},
239290
controller.signal,
291+
(inputTokens, outputTokens) => {
292+
// Context used = prompt tokens + the tokens we just generated (both
293+
// occupy the window on the next turn). Input alone is tiny on the
294+
// first turn and rounds to 0.0k, hiding the real usage.
295+
const used = inputTokens + outputTokens;
296+
setContextTokens(used);
297+
const st = getRuntimeState();
298+
setRuntimeState({
299+
chatContextTokens: { ...st.chatContextTokens, [projectPath ?? ""]: used },
300+
});
301+
},
240302
);
241303
} catch (err: unknown) {
242304
const msg = err instanceof Error ? err.message : "Something went wrong.";
@@ -302,6 +364,17 @@ export function ChatPane({ hidden, projectPath }: Props) {
302364
: rawPickerModels;
303365
const hasMessages = messages.length > 0 || isLoading;
304366

367+
// Context ring geometry
368+
const ctxSize = getContextSize(model.id);
369+
const ctxPct = contextTokens > 0 ? Math.min(contextTokens / ctxSize, 1) : 0;
370+
const ctxR = 7;
371+
const ctxCirc = 2 * Math.PI * ctxR;
372+
const ctxOffset = ctxCirc * (1 - ctxPct);
373+
const ctxLevel = ctxPct >= 0.9 ? "danger" : ctxPct >= 0.7 ? "warn" : "ok";
374+
const ctxUsedK = (contextTokens / 1000).toFixed(1);
375+
const ctxTotalK = Math.round(ctxSize / 1000);
376+
const ctxLeftK = ((ctxSize - contextTokens) / 1000).toFixed(1);
377+
305378
const inputBox = (
306379
<div className="chat-box" onClick={focusInput}>
307380
{isEmpty && <div className="chat-box-ph">Ask anything…</div>}
@@ -339,9 +412,47 @@ export function ChatPane({ hidden, projectPath }: Props) {
339412
</button>
340413

341414
<div className="chat-bar-space" />
342-
<button className="chat-bar-btn" disabled title="Preferences">
343-
<SlidersHorizontal size={14} />
344-
</button>
415+
416+
{/* Context ring — visible only once we have real usage data */}
417+
{hasMessages && contextTokens > 0 && (
418+
<div
419+
className={`chat-ctx-ring chat-ctx-ring--${ctxLevel}`}
420+
onMouseEnter={() => setCtxPopupOpen(true)}
421+
onMouseLeave={() => setCtxPopupOpen(false)}
422+
>
423+
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
424+
<circle cx="10" cy="10" r={ctxR} strokeWidth="2" className="chat-ctx-track" />
425+
<circle
426+
cx="10" cy="10" r={ctxR} strokeWidth="2"
427+
strokeDasharray={ctxCirc}
428+
strokeDashoffset={ctxOffset}
429+
strokeLinecap="round"
430+
transform={`rotate(-90 10 10)`}
431+
className="chat-ctx-progress"
432+
/>
433+
</svg>
434+
{ctxPopupOpen && (
435+
<div className="chat-ctx-popup">
436+
<div className="chat-ctx-popup-title">Context window</div>
437+
<div className="chat-ctx-popup-bar">
438+
<div
439+
className={`chat-ctx-popup-fill chat-ctx-popup-fill--${ctxLevel}`}
440+
style={{ width: `${Math.round(ctxPct * 100)}%` }}
441+
/>
442+
</div>
443+
<div className="chat-ctx-popup-row">
444+
<span className="chat-ctx-popup-label">Used</span>
445+
<span className="chat-ctx-popup-value">{ctxUsedK}k / {ctxTotalK}k</span>
446+
</div>
447+
<div className="chat-ctx-popup-row">
448+
<span className="chat-ctx-popup-label">Remaining</span>
449+
<span className="chat-ctx-popup-value">{ctxLeftK}k tokens</span>
450+
</div>
451+
</div>
452+
)}
453+
</div>
454+
)}
455+
345456
<button
346457
className="chat-bar-send"
347458
onClick={(e) => { e.stopPropagation(); send(); }}
@@ -361,9 +472,15 @@ export function ChatPane({ hidden, projectPath }: Props) {
361472
<div className="chat-msgs">
362473
{messages.map((msg) => (
363474
<div key={msg.id} className="chat-msg">
364-
<div className="chat-msg-role">
365-
{msg.role === "user" ? "You" : "Assistant"}
366-
</div>
475+
{msg.role === "user" ? (
476+
<div className="chat-msg-avatar chat-msg-avatar--user" />
477+
) : (
478+
<img
479+
className="chat-msg-avatar chat-msg-avatar--assistant"
480+
src={tempestChat}
481+
alt="Tempest"
482+
/>
483+
)}
367484
<div className="chat-msg-body">
368485
{msg.role === "assistant" ? (
369486
isLoading && msg.content === "" ? (

src/components/WorkspaceView.tsx

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -512,8 +512,11 @@ export function WorkspaceView({ zen, name, path }: Props) {
512512
}
513513

514514
// Non-terminal tabs (diff, preview, editor) — only for projects still open.
515+
// Chat tabs are intentionally excluded: their PersistedTab is kept as a ghost
516+
// sidebar marker even after the user closes them, so restoring them here would
517+
// re-open a tab the user explicitly closed. The ghost handles re-entry instead.
515518
const openProjectIds = new Set(projects.map((p) => p.id));
516-
for (const tab of savedTabs.filter((t) => openProjectIds.has(t.projectId))) {
519+
for (const tab of savedTabs.filter((t) => openProjectIds.has(t.projectId) && t.kind !== "chat")) {
517520
const newId = crypto.randomUUID();
518521
items.push({
519522
instanceId: tab.instanceId,
@@ -1124,10 +1127,20 @@ export function WorkspaceView({ zen, name, path }: Props) {
11241127
function openChatTab(projectId: string) {
11251128
const existing = sessionsRef.current.find((s) => s.kind === "chat" && s.projectId === projectId);
11261129
if (existing) { setActiveSessionId(existing.id); return; }
1127-
const sessionId = crypto.randomUUID();
1128-
const tab: PersistedTab = { instanceId: sessionId, kind: "chat", projectId, cwd: "", name: "Chat" };
1130+
// Reuse the existing PersistedTab's instanceId when the chat is a ghost (closed but
1131+
// not removed). Without this, every click on the ghost mints a new UUID and pushes a
1132+
// second PersistedTab — leading to two tabs restored side-by-side on the next boot.
11291133
const st = getRuntimeState();
1130-
setRuntimeState({ tabs: [...st.tabs, tab] });
1134+
const existingChatTabs = st.tabs.filter((t) => t.kind === "chat" && t.projectId === projectId);
1135+
const existingTab = existingChatTabs[0];
1136+
const sessionId = existingTab?.instanceId ?? crypto.randomUUID();
1137+
if (existingChatTabs.length > 1) {
1138+
// Purge accidental duplicates left by the old bug: keep only the first entry.
1139+
setRuntimeState({ tabs: st.tabs.filter((t) => !(t.kind === "chat" && t.projectId === projectId && t.instanceId !== sessionId)) });
1140+
}
1141+
if (!existingTab) {
1142+
setRuntimeState({ tabs: [...st.tabs, { instanceId: sessionId, kind: "chat", projectId, cwd: "", name: "Chat" }] });
1143+
}
11311144
setSessions((prev) => [...prev, {
11321145
id: sessionId, instanceId: sessionId, name: "Chat", cwd: "", projectId, kind: "chat",
11331146
createdAt: new Date().toISOString(),
@@ -2659,9 +2672,21 @@ export function WorkspaceView({ zen, name, path }: Props) {
26592672
</button>
26602673
)}
26612674
{hasChat && (
2662-
<button className="ctx-item ctx-item--danger" onClick={() => { clearChatHistory(m.projectId, targetSession?.id); setCtxMenu(null); }}>
2663-
<Trash2 size={13} /> Clear history
2664-
</button>
2675+
<>
2676+
<button className="ctx-item ctx-item--danger" onClick={() => { clearChatHistory(m.projectId, targetSession?.id); setCtxMenu(null); }}>
2677+
<Trash2 size={13} /> Clear history
2678+
</button>
2679+
<button className="ctx-item ctx-item--danger" onClick={() => {
2680+
const chatSess = sessions.find((s) => s.kind === "chat" && s.projectId === m.projectId);
2681+
if (chatSess) closeSession(chatSess.id);
2682+
const st = getRuntimeState();
2683+
setRuntimeState({ tabs: st.tabs.filter((t) => !(t.kind === "chat" && t.projectId === m.projectId)) });
2684+
clearChatHistory(m.projectId, chatSess?.id);
2685+
setCtxMenu(null);
2686+
}}>
2687+
<X size={13} /> Remove chat
2688+
</button>
2689+
</>
26652690
)}
26662691

26672692
{/* ── Destructive ── */}

src/components/onboarding/WelcomePage.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import { TempestLogo } from '../../assets/TempestLogo';
44
import screenshotDark from '../../assets/onboarding/screenshot-dark.png';
55
import screenshotLight from '../../assets/onboarding/screenshot-light.png';
66

7+
// Preload both at module load time so they're decoded before the page renders
8+
const _preloadDark = new Image(); _preloadDark.src = screenshotDark;
9+
const _preloadLight = new Image(); _preloadLight.src = screenshotLight;
10+
711
interface Props {
812
onStart: () => void;
913
onSkip: () => void;

0 commit comments

Comments
 (0)