Skip to content

Commit 894291c

Browse files
feat(app): add model selector, diff editor, usage dashboard, search, split view
Model selector in composer (opus/sonnet/haiku), git diff viewer pane, token usage dashboard overlay, cross-message search, split view for side-by-side threads, and worktree management commands.
1 parent fb4fd6b commit 894291c

11 files changed

Lines changed: 2438 additions & 1 deletion

File tree

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { createSignal } from "solid-js";
2+
import { appStore } from "../../stores/app-store";
3+
4+
function injectStyles() {
5+
if (document.getElementById("thread-toolbar-styles")) return;
6+
const s = document.createElement("style");
7+
s.id = "thread-toolbar-styles";
8+
s.textContent = `
9+
.thread-toolbar {
10+
display: flex;
11+
align-items: center;
12+
justify-content: flex-end;
13+
gap: 2px;
14+
height: 32px;
15+
padding: 0 8px;
16+
background: var(--bg-surface);
17+
border-bottom: 1px solid var(--border);
18+
flex-shrink: 0;
19+
user-select: none;
20+
}
21+
.tt-btn {
22+
width: 26px;
23+
height: 26px;
24+
border: none;
25+
background: transparent;
26+
color: var(--text-tertiary);
27+
cursor: pointer;
28+
border-radius: var(--radius-sm, 6px);
29+
display: flex;
30+
align-items: center;
31+
justify-content: center;
32+
transition: background 0.1s, color 0.1s;
33+
position: relative;
34+
}
35+
.tt-btn:hover {
36+
background: var(--bg-accent);
37+
color: var(--text-secondary);
38+
}
39+
.tt-btn.active {
40+
color: var(--primary);
41+
background: var(--primary-glow);
42+
}
43+
.tt-btn.active:hover {
44+
background: rgba(107, 124, 255, 0.25);
45+
}
46+
.tt-toast {
47+
position: absolute;
48+
top: -28px;
49+
left: 50%;
50+
transform: translateX(-50%);
51+
background: var(--bg-accent);
52+
color: var(--green);
53+
font-size: 10px;
54+
font-weight: 600;
55+
font-family: var(--font-body);
56+
padding: 3px 8px;
57+
border-radius: var(--radius-sm, 6px);
58+
border: 1px solid var(--border);
59+
white-space: nowrap;
60+
pointer-events: none;
61+
animation: ttToastIn 0.15s ease-out;
62+
}
63+
@keyframes ttToastIn {
64+
from { opacity: 0; transform: translateX(-50%) translateY(4px); }
65+
to { opacity: 1; transform: translateX(-50%) translateY(0); }
66+
}
67+
`;
68+
document.head.appendChild(s);
69+
}
70+
71+
// Inject once on module load
72+
injectStyles();
73+
74+
export function ThreadToolbar() {
75+
const { store, setStore } = appStore;
76+
const [showCopied, setShowCopied] = createSignal(false);
77+
78+
const activeTab = () => store.activeTab;
79+
const browserOpen = () => {
80+
const tab = activeTab();
81+
return tab ? !!store.threadBrowserOpen[tab] : false;
82+
};
83+
const diffOpen = () => store.diffPanelOpen;
84+
85+
function toggleBrowser() {
86+
const tab = activeTab();
87+
if (tab) {
88+
setStore("threadBrowserOpen", tab, !store.threadBrowserOpen[tab]);
89+
}
90+
}
91+
92+
function toggleDiff() {
93+
setStore("diffPanelOpen", !store.diffPanelOpen);
94+
}
95+
96+
function exportChat() {
97+
const tab = activeTab();
98+
if (!tab) return;
99+
const msgs = store.threadMessages[tab];
100+
if (!msgs || msgs.length === 0) return;
101+
102+
const thread = store.projects.flatMap((p) => p.threads).find((t) => t.id === tab);
103+
const title = thread?.title || "Chat";
104+
105+
const md = `# ${title}\n\n` + msgs.map((m) => {
106+
const role = m.role === "user" ? "You" : m.role === "assistant" ? "Assistant" : "System";
107+
return `**${role}:**\n${m.content}\n`;
108+
}).join("\n---\n\n");
109+
110+
navigator.clipboard.writeText(md).then(() => {
111+
setShowCopied(true);
112+
setTimeout(() => setShowCopied(false), 1500);
113+
}).catch(() => {
114+
// Fallback: trigger download
115+
const blob = new Blob([md], { type: "text/markdown" });
116+
const url = URL.createObjectURL(blob);
117+
const a = document.createElement("a");
118+
a.href = url;
119+
a.download = `${title.replace(/[^a-zA-Z0-9]/g, "_")}.md`;
120+
a.click();
121+
URL.revokeObjectURL(url);
122+
});
123+
}
124+
125+
return (
126+
<div class="thread-toolbar">
127+
{/* Browser toggle */}
128+
<button
129+
class={`tt-btn ${browserOpen() ? "active" : ""}`}
130+
onClick={toggleBrowser}
131+
title="Toggle browser pane (Cmd+Shift+B)"
132+
>
133+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
134+
<circle cx="12" cy="12" r="10"/>
135+
<line x1="2" y1="12" x2="22" y2="12"/>
136+
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
137+
</svg>
138+
</button>
139+
140+
{/* Export chat */}
141+
<button
142+
class="tt-btn"
143+
onClick={exportChat}
144+
title="Export chat as markdown"
145+
style="position: relative;"
146+
>
147+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
148+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
149+
<polyline points="7 10 12 15 17 10"/>
150+
<line x1="12" y1="15" x2="12" y2="3"/>
151+
</svg>
152+
{showCopied() && <span class="tt-toast">Copied!</span>}
153+
</button>
154+
155+
{/* Diff view toggle */}
156+
<button
157+
class={`tt-btn ${diffOpen() ? "active" : ""}`}
158+
onClick={toggleDiff}
159+
title="Toggle diff view (Cmd+Shift+D)"
160+
>
161+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
162+
<line x1="6" y1="3" x2="6" y2="15"/>
163+
<circle cx="18" cy="6" r="3"/>
164+
<circle cx="6" cy="18" r="3"/>
165+
<path d="M18 9a9 9 0 0 1-9 9"/>
166+
</svg>
167+
</button>
168+
</div>
169+
);
170+
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { createSignal, For, Show, onCleanup } from "solid-js";
2+
import { appStore } from "../../stores/app-store";
3+
4+
const MODELS = [
5+
{ value: null, label: "Default", desc: "Use CLI default model" },
6+
{ value: "opus", label: "Opus", desc: "Most capable, complex tasks" },
7+
{ value: "sonnet", label: "Sonnet", desc: "Balanced speed and quality" },
8+
{ value: "haiku", label: "Haiku", desc: "Fast and lightweight" },
9+
] as const;
10+
11+
export function ModelSelector() {
12+
const { store, setStore } = appStore;
13+
const [open, setOpen] = createSignal(false);
14+
let dropdownRef: HTMLDivElement | undefined;
15+
16+
const currentLabel = () => {
17+
const m = MODELS.find((m) => m.value === store.selectedModel);
18+
return m ? m.label : "Default";
19+
};
20+
21+
function select(value: string | null) {
22+
setStore("selectedModel", value);
23+
setOpen(false);
24+
}
25+
26+
function handleClickOutside(e: MouseEvent) {
27+
if (dropdownRef && !dropdownRef.contains(e.target as Node)) {
28+
setOpen(false);
29+
}
30+
}
31+
32+
function toggle() {
33+
const willOpen = !open();
34+
setOpen(willOpen);
35+
if (willOpen) {
36+
document.addEventListener("mousedown", handleClickOutside);
37+
} else {
38+
document.removeEventListener("mousedown", handleClickOutside);
39+
}
40+
}
41+
42+
onCleanup(() => {
43+
document.removeEventListener("mousedown", handleClickOutside);
44+
});
45+
46+
return (
47+
<div class="model-selector" ref={dropdownRef}>
48+
<button class="meta-pill" onClick={toggle}>
49+
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
50+
<path d="M12 2L2 7l10 5 10-5-10-5z" />
51+
<path d="M2 17l10 5 10-5" />
52+
<path d="M2 12l10 5 10-5" />
53+
</svg>
54+
{currentLabel()}
55+
<svg class="chevron" width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><polyline points="6 9 12 15 18 9" /></svg>
56+
</button>
57+
58+
<Show when={open()}>
59+
<div class="model-dropdown">
60+
<For each={MODELS}>
61+
{(model) => {
62+
const isSelected = () => store.selectedModel === model.value;
63+
return (
64+
<button
65+
class="model-option"
66+
classList={{ selected: isSelected() }}
67+
onClick={() => select(model.value)}
68+
>
69+
<div class="model-option-header">
70+
<span class="model-option-label">{model.label}</span>
71+
<Show when={isSelected()}>
72+
<svg class="model-check" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
73+
<polyline points="20 6 9 17 4 12" />
74+
</svg>
75+
</Show>
76+
</div>
77+
<span class="model-option-desc">{model.desc}</span>
78+
</button>
79+
);
80+
}}
81+
</For>
82+
</div>
83+
</Show>
84+
</div>
85+
);
86+
}
87+
88+
if (!document.getElementById("model-selector-styles")) {
89+
const style = document.createElement("style");
90+
style.id = "model-selector-styles";
91+
style.textContent = `
92+
.model-selector {
93+
position: relative;
94+
}
95+
.model-dropdown {
96+
position: absolute;
97+
bottom: calc(100% + 6px);
98+
left: 0;
99+
min-width: 200px;
100+
background: var(--bg-card);
101+
border: 1px solid var(--border-strong);
102+
border-radius: var(--radius-md);
103+
padding: 4px;
104+
z-index: 100;
105+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
106+
animation: model-dropdown-in 120ms cubic-bezier(0.16, 1, 0.3, 1) both;
107+
}
108+
@keyframes model-dropdown-in {
109+
from {
110+
opacity: 0;
111+
transform: translateY(4px) scale(0.97);
112+
}
113+
to {
114+
opacity: 1;
115+
transform: translateY(0) scale(1);
116+
}
117+
}
118+
.model-option {
119+
display: flex;
120+
flex-direction: column;
121+
gap: 2px;
122+
width: 100%;
123+
padding: 8px 10px;
124+
border-radius: var(--radius-sm);
125+
background: none;
126+
border: none;
127+
cursor: pointer;
128+
text-align: left;
129+
transition: background 0.12s;
130+
}
131+
.model-option:hover {
132+
background: var(--bg-accent);
133+
}
134+
.model-option.selected {
135+
background: rgba(107, 124, 255, 0.08);
136+
}
137+
.model-option-header {
138+
display: flex;
139+
align-items: center;
140+
justify-content: space-between;
141+
gap: 8px;
142+
}
143+
.model-option-label {
144+
font-size: 12px;
145+
font-weight: 500;
146+
color: var(--text);
147+
font-family: var(--font-body);
148+
}
149+
.model-option.selected .model-option-label {
150+
color: var(--primary);
151+
}
152+
.model-check {
153+
color: var(--primary);
154+
flex-shrink: 0;
155+
}
156+
.model-option-desc {
157+
font-size: 10px;
158+
color: var(--text-tertiary);
159+
font-family: var(--font-body);
160+
line-height: 1.3;
161+
}
162+
`;
163+
document.head.appendChild(style);
164+
}

0 commit comments

Comments
 (0)