Thomas G. Lopes commited on
Commit
c2603a4
·
1 Parent(s): 2931ca0

some more work

Browse files
plan.md ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Layout Redesign Improvements Plan
2
+
3
+ ## Issues to Fix
4
+
5
+ ### Top Bar Adjustments
6
+
7
+ - [x] Move model and provider select to be the first items in the top bar
8
+ - [x] Remove project name from the top bar (redundant since it's selected in sidebar)
9
+ - [x] Move collapse sidebar button from top bar to the sidebar itself
10
+
11
+ ### Sidebar Improvements
12
+
13
+ - [x] Make sidebar resizable (use example from visual playground branch)
14
+ - [x] Position collapse button on the sidebar side
15
+
16
+ ### Layout Fixes
17
+
18
+ - [x] Fix "View docs" and "Give feedback" links overlapping with trash icon
19
+ - Moved links to bottom bar next to trash icon
20
+
21
+ ## Additional Potential Issues & Improvements
22
+
23
+ ### Usability
24
+
25
+ - [x] Add keyboard shortcut for collapsing sidebar (Cmd+B / Ctrl+B)
26
+ - [x] Persist sidebar collapsed state in localStorage
27
+ - [x] Persist sidebar width when resizable
28
+
29
+ ### Visual Polish
30
+
31
+ - [x] Add transition animation when sidebar collapses/expands
32
+ - [ ] Ensure proper spacing between top bar elements
33
+ - [ ] Check dark mode styling consistency
34
+
35
+ ### Responsive Design
36
+
37
+ - [ ] Test and fix mobile layout
38
+ - [ ] Ensure settings popover doesn't overflow on smaller screens
39
+ - [ ] Handle long project names in sidebar gracefully
40
+
41
+ ### Functionality
42
+
43
+ - [ ] Ensure system prompt changes are saved properly
44
+ - [ ] Verify model/provider selection works in new top bar position
45
+ - [ ] Test compare mode with new layout
46
+
47
+ ### Accessibility
48
+
49
+ - [ ] Add proper ARIA labels for interactive elements
50
+ - [ ] Ensure keyboard navigation works properly
51
+ - [ ] Test with screen readers
52
+
53
+ ## Implementation Order
54
+
55
+ 1. **Top Bar Reorganization** (First Priority)
56
+
57
+ - Move model/provider select to first position
58
+ - Remove project name
59
+ - Relocate sidebar toggle
60
+
61
+ 2. **Resizable Sidebar** (Second Priority)
62
+
63
+ - Implement resize functionality
64
+ - Add persistence
65
+
66
+ 3. **Fix Overlapping Elements** (Third Priority)
67
+
68
+ - Resolve footer links position issue
69
+
70
+ 4. **Polish & Testing** (Final)
71
+ - Add animations
72
+ - Test all functionality
73
+ - Fix any remaining issues
74
+
75
+ ---
76
+
77
+ **Current Status**: Resizable sidebar with persistence implemented. Provider select now visible in top bar alongside model select.
src/lib/components/inference-playground/playground.svelte CHANGED
@@ -5,7 +5,9 @@
5
  import { isHFModel } from "$lib/types.js";
6
  import { iterate } from "$lib/utils/array.js";
7
  import { atLeastNDecimals } from "$lib/utils/number.js";
 
8
  import { Popover } from "melt/builders";
 
9
  import IconExternal from "~icons/carbon/arrow-up-right";
10
  import IconWaterfall from "~icons/carbon/chart-waterfall";
11
  import IconCode from "~icons/carbon/code";
@@ -13,8 +15,6 @@
13
  import IconInfo from "~icons/carbon/information";
14
  import IconSettings from "~icons/carbon/settings";
15
  import IconShare from "~icons/carbon/share";
16
- import IconSidebarCollapse from "~icons/carbon/side-panel-close";
17
- import IconSidebarExpand from "~icons/carbon/side-panel-open";
18
  import { default as IconDelete } from "~icons/carbon/trash-can";
19
  import BillingIndicator from "../billing-indicator.svelte";
20
  import { showShareModal } from "../share-modal.svelte";
@@ -27,17 +27,65 @@
27
  import MessageTextarea from "./message-textarea.svelte";
28
  import ModelSelectorModal from "./model-selector-modal.svelte";
29
  import ModelSelector from "./model-selector.svelte";
 
30
  import ProjectTreeSidebar from "./project-tree-sidebar.svelte";
31
  import CheckpointsMenu from "./checkpoints-menu.svelte";
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  let viewCode = $state(false);
34
- let sidebarCollapsed = $state(false);
 
35
  let billingModalOpen = $state(false);
36
  let selectCompareModelOpen = $state(false);
37
  let settingsPopoverOpen = $state(false);
38
 
39
  const compareActive = $derived(conversations.active.length === 2);
40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  // Settings popover
42
  const settingsPopover = new Popover({
43
  open: () => settingsPopoverOpen,
@@ -54,56 +102,44 @@
54
  ]}
55
  >
56
  <!-- Project tree sidebar -->
57
- <ProjectTreeSidebar collapsed={sidebarCollapsed} />
 
 
 
 
 
58
 
59
  <!-- Main content area -->
60
  <div class="relative flex flex-1 flex-col overflow-hidden">
61
  <!-- Top bar -->
62
  <header
63
- class="flex h-14 items-center justify-between border-b border-gray-200 bg-white px-4 dark:border-gray-800 dark:bg-gray-900"
64
  >
 
65
  <div class="flex items-center gap-3">
66
- <!-- Sidebar toggle -->
67
- <Tooltip>
68
- {#snippet trigger(tooltip)}
69
- <button
70
- onclick={() => (sidebarCollapsed = !sidebarCollapsed)}
71
- class="rounded p-1.5 text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
72
- aria-label={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
73
- {...tooltip.trigger}
74
- >
75
- {#if sidebarCollapsed}
76
- <IconSidebarExpand class="size-5" />
77
- {:else}
78
- <IconSidebarCollapse class="size-5" />
79
- {/if}
80
- </button>
81
- {/snippet}
82
- {sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
83
- </Tooltip>
84
-
85
- <!-- Project name and checkpoints -->
86
- <div class="flex items-center gap-2">
87
- <span class="text-sm font-semibold">{projects.current?.name}</span>
88
- <CheckpointsMenu />
89
- </div>
90
- </div>
91
-
92
- <!-- Right side of top bar -->
93
- <div class="flex items-center gap-3">
94
- <!-- Model selector -->
95
  {#if !compareActive && conversations.active[0]}
96
- <div class="flex items-center gap-2">
 
97
  <ModelSelector conversation={conversations.active[0]} compact />
98
- <button
99
- class="flex items-center gap-1 rounded px-2 py-1 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-300"
100
- onclick={() => (selectCompareModelOpen = true)}
101
- >
102
- <IconCompare class="size-4" />
103
- Compare
104
- </button>
105
  </div>
 
 
 
 
 
 
 
106
  {/if}
 
 
 
 
 
 
107
 
108
  <!-- Settings button with popover -->
109
  <Tooltip>
@@ -165,7 +201,7 @@
165
  <div
166
  class="relative flex h-12 shrink-0 items-center justify-between border-t border-gray-200 bg-white px-4 dark:border-gray-800 dark:bg-gray-900"
167
  >
168
- <div class="flex items-center gap-2">
169
  <Tooltip>
170
  {#snippet trigger(tooltip)}
171
  <button
@@ -180,11 +216,40 @@
180
  {/snippet}
181
  Clear conversation
182
  </Tooltip>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  </div>
184
 
185
  <!-- Stats in center -->
186
  <div
187
- class="pointer-events-none absolute inset-x-0 flex items-center justify-center gap-x-8 text-center text-sm text-gray-500 max-lg:hidden"
188
  >
189
  {#each iterate(conversations.generationStats) as [{ latency, tokens, cost }]}
190
  <span>
@@ -201,35 +266,6 @@
201
  </div>
202
  </div>
203
  </div>
204
-
205
- <!-- Footer links -->
206
- <div class="absolute bottom-3 left-4 flex items-center gap-2 text-xs">
207
- <a
208
- target="_blank"
209
- href="https://huggingface.co/docs/inference-providers/tasks/chat-completion"
210
- class="flex items-center gap-1 text-gray-500 underline decoration-gray-300 hover:text-gray-800 dark:text-gray-400 dark:decoration-gray-600 dark:hover:text-gray-200"
211
- >
212
- <IconInfo class="size-3" />
213
- View Docs
214
- </a>
215
- <span class="text-gray-500 dark:text-gray-500">·</span>
216
- <a
217
- target="_blank"
218
- href="https://huggingface.co/spaces/huggingface/inference-playground/discussions/1"
219
- class="flex items-center gap-1 text-gray-500 underline decoration-gray-300 hover:text-gray-800 dark:text-gray-400 dark:decoration-gray-600 dark:hover:text-gray-200"
220
- >
221
- Give feedback
222
- </a>
223
- <span class="text-gray-500 dark:text-gray-500">·</span>
224
- <a
225
- href="https://huggingface.co/inference/models"
226
- target="_blank"
227
- class="flex items-center gap-1 text-gray-500 underline decoration-gray-300 hover:text-gray-800 dark:text-gray-400 dark:decoration-gray-600 dark:hover:text-gray-200"
228
- >
229
- <IconWaterfall class="size-3" />
230
- Metrics
231
- </a>
232
- </div>
233
  </div>
234
  </div>
235
 
 
5
  import { isHFModel } from "$lib/types.js";
6
  import { iterate } from "$lib/utils/array.js";
7
  import { atLeastNDecimals } from "$lib/utils/number.js";
8
+ import { isMac } from "$lib/utils/platform.js";
9
  import { Popover } from "melt/builders";
10
+ import { onMount } from "svelte";
11
  import IconExternal from "~icons/carbon/arrow-up-right";
12
  import IconWaterfall from "~icons/carbon/chart-waterfall";
13
  import IconCode from "~icons/carbon/code";
 
15
  import IconInfo from "~icons/carbon/information";
16
  import IconSettings from "~icons/carbon/settings";
17
  import IconShare from "~icons/carbon/share";
 
 
18
  import { default as IconDelete } from "~icons/carbon/trash-can";
19
  import BillingIndicator from "../billing-indicator.svelte";
20
  import { showShareModal } from "../share-modal.svelte";
 
27
  import MessageTextarea from "./message-textarea.svelte";
28
  import ModelSelectorModal from "./model-selector-modal.svelte";
29
  import ModelSelector from "./model-selector.svelte";
30
+ import ProviderSelect from "./provider-select.svelte";
31
  import ProjectTreeSidebar from "./project-tree-sidebar.svelte";
32
  import CheckpointsMenu from "./checkpoints-menu.svelte";
33
 
34
+ // LocalStorage keys
35
+ const SIDEBAR_COLLAPSED_KEY = "playground:sidebar:collapsed";
36
+ const SIDEBAR_WIDTH_KEY = "playground:sidebar:width";
37
+ const DEFAULT_SIDEBAR_WIDTH = 256;
38
+
39
+ // Initialize from localStorage or defaults
40
+ function get_initial_collapsed(): boolean {
41
+ if (typeof localStorage === "undefined") return false;
42
+ const stored = localStorage.getItem(SIDEBAR_COLLAPSED_KEY);
43
+ return stored === "true";
44
+ }
45
+
46
+ function get_initial_width(): number {
47
+ if (typeof localStorage === "undefined") return DEFAULT_SIDEBAR_WIDTH;
48
+ const stored = localStorage.getItem(SIDEBAR_WIDTH_KEY);
49
+ const parsed = stored ? parseInt(stored, 10) : NaN;
50
+ return isNaN(parsed) ? DEFAULT_SIDEBAR_WIDTH : parsed;
51
+ }
52
+
53
  let viewCode = $state(false);
54
+ let sidebarCollapsed = $state(get_initial_collapsed());
55
+ let sidebarWidth = $state(get_initial_width());
56
  let billingModalOpen = $state(false);
57
  let selectCompareModelOpen = $state(false);
58
  let settingsPopoverOpen = $state(false);
59
 
60
  const compareActive = $derived(conversations.active.length === 2);
61
 
62
+ // Persist sidebar state to localStorage
63
+ function toggle_sidebar_collapsed() {
64
+ sidebarCollapsed = !sidebarCollapsed;
65
+ localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(sidebarCollapsed));
66
+ }
67
+
68
+ function handle_sidebar_width_change(width: number) {
69
+ sidebarWidth = width;
70
+ localStorage.setItem(SIDEBAR_WIDTH_KEY, String(width));
71
+ }
72
+
73
+ // Keyboard shortcut for toggling sidebar (Cmd+B / Ctrl+B)
74
+ function handle_keydown(e: KeyboardEvent) {
75
+ const mod_key = isMac() ? e.metaKey : e.ctrlKey;
76
+ if (mod_key && e.key === "b") {
77
+ e.preventDefault();
78
+ toggle_sidebar_collapsed();
79
+ }
80
+ }
81
+
82
+ onMount(() => {
83
+ document.addEventListener("keydown", handle_keydown);
84
+ return () => {
85
+ document.removeEventListener("keydown", handle_keydown);
86
+ };
87
+ });
88
+
89
  // Settings popover
90
  const settingsPopover = new Popover({
91
  open: () => settingsPopoverOpen,
 
102
  ]}
103
  >
104
  <!-- Project tree sidebar -->
105
+ <ProjectTreeSidebar
106
+ collapsed={sidebarCollapsed}
107
+ width={sidebarWidth}
108
+ onToggleCollapse={toggle_sidebar_collapsed}
109
+ onWidthChange={handle_sidebar_width_change}
110
+ />
111
 
112
  <!-- Main content area -->
113
  <div class="relative flex flex-1 flex-col overflow-hidden">
114
  <!-- Top bar -->
115
  <header
116
+ class="flex items-center justify-between border-b border-gray-200 bg-white px-4 py-2 dark:border-gray-800 dark:bg-gray-900"
117
  >
118
+ <!-- Left side: Model selector, provider selector, and compare -->
119
  <div class="flex items-center gap-3">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  {#if !compareActive && conversations.active[0]}
121
+ <!-- Model and provider stacked vertically -->
122
+ <div class="flex flex-col gap-1">
123
  <ModelSelector conversation={conversations.active[0]} compact />
124
+ {#if isHFModel(conversations.active[0].model)}
125
+ <!-- eslint-disable-next-line @typescript-eslint/no-explicit-any -->
126
+ <ProviderSelect conversation={conversations.active[0] as any} compact />
127
+ {/if}
 
 
 
128
  </div>
129
+ <button
130
+ class="flex items-center gap-1 rounded px-2 py-1 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-300"
131
+ onclick={() => (selectCompareModelOpen = true)}
132
+ >
133
+ <IconCompare class="size-4" />
134
+ Compare
135
+ </button>
136
  {/if}
137
+ </div>
138
+
139
+ <!-- Right side: Actions -->
140
+ <div class="flex items-center gap-3">
141
+ <!-- Checkpoints menu -->
142
+ <CheckpointsMenu />
143
 
144
  <!-- Settings button with popover -->
145
  <Tooltip>
 
201
  <div
202
  class="relative flex h-12 shrink-0 items-center justify-between border-t border-gray-200 bg-white px-4 dark:border-gray-800 dark:bg-gray-900"
203
  >
204
+ <div class="flex items-center gap-4">
205
  <Tooltip>
206
  {#snippet trigger(tooltip)}
207
  <button
 
216
  {/snippet}
217
  Clear conversation
218
  </Tooltip>
219
+
220
+ <!-- Footer links - moved here to avoid overlap -->
221
+ <div class="flex items-center gap-2 text-xs max-md:hidden">
222
+ <a
223
+ target="_blank"
224
+ href="https://huggingface.co/docs/inference-providers/tasks/chat-completion"
225
+ class="flex items-center gap-1 text-gray-500 underline decoration-gray-300 hover:text-gray-800 dark:text-gray-400 dark:decoration-gray-600 dark:hover:text-gray-200"
226
+ >
227
+ <IconInfo class="size-3" />
228
+ Docs
229
+ </a>
230
+ <span class="text-gray-400 dark:text-gray-600">·</span>
231
+ <a
232
+ target="_blank"
233
+ href="https://huggingface.co/spaces/huggingface/inference-playground/discussions/1"
234
+ class="text-gray-500 underline decoration-gray-300 hover:text-gray-800 dark:text-gray-400 dark:decoration-gray-600 dark:hover:text-gray-200"
235
+ >
236
+ Feedback
237
+ </a>
238
+ <span class="text-gray-400 dark:text-gray-600">·</span>
239
+ <a
240
+ href="https://huggingface.co/inference/models"
241
+ target="_blank"
242
+ class="flex items-center gap-1 text-gray-500 underline decoration-gray-300 hover:text-gray-800 dark:text-gray-400 dark:decoration-gray-600 dark:hover:text-gray-200"
243
+ >
244
+ <IconWaterfall class="size-3" />
245
+ Metrics
246
+ </a>
247
+ </div>
248
  </div>
249
 
250
  <!-- Stats in center -->
251
  <div
252
+ class="pointer-events-none absolute inset-x-0 flex items-center justify-center gap-x-8 text-center text-sm text-gray-500 max-xl:hidden"
253
  >
254
  {#each iterate(conversations.generationStats) as [{ latency, tokens, cost }]}
255
  <span>
 
266
  </div>
267
  </div>
268
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
  </div>
270
  </div>
271
 
src/lib/components/inference-playground/project-tree-sidebar.svelte CHANGED
@@ -15,7 +15,10 @@
15
  import IconEdit from "~icons/carbon/edit";
16
  import IconDelete from "~icons/carbon/trash-can";
17
  import IconHistory from "~icons/carbon/recently-viewed";
 
 
18
  import { prompt } from "../prompts.svelte";
 
19
 
20
  interface ProjectTreeItem extends TreeItem {
21
  id: string;
@@ -24,11 +27,50 @@
24
  children?: ProjectTreeItem[];
25
  }
26
 
 
 
 
 
 
27
  interface Props {
28
  collapsed?: boolean;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  }
30
 
31
- let { collapsed = false }: Props = $props();
 
 
 
 
 
 
32
 
33
  // Build tree structure from projects
34
  const tree_items = $derived.by((): ProjectTreeItem[] => {
@@ -142,20 +184,42 @@
142
 
143
  <aside
144
  class={cn(
145
- "flex h-full flex-col overflow-hidden border-r border-gray-200 bg-gray-50/50 transition-all dark:border-gray-800 dark:bg-gray-900/50",
146
- collapsed ? "w-12" : "w-64",
147
  )}
 
148
  >
149
  {#if !collapsed}
150
- <div class="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-800">
151
  <h2 class="text-sm font-semibold text-gray-700 uppercase dark:text-gray-300">Projects</h2>
152
- <button
153
- onclick={handle_new_project}
154
- class="rounded p-1 text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:hover:bg-gray-700 dark:hover:text-gray-200"
155
- aria-label="New project"
156
- >
157
- <IconPlus class="size-4" />
158
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  </div>
160
 
161
  <div class="flex-1 overflow-y-auto p-2" {...tree.root}>
@@ -175,30 +239,59 @@
175
  {/if}
176
  </div>
177
  {:else}
178
- <!-- Collapsed state - just show icons -->
179
- <div class="flex flex-col items-center gap-1 py-3">
180
- {#each tree_items as item}
181
- {@const is_active = tree.isSelected(item.id)}
182
- {@const is_branch = item.project.branchedFromId !== null}
183
- <button
184
- onclick={() => tree.toggleSelect(item.id)}
185
- class={cn(
186
- "grid size-8 place-items-center rounded-md transition-colors",
187
- is_active
188
- ? "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
189
- : "text-gray-600 hover:bg-gray-200 dark:text-gray-400 dark:hover:bg-gray-700",
190
- )}
191
- title={item.project.name}
192
- >
193
- {#if is_branch}
194
- <IconBranch class="size-4" />
195
- {:else}
196
- <IconFolder class="size-4" />
197
- {/if}
198
- </button>
199
- {/each}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  </div>
201
  {/if}
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  </aside>
203
 
204
  {#snippet tree_node(items: typeof tree.children)}
 
15
  import IconEdit from "~icons/carbon/edit";
16
  import IconDelete from "~icons/carbon/trash-can";
17
  import IconHistory from "~icons/carbon/recently-viewed";
18
+ import IconSidebarCollapse from "~icons/carbon/side-panel-close";
19
+ import IconSidebarExpand from "~icons/carbon/side-panel-open";
20
  import { prompt } from "../prompts.svelte";
21
+ import Tooltip from "../tooltip.svelte";
22
 
23
  interface ProjectTreeItem extends TreeItem {
24
  id: string;
 
27
  children?: ProjectTreeItem[];
28
  }
29
 
30
+ const MIN_WIDTH = 200;
31
+ const MAX_WIDTH = 400;
32
+ const DEFAULT_WIDTH = 256;
33
+ const COLLAPSED_WIDTH = 48;
34
+
35
  interface Props {
36
  collapsed?: boolean;
37
+ width?: number;
38
+ onToggleCollapse?: () => void;
39
+ onWidthChange?: (width: number) => void;
40
+ }
41
+
42
+ let { collapsed = false, width = DEFAULT_WIDTH, onToggleCollapse, onWidthChange }: Props = $props();
43
+
44
+ // Resize state
45
+ let is_resizing = $state(false);
46
+ let resize_start_x = $state(0);
47
+ let resize_start_width = $state(0);
48
+
49
+ function handle_resize_start(e: MouseEvent) {
50
+ if (collapsed) return;
51
+ is_resizing = true;
52
+ resize_start_x = e.clientX;
53
+ resize_start_width = width;
54
+ document.addEventListener("mousemove", handle_resize_move);
55
+ document.addEventListener("mouseup", handle_resize_end);
56
+ document.body.style.cursor = "ew-resize";
57
+ document.body.style.userSelect = "none";
58
+ }
59
+
60
+ function handle_resize_move(e: MouseEvent) {
61
+ if (!is_resizing) return;
62
+ const delta = e.clientX - resize_start_x;
63
+ const new_width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, resize_start_width + delta));
64
+ onWidthChange?.(new_width);
65
  }
66
 
67
+ function handle_resize_end() {
68
+ is_resizing = false;
69
+ document.removeEventListener("mousemove", handle_resize_move);
70
+ document.removeEventListener("mouseup", handle_resize_end);
71
+ document.body.style.cursor = "";
72
+ document.body.style.userSelect = "";
73
+ }
74
 
75
  // Build tree structure from projects
76
  const tree_items = $derived.by((): ProjectTreeItem[] => {
 
184
 
185
  <aside
186
  class={cn(
187
+ "relative flex h-full flex-col overflow-hidden border-r border-gray-200 bg-gray-50/50 dark:border-gray-800 dark:bg-gray-900/50",
188
+ !is_resizing && "transition-[width] duration-200 ease-out",
189
  )}
190
+ style="width: {collapsed ? COLLAPSED_WIDTH : width}px"
191
  >
192
  {#if !collapsed}
193
+ <div class="flex items-center justify-between border-b border-gray-200 px-3 py-2 dark:border-gray-800">
194
  <h2 class="text-sm font-semibold text-gray-700 uppercase dark:text-gray-300">Projects</h2>
195
+ <div class="flex items-center gap-1">
196
+ <Tooltip>
197
+ {#snippet trigger(tooltip)}
198
+ <button
199
+ onclick={handle_new_project}
200
+ class="rounded p-1 text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:hover:bg-gray-700 dark:hover:text-gray-200"
201
+ aria-label="New project"
202
+ {...tooltip.trigger}
203
+ >
204
+ <IconPlus class="size-4" />
205
+ </button>
206
+ {/snippet}
207
+ New project
208
+ </Tooltip>
209
+ <Tooltip>
210
+ {#snippet trigger(tooltip)}
211
+ <button
212
+ onclick={onToggleCollapse}
213
+ class="rounded p-1 text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:hover:bg-gray-700 dark:hover:text-gray-200"
214
+ aria-label="Collapse sidebar"
215
+ {...tooltip.trigger}
216
+ >
217
+ <IconSidebarCollapse class="size-4" />
218
+ </button>
219
+ {/snippet}
220
+ Collapse sidebar
221
+ </Tooltip>
222
+ </div>
223
  </div>
224
 
225
  <div class="flex-1 overflow-y-auto p-2" {...tree.root}>
 
239
  {/if}
240
  </div>
241
  {:else}
242
+ <!-- Collapsed state - show expand button and project icons -->
243
+ <div class="flex flex-col items-center py-2">
244
+ <Tooltip>
245
+ {#snippet trigger(tooltip)}
246
+ <button
247
+ onclick={onToggleCollapse}
248
+ class="mb-2 grid size-8 place-items-center rounded-md text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:hover:bg-gray-700 dark:hover:text-gray-200"
249
+ aria-label="Expand sidebar"
250
+ {...tooltip.trigger}
251
+ >
252
+ <IconSidebarExpand class="size-4" />
253
+ </button>
254
+ {/snippet}
255
+ Expand sidebar
256
+ </Tooltip>
257
+ <div class="h-px w-6 bg-gray-200 dark:bg-gray-700"></div>
258
+ <div class="mt-2 flex flex-col items-center gap-1">
259
+ {#each tree_items as item}
260
+ {@const is_active = tree.isSelected(item.id)}
261
+ {@const is_branch = item.project.branchedFromId !== null}
262
+ <button
263
+ onclick={() => tree.toggleSelect(item.id)}
264
+ class={cn(
265
+ "grid size-8 place-items-center rounded-md transition-colors",
266
+ is_active
267
+ ? "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
268
+ : "text-gray-600 hover:bg-gray-200 dark:text-gray-400 dark:hover:bg-gray-700",
269
+ )}
270
+ title={item.project.name}
271
+ >
272
+ {#if is_branch}
273
+ <IconBranch class="size-4" />
274
+ {:else}
275
+ <IconFolder class="size-4" />
276
+ {/if}
277
+ </button>
278
+ {/each}
279
+ </div>
280
  </div>
281
  {/if}
282
+
283
+ <!-- Resize handle -->
284
+ {#if !collapsed}
285
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
286
+ <div
287
+ class={cn(
288
+ "absolute top-0 right-0 h-full w-1 cursor-ew-resize transition-colors",
289
+ "hover:bg-blue-400 dark:hover:bg-blue-500",
290
+ is_resizing && "bg-blue-500 dark:bg-blue-400",
291
+ )}
292
+ onmousedown={handle_resize_start}
293
+ ></div>
294
+ {/if}
295
  </aside>
296
 
297
  {#snippet tree_node(items: typeof tree.children)}
src/lib/components/inference-playground/provider-select.svelte CHANGED
@@ -13,9 +13,10 @@
13
  interface Props {
14
  conversation: ConversationClass & { model: Model };
15
  class?: string | undefined;
 
16
  }
17
 
18
- const { conversation, class: classes = undefined }: Props = $props();
19
 
20
  function reset(providers: typeof conversation.model.inferenceProviderMapping) {
21
  const validProvider = providers.find(p => p.provider === conversation.data.provider);
@@ -102,14 +103,14 @@
102
  });
103
  </script>
104
 
105
- {#snippet providerDisplay(provider: string)}
106
  {@const providerPricing = getProviderPricing(provider)}
107
  <div class="flex flex-col items-start gap-0.5">
108
  <div class="flex items-center gap-2 text-sm">
109
  <IconProvider {provider} />
110
  <span>{getProviderName(provider) ?? "loading"}</span>
111
  </div>
112
- {#if providerPricing}
113
  <span class="text-xs text-gray-500 dark:text-gray-400">
114
  In: {providerPricing.input} • Out: {providerPricing.output}
115
  </span>
@@ -117,68 +118,70 @@
117
  </div>
118
  {/snippet}
119
 
120
- <div class="flex flex-col gap-2">
121
- <button
122
- {...select.trigger}
123
- class={cn(
124
- "relative flex items-center justify-between gap-6 overflow-hidden rounded-lg border bg-gray-100/80 px-3 py-1.5 leading-tight whitespace-nowrap shadow-sm",
125
- "hover:brightness-95 dark:border-gray-700 dark:bg-gray-800 dark:hover:brightness-110",
126
- classes,
127
- )}
128
- >
129
- {@render providerDisplay(conversation.data.provider ?? "")}
130
- <div
131
- class="absolute right-2 grid size-4 flex-none place-items-center rounded-sm bg-gray-100 text-xs dark:bg-gray-600"
 
 
 
 
 
 
 
132
  >
133
- <IconCaret />
134
- </div>
135
- </button>
136
 
137
- <div {...select.content} class="rounded-lg border bg-gray-100 dark:border-gray-700 dark:bg-gray-800">
138
- {#snippet option(provider: string)}
139
- <div {...select.getOption(provider)} class="group block w-full p-1 text-sm dark:text-white">
140
- <div
141
- class="rounded-md px-2 py-1.5 group-data-[highlighted]:bg-gray-200 dark:group-data-[highlighted]:bg-gray-700"
142
- >
143
- {@render providerDisplay(provider)}
 
144
  </div>
145
- </div>
146
- {/snippet}
147
- {#each conversation.model.inferenceProviderMapping as { provider, providerId } (provider + providerId)}
148
- {@render option(provider)}
149
- {/each}
150
- {@render option("auto")}
151
- </div>
152
 
153
- {#if conversation.data.provider === "auto"}
154
- <div class="flex flex-col gap-1.5">
155
- <div class="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400">
156
- <span>Auto Policy</span>
157
- <Tooltip>
158
- {#snippet trigger(tooltip)}
159
- <button class="flex items-center" {...tooltip.trigger}>
160
- <IconInfo class="size-3" />
161
- </button>
162
- {/snippet}
163
- {autoPolicyDescriptions[autoPolicyValue]}
164
- </Tooltip>
165
- </div>
166
- <button
167
- {...autoPolicySelect.trigger}
168
- class={cn(
169
- "relative flex items-center justify-between gap-6 overflow-hidden rounded-lg border bg-gray-100/80 px-3 py-1.5 text-sm leading-tight whitespace-nowrap shadow-sm",
170
- "hover:brightness-95 dark:border-gray-700 dark:bg-gray-800 dark:hover:brightness-110",
171
- )}
172
- >
173
- {autoPolicyLabels[autoPolicyValue]}
174
- <div
175
- class="absolute right-2 grid size-4 flex-none place-items-center rounded-sm bg-gray-100 text-xs dark:bg-gray-600"
176
- >
177
- <IconCaret />
178
- </div>
179
- </button>
180
 
181
- <div {...autoPolicySelect.content} class="rounded-lg border bg-gray-100 dark:border-gray-700 dark:bg-gray-800">
 
 
 
182
  {#snippet policyOption(policy: "default" | "fastest" | "cheapest", label: string)}
183
  <div {...autoPolicySelect.getOption(policy)} class="group block w-full p-1 text-sm dark:text-white">
184
  <div
@@ -197,6 +200,91 @@
197
  {@render policyOption("fastest", "Fastest")}
198
  {@render policyOption("cheapest", "Cheapest")}
199
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  </div>
201
- {/if}
202
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  interface Props {
14
  conversation: ConversationClass & { model: Model };
15
  class?: string | undefined;
16
+ compact?: boolean;
17
  }
18
 
19
+ const { conversation, class: classes = undefined, compact = false }: Props = $props();
20
 
21
  function reset(providers: typeof conversation.model.inferenceProviderMapping) {
22
  const validProvider = providers.find(p => p.provider === conversation.data.provider);
 
103
  });
104
  </script>
105
 
106
+ {#snippet providerDisplay(provider: string, showPricing: boolean = true)}
107
  {@const providerPricing = getProviderPricing(provider)}
108
  <div class="flex flex-col items-start gap-0.5">
109
  <div class="flex items-center gap-2 text-sm">
110
  <IconProvider {provider} />
111
  <span>{getProviderName(provider) ?? "loading"}</span>
112
  </div>
113
+ {#if showPricing && providerPricing}
114
  <span class="text-xs text-gray-500 dark:text-gray-400">
115
  In: {providerPricing.input} • Out: {providerPricing.output}
116
  </span>
 
118
  </div>
119
  {/snippet}
120
 
121
+ {#snippet compactProviderDisplay(provider: string)}
122
+ <div class="flex items-center gap-1.5">
123
+ <IconProvider {provider} />
124
+ <span class="text-sm">{getProviderName(provider) ?? "loading"}</span>
125
+ </div>
126
+ {/snippet}
127
+
128
+ {#if compact}
129
+ <!-- Compact mode for top bar - provider and auto policy side by side -->
130
+ <div class="flex items-center gap-2">
131
+ <!-- Provider select -->
132
+ <button
133
+ {...select.trigger}
134
+ class={cn(
135
+ "focus-outline relative flex items-center gap-2 overflow-hidden rounded-lg border",
136
+ "bg-gray-100/80 px-3 py-1.5 text-sm leading-tight whitespace-nowrap shadow-sm",
137
+ "hover:brightness-95 dark:border-gray-700 dark:bg-gray-800 dark:hover:brightness-110",
138
+ classes,
139
+ )}
140
  >
141
+ {@render compactProviderDisplay(conversation.data.provider ?? "")}
142
+ <IconCaret class="size-4 flex-none text-gray-500" />
143
+ </button>
144
 
145
+ <div {...select.content} class="z-50 rounded-lg border bg-gray-100 dark:border-gray-700 dark:bg-gray-800">
146
+ {#snippet option(provider: string)}
147
+ <div {...select.getOption(provider)} class="group block w-full p-1 text-sm dark:text-white">
148
+ <div
149
+ class="rounded-md px-2 py-1.5 group-data-[highlighted]:bg-gray-200 dark:group-data-[highlighted]:bg-gray-700"
150
+ >
151
+ {@render providerDisplay(provider)}
152
+ </div>
153
  </div>
154
+ {/snippet}
155
+ {#each conversation.model.inferenceProviderMapping as { provider, providerId } (provider + providerId)}
156
+ {@render option(provider)}
157
+ {/each}
158
+ {@render option("auto")}
159
+ </div>
 
160
 
161
+ <!-- Auto policy select (only shown when provider is auto) -->
162
+ {#if conversation.data.provider === "auto"}
163
+ <Tooltip>
164
+ {#snippet trigger(tooltip)}
165
+ <button
166
+ {...autoPolicySelect.trigger}
167
+ class={cn(
168
+ "focus-outline relative flex items-center gap-2 overflow-hidden rounded-lg border",
169
+ "bg-gray-100/80 px-3 py-1.5 text-sm leading-tight whitespace-nowrap shadow-sm",
170
+ "hover:brightness-95 dark:border-gray-700 dark:bg-gray-800 dark:hover:brightness-110",
171
+ )}
172
+ {...tooltip.trigger}
173
+ >
174
+ {autoPolicyLabels[autoPolicyValue]}
175
+ <IconCaret class="size-4 flex-none text-gray-500" />
176
+ </button>
177
+ {/snippet}
178
+ {autoPolicyDescriptions[autoPolicyValue]}
179
+ </Tooltip>
 
 
 
 
 
 
 
 
180
 
181
+ <div
182
+ {...autoPolicySelect.content}
183
+ class="z-50 rounded-lg border bg-gray-100 dark:border-gray-700 dark:bg-gray-800"
184
+ >
185
  {#snippet policyOption(policy: "default" | "fastest" | "cheapest", label: string)}
186
  <div {...autoPolicySelect.getOption(policy)} class="group block w-full p-1 text-sm dark:text-white">
187
  <div
 
200
  {@render policyOption("fastest", "Fastest")}
201
  {@render policyOption("cheapest", "Cheapest")}
202
  </div>
203
+ {/if}
204
+ </div>
205
+ {:else}
206
+ <!-- Full mode for settings panel -->
207
+ <div class="flex flex-col gap-2">
208
+ <button
209
+ {...select.trigger}
210
+ class={cn(
211
+ "relative flex items-center justify-between gap-6 overflow-hidden rounded-lg border bg-gray-100/80 px-3 py-1.5 leading-tight whitespace-nowrap shadow-sm",
212
+ "hover:brightness-95 dark:border-gray-700 dark:bg-gray-800 dark:hover:brightness-110",
213
+ classes,
214
+ )}
215
+ >
216
+ {@render providerDisplay(conversation.data.provider ?? "")}
217
+ <div
218
+ class="absolute right-2 grid size-4 flex-none place-items-center rounded-sm bg-gray-100 text-xs dark:bg-gray-600"
219
+ >
220
+ <IconCaret />
221
+ </div>
222
+ </button>
223
+
224
+ <div {...select.content} class="rounded-lg border bg-gray-100 dark:border-gray-700 dark:bg-gray-800">
225
+ {#snippet option(provider: string)}
226
+ <div {...select.getOption(provider)} class="group block w-full p-1 text-sm dark:text-white">
227
+ <div
228
+ class="rounded-md px-2 py-1.5 group-data-[highlighted]:bg-gray-200 dark:group-data-[highlighted]:bg-gray-700"
229
+ >
230
+ {@render providerDisplay(provider)}
231
+ </div>
232
+ </div>
233
+ {/snippet}
234
+ {#each conversation.model.inferenceProviderMapping as { provider, providerId } (provider + providerId)}
235
+ {@render option(provider)}
236
+ {/each}
237
+ {@render option("auto")}
238
  </div>
239
+
240
+ {#if conversation.data.provider === "auto"}
241
+ <div class="flex flex-col gap-1.5">
242
+ <div class="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400">
243
+ <span>Auto Policy</span>
244
+ <Tooltip>
245
+ {#snippet trigger(tooltip)}
246
+ <button class="flex items-center" {...tooltip.trigger}>
247
+ <IconInfo class="size-3" />
248
+ </button>
249
+ {/snippet}
250
+ {autoPolicyDescriptions[autoPolicyValue]}
251
+ </Tooltip>
252
+ </div>
253
+ <button
254
+ {...autoPolicySelect.trigger}
255
+ class={cn(
256
+ "relative flex items-center justify-between gap-6 overflow-hidden rounded-lg border bg-gray-100/80 px-3 py-1.5 text-sm leading-tight whitespace-nowrap shadow-sm",
257
+ "hover:brightness-95 dark:border-gray-700 dark:bg-gray-800 dark:hover:brightness-110",
258
+ )}
259
+ >
260
+ {autoPolicyLabels[autoPolicyValue]}
261
+ <div
262
+ class="absolute right-2 grid size-4 flex-none place-items-center rounded-sm bg-gray-100 text-xs dark:bg-gray-600"
263
+ >
264
+ <IconCaret />
265
+ </div>
266
+ </button>
267
+
268
+ <div {...autoPolicySelect.content} class="rounded-lg border bg-gray-100 dark:border-gray-700 dark:bg-gray-800">
269
+ {#snippet policyOption(policy: "default" | "fastest" | "cheapest", label: string)}
270
+ <div {...autoPolicySelect.getOption(policy)} class="group block w-full p-1 text-sm dark:text-white">
271
+ <div
272
+ class="rounded-md px-2 py-1.5 group-data-[highlighted]:bg-gray-200 dark:group-data-[highlighted]:bg-gray-700"
273
+ >
274
+ <div class="flex flex-col items-start gap-0.5">
275
+ <span>{label}</span>
276
+ <span class="text-xs text-gray-500 dark:text-gray-400">
277
+ {autoPolicyDescriptions[policy]}
278
+ </span>
279
+ </div>
280
+ </div>
281
+ </div>
282
+ {/snippet}
283
+ {@render policyOption("default", "Default")}
284
+ {@render policyOption("fastest", "Fastest")}
285
+ {@render policyOption("cheapest", "Cheapest")}
286
+ </div>
287
+ </div>
288
+ {/if}
289
+ </div>
290
+ {/if}