c61712d8c4
- Add shell terminal: new tmux window spawned at project cwd, opened from the Terminal icon in the ChatView header; ShellTerminalView lets you navigate back to chat or jump to the Claude tmux terminal mode - Fix system keyboard appearing on first terminal launch: ghostty-web calls focus() on a contenteditable div inside open(), triggering iOS keyboard before we can suppress it; patch HTMLElement.prototype.focus as a no-op for the synchronous duration of terminal.open() - Add keyboard font-size slider in Settings - Fix virtual keyboard button corner radius and depth scaling with key height - Fix keybar gap/padding proportionality via heightScale factor - Cap keybar font size so it does not grow when bar height is increased Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
180 lines
5.6 KiB
TypeScript
180 lines
5.6 KiB
TypeScript
import { STORAGE } from './storage-keys';
|
|
|
|
const BASE = '';
|
|
|
|
function getToken(): string | null {
|
|
return localStorage.getItem(STORAGE.TOKEN);
|
|
}
|
|
|
|
export function setToken(token: string) {
|
|
localStorage.setItem(STORAGE.TOKEN, token);
|
|
}
|
|
|
|
export function clearToken() {
|
|
localStorage.removeItem(STORAGE.TOKEN);
|
|
}
|
|
|
|
/** Fired when the server rejects the stored token (expired/invalid). */
|
|
export const AUTH_EXPIRED_EVENT = 'clawtap:auth-expired';
|
|
|
|
function handleAuthExpired() {
|
|
clearToken();
|
|
window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT));
|
|
}
|
|
|
|
export function isAuthenticated(): boolean {
|
|
return !!getToken();
|
|
}
|
|
|
|
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
|
const token = getToken();
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
...((options.headers as Record<string, string>) || {}),
|
|
};
|
|
if (token) {
|
|
headers['Authorization'] = `Bearer ${token}`;
|
|
}
|
|
|
|
const res = await fetch(`${BASE}${path}`, { ...options, headers });
|
|
|
|
const refreshed = res.headers.get('X-Refreshed-Token');
|
|
if (refreshed) {
|
|
setToken(refreshed);
|
|
}
|
|
|
|
if (!res.ok) {
|
|
if ((res.status === 401 || res.status === 403) && token) {
|
|
handleAuthExpired();
|
|
}
|
|
const body = await res.json().catch(() => ({}));
|
|
throw new Error(body.error || `HTTP ${res.status}`);
|
|
}
|
|
|
|
return res.json();
|
|
}
|
|
|
|
export const api = {
|
|
login: (password: string) =>
|
|
request<{ token: string }>('/api/auth/login', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ password }),
|
|
}),
|
|
|
|
sessions: (dir?: string, limit?: number) => {
|
|
const params = new URLSearchParams();
|
|
if (dir) params.set('dir', dir);
|
|
if (limit) params.set('limit', String(limit));
|
|
const qs = params.toString();
|
|
return request<any[]>(`/api/sessions${qs ? `?${qs}` : ''}`);
|
|
},
|
|
|
|
sessionMessages: (id: string, dir?: string) => {
|
|
const qs = dir ? `?dir=${encodeURIComponent(dir)}` : '';
|
|
return request<{ messages: any[]; lastModified: string | null }>(`/api/sessions/${id}/messages${qs}`);
|
|
},
|
|
|
|
uploadImage: async (file: File): Promise<{ path: string; filename: string }> => {
|
|
const token = getToken();
|
|
const form = new FormData();
|
|
form.append('image', file);
|
|
const res = await fetch('/api/upload', {
|
|
method: 'POST',
|
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
body: form,
|
|
});
|
|
if (!res.ok) {
|
|
if ((res.status === 401 || res.status === 403) && token) {
|
|
handleAuthExpired();
|
|
}
|
|
throw new Error('Upload failed');
|
|
}
|
|
return res.json();
|
|
},
|
|
|
|
browse: (path?: string) => {
|
|
const qs = path ? `?path=${encodeURIComponent(path)}` : '';
|
|
return request<{ name: string; path: string; hasChildren: boolean }[]>(
|
|
`/api/browse${qs}`
|
|
);
|
|
},
|
|
|
|
adapters: () =>
|
|
request<{ id: string; displayName: string; available: boolean }[]>('/api/adapters'),
|
|
|
|
adapterConfig: (name: string) =>
|
|
request<{
|
|
models: { value: string; label: string; contextWindow: number }[];
|
|
permissionModes: { value: string; label: string }[];
|
|
effortLevels: { value: string; label: string }[];
|
|
effortLabel: string;
|
|
capabilities?: {
|
|
supportsPermissionModes: boolean;
|
|
permissionModeType?: 'cycle' | 'toggle';
|
|
};
|
|
}>(`/api/adapter/${name}/config`),
|
|
|
|
activeSessions: () =>
|
|
request<any[]>('/api/active-sessions'),
|
|
|
|
destroySession: (sessionId: string, adapter?: string) => {
|
|
const qs = adapter ? `?adapter=${adapter}` : '';
|
|
return request<{ ok: boolean }>(`/api/active-sessions/${sessionId}${qs}`, { method: 'DELETE' });
|
|
},
|
|
|
|
vapidPublicKey: () =>
|
|
request<{ publicKey: string }>('/api/push/vapid-public-key'),
|
|
|
|
pushSubscribe: (subscription: PushSubscriptionJSON, deviceId: string) =>
|
|
request<{ ok: boolean }>('/api/push/subscribe', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ subscription, deviceId }),
|
|
}),
|
|
|
|
pushUnsubscribe: (endpoint: string) =>
|
|
request<{ ok: boolean }>('/api/push/unsubscribe', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ endpoint }),
|
|
}),
|
|
|
|
pushPending: () =>
|
|
request<Record<string, number>>('/api/push/pending'),
|
|
|
|
registerReview: (params: { reviewId: string; parentCliSessionId: string; childSessionId: string; targetAdapter: string; anchorMessageId: string; prompt: string; title: string }) =>
|
|
request<{ reviewId: string }>('/api/reviews/register', {
|
|
method: 'POST',
|
|
body: JSON.stringify(params),
|
|
}),
|
|
|
|
endReview: (reviewId: string, endAnchorMessageId?: string) =>
|
|
request<{ ok: boolean }>(`/api/reviews/${reviewId}`, {
|
|
method: 'DELETE',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ endAnchorMessageId }),
|
|
}),
|
|
|
|
sendBackToParent: (reviewId: string, message: string) =>
|
|
request<{ ok: boolean }>(`/api/reviews/${reviewId}/send-back`, { method: 'POST', body: JSON.stringify({ message }) }),
|
|
|
|
getReviews: (parentCliSessionId: string) =>
|
|
request<any[]>(`/api/reviews?parentCliSessionId=${encodeURIComponent(parentCliSessionId)}`),
|
|
|
|
getInstructions: () =>
|
|
request<{ id: string; label: string; instruction: string; created_at: string }[]>('/api/instructions'),
|
|
|
|
createInstruction: (label: string, instruction: string) =>
|
|
request<{ id: string; label: string; instruction: string }>('/api/instructions', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ label, instruction }),
|
|
}),
|
|
|
|
deleteInstruction: (id: string) =>
|
|
request<void>(`/api/instructions/${id}`, { method: 'DELETE' }),
|
|
|
|
newShellTerminal: (cwd: string) =>
|
|
request<{ sessionId: string }>('/api/terminal/shell', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ cwd }),
|
|
}),
|
|
};
|