mirror of
https://github.com/codeaashu/claude-code.git
synced 2026-04-08 14:18:50 +03:00
398 lines
12 KiB
TypeScript
398 lines
12 KiB
TypeScript
import { create } from "zustand";
|
|
import { persist } from "zustand/middleware";
|
|
import { nanoid } from "nanoid";
|
|
import type { Conversation, Message, AppSettings, ConversationTag } from "./types";
|
|
import { DEFAULT_MODEL } from "./constants";
|
|
|
|
const DEFAULT_SETTINGS: AppSettings = {
|
|
// General
|
|
theme: "dark",
|
|
fontSize: { chat: 14, code: 13 },
|
|
sendOnEnter: true,
|
|
showTimestamps: false,
|
|
compactMode: false,
|
|
|
|
// Model
|
|
model: DEFAULT_MODEL,
|
|
maxTokens: 8096,
|
|
temperature: 1.0,
|
|
systemPrompt: "",
|
|
|
|
// API
|
|
apiUrl: process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001",
|
|
apiKey: "",
|
|
streamingEnabled: true,
|
|
|
|
// Permissions
|
|
permissions: {
|
|
autoApprove: {
|
|
file_read: false,
|
|
file_write: false,
|
|
bash: false,
|
|
web_search: false,
|
|
},
|
|
restrictedDirs: [],
|
|
},
|
|
|
|
// MCP
|
|
mcpServers: [],
|
|
|
|
// Keybindings
|
|
keybindings: {
|
|
"new-conversation": "Ctrl+Shift+N",
|
|
"send-message": "Enter",
|
|
"focus-input": "Ctrl+L",
|
|
"toggle-sidebar": "Ctrl+B",
|
|
"open-settings": "Ctrl+,",
|
|
"command-palette": "Ctrl+K",
|
|
},
|
|
|
|
// Privacy
|
|
telemetryEnabled: false,
|
|
};
|
|
|
|
interface ChatState {
|
|
conversations: Conversation[];
|
|
activeConversationId: string | null;
|
|
settings: AppSettings;
|
|
settingsOpen: boolean;
|
|
|
|
// Sidebar state
|
|
sidebarOpen: boolean;
|
|
sidebarWidth: number;
|
|
sidebarTab: "chats" | "history" | "files" | "settings";
|
|
pinnedIds: string[];
|
|
searchQuery: string;
|
|
|
|
// Search & selection state (not persisted)
|
|
isSearchOpen: boolean;
|
|
selectedConversationIds: string[];
|
|
|
|
// Persisted search/tag state
|
|
recentSearches: string[];
|
|
tags: ConversationTag[];
|
|
|
|
// Conversation actions
|
|
createConversation: () => string;
|
|
setActiveConversation: (id: string) => void;
|
|
deleteConversation: (id: string) => void;
|
|
renameConversation: (id: string, title: string) => void;
|
|
updateConversation: (id: string, updates: Partial<Pick<Conversation, "title" | "model">>) => void;
|
|
addMessage: (conversationId: string, message: Omit<Message, "id" | "createdAt">) => string;
|
|
updateMessage: (conversationId: string, messageId: string, updates: Partial<Message>) => void;
|
|
/** Keep only the first `keepCount` messages in the conversation. */
|
|
truncateMessages: (conversationId: string, keepCount: number) => void;
|
|
getActiveConversation: () => Conversation | null;
|
|
|
|
// Bulk actions
|
|
toggleSelectConversation: (id: string) => void;
|
|
clearSelection: () => void;
|
|
bulkDeleteConversations: (ids: string[]) => void;
|
|
|
|
// Tag actions
|
|
createTag: (label: string, color: string) => string;
|
|
deleteTag: (id: string) => void;
|
|
updateTag: (id: string, updates: Partial<Pick<ConversationTag, "label" | "color">>) => void;
|
|
tagConversation: (conversationId: string, tagId: string) => void;
|
|
untagConversation: (conversationId: string, tagId: string) => void;
|
|
|
|
// Search actions
|
|
openSearch: () => void;
|
|
closeSearch: () => void;
|
|
addRecentSearch: (query: string) => void;
|
|
clearRecentSearches: () => void;
|
|
|
|
// Settings actions
|
|
updateSettings: (settings: Partial<AppSettings>) => void;
|
|
resetSettings: (section?: string) => void;
|
|
openSettings: () => void;
|
|
closeSettings: () => void;
|
|
|
|
// Sidebar actions
|
|
toggleSidebar: () => void;
|
|
setSidebarWidth: (w: number) => void;
|
|
setSidebarTab: (tab: "chats" | "history" | "files" | "settings") => void;
|
|
pinConversation: (id: string) => void;
|
|
setSearchQuery: (q: string) => void;
|
|
}
|
|
|
|
export const useChatStore = create<ChatState>()(
|
|
persist(
|
|
(set, get) => ({
|
|
conversations: [],
|
|
activeConversationId: null,
|
|
settings: DEFAULT_SETTINGS,
|
|
settingsOpen: false,
|
|
|
|
sidebarOpen: true,
|
|
sidebarWidth: 280,
|
|
sidebarTab: "chats",
|
|
pinnedIds: [],
|
|
searchQuery: "",
|
|
|
|
isSearchOpen: false,
|
|
selectedConversationIds: [],
|
|
|
|
recentSearches: [],
|
|
tags: [],
|
|
|
|
createConversation: () => {
|
|
const id = nanoid();
|
|
const now = Date.now();
|
|
const conversation: Conversation = {
|
|
id,
|
|
title: "New conversation",
|
|
messages: [],
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
model: get().settings.model,
|
|
tags: [],
|
|
};
|
|
set((state) => ({
|
|
conversations: [conversation, ...state.conversations],
|
|
activeConversationId: id,
|
|
sidebarTab: "chats",
|
|
}));
|
|
return id;
|
|
},
|
|
|
|
setActiveConversation: (id) => {
|
|
set({ activeConversationId: id, sidebarTab: "chats" });
|
|
},
|
|
|
|
deleteConversation: (id) => {
|
|
set((state) => {
|
|
const remaining = state.conversations.filter((c) => c.id !== id);
|
|
const nextActive =
|
|
state.activeConversationId === id
|
|
? (remaining[0]?.id ?? null)
|
|
: state.activeConversationId;
|
|
return {
|
|
conversations: remaining,
|
|
activeConversationId: nextActive,
|
|
pinnedIds: state.pinnedIds.filter((pid) => pid !== id),
|
|
};
|
|
});
|
|
},
|
|
|
|
renameConversation: (id, title) => {
|
|
set((state) => ({
|
|
conversations: state.conversations.map((c) =>
|
|
c.id === id ? { ...c, title, updatedAt: Date.now() } : c
|
|
),
|
|
}));
|
|
},
|
|
|
|
updateConversation: (id, updates) => {
|
|
set((state) => ({
|
|
conversations: state.conversations.map((c) =>
|
|
c.id === id ? { ...c, ...updates, updatedAt: Date.now() } : c
|
|
),
|
|
}));
|
|
},
|
|
|
|
addMessage: (conversationId, message) => {
|
|
const id = nanoid();
|
|
const now = Date.now();
|
|
set((state) => ({
|
|
conversations: state.conversations.map((c) =>
|
|
c.id === conversationId
|
|
? {
|
|
...c,
|
|
messages: [...c.messages, { ...message, id, createdAt: now }],
|
|
updatedAt: now,
|
|
}
|
|
: c
|
|
),
|
|
}));
|
|
return id;
|
|
},
|
|
|
|
updateMessage: (conversationId, messageId, updates) => {
|
|
set((state) => ({
|
|
conversations: state.conversations.map((c) =>
|
|
c.id === conversationId
|
|
? {
|
|
...c,
|
|
messages: c.messages.map((m) =>
|
|
m.id === messageId ? { ...m, ...updates } : m
|
|
),
|
|
updatedAt: Date.now(),
|
|
}
|
|
: c
|
|
),
|
|
}));
|
|
},
|
|
|
|
toggleSelectConversation: (id) => {
|
|
set((state) => ({
|
|
selectedConversationIds: state.selectedConversationIds.includes(id)
|
|
? state.selectedConversationIds.filter((sid) => sid !== id)
|
|
: [...state.selectedConversationIds, id],
|
|
}));
|
|
},
|
|
|
|
clearSelection: () => set({ selectedConversationIds: [] }),
|
|
|
|
bulkDeleteConversations: (ids) => {
|
|
set((state) => {
|
|
const idSet = new Set(ids);
|
|
const remaining = state.conversations.filter((c) => !idSet.has(c.id));
|
|
const nextActive =
|
|
state.activeConversationId && idSet.has(state.activeConversationId)
|
|
? (remaining[0]?.id ?? null)
|
|
: state.activeConversationId;
|
|
return {
|
|
conversations: remaining,
|
|
activeConversationId: nextActive,
|
|
pinnedIds: state.pinnedIds.filter((pid) => !idSet.has(pid)),
|
|
selectedConversationIds: [],
|
|
};
|
|
});
|
|
},
|
|
|
|
createTag: (label, color) => {
|
|
const id = nanoid();
|
|
set((state) => ({ tags: [...state.tags, { id, label, color }] }));
|
|
return id;
|
|
},
|
|
|
|
deleteTag: (id) => {
|
|
set((state) => ({
|
|
tags: state.tags.filter((t) => t.id !== id),
|
|
conversations: state.conversations.map((c) => ({
|
|
...c,
|
|
tags: c.tags?.filter((tid) => tid !== id),
|
|
})),
|
|
}));
|
|
},
|
|
|
|
updateTag: (id, updates) => {
|
|
set((state) => ({
|
|
tags: state.tags.map((t) => (t.id === id ? { ...t, ...updates } : t)),
|
|
}));
|
|
},
|
|
|
|
tagConversation: (conversationId, tagId) => {
|
|
set((state) => ({
|
|
conversations: state.conversations.map((c) =>
|
|
c.id === conversationId
|
|
? { ...c, tags: [...new Set([...(c.tags ?? []), tagId])] }
|
|
: c
|
|
),
|
|
}));
|
|
},
|
|
|
|
untagConversation: (conversationId, tagId) => {
|
|
set((state) => ({
|
|
conversations: state.conversations.map((c) =>
|
|
c.id === conversationId
|
|
? { ...c, tags: c.tags?.filter((tid) => tid !== tagId) }
|
|
: c
|
|
),
|
|
}));
|
|
},
|
|
|
|
openSearch: () => set({ isSearchOpen: true }),
|
|
closeSearch: () => set({ isSearchOpen: false }),
|
|
|
|
addRecentSearch: (query) => {
|
|
if (!query.trim()) return;
|
|
set((state) => ({
|
|
recentSearches: [
|
|
query,
|
|
...state.recentSearches.filter((s) => s !== query),
|
|
].slice(0, 10),
|
|
}));
|
|
},
|
|
|
|
clearRecentSearches: () => set({ recentSearches: [] }),
|
|
|
|
updateSettings: (settings) => {
|
|
set((state) => ({
|
|
settings: { ...state.settings, ...settings },
|
|
}));
|
|
},
|
|
|
|
resetSettings: (section) => {
|
|
if (!section) {
|
|
set({ settings: DEFAULT_SETTINGS });
|
|
return;
|
|
}
|
|
const sectionDefaults: Record<string, Partial<AppSettings>> = {
|
|
general: {
|
|
theme: DEFAULT_SETTINGS.theme,
|
|
fontSize: DEFAULT_SETTINGS.fontSize,
|
|
sendOnEnter: DEFAULT_SETTINGS.sendOnEnter,
|
|
showTimestamps: DEFAULT_SETTINGS.showTimestamps,
|
|
compactMode: DEFAULT_SETTINGS.compactMode,
|
|
},
|
|
model: {
|
|
model: DEFAULT_SETTINGS.model,
|
|
maxTokens: DEFAULT_SETTINGS.maxTokens,
|
|
temperature: DEFAULT_SETTINGS.temperature,
|
|
systemPrompt: DEFAULT_SETTINGS.systemPrompt,
|
|
},
|
|
api: {
|
|
apiUrl: DEFAULT_SETTINGS.apiUrl,
|
|
streamingEnabled: DEFAULT_SETTINGS.streamingEnabled,
|
|
},
|
|
permissions: { permissions: DEFAULT_SETTINGS.permissions },
|
|
keybindings: { keybindings: DEFAULT_SETTINGS.keybindings },
|
|
data: { telemetryEnabled: DEFAULT_SETTINGS.telemetryEnabled },
|
|
};
|
|
const defaults = sectionDefaults[section];
|
|
if (defaults) {
|
|
set((state) => ({ settings: { ...state.settings, ...defaults } }));
|
|
}
|
|
},
|
|
|
|
openSettings: () => set({ settingsOpen: true }),
|
|
closeSettings: () => set({ settingsOpen: false }),
|
|
|
|
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
|
setSidebarWidth: (w) => set({ sidebarWidth: w }),
|
|
setSidebarTab: (tab) => set({ sidebarTab: tab }),
|
|
pinConversation: (id) =>
|
|
set((state) => ({
|
|
pinnedIds: state.pinnedIds.includes(id)
|
|
? state.pinnedIds.filter((pid) => pid !== id)
|
|
: [id, ...state.pinnedIds],
|
|
})),
|
|
setSearchQuery: (q) => set({ searchQuery: q }),
|
|
|
|
getActiveConversation: () => {
|
|
const state = get();
|
|
return (
|
|
state.conversations.find((c) => c.id === state.activeConversationId) ??
|
|
null
|
|
);
|
|
},
|
|
}),
|
|
{
|
|
name: "claude-code-chat",
|
|
partialize: (state) => ({
|
|
conversations: state.conversations,
|
|
activeConversationId: state.activeConversationId,
|
|
settings: state.settings,
|
|
pinnedIds: state.pinnedIds,
|
|
recentSearches: state.recentSearches,
|
|
tags: state.tags,
|
|
}),
|
|
merge: (persisted, current) => ({
|
|
...current,
|
|
...(persisted as object),
|
|
settings: {
|
|
...DEFAULT_SETTINGS,
|
|
...((persisted as { settings?: Partial<AppSettings> }).settings ?? {}),
|
|
},
|
|
// Never persist UI state
|
|
settingsOpen: false,
|
|
isSearchOpen: false,
|
|
sidebarTab: "chats",
|
|
selectedConversationIds: [],
|
|
}),
|
|
}
|
|
)
|
|
);
|