• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

stacklok / codegate-ui / 13034749408

29 Jan 2025 03:31PM UTC coverage: 72.297%. Remained the same
13034749408

Pull #227

github

web-flow
Merge fdea945fe into efec5fa1e
Pull Request #227: refactor: ui for prompts picker

355 of 554 branches covered (64.08%)

Branch coverage included in aggregate %.

0 of 1 new or added line in 1 file covered. (0.0%)

2 existing lines in 1 file now uncovered.

775 of 1009 relevant lines covered (76.81%)

89.09 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

75.32
/src/features/workspace/components/workspace-custom-instructions.tsx
1
import Editor, { type Theme } from "@monaco-editor/react";
2
import {
3
  Button,
4
  Card,
5
  CardBody,
6
  CardFooter,
7
  DarkModeContext,
8
  Dialog,
9
  DialogCloseButton,
10
  DialogContent,
11
  DialogFooter,
12
  DialogHeader,
13
  DialogModal,
14
  DialogModalOverlay,
15
  DialogTitle,
16
  DialogTrigger,
17
  FieldGroup,
18
  Input,
19
  Link,
20
  Loader,
21
  SearchField,
22
  SearchFieldClearButton,
23
  Text,
24
  TextLink,
25
} from "@stacklok/ui-kit";
26
import {
27
  Dispatch,
28
  SetStateAction,
29
  useCallback,
30
  useContext,
31
  useEffect,
32
  useMemo,
33
  useState,
34
} from "react";
35

36
import { twMerge } from "tailwind-merge";
37
import {
38
  V1GetWorkspaceCustomInstructionsData,
39
  V1GetWorkspaceCustomInstructionsResponse,
40
  V1SetWorkspaceCustomInstructionsData,
41
} from "@/api/generated";
42

43
import {
44
  QueryCacheNotifyEvent,
45
  QueryClient,
46
  useQueryClient,
47
} from "@tanstack/react-query";
48
import { v1GetWorkspaceCustomInstructionsQueryKey } from "@/api/generated/@tanstack/react-query.gen";
49
import { useQueryGetWorkspaceCustomInstructions } from "../hooks/use-query-get-workspace-custom-instructions";
50
import { useMutationSetWorkspaceCustomInstructions } from "../hooks/use-mutation-set-workspace-custom-instructions";
51
import Fuse from "fuse.js";
52
import systemPrompts from "../constants/built-in-system-prompts.json";
53
import { MessageTextSquare02, SearchMd } from "@untitled-ui/icons-react";
54

55
type DarkModeContextValue = {
56
  preference: "dark" | "light" | null;
57
  override: "dark" | "light" | null;
58
};
59

60
function inferDarkMode(
61
  contextValue:
62
    | null
63
    | [DarkModeContextValue, Dispatch<SetStateAction<DarkModeContextValue>>],
64
): Theme {
65
  if (contextValue === null) return "light";
40!
66

67
  // Handle override
68
  if (contextValue[0].override === "dark") return "vs-dark";
40!
69
  if (contextValue[0].override === "light") return "light";
40!
70

71
  // Handle preference
72
  if (contextValue[0].preference === "dark") return "vs-dark";
40!
73
  return "light";
40✔
74
}
75

76
function EditorLoadingUI() {
77
  return (
78
    // arbitrary value to match the monaco editor height
79
    // eslint-disable-next-line tailwindcss/no-unnecessary-arbitrary-value
80
    <div className="min-h-[20rem] w-full flex items-center justify-center">
81
      <Loader className="my-auto" />
82
    </div>
83
  );
84
}
85

86
function isGetWorkspaceCustomInstructionsQuery(
87
  queryKey: unknown,
88
  options: V1GetWorkspaceCustomInstructionsData,
89
): boolean {
90
  return (
15✔
91
    Array.isArray(queryKey) &&
30✔
92
    queryKey[0]._id ===
93
      v1GetWorkspaceCustomInstructionsQueryKey(options)[0]?._id
94
  );
95
}
96

97
function getCustomInstructionsFromEvent(
98
  event: QueryCacheNotifyEvent,
99
): string | null {
100
  if ("action" in event === false || "data" in event.action === false)
5!
101
    return null;
×
102
  return (
5✔
103
    (
5!
104
      event.action.data as
105
        | V1GetWorkspaceCustomInstructionsResponse
106
        | undefined
107
        | null
108
    )?.prompt ?? null
109
  );
110
}
111

