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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ keyboard alone, and the mouse stays first-class.
vertical icon rail or horizontal toolbar tabs, your pick in Appearance)
persisted across launches, saveable as named **workspaces** (the repos behind
one product — open a workspace to focus the rail on just those repos, close it
to return to your default set; a manage dialog curates each), native macOS
to return to your default set; a manage dialog curates each, and creating,
switching, and managing are all in ⌘K), native macOS
menubar, open in your editor or terminal, settings (⌘,) for appearance / diff /
git / integrations / AI, in-app updates.
- **AI commit messages** — suggest subject + body from staged changes via
Expand Down
15 changes: 15 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -826,6 +826,21 @@ queue live, which the single-repo store never did. Verified: `tsc`, `vitest`
repos and clamps, Space flips 0/3 → 1/3 with the section count following,
row-visibility gate at ≥2 members).

**Workspaces — Phase 3 kick, palette entries (2026-07-02):** Workspace
management joined the command palette, closing the keyboard-first gap. A new
**Workspaces** palette group lists every workspace (Default included, active
check-marked, repo counts as meta) — it shows once a named workspace exists
and is part of the empty-query set, so switching is ⌘K → pick; rows run the
same `openWorkspace` path as the switcher menu. The Actions group gains
"New workspace…" and "Manage workspaces…"; create routes into
`WorkspaceManagerDialog` in a new create mode (`initialCreate`), and the
manager itself grew a "+ New workspace" row (creating was switcher-menu-only
before) that spawns the workspace with a placeholder name and autofocuses
the name field with the text selected — type to replace, Enter to commit.
Verified: `tsc`, `vitest` (158), `vite build`. Remaining Phase 3:
`.code-workspace` import + the Workspace Review follow-ups (tracked in
TASKS).

---

## 1.0 — Stable (≈ 20 weeks)
Expand Down
19 changes: 15 additions & 4 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -440,10 +440,21 @@ Legend: ☐ not started · ◐ in progress · ☑ done · ✗ blocked
notes, and ⌘F stay in the per-repo Review (deliberate v1 cuts — hunk
staging needs per-path `apply_patch` plumbing; cross-repo note export needs
a repo-grouped feedback format).
- ☐ Workspace polish (Phase 3): command-palette entries for workspace
management (create/open/switch), `.code-workspace` import, and Workspace
Review follow-ups: hunk-level stage/discard, notes + repo-grouped feedback
export, ⌘F across member pools, per-worktree members.
- ◐ Workspace polish (Phase 3):
- ☑ Command-palette entries for workspace management (2026-07-02): a
**Workspaces** palette group — one row per workspace (Default included;
active one check-marked, others show a repo-count meta; the group appears
once a named workspace exists and is included in the empty-query groups,
so ⌘K → pick is a two-keystroke switch) running `openWorkspace`; Actions
gain "New workspace…" + "Manage workspaces…". "New workspace…" opens
`WorkspaceManagerDialog` in create mode (new `initialCreate` prop) — the
manager grew its own create path (a "+ New workspace" row in the list;
creating was switcher-menu-only before): the workspace is spawned with a
placeholder name and the name field autofocuses with the text selected.
- ☐ `.code-workspace` import
- ☐ Workspace Review follow-ups: hunk-level stage/discard, notes +
repo-grouped feedback export, ⌘F across member pools, per-worktree
members.

