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
8 changes: 7 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,10 @@ AGENTS.md
.envrc

# Session store used for example-client.ts
.session-store.json
.session-store.json

# PostHog task artifacts (generated per-task)
.posthog/

# Test media files
sounds/
2 changes: 1 addition & 1 deletion apps/array/src/main/services/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ export class SessionManager {
await connection.newSession({
cwd: repoPath,
mcpServers,
_meta: { sessionId: taskRunId },
_meta: { sessionId: taskRunId, taskId },
});
}

Expand Down
126 changes: 99 additions & 27 deletions apps/array/src/renderer/features/editor/components/PlanEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,48 @@
import { RichTextEditor } from "@features/editor/components/RichTextEditor";
import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore";
import { FloppyDiskIcon } from "@phosphor-icons/react";
import { Box, Button, TextArea } from "@radix-ui/themes";
import { logger } from "@renderer/lib/logger";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";

const log = logger.scope("plan-editor");

// Hook to watch for external file changes
function usePlanFileWatcher(
repoPath: string | undefined,
taskId: string,
fileName: string,
onFileChanged: () => void,
) {
const onFileChangedRef = useRef(onFileChanged);
onFileChangedRef.current = onFileChanged;

useEffect(() => {
if (!repoPath || !window.electronAPI?.onFileChanged) return;

// Build the expected path for the plan file
const expectedPath = `${repoPath}/.posthog/${taskId}/${fileName}`;

log.debug("Watching for changes to:", expectedPath);

const unsubscribe = window.electronAPI.onFileChanged(
({ repoPath: eventRepoPath, filePath }) => {
// Only process events for our repo
if (eventRepoPath !== repoPath) return;

// Check if the changed file is our plan file
if (filePath === expectedPath) {
log.debug("Plan file changed externally:", filePath);
onFileChangedRef.current();
}
},
);

return unsubscribe;
}, [repoPath, taskId, fileName]);
}

