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

feat: Redesign layout with project tree sidebar and improved UX

Browse files

- Replace system prompt sidebar with dedicated project/branch tree
- Move system prompt to conversation area as optional first message
- Create unified top bar with model selector and settings
- Convert right sidebar settings to popover for more conversation space
- Add collapsible sidebar functionality
- Improve overall layout efficiency and user experience

src/lib/components/inference-playground/conversation.svelte CHANGED
@@ -1,8 +1,15 @@
1
  <script lang="ts">
2
  import { ScrollState } from "$lib/spells/scroll-state.svelte";
3
  import { type ConversationClass } from "$lib/state/conversations.svelte";
 
 
 
4
  import { watch } from "runed";
5
  import { tick } from "svelte";
 
 
 
 
6
  import CodeSnippets from "./code-snippets.svelte";
7
  import Message from "./message.svelte";
8
 
@@ -10,11 +17,18 @@
10
  conversation: ConversationClass;
11
  viewCode: boolean;
12
  onCloseCode: () => void;
 
13
  }
14
 
15
- const { conversation, viewCode, onCloseCode }: Props = $props();
16
 
17
  let messageContainer: HTMLDivElement | null = $state(null);
 
 
 
 
 
 
18
  const scrollState = new ScrollState({
19
  element: () => messageContainer,
20
  offset: { bottom: 100 },
@@ -36,6 +50,13 @@
36
  },
37
  );
38
 
 
 
 
 
 
 
 
39
  async function regenMessage(idx: number) {
40
  // TODO: migrate to new logic
41
  const msg = conversation.data.messages?.[idx];
@@ -49,6 +70,17 @@
49
  conversation.stopGenerating();
50
  conversation.genNextMessage();
51
  }
 
 
 
 
 
 
 
 
 
 
 
52
  </script>
53
 
54
  <div
@@ -57,7 +89,71 @@
57
  bind:this={messageContainer}
58
  >
