diff --git a/public/slskd.png b/public/slskd.png
index 0b82062c..980e0078 100644
Binary files a/public/slskd.png and b/public/slskd.png differ
diff --git a/src/workspaces/officerdev/src/apps/Soulseek/Cards.tsx b/src/workspaces/officerdev/src/apps/Soulseek/Cards.tsx
new file mode 100644
index 00000000..e78708e8
--- /dev/null
+++ b/src/workspaces/officerdev/src/apps/Soulseek/Cards.tsx
@@ -0,0 +1,95 @@
+import type { ReactNode } from 'react';
+import { ChevronRight } from 'lucide-react';
+
+// Shared visual language for the /soulseek grouped views (search results + downloads). Each top-level
+// grouping (a peer/user) is an almost-black Card; each folder within it is a lifted SubCard. Kept here
+// so search and downloads stay pixel-identical — change the look in one place.
+
+export const Card = ({ children }: { children: ReactNode }) => (
+
{children}
+);
+
+type CardHeaderProps = { open: boolean; onToggle: () => void; title: ReactNode; meta?: ReactNode };
+export const CardHeader = ({ open, onToggle, title, meta }: CardHeaderProps) => (
+
+
+ {title}
+ {meta && {meta}
}
+
+);
+
+export const CardBody = ({ children }: { children: ReactNode }) => (
+ {children}
+);
+
+export const SubCard = ({ children }: { children: ReactNode }) => (
+ {children}
+);
+
+// A folder header. When `onToggle` is supplied it becomes a collapse control (chevron + clickable
+// label); `action` stays a sibling so it never nests inside the toggle button.
+type SubCardHeaderProps = {
+ icon?: ReactNode;
+ label: ReactNode;
+ title?: string;
+ meta?: ReactNode;
+ action?: ReactNode;
+ open?: boolean;
+ onToggle?: () => void;
+};
+export const SubCardHeader = ({ icon, label, title, meta, action, open, onToggle }: SubCardHeaderProps) => {
+ const inner = (
+ <>
+ {onToggle && (
+
+ )}
+ {icon}
+
+ {label}
+
+ {meta && {meta} }
+ >
+ );
+ return (
+
+ {onToggle ? (
+
+ {inner}
+
+ ) : (
+ inner
+ )}
+ {action}
+
+ );
+};
+
+// File rows inside a SubCard, hairline-divided.
+export const RowList = ({ children }: { children: ReactNode }) => (
+ {children}
+);
+
+type PillProps = { active: boolean; onClick: () => void; children: ReactNode };
+export const Pill = ({ active, onClick, children }: PillProps) => (
+
+ {children}
+
+);
diff --git a/src/workspaces/officerdev/src/apps/Soulseek/shared.ts b/src/workspaces/officerdev/src/apps/Soulseek/shared.ts
index fc48cf87..017925df 100644
--- a/src/workspaces/officerdev/src/apps/Soulseek/shared.ts
+++ b/src/workspaces/officerdev/src/apps/Soulseek/shared.ts
@@ -5,6 +5,15 @@
// panel refetches immediately instead of waiting for its next poll tick.
export const SLSKD_REFRESH_CHANNEL = 'soulseek:refresh';
+// Per-panel content zoom for the soulseek-view (right) panel. The panel header (+/- buttons) writes the
+// factor here; SoulseekView reads it and scales its content. Keyed by panelId so each panel instance
+// zooms independently. Stored under the `screens/` namespace via useDashboardState — the same persisted
+// dashboards store that holds the layout config — so the zoom level survives reloads.
+export const soulseekZoomKey = (panelId: string) => `screens/soulseek-zoom/${panelId}`;
+export const SOULSEEK_ZOOM_MIN = 0.7;
+export const SOULSEEK_ZOOM_MAX = 1.6;
+export const SOULSEEK_ZOOM_STEP = 0.1;
+
export type SlskdApplication = {
version?: { current?: string; full?: string; latest?: string; isUpdateAvailable?: boolean };
server?: { state?: string; address?: string; isConnected?: boolean };
@@ -49,6 +58,73 @@ export type ResultRow = {
uploadSpeed: number;
};
+// slskd presents results grouped by responder (user) → folder → files. We mirror that shape.
+export type ResultFile = {
+ filename: string; // full remote path (slskd needs this verbatim to enqueue)
+ name: string; // basename for display
+ size: number;
+ extension: string;
+ bitRate?: number;
+ length?: number;
+ isLocked: boolean;
+};
+export type ResultFolder = { path: string; label: string; files: ResultFile[]; size: number };
+export type ResultUser = {
+ username: string;
+ hasFreeUploadSlot: boolean;
+ queueLength: number;
+ uploadSpeed: number;
+ fileCount: number;
+ folders: ResultFolder[];
+};
+
+// Group a search's responses into slskd's user → folder → file layout, best peers first.
+export const groupResponses = (responses: SlskdResponse[]): ResultUser[] => {
+ const users: ResultUser[] = [];
+ for (const resp of responses) {
+ const byFolder = new Map();
+ for (const f of resp.files ?? []) {
+ const parts = f.filename.split(/[\\/]/);
+ const name = parts.pop() ?? f.filename;
+ const path = parts.join('\\') || '\\';
+ const file: ResultFile = {
+ filename: f.filename,
+ name,
+ size: f.size,
+ extension: extOf(f),
+ bitRate: f.bitRate,
+ length: f.length,
+ isLocked: !!f.isLocked,
+ };
+ const arr = byFolder.get(path);
+ if (arr) arr.push(file);
+ else byFolder.set(path, [file]);
+ }
+ const folders: ResultFolder[] = [...byFolder.entries()]
+ .map(([path, files]) => {
+ files.sort((a, b) => a.name.localeCompare(b.name));
+ return { path, label: folderLabel(path), files, size: files.reduce((n, f) => n + f.size, 0) };
+ })
+ .sort((a, b) => a.path.localeCompare(b.path));
+ users.push({
+ username: resp.username,
+ hasFreeUploadSlot: resp.hasFreeUploadSlot,
+ queueLength: resp.queueLength,
+ uploadSpeed: resp.uploadSpeed,
+ fileCount: resp.fileCount ?? resp.files?.length ?? 0,
+ folders,
+ });
+ }
+ // Best peer first: free slot, then faster upload, then more files.
+ users.sort(
+ (a, b) =>
+ Number(b.hasFreeUploadSlot) - Number(a.hasFreeUploadSlot) ||
+ b.uploadSpeed - a.uploadSpeed ||
+ b.fileCount - a.fileCount,
+ );
+ return users;
+};
+
// A download transfer. GET /transfers/downloads nests these as user → directories → files.
export type SlskdTransfer = {
id: string;
@@ -64,6 +140,49 @@ export type SlskdTransfer = {
};
export type SlskdDownloadDirectory = { directory: string; fileCount: number; files: SlskdTransfer[] };
export type SlskdDownloadUser = { username: string; directories: SlskdDownloadDirectory[] };
+// Uploads (GET /transfers/uploads) use the same user → directories → files shape as downloads.
+export type SlskdTransferUser = SlskdDownloadUser;
+
+// Private conversations. GET /conversations lists them (one per peer); GET /conversations/{user}/messages
+// returns that thread. A message's direction ('Out' = sent by us) drives self-alignment in the UI.
+export type SlskdPrivateMessage = {
+ id: number;
+ timestamp: string;
+ username: string;
+ direction: string; // 'In' | 'Out'
+ message: string;
+ isAcknowledged?: boolean;
+};
+export type SlskdConversation = {
+ username: string;
+ isActive?: boolean;
+ unAcknowledgedMessageCount?: number;
+ hasUnAcknowledgedMessages?: boolean;
+ messages?: SlskdPrivateMessage[];
+};
+
+// A peer, as seen through the Users section. GET /users/{u}/info + /status; /browse returns their shares.
+export type SlskdUserInfo = {
+ description?: string;
+ hasFreeUploadSlot?: boolean;
+ hasPicture?: boolean;
+ queueLength?: number;
+ uploadSlots?: number;
+};
+export type SlskdUserStatus = {
+ isPrivileged?: boolean;
+ presence?: string; // 'Offline' | 'Away' | 'Online'
+};
+export type SlskdBrowseDirectory = { name: string; fileCount: number; files?: SlskdFile[] };
+
+// Server connection state (GET /server, and the server block of GET /application).
+export type SlskdServerState = { address?: string; state?: string; isConnected?: boolean; username?: string };
+
+// Chat rooms. GET /rooms/joined/{name} inlines users + messages; GET /rooms/available lists the rest.
+export type SlskdRoomUser = { username: string };
+export type SlskdRoomMessage = { timestamp: string; username: string; message: string; roomName?: string; self?: boolean };
+export type SlskdRoom = { name: string; isPrivate?: boolean; users?: SlskdRoomUser[]; messages?: SlskdRoomMessage[] };
+export type SlskdRoomInfo = { name: string; userCount: number; isPrivate?: boolean };
// The nav panel (SoulseekNav) publishes the active section here; the view panel (SoulseekView) reads
// it and renders the matching UI. Mirrors slskd's own top-menu sections.
@@ -103,6 +222,12 @@ export type SlskdSearchSummary = {
export const basename = (path: string) => path.split(/[\\/]/).pop() ?? path;
+// Compact folder label for grouped views — the last two path segments (slskd paths use backslashes).
+export const folderLabel = (path: string) => {
+ const segs = path.split(/[\\/]/).filter(Boolean);
+ return segs.slice(-2).join(' / ') || path;
+};
+
// Compact "time ago" for search-history rows.
export const formatWhen = (iso?: string): string => {
if (!iso) return '';
@@ -118,6 +243,13 @@ export const formatWhen = (iso?: string): string => {
return d < 7 ? `${d}d ago` : new Date(iso).toLocaleDateString();
};
+// Wall-clock HH:MM for chat message timestamps.
+export const formatClock = (iso?: string): string => {
+ if (!iso) return '';
+ const d = new Date(iso);
+ return Number.isFinite(d.getTime()) ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
+};
+
export const formatSize = (bytes: number) => {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
@@ -127,6 +259,14 @@ export const formatSize = (bytes: number) => {
export const formatSpeed = (bytesPerSec: number) => (bytesPerSec > 0 ? `${formatSize(bytesPerSec)}/s` : '');
+// Track length (seconds) → m:ss, as slskd shows it.
+export const formatDuration = (sec?: number): string => {
+ if (!sec || sec < 0) return '';
+ const m = Math.floor(sec / 60);
+ const s = Math.floor(sec % 60);
+ return `${m}:${String(s).padStart(2, '0')}`;
+};
+
export const extOf = (file: SlskdFile) =>
(file.extension?.trim() || basename(file.filename).split('.').pop() || '').toLowerCase();