Skip to content
Open
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
100 changes: 100 additions & 0 deletions apps/desktop/src/main/__tests__/build-info.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* The build stamp exists to tell a locally built tree apart from a release.
* A linked worktree is the checkout where that matters most — it is the one
* whose HEAD differs from the tree a developer thinks they are running — and
* it is also the one whose `.git` is a file rather than a directory, so a
* resolver that only reads `<root>/.git/HEAD` finds nothing there and the
* stamp falls back to a bare version number.
*/

import assert from 'node:assert/strict';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { after, test } from 'node:test';
import { resolveBuildInfo } from '../build-info.js';

const roots: string[] = [];

after(async () => {
await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })));
});

const SHA = '4d223d05beea1bfa8af3b4bb4fe1211a9e93acfe';

test('a packaged build reports no commit and does not read the filesystem for one', () => {
// `app.isPackaged` is the whole answer: a packaged tree has no repository,
// and a commit resolved from one next to it would describe the wrong thing.
assert.deepEqual(resolveBuildInfo(true, process.cwd()), { mode: 'packaged', commit: null });
});

test('an ordinary clone resolves the commit through a ref', async () => {
const root = await makeRoot();
await seedGitDir(join(root, '.git'), { head: 'ref: refs/heads/main', refs: { 'refs/heads/main': SHA } });
assert.deepEqual(resolveBuildInfo(false, root), { mode: 'dev', commit: SHA.slice(0, 7) });
});

test('a linked worktree resolves through its gitdir pointer', async () => {
// `.git` is a FILE here, holding `gitdir: <path>`. This is the case the
// resolver used to miss entirely.
const root = await makeRoot();
const real = join(root, 'real-git-dir');
await seedGitDir(real, { head: 'ref: refs/heads/feature', refs: { 'refs/heads/feature': SHA } });
await writeFile(join(root, '.git'), `gitdir: ${real}\n`);
assert.deepEqual(resolveBuildInfo(false, root), { mode: 'dev', commit: SHA.slice(0, 7) });
});

test('a worktree pointer relative to the checkout resolves', async () => {
// Git writes a relative pointer for a worktree created inside the repo.
const root = await makeRoot();
await seedGitDir(join(root, 'nested', 'git-dir'), {
head: 'ref: refs/heads/main',
refs: { 'refs/heads/main': SHA },
});
await writeFile(join(root, '.git'), 'gitdir: nested/git-dir\n');
assert.deepEqual(resolveBuildInfo(false, root), { mode: 'dev', commit: SHA.slice(0, 7) });
});

test('a detached HEAD is already the sha', async () => {
const root = await makeRoot();
await seedGitDir(join(root, '.git'), { head: SHA, refs: {} });
assert.deepEqual(resolveBuildInfo(false, root), { mode: 'dev', commit: SHA.slice(0, 7) });
});

test('a packed ref resolves when no loose ref file exists', async () => {
const root = await makeRoot();
const gitDir = join(root, '.git');
await seedGitDir(gitDir, { head: 'ref: refs/heads/main', refs: {} });
await writeFile(join(gitDir, 'packed-refs'), `# pack-refs with: peeled\n${SHA} refs/heads/main\n`);
assert.deepEqual(resolveBuildInfo(false, root), { mode: 'dev', commit: SHA.slice(0, 7) });
});

test('an unreadable checkout reports dev with no commit rather than throwing', async () => {
// Absence is a supported answer: the stamp shows the version alone. A throw
// here would fail `app:info`, which carries far more than the commit.
const root = await makeRoot();
assert.deepEqual(resolveBuildInfo(false, root), { mode: 'dev', commit: null });

const dangling = await makeRoot();
await writeFile(join(dangling, '.git'), 'gitdir: /nowhere/that/exists\n');
assert.deepEqual(resolveBuildInfo(false, dangling), { mode: 'dev', commit: null });
});

async function makeRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'maka-build-info-'));
roots.push(root);
return root;
}

async function seedGitDir(
gitDir: string,
input: { head: string; refs: Readonly<Record<string, string>> },
): Promise<void> {
await mkdir(gitDir, { recursive: true });
await writeFile(join(gitDir, 'HEAD'), `${input.head}\n`);
for (const [ref, sha] of Object.entries(input.refs)) {
const refPath = join(gitDir, ...ref.split('/'));
await mkdir(join(refPath, '..'), { recursive: true });
await writeFile(refPath, `${sha}\n`);
}
}
43 changes: 38 additions & 5 deletions apps/desktop/src/main/build-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
* One-shot — captured at module load and cached.
*/

