✨ add gitmoji support for commit messages (#165)
Add gitmoji toggle in Git settings to enable emoji support Show Add gitmoji button in commit section when feature is enabled Display gitmoji picker dialog with searchable emoji list
This commit is contained in:
@@ -275,6 +275,9 @@ fn sanitize_settings_update(payload: &Value) -> Value {
|
||||
}
|
||||
|
||||
// Boolean fields
|
||||
if let Some(Value::Bool(b)) = obj.get("gitmojiEnabled") {
|
||||
result_obj.insert("gitmojiEnabled".to_string(), json!(b));
|
||||
}
|
||||
if let Some(Value::Bool(b)) = obj.get("useSystemTheme") {
|
||||
result_obj.insert("useSystemTheme".to_string(), json!(b));
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ const getDisplayModel = (
|
||||
export const GitSettings: React.FC = () => {
|
||||
const settingsCommitMessageModel = useConfigStore((state) => state.settingsCommitMessageModel);
|
||||
const setSettingsCommitMessageModel = useConfigStore((state) => state.setSettingsCommitMessageModel);
|
||||
const settingsGitmojiEnabled = useConfigStore((state) => state.settingsGitmojiEnabled);
|
||||
const setSettingsGitmojiEnabled = useConfigStore((state) => state.setSettingsGitmojiEnabled);
|
||||
const providers = useConfigStore((state) => state.providers);
|
||||
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
@@ -56,7 +58,7 @@ export const GitSettings: React.FC = () => {
|
||||
React.useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
let data: { commitMessageModel?: string } | null = null;
|
||||
let data: { commitMessageModel?: string; gitmojiEnabled?: boolean } | null = null;
|
||||
|
||||
// 1. Desktop runtime (Tauri)
|
||||
if (isDesktopRuntime()) {
|
||||
@@ -71,6 +73,9 @@ export const GitSettings: React.FC = () => {
|
||||
if (settings) {
|
||||
data = {
|
||||
commitMessageModel: typeof settings.commitMessageModel === 'string' ? settings.commitMessageModel : undefined,
|
||||
gitmojiEnabled: typeof (settings as Record<string, unknown>).gitmojiEnabled === 'boolean'
|
||||
? ((settings as Record<string, unknown>).gitmojiEnabled as boolean)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
@@ -90,12 +95,16 @@ export const GitSettings: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (data) {
|
||||
const model = typeof data.commitMessageModel === 'string' && data.commitMessageModel.trim().length > 0
|
||||
? data.commitMessageModel.trim()
|
||||
: undefined;
|
||||
setSettingsCommitMessageModel(model);
|
||||
}
|
||||
if (data) {
|
||||
const model = typeof data.commitMessageModel === 'string' && data.commitMessageModel.trim().length > 0
|
||||
? data.commitMessageModel.trim()
|
||||
: undefined;
|
||||
setSettingsCommitMessageModel(model);
|
||||
if (typeof data.gitmojiEnabled === 'boolean') {
|
||||
setSettingsGitmojiEnabled(data.gitmojiEnabled);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.warn('Failed to load git settings:', error);
|
||||
} finally {
|
||||
@@ -103,7 +112,7 @@ export const GitSettings: React.FC = () => {
|
||||
}
|
||||
};
|
||||
loadSettings();
|
||||
}, [setSettingsCommitMessageModel]);
|
||||
}, [setSettingsCommitMessageModel, setSettingsGitmojiEnabled]);
|
||||
|
||||
const handleModelChange = React.useCallback(async (providerId: string, modelId: string) => {
|
||||
const newValue = providerId && modelId ? `${providerId}/${modelId}` : undefined;
|
||||
@@ -118,6 +127,18 @@ export const GitSettings: React.FC = () => {
|
||||
}
|
||||
}, [setSettingsCommitMessageModel]);
|
||||
|
||||
const handleGitmojiChange = React.useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const enabled = event.target.checked;
|
||||
setSettingsGitmojiEnabled(enabled);
|
||||
try {
|
||||
await updateDesktopSettings({
|
||||
gitmojiEnabled: enabled,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Failed to save gitmoji setting:', error);
|
||||
}
|
||||
}, [setSettingsGitmojiEnabled]);
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
@@ -139,8 +160,8 @@ export const GitSettings: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="typography-ui-label text-muted-foreground">Model for generation</label>
|
||||
<fieldset className="flex flex-col gap-1.5">
|
||||
<legend className="typography-ui-label text-muted-foreground">Model for generation</legend>
|
||||
<ModelSelector
|
||||
providerId={parsedModel.providerId}
|
||||
modelId={parsedModel.modelId}
|
||||
@@ -151,6 +172,21 @@ export const GitSettings: React.FC = () => {
|
||||
This model will be used to analyze diffs and suggest commit messages.
|
||||
{!settingsCommitMessageModel && <> Default: <span className="text-foreground">opencode/big-pickle</span></>}
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3.5 w-3.5 accent-primary"
|
||||
checked={settingsGitmojiEnabled}
|
||||
onChange={handleGitmojiChange}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">Enable gitmoji picker</span>
|
||||
</label>
|
||||
<p className="typography-meta text-muted-foreground pl-5.5">
|
||||
Adds a gitmoji selector to the Git commit message input.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useFireworksCelebration } from '@/contexts/FireworksContext';
|
||||
import type { GitIdentityProfile, CommitFileEntry } from '@/lib/api/types';
|
||||
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
|
||||
@@ -15,6 +16,20 @@ import {
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { RiGitBranchLine, RiLoader4Line } from '@remixicon/react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
@@ -86,9 +101,12 @@ export const GitView: React.FC = () => {
|
||||
return gitViewSnapshot;
|
||||
}, [currentDirectory]);
|
||||
|
||||
const settingsGitmojiEnabled = useConfigStore((state) => state.settingsGitmojiEnabled);
|
||||
|
||||
const [commitMessage, setCommitMessage] = React.useState(
|
||||
initialSnapshot?.commitMessage ?? ''
|
||||
);
|
||||
const [isGitmojiPickerOpen, setIsGitmojiPickerOpen] = React.useState(false);
|
||||
const [syncAction, setSyncAction] = React.useState<SyncAction>(null);
|
||||
const [commitAction, setCommitAction] = React.useState<CommitAction>(null);
|
||||
const [logMaxCountLocal, setLogMaxCountLocal] = React.useState<number>(25);
|
||||
@@ -124,6 +142,8 @@ export const GitView: React.FC = () => {
|
||||
const [commitFilesMap, setCommitFilesMap] = React.useState<Map<string, CommitFileEntry[]>>(new Map());
|
||||
const [loadingCommitHashes, setLoadingCommitHashes] = React.useState<Set<string>>(new Set());
|
||||
const [remoteUrl, setRemoteUrl] = React.useState<string | null>(null);
|
||||
const [gitmojiEmojis, setGitmojiEmojis] = React.useState<Array<{ emoji: string; code: string; description: string }>>([]);
|
||||
const [gitmojiSearch, setGitmojiSearch] = React.useState('');
|
||||
|
||||
const handleCopyCommitHash = React.useCallback((hash: string) => {
|
||||
navigator.clipboard
|
||||
@@ -160,11 +180,13 @@ export const GitView: React.FC = () => {
|
||||
|
||||
setLoadingCommitHashes((prev) => {
|
||||
const next = new Set(prev);
|
||||
hashesToLoad.forEach((h) => next.add(h));
|
||||
for (const hash of hashesToLoad) {
|
||||
next.add(hash);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
hashesToLoad.forEach((hash) => {
|
||||
for (const hash of hashesToLoad) {
|
||||
git
|
||||
.getCommitFiles(currentDirectory, hash)
|
||||
.then((response) => {
|
||||
@@ -181,7 +203,7 @@ export const GitView: React.FC = () => {
|
||||
return next;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [expandedCommitHashes, currentDirectory, git, commitFilesMap, loadingCommitHashes]);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -213,6 +235,38 @@ export const GitView: React.FC = () => {
|
||||
git.getRemoteUrl(currentDirectory).then(setRemoteUrl).catch(() => setRemoteUrl(null));
|
||||
}, [currentDirectory, git]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!settingsGitmojiEnabled) {
|
||||
setGitmojiEmojis([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const loadGitmojis = async () => {
|
||||
try {
|
||||
const response = await fetch('https://raw.githubusercontent.com/carloscuesta/gitmoji/master/packages/gitmojis/src/gitmojis.json');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load gitmojis: ${response.statusText}`);
|
||||
}
|
||||
const payload = (await response.json()) as { gitmojis?: Array<{ emoji: string; code: string; description: string }> };
|
||||
if (!cancelled) {
|
||||
setGitmojiEmojis(payload.gitmojis ?? []);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
console.warn('Failed to load gitmoji list:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadGitmojis();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [settingsGitmojiEnabled]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (currentDirectory) {
|
||||
setActiveDirectory(currentDirectory);
|
||||
@@ -269,9 +323,9 @@ export const GitView: React.FC = () => {
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
const hasLocal = await git.hasLocalIdentity!(currentDirectory);
|
||||
const hasLocal = await git.hasLocalIdentity?.(currentDirectory);
|
||||
if (cancelled) return;
|
||||
if (hasLocal) return;
|
||||
if (hasLocal === true) return;
|
||||
|
||||
beginIdentityApply();
|
||||
await git.setGitIdentity(currentDirectory, defaultId);
|
||||
@@ -293,18 +347,19 @@ export const GitView: React.FC = () => {
|
||||
};
|
||||
}, [beginIdentityApply, currentDirectory, defaultGitIdentityId, endIdentityApply, git, isGitRepo, refreshIdentity]);
|
||||
|
||||
const changeEntries = React.useMemo(() => {
|
||||
const changeEntries = React.useMemo(() => {
|
||||
if (!status) return [];
|
||||
const files = status.files ?? [];
|
||||
const unique = new Map<string, (typeof files)[number]>();
|
||||
|
||||
files.forEach((file) => {
|
||||
for (const file of files) {
|
||||
unique.set(file.path, file);
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(unique.values()).sort((a, b) => a.path.localeCompare(b.path));
|
||||
}, [status]);
|
||||
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!status || changeEntries.length === 0) {
|
||||
setSelectedPaths(new Set());
|
||||
@@ -316,13 +371,13 @@ export const GitView: React.FC = () => {
|
||||
const next = new Set<string>();
|
||||
const previousSet = previous ?? new Set<string>();
|
||||
|
||||
changeEntries.forEach((file) => {
|
||||
for (const file of changeEntries) {
|
||||
if (previousSet.has(file.path)) {
|
||||
next.add(file.path);
|
||||
} else if (!hasUserAdjustedSelection) {
|
||||
next.add(file.path);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
@@ -565,7 +620,7 @@ export const GitView: React.FC = () => {
|
||||
try {
|
||||
let normalized = remoteUrl.trim();
|
||||
if (normalized.startsWith('git@')) {
|
||||
normalized = 'https://' + normalized.slice(4).replace(':', '/');
|
||||
normalized = `https://${normalized.slice(4).replace(':', '/')}`;
|
||||
}
|
||||
if (normalized.endsWith('.git')) {
|
||||
normalized = normalized.slice(0, -4);
|
||||
@@ -706,6 +761,20 @@ export const GitView: React.FC = () => {
|
||||
});
|
||||
}, [generatedHighlights, clearGeneratedHighlights]);
|
||||
|
||||
const handleSelectGitmoji = React.useCallback((emoji: string, code: string) => {
|
||||
const token = code || emoji;
|
||||
setCommitMessage((current) => {
|
||||
const trimmed = current.trimStart();
|
||||
if (trimmed.startsWith(emoji) || (code && trimmed.startsWith(code))) {
|
||||
return current;
|
||||
}
|
||||
const prefix = token.endsWith(' ') ? token : `${token} `;
|
||||
return `${prefix}${current}`.trimStart();
|
||||
});
|
||||
setGitmojiSearch('');
|
||||
setIsGitmojiPickerOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleLogMaxCountChange = React.useCallback(
|
||||
(count: number) => {
|
||||
setLogMaxCountLocal(count);
|
||||
@@ -773,64 +842,108 @@ export const GitView: React.FC = () => {
|
||||
isWorktreeMode={!!worktreeMetadata}
|
||||
/>
|
||||
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="p-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Two-column layout on large screens: Changes + Commit */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{hasChanges ? (
|
||||
<ChangesSection
|
||||
changeEntries={changeEntries}
|
||||
selectedPaths={selectedPaths}
|
||||
diffStats={status?.diffStats}
|
||||
revertingPaths={revertingPaths}
|
||||
onToggleFile={toggleFileSelection}
|
||||
onSelectAll={selectAll}
|
||||
onClearSelection={clearSelection}
|
||||
onViewDiff={(path) => useUIStore.getState().navigateToDiff(path)}
|
||||
onRevertFile={handleRevertFile}
|
||||
/>
|
||||
) : (
|
||||
<div className="lg:col-span-2 flex justify-center">
|
||||
<GitEmptyState
|
||||
behind={status?.behind ?? 0}
|
||||
onPull={() => handleSyncAction('pull')}
|
||||
isPulling={syncAction === 'pull'}
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="p-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Two-column layout on large screens: Changes + Commit */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{hasChanges ? (
|
||||
<ChangesSection
|
||||
changeEntries={changeEntries}
|
||||
selectedPaths={selectedPaths}
|
||||
diffStats={status?.diffStats}
|
||||
revertingPaths={revertingPaths}
|
||||
onToggleFile={toggleFileSelection}
|
||||
onSelectAll={selectAll}
|
||||
onClearSelection={clearSelection}
|
||||
onViewDiff={(path) => useUIStore.getState().navigateToDiff(path)}
|
||||
onRevertFile={handleRevertFile}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<div className="lg:col-span-2 flex justify-center">
|
||||
<GitEmptyState
|
||||
behind={status?.behind ?? 0}
|
||||
onPull={() => handleSyncAction('pull')}
|
||||
isPulling={syncAction === 'pull'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{changeEntries.length > 0 && (
|
||||
<CommitSection
|
||||
selectedCount={selectedCount}
|
||||
commitMessage={commitMessage}
|
||||
onCommitMessageChange={setCommitMessage}
|
||||
generatedHighlights={generatedHighlights}
|
||||
onInsertHighlights={handleInsertHighlights}
|
||||
onClearHighlights={clearGeneratedHighlights}
|
||||
onGenerateMessage={handleGenerateCommitMessage}
|
||||
isGeneratingMessage={isGeneratingMessage}
|
||||
onCommit={() => handleCommit({ pushAfter: false })}
|
||||
onCommitAndPush={() => handleCommit({ pushAfter: true })}
|
||||
commitAction={commitAction}
|
||||
isBusy={isBusy}
|
||||
/>
|
||||
)}
|
||||
{changeEntries.length > 0 && (
|
||||
<CommitSection
|
||||
selectedCount={selectedCount}
|
||||
commitMessage={commitMessage}
|
||||
onCommitMessageChange={setCommitMessage}
|
||||
generatedHighlights={generatedHighlights}
|
||||
onInsertHighlights={handleInsertHighlights}
|
||||
onClearHighlights={clearGeneratedHighlights}
|
||||
onGenerateMessage={handleGenerateCommitMessage}
|
||||
isGeneratingMessage={isGeneratingMessage}
|
||||
onCommit={() => handleCommit({ pushAfter: false })}
|
||||
onCommitAndPush={() => handleCommit({ pushAfter: true })}
|
||||
commitAction={commitAction}
|
||||
isBusy={isBusy}
|
||||
gitmojiEnabled={settingsGitmojiEnabled}
|
||||
onOpenGitmojiPicker={() => setIsGitmojiPickerOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* History below, constrained width */}
|
||||
<HistorySection
|
||||
log={log}
|
||||
isLogLoading={isLogLoading}
|
||||
logMaxCount={logMaxCountLocal}
|
||||
onLogMaxCountChange={handleLogMaxCountChange}
|
||||
expandedCommitHashes={expandedCommitHashes}
|
||||
onToggleCommit={handleToggleCommit}
|
||||
commitFilesMap={commitFilesMap}
|
||||
loadingCommitHashes={loadingCommitHashes}
|
||||
onCopyHash={handleCopyCommitHash}
|
||||
/>
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
|
||||
<Dialog open={isGitmojiPickerOpen} onOpenChange={setIsGitmojiPickerOpen}>
|
||||
<DialogContent className="max-w-md p-0 overflow-hidden">
|
||||
<DialogHeader className="px-4 pt-4">
|
||||
<DialogTitle>Pick a gitmoji</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Command className="h-[420px]">
|
||||
<CommandInput
|
||||
placeholder="Search gitmojis..."
|
||||
value={gitmojiSearch}
|
||||
onValueChange={setGitmojiSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No gitmojis found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{(gitmojiEmojis.length === 0
|
||||
? []
|
||||
: gitmojiEmojis.filter((entry) => {
|
||||
const term = gitmojiSearch.trim().toLowerCase();
|
||||
if (!term) return true;
|
||||
return (
|
||||
entry.emoji.includes(term) ||
|
||||
entry.code.toLowerCase().includes(term) ||
|
||||
entry.description.toLowerCase().includes(term)
|
||||
);
|
||||
})
|
||||
).map((entry) => (
|
||||
<CommandItem
|
||||
key={entry.code}
|
||||
onSelect={() => handleSelectGitmoji(entry.emoji, entry.code)}
|
||||
>
|
||||
<span className="text-lg">{entry.emoji}</span>
|
||||
<span className="typography-ui-label text-foreground">{entry.code}</span>
|
||||
<span className="typography-meta text-muted-foreground">{entry.description}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* History below, constrained width */}
|
||||
<HistorySection
|
||||
log={log}
|
||||
isLogLoading={isLogLoading}
|
||||
logMaxCount={logMaxCountLocal}
|
||||
onLogMaxCountChange={handleLogMaxCountChange}
|
||||
expandedCommitHashes={expandedCommitHashes}
|
||||
onToggleCommit={handleToggleCommit}
|
||||
commitFilesMap={commitFilesMap}
|
||||
loadingCommitHashes={loadingCommitHashes}
|
||||
onCopyHash={handleCopyCommitHash}
|
||||
/>
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
RiGitCommitLine,
|
||||
RiArrowUpLine,
|
||||
RiAiGenerate2,
|
||||
RiLoader4Line,
|
||||
RiEmotionHappyLine,
|
||||
} from '@remixicon/react';
|
||||
import {
|
||||
Collapsible,
|
||||
@@ -30,6 +30,8 @@ interface CommitSectionProps {
|
||||
onCommitAndPush: () => void;
|
||||
commitAction: CommitAction;
|
||||
isBusy: boolean;
|
||||
gitmojiEnabled: boolean;
|
||||
onOpenGitmojiPicker: () => void;
|
||||
}
|
||||
|
||||
export const CommitSection: React.FC<CommitSectionProps> = ({
|
||||
@@ -45,6 +47,8 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
|
||||
onCommitAndPush,
|
||||
commitAction,
|
||||
isBusy,
|
||||
gitmojiEnabled,
|
||||
onOpenGitmojiPicker,
|
||||
}) => {
|
||||
const hasSelectedFiles = selectedCount > 0;
|
||||
const canCommit = commitMessage.trim() && hasSelectedFiles && commitAction === null;
|
||||
@@ -79,6 +83,19 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
|
||||
disabled={commitAction !== null}
|
||||
/>
|
||||
|
||||
{gitmojiEnabled && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onOpenGitmojiPicker}
|
||||
className="w-fit"
|
||||
type="button"
|
||||
>
|
||||
<RiEmotionHappyLine className="size-4" />
|
||||
Add gitmoji
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -385,6 +385,7 @@ export interface SettingsPayload {
|
||||
autoDeleteEnabled?: boolean;
|
||||
autoDeleteAfterDays?: number;
|
||||
queueModeEnabled?: boolean;
|
||||
gitmojiEnabled?: boolean;
|
||||
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ export type DesktopSettings = {
|
||||
autoCreateWorktree?: boolean;
|
||||
queueModeEnabled?: boolean;
|
||||
commitMessageModel?: string; // format: "provider/model"
|
||||
gitmojiEnabled?: boolean;
|
||||
|
||||
// User-added skills catalogs (persisted to ~/.config/openchamber/settings.json)
|
||||
skillCatalogs?: SkillCatalogConfig[];
|
||||
|
||||
@@ -52,11 +52,16 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
|
||||
} else {
|
||||
localStorage.removeItem('commitMessageModel');
|
||||
}
|
||||
if (typeof settings.gitmojiEnabled === 'boolean') {
|
||||
localStorage.setItem('gitmojiEnabled', String(settings.gitmojiEnabled));
|
||||
} else {
|
||||
localStorage.removeItem('gitmojiEnabled');
|
||||
}
|
||||
};
|
||||
|
||||
type PersistApi = {
|
||||
hasHydrated?: () => boolean;
|
||||
onFinishHydration?: (callback: () => void) => (() => void) | void;
|
||||
onFinishHydration?: (callback: () => void) => (() => void) | undefined;
|
||||
};
|
||||
|
||||
const sanitizeSkillCatalogs = (value: unknown): DesktopSettings['skillCatalogs'] | undefined => {
|
||||
@@ -269,6 +274,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
||||
if (typeof candidate.commitMessageModel === 'string' && candidate.commitMessageModel.length > 0) {
|
||||
result.commitMessageModel = candidate.commitMessageModel;
|
||||
}
|
||||
if (typeof candidate.gitmojiEnabled === 'boolean') {
|
||||
result.gitmojiEnabled = candidate.gitmojiEnabled;
|
||||
}
|
||||
if (typeof candidate.queueModeEnabled === 'boolean') {
|
||||
result.queueModeEnabled = candidate.queueModeEnabled;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ interface OpenChamberDefaults {
|
||||
defaultAgent?: string;
|
||||
autoCreateWorktree?: boolean;
|
||||
commitMessageModel?: string;
|
||||
gitmojiEnabled?: boolean;
|
||||
}
|
||||
|
||||
const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
|
||||
@@ -39,6 +40,7 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
|
||||
defaultAgent: settings?.defaultAgent,
|
||||
autoCreateWorktree: settings?.autoCreateWorktree,
|
||||
commitMessageModel: settings?.commitMessageModel,
|
||||
gitmojiEnabled: settings?.gitmojiEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,6 +55,7 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
|
||||
const defaultVariant = typeof data?.defaultVariant === 'string' ? data.defaultVariant.trim() : '';
|
||||
const defaultAgent = typeof data?.defaultAgent === 'string' ? data.defaultAgent.trim() : '';
|
||||
const commitMessageModel = typeof data?.commitMessageModel === 'string' ? data.commitMessageModel.trim() : '';
|
||||
const gitmojiEnabled = typeof data?.gitmojiEnabled === 'boolean' ? data.gitmojiEnabled : undefined;
|
||||
|
||||
return {
|
||||
defaultModel: defaultModel.length > 0 ? defaultModel : undefined,
|
||||
@@ -60,6 +63,7 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
|
||||
defaultAgent: defaultAgent.length > 0 ? defaultAgent : undefined,
|
||||
autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined,
|
||||
commitMessageModel: commitMessageModel.length > 0 ? commitMessageModel : undefined,
|
||||
gitmojiEnabled,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
@@ -80,6 +84,7 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
|
||||
const defaultVariant = typeof data?.defaultVariant === 'string' ? data.defaultVariant.trim() : '';
|
||||
const defaultAgent = typeof data?.defaultAgent === 'string' ? data.defaultAgent.trim() : '';
|
||||
const commitMessageModel = typeof data?.commitMessageModel === 'string' ? data.commitMessageModel.trim() : '';
|
||||
const gitmojiEnabled = typeof data?.gitmojiEnabled === 'boolean' ? data.gitmojiEnabled : undefined;
|
||||
|
||||
return {
|
||||
defaultModel: defaultModel.length > 0 ? defaultModel : undefined,
|
||||
@@ -87,6 +92,7 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
|
||||
defaultAgent: defaultAgent.length > 0 ? defaultAgent : undefined,
|
||||
autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined,
|
||||
commitMessageModel: commitMessageModel.length > 0 ? commitMessageModel : undefined,
|
||||
gitmojiEnabled,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
@@ -379,6 +385,7 @@ interface ConfigStore {
|
||||
settingsDefaultAgent: string | undefined;
|
||||
settingsAutoCreateWorktree: boolean;
|
||||
settingsCommitMessageModel: string | undefined; // format: "provider/model"
|
||||
settingsGitmojiEnabled: boolean;
|
||||
|
||||
activateDirectory: (directory: string | null | undefined) => Promise<void>;
|
||||
|
||||
@@ -396,6 +403,7 @@ interface ConfigStore {
|
||||
setSettingsDefaultAgent: (agent: string | undefined) => void;
|
||||
setSettingsAutoCreateWorktree: (enabled: boolean) => void;
|
||||
setSettingsCommitMessageModel: (model: string | undefined) => void;
|
||||
setSettingsGitmojiEnabled: (enabled: boolean) => void;
|
||||
saveAgentModelSelection: (agentName: string, providerId: string, modelId: string) => void;
|
||||
getAgentModelSelection: (agentName: string) => { providerId: string; modelId: string } | null;
|
||||
checkConnection: () => Promise<boolean>;
|
||||
@@ -440,6 +448,7 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
settingsDefaultAgent: undefined,
|
||||
settingsAutoCreateWorktree: false,
|
||||
settingsCommitMessageModel: undefined,
|
||||
settingsGitmojiEnabled: false,
|
||||
|
||||
activateDirectory: async (directory) => {
|
||||
const directoryKey = toDirectoryKey(directory);
|
||||
@@ -879,13 +888,13 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
};
|
||||
|
||||
const nextState: Partial<ConfigStore> = {
|
||||
settingsDefaultModel: openChamberDefaults.defaultModel,
|
||||
settingsDefaultVariant: openChamberDefaults.defaultVariant,
|
||||
settingsDefaultAgent: openChamberDefaults.defaultAgent,
|
||||
settingsAutoCreateWorktree: openChamberDefaults.autoCreateWorktree ?? false,
|
||||
settingsCommitMessageModel: openChamberDefaults.commitMessageModel,
|
||||
directoryScoped: {
|
||||
|
||||
settingsDefaultModel: openChamberDefaults.defaultModel,
|
||||
settingsDefaultVariant: openChamberDefaults.defaultVariant,
|
||||
settingsDefaultAgent: openChamberDefaults.defaultAgent,
|
||||
settingsAutoCreateWorktree: openChamberDefaults.autoCreateWorktree ?? false,
|
||||
settingsCommitMessageModel: openChamberDefaults.commitMessageModel,
|
||||
settingsGitmojiEnabled: openChamberDefaults.gitmojiEnabled ?? false,
|
||||
directoryScoped: {
|
||||
...state.directoryScoped,
|
||||
[directoryKey]: nextSnapshot,
|
||||
},
|
||||
@@ -996,9 +1005,10 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
|
||||
// 2. Fall back to agent's preferred model
|
||||
if (!resolvedProviderId && resolvedAgent?.model?.providerID && resolvedAgent?.model?.modelID) {
|
||||
if (validateModel(resolvedAgent.model.providerID, resolvedAgent.model.modelID)) {
|
||||
resolvedProviderId = resolvedAgent.model.providerID;
|
||||
resolvedModelId = resolvedAgent.model.modelID;
|
||||
const { providerID, modelID } = resolvedAgent.model;
|
||||
if (validateModel(providerID, modelID)) {
|
||||
resolvedProviderId = providerID;
|
||||
resolvedModelId = modelID;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1010,9 +1020,10 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
} else {
|
||||
// Last resort: first provider's first model
|
||||
const firstProvider = providers[0];
|
||||
if (firstProvider && firstProvider.models[0]) {
|
||||
const firstModel = firstProvider?.models[0];
|
||||
if (firstProvider && firstModel) {
|
||||
resolvedProviderId = firstProvider.id;
|
||||
resolvedModelId = firstProvider.models[0].id;
|
||||
resolvedModelId = firstModel.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1034,9 +1045,9 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
providers,
|
||||
agents: safeAgents,
|
||||
currentAgentName: resolvedAgent.name,
|
||||
currentProviderId: resolvedProviderId ?? baseSnapshot.currentProviderId,
|
||||
currentModelId: resolvedModelId ?? baseSnapshot.currentModelId,
|
||||
currentVariant: resolvedVariant,
|
||||
currentProviderId: resolvedProviderId ?? baseSnapshot.currentProviderId,
|
||||
currentModelId: resolvedModelId ?? baseSnapshot.currentModelId,
|
||||
currentVariant: resolvedVariant,
|
||||
};
|
||||
|
||||
const nextState: Partial<ConfigStore> = {
|
||||
@@ -1046,14 +1057,14 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
},
|
||||
};
|
||||
|
||||
if (state.activeDirectoryKey === directoryKey) {
|
||||
nextState.currentAgentName = resolvedAgent.name;
|
||||
if (resolvedProviderId && resolvedModelId) {
|
||||
nextState.currentProviderId = resolvedProviderId;
|
||||
nextState.currentModelId = resolvedModelId;
|
||||
nextState.currentVariant = resolvedVariant;
|
||||
}
|
||||
}
|
||||
if (state.activeDirectoryKey === directoryKey) {
|
||||
nextState.currentAgentName = resolvedAgent.name;
|
||||
if (resolvedProviderId && resolvedModelId) {
|
||||
nextState.currentProviderId = resolvedProviderId;
|
||||
nextState.currentModelId = resolvedModelId;
|
||||
nextState.currentVariant = resolvedVariant;
|
||||
}
|
||||
}
|
||||
|
||||
return nextState;
|
||||
});
|
||||
@@ -1178,7 +1189,7 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
|
||||
if (agentName && typeof window !== "undefined") {
|
||||
const sessionStore = window.__zustand_session_store__;
|
||||
if (sessionStore) {
|
||||
if (sessionStore?.getState) {
|
||||
const { currentSessionId, getAgentModelForSession } = sessionStore.getState();
|
||||
|
||||
if (currentSessionId) {
|
||||
@@ -1245,43 +1256,43 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
|
||||
// Fall back to agent's preferred model
|
||||
const agent = agents.find((candidate) => candidate.name === agentName);
|
||||
if (agent?.model?.providerID && agent?.model?.modelID) {
|
||||
const agentProvider = providers.find((provider) => provider.id === agent.model!.providerID);
|
||||
if (agentProvider) {
|
||||
const agentModel = agentProvider.models.find((model) => model.id === agent.model!.modelID);
|
||||
const agentModelSelection = agent?.model;
|
||||
if (agentModelSelection?.providerID && agentModelSelection?.modelID) {
|
||||
const { providerID, modelID } = agentModelSelection;
|
||||
const agentProvider = providers.find((provider) => provider.id === providerID);
|
||||
const agentModel = agentProvider?.models.find((model) => model.id === modelID);
|
||||
|
||||
if (agentModel) {
|
||||
set((state) => {
|
||||
const directoryKey = state.activeDirectoryKey;
|
||||
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
|
||||
providers: state.providers,
|
||||
agents: state.agents,
|
||||
currentProviderId: state.currentProviderId,
|
||||
currentModelId: state.currentModelId,
|
||||
currentAgentName: state.currentAgentName,
|
||||
selectedProviderId: state.selectedProviderId,
|
||||
agentModelSelections: state.agentModelSelections,
|
||||
defaultProviders: state.defaultProviders,
|
||||
};
|
||||
if (agentModel) {
|
||||
set((state) => {
|
||||
const directoryKey = state.activeDirectoryKey;
|
||||
const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? {
|
||||
providers: state.providers,
|
||||
agents: state.agents,
|
||||
currentProviderId: state.currentProviderId,
|
||||
currentModelId: state.currentModelId,
|
||||
currentAgentName: state.currentAgentName,
|
||||
selectedProviderId: state.selectedProviderId,
|
||||
agentModelSelections: state.agentModelSelections,
|
||||
defaultProviders: state.defaultProviders,
|
||||
};
|
||||
|
||||
const nextSnapshot: DirectoryScopedConfig = {
|
||||
...baseSnapshot,
|
||||
currentProviderId: agent.model!.providerID,
|
||||
currentModelId: agent.model!.modelID,
|
||||
selectedProviderId: agent.model!.providerID,
|
||||
};
|
||||
const nextSnapshot: DirectoryScopedConfig = {
|
||||
...baseSnapshot,
|
||||
currentProviderId: providerID,
|
||||
currentModelId: modelID,
|
||||
selectedProviderId: providerID,
|
||||
};
|
||||
|
||||
return {
|
||||
currentProviderId: agent.model!.providerID,
|
||||
currentModelId: agent.model!.modelID,
|
||||
selectedProviderId: agent.model!.providerID,
|
||||
directoryScoped: {
|
||||
...state.directoryScoped,
|
||||
[directoryKey]: nextSnapshot,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
return {
|
||||
currentProviderId: providerID,
|
||||
currentModelId: modelID,
|
||||
selectedProviderId: providerID,
|
||||
directoryScoped: {
|
||||
...state.directoryScoped,
|
||||
[directoryKey]: nextSnapshot,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1307,6 +1318,10 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
set({ settingsCommitMessageModel: model });
|
||||
},
|
||||
|
||||
setSettingsGitmojiEnabled: (enabled: boolean) => {
|
||||
set({ settingsGitmojiEnabled: enabled });
|
||||
},
|
||||
|
||||
checkConnection: async () => {
|
||||
const maxAttempts = 5;
|
||||
let attempt = 0;
|
||||
@@ -1412,7 +1427,9 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
settingsDefaultVariant: state.settingsDefaultVariant,
|
||||
settingsDefaultAgent: state.settingsDefaultAgent,
|
||||
settingsAutoCreateWorktree: state.settingsAutoCreateWorktree,
|
||||
}),
|
||||
settingsCommitMessageModel: state.settingsCommitMessageModel,
|
||||
settingsGitmojiEnabled: state.settingsGitmojiEnabled,
|
||||
}),
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -619,6 +619,9 @@ const sanitizeSettingsUpdate = (payload) => {
|
||||
const trimmed = candidate.commitMessageModel.trim();
|
||||
result.commitMessageModel = trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
if (typeof candidate.gitmojiEnabled === 'boolean') {
|
||||
result.gitmojiEnabled = candidate.gitmojiEnabled;
|
||||
}
|
||||
|
||||
const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs);
|
||||
if (skillCatalogs) {
|
||||
|
||||
Reference in New Issue
Block a user