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
15 changes: 9 additions & 6 deletions apps/desktop/src/components/agents/agent-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { api, errorMessage } from "@/lib/api";
import { qk } from "@/lib/queryKeys";
import { useScopeWorkspace } from "@/lib/hooks";
import { modKey } from "@/lib/desktop";
import { clearDraft, useDraft } from "@/lib/drafts";
import { useLive, type LiveRun } from "@/stores/live";
import { AgentAvatar, Kbd } from "@/components/common";
import { LiveDot } from "@/components/aicss/Motion";
Expand Down Expand Up @@ -93,14 +94,16 @@ export function RunTaskDialog({
const navigate = useNavigate();
const qc = useQueryClient();
const workspace = useScopeWorkspace();
const [prompt, setPrompt] = useState("");
const [prompt, setPrompt, promptDraft] = useDraft(agent ? `run-task:${agent.id}` : undefined, "");
const run = useMutation({
mutationFn: () => api.agents.run(agent!.id, prompt.trim(), workspace?.id),
onSuccess: (res) => {
mutationFn: (input: { agentId: string; prompt: string }) => api.agents.run(input.agentId, input.prompt, workspace?.id),
onSuccess: (res, input) => {
qc.invalidateQueries({ queryKey: qk.conversationsAll });
qc.invalidateQueries({ queryKey: qk.runs });
toast.success(`${agent?.name ?? "Agent"} is on it`);
setPrompt("");
// Text typed while it was starting stays as the next draft.
if (input.agentId !== agent?.id) clearDraft(`run-task:${input.agentId}`);
else if (prompt.trim() === input.prompt) promptDraft.discard();
onOpenChange(false);
navigate(`/chat/${res.conversation.id}`);
},
Expand All @@ -123,7 +126,7 @@ export function RunTaskDialog({
<form
onSubmit={(e) => {
e.preventDefault();
if (canRun) run.mutate();
if (canRun) run.mutate({ agentId: agent.id, prompt: prompt.trim() });
}}
className="space-y-4"
>
Expand All @@ -139,7 +142,7 @@ export function RunTaskDialog({
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
if (canRun) run.mutate();
if (canRun) run.mutate({ agentId: agent.id, prompt: prompt.trim() });
}
}}
placeholder={agent?.description ? `e.g. ${agent.description}` : "Describe what it should do…"}
Expand Down
26 changes: 10 additions & 16 deletions apps/desktop/src/components/agents/agent-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ import { qk } from "@/lib/queryKeys";
import { useAllAgents, useBootstrap, useModelCatalog, useVmChoices, useWorkspaces } from "@/lib/hooks";
import { isMac, modKey } from "@/lib/desktop";
import { useUi } from "@/stores/ui";
import { useDraft } from "@/lib/drafts";
import { cn } from "@/lib/utils";
import { AgentAvatar, Kbd, Section } from "@/components/common";
import { AgentAvatar, DraftStatus, Kbd, Section } from "@/components/common";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
Expand Down Expand Up @@ -187,6 +188,7 @@ export function AgentForm({
onSubmit,
onCancel,
footerExtra,
draftKey,
}: {
initial?: Partial<Agent>;
agentId?: string;
Expand All @@ -198,6 +200,8 @@ export function AgentForm({
onCancel?: () => void;
/** Rendered above the submit bar (e.g. template automation opt-in). */
footerExtra?: ReactNode;
/** Keep unsaved values as a draft under this key; clear it with `clearDraft` once saved. */
draftKey?: string;
}) {
const { data: boot } = useBootstrap();
const { catalog } = useModelCatalog();
Expand All @@ -210,24 +214,13 @@ export function AgentForm({
[scope, boot?.settings.security.defaultSecretAccess],
);
const seed = useMemo(() => agentToValues(initial, defaults), [initial, defaults]);
const seedKey = JSON.stringify(seed);
const [values, setValues] = useState<AgentFormValues>(seed);
const [baseline, setBaseline] = useState(seedKey);
// Realtime updates flow into the fields that haven't been edited.
const [values, setValues, draft] = useDraft<AgentFormValues>(draftKey, seed);
const [showErrors, setShowErrors] = useState(false);
const dirty = JSON.stringify(values) !== baseline;
const dirty = JSON.stringify(values) !== JSON.stringify(seed);
const effectiveModel = findModel(catalog.models, values.model || boot?.settings.runner.model || DEFAULT_MODEL);
const efforts: readonly Effort[] = effectiveModel?.efforts ?? EFFORT_OPTIONS;

// Pick up external changes (realtime updates) while the user hasn't edited anything
const dirtyRef = useRef(dirty);
dirtyRef.current = dirty;
useEffect(() => {
if (!dirtyRef.current) {
setValues(JSON.parse(seedKey) as AgentFormValues);
setBaseline(seedKey);
}
}, [seedKey]);

const errors = validate(values);
const hasErrors = Object.keys(errors).length > 0;
const set = <K extends keyof AgentFormValues>(key: K, value: AgentFormValues[K]) => setValues((v) => ({ ...v, [key]: value }));
Expand Down Expand Up @@ -683,6 +676,7 @@ export function AgentForm({
</span>
)}
</div>
{mode === "create" && draft.saved && <DraftStatus onDiscard={draft.discard} />}
<span className="hidden items-center gap-1 text-xs text-muted-foreground @2xl:flex">
<Kbd>{modKey}S</Kbd>
</span>
Expand All @@ -692,7 +686,7 @@ export function AgentForm({
</Button>
)}
{mode === "edit" && dirty && !onCancel && (
<Button type="button" variant="ghost" onClick={() => setValues(JSON.parse(baseline) as AgentFormValues)}>
<Button type="button" variant="ghost" onClick={draft.discard}>
Discard
</Button>
)}
Expand Down
6 changes: 5 additions & 1 deletion apps/desktop/src/components/agents/detail/settings-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Trash2 } from "lucide-react";
import type { Agent, AgentInput } from "@godmode/shared";
import { api, errorMessage } from "@/lib/api";
import { qk } from "@/lib/queryKeys";
import { clearDraft } from "@/lib/drafts";
import { Section } from "@/components/common";
import { Button } from "@/components/ui/button";
import { isGrantCancelled, withGrant } from "@/components/vault/grant";
Expand All @@ -17,10 +18,12 @@ export function SettingsTab({ agent }: { agent: Agent }) {
const navigate = useNavigate();
const [formKey, setFormKey] = useState(0);
const [confirmDelete, setConfirmDelete] = useState(false);
const draftKey = `agent:${agent.id}`;

const save = useMutation({
mutationFn: (input: AgentInput) => withGrant((grant) => api.agents.update(agent.id, input, grant)),
onSuccess: (updated) => {
clearDraft(draftKey);
qc.setQueryData(qk.agent(agent.id), updated);
qc.invalidateQueries({ queryKey: qk.agents });
qc.invalidateQueries({ queryKey: qk.agentFile(agent.id, "CLAUDE.md") });
Expand All @@ -34,10 +37,11 @@ export function SettingsTab({ agent }: { agent: Agent }) {
return (
<div className="space-y-6">
<AgentForm
key={formKey}
key={`${agent.id}:${formKey}`}
mode="edit"
initial={agent}
agentId={agent.id}
draftKey={draftKey}
isDefault={agent.isDefault}
submitLabel="Save changes"
pending={save.isPending}
Expand Down
29 changes: 22 additions & 7 deletions apps/desktop/src/components/agents/routine-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import { api, errorMessage } from "@/lib/api";
import { qk } from "@/lib/queryKeys";
import { useAllAgents, useModelCatalog } from "@/lib/hooks";
import { modKey } from "@/lib/desktop";
import { Kbd } from "@/components/common";
import { clearDraft, useDraft } from "@/lib/drafts";
import { DraftStatus, Kbd } from "@/components/common";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
Expand Down Expand Up @@ -123,10 +124,13 @@ export function RoutineDialog({
const { data: agents = [] } = useAllAgents();
const { catalog } = useModelCatalog();
const editing = !!routine;
const [draft, setDraft] = useState<RoutineDraft>(() => initialDraft(routine, agentId, initial));
const [draftKey] = useState(() => (routine ? `routine:${routine.id}` : `routine:new:${agentId ?? "any"}:${JSON.stringify(initial ?? {})}`));
const [base] = useState(() => initialDraft(routine, agentId, initial));
const [draft, setDraft, kept] = useDraft(draftKey, base);
const [touched, setTouched] = useState(false);
/** Until the human sets "Keep one conversation" on a new automation, it follows the trigger type's default. */
const [reuseChosen, setReuseChosen] = useState(editing || initial?.reuseConversation !== undefined);
const reusePreset = editing || initial?.reuseConversation !== undefined;
const [reuseChosen, setReuseChosen] = useState(reusePreset || draft.reuseConversation !== defaultReuse(draft.triggerType));
/** An automation that just became a webhook: its URL is shown before the dialog closes. */
const [created, setCreated] = useState<Routine | null>(null);
const set = <K extends keyof RoutineDraft>(k: K, v: RoutineDraft[K]) => setDraft((d) => ({ ...d, [k]: v }));
Expand Down Expand Up @@ -206,6 +210,7 @@ export function RoutineDialog({
return routine ? api.routines.update(routine.id, input) : api.routines.create(input);
},
onSuccess: (r) => {
clearDraft(draftKey);
qc.invalidateQueries({ queryKey: qk.routines });
if (editing) toast.success("Automation updated", { description: r.name });
else if (r.trigger.type === "schedule") toast.success("Automation scheduled", { description: r.name });
Expand Down Expand Up @@ -485,10 +490,20 @@ export function RoutineDialog({
</div>

<DialogFooter className="items-center sm:justify-between">
<span className="hidden items-center gap-1 text-xs text-muted-foreground sm:flex">
<Kbd>{modKey}</Kbd>
<Kbd>↵</Kbd> to save
</span>
{kept.saved ? (
<DraftStatus
onDiscard={() => {
kept.discard();
setTouched(false);
setReuseChosen(reusePreset);
}}
/>
) : (
<span className="hidden items-center gap-1 text-xs text-muted-foreground sm:flex">
<Kbd>{modKey}</Kbd>
<Kbd>↵</Kbd> to save
</span>
)}
<div className="flex gap-2">
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/components/automations/one-prompt-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { api, errorMessage } from "@/lib/api";
import { qk } from "@/lib/queryKeys";
import { useAllAgents, useBootstrap, useScopeWorkspace } from "@/lib/hooks";
import { modKey } from "@/lib/desktop";
import { useDraft } from "@/lib/drafts";
import { cn } from "@/lib/utils";
import { Backdrop } from "@/components/brand";
import { Kbd } from "@/components/common";
Expand All @@ -35,7 +36,7 @@ export function OnePromptCard({ compact = false }: { compact?: boolean }) {
const { data: boot } = useBootstrap();
const { data: agents = [] } = useAllAgents();
const workspace = useScopeWorkspace();
const [text, setText] = useState("");
const [text, setText, textDraft] = useDraft("automation:describe", "");
const [focused, setFocused] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const godmodeId = boot?.defaultAgentId ?? agents.find((a) => a.isDefault)?.id;
Expand All @@ -44,6 +45,7 @@ export function OnePromptCard({ compact = false }: { compact?: boolean }) {
const start = useMutation({
mutationFn: () => api.chat.start({ agentId: godmodeId, content: `Set up an automation: ${text.trim()}`, workspaceId: workspace?.id }),
onSuccess: (res) => {
textDraft.discard();
qc.setQueryData<ConversationWithMessages>(qk.conversation(res.conversation.id), {
...res.conversation,
messages: [res.message],
Expand Down
58 changes: 21 additions & 37 deletions apps/desktop/src/components/chat/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { Kbd } from "@/components/common";
import { useDictation, useVoiceSettings } from "@/hooks/use-voice";
import { useVoicePrefs, useVoiceSession, stopSpeaking } from "@/lib/voice";
import { useUi } from "@/stores/ui";
import { loadDraft, saveDraft, useDraft } from "@/lib/drafts";
import { cn } from "@/lib/utils";
import { AttachmentChip, MAX_ATTACHMENT_BYTES, MAX_ATTACHMENTS, readAttachment, type PendingAttachment } from "./attachments";
import { LevelBars } from "./voice-visuals";
Expand Down Expand Up @@ -57,7 +58,7 @@ interface ComposerProps {
/** Rendered before the voice and send buttons (e.g. model picker) */
trailing?: ReactNode;
size?: "md" | "lg";
/** Persist an unsent draft (sessionStorage) under this key */
/** Keep the unsent text and files as a draft under this key */
draftKey?: string;
/** Agent whose Claude Code slash commands are offered after typing "/" */
agentId?: string;
Expand All @@ -71,24 +72,7 @@ function joinText(base: string, add: string): string {
return /\s$/.test(base) ? base + add : `${base} ${add}`;
}

function loadDraft(key?: string): string {
if (!key) return "";
try {
return sessionStorage.getItem(`gm-draft:${key}`) ?? "";
} catch {
return "";
}
}

function saveDraft(key: string | undefined, text: string) {
if (!key) return;
try {
if (text) sessionStorage.setItem(`gm-draft:${key}`, text);
else sessionStorage.removeItem(`gm-draft:${key}`);
} catch {
/* ignore */
}
}
const NO_ATTACHMENTS: PendingAttachment[] = [];

export function Composer({
onSubmit,
Expand All @@ -105,8 +89,10 @@ export function Composer({
ref,
}: ComposerProps) {
const navigate = useNavigate();
const [text, setText] = useState(() => loadDraft(draftKey));
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
const textKey = draftKey ? `chat:${draftKey}` : undefined;
const filesKey = draftKey ? `chat:${draftKey}:files` : undefined;
const [text, setText, textDraft] = useDraft(textKey, "");
const [attachments, setAttachments, filesDraft] = useDraft(filesKey, NO_ATTACHMENTS, { persist: false });
const [focused, setFocused] = useState(false);
const [dragOver, setDragOver] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
Expand All @@ -128,19 +114,13 @@ export function Composer({
const setVoiceMode = useUi((s) => s.setVoiceMode);
const armVoice = useVoiceSession((s) => s.arm);

// Draft persistence
useEffect(() => {
setText(loadDraft(draftKey));
}, [draftKey]);
useEffect(() => {
const t = setTimeout(() => saveDraft(draftKey, text), 250);
return () => clearTimeout(t);
}, [draftKey, text]);

// Revoke thumbnails on unmount
// Revoke thumbnails on unmount, unless they stay in the draft
const attachmentsRef = useRef(attachments);
attachmentsRef.current = attachments;
useEffect(() => () => attachmentsRef.current.forEach((a) => a.previewUrl && URL.revokeObjectURL(a.previewUrl)), []);
const keepsFiles = !!filesKey;
useEffect(() => () => {
if (!keepsFiles) attachmentsRef.current.forEach((a) => a.previewUrl && URL.revokeObjectURL(a.previewUrl));
}, [keepsFiles]);

// Autosize
useLayoutEffect(() => {
Expand All @@ -152,7 +132,10 @@ export function Composer({

useEffect(() => {
// preventScroll: focusing must never scroll an ancestor (it used to drag the whole page with it).
if (autoFocus) textareaRef.current?.focus({ preventScroll: true });
const el = textareaRef.current;
if (!autoFocus || !el) return;
el.focus({ preventScroll: true });
el.setSelectionRange(el.value.length, el.value.length);
}, [autoFocus]);

const dictation = useDictation({
Expand Down Expand Up @@ -266,15 +249,16 @@ export function Composer({
const sent = attachments;
const voice = voiceRef.current;
setMenuForced(false);
setText("");
setAttachments([]);
textDraft.discard();
filesDraft.discard();
voiceRef.current = false;
saveDraft(draftKey, "");
try {
await onSubmit({ content, attachments: sent.map(({ name, mime, data }) => ({ name, mime, data })), voice });
sent.forEach((a) => a.previewUrl && URL.revokeObjectURL(a.previewUrl));
} catch {
// Restore so nothing is lost; the caller shows the error.
// Restore so nothing is lost, also when the composer is gone by now; the caller shows the error.
if (textKey && content && !loadDraft(textKey, "")) saveDraft(textKey, content, "");
if (filesKey && sent.length && !loadDraft(filesKey, NO_ATTACHMENTS).length) saveDraft(filesKey, sent, NO_ATTACHMENTS, false);
setText((cur) => cur || content);
setAttachments((cur) => (cur.length ? cur : sent));
voiceRef.current = voice;
Expand Down
23 changes: 23 additions & 0 deletions apps/desktop/src/components/common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,29 @@ export function StatusDot({ status, className }: { status: "ok" | "warn" | "erro
return <span className={cn("inline-block size-2 rounded-full", map[status], className)} />;
}

export function DraftStatus({ onDiscard, className }: { onDiscard: () => void; className?: string }) {
return (
<motion.span
initial={{ opacity: 0, y: 2 }}
animate={{ opacity: 1, y: 0 }}
className={cn("inline-flex items-center gap-1.5 text-xs whitespace-nowrap text-muted-foreground", className)}
>
<span aria-hidden className="size-1.5 shrink-0 rounded-full bg-brand" />
Draft saved
<span aria-hidden className="opacity-40">
·
</span>
<button
type="button"
onClick={onDiscard}
className="rounded-sm font-medium text-foreground/70 transition hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none"
>
Discard
</button>
</motion.span>
);
}

export function Kbd({ children }: { children: ReactNode }) {
return (
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-0.5 rounded-[4px] border bg-card px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
Expand Down
Loading
Loading