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
59 changes: 59 additions & 0 deletions apps/server/src/tasks/TaskDocuments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,65 @@ describe("TaskDocuments", () => {
}).pipe(Effect.scoped, Effect.provide(layer)),
);

it.effect("reconciles a changed file ID only when explicitly requested", () =>
Effect.gen(function* () {
const { fs, path, directory, target, service } = yield* setup;
const original = yield* service.register({ path: target });
const duplicateId = "b8cf0bb8-531f-4d13-9aa3-3219ee85e623";
const replacementId = "c9df1cc9-642f-4e24-8bb4-432a0ff96f34";
const duplicate = path.join(directory, "other.md");
const replacement = path.join(directory, "replacement.md");
yield* fs.writeFileString(duplicate, fixture.replace(fileId, duplicateId));
yield* service.register({ path: duplicate });
yield* fs.writeFileString(replacement, fixture.replace(fileId, replacementId));

expect(
yield* service.relink({ ...original.summary, path: replacement }).pipe(Effect.flip),
).toMatchObject({ reason: "identity-changed" });
yield* fs.writeFileString(replacement, fixture.replace(fileId, duplicateId));
expect(
yield* service
.relink({ ...original.summary, path: replacement, reconcileIdentity: true })
.pipe(Effect.flip),
).toMatchObject({ reason: "duplicate-id" });

yield* fs.writeFileString(replacement, fixture.replace(fileId, replacementId));
const relinked = yield* service.relink({
...original.summary,
path: replacement,
reconcileIdentity: true,
});
expect(relinked.summary).toMatchObject({
taskDocumentId: original.summary.taskDocumentId,
canonicalPath: replacement,
locatorVersion: original.summary.locatorVersion + 1,
observedFileId: replacementId,
availability: "available",
});
}).pipe(Effect.scoped, Effect.provide(layer)),
);

it.effect("reads task metadata from bare-CR frontmatter closed with ...", () =>
Effect.gen(function* () {
const { fs, path, directory, service } = yield* setup;
const id = "d0ae6d1e-5375-4e9a-817d-721cb4fdb91d";
const target = path.join(directory, "bare-cr.md");
yield* fs.writeFileString(
target,
`---\rid: ${id}\rtitle: "CR task"\rstate: "Ready"\rpinned: true\r...\r# Body\r`,
);

const opened = yield* service.register({ path: target });
expect(opened.summary).toMatchObject({
observedFileId: id,
title: "CR task",
state: "Ready",
pinned: true,
availability: "available",
});
}).pipe(Effect.scoped, Effect.provide(layer)),
);

