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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,23 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.

## Unreleased

### A person can set standing instructions that every coworker follows

Settings now has a box for standing instructions: one piece of text per person, saved once and
spliced into every built-in coworker's prompt, in every channel, on every run, including the runs a
routine starts overnight. It is the place for what is true of every task rather than of any one of
them, such as how somebody wants to be written to or what their company is and is not to be called.
A coworker's role still decides what it does; these decide how it does it, and the prompt says so,
so an instruction cannot quietly redefine what a coworker is for.

Instructions belong to the person who wrote them. Nobody, administrators included, can read or set
somebody else's, and they are deleted with the account. A coworker running at a remote AG-UI
endpoint is not sent them, since this deployment does not compose that prompt. Nothing is added to
any prompt until somebody writes something, so a deployment where nobody uses this behaves exactly
as before.

This adds migration `0026_user_instructions`, which creates one table.

### Coworkers are made in a wizard and managed in a dialog

Creating a coworker is now a three-step wizard — who it is, who may see it, then where it runs,
Expand Down
115 changes: 115 additions & 0 deletions app/src/components/settings/standing-instructions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { PageSection } from "@/components/layout/page-shell";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { saveInstructionsMutationOptions } from "@/lib/settings/mutations";
import {
INSTRUCTIONS_LIMIT,
instructionsQueryOptions,
} from "@/lib/settings/queries";
import { queryClient } from "@/query-client";

/**
* One box, applied to every coworker in every channel.
*
* WHAT IT IS FOR, said on the screen rather than left to be guessed. A coworker's role and this are
* both durable instructions and a person meeting them for the first time has no reason to know which
* belongs where, so the description draws the line the prompt draws: the role says what a coworker
* is for, this says how the person wants things done. Without that sentence the obvious mistake is
* to write a job description here and get it applied to every coworker at once.
*/
export function StandingInstructions() {
const stored = useQuery(instructionsQueryOptions());
const save = useMutation(saveInstructionsMutationOptions(queryClient));

const [draft, setDraft] = useState<string | null>(null);
const [problem, setProblem] = useState<string | null>(null);
const [saved, setSaved] = useState(false);

/*
* The box holds the stored text until somebody types, and their typing after that.
*
* Null is "has not been edited", which is not the same as "is empty": seeding the state with ""
* and then filling it in when the read lands would overwrite whatever they had already started
* typing into a box that was ready before the network was.
*/
useEffect(() => {
if (stored.data !== undefined && draft === null) setDraft(stored.data);
}, [stored.data, draft]);

const text = draft ?? stored.data ?? "";
const over = text.trim().length > INSTRUCTIONS_LIMIT;
const unchanged = stored.data !== undefined && text === stored.data;

const submit = () => {
setProblem(null);
setSaved(false);
save.mutate(text, {
onError: (thrown: Error) => setProblem(thrown.message),
/* The server trims, so the box settles to what was actually stored rather than what was sent. */
onSuccess: (asStored) => {
setDraft(asStored);
setSaved(true);
},
});
};

return (
<PageSection
description="Applies to every coworker in every channel. Your role text on a coworker says what it does; this says how you want things done, for example writing style or how to describe your company."
title="Standing instructions"
>
{stored.isPending ? null : stored.error ? (
<p className="mt-4 text-destructive text-sm" role="alert">
{stored.error.message}
</p>
) : (
<div className="mt-4 flex flex-col gap-2">
<Textarea
aria-label="Standing instructions"
className="min-h-40"
disabled={save.isPending}
onChange={(event) => {
setDraft(event.target.value);
setSaved(false);
}}
placeholder="We are two people, not a team. Write in British English, and never call our product a platform."
value={text}
/>
<div className="flex flex-row items-center justify-between gap-4">
<p
className={
over
? "text-destructive text-xs"
: "text-muted-foreground text-xs"
}
>
{text.trim().length} of {INSTRUCTIONS_LIMIT} characters
</p>
<div className="flex flex-row items-center gap-3">
{/*
* One line, and only the news. A save that worked says so until the next keystroke; a
* refusal shows the server's own sentence, which names what was wrong with it.
*/}
{problem ? (
<span className="text-destructive text-xs" role="alert">
{problem}
</span>
) : saved ? (
<span className="text-muted-foreground text-xs">Saved</span>
) : null}
<Button
disabled={save.isPending || over || unchanged}
onClick={submit}
size="sm"
>
Save
</Button>
</div>
</div>
</div>
)}
</PageSection>
);
}
24 changes: 24 additions & 0 deletions app/src/lib/settings/mutations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { mutationOptions, type QueryClient } from "@tanstack/react-query";
import { client } from "@/lib/client";
import { settingsKeys } from "./queries";

/**
* Save, or clear by saving nothing.
*
* A PUT of the whole text rather than a patch, because there is one field and its new value is the
* whole of what changed. What comes back is what was stored — the server trims — so the cache is
* seeded from the reply rather than from what was sent, and a box that had trailing whitespace in it
* settles to what the database actually holds.
*/
export function saveInstructionsMutationOptions(queryClient: QueryClient) {
return mutationOptions({
mutationFn: (instructions: string): Promise<string> =>
client("/api/settings/instructions", "instructions", {
method: "PUT",
body: { instructions },
fallback: "Your standing instructions could not be saved",
}),
onSuccess: (saved) =>
queryClient.setQueryData(settingsKeys.instructions(), saved),
});
}
28 changes: 28 additions & 0 deletions app/src/lib/settings/queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { queryOptions } from "@tanstack/react-query";
import { client } from "@/lib/client";

/**
* How much one person may say, in characters.
*
* The server owns this rule and refuses past it; this copy exists so the box can count down rather
* than let somebody write four paragraphs and find out on save. A drift between the two therefore
* shows up as a refusal with the server's own sentence on it, which is the safe direction for a
* duplicated number to fail in.
*/
export const INSTRUCTIONS_LIMIT = 4000;

export const settingsKeys = {
all: ["settings"] as const,
instructions: () => [...settingsKeys.all, "instructions"] as const,
};

/** "" means this person has written none. There is no separate absent state to draw. */
export function instructionsQueryOptions() {
return queryOptions({
queryKey: settingsKeys.instructions(),
queryFn: async (): Promise<string> =>
client("/api/settings/instructions", "instructions", {
fallback: "Could not load your standing instructions",
}),
});
}
6 changes: 6 additions & 0 deletions app/src/routes/_authed/settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
PageSection,
PageShell,
} from "@/components/layout/page-shell";
import { StandingInstructions } from "@/components/settings/standing-instructions";
import { useTheme } from "@/components/theme-provider";
import {
Item,
Expand Down Expand Up @@ -56,6 +57,11 @@ function RouteComponent() {
</Item>
</PageRows>
</PageSection>
{/*
* Above the shortcuts and below the appearance switch, because it is the only thing on this
* screen that changes what a coworker says rather than what this browser looks like.
*/}
<StandingInstructions />
{/*
* Drawn from the same registry the listeners match against, so this list is what the keys
* actually do rather than what somebody remembered they did. Read-only on purpose: these
Expand Down
8 changes: 8 additions & 0 deletions server/drizzle/0026_user_instructions.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
CREATE TABLE "user_instructions" (
"user_id" text PRIMARY KEY NOT NULL,
"instructions" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "user_instructions" ADD CONSTRAINT "user_instructions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
Loading