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
33 changes: 4 additions & 29 deletions apps/desktop/src/components/chat/context-meter.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,18 @@
import { Link } from "react-router";
import { motion } from "motion/react";
import { ArrowRight, FoldVertical, Loader2, X } from "lucide-react";
import type { CompactInfo, ConversationContext } from "@godmode/shared";
import { COMPRESS_MIN_TOKENS, DEFAULT_AUTO_COMPACT_PERCENT } from "@godmode/shared";
import type { CompactInfo, ContextGauge, ConversationContext } from "@godmode/shared";
import { contextGauge, tokensLabel } from "@godmode/shared";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useSettings } from "@/lib/hooks";
import { cn } from "@/lib/utils";

/** Claude Code summarizes by itself close to a full window when Godmode sets no threshold. */
const CLAUDE_DEFAULT_SHARE = 0.9;

export function tokensLabel(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 100_000 ? 0 : 1)}k`;
return String(n);
}

export interface ContextGauge {
tokens: number;
window: number | null;
/** Share of the window in use, 0..1; null without a window. */
share: number | null;
/** Where Claude summarizes by itself (share of the window); null = never. */
autoAt: number | null;
tone: "calm" | "warn" | "full";
/** Worth a summary at all. */
compressible: boolean;
}
export { tokensLabel, type ContextGauge };

export function useContextGauge(context: ConversationContext | null | undefined): ContextGauge | null {
const pct = useSettings().data?.runner.autoCompactPercent;
if (!context) return null;
const autoAt = pct === 0 ? null : pct === null ? CLAUDE_DEFAULT_SHARE : (pct ?? DEFAULT_AUTO_COMPACT_PERCENT) / 100;
const share = context.window ? Math.min(1, context.tokens / context.window) : null;
const limit = autoAt ?? CLAUDE_DEFAULT_SHARE;
const tone = share === null ? "calm" : share >= limit * 0.95 ? "full" : share >= limit * 0.75 ? "warn" : "calm";
return { tokens: context.tokens, window: context.window, share, autoAt, tone, compressible: context.tokens >= COMPRESS_MIN_TOKENS };
return contextGauge(context, useSettings().data?.runner.autoCompactPercent);
}

const TONE = {
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/components/settings/ai-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ export function AiSection({ settings }: { settings: Settings }) {
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
{[30, 40, 50, 60, 70, 80].map((p) => (
{[40, 50, 60, 70, 80].map((p) => (
<SelectItem key={p} value={String(p)}>
At {p}%{p === DEFAULT_AUTO_COMPACT_PERCENT ? " (default)" : ""}
</SelectItem>
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export default function RootLayout() {
<Stack.Screen name="human-task/[id]" options={{ presentation: "formSheet", sheetAllowedDetents: [0.75, 1], sheetGrabberVisible: true, headerShown: false }} />
<Stack.Screen name="new-task" options={{ presentation: "formSheet", sheetAllowedDetents: [0.8, 1], sheetGrabberVisible: true, headerShown: false }} />
<Stack.Screen name="model" options={{ presentation: "formSheet", sheetAllowedDetents: [0.8, 1], sheetGrabberVisible: true, headerShown: false }} />
<Stack.Screen name="chat-memory" options={{ presentation: "formSheet", sheetAllowedDetents: [0.62, 1], sheetGrabberVisible: true, headerShown: false }} />
<Stack.Screen name="workspaces" options={{ presentation: "formSheet", sheetAllowedDetents: [0.5, 1], sheetGrabberVisible: true, headerShown: false }} />
<Stack.Screen name="settings" options={{ presentation: "modal", headerShown: false }} />
<Stack.Screen name="live" options={{ presentation: "fullScreenModal", headerShown: false, contentStyle: { backgroundColor: "#000" } }} />
Expand Down
125 changes: 125 additions & 0 deletions apps/mobile/src/app/chat-memory.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { useQuery } from "@tanstack/react-query";
import { router, useLocalSearchParams } from "expo-router";
import { useState } from "react";
import { Alert, Pressable, ScrollView, StyleSheet, View } from "react-native";
import { tokensLabel } from "@godmode/shared";
import { Icon } from "@/components/icon";
import { toneColor } from "@/components/context-ring";
import { Button, Card, T, tap } from "@/components/ui";
import { errorText } from "@/lib/api";
import { fetchConversation } from "@/lib/chat-cache";
import { compressChat, isCompressCommand, useContextGauge } from "@/lib/chat-memory";
import { useAgents } from "@/lib/hooks";
import { useChatRun } from "@/lib/live";
import { qk } from "@/lib/query";
import { radius, space, useColors } from "@/lib/theme";

/** How full a chat's memory is, and Compress: Claude Code turns the conversation so far into a summary. */
export default function ChatMemorySheet() {
const c = useColors();
const { conversationId = "" } = useLocalSearchParams<{ conversationId: string }>();
const conversation = useQuery({ queryKey: qk.conversation(conversationId), queryFn: () => fetchConversation(conversationId), enabled: !!conversationId });
const { byId } = useAgents();
const agentName = byId.get(conversation.data?.agentId ?? "")?.name ?? "The agent";
const run = useChatRun(conversationId);
const gauge = useContextGauge(conversation.data?.context);
const [busy, setBusy] = useState(false);
const queued = conversation.data?.queue.some((m) => isCompressCommand(m.content)) ?? false;

const compress = async () => {
setBusy(true);
try {
await compressChat(conversationId);
router.back();
} catch (err) {
Alert.alert("Couldn't compress the chat", errorText(err));
} finally {
setBusy(false);
}
};

const pct = gauge?.share != null ? Math.round(gauge.share * 100) : null;
const color = gauge ? toneColor(c, gauge.tone) : c.textMuted;

return (
<ScrollView contentContainerStyle={styles.content} style={{ backgroundColor: c.background }}>
<View style={{ gap: 4 }}>
<T variant="title">Chat memory</T>
<T variant="subhead" muted>
What {agentName} carries along with every step of this chat.
</T>
</View>

<Card style={{ padding: space.lg, gap: space.md }}>
{gauge ? (
<>
<View style={styles.row}>
<View style={styles.amount}>
<T variant="largeTitle" style={{ fontVariant: ["tabular-nums"] }}>
{tokensLabel(gauge.tokens)}
</T>
<T variant="footnote" muted>
{gauge.window ? `of ${tokensLabel(gauge.window)} tokens` : "tokens"}
</T>
</View>
{pct !== null ? (
<T variant="headline" color={color} style={{ fontVariant: ["tabular-nums"] }}>
{pct}%
</T>
) : null}
</View>
{gauge.share !== null ? (
<View style={[styles.track, { backgroundColor: c.sunken }]}>
<View style={[styles.fill, { width: `${Math.max(1.5, gauge.share * 100)}%`, backgroundColor: gauge.tone === "calm" ? c.text : color }]} />
{gauge.autoAt !== null ? <View style={[styles.tick, { left: `${gauge.autoAt * 100}%`, backgroundColor: c.textMuted }]} /> : null}
</View>
) : null}
<T variant="footnote" muted>
{gauge.autoAt === null ? "Auto-compress is off." : `Compresses by itself at ${Math.round(gauge.autoAt * 100)}%.`}
</T>
</>
) : (
<T variant="subhead" muted>
Known after {agentName}'s next answer in this chat.
</T>
)}
</Card>

<T variant="subhead" muted>
Compress turns the conversation so far into a short summary. {agentName} keeps the gist, your instructions and its files, and every next step gets faster and cheaper.
</T>

<Button
title={queued ? "Compress is queued" : !gauge?.compressible ? "Nothing to compress yet" : run ? "Compress after this step" : "Compress now"}
icon="layers"
loading={busy}
disabled={!gauge?.compressible || queued}
onPress={() => void compress()}
/>

<Pressable
accessibilityRole="link"
onPress={() => {
tap();
router.push("/settings/ai");
}}
style={({ pressed }) => [styles.link, { opacity: pressed ? 0.6 : 1 }]}
>
<T variant="footnote" muted style={{ flex: 1 }}>
Auto-compress setting
</T>
<Icon name="chevron" size={12} weight="semibold" color={c.textFaint} />
</Pressable>
</ScrollView>
);
}

const styles = StyleSheet.create({
content: { padding: space.xl, paddingBottom: 48, gap: space.lg },
row: { flexDirection: "row", alignItems: "flex-end", justifyContent: "space-between", gap: space.md },
amount: { flexDirection: "row", alignItems: "baseline", gap: 6 },
track: { height: 6, borderRadius: radius.pill, overflow: "visible", position: "relative" },
fill: { height: 6, borderRadius: radius.pill },
tick: { position: "absolute", top: -4, width: 1.5, height: 14, borderRadius: 1 },
link: { flexDirection: "row", alignItems: "center", paddingVertical: space.sm },
});
35 changes: 35 additions & 0 deletions apps/mobile/src/app/chat/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,15 @@ import { LiveStrip } from "@/components/live-strip";
import { AssistantMessage, UserMessage } from "@/components/message";
import { Icon } from "@/components/icon";
import { ModelButton } from "@/components/model-button";
import { ContextRing } from "@/components/context-ring";
import { ChatScope, type ChatScopeValue } from "@/components/question-card";
import { QueueTray } from "@/components/queue-tray";
import { Button, ErrorState, Skeleton, T, tap } from "@/components/ui";
import { openHumanTask } from "@/components/human-task-row";
import { ApiError, api, errorText } from "@/lib/api";
import { encodeFiles, type PendingFile } from "@/lib/attachments";
import { fetchConversation, loadEarlier } from "@/lib/chat-cache";
import { isCompressCommand, useContextGauge } from "@/lib/chat-memory";
import { setQueue } from "@/lib/composer";
import { whenText } from "@/lib/format";
import { newQueueId, pendingQueued, withPending } from "@/lib/pending-queue";
Expand Down Expand Up @@ -101,6 +103,7 @@ export default function Chat() {
const { byId } = useAgents();
const agent = conversation.data ? byId.get(conversation.data.agentId) : undefined;
const run = useChatRun(id);
const activity = useRunActivity(run?.id);
// Only which message streams: its text lives in the row that shows it, so the screen doesn't render with every word.
const liveMessageId = useLive((s) => (run && s.drafts[id]?.runId === run.id ? s.drafts[id]!.messageId : null));
const screens = useChatScreens(conversation.data, agent);
Expand Down Expand Up @@ -145,6 +148,12 @@ export default function Chat() {
// While the agent works, is paused or older messages still wait, a new message joins the queue.
const queueing = !waiting && (!!run || !!paused || queue.length > 0);
const followup = !run && !paused ? (conversation.data?.followup ?? null) : null;
// Chat memory: the ring next to the model, and a strip once the chat gets long (hidden until it grows further).
const gauge = useContextGauge(conversation.data?.context);
const compressing = (!!run && (activity ?? "").startsWith("Compressing")) || queue.some((m) => isCompressCommand(m.content));
const [compressHiddenAt, setCompressHiddenAt] = useState<number | null>(null);
const longChat =
!!gauge && gauge.tone !== "calm" && gauge.compressible && !compressing && (compressHiddenAt === null || gauge.tokens > compressHiddenAt * 1.2) ? gauge : null;

const scope = useMemo<ChatScopeValue>(
() => ({ conversationId: id, agentName: agent?.name ?? "The agent", openQuestionId: (pause?.reason === "question" && pause.question?.id) || null }),
Expand Down Expand Up @@ -371,6 +380,8 @@ export default function Chat() {
<EndedStrip mode={ended.mode} onRetry={retry} busy={retrying} />
) : followup ? (
<FollowupStrip conversationId={id} followup={followup} agentName={agent?.name ?? "The agent"} />
) : longChat ? (
<LongChatStrip pct={longChat.share === null ? null : Math.round(longChat.share * 100)} full={longChat.tone === "full"} onCompress={() => void send("/compress", [])} onHide={() => setCompressHiddenAt(longChat.tokens)} />
) : null}
<QueueTray
conversationId={id}
Expand Down Expand Up @@ -408,11 +419,14 @@ export default function Chat() {
disabled={agent ? !agent.enabled : false}
trailing={
conversation.data ? (
<>
{gauge ? <ContextRing gauge={gauge} conversationId={id} /> : null}
<ModelButton
agent={agent}
conversationId={id}
choice={{ model: conversation.data.model ?? null, effort: conversation.data.effort ?? null, ultracode: conversation.data.ultracode ?? null }}
/>
</>
) : null
}
/>
Expand Down Expand Up @@ -664,6 +678,27 @@ function endedTurn(conv: ConversationWithMessages): { runId: string; mode: Retry
return { runId: last.runId, mode: retryModeOf(last.blocks) === "continue" && conv.claudeSessionId ? "continue" : "again" };
}

/** Above the composer once the chat gets long. */
function LongChatStrip({ pct, full, onCompress, onHide }: { pct: number | null; full: boolean; onCompress: () => void; onHide: () => void }) {
const c = useColors();
return (
<View style={[styles.paused, { backgroundColor: full ? c.dangerSoft : c.warningSoft, borderColor: c.border }]}>
<Icon name="layers" size={15} color={full ? c.danger : c.warning} />
<T variant="footnote" style={{ flex: 1 }} numberOfLines={2}>
This chat is getting long{pct !== null ? ` · ${pct}%` : ""}
</T>
<Pressable onPress={() => { tap(); onCompress(); }} hitSlop={10} accessibilityRole="button" accessibilityLabel="Compress the chat">
<T variant="footnote" color={c.primary} style={{ fontWeight: "600" }}>
Compress
</T>
</Pressable>
<Pressable onPress={onHide} hitSlop={10} accessibilityRole="button" accessibilityLabel="Hide">
<Icon name="close" size={12} weight="semibold" color={c.textFaint} />
</Pressable>
</View>
);
}

/** Above the composer when the last turn ended early. */
function EndedStrip({ mode, onRetry, busy }: { mode: RetryMode; onRetry: () => void; busy: boolean }) {
const c = useColors();
Expand Down
16 changes: 15 additions & 1 deletion apps/mobile/src/app/settings/ai.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Stack } from "expo-router";
import { ActivityIndicator, ScrollView, StyleSheet, View } from "react-native";
import { ULTRACODE_HINT, effortForModel, findModel, type Effort } from "@godmode/shared";
import { DEFAULT_AUTO_COMPACT_PERCENT, ULTRACODE_HINT, effortForModel, findModel, type Effort } from "@godmode/shared";
import { CloseButton } from "@/components/close-button";
import { FieldRow, Group, PickerRow, SegmentRow, StepperRow, SwitchRow } from "@/components/form";
import { useModelCatalog } from "@/lib/composer";
Expand Down Expand Up @@ -119,6 +119,20 @@ export default function ModelAndLimits() {
/>
</Group>

<Group title="Long chats" footer="A chat that fills this much of the model's memory is summarized by itself and goes on from the summary. Earlier keeps every step faster and cheaper, later keeps more detail. Compress any chat by hand with the ring next to the model.">
<PickerRow
icon="layers"
title="Compress by itself"
value={r.autoCompactPercent === undefined ? String(DEFAULT_AUTO_COMPACT_PERCENT) : r.autoCompactPercent === null ? "claude" : String(r.autoCompactPercent)}
options={[
...[40, 50, 60, 70, 80].map((p) => ({ value: String(p), label: `At ${p}%${p === DEFAULT_AUTO_COMPACT_PERCENT ? " (default)" : ""}` })),
{ value: "claude", label: "When nearly full" },
{ value: "0", label: "Never by itself" },
]}
onChange={(v) => void patchSettings({ runner: { autoCompactPercent: v === "claude" ? null : Number(v) } })}
/>
</Group>

<Group
title="Budgets"
footer={
Expand Down
62 changes: 62 additions & 0 deletions apps/mobile/src/components/context-ring.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { router } from "expo-router";
import { Pressable, StyleSheet } from "react-native";
import Svg, { Circle } from "react-native-svg";
import type { ContextGauge } from "@godmode/shared";
import { T, tap } from "./ui";
import { useColors, type Colors } from "@/lib/theme";

export function toneColor(c: Colors, tone: ContextGauge["tone"]): string {
return tone === "full" ? c.danger : tone === "warn" ? c.warning : c.textMuted;
}

export function Ring({ share, color, track, size = 18, stroke = 2.4 }: { share: number; color: string; track: string; size?: number; stroke?: number }) {
const r = (size - stroke) / 2;
const len = 2 * Math.PI * r;
return (
<Svg width={size} height={size} style={{ transform: [{ rotate: "-90deg" }] }}>
<Circle cx={size / 2} cy={size / 2} r={r} stroke={track} strokeWidth={stroke} fill="none" />
<Circle
cx={size / 2}
cy={size / 2}
r={r}
stroke={color}
strokeWidth={stroke}
fill="none"
strokeLinecap="round"
strokeDasharray={`${len} ${len}`}
strokeDashoffset={len * (1 - Math.max(0.04, share))}
/>
</Svg>
);
}

/** How full the chat's memory is, in the composer's toolbar; opens the Chat memory sheet. */
export function ContextRing({ gauge, conversationId }: { gauge: ContextGauge; conversationId: string }) {
const c = useColors();
const pct = gauge.share === null ? null : Math.round(gauge.share * 100);
const color = toneColor(c, gauge.tone);
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={pct === null ? "Chat memory" : `Chat memory ${pct}% full`}
accessibilityHint="Shows how full this chat is and lets you compress it"
hitSlop={6}
onPress={() => {
tap();
router.push({ pathname: "/chat-memory", params: { conversationId } });
}}
style={({ pressed }) => [styles.button, { opacity: pressed ? 0.6 : 1 }]}
>
<Ring share={gauge.share ?? 0} color={color} track={c.border} />
{gauge.tone !== "calm" && pct !== null ? (
<T variant="footnote" color={color} style={{ fontWeight: "600", fontVariant: ["tabular-nums"] }}>
{pct}%
</T>
) : null}
</Pressable>
);
}

const styles = StyleSheet.create({
button: { flexDirection: "row", alignItems: "center", gap: 5, height: 36, paddingHorizontal: 6 },
});
Loading
Loading