summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src/lib/components
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-02 14:43:16 +0900
committerAdam Malczewski <[email protected]>2026-06-02 14:43:16 +0900
commit3ebcd49c404ed287a97af159ac8adfa63d572849 (patch)
treef8245ed28530a8e96046221eb1d7eca47d508dc8 /packages/frontend/src/lib/components
parent7c527b4d8a72159954405e720d5bf776802dc0ff (diff)
downloaddispatch-3ebcd49c404ed287a97af159ac8adfa63d572849.tar.gz
dispatch-3ebcd49c404ed287a97af159ac8adfa63d572849.zip
feat(tabs): drag-reorder + double-click rename + per-tab chat draft
- TabBar: HTML5 drag-and-drop to reorder user tabs (subagent tabs untouched); double-click a tab title to rename (Enter/blur confirm, Escape cancel). - Store: add reorderTabs/renameTab/setDraft; per-tab in-memory `draft` and `manualTitle` fields. Manual rename suppresses first-message auto-title. - ChatInput: bind to the active tab's draft so switching tabs saves/restores unsent text instead of clobbering it. - Backend: updateTabPositions() + PATCH /tabs/reorder persist tab order to the existing `position` column; tabs without a stored position fall to the end then get explicit positions on first reorder. - Tests: store reorder/rename/auto-title-guard/draft coverage; core updateTabPositions coverage (FakeDatabase extended with transaction support).
Diffstat (limited to 'packages/frontend/src/lib/components')
-rw-r--r--packages/frontend/src/lib/components/ChatInput.svelte21
-rw-r--r--packages/frontend/src/lib/components/TabBar.svelte89
2 files changed, 102 insertions, 8 deletions
diff --git a/packages/frontend/src/lib/components/ChatInput.svelte b/packages/frontend/src/lib/components/ChatInput.svelte
index 0c99078..71eb496 100644
--- a/packages/frontend/src/lib/components/ChatInput.svelte
+++ b/packages/frontend/src/lib/components/ChatInput.svelte
@@ -4,12 +4,17 @@ import { tabStore } from "../tabs.svelte.js";
const MAX_LINES = 7;
let inputEl: HTMLTextAreaElement | undefined;
-let inputValue = $state("");
const agentStatus = $derived(tabStore.activeTab?.agentStatus ?? "idle");
const tabId = $derived(tabStore.activeTab?.id ?? "");
+// The current input text lives on the active tab (in-memory draft), so
+// switching tabs saves the current draft and restores the target tab's text
+// automatically — drafts are never lost or clobbered by tab switching.
+const inputValue = $derived(tabStore.activeTab?.draft ?? "");
$effect(() => {
+ // Re-focus when switching tabs.
+ void tabId;
inputEl?.focus();
});
@@ -29,13 +34,19 @@ function resize() {
el.style.overflowY = el.scrollHeight > maxHeight ? "auto" : "hidden";
}
-// Re-run resize whenever the value changes (covers programmatic clears too).
+// Re-run resize whenever the value changes (covers tab switches and
+// programmatic clears too).
$effect(() => {
// Touch inputValue so this effect tracks it.
void inputValue;
resize();
});
+function handleInput(e: Event) {
+ if (!tabId) return;
+ tabStore.setDraft(tabId, (e.currentTarget as HTMLTextAreaElement).value);
+}
+
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
@@ -46,7 +57,7 @@ function handleKeydown(e: KeyboardEvent) {
function submit() {
const text = inputValue.trim();
if (!text) return;
- inputValue = "";
+ if (tabId) tabStore.setDraft(tabId, "");
tabStore.sendMessage(text);
}
</script>
@@ -75,12 +86,12 @@ function submit() {
{/if}
<textarea
bind:this={inputEl}
- bind:value={inputValue}
+ value={inputValue}
rows="1"
placeholder="Type a message..."
class="textarea textarea-ghost flex-1 resize-none leading-normal !min-h-0 h-auto"
onkeydown={handleKeydown}
- oninput={resize}
+ oninput={handleInput}
></textarea>
<button
type="button"
diff --git a/packages/frontend/src/lib/components/TabBar.svelte b/packages/frontend/src/lib/components/TabBar.svelte
index 3cbd849..4fbe3b1 100644
--- a/packages/frontend/src/lib/components/TabBar.svelte
+++ b/packages/frontend/src/lib/components/TabBar.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+import { tick } from "svelte";
import { tabStore } from "../tabs.svelte.js";
function statusColor(status: string): string {
@@ -20,6 +21,59 @@ const activeUserTabId = $derived(
? activeTab.parentTabId
: tabStore.activeTabId,
);
+
+// ── Drag-and-drop reorder (user tabs only) ──
+// Mirrors the native HTML5 DnD pattern used in AgentBuilder.svelte.
+let dragIndex = $state<number | null>(null);
+let dragOverIndex = $state<number | null>(null);
+
+function dropReorder(targetIndex: number): void {
+ if (dragIndex !== null && dragIndex !== targetIndex) {
+ const ids = userTabs.map((t) => t.id);
+ const moved = ids.splice(dragIndex, 1)[0];
+ if (moved) {
+ ids.splice(targetIndex, 0, moved);
+ tabStore.reorderTabs(ids);
+ }
+ }
+ dragIndex = null;
+ dragOverIndex = null;
+}
+
+// ── Double-click rename (user tabs only) ──
+let editingTabId = $state<string | null>(null);
+let editValue = $state("");
+let editInputEl = $state<HTMLInputElement | undefined>(undefined);
+
+async function startRename(tab: { id: string; title: string }): Promise<void> {
+ editingTabId = tab.id;
+ editValue = tab.title;
+ await tick();
+ editInputEl?.focus();
+ editInputEl?.select();
+}
+
+function commitRename(): void {
+ if (editingTabId === null) return;
+ const id = editingTabId;
+ editingTabId = null;
+ const next = editValue.trim();
+ if (next) tabStore.renameTab(id, next);
+}
+
+function cancelRename(): void {
+ editingTabId = null;
+}
+
+function handleRenameKeydown(e: KeyboardEvent): void {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ commitRename();
+ } else if (e.key === "Escape") {
+ e.preventDefault();
+ cancelRename();
+ }
+}
</script>
<!-- Top row: user tabs -->
@@ -45,19 +99,48 @@ const activeUserTabId = $derived(
+
</button>
- {#each userTabs as tab (tab.id)}
+ {#each userTabs as tab, i (tab.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
role="tab"
- class="tab !flex items-stretch gap-1.5 {tab.id === activeUserTabId ? 'tab-active' : ''}"
+ class="tab !flex items-stretch gap-1.5 {tab.id === activeUserTabId ? 'tab-active' : ''} {dragOverIndex === i ? 'bg-primary/10' : ''} {dragIndex === i ? 'opacity-50' : ''}"
+ draggable={editingTabId === tab.id ? "false" : "true"}
onclick={() => tabStore.switchTab(tab.id)}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') tabStore.switchTab(tab.id); }}
+ ondragstart={(e) => {
+ dragIndex = i;
+ if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
+ }}
+ ondragover={(e) => {
+ e.preventDefault();
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
+ dragOverIndex = i;
+ }}
+ ondragleave={() => { if (dragOverIndex === i) dragOverIndex = null; }}
+ ondrop={(e) => { e.preventDefault(); dropReorder(i); }}
+ ondragend={() => { dragIndex = null; dragOverIndex = null; }}
tabindex="0"
>
<span class="flex items-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full shrink-0 {statusColor(tab.agentStatus)}"></span>
<span class="font-mono text-[10px] px-1 py-0.5 rounded bg-base-300 text-base-content/60 shrink-0" title="Tab ID — agents address this tab by this handle">{tabStore.shortHandleFor(tab.id)}</span>
- <span class="max-w-32 truncate text-xs">{tab.title}</span>
+ {#if editingTabId === tab.id}
+ <input
+ bind:this={editInputEl}
+ bind:value={editValue}
+ class="max-w-32 text-xs bg-base-100 rounded px-1 outline-none ring-1 ring-primary/40"
+ onclick={(e) => e.stopPropagation()}
+ ondblclick={(e) => e.stopPropagation()}
+ onkeydown={handleRenameKeydown}
+ onblur={commitRename}
+ />
+ {:else}
+ <span
+ class="max-w-32 truncate text-xs"
+ ondblclick={(e) => { e.stopPropagation(); startRename(tab); }}
+ title="Double-click to rename"
+ >{tab.title}</span>
+ {/if}
</span>
<button
type="button"