Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { Trans } from "@lingui/react/macro";
import { Textarea } from "@superset/ui/textarea";
import { useCallback, useEffect, useRef, useState } from "react";
import { HiCheckCircle } from "react-icons/hi2";

interface NamingInstructionsFieldProps {
id: string;
/** `null` means no instructions are set. */
savedValue: string | null;
placeholder: string;
/** Blank saves as `null`, which clears the setting. */
save: (instructions: string | null) => Promise<unknown>;
onSaved?: () => void;
}

type SaveStatus = "idle" | "saving" | "saved" | "error";

const SAVE_DEBOUNCE_MS = 500;
const SAVED_INDICATOR_MS = 2000;

export function NamingInstructionsField({
id,
savedValue: savedInstructions,
placeholder,
save,
onSaved,
}: NamingInstructionsFieldProps) {
const savedValue = savedInstructions ?? "";
const [value, setValue] = useState(savedValue);
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");

// Ask the DOM instead of tracking focus in a ref: a focus-tracking ref
// wedges stuck-true if the field unmounts while focused (same rationale
// as SparseCheckoutSection).
const textareaRef = useRef<HTMLTextAreaElement>(null);
const latestValueRef = useRef(value);
const lastSavedRef = useRef(savedValue);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const savedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const saveInFlightRef = useRef(false);
const queuedValueRef = useRef<string | null>(null);
const isMountedRef = useRef(true);
const saveRef = useRef(save);
saveRef.current = save;
const onSavedRef = useRef(onSaved);
onSavedRef.current = onSaved;

useEffect(() => {
// Don't clobber an in-progress edit when the saved value refetches.
const isFocused =
!!textareaRef.current && document.activeElement === textareaRef.current;
if (isFocused || debounceTimerRef.current || saveInFlightRef.current) {
return;
}
setValue(savedValue);
latestValueRef.current = savedValue;
lastSavedRef.current = savedValue;
}, [savedValue]);

useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
if (savedTimerRef.current) clearTimeout(savedTimerRef.current);
};
}, []);

const flushSave = useCallback(async (next: string): Promise<void> => {
// Remember the latest intent while a request is in flight; the
// `finally` below drains it once this attempt settles.
if (saveInFlightRef.current) {
queuedValueRef.current = next;
return;
}
if (next.trim() === lastSavedRef.current.trim()) {
queuedValueRef.current = null;
return;
}

if (savedTimerRef.current) {
clearTimeout(savedTimerRef.current);
savedTimerRef.current = null;
}
setSaveStatus("saving");
saveInFlightRef.current = true;
try {
await saveRef.current(next.trim() || null);
lastSavedRef.current = next;
if (!isMountedRef.current) return;
setSaveStatus("saved");
savedTimerRef.current = setTimeout(() => {
setSaveStatus("idle");
savedTimerRef.current = null;
}, SAVED_INDICATOR_MS);
onSavedRef.current?.();
} catch (err) {
console.warn("[naming-instructions/save] failed", err);
if (isMountedRef.current) setSaveStatus("error");
} finally {
saveInFlightRef.current = false;
const pending = queuedValueRef.current;
queuedValueRef.current = null;
if (pending !== null && isMountedRef.current) {
void flushSave(pending);
}
}
}, []);

const handleChange = useCallback(
(nextValue: string) => {
setValue(nextValue);
latestValueRef.current = nextValue;
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = setTimeout(() => {
debounceTimerRef.current = null;
void flushSave(latestValueRef.current);
}, SAVE_DEBOUNCE_MS);
},
[flushSave],
);

const handleBlur = useCallback(() => {
// Commit immediately on the way out instead of leaving the last
// keystrokes sitting in the debounce window.
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
void flushSave(latestValueRef.current);
}, [flushSave]);