import { readFileSync, existsSync } from 'node:fs';
import { join, resolve, dirname } from 'node:path';
import { existsSync, readFileSync, statSync } from 'node:fs';
import { dirname, isAbsolute, join, resolve } from 'node:path';

export interface BuildInfo {
readonly mode: 'dev' | 'packaged';
Expand All @@ -38,16 +38,49 @@ function findRepoRoot(start: string): string | null {
return null;
}

/**
* The directory holding this checkout's Git metadata.
*
* `.git` is a directory in an ordinary clone and a **file** in a linked
* worktree, where it holds `gitdir: <path>` pointing at
* `<main>/.git/worktrees/<name>`. Reading `<root>/.git/HEAD` therefore finds
* nothing in a worktree — and a worktree is exactly the checkout whose commit
* a developer most needs to see, since it is the one that differs from the
* tree they think they are running.
*
* A relative `gitdir:` is resolved against the checkout, which is how Git
* writes it for a worktree created inside the repository.
*/
function resolveGitDir(repoRoot: string): string | null {
const dotGit = join(repoRoot, '.git');
try {
if (statSync(dotGit).isDirectory()) return dotGit;
} catch {
return null;
}
try {
const pointer = readFileSync(dotGit, 'utf8').trim();
if (!pointer.startsWith('gitdir:')) return null;
const target = pointer.slice('gitdir:'.length).trim();
if (!target) return null;
return isAbsolute(target) ? target : resolve(repoRoot, target);
} catch {
return null;
}
}

function resolveCommit(repoRoot: string): string | null {
const gitDir = resolveGitDir(repoRoot);
if (!gitDir) return null;
try {
const headPath = join(repoRoot, '.git', 'HEAD');
const headPath = join(gitDir, 'HEAD');
if (!existsSync(headPath)) return null;
const head = readFileSync(headPath, 'utf8').trim();
if (head.startsWith('ref: ')) {
const refPath = join(repoRoot, '.git', head.slice(5).trim());
const refPath = join(gitDir, head.slice(5).trim());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Follow the worktree commondir when resolving branch refs

Thanks for moving the fix into the existing build-info authority. The .git → gitdir hop is now handled, but a normal branch-linked worktree has one more layer: its private admin directory contains HEAD and a commondir file, while the loose refs and packed-refs live in the common Git directory.

The current resolver still looks for the branch ref inside the private worktree admin directory, so real branch worktrees return commit: null. The new fixture passes because it places refs/heads/feature in that private directory, which Git normally does not do.

The clean fix is to keep reading HEAD from the worktree admin directory, resolve commondir, and read loose or packed refs from the resulting common Git directory. Updating the existing fixture to match that real layout should be sufficient; no additional renderer or IPC path is needed.

if (!existsSync(refPath)) {
// Packed refs path — read packed-refs and match the ref name.
const packed = join(repoRoot, '.git', 'packed-refs');
const packed = join(gitDir, 'packed-refs');
if (!existsSync(packed)) return null;
const target = head.slice(5).trim();
const lines = readFileSync(packed, 'utf8').split('\n');
Expand Down
34 changes: 34 additions & 0 deletions apps/desktop/src/renderer/app-shell-build-stamp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useEffect, useState } from 'react';
import type { SidebarBuildStamp } from '@maka/ui';

/**
* The running build, for the rail's footer.
*
* Read once at mount and never again: neither the version nor the commit can
* change without the process restarting, so a subscription would re-render for
* a value that cannot move.
*
* Failure is silent and the stamp is simply absent. This is an orientation
* label — a rail that shows nothing is strictly better than one that shows an
* error where a version belongs, and the About page reports the same failure
* where a user went looking for it.
*/
export function useBuildStamp(): SidebarBuildStamp | undefined {
const [stamp, setStamp] = useState<SidebarBuildStamp | undefined>(undefined);

useEffect(() => {
let cancelled = false;
window.maka.app
.info()
.then((info) => {
if (cancelled) return;
setStamp({ version: info.appVersion, commit: info.buildCommit });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Thanks for wiring the footer to the existing app:info result. One development-path gap remains: when Maka is run from a normal Git worktree, .git is a file containing a gitdir pointer, so the current main-process build-info resolver returns commit: null. This makes the new stamp render only v<version> and fails to distinguish the locally built tree from a packaged release—the exact problem this change is meant to solve for contributors. Could we extend the existing build-info authority to follow worktree metadata and add a focused resolver test, while keeping the renderer as a pure app:info consumer rather than adding a second Git reader here?

})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, []);

return stamp;
}
3 changes: 3 additions & 0 deletions apps/desktop/src/renderer/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ import { modelSetupToastCopy } from './model-connection-errors';
import type { AppShellCommandListOptions } from './app-shell-command-actions';
import { AppShellTopbarActions, AppShellWorkspaceTopActions } from './app-shell-chrome-actions';
import { updateReminderFromStatus } from './app-shell-app-update';
import { useBuildStamp } from './app-shell-build-stamp';
import { AppShellDetailPanel } from './app-shell-detail-panel';
import { AppShellOverlays } from './app-shell-overlays';
import type { ArchivedTasksBridge } from './settings/tasks-settings-page';
Expand Down Expand Up @@ -602,6 +603,7 @@ function AppShellContent({
}, []);

const updateReminder = updateReminderFromStatus(appUpdateStatus);
const buildStamp = useBuildStamp();
// Dispatches on the task, not on the raw status: the footer is this
// callback's only caller and it only renders for the two states above, so
// reading the status again here would be the same "who needs the user" list
Expand Down Expand Up @@ -3031,6 +3033,7 @@ function AppShellContent({
onSelect={setNavSelection}
onSelectSession={sessionListSelectSession}
onOpenSettings={openSettings}
buildStamp={buildStamp}
updateReminder={updateReminder}
onOpenUpdate={openUpdateDownload}
onNew={createSession}
Expand Down
35 changes: 34 additions & 1 deletion apps/desktop/src/renderer/styles/sidebar.css
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,10 @@
}

.maka-sidebar-footer-row-primary {
flex: 1;
/* `auto` rather than `1 1 0`: the settings row asks for the width its label
needs and gives back the rest, so the stamp shrinks into what is left
instead of the label being clipped first. */
flex: 1 1 auto;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P3] Let the build stamp yield before the Settings label

This is much better than the previous fixed-width stamp. At the supported 180px width with an update reminder present, however, both the Settings row and the stamp can still shrink, so the Settings label may retain a slight ellipsis. Preserving the Settings row's minimum content width and letting only the stamp truncate would make the intended priority deterministic. The icon, action, and accessible name remain intact, so this is only polish.

min-width: 0;
}

Expand All @@ -177,6 +180,36 @@
align-self: center;
}

/* A label, not a control: it takes no pointer events and no focus, so tabbing
through the rail still goes Settings -> update button as it did before. */
.maka-sidebar-build-stamp {
/* The stamp yields first. At the 180px minimum width with an update button
present, something has to give, and it should be the label a user is
reading rather than the row they are aiming at — Settings keeps its text
and its hit target, the stamp ellipsises, and its `title` still carries
the full build. */
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-tertiary);
font-size: var(--font-size-xs);
font-variant-numeric: tabular-nums;
letter-spacing: 0.01em;
white-space: nowrap;
pointer-events: none;
user-select: none;
-webkit-app-region: no-drag;
}

/* Collapsed, the rail is 48px and the footer stacks. `v0.1.11 · a1b2c3d` does
not fit and would either clip or force the rail wider, so it drops — the
About page still carries it, and a collapsed rail is a deliberate ask for
less. */
.appFrame[data-sidebar-state="collapsed"] .maka-sidebar-build-stamp {
display: none;
}

.maka-sidebar-update-button {
flex: none;
/* Astryx has no shape prop — `isIconOnly` is documented as rendering a
Expand Down
69 changes: 69 additions & 0 deletions packages/ui/src/__tests__/sidebar-build-stamp.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* The rail footer answers "which build is this?" — the question the About
* page already answers two clicks away, which is why it was worth putting
* where the user already looks.
*
* Both halves are asserted. A dev build and a release build render different
* strings, and the stamp keeps its place when the update button appears: the
* button is an action and takes the edge, the stamp is a label and must not
* move under it.
*/

import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { LocaleProvider } from '../locale-context.js';
import { SessionSidebarFooter, type SidebarBuildStamp } from '../session-sidebar-nav.js';

function renderFooter(
buildStamp?: SidebarBuildStamp,
updateReminder?: { state: 'downloaded' | 'error'; latestVersion: string },
): string {
return renderToStaticMarkup(
<LocaleProvider locale="en">
<SessionSidebarFooter
buildStamp={buildStamp}
updateReminder={updateReminder}
onOpenSettings={() => undefined}
onOpenUpdate={() => undefined}
/>
</LocaleProvider>,
);
}

function stampText(markup: string): string | null {
const match = /class="maka-sidebar-build-stamp"[^>]*>([^<]*)</.exec(markup);
return match?.[1] ?? null;
}

test('a release build shows the version alone', () => {
// `build-info.ts` returns `commit: null` once packaged — there is no `.git`
// to read — so the version is the whole identity a release can offer.
assert.equal(stampText(renderFooter({ version: '0.1.11', commit: null })), 'v0.1.11');
});

test('a dev build appends the short commit, which is what distinguishes two of them', () => {
assert.equal(
stampText(renderFooter({ version: '0.1.11', commit: '4d223d05beea1bfa' })),
'v0.1.11 · 4d223d0',
);
});

test('no stamp renders before the build info resolves', () => {
// The value arrives over async IPC. Rendering a placeholder would put a
// wrong version on screen for the time it takes to be replaced.
assert.equal(stampText(renderFooter(undefined)), null);
});

test('the update button takes the edge and the stamp keeps its place', () => {
const markup = renderFooter({ version: '0.1.11', commit: null }, {
state: 'downloaded',
latestVersion: '0.1.12',
});
const stampAt = markup.indexOf('maka-sidebar-build-stamp');
const buttonAt = markup.indexOf('maka-sidebar-update-button');
assert.ok(stampAt > 0, 'the stamp renders alongside an update reminder');
assert.ok(buttonAt > 0, 'the update button still renders');
// Order in the flex row is order in the markup: settings, stamp, button.
assert.ok(stampAt < buttonAt, 'the stamp precedes the update button');
});
2 changes: 1 addition & 1 deletion packages/ui/src/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export type { ModuleHubHeader } from './module-hub-selector.js';
export { SearchModal } from './search-modal.js';
export { SessionListPanel } from './session-list-panel.js';
export type { SessionViewMode } from './session-list-panel.js';
export type { SidebarUpdateReminder } from './session-sidebar-nav.js';
export type { SidebarBuildStamp, SidebarUpdateReminder } from './session-sidebar-nav.js';
export type { BundledSkillCatalogEntry, DailyReviewMarkdownActionInput, ManagedSkillSourceEntry, ManagedSkillUpdatePreview, SkillEntry, SkillGovernanceDetails } from './module-panel-types.js';
export { describeLoadToolResult, formatRedactedJson, formatToolIntent, loadToolDisplayName } from './tool-format.js';
export { formatBytes, ToolCallDetail, ToolTrow } from './tool-activity.js';
Expand Down
9 changes: 8 additions & 1 deletion packages/ui/src/session-list-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ import {
type SessionHistoryGroup,
type SessionRowActions,
} from './session-history-list.js';
import { SessionSidebarFooter, SessionSidebarNav, type SidebarUpdateReminder } from './session-sidebar-nav.js';
import {
SessionSidebarFooter,
SessionSidebarNav,
type SidebarBuildStamp,
type SidebarUpdateReminder,
} from './session-sidebar-nav.js';
import { useUiLocale } from './locale-context.js';
import { getConversationCopy } from './conversation-copy.js';
import type { Ref } from 'react';
Expand Down Expand Up @@ -49,6 +54,7 @@ export function SessionListPanel(props: {
moduleMemory?: NavModuleMemory;
onSelect(selection: NavSelection): void;
onOpenSettings(): void;
buildStamp?: SidebarBuildStamp;
updateReminder?: SidebarUpdateReminder;
onOpenUpdate?(): void;
onNew(): void;
Expand Down Expand Up @@ -151,6 +157,7 @@ export function SessionListPanel(props: {
}
footer={
<SessionSidebarFooter
buildStamp={props.buildStamp}
updateReminder={props.updateReminder}
onOpenSettings={props.onOpenSettings}
onOpenUpdate={props.onOpenUpdate}
Expand Down
Loading