Skip to content
Open
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
204 changes: 189 additions & 15 deletions packages/tasks/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import {
getState,
setState,
getTaskUIState,
type Task,
type TaskDetails,
type TasksPushMessage,
type TaskTemplate,
} from "@repo/webview-shared";
Expand All @@ -15,23 +17,56 @@ import {
ErrorState,
NoTemplateState,
NotSupportedState,
TaskDetailView,
TaskList,
} from "./components";
import { POLLING_CONFIG } from "./config";
import { taskArraysEqual, templateArraysEqual } from "./utils";
import {
taskArraysEqual,
taskDetailsEqual,
taskEqual,
templateArraysEqual,
} from "./utils";

interface PersistedState {
tasks: Task[];
templates: TaskTemplate[];
selectedTaskId: string | null;
selectedTask: TaskDetails | null;
createExpanded: boolean;
historyExpanded: boolean;
tasksSupported: boolean;
}

function validatePersistedState(
state: PersistedState | undefined,
): PersistedState | undefined {
if (!state) return undefined;

if (state.selectedTask && state.tasks.length > 0) {
const taskExists = state.tasks.some(
(t) => t.id === state.selectedTask?.task.id,
);
if (!taskExists) {
return { ...state, selectedTaskId: null, selectedTask: null };
}
}

return state;
}

function isTaskActive(task: Task | null | undefined): boolean {
if (!task) return false;
const state = getTaskUIState(task);
return state === "working" || state === "initializing";
}

export default function App() {
const api = useTasksApi();

const persistedState = useRef(getState<PersistedState>());
const persistedState = useRef(
validatePersistedState(getState<PersistedState>()),
);
const restored = persistedState.current;

const [initialized, setInitialized] = useState(!!restored?.tasks?.length);
Expand All @@ -43,22 +78,35 @@ export default function App() {
restored?.tasksSupported ?? true,
);

const [selectedTask, setSelectedTask] = useState<TaskDetails | null>(
restored?.selectedTask ?? null,
);
const [createExpanded, setCreateExpanded] = useState(
restored?.createExpanded ?? true,
);
const [historyExpanded, setHistoryExpanded] = useState(
restored?.historyExpanded ?? true,
);
const [isTransitioning, setIsTransitioning] = useState(false);

useEffect(() => {
setState<PersistedState>({
tasks,
templates,
selectedTaskId: selectedTask?.task.id ?? null,
selectedTask,
createExpanded,
historyExpanded,
tasksSupported,
});
}, [tasks, templates, createExpanded, historyExpanded, tasksSupported]);
}, [
tasks,
templates,
selectedTask,
createExpanded,
historyExpanded,
tasksSupported,
]);

