0743477d48
* feat(orchestrator): persist N-entry FIFO message queue
Replaces the single-slot, latest-wins, in-memory queue with a SQLite-backed
FIFO queue (default cap 10 per session, configurable via
KANDEV_QUEUE_MAX_PER_SESSION). Drain stays one-per-turn so each entry gets its
own user message and `on_turn_*` workflow cycle. Inter-task `message_task_kandev`
returns a structured `queue_full` error with sender attribution when the
target session is saturated, so the calling LLM can decide whether to retry.
Surfaces a new WS action `message.queue.remove` for single-entry deletion;
`message.queue.cancel` now means clear-all. Edit/remove are keyed on the
entry's UUID, returning `entry_not_found` when atomic-take won the drain race.
* feat(web): N-entry queue state, API, and optimistic hook
Replaces the single-slot QueueStatus with an ordered `entries[]` per session
plus capacity meta (`count`, `max`). API exposes typed `QueueFullError` and
`QueueEntryNotFoundError`; the hook surfaces optimistic edit/remove with
drain-race rollback (refetch on `entry_not_found`).
* feat(web): inline ghost-message queue UI with sender chips
Replaces the floating QueuedMessageIndicator overlay with a stack of ghost
messages rendered above the chat input. Each ghost shows a sender chip
(user / agent) and per-entry edit/remove. A small "n/max queued · clear all"
pill summarizes capacity. Inter-task entries are visually distinct and edit
is gated to the original sender.
* fix(queue): address review — auth guard, TOCTOU, error UX
- Add `messagequeue.QueuedByUser` / `QueuedByAgent` constants and centralize
the `queue_full` error code so the WS and MCP handlers stay in sync.
- WS `message.queue.{add,append,update}` default empty user_id to QueuedByUser
so the UpdateContent ownership guard always runs against a non-empty owner;
inter-task entries (`queued_by="agent"`) can no longer be overwritten by
user-initiated edits even when `user_id` is omitted from the payload.
- memoryRepository: hold the mutex through the whole append-or-insert path
(prior split released the lock between tail-check and Insert, allowing two
same-sender callers to race past coalescing). TransferSession now shifts
source positions past the destination's max so transferred entries always
drain after pre-existing destination entries, matching SQLite semantics.
- Frontend: surface edit/clear/remove failures via `sonner` toasts (drain race
→ "message already sent"); exit edit mode on failure instead of leaving the
textarea silently stuck. Hide the remove button on read-only (agent) ghosts.
- `useQueue.clearAll` no longer derives `max` from pre-clear entry count;
resets to `metaMax ?? 0` so the next status_changed event lands authoritative
capacity.
- `senderKindOf` drops the unreachable metadata fallback; route on `queued_by`
alone since `dispatchTaskMessage` always sets it.
- Test loggers now write to stderr per CLAUDE.md "stderr for logs" guideline.
* test(web): align queue e2e selectors with new ghost-message UI
The ghost-message rewrite renamed button titles ("Cancel queued message"
→ "Remove queued message", "Edit message" → "Edit queued message") and
introduced a `data-testid="queued-ghost-list"` container. The
`message-queue.spec.ts` Playwright tests still pointed at the old titles.
* fix(queue): address second review pass — auth, drain race, tests
- WS handlers (`add` / `append` / `update`) now reject `user_id="agent"`
outright, closing the gap a hostile WS client could exploit by
impersonating the inter-task sender identity to overwrite agent
entries (greptile P1).
- WS `update` / `remove` now require `session_id` so the post-mutation
`MessageQueueStatusChanged` broadcast can never be silently skipped.
- `sqliteRepository.TakeHead` checks `RowsAffected()` after DELETE;
when two concurrent take callers race past the busy-timeout the
loser sees 0 affected rows and now returns `nil, nil` instead of
the already-drained message (would otherwise dispatch the same
prompt twice).
- `Service.GetStatus` returns `Entries: []` instead of `nil` on the
error path so the wire shape stays consistent (frontend already
guards but strict consumers wouldn't tolerate the type mismatch).
- New tests: `queue-api.test.ts` covers `rethrowQueueError` mappings
(queue_full / entry_not_found / generic / Error pass-through);
`session-slice.upsert.test.ts` covers `setQueueEntries`,
`removeQueueEntry`, and `clearQueueStatus`. Backend gains an
agent-impersonation handler test.
- `rethrowQueueError` now surfaces `WSError.message` for non-queue
errors instead of stringifying the whole object.
- Cleanup: replace unused `a, c := ...` discards in service_test
with `_, _ = ...`.
* fix(queue): scope update/remove by session_id (cross-session deletion guard)
The MCP `queue_full` error payload deliberately discloses sibling-task
entry IDs (so the calling agent's LLM can reason about the wedge state).
Without a session_id filter on `DeleteByID` / `UpdateContent`, a hostile
agent that received that payload could call `wsRemoveEntry` /
`wsUpdateMessage` against another task's entries and delete or rewrite
them — only the per-entry UUID was being checked.
- `Repository.UpdateContent` and `Repository.DeleteByID` now require
`sessionID` and add `AND session_id = ?` to the SQL guard. The memory
repo mirrors the scope (lookup keyed on session before the entry scan).
- `Service.UpdateMessage` / `Service.RemoveEntry` take `sessionID`; the
WS handlers thread `req.SessionID` through (already validated as
non-empty in the previous fixup).
- TypeScript: `session_id` is now required on `updateQueuedMessage` /
`removeQueuedEntry` params so callers fail at compile time, matching
the backend contract.
- Tests cover the cross-session attack: `RemoveEntry`/`UpdateMessage`
with an attacker-supplied session_id but a real entry id from another
session must return `ErrEntryNotFound` and leave the victim entry
untouched. SQLite repo gets parallel coverage.
* fix(queue): propagate transfer error + tighten WSError narrowing
- `Service.TransferSession` now returns an error so callers can fail
closed instead of silently leaving entries orphaned on the previous
session. The two existing call sites pick the right policy:
`switchSessionForStep` (reusing an existing session) propagates the
error so the workflow switch aborts; the new-session path logs and
continues, since the new session is already committed and rolling
back here would leave a half-switched workflow.
- `asWSError` now also narrows on `message` / `details`, so WS payloads
without a `code` field are still mapped to a meaningful `Error.message`
via `rethrowQueueError` (instead of "[object Object]"). Native
`Error` instances explicitly skip the WS-error branch so they pass
through untouched, preserving the existing pass-through guarantee.
- `queue-api.test.ts`: `expect.assertions(N)` on the catch-only tests
so a missing throw fails the test instead of silently passing.
* fix(queue): check TransferSession error in service tests
The TransferSession signature change in 83abe6d7 broke errcheck on two
test sites that hadn't been updated. Wrap them in require.NoError.
549 lines
18 KiB
TypeScript
549 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
import { useAppStore } from "@/components/state-provider";
|
|
import { useLayoutStore } from "@/lib/state/layout-store";
|
|
import { useDockviewStore } from "@/lib/state/dockview-store";
|
|
import { usePanelActions } from "@/hooks/use-panel-actions";
|
|
import { useSessionMessages } from "@/hooks/domains/session/use-session-messages";
|
|
import { useCustomPrompts } from "@/hooks/domains/settings/use-custom-prompts";
|
|
import { useSessionState } from "@/hooks/domains/session/use-session-state";
|
|
import { useSessionMcp } from "@/hooks/domains/session/use-session-mcp";
|
|
import { useProcessedMessages } from "@/hooks/use-processed-messages";
|
|
import { useSessionModel } from "@/hooks/domains/session/use-session-model";
|
|
import { useQueue } from "@/hooks/domains/session/use-queue";
|
|
import { useContextFilesStore, type ContextFile } from "@/lib/state/context-files-store";
|
|
import { useCommentsStore, isPlanComment, isPRFeedbackComment } from "@/lib/state/slices/comments";
|
|
import { usePendingDiffCommentsByFile } from "@/hooks/domains/comments/use-diff-comments";
|
|
import {
|
|
usePendingPlanComments,
|
|
usePendingPRFeedback,
|
|
} from "@/hooks/domains/comments/use-pending-comments";
|
|
import { buildContextItems } from "../chat-context-items";
|
|
import { useAutoDisablePlanMode, usePlanLayoutHandlers } from "./use-plan-mode-helpers";
|
|
import type { ContextItem } from "@/lib/types/context";
|
|
import type { DiffComment } from "@/lib/diff/types";
|
|
import type { PlanComment, PRFeedbackComment } from "@/lib/state/slices/comments";
|
|
import type { ActiveDocument } from "@/lib/state/slices/ui/types";
|
|
import type { BuiltInPreset } from "@/lib/state/layout-manager/presets";
|
|
|
|
const EMPTY_CONTEXT_FILES: ContextFile[] = [];
|
|
const PLAN_CONTEXT_PATH = "plan:context";
|
|
|
|
// Tracks sessions for which the plan layout has already been auto-applied.
|
|
// Module-scoped so it survives TaskChatPanel remounts (dockview's fromJSON
|
|
// tears down and rebuilds the portal-hosted panel, which would otherwise
|
|
// reset a component-local ref and cause the plan preset to be re-applied —
|
|
// clobbering the just-restored saved layout on env switch).
|
|
const autoAppliedPlanSessions = new Set<string>();
|
|
|
|
export type CommentsState = {
|
|
planComments: PlanComment[];
|
|
pendingCommentsByFile: Record<string, DiffComment[]>;
|
|
pendingPRFeedback: PRFeedbackComment[];
|
|
markCommentsSent: (ids: string[]) => void;
|
|
handleRemoveCommentFile: (filePath: string) => void;
|
|
handleRemoveComment: (commentId: string) => void;
|
|
handleRemovePRFeedback: (commentId: string) => void;
|
|
handleClearPRFeedback: () => void;
|
|
clearSessionPlanComments: () => void;
|
|
};
|
|
|
|
/** Re-focus chat input after dockview layout rebuild. */
|
|
function useRefocusChatAfterLayout() {
|
|
return useCallback(() => {
|
|
const unsub = useDockviewStore.subscribe((state) => {
|
|
if (!state.isRestoringLayout) {
|
|
unsub();
|
|
requestAnimationFrame(() => {
|
|
document.querySelector<HTMLElement>(".tiptap.ProseMirror")?.focus();
|
|
});
|
|
}
|
|
});
|
|
}, []);
|
|
}
|
|
|
|
type AutoApplyPlanLayoutOpts = {
|
|
resolvedSessionId: string | null;
|
|
taskId: string | null;
|
|
sessionMetaPlanMode: boolean;
|
|
currentStepHasPlanMode: boolean;
|
|
setActiveDocument: (sid: string, doc: ActiveDocument | null) => void;
|
|
applyBuiltInPreset: (preset: BuiltInPreset) => void;
|
|
setPlanMode: (sid: string, enabled: boolean) => void;
|
|
addContextFile: (sid: string, file: { path: string; name: string }) => void;
|
|
};
|
|
|
|
/** Auto-apply plan layout when session metadata has plan_mode enabled AND the current
|
|
* step actually configures enable_plan_mode. The step check guards against stale
|
|
* sessionMetaPlanMode from deepMerge hydration preserving deleted metadata keys. */
|
|
function useAutoApplyPlanLayout(opts: AutoApplyPlanLayoutOpts) {
|
|
const {
|
|
resolvedSessionId,
|
|
taskId,
|
|
sessionMetaPlanMode,
|
|
currentStepHasPlanMode,
|
|
setActiveDocument,
|
|
applyBuiltInPreset,
|
|
setPlanMode,
|
|
addContextFile,
|
|
} = opts;
|
|
useEffect(() => {
|
|
if (!resolvedSessionId || !taskId) return;
|
|
// Reset the guard when plan mode is disabled so future plan-mode steps
|
|
// in the same session can be auto-applied (e.g. after proceeding away and back).
|
|
if (!sessionMetaPlanMode && autoAppliedPlanSessions.has(resolvedSessionId)) {
|
|
autoAppliedPlanSessions.delete(resolvedSessionId);
|
|
return;
|
|
}
|
|
if (autoAppliedPlanSessions.has(resolvedSessionId)) return;
|
|
// Only auto-apply if both the session metadata AND the current step agree on plan mode.
|
|
// sessionMetaPlanMode can be stale (deepMerge hydration preserves deleted keys),
|
|
// so we cross-check with the step's actual configuration.
|
|
if (sessionMetaPlanMode && currentStepHasPlanMode) {
|
|
autoAppliedPlanSessions.add(resolvedSessionId);
|
|
setActiveDocument(resolvedSessionId, { type: "plan", taskId });
|
|
applyBuiltInPreset("plan");
|
|
setPlanMode(resolvedSessionId, true);
|
|
addContextFile(resolvedSessionId, { path: PLAN_CONTEXT_PATH, name: "Plan" });
|
|
}
|
|
}, [
|
|
resolvedSessionId,
|
|
taskId,
|
|
sessionMetaPlanMode,
|
|
currentStepHasPlanMode,
|
|
setActiveDocument,
|
|
applyBuiltInPreset,
|
|
setPlanMode,
|
|
addContextFile,
|
|
]);
|
|
}
|
|
|
|
export function usePlanMode(resolvedSessionId: string | null, taskId: string | null) {
|
|
const activeDocument = useAppStore((state) =>
|
|
resolvedSessionId
|
|
? (state.documentPanel.activeDocumentBySessionId[resolvedSessionId] ?? null)
|
|
: null,
|
|
);
|
|
const closeDocument = useLayoutStore((state) => state.closeDocument);
|
|
const setActiveDocument = useAppStore((state) => state.setActiveDocument);
|
|
const setPlanMode = useAppStore((state) => state.setPlanMode);
|
|
const addContextFile = useContextFilesStore((s) => s.addFile);
|
|
const removeContextFile = useContextFilesStore((s) => s.removeFile);
|
|
const applyBuiltInPreset = useDockviewStore((s) => s.applyBuiltInPreset);
|
|
|
|
const planModeFromStore = useAppStore((state) =>
|
|
resolvedSessionId ? (state.chatInput.planModeBySessionId[resolvedSessionId] ?? false) : false,
|
|
);
|
|
const sessionMetaPlanMode = useAppStore((state) =>
|
|
resolvedSessionId
|
|
? state.taskSessions.items[resolvedSessionId]?.metadata?.plan_mode === true
|
|
: false,
|
|
);
|
|
const currentStepHasPlanMode = useAppStore((s) => {
|
|
if (!taskId) return false;
|
|
const task = s.kanban.tasks.find((t) => t.id === taskId);
|
|
const stepId = task?.workflowStepId;
|
|
if (!stepId) return false;
|
|
const step = s.kanban.steps.find((st) => st.id === stepId);
|
|
return step?.events?.on_enter?.some((a) => a.type === "enable_plan_mode") ?? false;
|
|
});
|
|
const planModeEnabled = planModeFromStore;
|
|
const planLayoutVisible = activeDocument?.type === "plan";
|
|
|
|
useAutoApplyPlanLayout({
|
|
resolvedSessionId,
|
|
taskId,
|
|
sessionMetaPlanMode,
|
|
currentStepHasPlanMode,
|
|
setActiveDocument,
|
|
applyBuiltInPreset,
|
|
setPlanMode,
|
|
addContextFile,
|
|
});
|
|
|
|
useAutoDisablePlanMode({
|
|
resolvedSessionId,
|
|
taskId,
|
|
sessionMetaPlanMode,
|
|
planModeFromStore,
|
|
applyBuiltInPreset,
|
|
closeDocument,
|
|
setActiveDocument,
|
|
setPlanMode,
|
|
removeContextFile,
|
|
});
|
|
|
|
const refocusChatAfterLayout = useRefocusChatAfterLayout();
|
|
const { togglePlanLayout, handlePlanModeChange } = usePlanLayoutHandlers({
|
|
resolvedSessionId,
|
|
taskId,
|
|
setActiveDocument,
|
|
applyBuiltInPreset,
|
|
closeDocument,
|
|
setPlanMode,
|
|
addContextFile,
|
|
removeContextFile,
|
|
refocusChatAfterLayout,
|
|
});
|
|
|
|
return {
|
|
planModeEnabled,
|
|
planLayoutVisible,
|
|
activeDocument,
|
|
handlePlanModeChange,
|
|
togglePlanLayout,
|
|
};
|
|
}
|
|
|
|
export function useContextFiles(resolvedSessionId: string | null) {
|
|
const contextFiles = useContextFilesStore((s) =>
|
|
resolvedSessionId
|
|
? (s.filesBySessionId[resolvedSessionId] ?? EMPTY_CONTEXT_FILES)
|
|
: EMPTY_CONTEXT_FILES,
|
|
);
|
|
const hydrateContextFiles = useContextFilesStore((s) => s.hydrateSession);
|
|
const addContextFile = useContextFilesStore((s) => s.addFile);
|
|
const toggleContextFile = useContextFilesStore((s) => s.toggleFile);
|
|
const removeContextFile = useContextFilesStore((s) => s.removeFile);
|
|
const unpinFile = useContextFilesStore((s) => s.unpinFile);
|
|
const clearEphemeral = useContextFilesStore((s) => s.clearEphemeral);
|
|
|
|
useEffect(() => {
|
|
if (resolvedSessionId) hydrateContextFiles(resolvedSessionId);
|
|
}, [resolvedSessionId, hydrateContextFiles]);
|
|
|
|
const handleToggleContextFile = useCallback(
|
|
(file: { path: string; name: string; pinned?: boolean }) => {
|
|
if (resolvedSessionId) toggleContextFile(resolvedSessionId, file);
|
|
},
|
|
[resolvedSessionId, toggleContextFile],
|
|
);
|
|
|
|
const handleAddContextFile = useCallback(
|
|
(file: { path: string; name: string; pinned?: boolean }) => {
|
|
if (resolvedSessionId) addContextFile(resolvedSessionId, file);
|
|
},
|
|
[resolvedSessionId, addContextFile],
|
|
);
|
|
|
|
return {
|
|
contextFiles,
|
|
addContextFile,
|
|
removeContextFile,
|
|
unpinFile,
|
|
clearEphemeral,
|
|
handleToggleContextFile,
|
|
handleAddContextFile,
|
|
};
|
|
}
|
|
|
|
export function useCommentsState(resolvedSessionId: string | null): CommentsState {
|
|
const hydrateComments = useCommentsStore((state) => state.hydrateSession);
|
|
useEffect(() => {
|
|
if (resolvedSessionId) hydrateComments(resolvedSessionId);
|
|
}, [resolvedSessionId, hydrateComments]);
|
|
// Filter pending comments by sessionId to prevent cross-session leakage
|
|
const planComments = usePendingPlanComments(resolvedSessionId);
|
|
const pendingCommentsByFile = usePendingDiffCommentsByFile(resolvedSessionId);
|
|
const pendingPRFeedback = usePendingPRFeedback(resolvedSessionId);
|
|
const markCommentsSent = useCommentsStore((state) => state.markCommentsSent);
|
|
const removeComment = useCommentsStore((state) => state.removeComment);
|
|
const clearSessionPlanComments = useCallback(() => {
|
|
const state = useCommentsStore.getState();
|
|
const ids = resolvedSessionId ? state.bySession[resolvedSessionId] : undefined;
|
|
if (!ids) return;
|
|
for (const id of ids) {
|
|
const c = state.byId[id];
|
|
if (c && isPlanComment(c)) state.removeComment(id);
|
|
}
|
|
}, [resolvedSessionId]);
|
|
const handleRemoveCommentFile = useCallback(
|
|
(filePath: string) => {
|
|
const comments = pendingCommentsByFile[filePath] || [];
|
|
for (const comment of comments) removeComment(comment.id);
|
|
},
|
|
[pendingCommentsByFile, removeComment],
|
|
);
|
|
const handleRemoveComment = useCallback(
|
|
(commentId: string) => removeComment(commentId),
|
|
[removeComment],
|
|
);
|
|
const handleRemovePRFeedback = useCallback(
|
|
(commentId: string) => removeComment(commentId),
|
|
[removeComment],
|
|
);
|
|
const handleClearPRFeedback = useCallback(() => {
|
|
if (!resolvedSessionId) return;
|
|
const state = useCommentsStore.getState();
|
|
const allPending = [...state.pendingForChat];
|
|
for (const id of allPending) {
|
|
const c = state.byId[id];
|
|
// Only clear PR feedback for the current session
|
|
if (c && isPRFeedbackComment(c) && c.sessionId === resolvedSessionId) {
|
|
state.removeComment(id);
|
|
}
|
|
}
|
|
}, [resolvedSessionId]);
|
|
return {
|
|
planComments,
|
|
pendingCommentsByFile,
|
|
pendingPRFeedback,
|
|
markCommentsSent,
|
|
handleRemoveCommentFile,
|
|
handleRemoveComment,
|
|
handleRemovePRFeedback,
|
|
handleClearPRFeedback,
|
|
clearSessionPlanComments,
|
|
};
|
|
}
|
|
|
|
type ChatContextItemsOptions = {
|
|
planContextEnabled: boolean;
|
|
contextFiles: ContextFile[];
|
|
resolvedSessionId: string | null;
|
|
removeContextFile: (sid: string, path: string) => void;
|
|
unpinFile: (sid: string, path: string) => void;
|
|
comments: CommentsState;
|
|
taskId: string | null;
|
|
onOpenFile?: (path: string) => void;
|
|
onOpenFileAtLine?: (filePath: string) => void;
|
|
};
|
|
|
|
function useChatContextItems(opts: ChatContextItemsOptions) {
|
|
const {
|
|
planContextEnabled,
|
|
contextFiles,
|
|
resolvedSessionId,
|
|
removeContextFile,
|
|
unpinFile,
|
|
comments,
|
|
taskId,
|
|
onOpenFile,
|
|
onOpenFileAtLine,
|
|
} = opts;
|
|
const { addPlan } = usePanelActions();
|
|
const { prompts } = useCustomPrompts();
|
|
|
|
const promptsMap = useMemo(() => {
|
|
const map = new Map<string, { content: string }>();
|
|
for (const p of prompts) map.set(p.id, { content: p.content });
|
|
return map;
|
|
}, [prompts]);
|
|
|
|
const contextItems = useMemo<ContextItem[]>(
|
|
() =>
|
|
buildContextItems({
|
|
planContextEnabled,
|
|
contextFiles,
|
|
resolvedSessionId,
|
|
removeContextFile,
|
|
unpinFile,
|
|
addPlan,
|
|
promptsMap,
|
|
onOpenFile,
|
|
pendingCommentsByFile: comments.pendingCommentsByFile,
|
|
handleRemoveCommentFile: comments.handleRemoveCommentFile,
|
|
handleRemoveComment: comments.handleRemoveComment,
|
|
onOpenFileAtLine,
|
|
planComments: comments.planComments,
|
|
handleClearPlanComments: comments.clearSessionPlanComments,
|
|
pendingPRFeedback: comments.pendingPRFeedback,
|
|
handleRemovePRFeedback: comments.handleRemovePRFeedback,
|
|
handleClearPRFeedback: comments.handleClearPRFeedback,
|
|
taskId,
|
|
}),
|
|
[
|
|
planContextEnabled,
|
|
contextFiles,
|
|
resolvedSessionId,
|
|
removeContextFile,
|
|
unpinFile,
|
|
addPlan,
|
|
promptsMap,
|
|
onOpenFile,
|
|
comments.pendingCommentsByFile,
|
|
comments.handleRemoveCommentFile,
|
|
comments.handleRemoveComment,
|
|
onOpenFileAtLine,
|
|
comments.planComments,
|
|
comments.clearSessionPlanComments,
|
|
comments.pendingPRFeedback,
|
|
comments.handleRemovePRFeedback,
|
|
comments.handleClearPRFeedback,
|
|
taskId,
|
|
],
|
|
);
|
|
|
|
return { contextItems, prompts };
|
|
}
|
|
|
|
function useSessionData(
|
|
resolvedSessionId: string | null,
|
|
session: ReturnType<typeof useSessionState>["session"],
|
|
taskId: string | null,
|
|
taskDescription: string | null,
|
|
) {
|
|
const { messages, isLoading: messagesLoading } = useSessionMessages(resolvedSessionId);
|
|
const processed = useProcessedMessages(messages, taskId, resolvedSessionId, taskDescription);
|
|
const { sessionModel, activeModel } = useSessionModel(
|
|
resolvedSessionId,
|
|
session?.agent_profile_id,
|
|
);
|
|
const chatSubmitKey = useAppStore((state) => state.userSettings.chatSubmitKey);
|
|
const agentCommands = useAppStore((state) =>
|
|
resolvedSessionId ? state.availableCommands.bySessionId[resolvedSessionId] : undefined,
|
|
);
|
|
const {
|
|
clearAll: clearQueue,
|
|
editEntry: editQueueEntry,
|
|
removeEntry: removeQueueEntry,
|
|
...queueRest
|
|
} = useQueue(resolvedSessionId);
|
|
return {
|
|
messages,
|
|
messagesLoading,
|
|
...processed,
|
|
sessionModel,
|
|
activeModel,
|
|
chatSubmitKey,
|
|
agentCommands,
|
|
clearQueue,
|
|
editQueueEntry,
|
|
removeQueueEntry,
|
|
...queueRest,
|
|
};
|
|
}
|
|
|
|
type TodoStatus = "pending" | "in_progress" | "completed" | "failed";
|
|
|
|
function useSessionTodoItems(
|
|
resolvedSessionId: string | null,
|
|
messageTodos: Array<{ text: string; done?: boolean }>,
|
|
) {
|
|
const storeTodos = useAppStore((s) =>
|
|
resolvedSessionId ? s.sessionTodos.bySessionId[resolvedSessionId] : undefined,
|
|
);
|
|
return useMemo(() => {
|
|
if (storeTodos && storeTodos.length > 0) {
|
|
return storeTodos.map((e: { description: string; status: string }) => ({
|
|
text: e.description,
|
|
done: e.status === "completed",
|
|
status: e.status as TodoStatus,
|
|
}));
|
|
}
|
|
return messageTodos;
|
|
}, [storeTodos, messageTodos]);
|
|
}
|
|
|
|
export type UseChatPanelStateOptions = {
|
|
sessionId: string | null;
|
|
onOpenFile?: (path: string) => void;
|
|
onOpenFileAtLine?: (filePath: string) => void;
|
|
};
|
|
|
|
export function useChatPanelState({
|
|
sessionId,
|
|
onOpenFile,
|
|
onOpenFileAtLine,
|
|
}: UseChatPanelStateOptions) {
|
|
const sessionState = useSessionState(sessionId);
|
|
const { resolvedSessionId, taskId } = sessionState;
|
|
const planMode = usePlanMode(resolvedSessionId, taskId);
|
|
const { supportsMcp, mcpServers } = useSessionMcp(sessionState.session?.agent_profile_id);
|
|
const planModeAvailable = supportsMcp;
|
|
|
|
// When MCP is available: full plan mode toggle (layout + chat input state)
|
|
// When MCP is unavailable: layout-only toggle (plan panel visible, but no plan context/border)
|
|
const {
|
|
handlePlanModeChange: rawHandlePlanModeChange,
|
|
togglePlanLayout,
|
|
planModeEnabled,
|
|
planLayoutVisible,
|
|
} = planMode;
|
|
const guardedHandlePlanModeChange = useCallback(
|
|
(enabled: boolean) => {
|
|
if (planModeAvailable) {
|
|
rawHandlePlanModeChange(enabled);
|
|
} else {
|
|
// Toggle based on current layout state, ignoring the passed value
|
|
togglePlanLayout(!planLayoutVisible);
|
|
}
|
|
},
|
|
[planModeAvailable, rawHandlePlanModeChange, togglePlanLayout, planLayoutVisible],
|
|
);
|
|
|
|
// Auto-disable plan mode if agent doesn't support MCP (e.g. started from create dialog).
|
|
// Only clear state — do NOT call applyBuiltInPreset("default") because the layout
|
|
// may have just been set via URL intent (?layout=plan) and we don't want to overwrite it.
|
|
const hasAgentProfile = Boolean(sessionState.session?.agent_profile_id);
|
|
const setPlanMode = useAppStore((s) => s.setPlanMode);
|
|
const removeCtxFile = useContextFilesStore((s) => s.removeFile);
|
|
const hasAutoDisabled = useRef(false);
|
|
useEffect(() => {
|
|
if (
|
|
planModeEnabled &&
|
|
hasAgentProfile &&
|
|
!planModeAvailable &&
|
|
resolvedSessionId &&
|
|
!hasAutoDisabled.current
|
|
) {
|
|
hasAutoDisabled.current = true;
|
|
setPlanMode(resolvedSessionId, false);
|
|
removeCtxFile(resolvedSessionId, PLAN_CONTEXT_PATH);
|
|
}
|
|
if (!planModeEnabled) hasAutoDisabled.current = false;
|
|
}, [
|
|
planModeEnabled,
|
|
hasAgentProfile,
|
|
planModeAvailable,
|
|
resolvedSessionId,
|
|
setPlanMode,
|
|
removeCtxFile,
|
|
]);
|
|
|
|
const contextFilesState = useContextFiles(resolvedSessionId);
|
|
const { contextFiles, removeContextFile, unpinFile } = contextFilesState;
|
|
const sessionData = useSessionData(
|
|
resolvedSessionId,
|
|
sessionState.session,
|
|
taskId,
|
|
sessionState.taskDescription,
|
|
);
|
|
const comments = useCommentsState(resolvedSessionId);
|
|
|
|
const planContextEnabled = useMemo(
|
|
() => contextFiles.some((f) => f.path === PLAN_CONTEXT_PATH),
|
|
[contextFiles],
|
|
);
|
|
|
|
const { contextItems, prompts } = useChatContextItems({
|
|
planContextEnabled,
|
|
contextFiles,
|
|
resolvedSessionId,
|
|
removeContextFile,
|
|
unpinFile,
|
|
comments,
|
|
taskId,
|
|
onOpenFile,
|
|
onOpenFileAtLine,
|
|
});
|
|
|
|
const todoItems = useSessionTodoItems(resolvedSessionId, sessionData.todoItems);
|
|
|
|
return {
|
|
...sessionState,
|
|
...planMode,
|
|
handlePlanModeChange: guardedHandlePlanModeChange,
|
|
...contextFilesState,
|
|
...sessionData,
|
|
...comments,
|
|
contextItems,
|
|
planContextEnabled,
|
|
planModeAvailable,
|
|
mcpServers,
|
|
prompts,
|
|
todoItems,
|
|
};
|
|
}
|