Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ceeafba
fix(desktop): minimize inline comments to one chip per line, as stage…
dastratakos Oct 19, 2026
92655cb
Merge branch 'main' into luke/files-changed-minimize-threads
dastratakos Oct 19, 2026
b5b312d
fix(desktop): keep a minimized line's fold mounted, and give threads …
dastratakos Oct 19, 2026
6eafcab
style(desktop): Files changed as cards that share one edge, with a qu…
dastratakos Oct 19, 2026
e454bc1
Merge branch 'main' into luke/files-changed-minimize-threads
dastratakos Oct 19, 2026
c20be68
Merge branch 'luke/files-changed-minimize-threads' into luke/files-ch…
dastratakos Oct 19, 2026
c1e257c
Merge main into luke/files-changed-minimize-threads
dastratakos Oct 19, 2026
c415bbb
Merge luke/files-changed-minimize-threads into luke/files-changed-design
dastratakos Oct 19, 2026
88a5818
Merge main into luke/files-changed-design
dastratakos Oct 19, 2026
39f3d70
feat(desktop): mark files viewed and count their threads and lines on…
dastratakos Oct 19, 2026
67f02a8
Merge remote-tracking branch 'origin/luke/files-changed-design' into …
dastratakos Oct 19, 2026
b479eef
fix(desktop): keep a tree row's toggle room only while the toggle is …
dastratakos Oct 19, 2026
cf1e3f3
fix(desktop): let a tree row's name win over its marks, the toggle st…
dastratakos Oct 19, 2026
25622f8
fix(desktop): let V press the focused tree row's toggle, and settle t…
dastratakos Oct 19, 2026
41a8c08
fix(desktop): leave a V typed through an input method to the view
dastratakos Oct 19, 2026
61a1527
feat(desktop): show or hide Files changed's tree with ⇧F, drag it wid…
dastratakos Oct 19, 2026
19d70d3
style(desktop): float the peeked tree on the window's floating surface
dastratakos Oct 19, 2026
24d1aee
fix(desktop): fit every strip mark however many files, and draw no tr…
dastratakos Oct 19, 2026
ce79705
Merge remote-tracking branch 'origin/main' into luke/files-changed-tr…
dastratakos Oct 19, 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
1 change: 1 addition & 0 deletions packages/panel/src/desktop/desktop-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,7 @@ test("Keyboard shortcuts lists Files changed's own keys under a group of their o
["Clear file focus", "Esc"],
["Toggle viewed and go to next file", "V"],
["Minimize or show inline comments", "I"],
["Show or hide file tree", "⇧F"],
],
);
assert.deepEqual(foundUnder(page, "Collapse or expand all files"), ["Keyboard shortcuts"]);
Expand Down
44 changes: 42 additions & 2 deletions packages/panel/src/desktop/folded-groups.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Option, Schema } from "effect";
import { useState } from "react";
import { useCallback, useState } from "react";
import {
EVERYONES,
INBOX_GROUPING,
Expand Down Expand Up @@ -36,7 +36,8 @@ import {
* Everything where it was not, which is what that list then held; a fixture run draws no inbox, so it has nothing staged to
* keep apart. An automation's Runs panel keeps its width and whether it
* stands on the same terms, one for every automation, as the side panel
* keeps one for every plan.
* keeps one for every plan, and so does the tree beside every File and
* Changes tab, one width for them all.
*/

/** Where the folds are kept in the renderer's storage. */
Expand All @@ -59,9 +60,21 @@ const STORAGE_KEY = {
INBOX_MEDIA_PREVIEWS: "luke.inboxMediaPreviews",
INBOX_LIST_WIDTH: "luke.inboxListWidth",
RUNS_PANEL: "luke.automationRunsPanel",
FILE_TREE_WIDTH: "luke.fileTreeWidth",
} as const;

/** How wide an automation's Runs panel may stand, as the side panel is bounded. */
/**
* How wide the tree beside a File or Changes tab may be dragged, in CSS
* pixels, and where it starts, as stage-cli's file picker is: wide enough
* for a nested name, never so wide that it takes the diff's room.
*/
export const FILE_TREE_WIDTH = {
MIN: 220,
MAX: 480,
DEFAULT: 264,
} as const;

export const RUNS_PANEL_WIDTH = {
MIN: 280,
MAX: 560,
Expand Down Expand Up @@ -406,6 +419,33 @@ export function useInboxListWidth(): InboxListWidth {
};
}

/** How wide the tree beside File and Changes tabs stands, and the drag that asks for another width. */
export interface FileTreeWidth {
/** Always within {@link FILE_TREE_WIDTH}. */
width: number;
/** Asks for a width; it is clamped to the bounds before it is kept. */
onResize: (width: number) => void;
}

/** A tree width held within its bounds, in whole pixels. */
function clampTreeWidth(width: number): number {
return Math.min(FILE_TREE_WIDTH.MAX, Math.max(FILE_TREE_WIDTH.MIN, Math.round(width)));
}

/** The tree's width, the default until first dragged, and kept as it is. */
export function useFileTreeWidth(): FileTreeWidth {
const [width, setWidth] = useState(() =>
clampTreeWidth(readKept(STORAGE_KEY.FILE_TREE_WIDTH, storedWidth, FILE_TREE_WIDTH.DEFAULT)),
);
// Note that the drag's door is the same door across draws, so the tree the panel hands every tab is the same tree while nothing changed.
const onResize = useCallback((asked: number) => {
const next = clampTreeWidth(asked);
setWidth(next);
writeKept(STORAGE_KEY.FILE_TREE_WIDTH, storedWidth, next);
}, []);
return { width, onResize };
}

/** A Runs panel width held within its bounds, in whole pixels. */
function clampRunsWidth(width: number): number {
return Math.min(RUNS_PANEL_WIDTH.MAX, Math.max(RUNS_PANEL_WIDTH.MIN, Math.round(width)));
Expand Down
17 changes: 13 additions & 4 deletions packages/panel/src/desktop/side-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ import { CodePane, centreWithin } from "../tasks/code-pane.js";
import { agentGlyphState, agentStateWords, agentTabLabel } from "../tasks/coding-agent-model.js";
import { FileCard, fileLook, newestFileNote } from "../tasks/file-ref.js";
import { snippetOf } from "../tasks/file-tab-model.js";
import { FileView } from "../tasks/file-view.js";
import { FileView, type TreeShown } from "../tasks/file-view.js";
import { MacTerminalTab } from "../tasks/mac-terminal-tab.js";
import { PageCard } from "../tasks/page-card.js";
import { PagePane } from "../tasks/page-pane.js";
Expand Down Expand Up @@ -138,6 +138,7 @@ import {
} from "../tasks/work-model.js";
import { Tooltip } from "../tooltip.js";
import { useCapability, useExperiment } from "../use-app-state.js";
import { useFileTreeWidth } from "./folded-groups.js";
import { paneTiming } from "./pane-motion.js";
import {
type AddEntry,
Expand Down Expand Up @@ -1379,7 +1380,7 @@ function TabContent({
/** Opens a file of the task's repository in a tab of its own, the reference's lines lit. */
onOpenFile: (ref: CodeRef, commit?: string) => void;
/** Whether a File or Changes tab's tree is open, which holds for every one of them alike, and its toggle. */
fileTree: { readonly open: boolean; readonly onToggle: () => void };
fileTree: TreeShown;
/** Shows a file browsed to from a File tab's tree or breadcrumbs (`SidePanelControl.onBrowseFile`). */
onBrowseFile: (from: string, path: string, newTab: boolean) => void;
/** Walks a File tab's history a step (`SidePanelControl.onFileHistory`). */
Expand Down Expand Up @@ -1491,6 +1492,7 @@ function TabContent({
openGitHub={(url) => appPlatform().openGitHub(url)}
treeOpen={fileTree.open}
onToggleTree={fileTree.onToggle}
treeWidth={fileTree.width}
onBrowse={(path, newTab) => onBrowseFile(file.path, path, newTab)}
history={{
canGoBack: (file.back?.length ?? 0) > 0,
Expand Down Expand Up @@ -1871,9 +1873,16 @@ export function SidePanel({
// alike and for the window's life, as an editor's explorer stands across
// its tabs.
const [fileTreeOpen, setFileTreeOpen] = useState(true);
// How wide the tree stands, kept across launches as the Inbox's list's width is.
const fileTreeWidth = useFileTreeWidth();
const { width: treeWidth, onResize: onTreeResize } = fileTreeWidth;
const fileTree = useMemo(
() => ({ open: fileTreeOpen, onToggle: () => setFileTreeOpen((was) => !was) }),
[fileTreeOpen],
() => ({
open: fileTreeOpen,
onToggle: () => setFileTreeOpen((was) => !was),
width: { width: treeWidth, onResize: onTreeResize },
}),
[fileTreeOpen, treeWidth, onTreeResize],
);
// Whether a Terminal tab's shells list is open, for every one of them alike
// and for the window's life, as the File tabs' tree is.
Expand Down
118 changes: 115 additions & 3 deletions packages/panel/src/styles/tasks.css
Original file line number Diff line number Diff line change
Expand Up @@ -1170,10 +1170,14 @@
display: flex;
}

/* The tree beside a File tab, its search level with the tab's heading. */
/* The tree beside a File tab, its search level with the tab's heading, as
wide as it was last dragged (`useTreeEdge`), never so wide that it takes
the tab's room, and dragged by the edge on its left, which straddles its
border as the side panel's does. */
.file-tree-pane {
position: relative;
display: flex;
width: min(264px, 40%);
width: min(var(--file-tree-width, 264px), max(40%, 100% - 320px));
Comment thread
luke-agent[bot] marked this conversation as resolved.
min-width: 0;
flex: 0 0 auto;
flex-direction: column;
Expand Down Expand Up @@ -1204,6 +1208,100 @@
display: none;
}

.file-tree-pane[data-leaving] > .side-panel-resize {
display: none;
}

/* While Files changed's tree is hidden, a strip stands in its place, one
mark for each changed file, coloured as its letter is in the tree, dimmed
once viewed, the file the bar names standing out (changes-strip.tsx);
the pointer resting on it peeks the tree out over the list, as a pane
floats where the panel is too narrow for it (below). */
.file-tree-strip {
display: none;
}

.file-view[data-pane-beside="false"] > .file-view-body > .file-tree-strip {
display: flex;
width: var(--luke-space-12);
flex: none;
flex-direction: column;
align-items: center;
padding-block: var(--luke-space-12);
border-left: 1px solid var(--luke-color-border);
}

/* Each mark is a share of the strip's height, at most a line's mark, its
colour filling the top of its share so the gap between marks shrinks
with them: a pull request of thousands of files draws every mark, however
thin, and the file the bar names never thinner than a line. */
.file-tree-strip-mark {
--strip-mark-colour: var(--luke-color-attention);
width: var(--luke-space-6);
min-height: 0;
max-height: var(--luke-space-6);
flex: 1 1 0;
background: linear-gradient(var(--strip-mark-colour) 0 66%, transparent 66%);
}

.file-tree-strip-mark[data-status="added"] {
--strip-mark-colour: var(--luke-color-complete);
}

.file-tree-strip-mark[data-status="deleted"] {
--strip-mark-colour: var(--luke-color-danger-text);
}

.file-tree-strip-mark[data-status="renamed"] {
--strip-mark-colour: var(--luke-color-working);
}

.file-tree-strip-mark[data-viewed] {
opacity: 0.35;
}

.file-tree-strip-mark[data-current] {
width: var(--luke-space-12);
min-height: var(--luke-space-2);
opacity: 1;
box-shadow: 0 0 0 1px var(--luke-color-text);
}

/* The tree peeked from the strip, or opened over the list, floats over the
list's right edge, as it does in a narrow panel, on the window's floating
surface: a menu's ground, edge, and shadow, so it reads as standing above
the list rather than as the list cut short. */
.file-view:is([data-pane-over="peek"], [data-pane-over="open"]) .file-tree-pane {
border-left-color: var(--luke-color-border-strong);
background: var(--luke-color-overlay);
}

.file-view:is([data-pane-over="peek"], [data-pane-over="open"]) .file-tree-host {
--trees-bg-override: var(--luke-color-overlay);
}

.file-view[data-pane-beside="false"]:is([data-pane-over="peek"], [data-pane-over="open"])
.file-tree-pane {
position: absolute;
z-index: 1;
inset-block: 0;
right: 0;
display: flex;
width: min(var(--file-tree-width, 264px), calc(100% - var(--luke-space-48)));
box-shadow: var(--luke-menu-shadow);
animation: pane-over-in var(--duration-pane) var(--motion-pane) backwards;
}

.file-view[data-pane-beside="false"][data-pane-over="peek"] .file-tree-pane {
animation-delay: var(--duration-quick);
}

.file-view[data-pane-beside="false"]:is([data-pane-over="peek"], [data-pane-over="open"])
.file-tree-pane
> .side-panel-resize {
display: none;
}

.file-tree-toggle[data-place="over"] {
display: none;
}
Expand All @@ -1225,11 +1323,16 @@
z-index: 1;
inset-block: 0;
right: 0;
width: min(264px, calc(100% - var(--luke-space-48)));
width: min(var(--file-tree-width, 264px), calc(100% - var(--luke-space-48)));
background: var(--luke-color-well);
box-shadow: var(--luke-menu-shadow);
}

.file-view .file-tree-pane > .side-panel-resize,
.file-view[data-pane-beside="false"] > .file-view-body > .file-tree-strip {
display: none;
}

.file-view:is([data-pane-over="peek"], [data-pane-over="open"]) .file-tree-pane {
display: flex;
animation: pane-over-in var(--duration-pane) var(--motion-pane) backwards;
Expand Down Expand Up @@ -1260,6 +1363,15 @@
}
}

/* A panel with no room for the tree's least width beside the tab's own
keeps the tree at its share of the panel, with no edge to drag, since
any width a drag asked for would be drawn no wider (`useTreeEdge`). */
@container side-panel (max-width: 539px) {
.file-view .file-tree-pane > .side-panel-resize {
display: none;
}
Comment thread
luke-agent[bot] marked this conversation as resolved.
}

/* A Terminal tab's shells float the same way below a 480px panel, so the
screen beside them never falls under forty columns: at the panel's
default width the screen has the whole tab, the shells a hover away. */
Expand Down
78 changes: 77 additions & 1 deletion packages/panel/src/tasks/agent-changes-tab.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { landedBundles, onSideBundles } from "../testing/side-bundles.js";
import { codingAgentsControl } from "../testing/tasks-control.js";
import type { ChangesThreads } from "./agent-changes-tab.js";
import { blobShaOf } from "./changes-model.js";
import type { TreeShown } from "./file-view.js";

/**
* agent-changes-tab.test.ts -- a Files changed view hands the diff bundle
Expand Down Expand Up @@ -251,6 +252,8 @@ let registry = AtomRegistry.make();
interface DrawOptions {
readonly threads?: ChangesThreads;
readonly treeOpen?: boolean;
/** The tree as the panel holds it, in `treeOpen`'s place: its toggle and its width. */
readonly tree?: TreeShown;
readonly beside?: ReactElement;
readonly repository?: string;
readonly control?: typeof control;
Expand All @@ -268,7 +271,7 @@ async function redraw(options: DrawOptions = {}): Promise<DiffListProps> {
control: options.control ?? control,
shown: options.shown ?? true,
repository: options.repository ?? "acme/relay",
tree: { open: options.treeOpen ?? false, onToggle: () => undefined },
tree: options.tree ?? { open: options.treeOpen ?? false, onToggle: () => undefined },
threads: options.threads,
});
act(() =>
Expand Down Expand Up @@ -525,6 +528,78 @@ test("a file the filter leaves out, chosen in a tree of every changed file, clea
assert.match(document.querySelector(".file-view-bar")?.textContent ?? "", /README\.md/u);
});

/** A tree as the panel holds it, open as given, counting its toggles and keeping the widths asked for. */
function heldTree(open: boolean, width = 300) {
const widths: number[] = [];
const held = { toggles: 0, widths };
const tree: TreeShown = {
open,
onToggle: () => {
held.toggles += 1;
},
width: { width, onResize: (asked) => held.widths.push(asked) },
};
return { tree, held };
}

test("⇧F inside the view shows or hides the tree, as its toggle does, and is no key while typing", async () => {
files = [changed("a.ts"), changed("b.ts")];
const { tree, held } = heldTree(true);
await drawTab({ tree });

assert.equal(press(listBox(), { key: "F", shiftKey: true }), true);
const field = document.querySelector<HTMLInputElement>(".file-tree-search input");
assert.equal(
press(field ?? assert.fail("the search field is drawn"), { key: "F", shiftKey: true }),
false,
);
assert.equal(held.toggles, 1);
});

test("the tree stands as wide as the panel keeps it, and its edge asks for another width", async () => {
files = [changed("a.ts")];
const { tree, held } = heldTree(true, 360);
await drawTab({ tree });

const pane = document.querySelector<HTMLElement>(".file-tree-pane") ?? assert.fail("a tree");
assert.equal(pane.style.getPropertyValue("--file-tree-width"), "360px");
const edge = pane.querySelector<HTMLElement>('[role="separator"]') ?? assert.fail("an edge");
assert.equal(edge.getAttribute("aria-label"), "Resize files");
press(edge, { key: "Enter" });
assert.deepEqual(held.widths, [264], "Enter puts back the tree's own width");
});

test("while the tree is hidden a strip stands in its place, a mark for each file, and resting on it peeks the tree", async () => {
viewedAnswer = MARKED;
files = [
changed("c.ts"),
changed("a.ts", { status: CHANGED_FILE_STATUS.ADDED }),
changed("b.ts", { status: CHANGED_FILE_STATUS.DELETED }),
];
const { tree } = heldTree(false);
await drawTab({ tree });

const strip = document.querySelector<HTMLElement>(".file-tree-strip") ?? assert.fail("a strip");
assert.deepEqual(
[...strip.children].map((mark) => [
mark.getAttribute("data-status"),
mark.hasAttribute("data-viewed"),
mark.hasAttribute("data-current"),
]),
[
["added", true, true],
["deleted", false, false],
["modified", false, false],
],
);
assert.equal(document.querySelector(".file-tree-pane"), null);

act(() => strip.dispatchEvent(new PointerEvent("pointerover", { bubbles: true })));
await flush();
assert.equal(document.querySelector(".file-view")?.getAttribute("data-pane-over"), "peek");
assert.notEqual(document.querySelector(".file-tree-pane"), null, "the tree peeks out");
});

test("J and K step through the files inside the view, ; and ⇧; fold, and Escape lets go of the file", async () => {
files = [changed("a.ts"), changed("b.ts"), changed("c.ts")];
await drawTab();
Expand Down Expand Up @@ -707,6 +782,7 @@ test("? inside the view opens a sheet of its keys by group, which Escape or ? ag
["Minimize or show inline comments", "I"],
],
],
["Panels", [["Show or hide file tree", "⇧F"]]],
]);

press(sheet, { key: "Escape" });
Expand Down
Loading
Loading