59
  {#if !viewCode}
60
- {#if conversation.data.messages}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  {#each conversation.data.messages as message, index}
62
  <Message
63
  {message}
@@ -66,12 +162,12 @@
66
  onDelete={() => conversation.deleteMessage(index)}
67
  onRegen={() => regenMessage(index)}
68
  />
69
- {:else}
70
- <div class="m-auto flex flex-col items-center gap-2 text-center px-4 text-balance">
71
- <h1 class="text-2xl font-semibold">Welcome to Hugging Face Inference Playground</h1>
72
- <p class="text-lg text-gray-500">Try hundreds of models on different providers</p>
73
- </div>
74
  {/each}
 
 
 
 
 
75
  {/if}
76
  {:else}
77
  <CodeSnippets {conversation} {onCloseCode} />
 
1
  <script lang="ts">
2
  import { ScrollState } from "$lib/spells/scroll-state.svelte";
3
  import { type ConversationClass } from "$lib/state/conversations.svelte";
4
+ import { projects } from "$lib/state/projects.svelte";
5
+ import { isSystemPromptSupported } from "$lib/utils/business.svelte.js";
6
+ import { cn } from "$lib/utils/cn.js";
7
  import { watch } from "runed";
8
  import { tick } from "svelte";
9
+ import IconSystem from "~icons/carbon/bot";
10
+ import IconEdit from "~icons/carbon/edit";
11
+ import IconCheck from "~icons/carbon/checkmark";
12
+ import IconClose from "~icons/carbon/close";
13
  import CodeSnippets from "./code-snippets.svelte";
14
  import Message from "./message.svelte";
15
 
 
17
  conversation: ConversationClass;
18
  viewCode: boolean;
19
  onCloseCode: () => void;
20
+ showSystemPrompt?: boolean;
21
  }
22
 
23
+ const { conversation, viewCode, onCloseCode, showSystemPrompt = false }: Props = $props();
24
 
25
  let messageContainer: HTMLDivElement | null = $state(null);
26
+ let editingSystemPrompt = $state(false);
27
+ let systemPromptText = $state(projects.current?.systemMessage ?? "");
28
+
29
+ const systemPromptSupported = $derived(isSystemPromptSupported(conversation.model));
30
+ const hasSystemPrompt = $derived(!!projects.current?.systemMessage?.trim());
31
+
32
  const scrollState = new ScrollState({
33
  element: () => messageContainer,
34
  offset: { bottom: 100 },
 
50
  },
51
  );
52
 
53
+ watch(
54
+ () => projects.current?.systemMessage,
55
+ () => {
56
+ systemPromptText = projects.current?.systemMessage ?? "";
57
+ },
58
+ );
59
+
60
  async function regenMessage(idx: number) {
61
  // TODO: migrate to new logic
62
  const msg = conversation.data.messages?.[idx];
 
70
  conversation.stopGenerating();
71
  conversation.genNextMessage();
72
  }
73
+
74
+ function saveSystemPrompt() {
75
+ if (!projects.current) return;
76
+ projects.update({ ...projects.current, systemMessage: systemPromptText });
77
+ editingSystemPrompt = false;
78
+ }
79
+
80
+ function cancelSystemPromptEdit() {
81
+ systemPromptText = projects.current?.systemMessage ?? "";
82
+ editingSystemPrompt = false;
83
+ }
84
  </script>
85
 
86
  <div
 
89
  bind:this={messageContainer}
90
  >
91
  {#if !viewCode}
92
+ {#if showSystemPrompt && systemPromptSupported}
93
+ <!-- System prompt as first message -->
94
+ <div
95
+ class={cn(
96
+ "border-b border-gray-200 bg-gray-50 dark:border-gray-800 dark:bg-gray-900/50",
97
+ !hasSystemPrompt && !editingSystemPrompt && "opacity-60",
98
+ )}
99
+ >
100
+ {#if editingSystemPrompt}
101
+ <div class="px-4 py-3">
102
+ <div class="mb-2 flex items-center justify-between">
103
+ <div class="flex items-center gap-2">
104
+ <IconSystem class="size-5 text-blue-600 dark:text-blue-400" />
105
+ <span class="text-sm font-semibold text-gray-700 uppercase dark:text-gray-300">System</span>
106
+ </div>
107
+ <div class="flex items-center gap-1">
108
+ <button
109
+ onclick={saveSystemPrompt}
110
+ class="rounded p-1 text-green-600 hover:bg-green-100 dark:text-green-400 dark:hover:bg-green-900/30"
111
+ aria-label="Save"
112
+ >
113
+ <IconCheck class="size-4" />
114
+ </button>
115
+ <button
116
+ onclick={cancelSystemPromptEdit}
117
+ class="rounded p-1 text-gray-500 hover:bg-gray-200 dark:text-gray-400 dark:hover:bg-gray-700"
118
+ aria-label="Cancel"
119
+ >
120
+ <IconClose class="size-4" />
121
+ </button>
122
+ </div>
123
+ </div>
124
+ <textarea
125
+ bind:value={systemPromptText}
126
+ placeholder="Enter a system prompt to define the assistant's behavior..."
127
+ class="w-full resize-none rounded-lg border border-gray-300 bg-white p-2 text-sm outline-none focus:border-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:focus:border-blue-400"
128
+ rows="3"
129
+ ></textarea>
130
+ </div>
131
+ {:else}
132
+ <div class="group relative px-4 py-3 hover:bg-gray-100 dark:hover:bg-gray-800">
133
+ <button class="w-full text-left" onclick={() => (editingSystemPrompt = true)}>
134
+ <div class="flex items-center justify-between">
135
+ <div class="flex items-center gap-2">
136
+ <IconSystem class="size-5 text-blue-600 dark:text-blue-400" />
137
+ <span class="text-sm font-semibold text-gray-700 uppercase dark:text-gray-300">System</span>
138
+ </div>
139
+ <div class="rounded p-1 text-gray-400 opacity-0 group-hover:opacity-100">
140
+ <IconEdit class="size-4" />
141
+ </div>
142
+ </div>
143
+ {#if hasSystemPrompt}
144
+ <p class="mt-2 text-sm whitespace-pre-wrap text-gray-700 dark:text-gray-300">
145
+ {projects.current?.systemMessage}
146
+ </p>
147
+ {:else}
148
+ <p class="mt-2 text-sm text-gray-500 italic">Click to add a system prompt...</p>
149
+ {/if}
150
+ </button>
151
+ </div>
152
+ {/if}
153
+ </div>
154
+ {/if}
155
+
156
+ {#if conversation.data.messages && conversation.data.messages.length > 0}
157
  {#each conversation.data.messages as message, index}
158
  <Message
159
  {message}
 
162
  onDelete={() => conversation.deleteMessage(index)}
163
  onRegen={() => regenMessage(index)}
164
  />
 
 
 
 
 
165
  {/each}
166
+ {:else}
167
+ <div class="m-auto flex flex-col items-center gap-2 px-4 text-center text-balance">
168
+ <h1 class="text-2xl font-semibold">Welcome to Hugging Face Inference Playground</h1>
169
+ <p class="text-lg text-gray-500">Try hundreds of models on different providers</p>
170
+ </div>
171
  {/if}
172
  {:else}
173
  <CodeSnippets {conversation} {onCloseCode} />
src/lib/components/inference-playground/model-selector.svelte CHANGED
@@ -2,6 +2,7 @@
2
  import type { ConversationClass } from "$lib/state/conversations.svelte";
3
  import { models } from "$lib/state/models.svelte.js";
4
  import { isCustomModel, isHFModel, type Model } from "$lib/types.js";
 
5
  import IconCaret from "~icons/carbon/chevron-down";
6
  import Avatar from "../avatar.svelte";
7
  import ModelSelectorModal from "./model-selector-modal.svelte";
@@ -9,9 +10,10 @@
9
 
10
  interface Props {
11
  conversation: ConversationClass;
 
12
  }
13
 
14
- const { conversation }: Props = $props();
15
 
16
  let showModelPickerModal = $state(false);
17
 
@@ -34,35 +36,56 @@
34
  const id = $props.id();
35
  </script>
36
 
37
- <div class="flex flex-col gap-2">
38
- <label for={id} class="flex items-baseline gap-2 text-sm font-medium text-gray-900 dark:text-white">
39
- Models<span class="text-xs font-normal text-gray-400">{models.all.length}</span>
40
- </label>
41
  <button
42
  {id}
43
- class="focus-outline 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 hover:brightness-95 dark:border-gray-700 dark:bg-gray-800 dark:hover:brightness-110"
 
 
 
 
44
  onclick={() => (showModelPickerModal = true)}
45
  >
46
- <div class="overflow-hidden text-start">
47
- <div class="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-300">
48
- <Avatar model={conversation.model} orgName={nameSpace} size="sm" />
49
- {nameSpace}
50
- </div>
51
- <div class="truncate">{modelName}</div>
52
- </div>
53
- <div
54
- class="absolute right-2 grid size-4 flex-none place-items-center rounded-sm bg-gray-100 text-xs dark:bg-gray-600"
55
- >
56
- <IconCaret />
57
  </div>
 
58
  </button>
59
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
 
61
  {#if showModelPickerModal}
62
  <ModelSelectorModal {conversation} onModelSelect={changeModel} onClose={() => (showModelPickerModal = false)} />
63
  {/if}
64
 
65
- {#if isHFModel(conversation.model)}
66
  <!-- eslint-disable-next-line @typescript-eslint/no-explicit-any -->
67
  <ProviderSelect conversation={conversation as any} />
68
  {/if}
 
2
  import type { ConversationClass } from "$lib/state/conversations.svelte";
3
  import { models } from "$lib/state/models.svelte.js";
4
  import { isCustomModel, isHFModel, type Model } from "$lib/types.js";
5
+ import { cn } from "$lib/utils/cn.js";
6
  import IconCaret from "~icons/carbon/chevron-down";
7
  import Avatar from "../avatar.svelte";
8
  import ModelSelectorModal from "./model-selector-modal.svelte";
 
10
 
11
  interface Props {
12
  conversation: ConversationClass;
13
+ compact?: boolean;
14
  }
15
 
16
+ const { conversation, compact = false }: Props = $props();
17
 
18
  let showModelPickerModal = $state(false);
19
 
 
36
  const id = $props.id();
37
  </script>
38
 
39
+ {#if compact}
40
+ <!-- Compact mode for top bar -->
 
 
41
  <button
42
  {id}
43
+ class={cn(
44
+ "focus-outline relative flex items-center gap-2 overflow-hidden rounded-lg border",
45
+ "bg-gray-100/80 px-3 py-1.5 text-sm leading-tight whitespace-nowrap shadow-sm",
46
+ "hover:brightness-95 dark:border-gray-700 dark:bg-gray-800 dark:hover:brightness-110",
47
+ )}
48
  onclick={() => (showModelPickerModal = true)}
49
  >
50
+ <Avatar model={conversation.model} orgName={nameSpace} size="sm" />
51
+ <div class="overflow-hidden">
52
+ <span class="text-xs text-gray-500 dark:text-gray-400">{nameSpace}/</span>
53
+ <span class="truncate font-medium">{modelName}</span>
 
 
 
 
 
 
 
54
  </div>
55
+ <IconCaret class="size-4 flex-none text-gray-500" />
56
  </button>
57
+ {:else}
58
+ <!-- Full mode for settings panel -->
59
+ <div class="flex flex-col gap-2">
60
+ <label for={id} class="flex items-baseline gap-2 text-sm font-medium text-gray-900 dark:text-white">
61
+ Models<span class="text-xs font-normal text-gray-400">{models.all.length}</span>
62
+ </label>
63
+ <button
64
+ {id}
65
+ class="focus-outline 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 hover:brightness-95 dark:border-gray-700 dark:bg-gray-800 dark:hover:brightness-110"
66
+ onclick={() => (showModelPickerModal = true)}
67
+ >
68
+ <div class="overflow-hidden text-start">
69
+ <div class="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-300">
70
+ <Avatar model={conversation.model} orgName={nameSpace} size="sm" />
71
+ {nameSpace}
72
+ </div>
73
+ <div class="truncate">{modelName}</div>
74
+ </div>
75
+ <div
76
+ class="absolute right-2 grid size-4 flex-none place-items-center rounded-sm bg-gray-100 text-xs dark:bg-gray-600"
77
+ >
78
+ <IconCaret />
79
+ </div>
80
+ </button>
81
+ </div>
82
+ {/if}
83
 
84
  {#if showModelPickerModal}
85
  <ModelSelectorModal {conversation} onModelSelect={changeModel} onClose={() => (showModelPickerModal = false)} />
86
  {/if}
87
 
88
+ {#if !compact && isHFModel(conversation.model)}
89
  <!-- eslint-disable-next-line @typescript-eslint/no-explicit-any -->
90
  <ProviderSelect conversation={conversation as any} />
91
  {/if}
src/lib/components/inference-playground/playground.svelte CHANGED
@@ -1,279 +1,264 @@
1
  <script lang="ts">
2
- import { observe, observed, ObservedElements } from "$lib/attachments/observe.svelte.js";
3
  import { TEST_IDS } from "$lib/constants.js";
4
  import { conversations } from "$lib/state/conversations.svelte";
5
  import { projects } from "$lib/state/projects.svelte";
6
  import { isHFModel } from "$lib/types.js";
7
  import { iterate } from "$lib/utils/array.js";
8
- import { isSystemPromptSupported } from "$lib/utils/business.svelte.js";
9
  import { atLeastNDecimals } from "$lib/utils/number.js";
 
10
  import IconExternal from "~icons/carbon/arrow-up-right";
11
  import IconWaterfall from "~icons/carbon/chart-waterfall";
12
- import IconClose from "~icons/carbon/close";
13
  import IconCode from "~icons/carbon/code";
14
  import IconCompare from "~icons/carbon/compare";
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";
21
  import Toaster from "../toaster.svelte";
22
  import Tooltip from "../tooltip.svelte";
23
  import BillingModal from "./billing-modal.svelte";
24
- import BranchTreeModal from "./branch-tree-modal.svelte";
25
  import PlaygroundConversationHeader from "./conversation-header.svelte";
26
  import PlaygroundConversation from "./conversation.svelte";
27
  import GenerationConfig from "./generation-config.svelte";
28
  import MessageTextarea from "./message-textarea.svelte";
29
  import ModelSelectorModal from "./model-selector-modal.svelte";
30
  import ModelSelector from "./model-selector.svelte";
31
- import ProjectSelect from "./project-select.svelte";
 
32
 
33
  let viewCode = $state(false);
34
- let viewSettings = $state(false);
35
  let billingModalOpen = $state(false);
36
-
37
  let selectCompareModelOpen = $state(false);
 
38
 
39
- const systemPromptSupported = $derived(conversations.active.some(c => isSystemPromptSupported(c.model)));
40
  const compareActive = $derived(conversations.active.length === 2);
 
 
 
 
 
 
 
 
41
  </script>
42
 
43
  <div
44
  class={[
45
- "motion-safe:animate-fade-in grid h-dvh divide-gray-200 overflow-hidden bg-gray-100/50",
46
- "max-md:grid-rows-[120px_1fr] max-md:divide-y",
47
- "dark:divide-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:[color-scheme:dark]",
48
- compareActive
49
- ? "md:grid-cols-[clamp(220px,20%,350px)_minmax(0,1fr)]"
50
- : "md:grid-cols-[clamp(220px,20%,350px)_minmax(0,1fr)_clamp(270px,25%,300px)]",
51
  ]}
52
  >
53
- <!-- First column -->
54
- <div class="flex flex-col gap-2 overflow-y-auto py-3 pr-3 max-md:pl-3">
55
- <div class="flex items-center gap-2 md:pl-2">
56
- <ProjectSelect />
57
- <BranchTreeModal />
58
- </div>
59
- <div
60
- class="relative flex flex-1 flex-col gap-6 overflow-y-hidden rounded-r-xl border-x border-y border-gray-200/80 bg-linear-to-b from-white via-white p-3 shadow-xs max-md:rounded-xl dark:border-white/5 dark:from-gray-800/40 dark:via-gray-800/40"
61
- class:pointer-events-none={!systemPromptSupported}
62
- class:opacity-70={!systemPromptSupported}
63
- >
64
- <div class="pb-2 text-sm font-semibold uppercase">system</div>
65
- <textarea
66
- name=""
67
- id=""
68
- placeholder={systemPromptSupported
69
- ? "Enter a custom prompt"
70
- : "System prompt is not supported with the chosen model."}
71
- value={systemPromptSupported ? (projects.current?.systemMessage ?? "") : ""}
72
- onchange={e => {
73
- if (!projects.current) return;
74
- projects.update({ ...projects.current, systemMessage: e.currentTarget.value });
75
- }}
76
- class="absolute inset-x-0 bottom-0 h-full resize-none bg-transparent px-3 pt-10 text-sm outline-hidden"
77
- ></textarea>
78
- </div>
79
- </div>
80
 
81
- <!-- Center column -->
82
- <div class="relative flex h-full flex-col overflow-hidden">
83
- <Toaster />
84
- <div
85
- class="flex flex-1 divide-x divide-gray-200 overflow-x-auto overflow-y-hidden *:w-full max-sm:w-dvw md:pt-3 dark:divide-gray-800"
86
  >
87
- {#each conversations.active as conversation, conversationIdx (conversation.data.id)}
88
- <div class="flex h-full flex-col overflow-hidden max-sm:min-w-full">
89
- {#if compareActive}
90
- <PlaygroundConversationHeader
91
- {conversationIdx}
92
- {conversation}
93
- on:close={() => conversations.delete(conversation.data)}
94
- />
95
- {/if}
96
- <PlaygroundConversation {conversation} {viewCode} onCloseCode={() => (viewCode = false)} />
97
- </div>
98
- {/each}
99
- </div>
100
-
101
- {#if !viewCode}
102
- <MessageTextarea />
103
- {/if}
104
-
105
- <!-- Bottom bar -->
106
- <div
107
- class="relative mt-auto flex h-14 shrink-0 items-center justify-center gap-2 overflow-hidden px-3 whitespace-nowrap"
108
- >
109
- <div class="flex flex-1 justify-start gap-x-2">
110
- {#if !compareActive}
111
- <button
112
- type="button"
113
- onclick={() => (viewSettings = !viewSettings)}
114
- class="flex h-[28px]! items-center gap-1 rounded-lg border border-gray-200 bg-white px-2 py-2.5 text-sm font-medium text-gray-900 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 focus:outline-hidden md:hidden dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700"
115
- >
116
- <IconSettings />
117
- {!viewSettings ? "Settings" : "Hide"}
118
- </button>
119
- {/if}
120
  <Tooltip>
121
  {#snippet trigger(tooltip)}
122
  <button
123
- type="button"
124
- onclick={conversations.reset}
125
- class="btn size-[28px]! p-0!"
126
  {...tooltip.trigger}
127
- data-test-id={TEST_IDS.reset}
128
  >
129
- <IconDelete />
 
 
 
 
130
  </button>
131
  {/snippet}
132
- Clear conversation
133
  </Tooltip>
 
 
 
 
 
 
134
  </div>
135
- <div
136
- class="pointer-events-none absolute inset-0 flex flex-1 shrink-0 items-center justify-around gap-x-8 text-center text-sm text-gray-500 max-xl:hidden"
137
- >
138
- {#each iterate(conversations.generationStats) as [{ latency, tokens, cost }, isLast]}
139
- {@const baLeft = observed["bottom-actions"].rect.left}
140
- {@const tceRight = observed["token-count-end"].offset.right}
141
- <span
142
- style:translate={isLast ? (baLeft - 12 < tceRight ? baLeft - tceRight - 12 + "px" : "") : undefined}
143
- {@attach observe({
144
- name: isLast ? ObservedElements.TokenCountEnd : ObservedElements.TokenCountStart,
145
- useRaf: true,
146
- })}
147
- >
148
- {tokens} tokens · Latency {latency}ms · Cost ${atLeastNDecimals(cost ?? 0, 1)}
149
- </span>
150
- {/each}
151
- </div>
152
- <div class="flex flex-1 justify-end gap-x-2">
153
- <button
154
- type="button"
155
- onclick={() => (viewCode = !viewCode)}
156
- class="btn h-[28px]! px-2!"
157
- {@attach observe({ name: ObservedElements.BottomActions, useRaf: true })}
158
- >
159
- <IconCode />
160
- {!viewCode ? "View Code" : "Hide Code"}
161
- </button>
162
- </div>
163
- </div>
164
- </div>
165
 
166
- <!-- Last column -->
167
- {#if !compareActive}
168
- <div
169
- class={[
170
- "z-50 flex h-full flex-col overflow-y-auto p-3 max-md:fixed max-md:inset-0 max-md:backdrop-blur-lg ",
171
- !viewSettings && "max-md:hidden",
172
- ]}
173
- >
174
- <div
175
- class="relative flex flex-1 flex-col gap-6 overflow-y-auto rounded-xl border border-gray-200/80
176
- bg-white bg-linear-to-b from-white via-white p-3 shadow-xs
177
- dark:border-white/5 dark:bg-gray-900 dark:from-gray-800/40 dark:via-gray-800/40"
178
- >
179
- <!-- Close button -->
180
- <button
181
- type="button"
182
- class="btn absolute top-1 right-1 flex size-6 items-center justify-center rounded-lg
183
- bg-gray-100 p-1 text-gray-500 hover:bg-gray-200 md:hidden dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700"
184
- onclick={() => (viewSettings = false)}
185
- >
186
- <IconClose />
187
- </button>
188
- <div class="flex flex-col gap-2">
189
- <ModelSelector conversation={conversations.active[0]!} />
190
- <div class="flex items-center gap-2 self-end px-2 text-xs whitespace-nowrap">
191
  <button
192
- class="flex items-center gap-0.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
193
  onclick={() => (selectCompareModelOpen = true)}
194
  >
195
- <IconCompare />
196
  Compare
197
  </button>
198
- {#if isHFModel(conversations.active[0]?.model)}
199
- <a
200
- href="https://huggingface.co/{conversations.active[0]?.model.id}?inference_provider={conversations
201
- .active[0].data.provider}"
202
- target="_blank"
203
- class="flex items-center gap-0.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
204
- >
205
- <IconExternal class="text-2xs" />
206
- Model page
207
- </a>
208
- {/if}
209
  </div>
210
- </div>
211
-
212
- <GenerationConfig conversation={conversations.active[0]!} />
213
 
214
- <div class="mt-auto space-y-3">
215
- <div class="flex items-center justify-end">
216
- <BillingIndicator showModal={() => (billingModalOpen = true)} />
217
- </div>
218
- <div class="flex flex-wrap items-center justify-end gap-4 whitespace-nowrap">
219
  <button
220
- onclick={() => projects.current && showShareModal(projects.current)}
221
- class="flex items-center gap-1 text-sm text-gray-500 underline decoration-gray-300 hover:text-gray-800 dark:text-gray-400 dark:decoration-gray-600 dark:hover:text-gray-200"
 
 
222
  >
223
- <IconShare class="text-xs" />
224
- Share
225
  </button>
226
- <a
227
- class="flex items-center gap-1 text-sm 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
- href="https://huggingface.co/inference/models"
229
- target="_blank"
230
- >
231
- <IconWaterfall class="text-xs" />
232
- Metrics
233
- </a>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  </div>
236
 
237
- <div class="mt-auto hidden">
238
- <div class="mb-3 flex items-center justify-between gap-2">
239
- <label for="default-range" class="block text-sm font-medium text-gray-900 dark:text-white">API Quota</label>
240
- <span
241
- class="rounded-sm bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-800 dark:bg-gray-700 dark:text-gray-300"
242
- >Free</span
243
- >
 
 
 
244
 
245
- <div class="ml-auto w-12 text-right text-sm">76%</div>
246
- </div>
247
- <div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
248
- <div class="h-2 rounded-full bg-black dark:bg-gray-400" style="width: 75%"></div>
249
- </div>
250
  </div>
251
  </div>
252
  </div>
253
- {/if}
254
- </div>
255
 
256
- <div class="absolute bottom-6 left-4 flex items-center gap-2 max-md:hidden">
257
- <a
258
- target="_blank"
259
- href="https://huggingface.co/docs/inference-providers/tasks/chat-completion"
260
- class="flex items-center gap-1 text-sm text-gray-500 underline decoration-gray-300 hover:text-gray-800 dark:text-gray-400 dark:decoration-gray-600 dark:hover:text-gray-200"
261
- >
262
- <div class="text-xs">
263
- <IconInfo />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  </div>
265
- View Docs
266
- </a>
267
- <span class="dark:text-gray-500">·</span>
268
- <a
269
- target="_blank"
270
- href="https://huggingface.co/spaces/huggingface/inference-playground/discussions/1"
271
- class="flex items-center gap-1 text-sm text-gray-500 underline decoration-gray-300 hover:text-gray-800 dark:text-gray-400 dark:decoration-gray-600 dark:hover:text-gray-200"
272
- >
273
- Give feedback
274
- </a>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
  </div>
276
 
 
277
  {#if selectCompareModelOpen}
278
  <ModelSelectorModal
279
  conversation={conversations.active[0]!}
@@ -290,6 +275,7 @@
290
  />
291
  {/if}
292
 
 
293
  {#if billingModalOpen}
294
  <BillingModal onClose={() => (billingModalOpen = false)} />
295
  {/if}
 
1
  <script lang="ts">
 
2
  import { TEST_IDS } from "$lib/constants.js";
3
  import { conversations } from "$lib/state/conversations.svelte";
4
  import { projects } from "$lib/state/projects.svelte";
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";
12
  import IconCompare from "~icons/carbon/compare";
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";
21
  import Toaster from "../toaster.svelte";
22
  import Tooltip from "../tooltip.svelte";
23
  import BillingModal from "./billing-modal.svelte";
 
24
  import PlaygroundConversationHeader from "./conversation-header.svelte";
25
  import PlaygroundConversation from "./conversation.svelte";
26
  import GenerationConfig from "./generation-config.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 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,
44
+ onOpenChange: value => {
45
+ settingsPopoverOpen = value;
46
+ },
47
+ });
48
  </script>
49
 
50
  <div
51
  class={[
52
+ "motion-safe:animate-fade-in flex h-dvh overflow-hidden bg-gray-100/50",
53
+ "dark:bg-gray-900 dark:text-gray-300 dark:[color-scheme:dark]",
 
 
 
 
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>
110
+ {#snippet trigger(tooltip)}
 
 
111
  <button
112
+ {...settingsPopover.trigger}
113
+ 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"
114
+ aria-label="Settings"
115
+ {...tooltip.trigger}
116
  >
117
+ <IconSettings class="size-5" />
 
118
  </button>
119
+ {/snippet}
120
+ Settings
121
+ </Tooltip>
122
+
123
+ <!-- Share button -->
124
+ <button
125
+ onclick={() => projects.current && showShareModal(projects.current)}
126
+ 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-200"
127
+ >
128
+ <IconShare class="size-4" />
129
+ Share
130
+ </button>
131
+
132
+ <!-- Billing indicator -->
133
+ <BillingIndicator showModal={() => (billingModalOpen = true)} />
134
+ </div>
135
+ </header>
136
+
137
+ <!-- Conversations area -->
138
+ <div class="relative flex flex-1 flex-col overflow-hidden">
139
+ <Toaster />
140
+ <div class="flex flex-1 divide-x divide-gray-200 overflow-x-auto overflow-y-hidden *:w-full dark:divide-gray-800">
141
+ {#each conversations.active as conversation, conversationIdx (conversation.data.id)}
142
+ <div class="flex h-full flex-col overflow-hidden">
143
+ {#if compareActive}
144
+ <PlaygroundConversationHeader
145
+ {conversationIdx}
146
+ {conversation}
147
+ on:close={() => conversations.delete(conversation.data)}
148
+ />
149
+ {/if}
150
+ <PlaygroundConversation
151
+ {conversation}
152
+ {viewCode}
153
+ onCloseCode={() => (viewCode = false)}
154
+ showSystemPrompt={true}
155
+ />
156
  </div>
157
+ {/each}
158
+ </div>
159
+
160
+ {#if !viewCode}
161
+ <MessageTextarea />
162
+ {/if}
163
+
164
+ <!-- Bottom bar -->
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
172
+ type="button"
173
+ onclick={conversations.reset}
174
+ class="btn size-[28px]! p-0!"
175
+ {...tooltip.trigger}
176
+ data-test-id={TEST_IDS.reset}
177
+ >
178
+ <IconDelete />
179
+ </button>
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>
191
+ {tokens} tokens · Latency {latency}ms · Cost ${atLeastNDecimals(cost ?? 0, 1)}
192
+ </span>
193
+ {/each}
194
+ </div>
195
 
196
+ <div class="flex items-center gap-2">
197
+ <button type="button" onclick={() => (viewCode = !viewCode)} class="btn h-[28px]! px-2!">
198
+ <IconCode />
199
+ {!viewCode ? "View Code" : "Hide Code"}
200
+ </button>
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
+
236
+ <!-- Settings popover content -->
237
+ <div
238
+ {...settingsPopover.content}
239
+ class="z-50 max-h-[600px] w-80 overflow-y-auto rounded-xl border border-gray-200 bg-white p-4 shadow-lg dark:border-gray-700 dark:bg-gray-800"
240
+ >
241
+ <h3 class="mb-4 text-sm font-semibold text-gray-700 uppercase dark:text-gray-300">Generation Settings</h3>
242
+ {#if conversations.active[0]}
243
+ <GenerationConfig conversation={conversations.active[0]} />
244
+
245
+ {#if isHFModel(conversations.active[0]?.model)}
246
+ <div class="mt-4 border-t border-gray-200 pt-4 dark:border-gray-700">
247
+ <a
248
+ href="https://huggingface.co/{conversations.active[0]?.model.id}?inference_provider={conversations.active[0]
249
+ .data.provider}"
250
+ target="_blank"
251
+ class="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
252
+ >
253
+ <IconExternal class="size-3" />
254
+ View model page
255
+ </a>
256
+ </div>
257
+ {/if}
258
+ {/if}
259
  </div>
260
 
261
+ <!-- Model selection modal -->
262
  {#if selectCompareModelOpen}
263
  <ModelSelectorModal
264
  conversation={conversations.active[0]!}
 
275
  />
276
  {/if}
277
 
278
+ <!-- Billing modal -->
279
  {#if billingModalOpen}
280
  <BillingModal onClose={() => (billingModalOpen = false)} />
281
  {/if}
src/lib/components/inference-playground/project-tree-sidebar.svelte ADDED
@@ -0,0 +1,301 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import { conversations } from "$lib/state/conversations.svelte.js";
3
+ import { projects } from "$lib/state/projects.svelte.js";
4
+ import { checkpoints } from "$lib/state/checkpoints.svelte.js";
5
+ import { cn } from "$lib/utils/cn.js";
6
+ import { Tree, type TreeItem } from "melt/builders";
7
+ import { watch } from "runed";
8
+ import { SvelteMap } from "svelte/reactivity";
9
+ import IconBranch from "~icons/carbon/branch";
10
+ import IconChevronDown from "~icons/carbon/chevron-down";
11
+ import IconChevronRight from "~icons/carbon/chevron-right";
12
+ import IconFolder from "~icons/carbon/folder";
13
+ import IconFolderOpen from "~icons/carbon/folder-open";
14
+ import IconPlus from "~icons/carbon/add";
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;
22
+ value: string;
23
+ project: NonNullable<typeof projects.current>;
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[] => {
35
+ const all_projects = projects.all;
36
+ const node_map = new SvelteMap<string, ProjectTreeItem>();
37
+
38
+ // Create nodes for all projects
39
+ all_projects.forEach(project => {
40
+ node_map.set(project.id, {
41
+ id: project.id,
42
+ value: project.id,
43
+ project,
44
+ children: [],
45
+ });
46
+ });
47
+
48
+ // Build tree structure
49
+ const roots: ProjectTreeItem[] = [];
50
+
51
+ all_projects.forEach(project => {
52
+ const node = node_map.get(project.id)!;
53
+
54
+ if (project.branchedFromId) {
55
+ const parent = node_map.get(project.branchedFromId);
56
+ if (parent) {
57
+ parent.children!.push(node);
58
+ } else {
59
+ // Parent doesn't exist, make it a root
60
+ roots.push(node);
61
+ }
62
+ } else {
63
+ // No parent, it's a root
64
+ roots.push(node);
65
+ }
66
+ });
67
+
68
+ // Sort nodes alphabetically
69
+ const sort_nodes = (nodes: ProjectTreeItem[]) => {
70
+ nodes.sort((a, b) => {
71
+ // Default project always comes first
72
+ if (a.project.id === "default") return -1;
73
+ if (b.project.id === "default") return 1;
74
+ return a.project.name.localeCompare(b.project.name);
75
+ });
76
+ nodes.forEach(node => {
77
+ if (node.children && node.children.length > 0) {
78
+ sort_nodes(node.children);
79
+ }
80
+ });
81
+ };
82
+ sort_nodes(roots);
83
+
84
+ return roots;
85
+ });
86
+
87
+ const tree = new Tree({
88
+ items: () => tree_items,
89
+ selected: () => projects.activeId,
90
+ onSelectedChange: value => {
91
+ if (value) {
92
+ projects.activeId = value;
93
+ }
94
+ },
95
+ expandOnClick: false,
96
+ });
97
+
98
+ // Auto-expand tree on first load
99
+ let init_expanded = false;
100
+ watch(
101
+ () => tree_items,
102
+ () => {
103
+ if (tree_items.length === 0 || init_expanded) return;
104
+ tree.expandAll();
105
+ init_expanded = true;
106
+ },
107
+ );
108
+
109
+ function get_branch_stats(project_id: string) {
110
+ const convs = conversations.for(project_id);
111
+ const total_messages = convs.reduce((sum, c) => sum + (c.data.messages?.length || 0), 0);
112
+ const has_checkpoints = checkpoints.for(project_id).length > 0;
113
+ return { conversations: convs.length, messages: total_messages, has_checkpoints };
114
+ }
115
+
116
+ async function handle_edit_project(e: MouseEvent, id: string, current_name: string) {
117
+ e.stopPropagation();
118
+ const new_name = await prompt("Edit project name", current_name);
119
+ if (new_name && new_name !== current_name) {
120
+ const project = projects.all.find(p => p.id === id);
121
+ if (project) {
122
+ projects.update({ ...project, name: new_name });
123
+ }
124
+ }
125
+ }
126
+
127
+ async function handle_delete_project(e: MouseEvent, id: string) {
128
+ e.stopPropagation();
129
+ if (confirm("Are you sure you want to delete this project? This cannot be undone.")) {
130
+ projects.delete(id);
131
+ }
132
+ }
133
+
134
+ async function handle_new_project() {
135
+ const name = await prompt("New project name");
136
+ if (name) {
137
+ const id = await projects.create({ name, systemMessage: "" });
138
+ projects.activeId = id;
139
+ }
140
+ }
141
+ </script>
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}>
162
+ {#if tree_items.length === 0}
163
+ <div class="flex flex-col items-center gap-2 py-8 text-center">
164
+ <span class="text-sm text-gray-500">No projects yet</span>
165
+ <button
166
+ onclick={handle_new_project}
167
+ class="flex items-center gap-1 rounded-lg bg-blue-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-600"
168
+ >
169
+ <IconPlus class="size-3" />
170
+ Create Project
171
+ </button>
172
+ </div>
173
+ {:else}
174
+ {@render tree_node(tree.children)}
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)}
205
+ {#each items as item (item.id)}
206
+ {@const project = item.item.project}
207
+ {@const is_active = tree.isSelected(item.id)}
208
+ {@const is_expanded = tree.isExpanded(item.id)}
209
+ {@const stats = get_branch_stats(project.id)}
210
+ {@const is_branch = project.branchedFromId !== null && project.branchedFromId !== undefined}
211
+ {@const has_children = item.children && item.children.length > 0}
212
+ {@const is_default = project.id === "default"}
213
+
214
+ <div class="select-none">
215
+ <div
216
+ {...item.attrs}
217
+ class={cn(
218
+ "group flex w-full items-center rounded-md px-1 py-1.5 text-left transition-colors",
219
+ is_active
220
+ ? "bg-blue-100 text-blue-900 dark:bg-blue-900/30 dark:text-blue-300"
221
+ : "text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800",
222
+ )}
223
+ >
224
+ <!-- Expand/collapse button -->
225
+ {#if has_children}
226
+ <button
227
+ onclick={e => {
228
+ e.stopPropagation();
229
+ tree.toggleExpand(item.id);
230
+ }}
231
+ class="flex-none p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
232
+ aria-label={is_expanded ? "Collapse" : "Expand"}
233
+ >
234
+ {#if is_expanded}
235
+ <IconChevronDown class="size-3" />
236
+ {:else}
237
+ <IconChevronRight class="size-3" />
238
+ {/if}
239
+ </button>
240
+ {:else}
241
+ <div class="size-5 flex-none"></div>
242
+ {/if}
243
+
244
+ <!-- Icon -->
245
+ <div class="mr-2 flex-none text-base">
246
+ {#if is_branch}
247
+ <IconBranch class="size-4 text-blue-600 dark:text-blue-400" />
248
+ {:else if is_expanded}
249
+ <IconFolderOpen class="size-4 text-gray-600 dark:text-gray-400" />
250
+ {:else}
251
+ <IconFolder class="size-4 text-gray-600 dark:text-gray-400" />
252
+ {/if}
253
+ </div>
254
+
255
+ <!-- Project name and stats -->
256
+ <div class="min-w-0 flex-1">
257
+ <div class="flex items-center gap-1">
258
+ <span class="truncate text-sm font-medium">
259
+ {project.name}
260
+ </span>
261
+ {#if stats.has_checkpoints}
262
+ <IconHistory class="size-3 flex-none text-yellow-600 dark:text-yellow-400" aria-label="Has checkpoints" />
263
+ {/if}
264
+ </div>
265
+ {#if stats.messages > 0}
266
+ <div class="text-2xs text-gray-500 dark:text-gray-500">
267
+ {stats.messages} msg{stats.messages === 1 ? "" : "s"}
268
+ </div>
269
+ {/if}
270
+ </div>
271
+
272
+ <!-- Actions -->
273
+ {#if !is_default}
274
+ <div class="ml-auto flex items-center gap-0.5 opacity-0 group-hover:opacity-100">
275
+ <button
276
+ onclick={e => handle_edit_project(e, project.id, project.name)}
277
+ class="rounded p-1 text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:hover:bg-gray-700 dark:hover:text-gray-300"
278
+ aria-label="Edit project name"
279
+ >
280
+ <IconEdit class="size-3" />
281
+ </button>
282
+ <button
283
+ onclick={e => handle_delete_project(e, project.id)}
284
+ class="rounded p-1 text-gray-500 hover:bg-gray-200 hover:text-red-600 dark:hover:bg-gray-700 dark:hover:text-red-400"
285
+ aria-label="Delete project"
286
+ >
287
+ <IconDelete class="size-3" />
288
+ </button>
289
+ </div>
290
+ {/if}
291
+ </div>
292
+
293
+ <!-- Children -->
294
+ {#if has_children && is_expanded}
295
+ <div {...tree.group} class="ml-3 border-l-2 border-gray-200 pl-1 dark:border-gray-700">
296
+ {@render tree_node(item.children)}
297
+ </div>
298
+ {/if}
299
+ </div>
300
+ {/each}
301
+ {/snippet}