{message}
diff --git a/apps/web/components/task/chat/use-chat-panel-state.ts b/apps/web/components/task/chat/use-chat-panel-state.ts
index bf59c9281d..1e30285246 100644
--- a/apps/web/components/task/chat/use-chat-panel-state.ts
+++ b/apps/web/components/task/chat/use-chat-panel-state.ts
@@ -24,6 +24,8 @@ import { buildContextItems } from "../chat-context-items";
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";
@@ -40,6 +42,63 @@ export type CommentsState = {
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
(".tiptap.ProseMirror")?.focus();
+ });
+ }
+ });
+ }, []);
+}
+
+type AutoApplyPlanLayoutOpts = {
+ resolvedSessionId: string | null;
+ taskId: string | null;
+ sessionMetaPlanMode: 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. */
+function useAutoApplyPlanLayout(opts: AutoApplyPlanLayoutOpts) {
+ const {
+ resolvedSessionId,
+ taskId,
+ sessionMetaPlanMode,
+ setActiveDocument,
+ applyBuiltInPreset,
+ setPlanMode,
+ addContextFile,
+ } = opts;
+ const autoAppliedPlanSessionRef = useRef(null);
+ useEffect(() => {
+ if (!resolvedSessionId || !taskId) return;
+ if (autoAppliedPlanSessionRef.current === resolvedSessionId) return;
+ if (sessionMetaPlanMode) {
+ autoAppliedPlanSessionRef.current = resolvedSessionId;
+ setActiveDocument(resolvedSessionId, { type: "plan", taskId });
+ applyBuiltInPreset("plan");
+ setPlanMode(resolvedSessionId, true);
+ addContextFile(resolvedSessionId, { path: PLAN_CONTEXT_PATH, name: "Plan" });
+ }
+ }, [
+ resolvedSessionId,
+ taskId,
+ sessionMetaPlanMode,
+ setActiveDocument,
+ applyBuiltInPreset,
+ setPlanMode,
+ addContextFile,
+ ]);
+}
+
export function usePlanMode(resolvedSessionId: string | null, taskId: string | null) {
const activeDocument = useAppStore((state) =>
resolvedSessionId
@@ -57,6 +116,12 @@ export function usePlanMode(resolvedSessionId: string | null, taskId: string | n
resolvedSessionId ? (state.chatInput.planModeBySessionId[resolvedSessionId] ?? false) : false,
);
+ const sessionMetaPlanMode = useAppStore((state) =>
+ resolvedSessionId
+ ? state.taskSessions.items[resolvedSessionId]?.metadata?.plan_mode === true
+ : false,
+ );
+
const planModeEnabled = planModeFromStore;
const planLayoutVisible = activeDocument?.type === "plan";
@@ -69,18 +134,17 @@ export function usePlanMode(resolvedSessionId: string | null, taskId: string | n
}
}, [resolvedSessionId, setPlanMode, addContextFile]);
- // Re-focus chat input after dockview layout rebuild.
- // Scroll preservation is handled by the store layout actions (before DOM rebuild).
- const refocusChatAfterLayout = useCallback(() => {
- const unsub = useDockviewStore.subscribe((state) => {
- if (!state.isRestoringLayout) {
- unsub();
- requestAnimationFrame(() => {
- document.querySelector(".tiptap.ProseMirror")?.focus();
- });
- }
- });
- }, []);
+ useAutoApplyPlanLayout({
+ resolvedSessionId,
+ taskId,
+ sessionMetaPlanMode,
+ setActiveDocument,
+ applyBuiltInPreset,
+ setPlanMode,
+ addContextFile,
+ });
+
+ const refocusChatAfterLayout = useRefocusChatAfterLayout();
// Toggle only the plan layout (plan panel + preset) without changing chat input plan mode state
const togglePlanLayout = useCallback(
diff --git a/apps/web/components/task/chat/use-tiptap-editor.ts b/apps/web/components/task/chat/use-tiptap-editor.ts
index 75d18ab1fd..982745508c 100644
--- a/apps/web/components/task/chat/use-tiptap-editor.ts
+++ b/apps/web/components/task/chat/use-tiptap-editor.ts
@@ -7,11 +7,13 @@ import Paragraph from "@tiptap/extension-paragraph";
import Text from "@tiptap/extension-text";
import HardBreak from "@tiptap/extension-hard-break";
import History from "@tiptap/extension-history";
-import Placeholder from "@tiptap/extension-placeholder";
+import { Plugin, PluginKey } from "@tiptap/pm/state";
+import { Node as ProseMirrorNode } from "@tiptap/pm/model";
+import { Decoration, DecorationSet } from "@tiptap/pm/view";
+import { Extension, isNodeEmpty } from "@tiptap/core";
import Code from "@tiptap/extension-code";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import { common, createLowlight } from "lowlight";
-import { Extension } from "@tiptap/core";
import { cn } from "@/lib/utils";
import { getChatDraftContent, setChatDraftContent } from "@/lib/local-storage";
import { getMarkdownText, textToHtml, handleEditorPaste } from "./tiptap-helpers";
@@ -33,6 +35,65 @@ export type TipTapInputHandle = {
const lowlightInstance = createLowlight(common);
+/**
+ * Custom Placeholder extension that reads the current placeholder from
+ * editor.storage instead of from the captured options object. TipTap's
+ * extension.options is a getter returning a fresh object each call, so the
+ * built-in Placeholder plugin's closure captures a stale options snapshot.
+ * This wrapper uses editor.storage.dynamicPlaceholder.text as the live source.
+ */
+const DynamicPlaceholder = Extension.create({
+ name: "dynamicPlaceholder",
+
+ addStorage() {
+ return { text: "" };
+ },
+
+ addProseMirrorPlugins() {
+ const editor = this.editor;
+ return [
+ new Plugin({
+ key: new PluginKey("dynamicPlaceholder"),
+ props: {
+ decorations: ({ doc, selection }) => {
+ if (!editor.isEditable) return null;
+ const { anchor } = selection;
+ const decorations: InstanceType[] = [];
+ const isEmptyDoc = editor.isEmpty;
+ doc.descendants((node: ProseMirrorNode, pos: number) => {
+ const hasAnchor = anchor >= pos && anchor <= pos + node.nodeSize;
+ const isEmpty = !node.isLeaf && isNodeEmpty(node);
+ if (hasAnchor && isEmpty) {
+ const classes = ["is-empty"];
+ if (isEmptyDoc) classes.push("is-editor-empty");
+ decorations.push(
+ Decoration.node(pos, pos + node.nodeSize, {
+ class: classes.join(" "),
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ "data-placeholder": (editor.storage as any).dynamicPlaceholder.text,
+ }),
+ );
+ }
+ return false;
+ });
+ return DecorationSet.create(doc, decorations);
+ },
+ },
+ }),
+ ];
+ },
+});
+
+/** Update the dynamic placeholder text and trigger a redecoration. Extracted to a
+ * standalone function so that the eslint react-hooks/immutability rule does not
+ * flag it as a mutation of the `editor` hook argument. */
+function updateDynamicPlaceholder(ed: ReturnType, text: string) {
+ if (!ed) return;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (ed.storage as any).dynamicPlaceholder.text = text;
+ ed.view.dispatch(ed.state.tr);
+}
+
type UseTipTapEditorOptions = {
value: string;
onChange: (value: string) => void;
@@ -124,7 +185,7 @@ export function useTipTapEditor(opts: UseTipTapEditorOptions) {
return ReactNodeViewRenderer(CodeBlockView);
},
}).configure({ lowlight: lowlightInstance }),
- Placeholder.configure({ placeholder }),
+ DynamicPlaceholder,
ContextMention.configure({ suggestions: [mentionSuggestion, slashSuggestion] }),
SubmitKeymap,
],
@@ -203,15 +264,11 @@ function useSyncEditor({
if (editor) editor.setEditable(!disabled);
}, [editor, disabled]);
- // Sync placeholder
+ // Sync placeholder via editor.storage. The DynamicPlaceholder extension reads
+ // from editor.storage.dynamicPlaceholder.text at decoration time.
useEffect(() => {
if (!editor) return;
- editor.extensionManager.extensions.forEach((ext) => {
- if (ext.name === "placeholder") {
- ext.options.placeholder = placeholder;
- editor.view.dispatch(editor.state.tr);
- }
- });
+ updateDynamicPlaceholder(editor, placeholder);
}, [editor, placeholder]);
// Reset sync flag when session changes
diff --git a/apps/web/components/task/files-panel.tsx b/apps/web/components/task/files-panel.tsx
index b5e7b73988..404b217ebc 100644
--- a/apps/web/components/task/files-panel.tsx
+++ b/apps/web/components/task/files-panel.tsx
@@ -43,7 +43,7 @@ const FilesPanel = memo(function FilesPanel({ onOpenFile }: FilesPanelProps) {
if (isArchived) return ;
return (
-
+
{activeSessionId ? (
& {
children: ReactNode;
className?: string;
};
/** Fills the dockview content slot. Use as the outermost element in every panel. */
-export function PanelRoot({ children, className }: PanelRootProps) {
- return {children}
;
+export function PanelRoot({ children, className, ...rest }: PanelRootProps) {
+ return (
+
+ {children}
+
+ );
}
type PanelBodyProps = Omit, "className"> & {
diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx
index 14a0089541..fee2cb810f 100644
--- a/apps/web/components/task/task-chat-panel.tsx
+++ b/apps/web/components/task/task-chat-panel.tsx
@@ -109,7 +109,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({
const handleClarificationResolved = useCallback(() => setClarificationKey((k) => k + 1), []);
return (
-
+
+ effectiveSessionId
+ ? (state.taskSessions.items[effectiveSessionId]?.workflow_step_id ?? null)
+ : null,
+ );
const previewOpen = useAppStore((state) =>
effectiveSessionId ? (state.previewPanel.openBySessionId[effectiveSessionId] ?? false) : false,
);
@@ -141,6 +146,7 @@ function useSessionPanelState(effectiveSessionId: string | null | undefined) {
return {
storeSessionState,
isSessionPassthrough,
+ sessionWorkflowStepId,
previewOpen,
previewStage,
previewUrl,
@@ -224,6 +230,17 @@ function resolveRemoteExecutor(status?: RemoteExecutorStatus | null) {
};
}
+/** Pick the best step ID. Both session-level (from session.state_changed WS event) and
+ * task-level (from task.updated WS event + kanban snapshot) carry the same value, but
+ * session.state_changed is delivered directly while task.updated goes through the hub's
+ * broadcast channel, so the session value arrives first and is less likely to be stale. */
+function resolveCurrentStepId(
+ sessionStepId: string | null,
+ taskStepId: string | null,
+): string | null {
+ return sessionStepId || taskStepId || null;
+}
+
function buildTaskTopBarProps(params: {
taskProps: ReturnType;
agent: ReturnType;
@@ -233,6 +250,7 @@ function buildTaskTopBarProps(params: {
onToggleDebugOverlay: () => void;
effectiveSessionId: string | null;
remote: ReturnType;
+ sessionWorkflowStepId: string | null;
}) {
const { taskProps, agent, merged, workflowSteps, showDebugOverlay, onToggleDebugOverlay } =
params;
@@ -252,7 +270,7 @@ function buildTaskTopBarProps(params: {
showDebugOverlay,
onToggleDebugOverlay,
workflowSteps,
- currentStepId: taskProps.workflowStepId,
+ currentStepId: resolveCurrentStepId(params.sessionWorkflowStepId, taskProps.workflowStepId),
workflowId: taskProps.workflowId,
isArchived: taskProps.isArchived,
isRemoteExecutor: params.remote.isRemoteExecutor,
@@ -368,6 +386,7 @@ function TaskPageInner({
onToggleDebugOverlay,
effectiveSessionId,
remote,
+ sessionWorkflowStepId: sessionPanel.sessionWorkflowStepId,
});
const layoutProps = buildTaskLayoutProps({
taskProps,
diff --git a/apps/web/components/task/task-plan-panel.tsx b/apps/web/components/task/task-plan-panel.tsx
index 2d3a91de49..215696e3d2 100644
--- a/apps/web/components/task/task-plan-panel.tsx
+++ b/apps/web/components/task/task-plan-panel.tsx
@@ -159,7 +159,7 @@ export const TaskPlanPanel = memo(function TaskPlanPanel({
const { textSelection, setTextSelection } = selectionState;
return (
-
+
+
+
{icon && }
{label}
diff --git a/apps/web/components/task/terminal-panel.tsx b/apps/web/components/task/terminal-panel.tsx
index 303dca9e36..541c80167e 100644
--- a/apps/web/components/task/terminal-panel.tsx
+++ b/apps/web/components/task/terminal-panel.tsx
@@ -36,7 +36,7 @@ export const TerminalPanel = memo(function TerminalPanel({ params }: TerminalPan
if (type === "dev-server" && processId) {
return (
-
+
@@ -45,7 +45,7 @@ export const TerminalPanel = memo(function TerminalPanel({ params }: TerminalPan
}
return (
-
+
+
{sortedSteps.map((step, index) => (
{
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ try {
+ const res = await fetch(url);
+ if (res.ok) return;
+ } catch {
+ // not ready yet
+ }
+ await new Promise((r) => setTimeout(r, HEALTH_POLL_MS));
+ }
+ throw new Error(`Service did not become healthy at ${url} within ${timeoutMs}ms`);
+}
+
+function killProcess(proc: ChildProcess): Promise
{
+ return new Promise((resolve) => {
+ proc.kill("SIGTERM");
+ const timeout = setTimeout(() => {
+ proc.kill("SIGKILL");
+ resolve();
+ }, 5_000);
+ proc.on("exit", () => {
+ clearTimeout(timeout);
+ resolve();
+ });
+ });
+}
+
+/**
+ * Worker-scoped fixture that spawns an isolated backend process and
+ * a dedicated Next.js frontend. Each Playwright worker gets its own
+ * backend on a unique port with an isolated HOME, database, and data
+ * directory, plus its own frontend with SSR routed to that backend.
+ */
+export const backendFixture = base.extend