const [initLoading, setInitLoading] = useState(!restored?.tasks?.length);
const [initError, setInitError] = useState<string | null>(null);
Expand All @@ -76,6 +124,16 @@ export default function App() {
setTasksSupported(data.tasksSupported);
setInitialized(true);
setInitError(null);

if (selectedTaskRef.current) {
const taskExists = data.tasks.some(
(t) => t.id === selectedTaskRef.current?.task.id,
);
if (!taskExists) {
expectedTaskIdRef.current = null;
setSelectedTask(null);
}
}
} catch (err) {
if (cancelled) return;
setInitError(
Expand All @@ -97,12 +155,19 @@ export default function App() {
const tasksRef = useRef<Task[]>(tasks);
tasksRef.current = tasks;

const selectedTaskRef = useRef<TaskDetails | null>(selectedTask);
selectedTaskRef.current = selectedTask;

const templatesRef = useRef<TaskTemplate[]>(templates);
templatesRef.current = templates;

// Poll for task list updates
const expectedTaskIdRef = useRef<string | null>(
restored?.selectedTaskId ?? null,
);

// Poll for task list updates when not viewing a specific task
useEffect(() => {
if (!initialized) return;
if (!initialized || selectedTask) return;

let cancelled = false;
const pollInterval = setInterval(() => {
Expand All @@ -121,7 +186,38 @@ export default function App() {
cancelled = true;
clearInterval(pollInterval);
};
}, [api, initialized]);
}, [api, initialized, selectedTask]);

const selectedTaskId = selectedTask?.task.id ?? null;
const isActive = isTaskActive(selectedTask?.task);

// Poll for selected task with adaptive interval based on task state
useEffect(() => {
if (!initialized || !selectedTaskId) return;

let cancelled = false;
const interval = isActive
? POLLING_CONFIG.TASK_ACTIVE_INTERVAL_MS
: POLLING_CONFIG.TASK_IDLE_INTERVAL_MS;

const poll = () => {
api
.getTaskDetails(selectedTaskId)
.then((details) => {
if (cancelled || expectedTaskIdRef.current !== selectedTaskId) return;
if (!taskDetailsEqual(selectedTaskRef.current, details)) {
setSelectedTask(details);
}
})
.catch(() => undefined);
};

const pollInterval = setInterval(poll, interval);
return () => {
cancelled = true;
clearInterval(pollInterval);
};
}, [api, initialized, selectedTaskId, isActive]);

const handleRetry = useCallback(() => {
setInitLoading(true);
Expand All @@ -147,18 +243,44 @@ export default function App() {

useMessage<TasksPushMessage>((msg) => {
switch (msg.type) {
case "tasksUpdated":
case "tasksUpdated": {
setTasks(msg.data);
const currentSelectedId = selectedTaskRef.current?.task.id;
if (currentSelectedId) {
const updatedTask = msg.data.find((t) => t.id === currentSelectedId);
if (
updatedTask &&
!taskEqual(selectedTaskRef.current?.task, updatedTask)
) {
setSelectedTask((prev) =>
prev ? { ...prev, task: updatedTask } : null,
);
}
}
break;
}

case "taskUpdated": {
const updatedTask = msg.data;
setTasks((prev) =>
prev.map((t) => (t.id === updatedTask.id ? updatedTask : t)),
);
if (selectedTaskRef.current?.task.id === updatedTask.id) {
setSelectedTask((prev) =>
prev ? { ...prev, task: updatedTask } : null,
);
}
break;
}

case "logsAppend":
if (selectedTaskRef.current) {
setSelectedTask((prev) =>
prev ? { ...prev, logs: [...prev.logs, ...msg.data] } : null,
);
}
break;

case "refresh": {
api
.getTasks()
Expand All @@ -176,22 +298,63 @@ export default function App() {
}
})
.catch(() => undefined);
const taskIdAtRequest = selectedTaskRef.current?.task.id;
if (taskIdAtRequest) {
api
.getTaskDetails(taskIdAtRequest)
.then((details) => {
if (expectedTaskIdRef.current !== taskIdAtRequest) return;
if (!taskDetailsEqual(selectedTaskRef.current, details)) {
setSelectedTask(details);
}
})
.catch(() => undefined);
}
break;
}

case "showCreateForm":
setSelectedTask(null);
setCreateExpanded(true);
break;

case "logsAppend":
// Task detail view will handle this in next PR
break;
}
});

const handleSelectTask = useCallback((_taskId: string) => {
// Task detail view will be added in next PR
}, []);
const handleSelectTask = useCallback(
(taskId: string) => {
expectedTaskIdRef.current = taskId;
setIsTransitioning(true);

api
.getTaskDetails(taskId)
.then((details) => {
if (expectedTaskIdRef.current === taskId) {
setSelectedTask(details);
setIsTransitioning(false);
}
})
.catch(() => {
if (expectedTaskIdRef.current === taskId) {
setIsTransitioning(false);
}
});
},
[api],
);

const handleDeselectTask = useCallback(() => {
expectedTaskIdRef.current = null;
setSelectedTask(null);

api
.getTasks()
.then((updatedTasks) => {
if (!taskArraysEqual(tasksRef.current, updatedTasks)) {
setTasks(updatedTasks);
}
})
.catch(() => undefined);
}, [api]);

if (initLoading) {
return (
Expand Down Expand Up @@ -228,7 +391,18 @@ export default function App() {
expanded={historyExpanded}
onToggle={() => setHistoryExpanded(!historyExpanded)}
>
<TaskList tasks={tasks} onSelectTask={handleSelectTask} />
<div
className={`task-history-content ${isTransitioning ? "transitioning" : ""}`}
>
{selectedTask ? (
<TaskDetailView
details={selectedTask}
onBack={handleDeselectTask}
/>
) : (
<TaskList tasks={tasks} onSelectTask={handleSelectTask} />
)}
</div>
</CollapsibleSection>
</div>
);
Expand Down
Loading
Loading