Skip to content

Commit a60bf4e

Browse files
committed
fix(mobile): pin the workspace selector to the picker header
The workspace governs everything below it, but it sat inside the scrolling list — with 50 projects it scrolled away, leaving no indication of which workspace the rows belonged to and no way to change it without scrolling back. It moves into the pinned header beside the title: chips when there are several, a labelled name when there is one. The list below becomes the selected workspace's projects, which also drops the nested section-per-workspace shape it no longer needs. Splits the old component in three along the way — the selector, the list, and the grouping, which was an inline useMemo and is now a covered pure function.
1 parent 9de9508 commit a60bf4e

8 files changed

Lines changed: 172 additions & 107 deletions

File tree

web/mobile/src/features/context/ContextResolver.tsx

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import {ScreenScaffold} from "@/components/ScreenScaffold"
77
import {fetchProjects, readDesktopLastUsed, readLastContext, type LastContext} from "@/lib/context"
88

99
import {selectContextTarget} from "./contextTarget"
10+
import {ProjectList} from "./ProjectList"
1011
import {SignedOutNotice} from "./states/SignedOutNotice"
11-
import {WorkspaceProjectList, type WorkspaceGroup} from "./WorkspaceProjectList"
12+
import {groupByWorkspace, type WorkspaceGroup} from "./workspaceGroups"
13+
import {WorkspaceSelector} from "./WorkspaceSelector"
1214

1315
const sessionsUrl = ({workspaceId, projectId}: LastContext) =>
1416
`/w/${workspaceId}/p/${projectId}/sessions`
@@ -36,21 +38,15 @@ export const ContextResolver = () => {
3638
})
3739
const result = query.data
3840

39-
const groups = useMemo<WorkspaceGroup[]>(() => {
40-
if (result?.kind !== "ok") return []
41-
const byWorkspace = new Map<string, WorkspaceGroup>()
42-
for (const project of result.projects) {
43-
if (!project.workspace_id) continue
44-
const group = byWorkspace.get(project.workspace_id) ?? {
45-
workspaceId: project.workspace_id,
46-
workspaceName: project.workspace_name ?? "Workspace",
47-
projects: [],
48-
}
49-
group.projects.push(project)
50-
byWorkspace.set(project.workspace_id, group)
51-
}
52-
return [...byWorkspace.values()]
53-
}, [result])
41+
const groups = useMemo<WorkspaceGroup[]>(
42+
() => (result?.kind === "ok" ? groupByWorkspace(result.projects) : []),
43+
[result],
44+
)
45+
// Which workspace the header is scoped to. Falls back rather than syncing on every fetch:
46+
// a refetch can drop the selected workspace entirely.
47+
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState("")
48+
const selectedGroup =
49+
groups.find((group) => group.workspaceId === selectedWorkspaceId) ?? groups[0]
5450

