Skip to content
Merged
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
4 changes: 4 additions & 0 deletions apps/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ Notes:
- `bun run dev:desktop:tauri` is the recommended way to start Tauri in dev (it starts the watcher + `cargo tauri dev`).
- `bun run dev:desktop:tauri:oneshot` runs a one-time frontend build then starts Tauri (no watch, single process after startup).

## Plans

- The "New Plan" guided flow detects both newly created and updated plan files in `plans/` using the `modifiedMs` timestamps returned by `/api/plans/list`.

## Packs + Guidance Updates

Forge Desktop can download non-executable "packs" (starting with `forge-guidance-pack`) from GitHub Releases and install them into a selected project directory.
Expand Down
16 changes: 10 additions & 6 deletions apps/desktop/src-tauri/src/http_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -826,6 +826,7 @@ struct PlansListQuery {
struct PlanFileEntry {
filename: String,
path: String,
modified_ms: u64,
}

fn list_plans(project_root: &str) -> Result<Vec<PlanFileEntry>, String> {
Expand All @@ -836,7 +837,7 @@ fn list_plans(project_root: &str) -> Result<Vec<PlanFileEntry>, String> {

struct PlanWithTime {
entry: PlanFileEntry,
modified: std::time::Duration,
modified_ms: u64,
}

let mut entries: Vec<PlanWithTime> = Vec::new();
Expand All @@ -858,25 +859,27 @@ fn list_plans(project_root: &str) -> Result<Vec<PlanFileEntry>, String> {
None => continue,
};

let modified = std::fs::metadata(&path)
let modified_ms = std::fs::metadata(&path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.unwrap_or(std::time::Duration::from_secs(0));
.map(|d| d.as_millis() as u64)
.unwrap_or(0);

entries.push(PlanWithTime {
entry: PlanFileEntry {
filename,
path: path.to_string_lossy().to_string(),
modified_ms,
},
modified,
modified_ms,
});
}

// Newest first, stable tie-breaker by filename.
entries.sort_by(|a, b| {
b.modified
.cmp(&a.modified)
b.modified_ms
.cmp(&a.modified_ms)
.then_with(|| a.entry.filename.cmp(&b.entry.filename))
});

Expand Down Expand Up @@ -921,6 +924,7 @@ mod plans_list_tests {
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].filename, "b.json");
assert_eq!(entries[1].filename, "a.json");
assert!(entries[0].modified_ms > entries[1].modified_ms);

let _ = fs::remove_dir_all(&root);
}
Expand Down
22 changes: 13 additions & 9 deletions apps/desktop/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ import { computed, onMounted, onUnmounted, reactive, ref, watch } from "vue";
import CreateProjectDialog from "./components/CreateProjectDialog.vue";
import NewPlanDialog from "./components/NewPlanDialog.vue";
import LiveOutputPane from "./components/LiveOutputPane.vue";
import { mergeDiscoveredPlans } from "./lib/mergeDiscoveredPlans.js";
import {
getCwd,
getEvidence,
Expand Down Expand Up @@ -453,6 +454,7 @@ function appendLiveOutput(stream: LiveOutputSegment["stream"], text: string): vo
type DiscoveredPlan = {
filename: string;
path: string;
modifiedMs: number;
valid: boolean | null;
validating: boolean;
taskStatuses: TaskStatus[];
Expand Down Expand Up @@ -495,29 +497,31 @@ onMounted(async () => {
async function pollPlans(): Promise<void> {
try {
const entries: PlanFileEntry[] = await plansList(projectRoot.value);
const existing = new Map(discoveredPlans.value.map((p) => [p.filename, p]));
const updated: DiscoveredPlan[] = entries.map((entry) => {
const prev = existing.get(entry.filename);
if (prev) return prev;
return { filename: entry.filename, path: entry.path, valid: null, validating: false, taskStatuses: [], issues: [] };
});
discoveredPlans.value = updated;
const merged = mergeDiscoveredPlans(discoveredPlans.value, entries, { guidedOpen: showNewPlan.value });
discoveredPlans.value = merged.plans;
if (showNewPlan.value && merged.updatedFilenames.length) {
for (const filename of merged.updatedFilenames) pushLog(`Plan updated during guided flow: ${filename}`);
}

// Auto-validate new plans
for (const plan of updated) {
// Auto-validate new or updated plans
for (const plan of discoveredPlans.value) {
if (plan.valid === null && !plan.validating) {
plan.validating = true;
const validatingForModifiedMs = plan.modifiedMs;
planValidate(projectRoot.value, plan.path)
.then((r) => {
if (plan.modifiedMs !== validatingForModifiedMs) return;
plan.valid = r.valid;
plan.issues = r.issues;
})
.catch((error) => {
if (plan.modifiedMs !== validatingForModifiedMs) return;
plan.valid = false;
plan.issues = [{ path: "", code: "validation_failed", message: String(error) }];
pushLog(`Plan validation failed (${plan.filename}): ${String(error)}`);
})
.finally(() => {
if (plan.modifiedMs !== validatingForModifiedMs) return;
plan.validating = false;
});
}
Expand Down
29 changes: 11 additions & 18 deletions apps/desktop/src/components/NewPlanDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
Describe the feature you want to build when prompted.
</li>
<li class="mb-2">
The agent will create a plan file in <code>plans/</code> inside your project.
The agent will create or update a plan file in <code>plans/</code> inside your project.
</li>
<li class="mb-2">
Click <strong>Done</strong> when finished.
Expand All @@ -36,9 +36,12 @@
Starting terminal...
</v-alert>

<v-alert v-for="plan in newPlans" :key="plan.filename" :type="plan.valid === false ? 'error' : 'success'" variant="tonal" class="mt-3">
<v-alert v-for="plan in changedPlans" :key="plan.filename" :type="plan.valid === false ? 'error' : 'success'" variant="tonal" class="mt-3">
<div class="d-flex align-center">
<span>Plan detected: <strong>{{ plan.filename }}</strong></span>
<span>
{{ plan.kind === "updated" ? "Plan updated" : "Plan detected" }}:
<strong>{{ plan.filename }}</strong>
</span>
<v-chip
class="ml-2"
size="small"
Expand Down Expand Up @@ -92,7 +95,7 @@
<v-card-actions class="flex-shrink-0">
<v-spacer />
<v-btn variant="text" @click="onClose">Cancel</v-btn>
<v-btn v-if="newPlans.length" color="success" @click="onUsePlan(newPlans[0])">Use Plan</v-btn>
<v-btn v-if="changedPlans.length" color="success" @click="onUsePlan(changedPlans[0])">Use Plan</v-btn>
<v-btn color="primary" @click="onDone">Done</v-btn>
</v-card-actions>
</v-card>
Expand All @@ -105,15 +108,7 @@ import TerminalPanel from "./TerminalPanel.vue";
import { terminalKill, terminalSpawn } from "../composables/useTerminal";
import { getSkillInvocation } from "./planDialogInstructions";
import { buildNewPlanSpawnConfig } from "./newPlanSpawnConfig";

interface DiscoveredPlan {
filename: string;
path: string;
valid: boolean | null;
validating: boolean;
taskStatuses: unknown[];
issues: Array<{ path: string; message: string; code: string }>;
}
import { computeChangedPlans, type DiscoveredPlan } from "../lib/plans.js";

const props = defineProps<{
projectRoot: string;
Expand All @@ -139,15 +134,13 @@ const dialogWidth = ref(1000);
const dialogHeight = ref(600);
const sessionId = ref<string>("");
const error = ref<string>("");
const initialPlanFilenames = ref<Set<string>>(new Set());
const initialBaselineByFilename = ref<Map<string, number>>(new Map());

const newPlans = computed(() =>
props.discoveredPlans.filter((p) => !initialPlanFilenames.value.has(p.filename))
);
const changedPlans = computed(() => computeChangedPlans(props.discoveredPlans, initialBaselineByFilename.value));

watch(open, async (value) => {
if (value) {
initialPlanFilenames.value = new Set(props.discoveredPlans.map((p) => p.filename));
initialBaselineByFilename.value = new Map(props.discoveredPlans.map((p) => [p.filename, p.modifiedMs]));
error.value = "";
sessionId.value = "";
try {
Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/src/composables/useControlPlane.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,8 +231,8 @@ describe("useControlPlane", () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => [
{ filename: "add-auth.json", path: "/project/plans/add-auth.json" },
{ filename: "fix-bug.json", path: "/project/plans/fix-bug.json" }
{ filename: "add-auth.json", path: "/project/plans/add-auth.json", modifiedMs: 1000 },
{ filename: "fix-bug.json", path: "/project/plans/fix-bug.json", modifiedMs: 2000 }
]
});
// When plansList is called
Expand All @@ -245,6 +245,7 @@ describe("useControlPlane", () => {
expect(result).toHaveLength(2);
expect(result[0]!.filename).toBe("add-auth.json");
expect(result[1]!.path).toBe("/project/plans/fix-bug.json");
expect(result[1]!.modifiedMs).toBe(2000);
});

it("plansStatus calls GET /api/plans/status and returns PlanStatusResult", async () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/composables/useControlPlane.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ export async function selectFolder(): Promise<string | null> {
return result.path;
}

export type PlanFileEntry = { filename: string; path: string };
export type PlanFileEntry = { filename: string; path: string; modifiedMs: number };
export type TaskStatus = { id: string; state: string };
export type PlanStatusResult = { tasks: TaskStatus[] };

Expand Down
70 changes: 70 additions & 0 deletions apps/desktop/src/lib/mergeDiscoveredPlans.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";

import { mergeDiscoveredPlans } from "./mergeDiscoveredPlans.js";
import type { DiscoveredPlan, PlanFileEntry } from "./plans.js";

describe("mergeDiscoveredPlans", () => {
it("preserves object identity for existing plans and updates modifiedMs", () => {
// Given a previous discovered plan
const prev: DiscoveredPlan = {
filename: "a.json",
path: "/p/plans/a.json",
modifiedMs: 1000,
valid: true,
validating: false,
taskStatuses: [],
issues: []
};

// When entries include the same plan with a newer modifiedMs
const entries: PlanFileEntry[] = [{ filename: "a.json", path: "/p/plans/a.json", modifiedMs: 2000 }];
const result = mergeDiscoveredPlans([prev], entries, { guidedOpen: true });

// Then identity is preserved and modifiedMs is updated
expect(result.plans).toHaveLength(1);
expect(result.plans[0]).toBe(prev);
expect(result.plans[0]!.modifiedMs).toBe(2000);
});

it("marks updated plans to be revalidated and emits update signal when guidedOpen", () => {
// Given a previously validated plan
const prev: DiscoveredPlan = {
filename: "a.json",
path: "/p/plans/a.json",
modifiedMs: 1000,
valid: true,
validating: false,
taskStatuses: [],
issues: [{ path: "x", code: "old_issue", message: "old" }]
};

// When the plan is updated on disk
const entries: PlanFileEntry[] = [{ filename: "a.json", path: "/p/plans/a.json", modifiedMs: 2000 }];
const result = mergeDiscoveredPlans([prev], entries, { guidedOpen: true });

// Then it is reset for validation and the update signal is emitted
expect(result.plans[0]!.valid).toBeNull();
expect(result.plans[0]!.issues).toEqual([]);
expect(result.updatedFilenames).toEqual(["a.json"]);
});

it("does not emit update signal when guidedOpen=false", () => {
// Given a plan that gets updated
const prev: DiscoveredPlan = {
filename: "a.json",
path: "/p/plans/a.json",
modifiedMs: 1000,
valid: true,
validating: false,
taskStatuses: [],
issues: []
};

// When merge is called outside guided flow
const entries: PlanFileEntry[] = [{ filename: "a.json", path: "/p/plans/a.json", modifiedMs: 2000 }];
const result = mergeDiscoveredPlans([prev], entries, { guidedOpen: false });

// Then no update signal is emitted
expect(result.updatedFilenames).toEqual([]);
});
});
48 changes: 48 additions & 0 deletions apps/desktop/src/lib/mergeDiscoveredPlans.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { DiscoveredPlan, PlanFileEntry } from "./plans.js";

export type MergeResult = {
plans: DiscoveredPlan[];
// Filenames updated (existing entry whose modifiedMs increased)
updatedFilenames: string[];
};

export function mergeDiscoveredPlans(
prevPlans: readonly DiscoveredPlan[],
entries: readonly PlanFileEntry[],
opts: { guidedOpen: boolean }
): MergeResult {
const existing = new Map(prevPlans.map((p) => [p.filename, p]));
const next: DiscoveredPlan[] = [];
const updatedFilenames: string[] = [];

for (const entry of entries) {
const prev = existing.get(entry.filename);
if (!prev) {
next.push({
filename: entry.filename,
path: entry.path,
modifiedMs: entry.modifiedMs,
valid: null,
validating: false,
taskStatuses: [],
issues: []
});
continue;
}

const wasModifiedMs = prev.modifiedMs;
prev.path = entry.path;
prev.modifiedMs = entry.modifiedMs;
next.push(prev);

if (entry.modifiedMs > wasModifiedMs) {
// Reset validation state so polling will re-validate the updated content.
prev.valid = null;
prev.issues = [];
prev.validating = false;
if (opts.guidedOpen) updatedFilenames.push(entry.filename);
}
}

return { plans: next, updatedFilenames };
}
52 changes: 52 additions & 0 deletions apps/desktop/src/lib/plans.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";

import type { BaselineByFilename, DiscoveredPlan } from "./plans.js";
import { computeChangedPlans } from "./plans.js";

describe("computeChangedPlans", () => {
it("returns updated when modifiedMs increased vs baseline", () => {
// Given a baseline for an existing plan
const baseline: BaselineByFilename = new Map([["a.json", 1000]]);
const plans: DiscoveredPlan[] = [
{ filename: "a.json", path: "/p/plans/a.json", modifiedMs: 2000, valid: true, validating: false, taskStatuses: [], issues: [] }
];

// When computing changes
const changed = computeChangedPlans(plans, baseline);

// Then the plan is marked as updated
expect(changed).toHaveLength(1);
expect(changed[0]!.filename).toBe("a.json");
expect(changed[0]!.kind).toBe("updated");
});

it("returns new when plan was not in baseline", () => {
// Given a baseline that does not include the plan
const baseline: BaselineByFilename = new Map([["a.json", 1000]]);
const plans: DiscoveredPlan[] = [
{ filename: "b.json", path: "/p/plans/b.json", modifiedMs: 5000, valid: null, validating: false, taskStatuses: [], issues: [] }
];

// When computing changes
const changed = computeChangedPlans(plans, baseline);

// Then the plan is marked as new
expect(changed).toHaveLength(1);
expect(changed[0]!.filename).toBe("b.json");
expect(changed[0]!.kind).toBe("new");
});

it("does not return unchanged plans", () => {
// Given a baseline matching the current modifiedMs
const baseline: BaselineByFilename = new Map([["a.json", 2000]]);
const plans: DiscoveredPlan[] = [
{ filename: "a.json", path: "/p/plans/a.json", modifiedMs: 2000, valid: true, validating: false, taskStatuses: [], issues: [] }
];

// When computing changes
const changed = computeChangedPlans(plans, baseline);

// Then nothing is returned
expect(changed).toEqual([]);
});
});
Loading