Skip to content

Commit 3cf15ef

Browse files
authored
Background Jobs + Basic Charm Indexer (#386)
* Tweak loader size * Add loading spinner for transcription * MVP BackgroundJob system * Basic charm indexing (add description to all charms) * Fix category of background tasks + format pass
1 parent d386998 commit 3cf15ef

File tree

9 files changed

+916
-468
lines changed

9 files changed

+916
-468
lines changed

typescript/packages/jumble/src/components/CommandCenter.tsx

Lines changed: 103 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import { Command } from "cmdk";
2-
import { useState, useEffect } from "react";
2+
import { useState, useEffect, useMemo, useCallback } from "react";
33
import "./commands.css";
44
import { useCharmManager } from "@/contexts/CharmManagerContext";
55
import { useMatch, useNavigate } from "react-router-dom";
66
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
77
import { DialogDescription, DialogTitle } from "@radix-ui/react-dialog";
88
import { DitheredCube } from "./DitherCube";
9-
import { CommandContext, CommandItem, CommandMode, commands, getTitle } from "./commands";
9+
import { CommandContext, CommandItem, CommandMode, commands, getChildren, getCommands, getTitle } from "./commands";
1010
import { usePreferredLanguageModel } from "@/contexts/LanguageModelContext";
1111
import { TranscribeInput } from "./TranscribeCommand";
12+
import { useBackgroundTasks } from "@/contexts/BackgroundTaskContext";
1213

1314
function CommandProcessor({
1415
mode,
@@ -60,7 +61,7 @@ function CommandProcessor({
6061
{mode.options.map((option) => (
6162
<Command.Item
6263
key={option.id}
63-
onSelect={() => mode.command.handler?.(context, option.value)} // Use mode.command instead
64+
onSelect={() => mode.command.handler?.(context, option.value)}
6465
>
6566
{option.title}
6667
</Command.Item>
@@ -77,16 +78,76 @@ export function CommandCenter() {
7778
const [open, setOpen] = useState(false);
7879
const [loading, setLoading] = useState(false);
7980
const [mode, setMode] = useState<CommandMode>({ type: "main" });
80-
const [commandPath, setCommandPath] = useState<CommandItem[]>([]);
81+
const [commandPathIds, setCommandPathIds] = useState<string[]>([]);
8182
const [search, setSearch] = useState("");
8283
const { modelId, setPreferredModel } = usePreferredLanguageModel();
84+
const { stopJob, startJob, addJobMessage, listJobs, updateJobProgress } = useBackgroundTasks();
8385

8486
const { charmManager } = useCharmManager();
8587
const navigate = useNavigate();
8688
const match = useMatch("/:replicaName/:charmId?/*");
8789
const focusedCharmId = match?.params.charmId ?? null;
8890
const focusedReplicaId = match?.params.replicaName ?? null;
8991

92+
const allCommands = useMemo(() => getCommands({
93+
charmManager,
94+
navigate,
95+
focusedCharmId,
96+
focusedReplicaId,
97+
setOpen,
98+
preferredModel: modelId ?? undefined,
99+
setPreferredModel,
100+
setMode,
101+
loading,
102+
setLoading,
103+
setModeWithInput: (mode: CommandMode, initialInput: string) => {
104+
Promise.resolve().then(() => {
105+
setMode(mode);
106+
setSearch(initialInput);
107+
});
108+
},
109+
stopJob,
110+
startJob,
111+
addJobMessage,
112+
listJobs,
113+
updateJobProgress,
114+
commandPathIds,
115+
}), [
116+
charmManager,
117+
navigate,
118+
focusedCharmId,
119+
focusedReplicaId,
120+
modelId,
121+
loading,
122+
commandPathIds,
123+
setMode,
124+
setPreferredModel,
125+
stopJob,
126+
startJob,
127+
addJobMessage,
128+
listJobs,
129+
updateJobProgress,
130+
]);
131+
132+
const getCommandById = useCallback((id: string): CommandItem | undefined => {
133+
const findInCommands = (commands: CommandItem[]): CommandItem | undefined => {
134+
for (const cmd of commands) {
135+
if (cmd.id === id) return cmd;
136+
if (cmd.children) {
137+
const found = findInCommands(cmd.children);
138+
if (found) return found;
139+
}
140+
}
141+
return undefined;
142+
};
143+
return findInCommands(allCommands);
144+
}, [allCommands]);
145+
146+
const currentCommandPath = useMemo(() =>
147+
commandPathIds.map(id => getCommandById(id)).filter((cmd): cmd is CommandItem => !!cmd),
148+
[commandPathIds, getCommandById]
149+
);
150+
90151
useEffect(() => {
91152
const down = (e: KeyboardEvent) => {
92153
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
@@ -117,7 +178,7 @@ export function CommandCenter() {
117178
useEffect(() => {
118179
if (!open) {
119180
setMode({ type: "main" });
120-
setCommandPath([]);
181+
setCommandPathIds([]);
121182
}
122183
}, [open]);
123184

@@ -128,19 +189,18 @@ export function CommandCenter() {
128189
setOpen(true);
129190
setMode({
130191
type: "input",
131-
command: commands.find((cmd) => cmd.id === "edit-recipe")!,
192+
command: allCommands.find((cmd) => cmd.id === "edit-recipe")!,
132193
placeholder: "What would you like to change?",
133194
});
134195
}
135196
};
136197

137198
const handleEditRecipeEvent = () => {
138199
if (focusedCharmId) {
139-
// Only open if there's a charm focused
140200
setOpen(true);
141201
setMode({
142202
type: "input",
143-
command: commands.find((cmd) => cmd.id === "edit-recipe")!,
203+
command: allCommands.find((cmd) => cmd.id === "edit-recipe")!,
144204
placeholder: "What would you like to change?",
145205
});
146206
}
@@ -153,7 +213,7 @@ export function CommandCenter() {
153213
document.removeEventListener("keydown", handleEditRecipe);
154214
window.removeEventListener("edit-recipe-command", handleEditRecipeEvent);
155215
};
156-
}, [focusedCharmId]); // Add focusedCharmId as a dependency
216+
}, [focusedCharmId, allCommands]);
157217

158218
const context: CommandContext = {
159219
charmManager,
@@ -172,28 +232,41 @@ export function CommandCenter() {
172232
setSearch(initialInput);
173233
});
174234
},
235+
stopJob,
236+
startJob,
237+
addJobMessage,
238+
listJobs,
239+
updateJobProgress,
240+
commandPathIds,
175241
};
176242

177243
const handleBack = () => {
178-
if (commandPath.length === 1) {
244+
if (commandPathIds.length === 1) {
179245
setMode({ type: "main" });
180-
setCommandPath([]);
246+
setCommandPathIds([]);
181247
} else {
182-
setCommandPath((prev) => prev.slice(0, -1));
183-
setMode({
184-
type: "menu",
185-
path: commandPath.slice(0, -1),
186-
parent: commandPath[commandPath.length - 2],
187-
});
248+
setCommandPathIds(prev => prev.slice(0, -1));
249+
const parentId = commandPathIds[commandPathIds.length - 2];
250+
const parentCommand = getCommandById(parentId);
251+
if (parentCommand) {
252+
setMode({
253+
type: "menu",
254+
path: commandPathIds.slice(0, -1),
255+
parent: parentCommand,
256+
});
257+
}
188258
}
189259
};
190260

191261
const getCurrentCommands = () => {
192-
const currentCommands =
193-
commandPath.length === 0 ? commands : commandPath[commandPath.length - 1].children || [];
262+
const commands = commandPathIds.length === 0
263+
? allCommands
264+
: getCommandById(commandPathIds[commandPathIds.length - 1])?.children ?? [];
194265

195-
return currentCommands.filter((cmd) => !cmd.predicate || cmd.predicate(context));
266+
return (commands)
267+
.filter(cmd => !cmd.predicate);
196268
};
269+
197270
return (
198271
<Command.Dialog title="Common" open={open} onOpenChange={setOpen} label="Command Menu">
199272
<VisuallyHidden>
@@ -229,7 +302,7 @@ export function CommandCenter() {
229302
if (mode.type === "input" && e.key === "Enter") {
230303
e.preventDefault();
231304
const command = mode.command;
232-
command.handler?.(context, search);
305+
command.handler?.(search);
233306
}
234307
}}
235308
style={{ flexGrow: 1 }}
@@ -248,14 +321,14 @@ export function CommandCenter() {
248321

249322
{mode.type === "main" || mode.type === "menu" ? (
250323
<>
251-
{commandPath.length > 0 && (
324+
{commandPathIds.length > 0 && (
252325
<Command.Item onSelect={handleBack}>
253-
← Back to {getTitle(commandPath[commandPath.length - 2].title, context) || "Main Menu"}
326+
← Back to {getCommandById(commandPathIds[commandPathIds.length - 2])?.title || "Main Menu"}
254327
</Command.Item>
255328
)}
256329

257330
{(() => {
258-
const groups = getCurrentCommands().reduce(
331+
const groups: Record<string, CommandItem[]> = getCurrentCommands().reduce(
259332
(acc, cmd) => {
260333
const group = cmd.group || "Other";
261334
if (!acc[group]) acc[group] = [];
@@ -272,15 +345,14 @@ export function CommandCenter() {
272345
key={cmd.id}
273346
onSelect={() => {
274347
if (cmd.children) {
275-
setCommandPath((prev) => [...prev, cmd]);
348+
setCommandPathIds((prev) => [...prev, cmd.id]);
276349
setMode({
277350
type: "menu",
278-
path: [...commandPath, cmd],
351+
path: [...commandPathIds, cmd.id],
279352
parent: cmd,
280353
});
281354
} else if (cmd.type === "action") {
282355
cmd.handler?.(context);
283-
// Only close if the handler doesn't set a new mode
284356
if (!cmd.handler || cmd.handler.length === 0) {
285357
setOpen(false);
286358
}
@@ -289,7 +361,7 @@ export function CommandCenter() {
289361
}
290362
}}
291363
>
292-
{typeof cmd.title === "function" ? cmd.title(context) : cmd.title}
364+
{cmd.title}
293365
{cmd.children && " →"}
294366
</Command.Item>
295367
))}
@@ -300,13 +372,13 @@ export function CommandCenter() {
300372
) : (
301373
<CommandProcessor
302374
mode={mode}
303-
command={commandPath[commandPath.length - 1]}
375+
command={currentCommandPath[currentCommandPath.length - 1]}
304376
context={context}
305377
onComplete={() => {
306378
setMode({
307379
type: "menu",
308-
path: commandPath,
309-
parent: commandPath[commandPath.length - 1],
380+
path: commandPathIds,
381+
parent: currentCommandPath[currentCommandPath.length - 1],
310382
});
311383
}}
312384
/>

typescript/packages/jumble/src/components/TranscribeCommand.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useEffect } from 'react';
22
import { useAudioRecorder } from '@/hooks/use-audio-recorder';
33
import { CommandContext, CommandItem } from './commands';
4+
import { DitheredCube } from './DitherCube';
45

56
interface TranscribeInputProps {
67
mode: { command: CommandItem; placeholder: string };
@@ -46,11 +47,14 @@ export function TranscribeInput({ mode, context }: TranscribeInputProps) {
4647

4748
return (
4849
<div className="flex items-center justify-center p-2 gap-2">
49-
<span className="text-sm text-red-500 animate-pulse">
50+
<span className={`text-sm ${isRecording ? 'text-red-500 animate-pulse' : ''}`}>
5051
{isRecording ? (
5152
<>🎤 Recording... {recordingSeconds}s</>
5253
) : isTranscribing ? (
53-
<>✍️ Transcribing...</>
54+
<div className="flex items-center gap-2">
55+
<DitheredCube width={24} height={24} animate animationSpeed={2} cameraZoom={12} />
56+
<span>Transcribing...</span>
57+
</div>
5458
) : null}
5559
</span>
5660
{isRecording && (

0 commit comments

Comments
 (0)