Repository navigation
lazurio: unsent prompt draft by link from the shell #36
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| import { useAtomValue } from "@effect/atom-react"; | ||
| import { scopeProjectRef } from "@t3tools/client-runtime/environment"; | ||
| import { Atom } from "effect/unstable/reactivity"; | ||
| import { useEffect, useRef } from "react"; | ||
|
|
||
| import { stackedThreadToast, toastManager } from "~/components/ui/toast"; | ||
| import { useComposerDraftStore } from "../composerDraftStore"; | ||
| import { useNewThreadHandler } from "../hooks/useHandleNewThread"; | ||
| import { findProjectByPath, inferProjectTitleFromPath } from "../lib/projectPaths"; | ||
| import { newProjectId } from "../lib/utils"; | ||
| import { readProjects, waitForProject } from "../state/entities"; | ||
| import { projectEnvironment } from "../state/projects"; | ||
| import { primaryServerConfigAtom } from "../state/server"; | ||
| import { environmentShell } from "../state/shell"; | ||
| import { useAtomCommand } from "../state/use-atom-command"; | ||
| import { openPromptDraft, takeLazurioPromptLink } from "./promptDraft"; | ||
|
|
||
| // The primary environment once its shell is live: its project list is | ||
| // complete, so a missing project is really missing, and commands reach it. | ||
| // Null until then (still loading, or disconnected), and the link waits. | ||
| const livePrimaryEnvironmentIdAtom = Atom.make((get) => { | ||
| const serverConfig = get(primaryServerConfigAtom); | ||
| if (serverConfig === null) return null; | ||
| const environmentId = serverConfig.environment.environmentId; | ||
| return get(environmentShell.stateValueAtom(environmentId)).status === "live" | ||
| ? environmentId | ||
| : null; | ||
| }).pipe(Atom.withLabel("lazurio-prompt-draft-live-primary-environment")); | ||
|
|
||
| /** | ||
| * Lazurio overlay (Lazurio/t3code#35): once the primary environment's shell | ||
| * is live, opens the prompt a Lazurio link named in a new thread of the | ||
| * primary environment's project rooted at the prompt's folder, with the text | ||
| * in the composer and nothing sent. Renders nothing; without a link it does | ||
| * nothing at all. | ||
| */ | ||
| export function LazurioPromptDraft() { | ||
| const primaryEnvironmentId = useAtomValue(livePrimaryEnvironmentIdAtom); | ||
| const handleNewThread = useNewThreadHandler(); | ||
| const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); | ||
| const started = useRef(false); | ||
|
|
||
| useEffect(() => { | ||
| if (started.current || primaryEnvironmentId === null) return; | ||
| const link = takeLazurioPromptLink(); | ||
| if (link === null) return; | ||
| started.current = true; | ||
| void openPromptDraft(link, { | ||
| origin: window.location.origin, | ||
| fetch: (url, init) => window.fetch(url, init), | ||
| findProject: (cwd) => { | ||
| const project = findProjectByPath( | ||
| readProjects().filter((candidate) => candidate.environmentId === primaryEnvironmentId), | ||
| cwd, | ||
| ); | ||
| return project ? scopeProjectRef(project.environmentId, project.id) : null; | ||
| }, | ||
| addProject: async (cwd) => { | ||
| const projectId = newProjectId(); | ||
| const created = await createProject({ | ||
| environmentId: primaryEnvironmentId, | ||
| input: { | ||
| projectId, | ||
| title: inferProjectTitleFromPath(cwd), | ||
| workspaceRoot: cwd, | ||
| createWorkspaceRootIfMissing: false, | ||
| defaultModelSelection: null, | ||
| }, | ||
| }); | ||
| if (created._tag === "Failure") return null; | ||
| const projectRef = scopeProjectRef(primaryEnvironmentId, projectId); | ||
| await waitForProject(projectRef, 3_000).catch(() => null); | ||
| return projectRef; | ||
| }, | ||
| openThread: (projectRef) => handleNewThread(projectRef), | ||
| setPrompt: (draftId, text) => useComposerDraftStore.getState().setPrompt(draftId, text), | ||
| }).then((outcome) => { | ||
| if (outcome === "failed") { | ||
| toastManager.add( | ||
| stackedThreadToast({ | ||
| type: "error", | ||
| title: "Could not open the prepared prompt", | ||
| description: "Nothing was added to the composer.", | ||
| }), | ||
| ); | ||
| } | ||
| }); | ||
| }, [createProject, handleNewThread, primaryEnvironmentId]); | ||
|
|
||
| return null; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,215 @@ | ||
| import { describe, expect, it, vi } from "vite-plus/test"; | ||
|
|
||
| import { | ||
| capturePromptLink, | ||
| fetchPrompt, | ||
| openPromptDraft, | ||
| type PromptDraftDependencies, | ||
| readPromptLink, | ||
| stripPromptLink, | ||
| } from "./promptDraft"; | ||
|
|
||
| const origin = "https://t3code.vm-01.example.lazurio.io"; | ||
| const link = { id: "new-module", organization: "Example-Org" } as const; | ||
| const document = { | ||
| schema: "lazurio.prompt.v1", | ||
| id: "new-module", | ||
| text: "I want to found a new module.\n\nAsk me first.", | ||
| cwd: "/home/operator/Lazurio/organizations/example_GEN3", | ||
| }; | ||
|
|
||
| function answer(body: unknown, init: ResponseInit & { url?: string } = {}): Response { | ||
| const response = new Response(typeof body === "string" ? body : JSON.stringify(body), { | ||
| status: 200, | ||
| headers: { "content-type": "application/json; charset=utf-8" }, | ||
| ...init, | ||
| }); | ||
| Object.defineProperty(response, "url", { | ||
| value: init.url ?? `${origin}/.lazurio/prompts/new-module?org=Example-Org`, | ||
| }); | ||
| return response; | ||
| } | ||
|
|
||
| describe("the link", () => { | ||
| it("carries only a prompt id and an Organization login, in the fragment", () => { | ||
| expect( | ||
| readPromptLink( | ||
| new URL(`${origin}/pair#token=abc&lazurio-prompt=new-module&lazurio-org=Example-Org`), | ||
| ), | ||
| ).toEqual(link); | ||
| expect( | ||
| readPromptLink(new URL(`${origin}/#lazurio-prompt=new-module&lazurio-org=Example-Org`)), | ||
| ).toEqual(link); | ||
| }); | ||
|
|
||
| it("refuses text, a query, a missing or repeated part and anything outside the grammar", () => { | ||
| for (const href of [ | ||
| `${origin}/#lazurio-prompt=new-module`, | ||
| `${origin}/#lazurio-org=Example-Org`, | ||
| `${origin}/?lazurio-prompt=new-module&lazurio-org=Example-Org`, | ||
| `${origin}/#lazurio-prompt=new-module&lazurio-org=Example-Org&lazurio-prompt=other`, | ||
| `${origin}/#lazurio-prompt=Do%20this%20now&lazurio-org=Example-Org`, | ||
| `${origin}/#lazurio-prompt=new-module&lazurio-org=a%2Fb`, | ||
| `${origin}/#lazurio-prompt=new-module&lazurio-org=-x`, | ||
| `${origin}/#lazurio-prompt=${"a".repeat(65)}&lazurio-org=Example-Org`, | ||
| `${origin}/#lazurio-text=hello&lazurio-org=Example-Org`, | ||
| `${origin}/pair#token=abc`, | ||
| ]) { | ||
| expect([href, readPromptLink(new URL(href))]).toEqual([href, null]); | ||
| } | ||
| }); | ||
|
|
||
| it("is stripped from the address, leaving everything else as it was", () => { | ||
| expect( | ||
| stripPromptLink( | ||
| new URL(`${origin}/pair#token=abc&lazurio-prompt=new-module&lazurio-org=Example-Org`), | ||
| ).href, | ||
| ).toBe(`${origin}/pair#token=abc`); | ||
| expect( | ||
| stripPromptLink(new URL(`${origin}/#lazurio-prompt=new-module&lazurio-org=Example-Org`)).href, | ||
| ).toBe(`${origin}/`); | ||
| expect(stripPromptLink(new URL(`${origin}/pair#token=abc`)).href).toBe( | ||
| `${origin}/pair#token=abc`, | ||
| ); | ||
| }); | ||
|
|
||
| it("is taken once at boot, before the router reads the address", () => { | ||
| const replace = vi.fn(); | ||
| const location = { | ||
| href: `${origin}/pair#token=abc&lazurio-prompt=new-module&lazurio-org=Example-Org`, | ||
| }; | ||
| const take = capturePromptLink(location, replace); | ||
| expect(replace).toHaveBeenCalledWith(`${origin}/pair#token=abc`); | ||
| expect(take()).toEqual(link); | ||
| expect(take()).toBeNull(); | ||
| const untouched = vi.fn(); | ||
| expect(capturePromptLink({ href: `${origin}/pair#token=abc` }, untouched)()).toBeNull(); | ||
| expect(untouched).not.toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
|
|
||
| describe("the text", () => { | ||
| it("comes only from this origin's /.lazurio/prompts/<id>, without credentials elsewhere", async () => { | ||
| const fetcher = vi.fn(async () => answer(document)); | ||
| await expect(fetchPrompt(link, origin, fetcher)).resolves.toEqual({ | ||
| text: document.text, | ||
| cwd: document.cwd, | ||
| }); | ||
| expect(fetcher).toHaveBeenCalledTimes(1); | ||
| const [url, init] = fetcher.mock.calls[0] as unknown as [string, RequestInit]; | ||
| expect(url).toBe(`${origin}/.lazurio/prompts/new-module?org=Example-Org`); | ||
| expect(init).toMatchObject({ | ||
| credentials: "same-origin", | ||
| redirect: "error", | ||
| cache: "no-store", | ||
| }); | ||
| }); | ||
|
|
||
| it("is nothing for an unknown id, a failed fetch, a non-JSON or wrong answer, or another origin", async () => { | ||
| const cases: Array<[string, () => Promise<Response>]> = [ | ||
| ["404", async () => answer({ error: "not-found" }, { status: 404 })], | ||
| [ | ||
| "network", | ||
| async () => { | ||
| throw new TypeError("Failed to fetch"); | ||
| }, | ||
| ], | ||
| ["html", async () => answer("<html></html>", { headers: { "content-type": "text/html" } })], | ||
| ["not json", async () => answer("{", { headers: { "content-type": "application/json" } })], | ||
| ["schema", async () => answer({ ...document, schema: "lazurio.prompt.v2" })], | ||
| ["id", async () => answer({ ...document, id: "other" })], | ||
| ["empty text", async () => answer({ ...document, text: " " })], | ||
| ["long text", async () => answer({ ...document, text: "x".repeat(16 * 1024 + 1) })], | ||
| ["text type", async () => answer({ ...document, text: 42 })], | ||
| ["relative cwd", async () => answer({ ...document, cwd: "organizations/example" })], | ||
| ["no cwd", async () => answer({ ...document, cwd: undefined })], | ||
| [ | ||
| "other origin", | ||
| async () => answer(document, { url: "https://evil.example/.lazurio/prompts/new-module" }), | ||
| ], | ||
| ["array", async () => answer([document])], | ||
| ]; | ||
| for (const [name, fetcher] of cases) { | ||
| await expect([name, await fetchPrompt(link, origin, fetcher)]).toEqual([name, null]); | ||
| } | ||
| }); | ||
| }); | ||
|
|
||
| function dependencies(overrides: Partial<PromptDraftDependencies> = {}) { | ||
| const calls: string[] = []; | ||
| const projectRef = { environmentId: "primary", projectId: "project-1" } as never; | ||
| const base: PromptDraftDependencies = { | ||
| origin, | ||
| fetch: async () => answer(document), | ||
| findProject: (cwd) => { | ||
| calls.push(`find ${cwd}`); | ||
| return projectRef; | ||
| }, | ||
| addProject: async (cwd) => { | ||
| calls.push(`add ${cwd}`); | ||
| return projectRef; | ||
| }, | ||
| openThread: async () => { | ||
| calls.push("open"); | ||
| return { draftId: "draft-1" as never }; | ||
| }, | ||
| setPrompt: (draftId, text) => { | ||
| calls.push(`prompt ${String(draftId)} ${text}`); | ||
| }, | ||
| }; | ||
| return { calls, dependencies: { ...base, ...overrides } }; | ||
| } | ||
|
|
||
| describe("the draft", () => { | ||
| it("opens a new thread in the project rooted at cwd and leaves the text in its composer", async () => { | ||
| const { calls, dependencies: deps } = dependencies(); | ||
| await expect(openPromptDraft(link, deps)).resolves.toBe("opened"); | ||
| expect(calls).toEqual([`find ${document.cwd}`, "open", `prompt draft-1 ${document.text}`]); | ||
| }); | ||
|
|
||
| it("adds the Organization's folder as a project when there is none", async () => { | ||
| const { calls, dependencies: deps } = dependencies({ | ||
| findProject: (cwd) => { | ||
| calls.push(`find ${cwd}`); | ||
| return null; | ||
| }, | ||
| }); | ||
| await expect(openPromptDraft(link, deps)).resolves.toBe("opened"); | ||
| expect(calls).toEqual([ | ||
| `find ${document.cwd}`, | ||
| `add ${document.cwd}`, | ||
| "open", | ||
| `prompt draft-1 ${document.text}`, | ||
| ]); | ||
| }); | ||
|
|
||
| it("asks again when another navigation overtook the new thread", async () => { | ||
| let attempts = 0; | ||
| const { calls, dependencies: deps } = dependencies({ | ||
| openThread: async () => { | ||
| attempts += 1; | ||
| calls.push("open"); | ||
| return attempts === 1 ? null : { draftId: "draft-2" as never }; | ||
| }, | ||
| }); | ||
| await expect(openPromptDraft(link, deps)).resolves.toBe("opened"); | ||
| expect(calls.slice(1)).toEqual(["open", "open", `prompt draft-2 ${document.text}`]); | ||
| }); | ||
|
|
||
| it("inserts nothing when the text, the project or the thread is not there", async () => { | ||
| for (const overrides of [ | ||
| { fetch: async () => answer({ error: "not-found" }, { status: 404 }) }, | ||
| { findProject: () => null, addProject: async () => null }, | ||
| { openThread: async () => null }, | ||
| { | ||
| openThread: async () => { | ||
| throw new Error("navigation failed"); | ||
| }, | ||
| }, | ||
| ] satisfies Array<Partial<PromptDraftDependencies>>) { | ||
| const { calls, dependencies: deps } = dependencies(overrides); | ||
| await expect(openPromptDraft(link, deps)).resolves.toBe("failed"); | ||
| expect(calls.some((call) => call.startsWith("prompt"))).toBe(false); | ||
| } | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.