Skip to content

Commit 72158b3

Browse files
committed
perf(web): reduce chat ui rendering work
- batch runtime tool output updates and drop stale queued deltas - defer code highlighting, timeline scroll, and relative-time ticks - surface provider usage refresh time and clean up theme chrome sync
1 parent e55bcce commit 72158b3

15 files changed

Lines changed: 362 additions & 140 deletions

README.md

Lines changed: 38 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,76 +1,57 @@
11
# MarCode
22

3-
MarCode is a web GUI for coding agents. On top of a solid foundation, MarCode brings performance optimizations, new integrations, and a refined experience:
3+
**One window for every AI coding agent. Wired into Jira, Git, and your team's workflow.**
44

5-
- **Claude Code CLI** as the default and primary provider
6-
- **Jira Cloud integration** (OAuth 2.0, sprint browsing, `@PROJ-123` mentions in composer)
7-
- **GitLab support** alongside GitHub (auto-detected from remote origin)
8-
- **Preview diff display** for proposed file changes
9-
- **Incremental state updates** for smooth, non-blocking UI during agent work
10-
- **Additional directories in composer** — add extra directories to agent context per thread
11-
- And much more
5+
MarCode is a desktop-grade GUI that puts Claude Code, Codex, OpenCode, and Cursor under a single, consistent surface — with first-class Jira context, GitHub & GitLab automation, remote control, and a tool-call display that actually makes agent runs readable.
126

