generated from nhcarrigan/template
feat: add discord rich presence (#105)
### Explanation _No response_ ### Issue _No response_ ### Attestations - [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/) - [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/). - [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/). ### Dependencies - [ ] I have pinned the dependencies to a specific patch version. ### Style - [ ] I have run the linter and resolved any errors. - [ ] My pull request uses an appropriate title, matching the conventional commit standards. - [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request. ### Tests - [ ] My contribution adds new code, and I have added tests to cover it. - [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes. - [ ] All new and existing tests pass locally with my changes. - [ ] Code coverage remains at or above the configured threshold. ### Documentation _No response_ ### Versioning _No response_ Reviewed-on: #105 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #105.
This commit is contained in:
@@ -8,9 +8,13 @@ import {
|
||||
} from "./slashCommands";
|
||||
|
||||
// Mock all external dependencies
|
||||
vi.mock("svelte/store", () => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
vi.mock("svelte/store", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("svelte/store")>();
|
||||
return {
|
||||
...actual,
|
||||
get: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(),
|
||||
|
||||
@@ -2,8 +2,10 @@ import { get } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import { setSkipNextGreeting } from "$lib/tauri";
|
||||
import { setSkipNextGreeting, updateDiscordRpc } from "$lib/tauri";
|
||||
import { searchState } from "$lib/stores/search";
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
import { configStore } from "$lib/stores/config";
|
||||
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
@@ -51,6 +53,17 @@ async function changeDirectory(path: string): Promise<void> {
|
||||
},
|
||||
});
|
||||
|
||||
// Update Discord RPC when reconnecting after directory change
|
||||
const config = configStore.getConfig();
|
||||
const activeConversation = get(conversationsStore.activeConversation);
|
||||
if (activeConversation) {
|
||||
await updateDiscordRpc(
|
||||
activeConversation.name,
|
||||
config.model || "claude",
|
||||
activeConversation.startedAt
|
||||
);
|
||||
}
|
||||
|
||||
// Wait for connection to establish
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
@@ -105,6 +118,17 @@ async function startNewConversation(): Promise<void> {
|
||||
},
|
||||
});
|
||||
|
||||
// Update Discord RPC when starting new conversation
|
||||
const config = configStore.getConfig();
|
||||
const activeConversation = get(conversationsStore.activeConversation);
|
||||
if (activeConversation) {
|
||||
await updateDiscordRpc(
|
||||
activeConversation.name,
|
||||
config.model || "claude",
|
||||
activeConversation.startedAt
|
||||
);
|
||||
}
|
||||
|
||||
claudeStore.addLine("system", "New conversation started!");
|
||||
characterState.setState("idle");
|
||||
} catch (error) {
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
session_cost_budget: null,
|
||||
budget_action: "warn",
|
||||
budget_warning_threshold: 0.8,
|
||||
discord_rpc_enabled: true,
|
||||
});
|
||||
|
||||
let showCustomThemeEditor = $state(false);
|
||||
@@ -967,6 +968,30 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Discord Rich Presence Section -->
|
||||
<section class="pt-6 pb-6 border-t border-[var(--border-color)]">
|
||||
<h3 class="text-lg font-semibold text-[var(--accent-primary)] mb-4 flex items-center gap-2">
|
||||
<span>🎮</span>
|
||||
<span>Discord Rich Presence</span>
|
||||
</h3>
|
||||
|
||||
<!-- Enable/Disable Discord RPC -->
|
||||
<div class="mb-4">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={config.discord_rpc_enabled}
|
||||
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
|
||||
/>
|
||||
<span class="text-sm text-[var(--text-primary)]">Show activity in Discord</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-[var(--text-tertiary)]">
|
||||
Display your current conversation session name and model in Discord when enabled.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="sticky bottom-0 pt-4 pb-2 bg-[var(--bg-secondary)]">
|
||||
<button
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import { handleNewUserMessage } from "$lib/notifications/rules";
|
||||
import { setSkipNextGreeting } from "$lib/tauri";
|
||||
import { setSkipNextGreeting, updateDiscordRpc } from "$lib/tauri";
|
||||
import { clipboardStore } from "$lib/stores/clipboard";
|
||||
import {
|
||||
setShouldRestoreHistory,
|
||||
@@ -26,6 +26,7 @@
|
||||
type SlashCommand,
|
||||
} from "$lib/commands/slashCommands";
|
||||
import { configStore, isStreamerMode } from "$lib/stores/config";
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
import { stats, estimateMessageCost, formatTokenCount } from "$lib/stores/stats";
|
||||
import AttachmentPreview from "$lib/components/AttachmentPreview.svelte";
|
||||
import SnippetLibraryPanel from "$lib/components/SnippetLibraryPanel.svelte";
|
||||
@@ -350,6 +351,17 @@ User: ${formattedMessage}`;
|
||||
working_dir: workingDir,
|
||||
},
|
||||
});
|
||||
|
||||
// Update Discord RPC when reconnecting
|
||||
const config = configStore.getConfig();
|
||||
const activeConversation = get(conversationsStore.activeConversation);
|
||||
if (activeConversation) {
|
||||
await updateDiscordRpc(
|
||||
activeConversation.name,
|
||||
config.model || "claude",
|
||||
activeConversation.startedAt
|
||||
);
|
||||
}
|
||||
} catch (reconnectError) {
|
||||
console.error("Failed to auto-reconnect:", reconnectError);
|
||||
claudeStore.addLine("error", `Failed to reconnect: ${reconnectError}`);
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
import { claudeStore, hasPermissionPending } from "$lib/stores/claude";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import type { PermissionRequest } from "$lib/types/messages";
|
||||
import { updateDiscordRpc } from "$lib/tauri";
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
import { configStore } from "$lib/stores/config";
|
||||
|
||||
let isVisible = $state(false);
|
||||
let permission: PermissionRequest | null = $state(null);
|
||||
@@ -64,6 +67,17 @@
|
||||
},
|
||||
});
|
||||
|
||||
// Update Discord RPC when reconnecting after permission grant
|
||||
const config = configStore.getConfig();
|
||||
const activeConversation = get(conversationsStore.activeConversation);
|
||||
if (activeConversation) {
|
||||
await updateDiscordRpc(
|
||||
activeConversation.name,
|
||||
config.model || "claude",
|
||||
activeConversation.startedAt
|
||||
);
|
||||
}
|
||||
|
||||
// Wait for connection to establish
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
createSummary,
|
||||
sanitizeForJson,
|
||||
} from "$lib/utils/conversationUtils";
|
||||
import { updateDiscordRpc } from "$lib/tauri";
|
||||
|
||||
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
||||
const DONATE_URL = "https://donate.nhcarrigan.com";
|
||||
@@ -86,6 +87,7 @@
|
||||
session_cost_budget: null,
|
||||
budget_action: "warn",
|
||||
budget_warning_threshold: 0.8,
|
||||
discord_rpc_enabled: true,
|
||||
});
|
||||
|
||||
let streamerModeActive = $state(false);
|
||||
@@ -165,6 +167,16 @@
|
||||
allowed_tools: allAllowedTools,
|
||||
},
|
||||
});
|
||||
|
||||
// Update Discord RPC when a new session starts
|
||||
const activeConversation = get(conversationsStore.activeConversation);
|
||||
if (activeConversation) {
|
||||
await updateDiscordRpc(
|
||||
activeConversation.name,
|
||||
currentConfig.model || "claude",
|
||||
activeConversation.startedAt
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to start Claude:", error);
|
||||
claudeStore.addLine("error", `Connection failed: ${error}`);
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
import { claudeStore, hasQuestionPending } from "$lib/stores/claude";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import type { UserQuestionEvent } from "$lib/types/messages";
|
||||
import { updateDiscordRpc } from "$lib/tauri";
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
import { configStore } from "$lib/stores/config";
|
||||
|
||||
let isVisible = $state(false);
|
||||
let question: UserQuestionEvent | null = $state(null);
|
||||
@@ -98,6 +101,17 @@
|
||||
},
|
||||
});
|
||||
|
||||
// Update Discord RPC when reconnecting after answering question
|
||||
const config = configStore.getConfig();
|
||||
const activeConversation = get(conversationsStore.activeConversation);
|
||||
if (activeConversation) {
|
||||
await updateDiscordRpc(
|
||||
activeConversation.name,
|
||||
config.model || "claude",
|
||||
activeConversation.startedAt
|
||||
);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
if (conversationHistory) {
|
||||
|
||||
@@ -192,6 +192,7 @@ describe("config store", () => {
|
||||
session_cost_budget: null,
|
||||
budget_action: "warn",
|
||||
budget_warning_threshold: 0.8,
|
||||
discord_rpc_enabled: true,
|
||||
};
|
||||
|
||||
expect(config.model).toBe("claude-sonnet-4");
|
||||
@@ -237,6 +238,7 @@ describe("config store", () => {
|
||||
session_cost_budget: null,
|
||||
budget_action: "warn",
|
||||
budget_warning_threshold: 0.8,
|
||||
discord_rpc_enabled: true,
|
||||
};
|
||||
|
||||
expect(config.model).toBeNull();
|
||||
|
||||
@@ -44,6 +44,8 @@ export interface HikariConfig {
|
||||
session_cost_budget: number | null;
|
||||
budget_action: BudgetAction;
|
||||
budget_warning_threshold: number;
|
||||
// Discord RPC settings
|
||||
discord_rpc_enabled: boolean;
|
||||
}
|
||||
|
||||
const defaultConfig: HikariConfig = {
|
||||
@@ -83,6 +85,7 @@ const defaultConfig: HikariConfig = {
|
||||
session_cost_budget: null,
|
||||
budget_action: "warn",
|
||||
budget_warning_threshold: 0.8,
|
||||
discord_rpc_enabled: true,
|
||||
};
|
||||
|
||||
function createConfigStore() {
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface Conversation {
|
||||
lastActivityAt: Date;
|
||||
attachments: Attachment[];
|
||||
summary: ConversationSummary | null;
|
||||
startedAt: Date;
|
||||
}
|
||||
|
||||
function createConversationsStore() {
|
||||
@@ -72,6 +73,7 @@ function createConversationsStore() {
|
||||
lastActivityAt: new Date(),
|
||||
attachments: [],
|
||||
summary: null,
|
||||
startedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { writable, derived } from "svelte/store";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { costTrackingStore } from "./costTracking";
|
||||
import { configStore } from "./config";
|
||||
|
||||
export type ContextWarning = "moderate" | "high" | "critical";
|
||||
export type BudgetType = "token" | "cost";
|
||||
@@ -133,7 +134,7 @@ export function formatTokenCount(tokens: number): string {
|
||||
}
|
||||
|
||||
// Derived store for formatted display values
|
||||
export const formattedStats = derived(stats, ($stats) => {
|
||||
export const formattedStats = derived([stats, configStore.config], ([$stats, $config]) => {
|
||||
const formatNumber = (num: number) => num.toLocaleString();
|
||||
const formatCost = (cost: number) => `$${cost.toFixed(4)}`;
|
||||
const formatDuration = (seconds: number) => {
|
||||
@@ -164,6 +165,9 @@ export const formattedStats = derived(stats, ($stats) => {
|
||||
}));
|
||||
};
|
||||
|
||||
// Use the model from stats if available, otherwise fall back to the configured model
|
||||
const currentModel = $stats.model ?? $config.model ?? "No model selected";
|
||||
|
||||
return {
|
||||
totalTokens: formatNumber($stats.total_input_tokens + $stats.total_output_tokens),
|
||||
totalInputTokens: formatNumber($stats.total_input_tokens),
|
||||
@@ -173,7 +177,7 @@ export const formattedStats = derived(stats, ($stats) => {
|
||||
sessionInputTokens: formatNumber($stats.session_input_tokens),
|
||||
sessionOutputTokens: formatNumber($stats.session_output_tokens),
|
||||
sessionCost: formatCost($stats.session_cost_usd),
|
||||
model: $stats.model || "No model selected",
|
||||
model: currentModel,
|
||||
|
||||
// New formatted fields
|
||||
messagesTotal: formatNumber($stats.messages_exchanged),
|
||||
|
||||
@@ -373,3 +373,73 @@ export function cleanupTauriListeners() {
|
||||
// Cleanup notification rules
|
||||
cleanupNotificationRules();
|
||||
}
|
||||
|
||||
export async function initializeDiscordRpc() {
|
||||
const config = configStore.getConfig();
|
||||
if (config.discord_rpc_enabled) {
|
||||
try {
|
||||
const startedAt = new Date();
|
||||
const startedAtUnixSeconds = Math.floor(startedAt.getTime() / 1000);
|
||||
const model = config.model || "claude";
|
||||
|
||||
await invoke("log_discord_rpc", {
|
||||
message: `[FRONTEND] Attempting to initialize Discord RPC: session='Idle', model='${model}', timestamp=${startedAtUnixSeconds}`,
|
||||
});
|
||||
|
||||
console.log("Initializing Discord RPC with initial activity:", {
|
||||
session_name: "Idle",
|
||||
model,
|
||||
started_at: startedAtUnixSeconds,
|
||||
});
|
||||
|
||||
await invoke("init_discord_rpc", {
|
||||
sessionName: "Idle",
|
||||
model,
|
||||
startedAt: startedAtUnixSeconds,
|
||||
});
|
||||
|
||||
await invoke("log_discord_rpc", {
|
||||
message: "[FRONTEND] Discord RPC initialized successfully!",
|
||||
});
|
||||
|
||||
console.log("Discord RPC initialized successfully with initial presence");
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
await invoke("log_discord_rpc", {
|
||||
message: `[FRONTEND] ERROR: Failed to initialize Discord RPC: ${errorMessage}`,
|
||||
});
|
||||
console.error("Failed to initialize Discord RPC:", error);
|
||||
console.warn("Discord RPC will be unavailable. Make sure Discord is running.");
|
||||
}
|
||||
} else {
|
||||
await invoke("log_discord_rpc", {
|
||||
message: "[FRONTEND] Discord RPC is disabled in config, skipping initialization",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateDiscordRpc(sessionName: string, model: string, startedAt: Date) {
|
||||
const config = configStore.getConfig();
|
||||
if (!config.discord_rpc_enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const startedAtUnixSeconds = Math.floor(startedAt.getTime() / 1000);
|
||||
await invoke("update_discord_rpc", {
|
||||
sessionName: sessionName,
|
||||
model,
|
||||
startedAt: startedAtUnixSeconds,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update Discord RPC:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopDiscordRpc() {
|
||||
try {
|
||||
await invoke("stop_discord_rpc");
|
||||
} catch (error) {
|
||||
console.error("Failed to stop Discord RPC:", error);
|
||||
}
|
||||
}
|
||||
|
||||
+29
-1
@@ -2,7 +2,13 @@
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { get } from "svelte/store";
|
||||
import { initializeTauriListeners, cleanupTauriListeners } from "$lib/tauri";
|
||||
import {
|
||||
initializeTauriListeners,
|
||||
cleanupTauriListeners,
|
||||
initializeDiscordRpc,
|
||||
stopDiscordRpc,
|
||||
updateDiscordRpc,
|
||||
} from "$lib/tauri";
|
||||
import { configStore, applyTheme, applyFontSize, isCompactMode } from "$lib/stores/config";
|
||||
import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications";
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
@@ -57,6 +63,24 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Get reactive references to conversation stores
|
||||
const activeConversationId = conversationsStore.activeConversationId;
|
||||
const conversations = conversationsStore.conversations;
|
||||
|
||||
// Update Discord RPC when active conversation or model changes
|
||||
$effect(() => {
|
||||
// Access stores directly (without get()) to create reactive dependencies
|
||||
const activeId = $activeConversationId;
|
||||
const convs = $conversations;
|
||||
const activeConv = activeId ? convs.get(activeId) : null;
|
||||
const config = configStore.getConfig();
|
||||
const model = config.model || "claude";
|
||||
|
||||
if (activeConv && config.discord_rpc_enabled) {
|
||||
updateDiscordRpc(activeConv.name, model, activeConv.startedAt);
|
||||
}
|
||||
});
|
||||
|
||||
// Window size constants
|
||||
const COMPACT_WIDTH = 280;
|
||||
const COMPACT_HEIGHT = 400;
|
||||
@@ -356,6 +380,9 @@
|
||||
const window = getCurrentWindow();
|
||||
await window.setSize(new LogicalSize(COMPACT_WIDTH, COMPACT_HEIGHT));
|
||||
}
|
||||
|
||||
// Initialize Discord RPC
|
||||
await initializeDiscordRpc();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -363,6 +390,7 @@
|
||||
if (initialized) {
|
||||
cleanupTauriListeners();
|
||||
cleanupNotificationSync();
|
||||
stopDiscordRpc();
|
||||
window.removeEventListener("keydown", handleGlobalKeydown);
|
||||
initialized = false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user