### Topbar
- ☑ Layout + native-chrome alignment
Expand Down
41 changes: 37 additions & 4 deletions ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,10 @@ export function App() {
const unstagedCount = useRepo((s) => s.unstagedDiffs.length);
const stagedCount = useRepo((s) => s.stagedDiffs.length);
const baselineDiffCount = useRepo((s) => s.baselineDiffs.length);
// Workspace list + active id feed the palette's Workspaces group — a
// handful of user-created entries, so subscribing whole is cheap.
const workspaces = useWorkspaces((s) => s.workspaces);
const activeWorkspaceId = useWorkspaces((s) => s.activeWorkspaceId);

const [paletteOpen, setPaletteOpen] = useState(false);
const [repoSwitcherOpen, setRepoSwitcherOpen] = useState(false);
Expand Down Expand Up @@ -227,7 +231,9 @@ export function App() {
const [worktreeOpen, setWorktreeOpen] = useState(false);
// null = closed; otherwise the repo whose rail tile is being customized.
const [iconDialog, setIconDialog] = useState<{ path: string; name: string } | null>(null);
const [workspaceManagerOpen, setWorkspaceManagerOpen] = useState(false);
// false = closed; 'create' opens the manager mid-create (palette "New
// workspace…") — the dialog spawns a workspace and focuses its name field.
const [workspaceManagerOpen, setWorkspaceManagerOpen] = useState<boolean | 'create'>(false);
const [syncing, setSyncing] = useState(false);
const [pulling, setPulling] = useState(false);
const [pushing, setPushing] = useState(false);
Expand Down Expand Up @@ -969,6 +975,8 @@ export function App() {
{ id: 'open', label: 'Open repository…', group: 'Actions', shortcut: keyHint('open-repo'), run: () => { void openViaDialog(); } },
{ id: 'clone', label: 'Clone repository…', group: 'Actions', shortcut: keyHint('clone-repo'), run: () => setCloneOpen(true) },
{ id: 'switch-repo', label: 'Switch repository…', group: 'Actions', shortcut: keyHint('switch-repo'), keywords: 'switch repo repository jump active picker quick open', run: () => setRepoSwitcherOpen(true) },
{ id: 'workspace-new', label: 'New workspace…', group: 'Actions', keywords: 'workspace create group repositories multi repo', run: () => setWorkspaceManagerOpen('create') },
{ id: 'workspace-manage', label: 'Manage workspaces…', group: 'Actions', keywords: 'workspace edit curate repositories add remove rename delete', run: () => setWorkspaceManagerOpen(true) },
];
// Repo-scoped actions only make sense — and only succeed — with a repo
// open, so don't surface them (the network ones would fail confusingly).
Expand Down Expand Up @@ -1117,6 +1125,27 @@ export function App() {
},
});
}
// Workspace switching — one row per workspace (Default included), shown
// only once a named workspace exists; with none, "switch" is meaningless.
const workspaceActions: PaletteAction[] =
workspaces.some((w) => w.id !== DEFAULT_WORKSPACE_ID)
? workspaces.map((w) => {
const isActive = (activeWorkspaceId ?? DEFAULT_WORKSPACE_ID) === w.id;
const n = w.repoPaths.length;
return {
id: `workspace:${w.id}`,
label: w.id === DEFAULT_WORKSPACE_ID ? 'Default' : w.name,
group: 'Workspaces',
keywords: 'workspace open switch group',
meta: isActive ? 'active' : `${n} repo${n === 1 ? '' : 's'}`,
metaLabel: isActive
? 'active workspace'
: `${n} repositor${n === 1 ? 'y' : 'ies'}`,
...(isActive ? { icon: 'check' as const } : {}),
run: () => { void useWorkspaces.getState().openWorkspace(w.id); },
};
})
: [];
const recentActions: PaletteAction[] = recents.map((r) => ({
id: `recent:${r.path}`,
label: r.name,
Expand All @@ -1126,14 +1155,15 @@ export function App() {
icon: 'history',
run: () => { void openByPath(r.path); },
}));
return [...base, ...repoActions, ...recentActions];
return [...base, ...repoActions, ...workspaceActions, ...recentActions];
}, [setView, selectFile, onSync, onPull, onPush, openViaDialog, openByPath, setTheme, recents,
pushAllTags, showToast, meta, abortOperation, requestCommitSearch,
requestDiffSearch, requestSuggestCommitMessage, requestSelectSinceBaseline, openInEditor, openInTerminal, openSettingsAt,
repoActions, setRebaseDialog, setRemoteDialog, setRenameBranchDialog,
baseline, setBaseline, clearBaseline, stageReviewed, commits, resetTo,
unstagedCount, stagedCount, baselineDiffCount, copyDiffs,
reviewNoteCount, clearReviewNotes, keyHint, platform, cycleTab]);
reviewNoteCount, clearReviewNotes, keyHint, platform, cycleTab,
workspaces, activeWorkspaceId]);

const rootStyle = {
'--font-ui': FONTS.ui[uiFont],
Expand Down Expand Up @@ -1381,7 +1411,10 @@ export function App() {
)}