it.effect(
"relinks a missing file without recreating its old locator, and unregisters without deletion",
() =>
Expand Down
13 changes: 9 additions & 4 deletions apps/server/src/tasks/TaskDocuments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,13 @@ const decodeRecovery = Schema.decodeUnknownEffect(Schema.fromJsonString(TaskDocu
const encodeRecovery = Schema.encodeEffect(Schema.fromJsonString(TaskDocumentRecovery));

function metadata(contents: string) {
const frontmatter = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(contents)?.[1];
const document = parseDocument(frontmatter ?? "", { uniqueKeys: true });
const frontmatter =
/^\uFEFF?---[ \t]*(?:\r\n|\r|\n)([\s\S]*?)(?:\r\n|\r|\n)(?:---|\.\.\.)[ \t]*(?:\r\n|\r|\n|$)/.exec(
contents,
)?.[1];
const document = parseDocument((frontmatter ?? "").replace(/\r(?!\n)/g, "\n"), {
uniqueKeys: true,
});
const get = (key: string): unknown =>
document.errors.length === 0 ? document.get(key) : undefined;
const id = decodeUuid(get("id"));
Expand Down Expand Up @@ -563,10 +568,10 @@ const make = Effect.gen(function* () {
return yield* new TaskDocumentError({ reason: "unsupported" });
const file = yield* read(input.path);
yield* unique(file, row.taskDocumentId);
if (file.metadata.fileId !== row.observedFileId)
if (file.metadata.fileId !== row.observedFileId && input.reconcileIdentity !== true)
return yield* new TaskDocumentError({ reason: "identity-changed" });
yield* mapIo(
sql`UPDATE task_documents SET canonical_path = ${file.canonicalPath}, locator_version = locator_version + 1 WHERE task_document_id = ${row.taskDocumentId}`,
sql`UPDATE task_documents SET canonical_path = ${file.canonicalPath}, file_id = ${file.metadata.fileId}, locator_version = locator_version + 1 WHERE task_document_id = ${row.taskDocumentId}`,
);
yield* notify(row.taskDocumentId);
yield* syncWatches();
Expand Down
99 changes: 91 additions & 8 deletions apps/web/src/components/tasks/TaskDocumentPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
} from "@t3tools/contracts";
import { maskMarkdownFrontmatter } from "@t3tools/shared/markdownFrontmatter";
import { Link, useNavigate } from "@tanstack/react-router";
import { CodeIcon, EyeIcon, MaximizeIcon, PlusIcon, SaveIcon } from "lucide-react";
import { CodeIcon, EyeIcon, MaximizeIcon, PencilIcon, PlusIcon, SaveIcon } from "lucide-react";
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";

import ChatMarkdown from "~/components/ChatMarkdown";
Expand All @@ -37,6 +37,8 @@ import { useProjects } from "~/state/entities";
import { readTaskUpdateInstructionsPath, taskDocumentEnvironment } from "~/state/taskDocuments";
import { useAtomCommand } from "~/state/use-atom-command";

import { TaskPropertiesDialog } from "./TaskPropertiesDialog";
import { TaskRelinkDialog } from "./TaskRelinkDialog";
import { type TaskSession, useTaskCatalogue, useTaskDocument } from "./useTaskDocuments";

const splitDocumentPath = (path: string) => {
Expand Down Expand Up @@ -273,14 +275,18 @@ export default function TaskDocumentPane(props: {
environmentId,
taskDocumentId,
);
const { tasks } = useTaskCatalogue(environmentId);
const { tasks, canRelink } = useTaskCatalogue(environmentId);
const navigate = useNavigate();
const projects = useProjects();
const { openScratchProject } = useScratchProject();
const handleNewThread = useNewThreadHandler();
const [showDisk, setShowDisk] = useState(false);
const [showProperties, setShowProperties] = useState(false);
const [showRelink, setShowRelink] = useState(false);
const { base, draft, disk, recovery, error, saving, view } = state;
const summary = base?.summary ?? null;
const catalogueTask = tasks.find((candidate) => candidate.taskDocumentId === taskDocumentId);
const relinkTask = summary ?? catalogueTask;
const { directory, name } = splitDocumentPath(summary?.canonicalPath ?? "");
const dirty = base !== null && draft !== base.contents;

Expand Down Expand Up @@ -328,10 +334,42 @@ export default function TaskDocumentPane(props: {
);
}
if (base === null) {
const canRelinkMissing =
relinkTask != null &&
(relinkTask.availability === "missing" || relinkTask.availability === "identity-changed");
return error ? (
<p role="alert" className="m-auto p-6 text-center text-muted-foreground text-sm">
{error}
</p>
<section className="m-auto flex max-w-xl flex-col items-center gap-3 p-6 text-center">
<h2 className="font-medium text-base">{relinkTask?.title ?? "Task file unavailable"}</h2>
<p role="alert" className="text-muted-foreground text-sm">
{relinkTask?.availability === "missing"
? "This task file is missing from its saved path."
: error}
</p>
{relinkTask ? (
<code className="max-w-full break-all text-muted-foreground text-xs">
{relinkTask.canonicalPath}
</code>
) : null}
{canRelinkMissing ? (
canRelink ? (
<Button size="sm" variant="outline" onClick={() => setShowRelink(true)}>
Relink task file
</Button>
) : (
<p className="text-muted-foreground text-xs">
You do not have permission to relink task files on this environment.
</p>
)
) : null}
{showRelink && relinkTask ? (
<TaskRelinkDialog
environmentId={environmentId}
task={relinkTask}
onRelink={(document) => session.acceptRead(session.beginRead(), document)}
onClose={() => setShowRelink(false)}
/>
) : null}
</section>
) : (
<div className="m-auto p-6">
<Spinner />
Expand All @@ -346,7 +384,7 @@ export default function TaskDocumentPane(props: {
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") {
event.preventDefault();
void save();
if (summary?.availability === "available") void save();
}
}}
>
Expand Down Expand Up @@ -378,11 +416,23 @@ export default function TaskDocumentPane(props: {
variant="ghost"
aria-label="Save task document"
title="Save task document"
disabled={!canSave || !dirty || disk !== null || saving}
disabled={
!canSave || summary?.availability !== "available" || !dirty || disk !== null || saving
}
onClick={() => void save()}
>
{saving ? <Spinner /> : <SaveIcon />}
</Button>
<Button
size="icon-xs"
variant="ghost"
aria-label="Edit task properties"
title="Edit task properties"
disabled={!canSave || summary?.availability !== "available" || disk !== null}
onClick={() => setShowProperties(true)}
>
<PencilIcon />
</Button>
<Button
size="icon-xs"
variant="ghost"
Expand Down Expand Up @@ -416,6 +466,21 @@ export default function TaskDocumentPane(props: {
thread={thread}
/>
) : null}
{summary?.availability === "identity-changed" ? (
<Notice
actions={
canRelink ? (
<Button size="xs" variant="outline" onClick={() => setShowRelink(true)}>
Relink task file
</Button>
) : null
}
>
This file has a different task ID. Relink it to keep this task's threads attached before
editing.
{!canRelink ? " You do not have permission to relink task files here." : ""}
</Notice>
) : null}
{error ? <Notice>{error} Your draft is kept.</Notice> : null}
{!canSave ? <Notice>You can read this task but not edit it here.</Notice> : null}
{disk !== null ? (
Expand Down Expand Up @@ -477,11 +542,29 @@ export default function TaskDocumentPane(props: {
taskDocumentId={taskDocumentId}
name={name}
contents={draft}
editable={canSave && disk === null}
editable={canSave && summary?.availability === "available" && disk === null}
session={session}
/>
</DiffWorkerPoolProvider>
)}
{showProperties && summary ? (
<TaskPropertiesDialog
contents={draft}
title={summary.title}
state={summary.state}
pinned={summary.pinned}
onApply={(contents) => session.edit(contents)}
onClose={() => setShowProperties(false)}
/>
) : null}
{showRelink && relinkTask ? (
<TaskRelinkDialog
environmentId={environmentId}
task={relinkTask}
onRelink={(document) => session.acceptRead(session.beginRead(), document)}
onClose={() => setShowRelink(false)}
/>
) : null}
</section>
);
}
103 changes: 103 additions & 0 deletions apps/web/src/components/tasks/TaskPropertiesDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { useState } from "react";

import { Button } from "~/components/ui/button";
import { Checkbox } from "~/components/ui/checkbox";
import {
Dialog,
DialogDescription,
DialogFooter,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
} from "~/components/ui/dialog";
import { Input } from "~/components/ui/input";
import { patchTaskFrontmatter, readTaskFrontmatter } from "@t3tools/shared/markdownFrontmatter";

export function TaskPropertiesDialog(props: {
readonly contents: string;
readonly title: string;
readonly state: string | null;
readonly pinned: boolean;
readonly onApply: (contents: string) => void;
readonly onClose: () => void;
}) {
const metadata = readTaskFrontmatter(props.contents);
const [title, setTitle] = useState(metadata?.title ?? props.title);
const [state, setState] = useState(metadata?.state ?? props.state ?? "");
const [pinned, setPinned] = useState(metadata?.pinned ?? props.pinned);
const [error, setError] = useState(false);

const apply = () => {
const contents = patchTaskFrontmatter(props.contents, { title, state, pinned });
if (contents === null) {
setError(true);
return;
}
props.onApply(contents);
props.onClose();
};

return (
<Dialog open onOpenChange={(open) => !open && props.onClose()}>
<DialogPopup className="max-w-lg">
<form
className="flex min-h-0 flex-col"
onSubmit={(event) => {
event.preventDefault();
apply();
}}
>
<DialogHeader>
<DialogTitle>Task properties</DialogTitle>
<DialogDescription>
Changes stay in your draft until you save the task file.
</DialogDescription>
</DialogHeader>
<DialogPanel>
<div className="flex flex-col gap-3">
<label className="flex flex-col gap-1 text-sm">
Title
<Input
autoFocus
maxLength={512}
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</label>
<label className="flex flex-col gap-1 text-sm">
State
<Input
maxLength={512}
placeholder="No state"
value={state}
onChange={(event) => setState(event.target.value)}
/>
</label>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={pinned}
onCheckedChange={(checked) => setPinned(checked === true)}
/>
Pin this task
</label>
{error ? (
<p role="alert" className="text-destructive-foreground text-sm">
The frontmatter could not be patched safely. Edit it in the source and try again.
</p>
) : null}
</div>
</DialogPanel>
<DialogFooter>
<Button type="button" variant="ghost" onClick={props.onClose}>
Cancel
</Button>
<Button type="submit" disabled={title.trim() === ""}>
Apply changes
</Button>
</DialogFooter>
</form>
</DialogPopup>
</Dialog>
);
}
Loading
Loading