5551
const target = useMemo<LastContext | null>(
5652
() =>
@@ -76,13 +72,18 @@ export const ContextResolver = () => {
7672
body = <p className="text-muted-foreground grow p-6 text-center text-xs">Loading…</p>
7773
} else if (result?.kind === "unauthenticated") {
7874
body = <SignedOutNotice />
79-
} else if (result?.kind === "ok" && groups.length > 0) {
75+
} else if (result?.kind === "ok" && selectedGroup) {
8076
header = (
81-
<h1 className="border-border shrink-0 border-b p-4 text-xs font-semibold">
82-
Choose a project
83-
</h1>
77+
<div className="border-border flex shrink-0 flex-col gap-2 border-b px-4 pt-3 pb-2">
78+
<h1 className="text-xs font-semibold">Choose a project</h1>
79+
<WorkspaceSelector
80+
groups={groups}
81+
selectedId={selectedGroup.workspaceId}
82+
onSelect={setSelectedWorkspaceId}
83+
/>
84+
</div>
8485
)
85-
body = <WorkspaceProjectList groups={groups} />
86+
body = <ProjectList group={selectedGroup} />
8687
} else {
8788
body = (
8889
<div className="flex grow flex-col items-center justify-center gap-3 p-6 text-center">
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import {useRouter} from "next/router"
2+
3+
import type {WorkspaceGroup} from "./workspaceGroups"
4+
5+
/** Tappable project rows for the selected workspace; the workspace itself is chosen in the header. */
6+
export const ProjectList = ({group}: {group: WorkspaceGroup}) => {
7+
const router = useRouter()
8+
return (
9+
<div className="flex flex-col gap-2 p-4">
10+
{group.projects.map((project) => (
11+
<button
12+
key={project.project_id}
13+
type="button"
14+
className="border-border min-h-11 rounded-md border px-3 py-2.5 text-left text-xs"
15+
onClick={() =>
16+
void router.replace(
17+
`/w/${group.workspaceId}/p/${project.project_id}/sessions`,
18+
)
19+
}
20+
>
21+
{project.project_name}
22+
{project.is_demo ? (
23+
<span className="text-muted-foreground ml-2">demo</span>
24+
) : null}
25+
</button>
26+
))}
27+
</div>
28+
)
29+
}

web/mobile/src/features/context/WorkspaceProjectList.tsx

Lines changed: 0 additions & 84 deletions
This file was deleted.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import {Building2} from "lucide-react"
2+
3+
import type {WorkspaceGroup} from "./workspaceGroups"
4+
5+
/**
6+
* The picker's scope control, pinned in the header rather than sitting in the scrolling list —
7+
* it governs everything below it, and a project list runs long enough to scroll its own scope
8+
* off screen.
9+
*
10+
* With one workspace there is nothing to select, so it degrades to a label naming where you are
11+
* (the default workspace is called "Default", indistinguishable from a project of that name
12+
* unless it is labelled).
13+
*/
14+
export const WorkspaceSelector = ({
15+
groups,
16+
selectedId,
17+
onSelect,
18+
}: {
19+
groups: WorkspaceGroup[]
20+
selectedId: string
21+
onSelect: (workspaceId: string) => void
22+
}) => {
23+
if (groups.length <= 1) {
24+
const only = groups[0]
25+
if (!only) return null
26+
return (
27+
<p className="text-muted-foreground flex items-center gap-1.5 text-xs">
28+
<Building2 aria-hidden className="size-3.5 shrink-0" />
29+
<span className="truncate">{only.workspaceName}</span>
30+
<span className="shrink-0 opacity-70">workspace</span>
31+
</p>
32+
)
33+
}
34+
35+
return (
36+
<div className="-mx-4 flex gap-2 overflow-x-auto px-4 pb-1">
37+
{groups.map((group) => {
38+
const isSelected = group.workspaceId === selectedId
39+
return (
40+
<button
41+
key={group.workspaceId}
42+
type="button"
43+
aria-pressed={isSelected}
44+
onClick={() => onSelect(group.workspaceId)}
45+
className={`relative h-8 shrink-0 rounded-full border px-3 text-xs font-medium after:absolute after:-inset-x-1 after:-inset-y-1.5 after:content-[''] ${
46+
isSelected
47+
? "border-foreground text-foreground"
48+
: "border-border text-muted-foreground"
49+
}`}
50+
>
51+
{group.workspaceName}
52+
</button>
53+
)
54+
})}
55+
</div>
56+
)
57+
}

web/mobile/src/features/context/contextTarget.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type {LastContext} from "@/lib/context"
22

3-
import type {WorkspaceGroup} from "./WorkspaceProjectList"
3+
import type {WorkspaceGroup} from "./workspaceGroups"
44

55
export interface ContextTargetInput {
66
/** False until the router has parsed the query string. */
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type {MobileProject} from "@/lib/context"
2+
3+
export interface WorkspaceGroup {
4+
workspaceId: string
5+
workspaceName: string
6+
projects: MobileProject[]
7+
}
8+
9+
/** Group the flat project list by workspace, preserving the server's order within each. */
10+
export const groupByWorkspace = (projects: MobileProject[]): WorkspaceGroup[] => {
11+
const byWorkspace = new Map<string, WorkspaceGroup>()
12+
for (const project of projects) {
13+
// A project with no workspace cannot be routed to (`/w/:id/p/:id`), so it is dropped.
14+
if (!project.workspace_id) continue
15+
const group = byWorkspace.get(project.workspace_id) ?? {
16+
workspaceId: project.workspace_id,
17+
workspaceName: project.workspace_name ?? "Workspace",
18+
projects: [],
19+
}
20+
group.projects.push(project)
21+
byWorkspace.set(project.workspace_id, group)
22+
}
23+
return [...byWorkspace.values()]
24+
}

web/mobile/tests/unit/contextTarget.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
selectContextTarget,
55
type ContextTargetInput,
66
} from "../../src/features/context/contextTarget"
7-
import type {WorkspaceGroup} from "../../src/features/context/WorkspaceProjectList"
7+
import type {WorkspaceGroup} from "../../src/features/context/workspaceGroups"
88

99
const project = (projectId: string, workspaceId: string) =>
1010
({
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import {describe, expect, it} from "vitest"
2+
3+
import {groupByWorkspace} from "../../src/features/context/workspaceGroups"
4+
import type {MobileProject} from "../../src/lib/context"
5+
6+
const project = (projectId: string, workspaceId: string | null, name = "Workspace") =>
7+
({
8+
project_id: projectId,
9+
project_name: projectId,
10+
workspace_id: workspaceId,
11+
workspace_name: workspaceId ? name : null,
12+
}) as MobileProject
13+
14+
describe("groupByWorkspace", () => {
15+
it("groups projects under their workspace, keeping server order", () => {
16+
const groups = groupByWorkspace([
17+
project("p1", "w1"),
18+
project("p2", "w2"),
19+
project("p3", "w1"),
20+
])
21+
22+
expect(groups.map((g) => g.workspaceId)).toEqual(["w1", "w2"])
23+
expect(groups[0].projects.map((p) => p.project_id)).toEqual(["p1", "p3"])
24+
})
25+
26+
it("drops a project with no workspace — it cannot be routed to", () => {
27+
// The session route is /w/:workspaceId/p/:projectId; without the first half the row
28+
// would render as a tap that goes nowhere.
29+
expect(groupByWorkspace([project("orphan", null), project("p1", "w1")])).toHaveLength(1)
30+
})
31+
32+
it("falls back to a generic name when the workspace is unnamed", () => {
33+
const [group] = groupByWorkspace([
34+
{project_id: "p1", project_name: "p1", workspace_id: "w1"} as MobileProject,
35+
])
36+
expect(group.workspaceName).toBe("Workspace")
37+
})
38+
})

0 commit comments

Comments
 (0)