Skip to content
Draft
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
59 changes: 33 additions & 26 deletions apps/mobile/src/app/task/[id].tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { Text } from "@components/text";
import type {
CloudTaskQueuedMessage,
CloudTaskQueueMoveDirection,
} from "@posthog/core/sessions/cloudTaskQueue";
import {
countUserMessages,
getSessionActivityPhase,
Expand Down Expand Up @@ -45,17 +49,18 @@ import {
useQueuedCount,
useToggleMessagingMode,
} from "@/features/tasks/hooks/useMessagingMode";
import { useTaskMessageQueue } from "@/features/tasks/hooks/useTaskMessageQueue";
import { taskKeys } from "@/features/tasks/hooks/useTasks";
import {
type MoveDirection,
type QueuedMessage,
useMessageQueueStore,
} from "@/features/tasks/stores/messageQueueStore";
import { taskMessageQueue } from "@/features/tasks/lib/taskMessageQueue";
import { taskSessionActions } from "@/features/tasks/services/taskSessionService";
import {
pendingTaskPromptStoreApi,
usePendingTaskPrompt,
} from "@/features/tasks/stores/pendingTaskPromptStore";
import { useTaskSessionStore } from "@/features/tasks/stores/taskSessionStore";
import {
getTaskSession,
useTaskSessionStore,
} from "@/features/tasks/stores/taskSessionStore";
import { useTaskStore } from "@/features/tasks/stores/taskStore";
import { confirmStopRun } from "@/features/tasks/utils/archiveGuard";
import { useScreenInsets } from "@/hooks/useScreenInsets";
Expand All @@ -69,6 +74,7 @@ import { getPostHogApiClient } from "@/lib/posthogApiClient";
import { useThemeColors } from "@/lib/theme";

const log = logger.scope("task-detail");
type QueuedMessage = CloudTaskQueuedMessage<PendingAttachment>;

function getFirstParam(value?: string | string[]): string | undefined {
return Array.isArray(value) ? value[0] : value;
Expand Down Expand Up @@ -107,12 +113,13 @@ export default function TaskDetailScreen() {
sendInterrupting,
sendPermissionResponse,
setConfigOption,
getSessionForTask,
setFocusedTaskId,
steerQueuedMessage,
flushQueuedMessagesIfIdle,
stopRun,
} = useTaskSessionStore();
} = taskSessionActions;
const setFocusedTaskId = useTaskSessionStore(
(state) => state.setFocusedTaskId,
);

useEffect(() => {
if (!taskId) return;
Expand All @@ -127,7 +134,7 @@ export default function TaskDetailScreen() {
// Cleared when the screen unmounts. Matches the desktop super-property.
useActiveTaskAnalyticsContext(task?.signal_report ?? null);

const session = taskId ? getSessionForTask(taskId) : undefined;
const session = taskId ? getTaskSession(taskId) : undefined;

// Optimistic echo set by the new-task screen (or the terminal-resume path
// below) so the user's prompt appears in the thread immediately, before
Expand Down Expand Up @@ -176,9 +183,7 @@ export default function TaskDetailScreen() {

const messagingMode = useMessagingMode(taskId);
const queuedCount = useQueuedCount(taskId);
const editingQueuedId = useMessageQueueStore((s) =>
taskId ? s.editingByTaskId[taskId] : undefined,
);
const { editingId: editingQueuedId } = useTaskMessageQueue(taskId);
const toggleMessagingMode = useToggleMessagingMode(taskId);
const analytics = useAnalytics();

Expand Down Expand Up @@ -369,11 +374,13 @@ export default function TaskDetailScreen() {
// Saving an in-place edit: overwrite the queued message and release the
// drain hold. If the turn already ended while editing, flush now — the
// turn-end drain won't fire again on its own.
const queue = useMessageQueueStore.getState();
const editingId = queue.editingByTaskId[taskId];
const editingId = taskMessageQueue.getSnapshot().editingByTaskId[taskId];
if (editingId) {
queue.update(taskId, editingId, { content: text, attachments });
queue.clearEditing(taskId);
taskMessageQueue.update(taskId, editingId, {
content: text,
attachments,
});
taskMessageQueue.clearEditing(taskId);
flushQueuedMessagesIfIdle(taskId);
return;
}
Expand All @@ -395,7 +402,7 @@ export default function TaskDetailScreen() {
// Steer interrupts the turn and resends right away.
if (session?.isPromptPending) {
if (messagingMode === "queue") {
useMessageQueueStore.getState().enqueue(taskId, text, attachments);
taskMessageQueue.enqueue(taskId, text, attachments);
return;
}
sendInterrupting(taskId, text, attachments)
Expand Down Expand Up @@ -448,7 +455,7 @@ export default function TaskDetailScreen() {
const handleEditQueued = useCallback(
(message: QueuedMessage) => {
if (!taskId) return;
useMessageQueueStore.getState().setEditing(taskId, message.id);
taskMessageQueue.setEditing(taskId, message.id);
setRestoredDraft({
text: message.content,
attachments: message.attachments,
Expand All @@ -459,16 +466,16 @@ export default function TaskDetailScreen() {

const handleCancelEdit = useCallback(() => {
if (!taskId) return;
useMessageQueueStore.getState().clearEditing(taskId);
taskMessageQueue.clearEditing(taskId);
setRestoredDraft({ text: "", attachments: [] });
flushQueuedMessagesIfIdle(taskId);
}, [taskId, flushQueuedMessagesIfIdle]);

const handleMoveQueued = useCallback(
(message: QueuedMessage, direction: MoveDirection) => {
(message: QueuedMessage, direction: CloudTaskQueueMoveDirection) => {
if (!taskId) return;
Haptics.selectionAsync();
useMessageQueueStore.getState().move(taskId, message.id, direction);
taskMessageQueue.move(taskId, message.id, direction);
},
[taskId],
);
Expand All @@ -477,8 +484,8 @@ export default function TaskDetailScreen() {
(message: QueuedMessage) => {
if (!taskId) return;
const wasEditing =
useMessageQueueStore.getState().editingByTaskId[taskId] === message.id;
useMessageQueueStore.getState().remove(taskId, message.id);
taskMessageQueue.getSnapshot().editingByTaskId[taskId] === message.id;
taskMessageQueue.remove(taskId, message.id);
if (wasEditing) setRestoredDraft({ text: "", attachments: [] });
},
[taskId],
Expand Down Expand Up @@ -525,7 +532,7 @@ export default function TaskDetailScreen() {
const handleStopRun = useCallback(() => {
if (!taskId) return;
confirmStopRun(() => {
const promptsSent = countUserMessages(getSessionForTask(taskId)?.events);
const promptsSent = countUserMessages(getTaskSession(taskId)?.events);
stopRun(taskId)
.then((ok) => {
if (ok) {
Expand All @@ -543,7 +550,7 @@ export default function TaskDetailScreen() {
})
.catch(() => {});
});
}, [taskId, stopRun, analytics, getSessionForTask]);
}, [taskId, stopRun, analytics]);

const canStopRun =
!!task &&
Expand Down
7 changes: 1 addition & 6 deletions apps/mobile/src/features/tasks/api.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
import type {
Adapter,
StoredLogEntry,
Task,
TaskRun,
} from "@posthog/shared";
import type { Adapter, StoredLogEntry, Task, TaskRun } from "@posthog/shared";
import { fetch } from "expo/fetch";
import {
authedFetch,
Expand Down
6 changes: 4 additions & 2 deletions apps/mobile/src/features/tasks/components/TaskSessionView.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import type {
PortableSessionEvent as SessionEvent,
PortableSessionNotification as SessionNotification,
} from "@posthog/core/sessions/portableSessionEvents";
import {
ArrowDown,
Brain,
Expand Down Expand Up @@ -25,8 +29,6 @@ import { useThemeColors } from "@/lib/theme";
import type {
CloudPendingPermissionRequest,
PlanEntry,
SessionEvent,
SessionNotification,
SessionNotificationAttachment,
} from "../types";
import { PlanApprovalCard } from "./PlanApprovalCard";
Expand Down
23 changes: 14 additions & 9 deletions apps/mobile/src/features/tasks/composer/QueuedMessagesDock.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { Text } from "@components/text";
import type {
CloudTaskQueuedMessage,
CloudTaskQueueMoveDirection,
} from "@posthog/core/sessions/cloudTaskQueue";
import {
CaretDown,
CaretUp,
Expand All @@ -12,19 +16,21 @@ import { type ReactNode, useState } from "react";
import { Pressable, View } from "react-native";
import { SheetContainer } from "@/components/SheetContainer";
import { useThemeColors } from "@/lib/theme";
import {
type MoveDirection,
type QueuedMessage,
useMessageQueueStore,
} from "../stores/messageQueueStore";
import { useTaskMessageQueue } from "../hooks/useTaskMessageQueue";
import type { PendingAttachment } from "./attachments/types";

type QueuedMessage = CloudTaskQueuedMessage<PendingAttachment>;

interface QueuedMessagesDockProps {
taskId: string;
canSteer: boolean;
onSteer: (message: QueuedMessage) => void;
onEdit: (message: QueuedMessage) => void;
onDiscard: (message: QueuedMessage) => void;
onMove: (message: QueuedMessage, direction: MoveDirection) => void;
onMove: (
message: QueuedMessage,
direction: CloudTaskQueueMoveDirection,
) => void;
}

function previewText(message: QueuedMessage): string {
Expand All @@ -42,11 +48,10 @@ export function QueuedMessagesDock({
onMove,
}: QueuedMessagesDockProps) {
const themeColors = useThemeColors();
const queued = useMessageQueueStore((s) => s.queuesByTaskId[taskId]);
const editingId = useMessageQueueStore((s) => s.editingByTaskId[taskId]);
const { messages: queued, editingId } = useTaskMessageQueue(taskId);
const [activeId, setActiveId] = useState<string | null>(null);

if (!queued || queued.length === 0) return null;
if (queued.length === 0) return null;
const active = queued.find((m) => m.id === activeId) ?? null;

return (
Expand Down
8 changes: 4 additions & 4 deletions apps/mobile/src/features/tasks/hooks/useMessagingMode.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import { useCallback } from "react";
import { useMessageQueueStore } from "../stores/messageQueueStore";
import { taskSessionActions } from "../services/taskSessionService";
import {
type MessagingMode,
useMessagingModeStore,
} from "../stores/messagingModeStore";
import { useTaskSessionStore } from "../stores/taskSessionStore";
import { useTaskMessageQueue } from "./useTaskMessageQueue";

/** Effective mode for a task: per-task override, else the global default. */
export function useMessagingMode(taskId: string | undefined): MessagingMode {
return useMessagingModeStore((s) => s.getEffectiveMode(taskId));
}

export function useQueuedCount(taskId: string | undefined): number {
return useMessageQueueStore((s) => (taskId ? s.getQueue(taskId).length : 0));
return useTaskMessageQueue(taskId ?? "").messages.length;
}

/**
Expand All @@ -27,7 +27,7 @@ export function useToggleMessagingMode(taskId: string | undefined): () => void {
const next: MessagingMode = mode === "steer" ? "queue" : "steer";
useMessagingModeStore.getState().setMode(taskId, next);
if (next === "steer") {
void useTaskSessionStore.getState().flushQueuedMessages(taskId);
void taskSessionActions.flushQueuedMessages(taskId);
}
}, [taskId, mode]);
}
20 changes: 20 additions & 0 deletions apps/mobile/src/features/tasks/hooks/useTaskMessageQueue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { CloudTaskQueuedMessage } from "@posthog/core/sessions/cloudTaskQueue";
import { useSyncExternalStore } from "react";
import type { PendingAttachment } from "../composer/attachments/types";
import { taskMessageQueue } from "../lib/taskMessageQueue";

interface TaskMessageQueueSelection {
messages: readonly CloudTaskQueuedMessage<PendingAttachment>[];
editingId: string | undefined;
}

export function useTaskMessageQueue(taskId: string): TaskMessageQueueSelection {
const snapshot = useSyncExternalStore(
taskMessageQueue.subscribe,
taskMessageQueue.getSnapshot,
);
return {
messages: snapshot.queuesByTaskId[taskId] ?? [],
editingId: snapshot.editingByTaskId[taskId],
};
}
5 changes: 1 addition & 4 deletions apps/mobile/src/features/tasks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,7 @@ export {
useUpdateTask,
} from "./hooks/useTasks";
// Stores
export {
type TaskSession,
useTaskSessionStore,
} from "./stores/taskSessionStore";
export { useTaskSessionStore } from "./stores/taskSessionStore";
export { useTaskStore } from "./stores/taskStore";

// Types
Expand Down
54 changes: 54 additions & 0 deletions apps/mobile/src/features/tasks/lib/cloudTaskStream.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
engine: {
off: vi.fn(),
on: vi.fn(),
reconnectIfDisconnected: vi.fn(),
unwatch: vi.fn(),
watch: vi.fn(),
},
}));

vi.mock("@posthog/core/cloud-task/cloud-task-engine", () => ({
createCloudTaskEngine: () => mocks.engine,
}));

vi.mock("@posthog/core/cloud-task/schemas", () => ({
CloudTaskEvent: { Update: "cloud-task-update" },
}));

vi.mock("expo/fetch", () => ({ fetch: vi.fn() }));

vi.mock("@/lib/api", () => ({
authedFetch: vi.fn(),
getBaseUrl: () => "https://app.posthog.test",
getProjectId: () => 42,
}));

vi.mock("@/lib/logger", () => ({
logger: { scope: vi.fn() },
}));

import { watchCloudTask } from "./cloudTaskStream";

describe("watchCloudTask", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("asks the shared engine to reconnect only when disconnected", () => {
const handle = watchCloudTask({
taskId: "task-1",
runId: "run-1",
onUpdate: vi.fn(),
});

handle.reconnectIfDisconnected();

expect(mocks.engine.reconnectIfDisconnected).toHaveBeenCalledWith(
"task-1",
"run-1",
);
});
});
Loading
Loading