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
29 changes: 23 additions & 6 deletions src/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,16 @@ export function App() {
setState(s);
setWorkspace(w);
setNeedsAuth(false);
setSelectedDot((previous) => previous || w.dots[0]?.id || '');
setSelectedDot((previous) =>
w.dots.some((item) => item.id === previous)
? previous
: (w.dots[0]?.id ?? ''),
);
setSelectedThread((previous) =>
w.conversations.some((item) => item.id === previous)
? previous
: undefined,
);
setNotices((current) => applyRefreshResult(current, { ok: true }));
} catch (e) {
if (e instanceof ApiError && e.status === 401) setNeedsAuth(true);
Expand Down Expand Up @@ -184,17 +193,25 @@ export function App() {
clearInterval(timer);
};
}, [selectedThread]);
const mutate = async (path: string, method: string, body?: unknown) => {
const mutate = async (
path: string,
method: string,
body?: unknown,
): Promise<{ ok: true } | { ok: false; error: string }> => {
setError('');
try {
await api(path, method, body);
await refresh();
if (taskDetail)
if (method === 'DELETE' && path.startsWith('/dots/')) {
setTaskDetail(undefined);
}
if (taskDetail && !(method === 'DELETE' && path.startsWith('/dots/')))
setTaskDetail(await api<Detail>(`/tasks/${taskDetail.task.id}`));
return true;
return { ok: true };
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not save.');
return false;
const message = e instanceof Error ? e.message : 'Could not save.';
setError(message);
return { ok: false, error: message };
}
};
const dot =
Expand Down
100 changes: 93 additions & 7 deletions src/client/WorkspaceDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { X } from 'lucide-react';
import { Trash2, X } from 'lucide-react';
import type { Dot, Memory, State, WorkspaceState } from '../shared/types';
import { ConnectionsSection } from './ConnectionsSection';
import {
Expand All @@ -24,7 +24,11 @@ export function WorkspaceDialog({
state: State;
workspace: WorkspaceState;
onClose: () => void;
mutate: (path: string, method: string, body?: unknown) => Promise<boolean>;
mutate: (
path: string,
method: string,
body?: unknown,
) => Promise<{ ok: true } | { ok: false; error: string }>;
}) {
const [name, setName] = useState(
dialog.type === 'dot' ? (dialog.dot?.name ?? '') : '',
Expand Down Expand Up @@ -71,6 +75,8 @@ export function WorkspaceDialog({
);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [confirmDelete, setConfirmDelete] = useState(false);
const [confirmName, setConfirmName] = useState('');
const [theme, setTheme] = useState(themePreference);
const container = useRef<HTMLElement>(null);
useEffect(() => {
Expand All @@ -88,7 +94,7 @@ export function WorkspaceDialog({
...(container.current?.querySelectorAll<HTMLElement>(
'button:not([disabled]),input,textarea,select,a[href]',
) ?? []),
];
].filter((item) => !item.closest('[hidden]'));
if (event.shiftKey && document.activeElement === items[0]) {
event.preventDefault();
items.at(-1)?.focus();
Expand All @@ -101,9 +107,16 @@ export function WorkspaceDialog({
document.addEventListener('keydown', key);
return () => {
document.removeEventListener('keydown', key);
previous?.focus();
if (previous?.isConnected) previous.focus();
else document.querySelector<HTMLElement>('.dots-nav .dot-nav')?.focus();
};
}, []);
useEffect(() => {
if (confirmDelete)
container.current
?.querySelector<HTMLInputElement>('#confirm-dot-name')
?.focus();
}, [confirmDelete]);
const title =
dialog.type === 'space'
? 'A space for something.'
Expand Down Expand Up @@ -135,7 +148,66 @@ export function WorkspaceDialog({
</button>
<span className="eyebrow">OPENDOTS TEMPLATE</span>
<h2 id="dialog-title">{title}</h2>
{confirmDelete && dialog.type === 'dot' && dialog.dot && (
<div className="dot-delete-confirmation">
<p>
Deleting <strong>{dialog.dot.name}</strong> removes it and its
conversations, calls, scheduled tasks, connections, and specialist
settings from this workspace. Shared Spaces and pages will remain.
Transcripts held by a configured conversation provider may be
retained there.
</p>
<label className="field-label" htmlFor="confirm-dot-name">
Type <strong>{dialog.dot.name}</strong> to confirm
</label>
<input
id="confirm-dot-name"
value={confirmName}
autoComplete="off"
onChange={(event) => setConfirmName(event.target.value)}
/>
{error && (
<p className="chat-error" role="alert">
{error}
</p>
)}
<div className="dot-delete-actions">
<button
type="button"
className="secondary"
disabled={busy}
onClick={() => {
setConfirmDelete(false);
setConfirmName('');
setError('');
}}
>
Back
</button>
<button
type="button"
className="danger-button"
disabled={busy || confirmName.trim() !== dialog.dot.name}
onClick={async () => {
setBusy(true);
setError('');
const result = await mutate(
`/dots/${dialog.dot!.id}`,
'DELETE',
);
if (result.ok) onClose();
else setError(result.error);
setBusy(false);
}}
>
<Trash2 size={14} />
{busy ? 'Deleting…' : 'Delete Dot'}
</button>
</div>
</div>
)}
<form
hidden={confirmDelete}
onSubmit={async (e) => {
e.preventDefault();
setBusy(true);
Expand Down Expand Up @@ -190,9 +262,9 @@ export function WorkspaceDialog({
intervalSeconds: Number(interval),
};
}
if (await mutate(path, method, body)) onClose();
else
setError('Could not save. Review the workspace error and retry.');
const result = await mutate(path, method, body);
if (result.ok) onClose();
else setError(result.error);
setBusy(false);
}}
>
Expand Down Expand Up @@ -510,6 +582,20 @@ export function WorkspaceDialog({
<button className="primary full" disabled={busy}>
{busy ? 'Saving…' : 'Save'}
</button>
{dialog.type === 'dot' && dialog.dot && (
<button
type="button"
className="danger-link full"
disabled={busy}
onClick={() => {
setConfirmName('');
setError('');
setConfirmDelete(true);
}}
>
<Trash2 size={14} /> Delete this Dot
</button>
)}
</form>
</section>
</div>
Expand Down
42 changes: 42 additions & 0 deletions src/client/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -1299,6 +1299,48 @@ h3 {
right: 15px;
top: 15px;
}
.danger-link,
.danger-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
border: 1px solid #d78a83;
border-radius: 8px;
color: #a7463e;
background: transparent;
padding: 9px 12px;
font-size: 11px;
}
.danger-link:hover:not(:disabled),
.danger-button:hover:not(:disabled) {
background: #fff2ef;
}
.danger-link:disabled,
.danger-button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.dot-delete-confirmation > p {
font-size: 12px;
line-height: 1.7;
color: #777b89;
}
.dot-delete-confirmation .field-label {
margin-top: 24px;
}
.dot-delete-confirmation > input {
width: 100%;
}
.dot-delete-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 18px;
}
.dot-delete-actions button {
min-height: 38px;
}
.field-label {
display: block;
font-size: 11px;
Expand Down
2 changes: 2 additions & 0 deletions src/server/dot-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export class DotAgent extends AbstractAgent {
}
run(input: RunAgentInput): Observable<BaseEvent> {
return new Observable((subscriber) => {
const endDotRun = this.workspace.trackDotRun(this.dotId);
const controller = new AbortController();
this.controller = controller;
let subscription: { unsubscribe(): void } | undefined;
Expand Down Expand Up @@ -467,6 +468,7 @@ export class DotAgent extends AbstractAgent {
fail(error);
}
return () => {
endDotRun();
clearTimeout(timeout);
clearInterval(watcher);
controller.abort();
Expand Down
13 changes: 13 additions & 0 deletions src/server/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,19 @@ export class Store {
.prepare('SELECT * FROM tasks WHERE id=?')
.get(id) as unknown as Task | undefined;
}
deleteTasks(ids: string[]) {
if (!ids.length) return;
this.transaction(() => {
const marks = ids.map(() => '?').join(',');
this.db
.prepare(`DELETE FROM events WHERE taskId IN (${marks})`)
.run(...ids);
this.db
.prepare(`DELETE FROM runs WHERE taskId IN (${marks})`)
.run(...ids);
this.db.prepare(`DELETE FROM tasks WHERE id IN (${marks})`).run(...ids);
});
}
createTask(prompt: string, intervalSeconds: number | null = null): Task {
const now = Date.now();
const id = randomUUID();
Expand Down
41 changes: 41 additions & 0 deletions src/server/workspace-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,47 @@ export function workspaceRoutes(platform: Platform, voice: VoiceService) {
}
return c.json(platform.workspace.updateDot(c.req.param('id'), data.data));
});
app.delete('/dots/:id', (c) => {
const id = c.req.param('id');
const dots = platform.workspace.dots();
if (!dots.some((dot) => dot.id === id))
return c.json({ error: 'Dot not found.' }, 404);
if (dots.length === 1)
return c.json({ error: 'Keep at least one Dot in the workspace.' }, 409);
const slackEnabled = !!(
platform.config.slackChannel &&
platform.config.slackTeam &&
platform.config.slackUsers.length
);
const slackDotId = platform.config.slackDotId ?? dots[0]?.id;
if (slackEnabled && slackDotId === id)
return c.json(
{
error:
'This Dot is assigned to the Slack integration. Change SLACK_DOT_ID and restart the app before deleting it.',
},
409,
);
const blocker = platform.workspace.dotDeletionBlocker(id);
if (blocker) return c.json({ error: blocker }, 409);
const taskIds = platform.workspace.taskIdsForDot(id);
if (
platform.store
.tasks()
.some((task) => taskIds.includes(task.id) && task.status === 'running')
)
return c.json(
{
error:
'Cancel or wait for this Dot’s running scheduled task before deleting it.',
},
409,
);
const deleted = platform.workspace.deleteDot(id);
if (!deleted) return c.json({ error: 'Dot not found.' }, 404);
platform.store.deleteTasks(deleted.taskIds);
return c.json({ ok: true });
});
app.post('/conversations', async (c) => {
const data = z
.object({
Expand Down
Loading