Skip to content

Commit b4f2cfb

Browse files
author
Test
committed
feat: align mobile workspace ordering w/ activity
Change-Id: Ia47a538b0c8d74105be6c20a6e07238bfc359606 Signed-off-by: Test <test@example.com>
1 parent 4eb61d6 commit b4f2cfb

File tree

15 files changed

+570
-53
lines changed

15 files changed

+570
-53
lines changed

apps/mobile/src/api/client.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
ProjectsListResponse,
99
WorkspaceChatEvent,
1010
Secret,
11+
WorkspaceActivitySnapshot,
1112
} from "../types";
1213

1314
export type Result<T, E = string> = { success: true; data: T } | { success: false; error: E };
@@ -43,6 +44,10 @@ const IPC_CHANNELS = {
4344
PROJECT_LIST: "project:list",
4445
PROJECT_LIST_BRANCHES: "project:listBranches",
4546
PROJECT_SECRETS_GET: "project:secrets:get",
47+
WORKSPACE_ACTIVITY: "workspace:activity",
48+
WORKSPACE_ACTIVITY_SUBSCRIBE: "workspace:activity",
49+
WORKSPACE_ACTIVITY_ACK: "workspace:activity:subscribe",
50+
WORKSPACE_ACTIVITY_LIST: "workspace:activity:list",
4651
PROJECT_SECRETS_UPDATE: "project:secrets:update",
4752
WORKSPACE_METADATA: "workspace:metadata",
4853
WORKSPACE_METADATA_SUBSCRIBE: "workspace:metadata",
@@ -82,6 +87,23 @@ function isJsonRecord(value: unknown): value is JsonRecord {
8287
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
8388
}
8489

90+
function parseWorkspaceActivity(value: unknown): WorkspaceActivitySnapshot | null {
91+
if (!isJsonRecord(value)) {
92+
return null;
93+
}
94+
const recency = typeof value.recency === "number" && Number.isFinite(value.recency) ? value.recency : null;
95+
if (recency === null) {
96+
return null;
97+
}
98+
const streaming = value.streaming === true;
99+
const lastModel = typeof value.lastModel === "string" ? value.lastModel : null;
100+
return {
101+
recency,
102+
streaming,
103+
lastModel,
104+
};
105+
}
106+
85107
function ensureWorkspaceId(id: string): string {
86108
assert(typeof id === "string", "workspaceId must be a string");
87109
const trimmed = id.trim();
@@ -528,6 +550,60 @@ export function createClient(cfg: CmuxMobileClientConfig = {}) {
528550
onMetadata({ workspaceId, metadata });
529551
}
530552
),
553+
activity: {
554+
list: async (): Promise<Record<string, WorkspaceActivitySnapshot>> => {
555+
const response = await invoke<Record<string, unknown>>(IPC_CHANNELS.WORKSPACE_ACTIVITY_LIST);
556+
const result: Record<string, WorkspaceActivitySnapshot> = {};
557+
if (response && typeof response === "object") {
558+
for (const [workspaceId, value] of Object.entries(response)) {
559+
if (typeof workspaceId !== "string") {
560+
continue;
561+
}
562+
const parsed = parseWorkspaceActivity(value);
563+
if (parsed) {
564+
result[workspaceId] = parsed;
565+
}
566+
}
567+
}
568+
return result;
569+
},
570+
subscribe: (
571+
onActivity: (payload: {
572+
workspaceId: string;
573+
activity: WorkspaceActivitySnapshot | null;
574+
}) => void
575+
): WebSocketSubscription =>
576+
subscribe(
577+
{ type: "subscribe", channel: IPC_CHANNELS.WORKSPACE_ACTIVITY_SUBSCRIBE },
578+
(data) => {
579+
if (data.channel !== IPC_CHANNELS.WORKSPACE_ACTIVITY) {
580+
return;
581+
}
582+
const args = Array.isArray(data.args) ? data.args : [];
583+
const [firstArg] = args;
584+
if (!isJsonRecord(firstArg)) {
585+
return;
586+
}
587+
const workspaceId =
588+
typeof firstArg.workspaceId === "string" ? firstArg.workspaceId : null;
589+
if (!workspaceId) {
590+
return;
591+
}
592+
593+
if (firstArg.activity === null) {
594+
onActivity({ workspaceId, activity: null });
595+
return;
596+
}
597+
598+
const activity = parseWorkspaceActivity(firstArg.activity);
599+
if (!activity) {
600+
return;
601+
}
602+
603+
onActivity({ workspaceId, activity });
604+
}
605+
),
606+
},
531607
},
532608
tokenizer: {
533609
calculateStats: async (messages: MuxMessage[], model: string): Promise<ChatStats> =>
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { JSX } from "react";
2+
import { StyleSheet, View } from "react-native";
3+
import type { WorkspaceActivitySnapshot } from "../types";
4+
import { ThemedText } from "./ThemedText";
5+
import { useTheme } from "../theme";
6+
7+
interface WorkspaceActivityIndicatorProps {
8+
activity?: WorkspaceActivitySnapshot;
9+
fallbackLabel: string;
10+
}
11+
12+
export function WorkspaceActivityIndicator(
13+
props: WorkspaceActivityIndicatorProps
14+
): JSX.Element {
15+
const theme = useTheme();
16+
const isStreaming = props.activity?.streaming ?? false;
17+
const dotColor = isStreaming ? theme.colors.accent : theme.colors.borderSubtle;
18+
const label = isStreaming
19+
? props.activity?.lastModel
20+
? `Streaming • ${props.activity.lastModel}`
21+
: "Streaming"
22+
: props.fallbackLabel;
23+
24+
return (
25+
<View style={[styles.container, { gap: theme.spacing.xs }]}>
26+
<View
27+
style={[
28+
styles.dot,
29+
{
30+
backgroundColor: dotColor,
31+
opacity: isStreaming ? 1 : 0.6,
32+
},
33+
]}
34+
/>
35+
<ThemedText variant="caption" style={{ color: theme.colors.foregroundMuted }}>
36+
{label}
37+
</ThemedText>
38+
</View>
39+
);
40+
}
41+
42+
const styles = StyleSheet.create({
43+
container: {
44+
flexDirection: "row",
45+
alignItems: "center",
46+
},
47+
dot: {
48+
width: 8,
49+
height: 8,
50+
borderRadius: 4,
51+
},
52+
});

apps/mobile/src/hooks/useProjectsData.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { useEffect } from "react";
22
import { useQuery, useQueryClient } from "@tanstack/react-query";
33
import { useApiClient } from "./useApiClient";
4-
import type { FrontendWorkspaceMetadata } from "../types";
4+
import type { FrontendWorkspaceMetadata, WorkspaceActivitySnapshot } from "../types";
55

66
const WORKSPACES_QUERY_KEY = ["workspaces"] as const;
7+
const WORKSPACE_ACTIVITY_QUERY_KEY = ["workspace-activity"] as const;
78
const PROJECTS_QUERY_KEY = ["projects"] as const;
89

910
export function useProjectsData() {
@@ -21,6 +22,12 @@ export function useProjectsData() {
2122
queryFn: () => api.workspace.list(),
2223
staleTime: 15_000,
2324
});
25+
const activityQuery = useQuery({
26+
queryKey: WORKSPACE_ACTIVITY_QUERY_KEY,
27+
queryFn: () => api.workspace.activity.list(),
28+
staleTime: 15_000,
29+
});
30+
2431

2532
useEffect(() => {
2633
const subscription = api.workspace.subscribeMetadata(({ workspaceId, metadata }) => {
@@ -31,15 +38,12 @@ export function useProjectsData() {
3138
return existing;
3239
}
3340

34-
// Handle deletion (null metadata)
3541
if (metadata === null) {
3642
return existing.filter((w) => w.id !== workspaceId);
3743
}
3844

39-
// Handle update/rename
4045
const index = existing.findIndex((workspace) => workspace.id === workspaceId);
4146
if (index === -1) {
42-
// New workspace - add it
4347
return [...existing, metadata];
4448
}
4549

@@ -55,9 +59,34 @@ export function useProjectsData() {
5559
};
5660
}, [api, queryClient]);
5761

62+
useEffect(() => {
63+
const subscription = api.workspace.activity.subscribe(({ workspaceId, activity }) => {
64+
queryClient.setQueryData<Record<string, WorkspaceActivitySnapshot> | undefined>(
65+
WORKSPACE_ACTIVITY_QUERY_KEY,
66+
(existing) => {
67+
const current = existing ?? {};
68+
if (activity === null) {
69+
if (!current[workspaceId]) {
70+
return existing;
71+
}
72+
const next = { ...current };
73+
delete next[workspaceId];
74+
return next;
75+
}
76+
return { ...current, [workspaceId]: activity };
77+
}
78+
);
79+
});
80+
81+
return () => {
82+
subscription.close();
83+
};
84+
}, [api, queryClient]);
85+
5886
return {
5987
api,
6088
projectsQuery,
6189
workspacesQuery,
90+
activityQuery,
6291
};
6392
}

apps/mobile/src/screens/ProjectsScreen.tsx

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@ import { Surface } from "../components/Surface";
1919
import { IconButton } from "../components/IconButton";
2020
import { SecretsModal } from "../components/SecretsModal";
2121
import { RenameWorkspaceModal } from "../components/RenameWorkspaceModal";
22+
import { WorkspaceActivityIndicator } from "../components/WorkspaceActivityIndicator";
2223
import { createClient } from "../api/client";
23-
import type { FrontendWorkspaceMetadata, Secret } from "../types";
24+
import type { FrontendWorkspaceMetadata, Secret, WorkspaceActivitySnapshot } from "../types";
2425

2526
interface WorkspaceListItem {
2627
metadata: FrontendWorkspaceMetadata;
@@ -35,6 +36,7 @@ interface ProjectGroup {
3536
}
3637

3738
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
39+
const EMPTY_ACTIVITY_MAP: Record<string, WorkspaceActivitySnapshot> = {};
3840

3941
function deriveProjectName(projectPath: string): string {
4042
if (!projectPath) {
@@ -53,7 +55,13 @@ function parseTimestamp(value?: string): number {
5355
return Number.isFinite(parsed) ? parsed : 0;
5456
}
5557

56-
function calculateLastActive(metadata: FrontendWorkspaceMetadata): number {
58+
function calculateLastActive(
59+
metadata: FrontendWorkspaceMetadata,
60+
activity?: WorkspaceActivitySnapshot
61+
): number {
62+
if (activity && Number.isFinite(activity.recency)) {
63+
return activity.recency;
64+
}
5765
return parseTimestamp(metadata.createdAt);
5866
}
5967

@@ -82,14 +90,15 @@ export function ProjectsScreen(): JSX.Element {
8290
const theme = useTheme();
8391
const spacing = theme.spacing;
8492
const router = useRouter();
85-
const { api, projectsQuery, workspacesQuery } = useProjectsData();
93+
const { api, projectsQuery, workspacesQuery, activityQuery } = useProjectsData();
8694
const [search, setSearch] = useState("");
8795
const [secretsModalState, setSecretsModalState] = useState<{
8896
visible: boolean;
8997
projectPath: string;
9098
projectName: string;
9199
secrets: Secret[];
92100
} | null>(null);
101+
const activityMap = activityQuery.data ?? EMPTY_ACTIVITY_MAP;
93102

94103
const [renameModalState, setRenameModalState] = useState<{
95104
visible: boolean;
@@ -136,7 +145,8 @@ export function ProjectsScreen(): JSX.Element {
136145
continue;
137146
}
138147
const group = ensureGroup(workspace.projectPath);
139-
const lastActive = calculateLastActive(workspace);
148+
const activity = activityMap[workspace.id];
149+
const lastActive = calculateLastActive(workspace, activity);
140150
const isOld = Date.now() - lastActive >= ONE_DAY_MS;
141151
group.workspaces.push({ metadata: workspace, lastActive, isOld });
142152
}
@@ -184,14 +194,27 @@ export function ProjectsScreen(): JSX.Element {
184194
);
185195

186196
return results;
187-
}, [projectsQuery.data, workspacesQuery.data, search]);
188-
189-
const isLoading = projectsQuery.isLoading || workspacesQuery.isLoading;
190-
const isRefreshing = projectsQuery.isRefetching || workspacesQuery.isRefetching;
191-
const hasError = Boolean(projectsQuery.error ?? workspacesQuery.error);
197+
}, [projectsQuery.data, workspacesQuery.data, activityMap, search]);
198+
199+
const isLoading =
200+
projectsQuery.isLoading || workspacesQuery.isLoading || activityQuery.isLoading;
201+
const isRefreshing =
202+
projectsQuery.isRefetching || workspacesQuery.isRefetching || activityQuery.isRefetching;
203+
const hasError = Boolean(
204+
projectsQuery.error ?? workspacesQuery.error ?? activityQuery.error
205+
);
206+
const errorMessage =
207+
(projectsQuery.error instanceof Error && projectsQuery.error.message) ||
208+
(workspacesQuery.error instanceof Error && workspacesQuery.error.message) ||
209+
(activityQuery.error instanceof Error && activityQuery.error.message) ||
210+
undefined;
192211

193212
const onRefresh = () => {
194-
void Promise.all([projectsQuery.refetch(), workspacesQuery.refetch()]);
213+
void Promise.all([
214+
projectsQuery.refetch(),
215+
workspacesQuery.refetch(),
216+
activityQuery.refetch(),
217+
]);
195218
};
196219

197220
const handleOpenSecrets = async (projectPath: string, projectName: string) => {
@@ -326,6 +349,7 @@ export function ProjectsScreen(): JSX.Element {
326349
const { metadata, lastActive, isOld } = item;
327350
const accentWidth = 3;
328351
const formattedTimestamp = lastActive ? formatRelativeTime(lastActive) : "Unknown";
352+
const activity = activityMap[metadata.id];
329353

330354
return (
331355
<Pressable
@@ -391,9 +415,9 @@ export function ProjectsScreen(): JSX.Element {
391415
{metadata.namedWorkspacePath}
392416
</ThemedText>
393417
</View>
394-
<ThemedText variant="caption" style={{ marginLeft: spacing.md }}>
395-
{formattedTimestamp}
396-
</ThemedText>
418+
<View style={{ marginLeft: spacing.md }}>
419+
<WorkspaceActivityIndicator activity={activity} fallbackLabel={formattedTimestamp} />
420+
</View>
397421
</Pressable>
398422
);
399423
};
@@ -466,7 +490,7 @@ export function ProjectsScreen(): JSX.Element {
466490
Unable to load data
467491
</ThemedText>
468492
<ThemedText variant="caption" style={{ marginTop: spacing.xs }}>
469-
Please check your connection and try again.
493+
{errorMessage ?? "Please check your connection and try again."}
470494
</ThemedText>
471495
<Pressable
472496
onPress={onRefresh}

apps/mobile/src/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export type { WorkspaceMetadata, FrontendWorkspaceMetadata, WorkspaceChatEvent } from "./workspace";
2+
export type { WorkspaceActivitySnapshot } from "@shared/types/workspace";
23
export type { ProjectConfig, ProjectsListResponse, WorkspaceConfigEntry } from "./project";
34
export type { DisplayedMessage } from "./message";
45
export type { Secret, SecretsConfig } from "./secrets";

src/App.stories.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ function setupMockAPI(options: {
6868
resumeStream: () => Promise.resolve({ success: true, data: undefined }),
6969
interruptStream: () => Promise.resolve({ success: true, data: undefined }),
7070
truncateHistory: () => Promise.resolve({ success: true, data: undefined }),
71+
activity: {
72+
list: () => Promise.resolve({}),
73+
subscribe: () => () => undefined,
74+
},
7175
replaceChatHistory: () => Promise.resolve({ success: true, data: undefined }),
7276
getInfo: () => Promise.resolve(null),
7377
executeBash: () =>
@@ -540,6 +544,10 @@ export const ActiveWorkspaceWithChat: Story = {
540544
},
541545
}),
542546
list: () => Promise.resolve(workspaces),
547+
activity: {
548+
list: () => Promise.resolve({}),
549+
subscribe: () => () => undefined,
550+
},
543551
rename: (workspaceId: string) =>
544552
Promise.resolve({
545553
success: true,
@@ -1254,6 +1262,10 @@ export const MarkdownTables: Story = {
12541262
},
12551263
}),
12561264
list: () => Promise.resolve(workspaces),
1265+
activity: {
1266+
list: () => Promise.resolve({}),
1267+
subscribe: () => () => undefined,
1268+
},
12571269
rename: (workspaceId: string) =>
12581270
Promise.resolve({
12591271
success: true,

0 commit comments

Comments
 (0)