Skip to content
56 changes: 52 additions & 4 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
useState,
Suspense,
type CSSProperties,
type WheelEvent as ReactWheelEvent,
} from "react";
import { useCoder } from "./useCoder";
import { isDevBuild } from "./coderApi";
Expand Down Expand Up @@ -36,11 +37,17 @@ import {
} from "./detachedWindow";
import { isShortcutBlocked } from "./sidebarSelection";
import {
canStep,
EMPTY_HISTORY,
stepThread,
visitThread,
type ThreadHistory,
} from "./threadHistory";
import {
accumulateSwipe,
hasHorizontalScrollAncestor,
type SwipeState,
} from "./swipeNav";
import { WorkflowsModal } from "./components/WorkflowsModal";
import {
PALETTE_ACTIONS,
Expand Down Expand Up @@ -1323,21 +1330,57 @@ export default function App({ rendererSha: rendererShaOverride }: AppProps = {})
// Back/forward over opened threads (#1506): every selection lands here,
// whatever opened it; stepping moves the index first so it is not re-pushed.
const historyRef = useRef<ThreadHistory>(EMPTY_HISTORY);
// Bumped whenever historyRef moves, so the sidebar's Back/Forward buttons
// (#1752) can recompute without the stack itself living in React state.
const [historyTick, setHistoryTick] = useState(0);
const historyExists = useCallback(
(id: string) => threadsRef.current.some((t) => t.id === id),
[],
);
useEffect(() => {
if (selectedThreadId) {
historyRef.current = visitThread(historyRef.current, selectedThreadId);
setHistoryTick((n) => n + 1);
}
}, [selectedThreadId]);
const goHistory = useCallback(
(delta: 1 | -1) => {
const step = stepThread(historyRef.current, delta, (id) =>
threadsRef.current.some((t) => t.id === id),
);
const step = stepThread(historyRef.current, delta, historyExists);
if (!step) return;
historyRef.current = step.history;
setHistoryTick((n) => n + 1);
handleSelectThread(step.id);
},
[handleSelectThread],
[handleSelectThread, historyExists],
);
const goBack = useCallback(() => goHistory(-1), [goHistory]);
const goForward = useCallback(() => goHistory(1), [goHistory]);
// `threads` isn't read directly here, but it's what historyExists actually
// reads (via threadsRef), so it has to be a dep or a delete/archive would
// leave a stale button enabled until the next selection change.
const canGoBack = useMemo(
() => canStep(historyRef.current, -1, historyExists),
[historyTick, threads, historyExists],
);
const canGoForward = useMemo(
() => canStep(historyRef.current, 1, historyExists),
[historyTick, threads, historyExists],
);

// Trackpad swipe over the thread pane (#1752): a horizontal wheel run past
// the threshold commits one step, same as the keyboard/button paths.
const swipeStateRef = useRef<SwipeState | null>(null);
const handleThreadPaneWheel = useCallback(
(e: ReactWheelEvent<HTMLDivElement>) => {
// Vertical scroll is most wheel traffic; skip the layout-reading
// ancestor walk for it.
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
if (hasHorizontalScrollAncestor(e.target as Element)) return;
const { state, commit } = accumulateSwipe(swipeStateRef.current, e, Date.now());
swipeStateRef.current = state;
if (commit) goHistory(commit);
},
[goHistory],
);

useAppShortcuts({
Expand Down Expand Up @@ -1826,6 +1869,10 @@ export default function App({ rendererSha: rendererShaOverride }: AppProps = {})
providers={providers}
activeThreadId={selectedThreadId}
onSelectThread={handleSelectThread}
onGoBack={goBack}
onGoForward={goForward}
canGoBack={canGoBack}
canGoForward={canGoForward}
activeView={view}
onOpenThreads={openThreads}
onOpenKanban={openKanban}
Expand Down Expand Up @@ -1910,6 +1957,7 @@ export default function App({ rendererSha: rendererShaOverride }: AppProps = {})
className={styles.threadSlot}
data-pane="thread"
inert={narrow && drawer !== null}
onWheel={handleThreadPaneWheel}
>
<ErrorBoundary pane="Thread view">
<Suspense fallback={null}>
Expand Down
11 changes: 10 additions & 1 deletion src/components/Markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -228,8 +228,17 @@ const REMARK_PLUGINS = [remarkGfm];

const COMPONENTS: Components = {
pre: (props) => <CodeBlock>{props.children}</CodeBlock>,
// Double-click takes the whole span: word selection stops at `-` and `/`,
// so `migration-gate` would otherwise select only "migration" (#1754).
code: (props) => (
<code className={styles.inlineCode}>{linkifyNode(props.children)}</code>
<code
className={styles.inlineCode}
onDoubleClick={(e) =>
window.getSelection()?.selectAllChildren(e.currentTarget)
}
>
{linkifyNode(props.children)}
</code>
),
p: (props) => <p>{linkifyNode(props.children)}</p>,
li: (props) => <li>{linkifyNode(props.children)}</li>,
Expand Down
8 changes: 7 additions & 1 deletion src/components/Sidebar.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -1840,8 +1840,14 @@
opacity: 0.8;
}

.header > [data-sidebar-collapse] {
.headerNav {
display: flex;
align-items: center;
gap: 2px;
margin-left: auto;
}

.headerNav [data-sidebar-collapse] {
width: 26px;
height: 26px;
}
55 changes: 47 additions & 8 deletions src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@ export interface SidebarProps {
providers: ProviderInfo[];
activeThreadId: string | null;
onSelectThread: (id: string) => void;
/** Back/forward over opened threads (#1752); each disabled with nowhere to go. */
onGoBack?: () => void;
onGoForward?: () => void;
canGoBack?: boolean;
canGoForward?: boolean;
/** Global + uses selected project; per-group New thread passes that projectId. */
onCreateThread: (
projectId?: string,
Expand Down Expand Up @@ -334,6 +339,10 @@ export const Sidebar = memo(function Sidebar({
providers,
activeThreadId,
onSelectThread,
onGoBack,
onGoForward,
canGoBack = false,
canGoForward = false,
onCreateThread,
listBaseBranches,
onAddProject,
Expand Down Expand Up @@ -1429,21 +1438,51 @@ export const Sidebar = memo(function Sidebar({
<span className={styles.brandChannel}>nightly</span>
)}
</div>
{onCollapseSidebar ? (
<div className={styles.headerNav}>
<button
type="button"
className={styles.iconBtn}
data-sidebar-collapse=""
aria-label="Hide sidebar"
title={`Hide sidebar (${bindingLabel("sidebar.toggle", true)})`}
onClick={onCollapseSidebar}
data-history-back=""
aria-label="Back"
title={`Back (${bindingLabel("history.back", true)})`}
disabled={!canGoBack}
onClick={onGoBack}
>
<Icon size={15}>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18" />
<path d="M19 12H5" />
<path d="M12 19l-7-7 7-7" />
</Icon>
</button>
) : null}
<button
type="button"
className={styles.iconBtn}
data-history-forward=""
aria-label="Forward"
title={`Forward (${bindingLabel("history.forward", true)})`}
disabled={!canGoForward}
onClick={onGoForward}
>
<Icon size={15}>
<path d="M5 12h14" />
<path d="M12 5l7 7-7 7" />
</Icon>
</button>
{onCollapseSidebar ? (
<button
type="button"
className={styles.iconBtn}
data-sidebar-collapse=""
aria-label="Hide sidebar"
title={`Hide sidebar (${bindingLabel("sidebar.toggle", true)})`}
onClick={onCollapseSidebar}
>
<Icon size={15}>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18" />
</Icon>
</button>
) : null}
</div>
</header>

<div className={styles.searchRow}>
Expand Down
68 changes: 68 additions & 0 deletions src/components/ThreadView.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -4502,6 +4502,74 @@ a.menuItem {
flex-shrink: 0;
}

/*
* Usage limits strip (issue #1755), opt-in via Settings -> Display. Rows
* read as percent LEFT, the inverse of the quota dialog's percent used, so
* a longer bar means more room.
*/
.usageStrip {
display: flex;
flex-direction: column;
gap: 4px;
padding: 2px 20px 6px;
}

.usageRow {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: var(--text-dim);
}

.usageLabel {
flex-shrink: 0;
width: 56px;
color: var(--text-muted);
}

.usageTrack {
flex: 1;
height: 3px;
border-radius: var(--radius-pill);
background: var(--border-soft);
overflow: hidden;
}

.usageFill {
height: 100%;
border-radius: inherit;
background: var(--text-dim);
}

.usageRow[data-usage-severity="warn"] .usageFill {
background: var(--amber);
}

.usageRow[data-usage-severity="warn"] .usagePct {
color: var(--amber);
}

.usageRow[data-usage-severity="danger"] .usageFill {
background: var(--danger);
}

.usageRow[data-usage-severity="danger"] .usagePct {
color: var(--danger);
}

.usagePct {
flex-shrink: 0;
width: 52px;
text-align: right;
font-variant-numeric: tabular-nums;
}

.usageUnavailable {
color: var(--text-dim);
font-style: italic;
}

.chatSlot[data-draft-hero] {
justify-content: center;
padding-bottom: 6vh;
Expand Down
12 changes: 12 additions & 0 deletions src/components/ThreadView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ import {
setTranscriptViewMode,
useDiffSplit,
useTranscriptViewMode,
useUsageLimitsStripEnabled,
} from "../uiPrefs";
import { DROP_OVERLAY_MESSAGE, type DroppedFolder } from "../dropFiles";
import { Composer, type ComposerProps } from "./Composer";
Expand Down Expand Up @@ -172,6 +173,7 @@ import {
DraftProjectChooser,
HeaderForkControl,
ReturnToViewButton,
UsageLimitsStrip,
returnToHeader,
} from "./thread/headerBits";
import {
Expand Down Expand Up @@ -1546,6 +1548,7 @@ export const ThreadView = memo(function ThreadView({
}),
};
}, [detail, providers]);
const showUsageLimitsStrip = useUsageLimitsStripEnabled();
const handleForkFresh = useCallback(() => {
if (isWorking || !onFork) return;
void onFork();
Expand Down Expand Up @@ -5127,6 +5130,15 @@ export const ThreadView = memo(function ThreadView({
</div>

<div className={styles.composerColumn}>
{showUsageLimitsStrip ? (
<UsageLimitsStrip
loadLimits={loadProviderLimits}
activeProvider={thread.provider}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Select usage limits with the full provider instance reference

For a thread using a named provider instance, such as claude:work, this passes only claude, so the strip displays the default account's limits instead of the account running the thread. electron/providerUsage.js returns named-instance usage under inst.ref, and UsageLimitsStrip selects its row by exact provider equality. Pass threadProviderRef(thread) here so users with separate provider logins see the correct account's remaining quota.

Useful? React with 👍 / 👎.

ring={ring?.view ?? null}
used={ring?.used ?? null}
refreshKey={`${thread.id}:${thread.status}`}
/>
) : null}
<Composer
threadId={thread.id}
permissionMode={thread.permissionMode}
Expand Down
16 changes: 16 additions & 0 deletions src/components/settings/GeneralPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ import {
setDivergenceCardEnabled,
setPasteCardsEnabled,
setRunDurationEnabled,
setUsageLimitsStripEnabled,
useComposerVimEnabled,
useEnterSendsEnabled,
useDivergenceCardEnabled,
usePasteCardsEnabled,
useRunDurationEnabled,
useUsageLimitsStripEnabled,
} from "../../uiPrefs";
import styles from "../SettingsModal.module.css";
import type { SettingsDraftKey, SettingsModalProps } from "../SettingsModal";
Expand Down Expand Up @@ -913,6 +915,7 @@ function DisplayPrefsSection() {
const pasteCards = usePasteCardsEnabled();
const composerVim = useComposerVimEnabled();
const enterSends = useEnterSendsEnabled();
const usageLimits = useUsageLimitsStripEnabled();
return (
<section className={styles.section} data-display-prefs="">
<h3 className={styles.sectionLabel}>Display</h3>
Expand Down Expand Up @@ -961,6 +964,19 @@ function DisplayPrefsSection() {
/>
<span>Send with Enter (⇧ Enter for a new line)</span>
</label>
<label className={styles.fieldRow}>
<input
type="checkbox"
data-usage-limits-strip-pref=""
checked={usageLimits}
onChange={(e) => setUsageLimitsStripEnabled(e.target.checked)}
/>
<span>Show usage limits in thread</span>
</label>
<p className={styles.note}>
A quiet strip above the composer with the 5-hour, weekly and
context limits as percent left. Off by default.
</p>
</section>
);
}
Loading
Loading