{workspaceManagerOpen && (
<WorkspaceManagerDialog onClose={() => setWorkspaceManagerOpen(false)} />
<WorkspaceManagerDialog
initialCreate={workspaceManagerOpen === 'create'}
onClose={() => setWorkspaceManagerOpen(false)}
/>
)}

{!isTauri() && !meta && (
Expand Down
8 changes: 8 additions & 0 deletions ui/src/styles/features.css
Original file line number Diff line number Diff line change
Expand Up @@ -3208,6 +3208,14 @@ textarea.clone-input {
color: var(--text-dim);
font-family: var(--font-mono);
}
/* The create row reads as an affordance, not a workspace. */
.ws-mgr-ws.new {
margin-top: 4px;
color: var(--text-dim);
}
.ws-mgr-ws.new:hover {
color: var(--text);
}

.ws-mgr-editor {
flex: 1;
Expand Down
12 changes: 7 additions & 5 deletions ui/src/views/Palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { match, type Match } from '../lib/fuzzy';
import { useSettings } from '../stores/settings';

/** Result groups, in the order they render in the list and the scope row. */
export type PaletteGroup = 'Actions' | 'Branches' | 'Tags' | 'Stashes' | 'Files' | 'Commits' | 'Recent';
export type PaletteGroup = 'Actions' | 'Branches' | 'Tags' | 'Stashes' | 'Files' | 'Commits' | 'Workspaces' | 'Recent';

export interface PaletteAction {
id: string;
Expand All @@ -25,14 +25,15 @@ export interface PaletteAction {
run(): void;
}

const GROUP_ORDER: PaletteGroup[] = ['Actions', 'Branches', 'Tags', 'Stashes', 'Files', 'Commits', 'Recent'];
const GROUP_ORDER: PaletteGroup[] = ['Actions', 'Branches', 'Tags', 'Stashes', 'Files', 'Commits', 'Workspaces', 'Recent'];
const GROUP_ICON: Record<PaletteGroup, IconName> = {
Actions: 'command',
Branches: 'branch',
Tags: 'tag',
Stashes: 'history',
Files: 'file',
Commits: 'graph',
Workspaces: 'workspace',
Recent: 'history',
};

Expand All @@ -41,7 +42,7 @@ const GROUP_ICON: Record<PaletteGroup, IconName> = {
const CAP_PER_GROUP = 6; // per group when scope = All
const CAP_SCOPED = 50; // single selected scope
// Defensive upper bound across all groups. The per-group caps already keep the
// total well under this (6 groups × 6 = 36 under "All", 50 under one scope);
// total well under this (8 groups × 6 = 48 under "All", 50 under one scope);
// it's belt-and-suspenders against a future group being added.
const CAP_TOTAL = 80;

Expand Down Expand Up @@ -116,8 +117,9 @@ export function CommandPalette({ actions, onClose }: Props) {
if (scope !== 'All' && a.group !== scope) continue;
// With no query under "All", only the cheap, always-relevant groups show;
// dumping every file/commit would be noise (and slow). Type or pick a
// scope to surface them.
if (empty && scope === 'All' && a.group !== 'Actions' && a.group !== 'Recent') continue;
// scope to surface them. (Workspaces qualify: a handful of user-created
// entries, and switching is exactly the "open palette, go" gesture.)
if (empty && scope === 'All' && a.group !== 'Actions' && a.group !== 'Workspaces' && a.group !== 'Recent') continue;
const m = match(query, a.label, a.keywords);
if (!m) continue;
const arr = byGroup.get(a.group) ?? [];
Expand Down
49 changes: 43 additions & 6 deletions ui/src/views/WorkspaceManagerDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,15 @@ import { DEFAULT_WORKSPACE_ID, useWorkspaces } from '../stores/workspaces';
* Membership edits are durable and take effect on the next open of that
* workspace; for the *active* one the rail/strip re-filter live (an added
* repo opens in the background and appears, a removed one hides).
*
* `initialCreate` (palette "New workspace…") starts a create on mount: the
* workspace is spawned immediately with a placeholder name and the name
* field focused with its text selected — type to replace, Enter to commit.
*/
export function WorkspaceManagerDialog({ onClose }: { onClose: () => void }) {
export function WorkspaceManagerDialog({ initialCreate, onClose }: { initialCreate?: boolean; onClose: () => void }) {
const workspaces = useWorkspaces((s) => s.workspaces);
const activeId = useWorkspaces((s) => s.activeWorkspaceId);
const create = useWorkspaces((s) => s.create);
const rename = useWorkspaces((s) => s.rename);
const remove = useWorkspaces((s) => s.remove);
const addRepo = useWorkspaces((s) => s.addRepo);
Expand All @@ -33,6 +38,9 @@ export function WorkspaceManagerDialog({ onClose }: { onClose: () => void }) {
const named = workspaces.filter((w) => w.id !== DEFAULT_WORKSPACE_ID);

const [selectedId, setSelectedId] = useState(activeId ?? DEFAULT_WORKSPACE_ID);
// The workspace just created here (if any) — its name field autofocuses
// with the placeholder selected so typing replaces it.
const [justCreatedId, setJustCreatedId] = useState<string | null>(null);
// Error from the last "add from disk" attempt (e.g. a non-repo folder).
const [addError, setAddError] = useState<string | null>(null);
const selected =
Expand Down Expand Up @@ -90,6 +98,25 @@ export function WorkspaceManagerDialog({ onClose }: { onClose: () => void }) {
setSelectedId(DEFAULT_WORKSPACE_ID);
};

// Create-then-rename: the workspace exists immediately (placeholder name,
// empty membership — the add list is right below), and the name field takes
// focus with the text selected so typing replaces it.
const createWorkspace = async () => {
const id = await create('Workspace', []);
setSelectedId(id);
setJustCreatedId(id);
};

// Palette "New workspace…" lands here mid-create. Ref-guarded so
// StrictMode's double-run of the mount effect can't create two.
const initialCreateRan = useRef(false);
useEffect(() => {
if (initialCreate && !initialCreateRan.current) {
initialCreateRan.current = true;
void createWorkspace();
}
}, []);

// Add repos picked from the native folder dialog. Each pick is validated +
// canonicalized through `repo_open` (membership keys on canonical paths),
// and recorded in recents so it shows up with a proper name from now on.
Expand Down Expand Up @@ -144,9 +171,14 @@ export function WorkspaceManagerDialog({ onClose }: { onClose: () => void }) {
onSelect={() => setSelectedId(w.id)}
/>
))}
{named.length === 0 && (
<div className="ws-mgr-hint">Create workspaces from the switcher menu.</div>
)}
<button
type="button"
className="ws-mgr-ws new"
onClick={() => void createWorkspace()}
>
<Icon name="plus" size={13} />
<span className="label">New workspace</span>
</button>
</div>

{/* Right: repos of the selected workspace */}
Expand All @@ -162,6 +194,7 @@ export function WorkspaceManagerDialog({ onClose }: { onClose: () => void }) {
<NameField
key={selected.id}
initial={selected.name}
autoFocus={selected.id === justCreatedId}
onCommit={(v) => void rename(selected.id, v)}
/>
)}
Expand Down Expand Up @@ -244,8 +277,10 @@ export function WorkspaceManagerDialog({ onClose }: { onClose: () => void }) {
}

/** Rename field that commits on blur or Enter (not per keystroke, so a rename
* isn't a burst of SQLite writes). Reset by keying on the workspace id. */
function NameField({ initial, onCommit }: { initial: string; onCommit: (name: string) => void }) {
* isn't a burst of SQLite writes). Reset by keying on the workspace id.
* `autoFocus` (just-created workspaces) also selects the placeholder text so
* typing replaces it. */
function NameField({ initial, onCommit, autoFocus }: { initial: string; onCommit: (name: string) => void; autoFocus?: boolean }) {
const [value, setValue] = useState(initial);
const commit = () => {
const trimmed = value.trim();
Expand All @@ -257,6 +292,8 @@ function NameField({ initial, onCommit }: { initial: string; onCommit: (name: st
className="clone-input ws-mgr-name"
value={value}
aria-label="Workspace name"
autoFocus={autoFocus}
onFocus={(e) => { if (autoFocus) e.currentTarget.select(); }}
onChange={(e) => setValue(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
Expand Down
Loading