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
2 changes: 2 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,8 @@ including recovery from an unknown prompt-admission outcome.
- [x] Match the OpenCode 2.2.5 desktop Inbox sidebar: Needs attention, Recent,
Archived, and Working in one scrolling list, desktop status labels and
colours, and a branch line under the title.
- [x] Dock Working below the session list and above the bottom menu, so a long
list never buries running sessions.
- [x] Archive and unarchive through the server so desktop and mobile share
archive state, and follow `session.archived`, `session.unarchived`, and
`session.viewed` events from other clients.
Expand Down
8 changes: 4 additions & 4 deletions apps/mobile/src/screens/workspace-screen-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,16 +261,16 @@ function collapsibleSection(
];
}

// Sessions that need attention lead, then finished runs, both without a header. Working and
// Archived follow under headers that collapse them, so a long archive never hides Working.
// Sessions that need attention lead, then finished runs, both without a header. Archived
// follows under a header that collapses it. Working is not in the list: it docks below it, so
// a long Recent list never buries running sessions.
export function workspaceInboxItems(
inbox: FollowedInboxSections,
expanded: Record<CollapsibleInboxSection, boolean>,
expanded: { archived: boolean },
): WorkspaceInboxItem[] {
return [
...inbox.needsYou.map(sessionItem),
...inbox.recent.map(sessionItem),
...collapsibleSection("working", inbox.working, expanded.working),
...collapsibleSection("archived", inbox.archived, expanded.archived),
];
}
Expand Down
12 changes: 7 additions & 5 deletions apps/mobile/src/screens/workspace-screen-session-row.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import * as Haptics from "expo-haptics";
import type { ReactNode } from "react";
import { StyleSheet } from "react-native";
import type { FollowedInboxRow } from "../state/followed-project-inbox";
import { fadedWorkingText, palette, pressedOpacity } from "../theme";
import { palette, pressedOpacity } from "../theme";
import {
runSessionRowAction,
SessionRow,
Expand Down Expand Up @@ -207,7 +207,7 @@ test("the third line shows the branch, else the worktree, else nothing", () => {
expect(screen.getByText("Project")).toBeOnTheScreen();
});

test("unread titles are semibold, working rows and archived rows recede", () => {
test("unread titles are semibold, working rows stay at full strength, archived rows recede", () => {
const view = renderRow(row({ status: "done" }));
const title = () => StyleSheet.flatten(screen.getByText("Build the app").props.style);
const copy = () => StyleSheet.flatten(screen.getByTestId("session-row-copy").props.style);
Expand All @@ -217,11 +217,13 @@ test("unread titles are semibold, working rows and archived rows recede", () =>
view.rerender(rowElement(row(), view.handlers));
expect(title()).toMatchObject({ color: palette.dim });

// Faded rows start from brighter text so the composite still clears 4.5:1.
// A running row is live, so it never fades like an archived one.
view.rerender(rowElement(row({ status: "working" }), view.handlers));
expect(title()).toMatchObject({ color: fadedWorkingText });
expect(copy()).toMatchObject({ opacity: 0.7 });
expect(title()).toMatchObject({ color: palette.ink });
expect(title()).not.toHaveProperty("fontWeight", "600");
expect(copy()).not.toHaveProperty("opacity");

// Archived rows start from brighter text so the faded composite still clears 4.5:1.
view.rerender(rowElement(row({ archived: true }), view.handlers));
expect(title()).toMatchObject({ color: palette.ink });
expect(copy()).toMatchObject({ opacity: 0.5 });
Expand Down
31 changes: 14 additions & 17 deletions apps/mobile/src/screens/workspace-screen-session-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import { ProjectAvatar } from "../components/project-avatar";
import { WorkingIndicator } from "../components/working-indicator";
import { type FollowedInboxRow, workingSince } from "../state/followed-project-inbox";
import {
fadedWorkingText,
minTouchTarget,
palette,
pressedOpacity,
Expand Down Expand Up @@ -128,20 +127,14 @@ function SessionRowSummary({
// Rows recede once read so unread results stand out, as on OpenCode desktop.
const unread = row.status !== "ready" && row.status !== "working";
const detail = branch ?? row.worktreeLabel;
// The spec fades working and archived rows. Their quiet text starts brighter so the faded
// result still clears 4.5:1 on the page background.
const faded = row.archived
? styles.fadedArchivedText
: row.status === "working"
? styles.fadedWorkingText
: undefined;
// Only archived rows fade. Their quiet text starts brighter so the faded result still clears
// 4.5:1 on the page background. Working rows stay at full strength so they never read as
// archived.
const faded = row.archived ? styles.fadedArchivedText : undefined;
const working = row.status === "working" && !row.archived;
return (
<View
style={[
styles.sessionCopy,
row.status === "working" && styles.sessionWorking,
row.archived && styles.sessionArchived,
]}
style={[styles.sessionCopy, row.archived && styles.sessionArchived]}
testID="session-row-copy"
>
{/* The project and status line, then the title, then the branch. */}
Expand All @@ -168,7 +161,11 @@ function SessionRowSummary({
numberOfLines={largeText ? 3 : 1}
style={[
styles.sessionTitle,
unread ? styles.sessionTitleUnread : [styles.sessionTitleRead, faded],
unread
? styles.sessionTitleUnread
: working
? styles.sessionTitleWorking
: [styles.sessionTitleRead, faded],
]}
>
{title}
Expand Down Expand Up @@ -259,9 +256,8 @@ export function sessionAccessibilityLabel(row: FollowedInboxRow, branch: string
const styles = StyleSheet.create({
pressed: { opacity: pressedOpacity },
sessionArchived: { opacity: 0.5 },
// Composite on the page: ink at 50% is about 4.8:1; the faded working text at 70% about 5.4:1.
// Composite on the page: ink at 50% is about 4.8:1.
fadedArchivedText: { color: palette.ink },
fadedWorkingText: { color: fadedWorkingText },
sessionCopy: { flex: 1, gap: 2, minWidth: 0 },
sessionDetail: { alignItems: "center", flexDirection: "row", gap: space.xs / 2 },
sessionDetailText: { ...typography.footnote, color: palette.dim, flexShrink: 1 },
Expand All @@ -280,14 +276,15 @@ const styles = StyleSheet.create({
sessionTitleRead: { color: palette.dim },
// Headline is the body size at semibold.
sessionTitleUnread: { ...typography.headline, color: palette.ink },
// A running session is live, not read: full-strength ink at the regular weight.
sessionTitleWorking: { color: palette.ink },
sessionTrailing: {
alignItems: "center",
flexDirection: "row",
flexShrink: 0,
justifyContent: "flex-end",
marginLeft: "auto",
},
sessionWorking: { opacity: 0.7 },
workingStatus: { alignItems: "center", flexDirection: "row", gap: space.xs },
swipeArchive: {
alignItems: "center",
Expand Down
40 changes: 27 additions & 13 deletions apps/mobile/src/screens/workspace-screen.integration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1180,7 +1180,7 @@ test("the Archived section collapses and expands, and stores it", async () => {
}
});

test("lists Needs attention and Recent without headers, then Working above Archived", async () => {
test("lists Needs attention and Recent without headers, then Archived, with Working docked below", async () => {
mockWorkspaceNeedsAttention = true;
mockWorkingCount = 3;
mockArchivedCount = 2;
Expand All @@ -1191,31 +1191,43 @@ test("lists Needs attention and Recent without headers, then Working above Archi
expect(list.props.data.map((item: { key: string }) => item.key)).toEqual([
"ses_0",
...Array.from({ length: 115 }, (_, index) => `ses_${index + 3}`),
"header:working",
"ses_1",
"ses_2",
"header:archived",
"ses_118",
"ses_119",
]);
expect(screen.queryByRole("header", { name: /^Needs attention/ })).toBeNull();
expect(screen.queryByRole("header", { name: "Recent" })).toBeNull();
// Working is part of the list, so nothing reserves space for it below.
expect(list.props.ListFooterComponentStyle).toBeUndefined();
// Working stays pinned outside the scrolling list, so a long Recent list never buries it.
const dock = within(screen.getByTestId("working-dock"));
expect(dock.getByRole("button", { name: "Working, 2 sessions" })).toBeExpanded();
expect(dock.getByRole("button", { name: /^Open session Session 1\./ })).toBeOnTheScreen();
expect(dock.getByRole("button", { name: /^Open session Session 2\./ })).toBeOnTheScreen();
} finally {
view.cleanup();
}
});

test("every working session sits under Working, and the idle state points to it", async () => {
test("search lists working sessions inline and hides the Working dock", async () => {
mockWorkingCount = 2;
const view = renderWorkspace();
try {
await screen.findByText("Session 0");
expect(screen.getByTestId("working-dock")).toBeOnTheScreen();
fireEvent.press(screen.getByRole("button", { name: "Search sessions" }));
fireEvent.changeText(screen.getByLabelText("Search sessions"), "Session");
await waitFor(() => expect(screen.queryByTestId("working-dock")).toBeNull());
} finally {
view.cleanup();
}
});

test("every working session sits in the Working dock, and the idle state points to it", async () => {
mockWorkspaceActive = true;
const view = renderWorkspace();
try {
await screen.findByText("Session 0");
const data = screen.UNSAFE_getByType(FlatList).props.data as { key: string }[];
expect(data[0]).toMatchObject({ count: 120, key: "header:working", section: "working" });
expect(data).toHaveLength(121);
// The idle state sits above Working when nothing else is in the list.
expect(screen.UNSAFE_getByType(FlatList).props.data).toHaveLength(0);
// The idle state fills the list when every session is running.
expect(screen.getByRole("header", { name: "Nothing needs you right now" })).toBeOnTheScreen();
expect(
screen.getByText(
Expand Down Expand Up @@ -1671,7 +1683,8 @@ test("rows show the project and status above the title, as on OpenCode desktop",
);
expect(workingStatus).toHaveTextContent("Working");
expect(workingStatus).toHaveProp("accessibilityLabel", "Working");
expect(working.getByTestId("session-row-copy")).toHaveStyle({ opacity: 0.7 });
expect(working.getByTestId("session-row-copy")).not.toHaveStyle({ opacity: 0.7 });
expect(working.getByText("Session 1")).toHaveStyle({ color: palette.ink });

// Read rows show their age and recede.
expect(within(finished.getByTestId("session-row-trailing")).getByText("now")).toBeOnTheScreen();
Expand Down Expand Up @@ -1794,11 +1807,12 @@ test("the Working section collapses and expands, stores it, and announces it on
const collapsed = screen.getByRole("button", { name: "Working, 2 sessions" });
expect(collapsed).toBeCollapsed();
expect(collapsed).toHaveProp("accessibilityHint", "Shows working sessions");
// The dock opens upward, so its collapsed chevron points up.
expect(
within(collapsed)
.UNSAFE_getAllByType(Feather)
.map((icon) => icon.props.name),
).toEqual(["chevron-right"]);
).toEqual(["chevron-up"]);

fireEvent.press(collapsed);
expect(mockSetSectionExpanded).toHaveBeenLastCalledWith("working", true);
Expand Down
29 changes: 10 additions & 19 deletions apps/mobile/src/screens/workspace-screen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,9 @@ test("keeps a large paginated list stable and accepts an empty response", () =>
expect(flattenSessionPages([{ cursor: {}, data: [] }])).toEqual([]);
});

const allExpanded = { archived: true, working: true };
const allExpanded = { archived: true };

test("lists Needs attention and Recent without headers, then Working and Archived", () => {
test("lists Needs attention and Recent without headers, then Archived, and leaves Working to the dock", () => {
const inbox = sections({
archived: [row("ses_archived", 3)],
needsYou: [row("ses_blocked", 1)],
Expand All @@ -96,35 +96,26 @@ test("lists Needs attention and Recent without headers, then Working and Archive
});

const items = workspaceInboxItems(inbox, allExpanded);
expect(keys(items)).toEqual([
"ses_blocked",
"ses_a",
"ses_b",
"header:working",
"ses_working",
"ses_working_2",
"header:archived",
"ses_archived",
]);
expect(keys(items)).toEqual(["ses_blocked", "ses_a", "ses_b", "header:archived", "ses_archived"]);
expect(items[3]).toEqual({
count: 2,
count: 1,
expanded: true,
key: "header:working",
section: "working",
key: "header:archived",
section: "archived",
type: "header",
});
});

test("collapsed sections keep their header and count but hide their rows", () => {
test("a collapsed section keeps its header and count but hides its rows", () => {
const inbox = sections({
archived: [row("ses_archived", 3)],
recent: [row("ses_a", 50)],
working: [row("ses_working", 2)],
});

const items = workspaceInboxItems(inbox, { archived: false, working: false });
expect(keys(items)).toEqual(["ses_a", "header:working", "header:archived"]);
expect(items[2]).toMatchObject({ count: 1, expanded: false, section: "archived" });
const items = workspaceInboxItems(inbox, { archived: false });
expect(keys(items)).toEqual(["ses_a", "header:archived"]);
expect(items[1]).toMatchObject({ count: 1, expanded: false, section: "archived" });
});

test("empty sections have no header", () => {
Expand Down
Loading
Loading