112
function useCustomInstructionsValue({
113
  initialValue,
114
  options,
115
  queryClient,
116
}: {
117
  initialValue: string;
118
  options: V1GetWorkspaceCustomInstructionsData;
119
  queryClient: QueryClient;
120
}) {
121
  const [value, setValue] = useState<string>(initialValue);
40✔
122

123
  // Subscribe to changes in the workspace system prompt value in the query cache
124
  useEffect(() => {
40✔
125
    const queryCache = queryClient.getQueryCache();
30✔
126
    const unsubscribe = queryCache.subscribe((event) => {
30✔
127
      if (
122✔
128
        event.type === "updated" &&
174✔
129
        event.action.type === "success" &&
130
        isGetWorkspaceCustomInstructionsQuery(
131
          event.query.options.queryKey,
132
          options,
133
        )
134
      ) {
135
        const prompt: string | null = getCustomInstructionsFromEvent(event);
5✔
136
        if (prompt === value || prompt === null) return;
5✔
137

138
        setValue(prompt);
3✔
139
      }
140
    });
141

142
    return () => {
30✔
143
      return unsubscribe();
30✔
144
    };
145
  }, [options, queryClient, value]);
146

147
  return { value, setValue };
40✔
148
}
149

150
type PromptPresetPickerProps = {
151
  onActivate: (text: string) => void;
152
};
153

154
function PromptPresetPicker({ onActivate }: PromptPresetPickerProps) {
155
  const [query, setQuery] = useState<string>("");
×
156

157
  const fuse = new Fuse(systemPrompts, {
×
158
    keys: ["name", "text"],
159
  });
160

161
  const handleActivate = useCallback(
×
162
    (prompt: string) => {
163
      onActivate(prompt);
×
164
    },
165
    [onActivate],
166
  );
167

168
  return (
169
    <>
170
      <DialogHeader>
171
        <DialogTitle>Choose a prompt template</DialogTitle>
172
        <DialogCloseButton />
173
      </DialogHeader>
174
      <DialogContent className="shrink-0 py-4">
175
        <SearchField className="w-full" value={query} onChange={setQuery}>
176
          <FieldGroup>
177
            <Input
178
              isBorderless
179
              icon={<SearchMd />}
180
              placeholder="Type to search"
181
              autoFocus
182
            />
183
            {query.length > 0 ? <SearchFieldClearButton /> : null}
×
184
          </FieldGroup>
185
        </SearchField>
186
      </DialogContent>
187
      <DialogContent className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4 pt-0">
188
        {fuse.search(query.length > 0 ? query : " ").map(({ item }) => {
×
189
          return (
190
            <Card className="min-h-96">
191
              <h2 className="font-bold p-2 flex gap-2 items-center">
192
                <MessageTextSquare02 className="size-4" />
193
                <div className="truncate">{item.name}</div>
194
              </h2>
195
              <pre className="h-72 overflow-hidden text-wrap text-sm bg-gray-50 p-2 overflow-y-auto">
196
                {item.text}
197
              </pre>
198
              <div className="flex gap-4 justify-between p-2">
199
                <div className="h-full items-center">
200
                  <div className="flex h-full items-center max-w-52 text-clip">
201
                    {item.contributors.map((contributor) => (
202
                      <Link
203
                        className="font-bold text-sm no-underline text-secondary flex gap-1 items-center hover:bg-gray-200 h-full px-2 rounded-md"
204
                        target="_blank"
205
                        href={`https://github.com/${contributor}/`}
206
                      >
207
                        <img
208
                          className="size-6 rounded-full"
209
                          src={`https://github.com/${contributor}.png?size=24`}
210
                        />
211
                        <span className="truncate">{contributor}</span>
212
                      </Link>
213
                    ))}
214
                  </div>
215
                </div>
216
                <Button
217
                  slot="close"
218
                  variant="secondary"
219
                  onPress={() => {
NEW
UNCOV
220
                    handleActivate(item.text);
×
221
                  }}
222
                >
223
                  Use prompt
224
                </Button>
225
              </div>
226
            </Card>
227
          );
228
        })}
229
      </DialogContent>
230
      <DialogFooter>
231
        <span className="ml-auto">
232
          Prompt templates sourced from{" "}
233
          <TextLink className="text-primary" href="https://cursor.directory">
234
            cursor.directory
235
          </TextLink>
236
        </span>
237
      </DialogFooter>
238
    </>
239
  );
240
}
241