return (
<>
<Textarea
ref={textareaRef}
id={id}
value={value}
onChange={(e) => handleChange(e.target.value)}
onBlur={handleBlur}
placeholder={placeholder}
rows={3}
spellCheck={false}
className="text-sm resize-y min-h-[calc(3lh+1.125rem)]"
/>
<div className="flex h-4 items-center justify-end text-xs text-muted-foreground">
{saveStatus === "saving" && (
<span>
<Trans>Saving…</Trans>
</span>
)}
{saveStatus === "saved" && (
<span className="flex items-center gap-1 text-emerald-600 dark:text-emerald-400">
<HiCheckCircle className="h-3.5 w-3.5" />
<Trans>Saved</Trans>
</span>
)}
{saveStatus === "error" && (
<span className="select-text cursor-text text-destructive">
<Trans>Couldn't save — edit or click away to retry</Trans>
</span>
)}
</div>
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { NamingInstructionsField } from "./NamingInstructionsField";
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
V2WorktreeLocationPicker,
} from "../../../components/V2WorktreeLocationPicker";
import { useDefaultWorktreePath } from "../../../components/WorktreeLocationPicker";
import { GlobalNamingInstructionsSection } from "./components/GlobalNamingInstructionsSection";

interface V2GitSettingsProps {
hostId: string | null;
Expand Down Expand Up @@ -256,6 +257,8 @@ export function V2GitSettings({ hostId }: V2GitSettingsProps) {
/>
</SettingsRow>
</section>

<GlobalNamingInstructionsSection />
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { Label } from "@superset/ui/label";
import { cloudTrpc } from "renderer/lib/cloud-trpc";
import { NamingInstructionsField } from "../../../../../components/NamingInstructionsField";

export function GlobalNamingInstructionsSection() {
const { t } = useLingui();
const utils = cloudTrpc.useUtils();
const { data: settings } = cloudTrpc.userSettings.get.useQuery();
const update = cloudTrpc.userSettings.update.useMutation({
onSuccess: (next) => utils.userSettings.get.setData(undefined, next),
});

return (
<section className="mt-8">
<div className="mb-3">
<Label
htmlFor="global-naming-instructions"
className="text-sm font-medium"
>
<Trans>Naming instructions</Trans>
</Label>
<p className="mt-0.5 text-xs text-muted-foreground">
<Trans>
Guides AI-generated workspace and branch names in every project, on
all your devices. A project's own instructions are added after these
and win where the two conflict.
</Trans>
</p>
</div>
{settings && (
<NamingInstructionsField
id="global-naming-instructions"
savedValue={settings.namingInstructions ?? null}
placeholder={t({
message:
"Include the Linear ticket id from the prompt in the branch name (e.g. bin-344-fix-login). Prefix branches with fix/ or feat/ based on the task.",
})}
save={(namingInstructions) =>
update.mutateAsync({ namingInstructions })
}
/>
)}
</section>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { GlobalNamingInstructionsSection } from "./GlobalNamingInstructionsSection";
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export const SETTING_ITEM_ID = {
GIT_BRANCH_PREFIX: "git-branch-prefix",
GIT_DELETE_LOCAL_BRANCH: "git-delete-local-branch",
GIT_WORKTREE_LOCATION: "git-worktree-location",
GIT_NAMING_INSTRUCTIONS: "git-naming-instructions",

AGENTS_ENABLED: "agents-enabled",
AGENTS_COMMANDS: "agents-commands",
Expand Down Expand Up @@ -217,6 +218,7 @@ export const SETTING_ITEM_VARIANT: Record<SettingItemId, SettingVariant> = {
[SETTING_ITEM_ID.GIT_BRANCH_PREFIX]: "shared",
[SETTING_ITEM_ID.GIT_DELETE_LOCAL_BRANCH]: "v1",
[SETTING_ITEM_ID.GIT_WORKTREE_LOCATION]: "shared",
[SETTING_ITEM_ID.GIT_NAMING_INSTRUCTIONS]: "v2",

[SETTING_ITEM_ID.AGENTS_ENABLED]: "shared",
[SETTING_ITEM_ID.AGENTS_COMMANDS]: "shared",
Expand Down Expand Up @@ -978,6 +980,25 @@ export const SETTINGS_ITEMS: SettingsItem[] = [
"default",
],
},
{
id: SETTING_ITEM_ID.GIT_NAMING_INSTRUCTIONS,
section: "git",
title: "Naming instructions",
description:
"Account-wide instructions for AI-generated workspace and branch names",
keywords: [
"git",
"branch",
"workspace",
"naming",
"name",
"instructions",
"ai",
"global",
"prefix",
"ticket",
],
},
{
id: SETTING_ITEM_ID.BEHAVIOR_OPEN_LINKS_IN_APP,
section: "browser",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useFeatureFlagEnabled } from "posthog-js/react";
import { useEffect, useMemo, useRef } from "react";
import { useHostProjects } from "renderer/hooks/host-projects/useHostProjects";
import { useHostUrl } from "renderer/hooks/host-service/useHostTargetUrl";
import { cloudTrpc } from "renderer/lib/cloud-trpc";
import { getHostServiceClientByUrl } from "renderer/lib/host-service-client";
import { useWorkspaceHostOptions } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions";
import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider";
Expand Down Expand Up @@ -104,6 +105,7 @@ export function V2ProjectSettings({
return client.project.get.query({ projectId });
},
});
const { data: userSettings } = cloudTrpc.userSettings.get.useQuery();
// External renames land on the merged fan-out item via project:changed;
// re-pull the targeted host's row so host-sourced fields (Name) follow.
const mergedUpdatedAt = project?.updatedAt;
Expand Down Expand Up @@ -141,6 +143,7 @@ export function V2ProjectSettings({
projectId={projectId}
project={project}
hostProject={hostProject}
globalNamingInstructions={userSettings?.namingInstructions ?? null}
targetHostUrl={targetHostUrl}
targetHostId={targetHostId}
targetHostName={targetHostName}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,57 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderToStaticMarkup } from "react-dom/server";
import { NamingInstructionsSection } from "./NamingInstructionsSection";

function renderEditor(instructions: string | null) {
type SectionProps = Parameters<typeof NamingInstructionsSection>[0];

function renderEditor(props: Partial<SectionProps> = {}) {
return renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<NamingInstructionsSection
projectId="project-1"
hostUrl="http://127.0.0.1:7777"
instructions={instructions}
instructions={null}
globalInstructions={null}
ignoreGlobalInstructions={false}
onChanged={() => {}}
{...props}
/>
</QueryClientProvider>,
);
}

describe("NamingInstructionsSection", () => {
test("opens on the host row's instructions", () => {
expect(renderEditor("Use the ticket id as the branch name")).toContain(
">Use the ticket id as the branch name</textarea>",
);
expect(
renderEditor({ instructions: "Use the ticket id as the branch name" }),
).toContain(">Use the ticket id as the branch name</textarea>");
});

test("opens empty on a host that has none", () => {
expect(renderEditor(null)).toContain("></textarea>");
expect(renderEditor()).toContain("></textarea>");
});

test("shows the global instructions it inherits", () => {
const html = renderEditor({ globalInstructions: "Prefix with feat/" });
expect(html).toContain('placeholder="Prefix with feat/"');
expect(html).toContain("Empty uses the global instructions.");
expect(html).toContain("Ignore global naming instructions");
});

test("does not show the global instructions when the project ignores them", () => {
const html = renderEditor({
globalInstructions: "Prefix with feat/",
ignoreGlobalInstructions: true,
});
expect(html).not.toContain("Prefix with feat/");
expect(html).toContain("Empty uses the default naming.");
});

test("a host without global instructions gets no toggle and no inherited text", () => {
const html = renderEditor({
globalInstructions: "Prefix with feat/",
ignoreGlobalInstructions: undefined,
});
expect(html).not.toContain("Prefix with feat/");
expect(html).not.toContain("Ignore global naming instructions");
});
});
Loading
Loading