Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6a4e5cd
feat: add advanced workspace mode
maker-or Sep 5, 2026
65d3945
fix: address worktree review findings
maker-or Sep 5, 2026
f03bef2
fix: make worktree cleanup resilient
maker-or Sep 5, 2026
4b4dd22
ci: allow react doctor with bun projects
maker-or Sep 5, 2026
2836332
fix: restore advanced worktree creation
maker-or Sep 5, 2026
adc1031
fix: newest worktree on top, system-token active highlight in sidebar
maker-or Sep 6, 2026
49672e4
Merge remote-tracking branch 'origin/main' into understand-codebase-w…
maker-or Sep 7, 2026
ee093c0
Merge origin/main: resolve authenticated-stage conflict (keep ui-mode…
maker-or Sep 9, 2026
a5bc12a
Merge remote-tracking branch 'origin/main' into understand-codebase-w…
maker-or Sep 9, 2026
5e64a9b
Merge remote-tracking branch 'origin/main' into understand-codebase-w…
maker-or Sep 10, 2026
23c5fb7
Merge remote-tracking branch 'origin/main' into understand-codebase-w…
maker-or Sep 11, 2026
3deefaa
Workspace git/PR integration, agent panel and shell improvements
maker-or Sep 12, 2026
a4b554c
Workspace git/PR integration polish, shell and agent panel fixes
maker-or Sep 12, 2026
b490fbc
Workspace UI polish: sidebar, changes tab, composer
maker-or Sep 14, 2026
48a6737
Sidebar redesign, git-state tint, and git status hardening
maker-or Sep 16, 2026
c1fb601
Merge remote-tracking branch 'origin/main' into understand-codebase-w…
maker-or Sep 18, 2026
190f18e
Merge origin/main into understand-codebase-worktrees
maker-or Sep 22, 2026
0bee92d
PR status caching, thread completion dock, and settings redesign
maker-or Sep 22, 2026
96131ad
Bundle git skills, surface base-branch staleness, and extract setting…
maker-or Sep 22, 2026
c2d2016
Add workspace git init, PR status caching, and running-agent marks
maker-or Sep 23, 2026
694430a
Grow the composer, top-align its turn marker, and simplify settings c…
maker-or Sep 23, 2026
b392146
Bundle git skills and drive workspace surfaces from a shared git-stat…
maker-or Sep 23, 2026
333d750
Tint turn identities, user bubbles, and the composer caret with the g…
maker-or Sep 23, 2026
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
26 changes: 19 additions & 7 deletions @/components/ui/chat-message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { cn } from "@/lib/utils";
import { springs } from "@/lib/springs";
import { useShape } from "@/lib/shape-context";
import { FileThumbnail } from "@/components/ui/file-thumbnail";
import { toneIdentity } from "@/lib/workspace-tone";
import { useWorkspaceTone } from "@/lib/workspace-tone-context";

interface ChatMessageProps extends Omit<HTMLMotionProps<"div">, "children"> {
from: "user" | "assistant";
Expand Down Expand Up @@ -40,7 +42,14 @@ const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
ref,
) => {
const shape = useShape();
const workspaceTone = useWorkspaceTone();
const isUser = from === "user";
// The user bubble carries the workspace's git-state tone when one is in
// scope (same colour as the composer's @ marker); otherwise it keeps the
// identity's default green.
const userBubble = workspaceTone
? toneIdentity(workspaceTone)
: { bg: "#26B25A", ink: "#052E16", ring: "#088139b3" };
const showTime = isUser && time != null;
// Three-line clamp on user messages (assistant replies stream, so they
// stay unclamped). `line-clamp-3` is always applied to user bubbles when
Expand Down Expand Up @@ -122,15 +131,18 @@ const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
{children != null && children !== "" && (
<div
ref={isUser ? bodyRef : undefined}
style={
isUser
? {
backgroundColor: userBubble.bg,
color: userBubble.ink,
boxShadow: `inset 0 0 0 1px ${userBubble.ring}`,
}
: undefined
}
className={cn(
"max-w-full py-2 text-[14px] whitespace-pre-wrap break-words text-pretty",
isUser
? cn(
shape.bg,
"bg-[#26B25A] px-3.5 text-[#052E16] ring-1 ring-inset ring-[#088139]/70",
!expanded && "line-clamp-3",
)
: "text-foreground",
isUser ? cn(shape.bg, "px-3.5", !expanded && "line-clamp-3") : "text-foreground",
)}
>
{children}
Expand Down
170 changes: 170 additions & 0 deletions contracts/git.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/**
* First-party git state for the advanced workspace UI. One workspace =
* one worktree, so every query is scoped to a worktree path.
*/

export interface WorkspaceGitFile {
path: string;
staged: boolean;
status: "added" | "modified" | "deleted" | "renamed" | "untracked";
/**
* Lines added in the working tree (untracked files count all their lines).
* `null` when git reports no line counts (binary, oversized, or unreadable).
*/
additions: number | null;
/** Lines removed in the working tree; `null` when unknown. */
deletions: number | null;
}

/**
* Whether git state could be read for a workspace.
* - `ready`: git answered; every field below is meaningful.
* - `absent`: there is genuinely no repository at this path. Initializing
* one is the legitimate fix, and only here.
* - `broken`: the path carries repository metadata but git would not answer
* — an orphaned worktree admin dir, a moved checkout, or a transient
* failure (spawn error, timeout, lock contention). The repository exists,
* so never offer to initialize one: it cannot help and would act on the
* wrong tree.
*
* These were one boolean until a worktree whose admin directory had been
* pruned reported "not a git repository" and the panel offered to create
* one inside an existing repo.
*/
export type WorkspaceRepoState = "ready" | "absent" | "broken";

/**
* Whether a *project root* can host a workspace worktree — read before
* creating one, when the project root is not yet a workspace.
*
* Extends `WorkspaceRepoState` with `unborn`: the repository exists but has
* no commits, so `git worktree add` has no commit to branch from and every
* workspace create fails. It is a distinct state because the fix is an
* initial commit, not an init — folding it into `absent` would offer to
* initialize a repository that already exists.
*/
export type ProjectRepoState = WorkspaceRepoState | "unborn";

export interface WorkspaceGitStatus {
repoState: WorkspaceRepoState;
branch: string | null;
/** Upstream of the current branch, e.g. "origin/pipper/foo". */
upstream: string | null;
/**
* Commits not yet published. With an upstream this is the usual
* ahead-of-@{u} count; without one it is the count beyond the base branch
* (what a first push would publish).
*/
ahead: number;
behind: number;
/**
* Commits on this branch beyond the base branch (origin default, else
* main). Distinguishes "pushed, nothing to do yet" from "pushed, ready
* for a PR" — `ahead` alone reads 0 for both once in sync.
*/
aheadOfBase: number;
/**
* Commits on the base branch this branch does not have yet — "the team
* has new changes". Measured against the local remote-tracking ref, so it
* only moves after a fetch; `baseFetchedAt` says how current that is.
*/
behindBase: number;
/** Ref the base counts are measured against, e.g. "origin/main". */
baseBranch: string | null;
/**
* Time of the last successful background fetch of the base branch for this
* workspace, or null when none has completed (no remote, offline, or not
* yet attempted).
*/
baseFetchedAt: number | null;
staged: number;
unstaged: number;
untracked: number;
/** Capped file list for the panel (newest/significant first). */
files: WorkspaceGitFile[];
truncated: boolean;
/** Remote URL host (github.com, …) when a remote exists. */
remoteHost: string | null;
/** True when the `gh` CLI is available for PR operations. */
ghAvailable: boolean;
/** Existing open PR number for the branch, when known. */
openPrNumber: number | null;
openPrUrl: string | null;
/** True when the open PR is a draft. */
isDraftPr: boolean;
/**
* Most recent merged PR for the branch, only reported when no PR is open.
* Drives the "merged — archive or continue" state.
*/
mergedPrNumber: number | null;
mergedPrUrl: string | null;
/** Aggregate CI state for the open PR. `none` = no check runs reported. */
checksState: "unknown" | "none" | "pending" | "passing" | "failing";
/** Per-check detail for the open PR's head commit (deduped by name, latest run). */
checks: WorkspacePrCheck[];
/** Rich detail for the open PR, or the merged one when nothing is open. */
pr: WorkspacePr | null;
/**
* Freshness of the GitHub-derived fields above. Local git fields remain
* live even when this is stale or unavailable.
*/
prDataState: "fresh" | "stale" | "unavailable";
/** Time of the last successful GitHub response used by this status. */
prUpdatedAt: number | null;
/**
* A background GitHub refresh is in flight, so a newer answer is coming
* shortly. GitHub is never awaited by a status read — it answers in ~1s
* against ~70ms for all local git — so the panel paints local state now and
* re-reads soon after instead of waiting for its next poll.
*/
prRefreshing: boolean;
}

export type WorkspacePrCheckState = "pending" | "passing" | "failing" | "skipped";

export interface WorkspacePrCheck {
name: string;
state: WorkspacePrCheckState;
/** Run length when the check has both timestamps. */
durationMs: number | null;
url: string | null;
}

export interface WorkspacePrDeployment {
environment: string;
state: "pending" | "success" | "failure" | "inactive";
url: string | null;
}

export interface WorkspacePrComment {
id: string;
author: string;
avatarUrl: string | null;
/** Full body, capped; the panel shows a one-line preview. */
body: string;
url: string | null;
createdAt: string;
/** Set for inline review comments. */
path: string | null;
line: number | null;
}

/** What the panel shows for a filed (draft or ready) or merged PR. */
export interface WorkspacePr {
number: number;
url: string;
title: string;
body: string;
isDraft: boolean;
state: "open" | "merged";
deployments: WorkspacePrDeployment[];
/** Issue comments then review-thread openers, oldest first, capped. */
comments: WorkspacePrComment[];
}

export interface WorkspaceGitActionResult {
ok: boolean;
message: string;
/** PR URL for create-pr. */
url?: string | null;
}
33 changes: 33 additions & 0 deletions contracts/worktrees.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,21 @@ export interface Worktree {
head: string;
/** True when this entry is the project's configured root checkout. */
isProjectRoot?: boolean;
/**
* The checkout is gone: git reports the entry as prunable, or its directory
* no longer exists. Git keeps listing such entries until
* `git worktree prune`, so `listWorktrees` drops them — nothing can run
* there. Only the raw porcelain parse ever reports this as true.
*/
missing?: boolean;
/** Git-derived label for this workspace (the default branch for the root). */
workspaceName?: string;
/**
* Worktree directory creation time (ms). `git worktree list` emits readdir
* order (effectively arbitrary), so UIs sort newest-first on this instead.
* Absent for the project root and when the filesystem reports nothing.
*/
createdAtMs?: number;
}

/**
Expand All @@ -36,6 +49,26 @@ export interface CreateWorktreeInput {
name: string;
}

/**
* Newest-first for linked worktrees (project root always pins top).
* `git worktree list` emits readdir order, so creation order must come from
* `createdAtMs`; entries without it keep git's relative order at the end.
*/
export function orderWorktreesForDisplay(worktrees: Worktree[]): Worktree[] {
const root = worktrees.filter((item) => item.isProjectRoot);
const rest = worktrees
.filter((item) => !item.isProjectRoot)
.map((item, index) => ({ item, index }))
.sort((a, b) => {
const at = a.item.createdAtMs ?? -1;
const bt = b.item.createdAtMs ?? -1;
if (at !== bt) return bt - at;
return a.index - b.index;
})
.map(({ item }) => item);
return [...root, ...rest];
}

/**
* Progress of the background dependency install that follows a worktree
* create, broadcast to the renderer for toasts. `skipped` means the project
Expand Down
10 changes: 10 additions & 0 deletions electron/analytics-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,16 @@ export type IntentCategory =
export type AnalyticsEventName =
| "app_opened"
| "project_created"
| "project_git_initialized"
| "worktree_created"
| "workspace_switched"
// advanced workspace git panel
| "workspace_committed"
| "workspace_pushed"
| "workspace_pr_created"
| "workspace_pr_merged"
| "workspace_merged"
| "workspace_continued"
| "thread_created"
| "thread_opened"
| "agent_run_completed"
Expand Down Expand Up @@ -50,6 +58,8 @@ export interface AnalyticsBaseProperties {

export interface AnalyticsProperties {
project_id?: string;
/** workspace_pr_created: whether the PR was opened as a draft. */
draft?: boolean;
thread_id?: string;
model_id?: string;
model_provider?: string;
Expand Down
17 changes: 17 additions & 0 deletions electron/db.migration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,20 @@ describe("threads table legacy-constraint rebuild", () => {
expect(created.title).toBeNull();
});
});

describe("GitHub PR snapshot cache", () => {
test("persists the last successful response across database reopen", async () => {
const { closeDb, getGithubPrSnapshot, saveGithubPrSnapshot } = await import("./db.ts");
saveGithubPrSnapshot("owner/repo", "feature", '{"number":42}', 1234);
expect(getGithubPrSnapshot("owner/repo", "feature")).toEqual({
snapshotJson: '{"number":42}',
updatedAt: 1234,
});

closeDb();
expect(getGithubPrSnapshot("owner/repo", "feature")).toEqual({
snapshotJson: '{"number":42}',
updatedAt: 1234,
});
});
});
65 changes: 65 additions & 0 deletions electron/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,21 @@ export function getDb(): DatabaseSync {
);
`);

// Last-known-good GitHub PR data. This is a display cache: local git stays
// authoritative, while the cached remote slice keeps the workflow panel
// useful during network/auth outages and across app restarts.
db.exec(`
CREATE TABLE IF NOT EXISTS github_pr_snapshots (
repository TEXT NOT NULL,
branch TEXT NOT NULL,
snapshot_json TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (repository, branch)
);
CREATE INDEX IF NOT EXISTS idx_github_pr_snapshots_updated_at
ON github_pr_snapshots(updated_at ASC);
`);

db.exec(`
CREATE TABLE IF NOT EXISTS mcp_servers (
id TEXT PRIMARY KEY,
Expand Down Expand Up @@ -290,6 +305,56 @@ export function closeDb(): void {
db = null;
}

export interface GithubPrSnapshotRecord {
snapshotJson: string;
updatedAt: number;
}

export function getGithubPrSnapshot(
repository: string,
branch: string,
): GithubPrSnapshotRecord | null {
const row = getDb()
.prepare(
`SELECT snapshot_json, updated_at
FROM github_pr_snapshots
WHERE repository = ? AND branch = ?`,
)
.get(repository, branch) as { snapshot_json: string; updated_at: number } | undefined;
return row ? { snapshotJson: row.snapshot_json, updatedAt: row.updated_at } : null;
}

export function saveGithubPrSnapshot(
repository: string,
branch: string,
snapshotJson: string,
updatedAt: number,
): void {
const database = getDb();
database
.prepare(
`INSERT INTO github_pr_snapshots (repository, branch, snapshot_json, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(repository, branch) DO UPDATE SET
snapshot_json = excluded.snapshot_json,
updated_at = excluded.updated_at`,
)
.run(repository, branch, snapshotJson, updatedAt);
// Branch names are effectively unbounded over an install's lifetime. Keep
// enough history for active work without allowing cached comments/bodies to
// grow the database forever.
database
.prepare(
`DELETE FROM github_pr_snapshots
WHERE rowid IN (
SELECT rowid FROM github_pr_snapshots
ORDER BY updated_at DESC
LIMIT -1 OFFSET 200
)`,
)
.run();
}

export interface AuthUserRecord {
provider: string;
provider_user_id: string;
Expand Down
Loading
Loading