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
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import { COLORS, MONO_FONT } from "../lanes/laneDesignTokens";
type ForeignLaneContextMenuProps = {
lane: LaneSummary;
machineName: string;
online: boolean;
x: number;
y: number;
onClose: () => void;
Expand All @@ -29,7 +28,6 @@ function branchNameFromRef(ref: string | null | undefined): string {
export function ForeignLaneContextMenu({
lane,
machineName,
online,
x,
y,
onClose,
Expand Down Expand Up @@ -100,35 +98,17 @@ export function ForeignLaneContextMenu({
{lane.name}
</div>
<div style={{ marginTop: 2, fontSize: 9.5, color: COLORS.textDim }}>
{machineName}{online ? "" : " · offline"}
{machineName}
</div>
</div>
{separator}
<HoverButton
style={menuItemStyle}
disabled={!online}
onClick={() => {
if (online) onStartChat();
}}
>
<HoverButton style={menuItemStyle} onClick={onStartChat}>
Start chat in lane
</HoverButton>
<HoverButton
style={menuItemStyle}
disabled={!online}
onClick={() => {
if (online) onManage();
}}
>
<HoverButton style={menuItemStyle} onClick={onManage}>
Manage lane
</HoverButton>
<HoverButton
style={menuItemStyle}
disabled={!online}
onClick={() => {
if (online) onOpenInLanes();
}}
>
<HoverButton style={menuItemStyle} onClick={onOpenInLanes}>
Open in Lanes
</HoverButton>
{lane.worktreePath ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,7 @@ export const SessionCard = React.memo(function SessionCard({
parentSessionTitle?: string | null;
/** When present, blocks interaction while the owning lane is being removed. */
disabledReason?: string | null;
/** Busy rows spin; offline rows show a stable machine glyph. */
/** Busy rows spin; rows blocked on an unresolved binding show a machine glyph. */
disabledBusy?: boolean;
/** Runtime that owns this session when it differs from the active project. */
runtimePin?: OpenProjectBinding | null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ describe("SessionListPane", () => {
},
});

const view = renderPane({
renderPane({
runningFiltered: [session],
allSessionsUnfiltered: [session],
sessionsGroupedByLane: new Map([[session.laneId, [session]]]),
Expand Down Expand Up @@ -1197,27 +1197,28 @@ describe("SessionListPane", () => {
);
});

it("shows a hover label for one online foreign machine and the name when it drops", async () => {
it("shows a hover label for one online foreign machine", async () => {
seedForeignMachine();
const view = renderPane();
renderPane();
const marker = document.querySelector("[data-machine-marker-mode]")!;
expect(marker.getAttribute("data-machine-marker-mode")).toBe("glyph");
expect(screen.queryByText("Mac Studio (12)")).toBeNull();
fireEvent.mouseEnter(marker.parentElement!);
expect((await screen.findByRole("tooltip")).textContent).toContain("Mac Studio (12)");
view.unmount();
});

it("removes an offline machine's lane, chats, and marker from the list", () => {
seedForeignMachine({ online: false });
renderPane();
// Offline: lanes REMAIN, dimmed, and the machine is named outright.
expect(screen.getByText("Elsewhere Lane")).toBeTruthy();
expect(document.querySelector("[data-machine-marker-mode]")?.getAttribute("data-machine-marker-mode"))
.toBe("name");
expect(screen.getByText("Mac Studio (12)")).toBeTruthy();
expect((screen.getByRole("button", { name: /Chat on the other machine/ }) as HTMLButtonElement).disabled)
.toBe(true);
expect(screen.getByText("Elsewhere Lane").closest(".ade-lane-group-header")?.parentElement?.className)
.toContain("opacity");

// Nothing about an unreachable machine survives in Work: not the lane
// group, not its chats, not a dimmed placeholder naming the machine.
expect(screen.queryByText("Elsewhere Lane")).toBeNull();
expect(screen.queryByText("Chat on the other machine")).toBeNull();
expect(document.querySelector('[data-session-id="session-elsewhere"]')).toBeNull();
expect(document.querySelector("[data-machine-marker-mode]")).toBeNull();
expect(screen.queryByText("Mac Studio (12)")).toBeNull();
expect(screen.queryByText(/is offline/i)).toBeNull();
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,6 @@ function StickyGroupHeader({
variant = "default",
busyLabel = null,
heading = false,
dimmed = false,
quietCounts = null,
pinned = false,
dragProps = null,
Expand Down Expand Up @@ -337,8 +336,6 @@ function StickyGroupHeader({
* on this machine, so local-only setups never render one.
*/
machineMarker?: React.ReactNode;
/** Dims the whole group — used for lanes on a machine that has gone offline. */
dimmed?: boolean;
/** Compact action shown next to the count for non-lane headers. */
headerAction?: React.ReactNode;
/** `lane` uses a larger header and pads the nested session list. */
Expand Down Expand Up @@ -396,7 +393,7 @@ function StickyGroupHeader({
transition={{ duration: 0.24, ease: [0.22, 1, 0.36, 1] }}
onLayoutAnimationStart={() => setSliding(true)}
onLayoutAnimationComplete={() => setSliding(false)}
className={cn("relative", !isLane && "mt-0.5 first:mt-0", dimmed && "opacity-55")}
className={cn("relative", !isLane && "mt-0.5 first:mt-0")}
>
{dropIndicatorEdge ? (
<div
Expand Down Expand Up @@ -624,30 +621,27 @@ function LanePrHeaderBadge({ pr, onOpen }: { pr: PrSummary; onOpen: () => void }
* Rendered ONLY for lanes that are not on the machine you're sitting at — the
* common single-machine case pays nothing. Default form is a bare monochrome
* glyph; the name is promoted into the row when a glyph alone would be
* ambiguous (machine offline, two or more foreign machines on screen, or the
* branch also exists elsewhere). The lane accent owns the color channel, so this
* stays monochrome: a tint here would read as a second lane color.
* ambiguous (two or more foreign machines on screen, or the branch also exists
* elsewhere). The lane accent owns the color channel, so this stays monochrome:
* a tint here would read as a second lane color.
*
* Every marked machine is reachable — offline machines leave the sidebar — so
* there is no dimmed variant here.
*/
function LaneMachineMarker({ marker }: { marker: CrossMachineLaneMarker }) {
const description = marker.online
? "This lane lives on another connected machine."
: "This lane is retained here while its machine is offline.";
return (
<SmartTooltip
forceEnabled
content={{
label: marker.machineName,
description,
description: "This lane lives on another connected machine.",
}}
>
<span
role="img"
tabIndex={0}
className={cn(
"inline-flex shrink-0 items-center gap-1 rounded-full border border-amber-400/20 bg-amber-400/[0.06] px-1.5 py-px text-[10px] font-medium leading-none",
marker.online ? "text-muted-fg/70" : "text-muted-fg/45",
)}
aria-label={`${marker.machineName}${marker.online ? "" : ", offline"}`}
className="inline-flex shrink-0 items-center gap-1 rounded-full border border-amber-400/20 bg-amber-400/[0.06] px-1.5 py-px text-[10px] font-medium leading-none text-muted-fg/70"
aria-label={marker.machineName}
data-machine-id={marker.machineId}
data-machine-marker-mode={marker.mode}
>
Expand Down Expand Up @@ -1270,12 +1264,13 @@ export const SessionListPane = React.memo(function SessionListPane({
const isFirst = !sessionItemAnchorEmitted;
if (isFirst) sessionItemAnchorEmitted = true;
const foreignRow = options?.foreignRow;
// Offline machines never reach this point — their rows are filtered out of
// the union — so the only foreign block left is a reachable machine whose
// call-routing binding hasn't resolved yet.
const disabledReason = foreignRow
? !foreignRow.online
? `${foreignRow.machineName} is offline`
: !foreignRow.binding
? `${foreignRow.machineName} is unavailable`
: null
? !foreignRow.binding
? `${foreignRow.machineName} is unavailable`
: null
: deleteProgressByLaneId[session.laneId]
? `${getLaneDeleteStatusLabel(deleteProgressByLaneId[session.laneId])} lane`
: null;
Expand Down Expand Up @@ -1789,9 +1784,6 @@ export const SessionListPane = React.memo(function SessionListPane({
settled: quiet.settled.length,
}
: null}
// Offline machines keep every lane they last reported, dimmed and
// read-only. A wifi blip must never reflow the sidebar.
dimmed={!row.online}
onToggleCollapsed={() => {
if (laneQuiet) toggleWorkSectionCollapsed(laneOpenMarker);
else toggleWorkLaneCollapsed(compositeLaneId);
Expand All @@ -1801,7 +1793,6 @@ export const SessionListPane = React.memo(function SessionListPane({
row.lane,
row.binding!,
row.machineName,
row.online,
event,
)
: undefined}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ describe("useWorkLaneContextMenu", () => {
});

act(() => {
result.current.triggerForeign(lane, binding, "Studio", true, {
result.current.triggerForeign(lane, binding, "Studio", {
preventDefault: vi.fn(),
clientX: 12,
clientY: 34,
Expand Down Expand Up @@ -213,7 +213,7 @@ describe("useWorkLaneContextMenu", () => {
});

act(() => {
result.current.triggerForeign(lane, binding, "Studio", true, {
result.current.triggerForeign(lane, binding, "Studio", {
preventDefault: vi.fn(),
clientX: 12,
clientY: 34,
Expand All @@ -234,43 +234,6 @@ describe("useWorkLaneContextMenu", () => {
expect(navigate).not.toHaveBeenCalled();
});

it("disables foreign lane management while its machine is offline", () => {
Object.defineProperty(window, "ade", {
configurable: true,
value: { app: { writeClipboardText: vi.fn().mockResolvedValue(undefined) } },
});
const lane = {
id: "lane-studio",
name: "Studio Lane",
laneType: "worktree",
branchRef: "refs/heads/studio-lane",
} as LaneSummary;
const binding = {
kind: "remote" as const,
key: "remote:studio:ade",
targetId: "studio",
projectId: "ade",
rootPath: "/Users/studio/ADE",
displayName: "ADE",
runtimeName: "Studio",
hostname: "studio.local",
};
const { result } = renderHook(() => useWorkLaneContextMenu(), {
wrapper: ({ children }) => <MemoryRouter>{children}</MemoryRouter>,
});

act(() => {
result.current.triggerForeign(lane, binding, "Studio", false, {
preventDefault: vi.fn(),
clientX: 12,
clientY: 34,
});
});
render(<>{result.current.menu}</>);

expect((screen.getByRole("menuitem", { name: "Manage lane" }) as HTMLButtonElement).disabled).toBe(true);
});

it("opens lane management in Work without navigating to the Lanes tab", () => {
const { result } = renderHook(() => useWorkLaneContextMenu(), {
wrapper: ({ children }) => <MemoryRouter>{children}</MemoryRouter>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,17 @@ import { ForeignLaneContextMenu } from "./ForeignLaneContextMenu";
import { WorkManageLaneDialogHost } from "./WorkManageLaneDialogHost";

type MenuState = { laneId: string; x: number; y: number };
/**
* Foreign menus are only ever opened from rows the Work union has already
* narrowed to reachable machines, so there is no offline state to carry here.
* A captured `online` flag would have been a lie anyway: it is a snapshot from
* right-click time, so it still reads "online" in the one case it looked like
* it covered — the machine dropping while the menu is open.
*/
type ForeignMenuState = {
lane: LaneSummary;
binding: OpenProjectBinding;
machineName: string;
online: boolean;
x: number;
y: number;
};
Expand All @@ -34,7 +40,6 @@ export type ForeignLaneContextTrigger = (
lane: LaneSummary,
binding: OpenProjectBinding,
machineName: string,
online: boolean,
e: { preventDefault: () => void; clientX: number; clientY: number },
) => void;

Expand Down Expand Up @@ -77,7 +82,6 @@ export function useWorkLaneContextMenu(options?: {
lane,
binding,
machineName,
online,
event,
) => {
event.preventDefault();
Expand All @@ -86,7 +90,6 @@ export function useWorkLaneContextMenu(options?: {
lane,
binding,
machineName,
online,
x: event.clientX,
y: event.clientY,
});
Expand Down Expand Up @@ -122,7 +125,7 @@ export function useWorkLaneContextMenu(options?: {
navigate,
});
const startForeignChat = useCallback(() => {
if (!foreignMenuState || !projectStateKey || !foreignMenuState.online) return;
if (!foreignMenuState || !projectStateKey) return;
const laneId = foreignMenuState.lane.id;
setWorkViewState(projectStateKey, (previous) => ({
...previous,
Expand All @@ -144,7 +147,7 @@ export function useWorkLaneContextMenu(options?: {
setWorkViewState,
]);
const openForeignLane = useCallback(() => {
if (!foreignMenuState?.online) return;
if (!foreignMenuState) return;
const { binding, lane } = foreignMenuState;
close();
const switching = binding.kind === "remote"
Expand All @@ -163,7 +166,7 @@ export function useWorkLaneContextMenu(options?: {
switchRemoteProject,
]);
const manageForeignLane = useCallback(() => {
if (!foreignMenuState?.online) return;
if (!foreignMenuState) return;
const { binding, lane } = foreignMenuState;
close();
setManagedLane({
Expand Down Expand Up @@ -218,7 +221,6 @@ export function useWorkLaneContextMenu(options?: {
<ForeignLaneContextMenu
lane={foreignMenuState.lane}
machineName={foreignMenuState.machineName}
online={foreignMenuState.online}
x={foreignMenuState.x}
y={foreignMenuState.y}
onClose={close}
Expand Down
11 changes: 7 additions & 4 deletions apps/desktop/src/renderer/state/appStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1011,9 +1011,11 @@ export type ProjectTransitionError = {
* one machine) and chats inherit their machine through `laneId`, so the union is
* keyed by machine and holds lanes — never a per-chat machine field.
*
* `online: false` is a *dimming* flag, never a removal signal: a machine that
* drops keeps every lane it last reported. A sidebar that reflowed away half its
* rows on a wifi blip would be worse than the single-machine list it replaces.
* `online: false` retains the slice in the store but hides it from Work: the
* union hook (`useCrossMachineLaneUnion`) drops offline machines' lanes and
* chats from the sidebar entirely. The retained data still backs the
* push-divergence guard, which needs a dropped machine's last-known branch
* state, so nothing here is deleted on disconnect.
*/
export type CrossMachineMachineLanes = {
/**
Expand Down Expand Up @@ -1775,7 +1777,8 @@ const createAppState: StateCreator<AppState> = (set, get) => {
nextRecord[machineId] = entry;
continue;
}
// Dim, never drop. The lanes and sessions carry over verbatim.
// Flag, never drop. The lanes and sessions carry over verbatim; the
// union hook decides what that flag hides.
nextRecord[machineId] = { ...entry, online: isOnline };
changed = true;
}
Expand Down
Loading