diff --git a/TODO.md b/TODO.md index 028e010..79495fd 100644 --- a/TODO.md +++ b/TODO.md @@ -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. diff --git a/apps/mobile/src/screens/workspace-screen-model.ts b/apps/mobile/src/screens/workspace-screen-model.ts index 08e66f4..3c31b40 100644 --- a/apps/mobile/src/screens/workspace-screen-model.ts +++ b/apps/mobile/src/screens/workspace-screen-model.ts @@ -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, + 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), ]; } diff --git a/apps/mobile/src/screens/workspace-screen-session-row.test.tsx b/apps/mobile/src/screens/workspace-screen-session-row.test.tsx index 1a5e2bd..1f522ba 100644 --- a/apps/mobile/src/screens/workspace-screen-session-row.test.tsx +++ b/apps/mobile/src/screens/workspace-screen-session-row.test.tsx @@ -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, @@ -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); @@ -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 }); diff --git a/apps/mobile/src/screens/workspace-screen-session-row.tsx b/apps/mobile/src/screens/workspace-screen-session-row.tsx index 10528a5..76c9649 100644 --- a/apps/mobile/src/screens/workspace-screen-session-row.tsx +++ b/apps/mobile/src/screens/workspace-screen-session-row.tsx @@ -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, @@ -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 ( {/* The project and status line, then the title, then the branch. */} @@ -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} @@ -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 }, @@ -280,6 +276,8 @@ 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", @@ -287,7 +285,6 @@ const styles = StyleSheet.create({ justifyContent: "flex-end", marginLeft: "auto", }, - sessionWorking: { opacity: 0.7 }, workingStatus: { alignItems: "center", flexDirection: "row", gap: space.xs }, swipeArchive: { alignItems: "center", diff --git a/apps/mobile/src/screens/workspace-screen.integration.test.tsx b/apps/mobile/src/screens/workspace-screen.integration.test.tsx index 0bbd9cb..3a4b822 100644 --- a/apps/mobile/src/screens/workspace-screen.integration.test.tsx +++ b/apps/mobile/src/screens/workspace-screen.integration.test.tsx @@ -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; @@ -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( @@ -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(); @@ -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); diff --git a/apps/mobile/src/screens/workspace-screen.test.ts b/apps/mobile/src/screens/workspace-screen.test.ts index aed084e..0b3cf0f 100644 --- a/apps/mobile/src/screens/workspace-screen.test.ts +++ b/apps/mobile/src/screens/workspace-screen.test.ts @@ -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)], @@ -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", () => { diff --git a/apps/mobile/src/screens/workspace-screen.tsx b/apps/mobile/src/screens/workspace-screen.tsx index 4d3e5bc..4717a70 100644 --- a/apps/mobile/src/screens/workspace-screen.tsx +++ b/apps/mobile/src/screens/workspace-screen.tsx @@ -42,6 +42,7 @@ import { Platform, Pressable, RefreshControl, + ScrollView, StyleSheet, Text, useWindowDimensions, @@ -172,6 +173,9 @@ const rowRipple = rippleFeedback.row; const borderlessRipple = rippleFeedback.borderless; // Keeps the last rows of every Sessions tab clear of the floating bottom menu. const bottomMenuInset = 96; +// Expanded Working rows scroll inside the dock past this share of the window, so the list +// above always keeps room. +const workingDockMaxHeightRatio = 0.4; export function WorkspaceScreen({ navigation }: WorkspaceProps) { const db = useSQLiteContext(); @@ -179,7 +183,7 @@ export function WorkspaceScreen({ navigation }: WorkspaceProps) { const workspaceSelection = useWorkspaceSelection(); const connections = useConnections(); const queryClient = useQueryClient(); - const { fontScale, width } = useWindowDimensions(); + const { fontScale, height, width } = useWindowDimensions(); const insets = useSafeAreaInsets(); const reducedMotion = useReducedMotion(); const largeText = usesLargeTextLayout(fontScale); @@ -246,10 +250,10 @@ export function WorkspaceScreen({ navigation }: WorkspaceProps) { const inbox = workspaceSelection.inbox; const inboxItems: WorkspaceInboxItem[] = deferredSessionSearch ? searchResultItems(inbox) - : workspaceInboxItems(inbox, { - archived: workspaceSelection.archivedExpanded, - working: workspaceSelection.workingExpanded, - }); + : workspaceInboxItems(inbox, { archived: workspaceSelection.archivedExpanded }); + // Running sessions dock below the list, so a long Recent list never buries them. Search + // lists them inline with every other match instead. + const workingDocked = !deferredSessionSearch && inbox.working.length > 0; // With only running sessions, the idle state still says nothing needs you, above Working. const onlyWorking = !deferredSessionSearch && @@ -400,13 +404,8 @@ export function WorkspaceScreen({ navigation }: WorkspaceProps) { onLoadMore: () => void workspaceSelection.fetchNextPage(), search: deferredSessionSearch, })} - ListHeaderComponent={ - <> - {header} - {onlyWorking ? emptyState : null} - - } - contentContainerStyle={styles.listContent} + ListHeaderComponent={header} + contentContainerStyle={[styles.listContent, workingDocked && styles.listContentDocked]} data={inboxItems} extraData={fontScale} keyboardShouldPersistTaps="handled" @@ -436,6 +435,22 @@ export function WorkspaceScreen({ navigation }: WorkspaceProps) { updateCellsBatchingPeriod={40} windowSize={7} /> + {workingDocked ? ( + + toggleInboxSection( + workspaceSelection, + { expanded: workspaceSelection.workingExpanded, section: "working" }, + reducedMotion, + ) + } + renderRow={renderRow} + rows={inbox.working} + /> + ) : null} {tablet ? null : ( void; + renderRow: (row: FollowedInboxRow) => ReactNode; + rows: readonly FollowedInboxRow[]; +}) { + return ( + + + {expanded ? ( + + {rows.map(renderRow)} + + ) : null} + + ); +} + // The separator above Working and Archived. It shows the section's count and collapses it. function InboxSectionHeader({ count, + docked = false, expanded, onToggle, section, }: { count: number; + // A docked section opens upward, so its collapsed chevron points up. + docked?: boolean; expanded: boolean; onToggle: () => void; section: CollapsibleInboxSection; }) { + const collapsedIcon = docked ? "chevron-up" : "chevron-right"; const label = workspaceSectionLabels[section]; const sessions = `${section} sessions`; return ( @@ -2203,7 +2261,7 @@ function InboxSectionHeader({ @@ -2450,6 +2508,8 @@ const styles = StyleSheet.create({ paddingBottom: bottomMenuInset, width: "100%", }, + // The Working dock already clears the bottom menu, so the list needs only a small gap. + listContentDocked: { paddingBottom: space.md }, listFooter: { alignItems: "center", padding: space.lg }, listSectionHeader: { alignItems: "center", @@ -2469,6 +2529,12 @@ const styles = StyleSheet.create({ staleRoute: { alignSelf: "center", maxWidth: 640, padding: space.lg, width: "100%" }, staleRouteAction: { marginTop: space.md }, tabPanel: { flex: 1 }, + workingDock: { + alignSelf: "center", + backgroundColor: palette.background, + maxWidth: 760, + width: "100%", + }, workspaceActions: { alignItems: "center", flexDirection: "row", gap: space.sm }, transcriptContainer: { flex: 1 }, transcriptList: { flex: 1 }, diff --git a/apps/mobile/src/theme.ts b/apps/mobile/src/theme.ts index 205a471..93e133b 100644 --- a/apps/mobile/src/theme.ts +++ b/apps/mobile/src/theme.ts @@ -27,9 +27,6 @@ export const palette = { warm: "#f2c86b", } as const; -// Quiet text in inbox rows the spec fades to 70% while working; the composite clears 5.3:1. -export const fadedWorkingText = "#b8bdca"; - // Added 7.7:1 and removed 5.9:1 on their line tints. export const diffPalette = { addedText: "#86d99a", diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index a3fe0da..7177300 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -12,6 +12,26 @@ the fail-closed database backup-exclusion startup guard. Statements marked pending in older dated entries describe the status at the time of that probe; later entries supersede them. +## 2026-11-14: Working docked above the bottom menu + +An iPhone screenshot showed the Working section below a long Recent list, out of +view until the user scrolled to the end. Working now sits in a dock between the +session list and the bottom menu. Expanded rows scroll inside the dock past 40% +of the window height, and search hides the dock. + +The full lint, typecheck, test, and build sequence passed, including 1534 mobile +tests and both Hermes exports. Expo Doctor passed 18 of 18 checks. Both native +fingerprints and runtime matched the installed 0.1.6 baseline under the preview +EAS environment, and local EAS Update publication succeeded for iOS and Android +on preview. The user confirmed the dock looks right on iPhone. Android appearance, +large text, and VoiceOver/TalkBack focus order remain unverified. + +A follow-up iPhone screenshot showed the docked working row at 70% opacity with a +dim title, so it read as archived. Working rows no longer fade, and their title +uses full-strength ink at the regular weight. Archived rows keep the 50% fade. +The follow-up was published the same way to preview; its device appearance is +pending. + ## 2026-11-14: event stream stays open in the iOS app switcher, pending device verification On an iPhone, swiping up to the app switcher showed "Request status is stale" diff --git a/docs/SPEC.md b/docs/SPEC.md index 3f100fa..3579491 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -397,20 +397,25 @@ list shows, in this order: ("Approval") or form ("Question") anywhere in their family. 2. Recent, with no header: root sessions that are not archived, not running, and not blocked. -3. "Working": running root sessions. -4. "Archived": root sessions whose `time.archived` is set. +3. "Archived": root sessions whose `time.archived` is set. + +"Working", the running root sessions, is not part of the scrolling list. It is +docked below the list and above the phone bottom menu, so a long Recent or +Archived list never buries running sessions. Expanded, its rows scroll inside +the dock once they exceed 40% of the window height. Search hides the dock and +lists running sessions inline with every other match. Working and Archived each start under a header with a hairline separator above it. The header shows the section name, its count, and a chevron. It is one 48-point button whose state says whether the section is expanded, and it collapses or expands the section. A collapsed section keeps its header and -count. The device stores each section's state per connection, expanded by +count. The docked Working section opens upward, so its collapsed chevron points +up. The device stores each section's state per connection, expanded by default. The change animates only when Reduce Motion is off. A section with no -rows has no header. Working sits above Archived so a long archive never pushes -running sessions out of reach. With Needs attention, Recent, and Archived all +rows has no header. With Needs attention, Recent, and Archived all empty, the list shows an idle state: "Nothing needs you right now", then "Sessions show up here when they're new, need your answer, or finish a run." It -ends with "Running sessions are under Working." above the Working section while +ends with "Running sessions are under Working." above the Working dock while Working has rows, and "Start one with New session." otherwise. Recent and Archived list only sessions whose last run finished (`time.idle`), @@ -452,11 +457,13 @@ dim footnote, with the status on the right: "Approval" or "Question" in amber, "Working" and the current run's duration in blue (ticking each second, while screen readers hear only "Working"), "Failed" in red or "Done" in green for an unread finished run, and otherwise the age of the last run. The second line is -the title, semibold while unread and dim once read. The third line shows the +the title, semibold while unread, full-strength ink at the regular weight while +working, and dim once read. The third line shows the branch for a session in the project's checkout or a current worktree, and the worktree directory name for a session elsewhere. The app reads VCS only for those live directories because reading initializes a location on the server. -Working rows are at 70% opacity and archived rows at 50%. Rows do not show +Archived rows are at 50% opacity. Working rows do not fade, so a running +session never reads as archived. Rows do not show child rows or background-task counts, and have no cards or separators between them. Search results use the same rows. The row's screen-reader label includes the title, project, branch or worktree, state, archive state, and the time of @@ -587,7 +594,8 @@ state rather than local transcript storage. connection. - Project, location, directory, and worktree selection for new sessions. - Cross-project session inbox in the OpenCode desktop Inbox layout: Needs - attention, Recent, Working, and Archived in one scrolling list, server-shared + attention, Recent, and Archived in one scrolling list with Working docked + below it, server-shared archive and read state, and server search and pagination for older sessions. - Session transcript with stable live-follow and unread controls. - Composer with multiline native editing and model/agent controls. diff --git a/docs/UX-REVIEW.md b/docs/UX-REVIEW.md index 05717ce..d5eeb0f 100644 --- a/docs/UX-REVIEW.md +++ b/docs/UX-REVIEW.md @@ -143,9 +143,9 @@ the new scope exposed gaps. All three were satisfied again after round 7. tools are red and interrupted ones amber. Code comments are easier to read, and the code block header has no dead band. Several images form a grid. Transcript and session load failures offer Retry. -- **Inbox:** Rows keep the specified 70% and 50% fades, but their quiet text +- **Inbox:** Archived rows keep the specified 50% fade, but their quiet text starts brighter so the result clears 4.5:1. Working rows show the working - indicator. Header icons match, attention dots sit on their icons, and search + indicator at full strength; a 70% fade made them read as archived. Header icons match, attention dots sit on their icons, and search has an empty state. - **Approvals:** Each pooled request names its project and session. Permission cards lead with the action. Reject shows its wider effect before the button,