13-
## Installation
14-
15-
> [!WARNING]
16-
> MarCode currently supports Codex and Claude.
17-
> Install and authenticate at least one provider before use:
18-
>
19-
> - Codex: install [Codex CLI](https://github.com/openai/codex) and run `codex login`
20-
> - Claude: install Claude Code and run `claude auth login`
21-
22-
### Prerequisites
23-
24-
You need at least one of the following coding agent CLIs installed and authorized:
25-
26-
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code)
27-
- [Codex CLI](https://github.com/openai/codex)
28-
29-
For git host integration:
7+
## The enterprise workflow
308

31-
- **PRs (GitHub):** [GitHub CLI (`gh`)](https://cli.github.com/) installed and authenticated
32-
- **MRs (GitLab):** [GitLab CLI (`glab`)](https://gitlab.com/gitlab-org/cli) installed and authenticated (Personal Access Token works for self-hosted instances)
9+
The whole point: your engineers stay in **one app** from ticket to merge request.
3310

34-
### Desktop app
35-
36-
Install the latest version of the desktop app from [GitHub Releases](https://github.com/tyulyukov/marcode/releases), or from your favorite package registry:
37-
38-
#### Windows (`winget`)
39-
40-
```bash
41-
winget install tyulyukov.MarCode
42-
```
11+
1. **Drop in a Jira ticket.** `@PROJ-123` in the composer pulls the title, description, attachments, and links straight into context. Paste an `*.atlassian.net/browse/...` URL and MarCode auto-detects it. Browse boards and sprints with `/jira` without ever leaving the chat.
12+
2. **Pick how you want to work.** Run the agent **in-place** against your local checkout, or one-click into an **isolated git worktree** so the agent can refactor without ever touching your working tree. Switch between threads, branches, and worktrees from the sidebar.
13+
3. **Watch every step in plain English.** File reads, shell commands, web searches, MCP tool calls, and subagent task groups all render as purpose-built cards — not raw JSON. Approve, reject, or revert any checkpoint with one click.
14+
4. **Ship with one click.** When the work is good, MarCode handles the rest of the chore work for you: it generates a semantic branch name (`feature/PROJ-123-…`), writes a clean commit message that reads naturally, opens a **PR on GitHub** or an **MR on GitLab** — auto-detected from your remote — with the Jira ticket key engraved in the title and the "why" lifted from the ticket description.
4315

44-
#### macOS (Homebrew)
16+
The Jira ticket key follows the work end-to-end: classified once on the first turn (so reference tickets don't leak), persisted on the thread, and visible on the chip beside the Commit / Push / MR button so reviewers always know what's being shipped.
4517

46-
```bash
47-
brew install --cask marcode
48-
```
18+
## One GUI, every agent
4919

50-
#### Arch Linux (AUR)
20+
| Provider | Status | CLI |
21+
| --------------- | ------------ | ---------------------------------------------------------- |
22+
| **Claude Code** | Default | [`claude`](https://docs.anthropic.com/en/docs/claude-code) |
23+
| **Codex** | Stable | [`codex`](https://github.com/openai/codex) |
24+
| **OpenCode** | Stable | [`opencode`](https://opencode.ai) |
25+
| **Cursor** | Early Access | [`cursor-agent`](https://docs.cursor.com/en/cli/overview) |
5126

52-
```bash
53-
yay -S marcode-bin
54-
```
27+
Pick a CLI, pick a model, switch mid-thread. Your agents share one history, one settings panel, and one set of keybindings.
5528

56-
## Some notes
29+
## Why MarCode
5730

58-
We are very very early in this project. Expect bugs.
31+
- **Rich tool-call display.** Every shell command, file edit, web fetch, MCP call, and subagent spawn renders as a dedicated card with diffs, status, and inline previews — not a wall of JSON.
32+
- **Native multi-host Git.** GitHub PRs and GitLab MRs from the same composer. The host is auto-detected from `remote.origin.url`; the UI relabels itself accordingly.
33+
- **Jira-aware end-to-end.** OAuth 2.0 to Atlassian Cloud, sprint browsing, `@PROJ-123` mentions, and ticket-engraved branches / titles / MRs.
34+
- **Remote control.** Run the server headless on a workstation or build box and connect from another desktop, phone, or tablet over your tailnet. See [REMOTE.md](./REMOTE.md).
35+
- **Yours to customize.** 24+ themes across Catppuccin, Dracula, Nord, Tokyo Night, Rose Pine, Ayu, Solarized, GitHub, Gruvbox, One Dark, Monokai, and the branded MarCode set. Custom keybindings via [`~/.marcode/keybindings.json`](./KEYBINDINGS.md).
36+
- **Fast under load.** Incremental event streaming, structural sharing in the store, and virtualized timelines keep the UI smooth through long agent runs.
5937

60-
We are not accepting contributions yet.
38+
A full inventory of fork-exclusive features lives in [FEATURES.md](./FEATURES.md).
6139

62-
Observability guide: [docs/observability.md](./docs/observability.md)
40+
## Installation
6341

64-
## If you REALLY want to contribute still.... read this first
42+
> [!NOTE]
43+
> MarCode requires at least one coding-agent CLI installed and authenticated. Install whichever you use:
44+
>
45+
> - **Claude Code:** install [Claude Code](https://docs.anthropic.com/en/docs/claude-code) and run `claude`
46+
> - **Codex:** install [Codex CLI](https://github.com/openai/codex) and run `codex login`
47+
> - **OpenCode:** install [OpenCode](https://opencode.ai) (`npm i -g opencode-ai`) and authenticate
48+
> - **Cursor (Early Access):** install [Cursor CLI](https://docs.cursor.com/en/cli/installation) and run `agent login`
6549
66-
Before local development, prepare the environment and install dependencies:
50+
For Git host integration:
6751

68-
```bash
69-
# Optional: only needed if you use mise for dev tool management.
70-
mise install
71-
bun install .
72-
```
52+
- **GitHub PRs:** [`gh`](https://cli.github.com/) installed and authenticated
53+
- **GitLab MRs:** [`glab`](https://gitlab.com/gitlab-org/cli) installed and authenticated (Personal Access Token works for self-hosted GitLab)
7354

74-
Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening an issue or PR.
55+
### Desktop app
7556

76-
Need support? Join the [Discord](https://discord.gg/jn4EGJjrvv).
57+
Install the latest release from [GitHub Releases](https://github.com/tyulyukov/marcode/releases).

apps/web/src/components/ChatMarkdown.tsx

Lines changed: 62 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,61 @@ function SuspenseShikiCodeBlock({
241241
);
242242
}
243243

244+
function DeferredShikiCodeBlock({
245+
className,
246+
code,
247+
themeName,
248+
isStreaming,
249+
fallback,
250+
}: SuspenseShikiCodeBlockProps & { fallback: ReactNode }) {
251+
const [enabled, setEnabled] = useState(false);
252+
253+
useEffect(() => {
254+
setEnabled(false);
255+
if (isStreaming) {
256+
return;
257+
}
258+
259+
let cancelled = false;
260+
const enable = () => {
261+
if (!cancelled) {
262+
setEnabled(true);
263+
}
264+
};
265+
266+
if ("requestIdleCallback" in window) {
267+
const idleId = window.requestIdleCallback(enable, { timeout: 1_000 });
268+
return () => {
269+
cancelled = true;
270+
window.cancelIdleCallback(idleId);
271+
};
272+
}
273+
274+
const timeoutId = globalThis.setTimeout(enable, 120);
275+
return () => {
276+
cancelled = true;
277+
globalThis.clearTimeout(timeoutId);
278+
};
279+
}, [className, code, isStreaming, themeName]);
280+
281+
if (!enabled) {
282+
return <>{fallback}</>;
283+
}
284+
285+
return (
286+
<CodeHighlightErrorBoundary fallback={fallback}>
287+
<Suspense fallback={fallback}>
288+
<SuspenseShikiCodeBlock
289+
className={className}
290+
code={code}
291+
themeName={themeName}
292+
isStreaming={isStreaming}
293+
/>
294+
</Suspense>
295+
</CodeHighlightErrorBoundary>
296+
);
297+
}
298+
244299
interface MarkdownFileLinkProps {
245300
href: string;
246301
targetPath: string;
@@ -541,16 +596,13 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
541596

542597
return (
543598
<MarkdownCodeBlock code={codeBlock.code}>
544-
<CodeHighlightErrorBoundary fallback={<pre {...props}>{children}</pre>}>
545-
<Suspense fallback={<pre {...props}>{children}</pre>}>
546-
<SuspenseShikiCodeBlock
547-
className={codeBlock.className}
548-
code={codeBlock.code}
549-
themeName={diffThemeName}
550-
isStreaming={isStreaming}
551-
/>
552-
</Suspense>
553-
</CodeHighlightErrorBoundary>
599+
<DeferredShikiCodeBlock
600+
className={codeBlock.className}
601+
code={codeBlock.code}
602+
themeName={diffThemeName}
603+
isStreaming={isStreaming}
604+
fallback={<pre {...props}>{children}</pre>}
605+
/>
554606
</MarkdownCodeBlock>
555607
);
556608
},

apps/web/src/components/ChatView.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2663,7 +2663,7 @@ export default function ChatView({
26632663
return () => {
26642664
observer.disconnect();
26652665
};
2666-
}, [activeThread?.id, composerFooterActionLayoutKey, composerFooterHasWideActions, scrollToEnd]);
2666+
}, [composerFooterActionLayoutKey, composerFooterHasWideActions, scrollToEnd]);
26672667
useEffect(() => {
26682668
const prevPhase = previousPhaseRef.current;
26692669
const prevThreadId = previousThreadIdRef.current;

apps/web/src/components/DiffPanelShell.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) {
1212
return cn(
1313
"flex items-center justify-between gap-2 px-4",
1414
shouldUseDragRegion
15-
? "drag-region h-[52px] border-b border-border wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]"
15+
? "drag-region h-[56px] border-b border-border wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]"
1616
: "h-12 wco:max-h-[env(titlebar-area-height)]",
1717
);
1818
}

apps/web/src/components/Sidebar.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,7 @@ interface SidebarThreadRowProps {
322322
}
323323

324324
const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) {
325-
useSyncedRelativeTimeTick();
325+
const relativeTimeNowMs = useSyncedRelativeTimeTick();
326326
const {
327327
orderedProjectThreadKeys,
328328
isActive,
@@ -722,6 +722,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
722722
>
723723
{formatRelativeTimeLabel(
724724
thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt,
725+
relativeTimeNowMs,
725726
)}
726727
</span>
727728
)}

apps/web/src/components/chat/ChatHeader.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,9 @@ export const ChatHeader = memo(function ChatHeader({
8080
onToggleDiff,
8181
onTogglePlanSidebar,
8282
}: ChatHeaderProps) {
83-
useSyncedRelativeTimeTick();
83+
const relativeTimeNowMs = useSyncedRelativeTimeTick();
8484
const relativeActivityAt = activeThreadActivityAt
85-
? formatRelativeTimeLabel(activeThreadActivityAt)
85+
? formatRelativeTimeLabel(activeThreadActivityAt, relativeTimeNowMs)
8686
: null;
8787
const showMetaRow = Boolean(activeProjectName) || Boolean(relativeActivityAt);
8888
const primaryEnvironmentId = usePrimaryEnvironmentId();

apps/web/src/components/chat/MessagesTimeline.tsx

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -387,22 +387,18 @@ export const MessagesTimeline = memo(function MessagesTimeline({
387387
let completed = false;
388388
let rafId: number | null = null;
389389

390-
const scheduleScroll = (depth: number) => {
390+
const scheduleScroll = () => {
391391
rafId = window.requestAnimationFrame(() => {
392392
rafId = null;
393393
if (cancelled) return;
394394
void listRef.current?.scrollToEnd?.({ animated: false });
395-
if (depth > 0) {
396-
scheduleScroll(depth - 1);
397-
} else {
398-
completed = true;
399-
hasFinishedInitialScrollRef.current = true;
400-
syncIsAtEnd();
401-
}
395+
completed = true;
396+
hasFinishedInitialScrollRef.current = true;
397+
syncIsAtEnd();
402398
});
403399
};
404400

405-
scheduleScroll(2);
401+
scheduleScroll();
406402

407403
return () => {
408404
cancelled = true;
@@ -414,7 +410,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
414410
hasAutoScrolledOnMountRef.current = false;
415411
}
416412
};
417-
}, [listRef, rows.length, syncIsAtEnd, updateIsAtEnd]);
413+
}, [listRef, rows.length, syncIsAtEnd, threadId, updateIsAtEnd]);
418414

419415
useEffect(() => {
420416
if (rows.length === 0) return;
@@ -564,13 +560,13 @@ export const MessagesTimeline = memo(function MessagesTimeline({
564560
<TimelineRowCtx.Provider value={sharedState}>
565561
<div className="@container/chat relative h-full">
566562
<LegendList<MessagesTimelineRow>
567-
key={threadId}
568563
ref={listRef}
569564
data={rows}
570565
keyExtractor={keyExtractor}
571566
renderItem={renderItem}
572567
estimatedItemSize={160}
573568
getEstimatedItemSize={estimateRowSize}
569+
drawDistance={120}
574570
initialScrollAtEnd
575571
maintainScrollAtEnd
576572
maintainScrollAtEndThreshold={0.1}

apps/web/src/components/chat/ProviderUsageMeter.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ function formatResetTime(resetsAt: number | null): string | null {
66
return null;
77
}
88
const date = new Date(resetsAt * 1000);
9-
return `Resets ${date.toLocaleDateString(undefined, {
9+
return `Resets ${formatUsageDate(date)}`;
10+
}
11+
12+
function formatUsageDate(date: Date): string {
13+
return `${date.toLocaleDateString(undefined, {
1014
month: "short",
1115
day: "numeric",
1216
})}, ${date.toLocaleTimeString(undefined, {
@@ -16,6 +20,14 @@ function formatResetTime(resetsAt: number | null): string | null {
1620
})}`;
1721
}
1822

23+
function formatLastUpdatedAt(updatedAt: string): string | null {
24+
const timestamp = Date.parse(updatedAt);
25+
if (!Number.isFinite(timestamp)) {
26+
return null;
27+
}
28+
return `Last updated ${formatUsageDate(new Date(timestamp))}`;
29+
}
30+
1931
function usageBarColor(percent: number | null): string {
2032
if (percent === null) {
2133
return "color-mix(in oklab, var(--color-muted-foreground) 45%, transparent)";
@@ -76,6 +88,7 @@ export function ProviderUsageMeter(props: { usage: ProviderUsageSnapshot }) {
7688
.filter((percent): percent is number => percent !== null);
7789
const maxPercent = Math.max(...reportedPercents, 0);
7890
const hasReportedPercent = reportedPercents.length > 0;
91+
const lastUpdatedText = formatLastUpdatedAt(usage.updatedAt);
7992

8093
return (
8194
<Popover>
@@ -122,6 +135,12 @@ export function ProviderUsageMeter(props: { usage: ProviderUsageSnapshot }) {
122135
</div>
123136
);
124137
})}
138+
139+
{lastUpdatedText ? (
140+
<div className="border-t border-border/60 pt-2 text-[11px] text-muted-foreground">
141+
{lastUpdatedText}
142+
</div>
143+
) : null}
125144
</div>
126145
</PopoverPopup>
127146
</Popover>

apps/web/src/hooks/useTheme.ts

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -72,26 +72,18 @@ function normalizeThemeColor(value: string | null | undefined): string | null {
7272
return value?.trim() ?? null;
7373
}
7474

75-
function resolveBrowserChromeSurface(): HTMLElement {
76-
return (
77-
document.querySelector<HTMLElement>("main[data-slot='sidebar-inset']") ??
78-
document.querySelector<HTMLElement>("[data-slot='sidebar-inner']") ??
79-
document.body
80-
);
81-
}
82-
83-
export function syncBrowserChromeTheme() {
84-
if (typeof document === "undefined" || typeof getComputedStyle === "undefined") return;
85-
const surfaceColor = normalizeThemeColor(
86-
getComputedStyle(resolveBrowserChromeSurface()).backgroundColor,
87-
);
88-
const fallbackColor = normalizeThemeColor(getComputedStyle(document.body).backgroundColor);
89-
const backgroundColor = surfaceColor ?? fallbackColor;
90-
if (!backgroundColor) return;
75+
export function syncBrowserChromeTheme(backgroundColor?: string | null) {
76+
if (typeof document === "undefined") return;
77+
const normalizedBackgroundColor = normalizeThemeColor(backgroundColor);
78+
const chromeColor = normalizedBackgroundColor ?? "var(--app-chrome-background)";
9179

92-
document.documentElement.style.backgroundColor = backgroundColor;
93-
document.body.style.backgroundColor = backgroundColor;
94-
ensureThemeColorMetaTag().setAttribute("content", backgroundColor);
80+
document.documentElement.style.backgroundColor = chromeColor;
81+
if (document.body) {
82+
document.body.style.backgroundColor = chromeColor;
83+
}
84+
if (normalizedBackgroundColor) {
85+
ensureThemeColorMetaTag().setAttribute("content", normalizedBackgroundColor);
86+
}
9587
}
9688

9789
function getAutoNightPair(): AutoNightPair {
@@ -148,7 +140,7 @@ function applyTheme(preference: ThemePreference, suppressTransitions = false) {
148140
const definition = resolvePreference(preference, getSystemDark(), getAutoNightPair());
149141
applyThemeToDOM(definition, suppressTransitions);
150142
applyAccentOverride(definition);
151-
syncBrowserChromeTheme();
143+
syncBrowserChromeTheme(definition.variables?.["--background"] ?? null);
152144
syncDesktopTheme(definition, preference === "system");
153145
}
154146

0 commit comments

Comments
 (0)