interface PlanEditorProps {
taskId: string;
repoPath: string;
Expand All @@ -29,12 +63,12 @@ export function PlanEditor({
const [content, setContent] = useState(initialContent || "");
const [isSaving, setIsSaving] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [hasInitialized, setHasInitialized] = useState(!!initialContent);
const savedContentRef = useRef<string>(initialContent || "");
const updateTabMetadata = usePanelLayoutStore(
(state) => state.updateTabMetadata,
);

const isMarkdownFile = fileName.endsWith(".md");

const queryClient = useQueryClient();
const { data: fetchedContent } = useQuery({
queryKey: ["task-file", repoPath, taskId, fileName],
Expand All @@ -56,11 +90,49 @@ export function PlanEditor({
},
});

// Initialize content from fetched data only once
useEffect(() => {
if (!initialContent && fetchedContent && content === "") {
if (!hasInitialized && fetchedContent !== undefined) {
setContent(fetchedContent);
savedContentRef.current = fetchedContent;
setHasInitialized(true);
}
}, [fetchedContent, initialContent, content]);
}, [fetchedContent, hasInitialized]);

// Handle external file changes
const handleExternalFileChange = useCallback(async () => {
// Refetch the file content
try {
let newContent: string | null = null;
if (fileName === "plan.md") {
newContent = await window.electronAPI?.readPlanFile(repoPath, taskId);
} else {
newContent = await window.electronAPI?.readTaskArtifact(
repoPath,
taskId,
fileName,
);
}

if (newContent !== null && newContent !== savedContentRef.current) {
// Only update if the file content actually changed from what we last saved/loaded
setContent(newContent);
savedContentRef.current = newContent;
// Also reset unsaved changes since we just loaded fresh content
setHasUnsavedChanges(false);
queryClient.setQueryData(
["task-file", repoPath, taskId, fileName],
newContent,
);
log.debug("Reloaded plan content from external change");
}
} catch (error) {
log.error("Failed to reload file after external change:", error);
}
}, [repoPath, taskId, fileName, queryClient]);

// Watch for external file changes
usePlanFileWatcher(repoPath, taskId, fileName, handleExternalFileChange);

const handleSave = useCallback(
async (contentToSave: string) => {
Expand All @@ -80,6 +152,7 @@ export function PlanEditor({
["task-file", repoPath, taskId, fileName],
contentToSave,
);
savedContentRef.current = contentToSave;
setHasUnsavedChanges(false);
} catch (error) {
log.error("Failed to save file:", error);
Expand All @@ -94,10 +167,10 @@ export function PlanEditor({
handleSave(content);
}, [content, handleSave]);

// Track unsaved changes
// Track unsaved changes by comparing to last saved content
useEffect(() => {
setHasUnsavedChanges(content !== fetchedContent);
}, [content, fetchedContent]);
setHasUnsavedChanges(content !== savedContentRef.current);
}, [content]);

// Update tab metadata when unsaved changes state changes
useEffect(() => {
Expand All @@ -115,7 +188,7 @@ export function PlanEditor({
handleManualSave();
}
},
{ enableOnFormTags: ["INPUT", "TEXTAREA"] },
{ enableOnFormTags: ["INPUT", "TEXTAREA"], enableOnContentEditable: true },
[hasUnsavedChanges, isSaving, handleManualSave],
);

Expand All @@ -134,23 +207,22 @@ export function PlanEditor({
overflow: "hidden",
}}
>
{isMarkdownFile ? (
<RichTextEditor
value={content}
onChange={setContent}
repoPath={repoPath}
placeholder="Your implementation plan will appear here..."
showToolbar={true}
minHeight="100%"
/>
) : (
<TextArea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="File content will appear here..."
className="min-h-full flex-1 resize-none rounded-none border-none bg-transparent font-mono text-sm shadow-none outline-none"
/>
)}
<TextArea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Your implementation plan will appear here..."
style={{
height: "100%",
width: "100%",
resize: "none",
border: "none",
outline: "none",
fontFamily: "monospace",
fontSize: "13px",
padding: "16px",
backgroundColor: "transparent",
}}
/>
</Box>

{/* Floating Save Button */}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -467,7 +467,9 @@ export function RichTextEditor({
}}
>
{showToolbar && editor && <FormattingToolbar editor={editor} />}
<EditorContent editor={editor} />
<Box style={{ flex: 1, overflow: "auto" }}>
<EditorContent editor={editor} />
</Box>
<style>
{`
.rich-text-file-path {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,5 @@ export const DEFAULT_TAB_IDS = {
TODO_LIST: "todo-list",
ARTIFACTS: "artifacts",
CHANGES: "changes",
PLAN: "plan",
} as const;
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export interface PanelLayoutStore {
openFile: (taskId: string, filePath: string) => void;
openArtifact: (taskId: string, fileName: string) => void;
openDiff: (taskId: string, filePath: string, status?: string) => void;
openPlan: (taskId: string) => void;
closeTab: (taskId: string, panelId: string, tabId: string) => void;
closeOtherTabs: (taskId: string, panelId: string, tabId: string) => void;
closeTabsToRight: (taskId: string, panelId: string, tabId: string) => void;
Expand Down Expand Up @@ -117,6 +118,14 @@ function createDefaultPanelTree(
closeable: false,
draggable: true,
},
{
id: DEFAULT_TAB_IDS.PLAN,
label: "Plan",
data: { type: "plan" },
component: null,
closeable: true,
draggable: true,
},
],
activeTabId: DEFAULT_TAB_IDS.LOGS,
showTabs: true,
Expand Down Expand Up @@ -172,6 +181,14 @@ function createDefaultPanelTree(
closeable: false,
draggable: true,
},
{
id: DEFAULT_TAB_IDS.PLAN,
label: "Plan",
data: { type: "plan" },
component: null,
closeable: true,
draggable: true,
},
{
id: DEFAULT_TAB_IDS.SHELL,
label: "Terminal",
Expand Down Expand Up @@ -276,6 +293,10 @@ export const usePanelLayoutStore = createWithEqualityFn<PanelLayoutStore>()(
set((state) => openTab(state, taskId, tabId));
},

openPlan: (taskId) => {
set((state) => openTab(state, taskId, DEFAULT_TAB_IDS.PLAN));
},

closeTab: (taskId, panelId, tabId) => {
set((state) =>
updateTaskLayout(state, taskId, (layout) => {
Expand Down Expand Up @@ -740,8 +761,70 @@ export const usePanelLayoutStore = createWithEqualityFn<PanelLayoutStore>()(
{
name: "panel-layout-store",
// Bump this version when the default panel structure changes to reset all layouts
version: 8,
migrate: () => ({ taskLayouts: {} }),
version: 9,
migrate: (persistedState, version) => {
const state = persistedState as {
taskLayouts: Record<string, PanelNode>;
};

// Migration from version 8 to 9: Add Plan tab to existing layouts
if (version === 8 && state.taskLayouts) {
const planTab: Tab = {
id: DEFAULT_TAB_IDS.PLAN,
label: "Plan",
data: { type: "plan" },
component: null,
closeable: true,
draggable: true,
};

// Recursively add Plan tab to panels that have the Logs tab
const addPlanTabToNode = (node: PanelNode): PanelNode => {
if (node.type === "leaf") {
const hasLogsTab = node.content.tabs.some(
(tab) => tab.id === DEFAULT_TAB_IDS.LOGS,
);
const hasPlanTab = node.content.tabs.some(
(tab) => tab.id === DEFAULT_TAB_IDS.PLAN,
);

if (hasLogsTab && !hasPlanTab) {
// Find the index of the logs tab and insert plan tab after it
const logsIndex = node.content.tabs.findIndex(
(tab) => tab.id === DEFAULT_TAB_IDS.LOGS,
);
const newTabs = [...node.content.tabs];
newTabs.splice(logsIndex + 1, 0, planTab);

return {
...node,
content: {
...node.content,
tabs: newTabs,
},
};
}
return node;
}

// Recursively process group children
return {
...node,
children: node.children.map(addPlanTabToNode),
};
};

const migratedLayouts: Record<string, PanelNode> = {};
for (const [taskId, layout] of Object.entries(state.taskLayouts)) {
migratedLayouts[taskId] = addPlanTabToNode(layout);
}

return { taskLayouts: migratedLayouts };
}

// For any other version, reset layouts
return { taskLayouts: {} };
},
},
),
);
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ export function createTabLabel(tabId: string): string {
const label = getStatusLabel(parsed.status);
return `${fileName} (${label})`;
}
if (tabId === "plan") {
return "Plan";
}
return parsed.value;
}

Expand Down Expand Up @@ -189,6 +192,8 @@ export function createNewTab(tabId: string, closeable = true): Tab {
case "system":
if (tabId === "logs") {
data = { type: "logs" };
} else if (tabId === "plan") {
data = { type: "plan" };
} else if (tabId.startsWith("shell")) {
data = {
type: "terminal",
Expand Down
3 changes: 3 additions & 0 deletions apps/array/src/renderer/features/panels/store/panelTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ export type TabData =
| {
type: "logs";
}
| {
type: "plan";
}
| {
type: "other";
// Generic tab without specific data
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,18 @@ function parseSessionNotification(
case "user_message_chunk":
case "agent_message_chunk": {
if (update.content.type === "text") {
const content = update.content.text;
// Filter out injected system reminders from display
if (
update.sessionUpdate === "user_message_chunk" &&
content.startsWith("[System reminder:")
) {
return null; // Skip the system reminder block entirely
}
return {
type:
update.sessionUpdate === "user_message_chunk" ? "user" : "agent",
content: update.content.text,
content,
};
}
return null;
Expand Down
Loading