242
export function WorkspaceCustomInstructions({
243
  className,
244
  workspaceName,
245
  isArchived,
246
}: {
247
  className?: string;
248
  workspaceName: string;
249
  isArchived: boolean | undefined;
250
}) {
251
  const context = useContext(DarkModeContext);
40✔
252
  const theme: Theme = inferDarkMode(context);
40✔
253

254
  const options: V1GetWorkspaceCustomInstructionsData &
255
    Omit<V1SetWorkspaceCustomInstructionsData, "body"> = useMemo(
40✔
256
    () => ({
6✔
257
      path: { workspace_name: workspaceName },
258
    }),
259
    [workspaceName],
260
  );
261

262
  const queryClient = useQueryClient();
40✔
263

264
  const {
265
    data: customInstructionsResponse,
266
    isPending: isCustomInstructionsPending,
267
  } = useQueryGetWorkspaceCustomInstructions(options);
40✔
268
  const { mutateAsync, isPending: isMutationPending } =
269
    useMutationSetWorkspaceCustomInstructions(options);
40✔
270

271
  const { setValue, value } = useCustomInstructionsValue({
40✔
272
    initialValue: customInstructionsResponse?.prompt ?? "",
52✔
273
    options,
274
    queryClient,
275
  });
276

277
  const handleSubmit = useCallback(
40✔
278
    (value: string) => {
279
      mutateAsync(
1✔
280
        { ...options, body: { prompt: value } },
281
        {
282
          onSuccess: () => {
283
            queryClient.invalidateQueries({
1✔
284
              queryKey: v1GetWorkspaceCustomInstructionsQueryKey(options),
285
              refetchType: "all",
286
            });
287
          },
288
        },
289
      );
290
    },
291
    [mutateAsync, options, queryClient],
292
  );
293

294
  return (
295
    <Card className={twMerge(className, "shrink-0")}>
296
      <CardBody>
297
        <Text className="text-primary">Custom instructions</Text>
298
        <Text className="text-secondary mb-4">
299
          Pass custom instructions to your LLM to augment its behavior, and save
300
          time & tokens.
301
        </Text>
302
        <div className="border border-gray-200 rounded overflow-hidden">
303
          {isCustomInstructionsPending ? (
304
            <EditorLoadingUI />
12✔
305
          ) : (
306
            <Editor
307
              options={{
308
                minimap: { enabled: false },
309
                readOnly: isArchived,
310
              }}
311
              value={value}
312
              onChange={(v) => setValue(v ?? "")}
21!
313
              height="20rem"
314
              defaultLanguage="Markdown"
315
              theme={theme}
316
              className={twMerge("bg-base", isArchived ? "opacity-25" : "")}
28!
317
            />
318
          )}
319
        </div>
320
      </CardBody>
321
      <CardFooter className="justify-end gap-2">
322
        <DialogTrigger>
323
          <Button variant="secondary">Use a preset</Button>
324
          <DialogModalOverlay isDismissable>
325
            <DialogModal isDismissable>
326
              <Dialog
327
                width="lg"
328
                className="min-h-[44rem]"
329
                style={{ maxWidth: "min(calc(100vw - 64px), 1200px)" }}
330
              >
331
                <PromptPresetPicker
332
                  onActivate={(prompt: string) => {
UNCOV
333
                    setValue(prompt);
×
334
                  }}
335
                />
336
              </Dialog>
337
            </DialogModal>
338
          </DialogModalOverlay>
339
        </DialogTrigger>
340
        <Button
341
          isPending={isMutationPending}
342
          isDisabled={Boolean(isArchived ?? isCustomInstructionsPending)}
52✔
343
          onPress={() => handleSubmit(value)}
1✔
344
        >
345
          Save
346
        </Button>
347
      </CardFooter>
348
    </Card>
349
  );
350
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc