[object Object]

← back to Exo

dashboard: redesign downloads page as model×node table (#1465)

d6301ed593733de84ed00b87032413b736510cf3 · 2026-02-17 06:31:47 -0800 · Alex Cheema

## Motivation

The current downloads page uses a node-centric card grid layout that is
messy and hard to read — the same model across different nodes appears
in separate cards, and deep nesting wastes space. This makes it
difficult to quickly see which models are on which nodes.

## Changes

Rewrote the downloads page
(`dashboard/src/routes/downloads/+page.svelte`) from a card grid to a
clean table layout:

- **Rows** = models (unique across all nodes)
- **Columns** = nodes (with disk free shown in header)
- **Cells** show status at a glance:
  - ✅ Green checkmark + size for completed downloads
  - 🟡 Yellow percentage + mini progress bar + speed for active downloads
  - `...` for pending downloads
  - ❌ Red X for failed downloads
  - `--` for models not present on a node
- Delete/download action buttons appear on row hover
- Model name column is sticky on horizontal scroll (for many-node
clusters)
- Models sorted by number of nodes with completed downloads
- Imported shared utilities from `$lib/utils/downloads` instead of
inline re-implementations

### Backend: model directory in download events

- Added `model_directory` field to `BaseDownloadProgress` so all
download status events include the on-disk path
- Added `_model_dir()` helper to `DownloadCoordinator` to compute the
path from `EXO_MODELS_DIR`
- Dashboard uses this to show file location and enable "open in Finder"
for completed downloads

### Info modal

- Clicking a model name opens an info modal showing card details
(family, quantization, capabilities, storage size, layer count, tensor
parallelism support)

### Other fixes

- Fixed model name truncation in the table
- Excluded `tests/start_distributed_test.py` from pytest collection (CLI
script that calls `sys.exit()` at import time)

## Test Plan

- [x] `uv run basedpyright` — 0 errors
- [x] `uv run ruff check` — all passed
- [x] `nix fmt` — clean
- [x] `uv run pytest` — 188 passed, 1 skipped

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

Files touched

Diff

commit d6301ed593733de84ed00b87032413b736510cf3
Author: Alex Cheema <41707476+AlexCheema@users.noreply.github.com>
Date:   Tue Feb 17 06:31:47 2026 -0800

    dashboard: redesign downloads page as model×node table (#1465)
    
    ## Motivation
    
    The current downloads page uses a node-centric card grid layout that is
    messy and hard to read — the same model across different nodes appears
    in separate cards, and deep nesting wastes space. This makes it
    difficult to quickly see which models are on which nodes.
    
    ## Changes
    
    Rewrote the downloads page
    (`dashboard/src/routes/downloads/+page.svelte`) from a card grid to a
    clean table layout:
    
    - **Rows** = models (unique across all nodes)
    - **Columns** = nodes (with disk free shown in header)
    - **Cells** show status at a glance:
      - ✅ Green checkmark + size for completed downloads
      - 🟡 Yellow percentage + mini progress bar + speed for active downloads
      - `...` for pending downloads
      - ❌ Red X for failed downloads
      - `--` for models not present on a node
    - Delete/download action buttons appear on row hover
    - Model name column is sticky on horizontal scroll (for many-node
    clusters)
    - Models sorted by number of nodes with completed downloads
    - Imported shared utilities from `$lib/utils/downloads` instead of
    inline re-implementations
    
    ### Backend: model directory in download events
    
    - Added `model_directory` field to `BaseDownloadProgress` so all
    download status events include the on-disk path
    - Added `_model_dir()` helper to `DownloadCoordinator` to compute the
    path from `EXO_MODELS_DIR`
    - Dashboard uses this to show file location and enable "open in Finder"
    for completed downloads
    
    ### Info modal
    
    - Clicking a model name opens an info modal showing card details
    (family, quantization, capabilities, storage size, layer count, tensor
    parallelism support)
    
    ### Other fixes
    
    - Fixed model name truncation in the table
    - Excluded `tests/start_distributed_test.py` from pytest collection (CLI
    script that calls `sys.exit()` at import time)
    
    ## Test Plan
    
    - [x] `uv run basedpyright` — 0 errors
    - [x] `uv run ruff check` — all passed
    - [x] `nix fmt` — clean
    - [x] `uv run pytest` — 188 passed, 1 skipped
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    ---------
    
    Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
---
 dashboard/src/routes/downloads/+page.svelte | 985 +++++++++++++++-------------
 pyproject.toml                              |   2 +-
 src/exo/download/coordinator.py             |  28 +-
 src/exo/shared/types/worker/downloads.py    |   1 +
 4 files changed, 553 insertions(+), 463 deletions(-)

diff --git a/dashboard/src/routes/downloads/+page.svelte b/dashboard/src/routes/downloads/+page.svelte
index 94e10612..91719dc1 100644
--- a/dashboard/src/routes/downloads/+page.svelte
+++ b/dashboard/src/routes/downloads/+page.svelte
@@ -1,43 +1,59 @@
 <script lang="ts">
   import { onMount } from "svelte";
+  import { fade, fly } from "svelte/transition";
+  import { cubicOut } from "svelte/easing";
   import {
     topologyData,
     downloads,
     nodeDisk,
-    type DownloadProgress,
     refreshState,
     lastUpdate as lastUpdateStore,
     startDownload,
     deleteDownload,
   } from "$lib/stores/app.svelte";
+  import {
+    getDownloadTag,
+    extractModelIdFromDownload,
+    extractShardMetadata,
+  } from "$lib/utils/downloads";
   import HeaderNav from "$lib/components/HeaderNav.svelte";
 
-  type FileProgress = {
-    name: string;
-    totalBytes: number;
-    downloadedBytes: number;
-    speed: number;
-    etaMs: number;
-    percentage: number;
+  type CellStatus =
+    | { kind: "completed"; totalBytes: number; modelDirectory?: string }
+    | {
+        kind: "downloading";
+        percentage: number;
+        downloadedBytes: number;
+        totalBytes: number;
+        speed: number;
+        etaMs: number;
+        modelDirectory?: string;
+      }
+    | { kind: "pending"; modelDirectory?: string }
+    | { kind: "failed"; modelDirectory?: string }
+    | { kind: "not_present" };
+
+  type ModelCardInfo = {
+    family: string;
+    quantization: string;
+    baseModel: string;
+    capabilities: string[];
+    storageSize: number;
+    nLayers: number;
+    supportsTensor: boolean;
   };
 
-  type ModelEntry = {
+  type ModelRow = {
     modelId: string;
-    prettyName?: string | null;
-    percentage: number;
-    downloadedBytes: number;
-    totalBytes: number;
-    speed: number;
-    etaMs: number;
-    status: "completed" | "downloading";
-    files: FileProgress[];
-    shardMetadata?: Record<string, unknown>;
+    prettyName: string | null;
+    cells: Record<string, CellStatus>;
+    shardMetadata: Record<string, unknown> | null;
+    modelCard: ModelCardInfo | null;
   };
 
-  type NodeEntry = {
+  type NodeColumn = {
     nodeId: string;
-    nodeName: string;
-    models: ModelEntry[];
+    label: string;
     diskAvailable?: number;
     diskTotal?: number;
   };
@@ -102,270 +118,188 @@
     return Math.min(100, Math.max(0, value as number));
   }
 
-  function extractModelIdFromDownload(
-    downloadPayload: Record<string, unknown>,
-  ): string | null {
-    const shardMetadata =
-      downloadPayload.shard_metadata ?? downloadPayload.shardMetadata;
-    if (!shardMetadata || typeof shardMetadata !== "object") return null;
+  const CELL_PRIORITY: Record<CellStatus["kind"], number> = {
+    completed: 4,
+    downloading: 3,
+    pending: 2,
+    failed: 1,
+    not_present: 0,
+  };
+
+  function shouldUpgradeCell(
+    existing: CellStatus,
+    candidate: CellStatus,
+  ): boolean {
+    return CELL_PRIORITY[candidate.kind] > CELL_PRIORITY[existing.kind];
+  }
 
+  function extractModelCard(payload: Record<string, unknown>): {
+    prettyName: string | null;
+    card: ModelCardInfo | null;
+  } {
+    const shardMetadata = payload.shard_metadata ?? payload.shardMetadata;
+    if (!shardMetadata || typeof shardMetadata !== "object")
+      return { prettyName: null, card: null };
     const shardObj = shardMetadata as Record<string, unknown>;
     const shardKeys = Object.keys(shardObj);
-    if (shardKeys.length !== 1) return null;
-
+    if (shardKeys.length !== 1) return { prettyName: null, card: null };
     const shardData = shardObj[shardKeys[0]] as Record<string, unknown>;
-    if (!shardData) return null;
-
-    const modelMeta = shardData.model_card ?? shardData.modelCard;
-    if (!modelMeta || typeof modelMeta !== "object") return null;
-
+    const modelMeta = shardData?.model_card ?? shardData?.modelCard;
+    if (!modelMeta || typeof modelMeta !== "object")
+      return { prettyName: null, card: null };
     const meta = modelMeta as Record<string, unknown>;
-    return (meta.model_id as string) ?? (meta.modelId as string) ?? null;
-  }
-
-  function parseDownloadProgress(
-    payload: Record<string, unknown>,
-  ): DownloadProgress | null {
-    const progress = payload.download_progress ?? payload.downloadProgress;
-    if (!progress || typeof progress !== "object") return null;
 
-    const prog = progress as Record<string, unknown>;
-    const totalBytes = getBytes(prog.total_bytes ?? prog.totalBytes);
-    const downloadedBytes = getBytes(
-      prog.downloaded_bytes ?? prog.downloadedBytes,
-    );
-    const speed = (prog.speed as number) ?? 0;
-    const completedFiles =
-      (prog.completed_files as number) ?? (prog.completedFiles as number) ?? 0;
-    const totalFiles =
-      (prog.total_files as number) ?? (prog.totalFiles as number) ?? 0;
-    const etaMs = (prog.eta_ms as number) ?? (prog.etaMs as number) ?? 0;
-
-    const files: DownloadProgress["files"] = [];
-    const filesObj = (prog.files ?? {}) as Record<string, unknown>;
-    for (const [fileName, fileData] of Object.entries(filesObj)) {
-      if (!fileData || typeof fileData !== "object") continue;
-      const fd = fileData as Record<string, unknown>;
-      const fTotal = getBytes(fd.total_bytes ?? fd.totalBytes);
-      const fDownloaded = getBytes(fd.downloaded_bytes ?? fd.downloadedBytes);
-      files.push({
-        name: fileName,
-        totalBytes: fTotal,
-        downloadedBytes: fDownloaded,
-        speed: (fd.speed as number) ?? 0,
-        etaMs: (fd.eta_ms as number) ?? (fd.etaMs as number) ?? 0,
-        percentage: fTotal > 0 ? (fDownloaded / fTotal) * 100 : 0,
-      });
-    }
-
-    return {
-      totalBytes,
-      downloadedBytes,
-      speed,
-      etaMs:
-        etaMs ||
-        (speed > 0 ? ((totalBytes - downloadedBytes) / speed) * 1000 : 0),
-      percentage: totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0,
-      completedFiles,
-      totalFiles,
-      files,
+    const prettyName = (meta.prettyName as string) ?? null;
+
+    const card: ModelCardInfo = {
+      family: (meta.family as string) ?? "",
+      quantization: (meta.quantization as string) ?? "",
+      baseModel:
+        (meta.base_model as string) ?? (meta.baseModel as string) ?? "",
+      capabilities: Array.isArray(meta.capabilities)
+        ? (meta.capabilities as string[])
+        : [],
+      storageSize: getBytes(meta.storage_size ?? meta.storageSize),
+      nLayers: (meta.n_layers as number) ?? (meta.nLayers as number) ?? 0,
+      supportsTensor:
+        (meta.supports_tensor as boolean) ??
+        (meta.supportsTensor as boolean) ??
+        false,
     };
-  }
 
-  function getBarGradient(percentage: number): string {
-    if (percentage >= 100) return "from-green-500 to-green-400";
-    if (percentage <= 0) return "from-red-500 to-red-400";
-    return "from-exo-yellow to-exo-yellow/70";
+    return { prettyName, card };
   }
 
-  let downloadOverview = $state<NodeEntry[]>([]);
+  let modelRows = $state<ModelRow[]>([]);
+  let nodeColumns = $state<NodeColumn[]>([]);
+  let infoRow = $state<ModelRow | null>(null);
 
   $effect(() => {
     try {
       if (!downloadsData || Object.keys(downloadsData).length === 0) {
-        downloadOverview = [];
+        modelRows = [];
+        nodeColumns = [];
         return;
       }
 
-      const entries = Object.entries(downloadsData);
-      const built: NodeEntry[] = [];
+      const allNodeIds = Object.keys(downloadsData);
+      const columns: NodeColumn[] = allNodeIds.map((nodeId) => {
+        const diskInfo = nodeDiskData?.[nodeId];
+        return {
+          nodeId,
+          label: getNodeLabel(nodeId),
+          diskAvailable: diskInfo?.available?.inBytes,
+          diskTotal: diskInfo?.total?.inBytes,
+        };
+      });
+
+      const rowMap = new Map<string, ModelRow>();
 
-      for (const [nodeId, nodeDownloads] of entries) {
-        const modelMap = new Map<string, ModelEntry>();
-        const nodeEntries = Array.isArray(nodeDownloads)
+      for (const [nodeId, nodeDownloads] of Object.entries(downloadsData)) {
+        const entries = Array.isArray(nodeDownloads)
           ? nodeDownloads
           : nodeDownloads && typeof nodeDownloads === "object"
             ? Object.values(nodeDownloads as Record<string, unknown>)
             : [];
 
-        for (const downloadWrapped of nodeEntries) {
-          if (!downloadWrapped || typeof downloadWrapped !== "object") continue;
-
-          const keys = Object.keys(downloadWrapped as Record<string, unknown>);
-          if (keys.length !== 1) continue;
-
-          const downloadKind = keys[0];
-          const downloadPayload = (downloadWrapped as Record<string, unknown>)[
-            downloadKind
-          ] as Record<string, unknown>;
-          if (!downloadPayload) continue;
+        for (const entry of entries) {
+          const tagged = getDownloadTag(entry);
+          if (!tagged) continue;
+          const [tag, payload] = tagged;
 
           const modelId =
-            extractModelIdFromDownload(downloadPayload) ?? "unknown-model";
-          const prettyName = (() => {
-            const shardMetadata =
-              downloadPayload.shard_metadata ?? downloadPayload.shardMetadata;
-            if (!shardMetadata || typeof shardMetadata !== "object")
-              return null;
-            const shardObj = shardMetadata as Record<string, unknown>;
-            const shardKeys = Object.keys(shardObj);
-            if (shardKeys.length !== 1) return null;
-            const shardData = shardObj[shardKeys[0]] as Record<string, unknown>;
-            const modelMeta = shardData?.model_card ?? shardData?.modelCard;
-            if (!modelMeta || typeof modelMeta !== "object") return null;
-            const meta = modelMeta as Record<string, unknown>;
-            return (meta.prettyName as string) ?? null;
-          })();
-
-          const rawProgress =
-            (downloadPayload as Record<string, unknown>).download_progress ??
-            (downloadPayload as Record<string, unknown>).downloadProgress ??
-            {};
-          // For DownloadCompleted, total_bytes is at top level; for DownloadOngoing, it's inside download_progress
-          const totalBytes = getBytes(
-            (downloadPayload as Record<string, unknown>).total_bytes ??
-              (downloadPayload as Record<string, unknown>).totalBytes ??
-              (rawProgress as Record<string, unknown>).total_bytes ??
-              (rawProgress as Record<string, unknown>).totalBytes,
-          );
-          const downloadedBytes = getBytes(
-            (rawProgress as Record<string, unknown>).downloaded_bytes ??
-              (rawProgress as Record<string, unknown>).downloadedBytes,
-          );
-          const speed =
-            ((rawProgress as Record<string, unknown>).speed as number) ?? 0;
-          const etaMs =
-            ((rawProgress as Record<string, unknown>).eta_ms as number) ??
-            ((rawProgress as Record<string, unknown>).etaMs as number) ??
-            0;
-          const percentage =
-            totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0;
-
-          const files: FileProgress[] = [];
-          const filesObj = (rawProgress as Record<string, unknown>).files as
-            | Record<string, unknown>
-            | undefined;
-          if (filesObj && typeof filesObj === "object") {
-            for (const [fileName, fileData] of Object.entries(filesObj)) {
-              if (!fileData || typeof fileData !== "object") continue;
-              const fd = fileData as Record<string, unknown>;
-              const fTotal = getBytes(fd.total_bytes ?? fd.totalBytes);
-              const fDownloaded = getBytes(
-                fd.downloaded_bytes ?? fd.downloadedBytes,
-              );
-              files.push({
-                name: fileName,
-                totalBytes: fTotal,
-                downloadedBytes: fDownloaded,
-                speed: (fd.speed as number) ?? 0,
-                etaMs: (fd.eta_ms as number) ?? (fd.etaMs as number) ?? 0,
-                percentage: clampPercent(
-                  fTotal > 0 ? (fDownloaded / fTotal) * 100 : 0,
-                ),
-              });
-            }
+            extractModelIdFromDownload(payload) ?? "unknown-model";
+          const { prettyName, card } = extractModelCard(payload);
+
+          if (!rowMap.has(modelId)) {
+            rowMap.set(modelId, {
+              modelId,
+              prettyName,
+              cells: {},
+              shardMetadata: extractShardMetadata(payload),
+              modelCard: card,
+            });
           }
-
-          // Extract shard_metadata for use with download actions
-          const shardMetadata = (downloadPayload.shard_metadata ??
-            downloadPayload.shardMetadata) as
-            | Record<string, unknown>
-            | undefined;
-
-          const entry: ModelEntry = {
-            modelId,
-            prettyName,
-            percentage:
-              downloadKind === "DownloadCompleted"
-                ? 100
-                : clampPercent(percentage),
-            downloadedBytes,
-            totalBytes,
-            speed,
-            etaMs,
-            status:
-              downloadKind === "DownloadCompleted"
-                ? "completed"
-                : "downloading",
-            files,
-            shardMetadata,
-          };
-
-          const existing = modelMap.get(modelId);
-          if (!existing) {
-            modelMap.set(modelId, entry);
-          } else if (
-            (entry.status === "completed" && existing.status !== "completed") ||
-            (entry.status === existing.status &&
-              entry.downloadedBytes > existing.downloadedBytes)
-          ) {
-            modelMap.set(modelId, entry);
+          const row = rowMap.get(modelId)!;
+          if (prettyName && !row.prettyName) row.prettyName = prettyName;
+          if (!row.shardMetadata)
+            row.shardMetadata = extractShardMetadata(payload);
+          if (!row.modelCard && card) row.modelCard = card;
+
+          const modelDirectory =
+            ((payload.model_directory ?? payload.modelDirectory) as string) ||
+            undefined;
+          let cell: CellStatus;
+          if (tag === "DownloadCompleted") {
+            const totalBytes = getBytes(
+              payload.total_bytes ?? payload.totalBytes,
+            );
+            cell = { kind: "completed", totalBytes, modelDirectory };
+          } else if (tag === "DownloadOngoing") {
+            const rawProgress =
+              payload.download_progress ?? payload.downloadProgress ?? {};
+            const prog = rawProgress as Record<string, unknown>;
+            const totalBytes = getBytes(
+              prog.total_bytes ??
+                prog.totalBytes ??
+                payload.total_bytes ??
+                payload.totalBytes,
+            );
+            const downloadedBytes = getBytes(
+              prog.downloaded_bytes ?? prog.downloadedBytes,
+            );
+            const speed = (prog.speed as number) ?? 0;
+            const etaMs =
+              (prog.eta_ms as number) ?? (prog.etaMs as number) ?? 0;
+            const percentage =
+              totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0;
+            cell = {
+              kind: "downloading",
+              percentage: clampPercent(percentage),
+              downloadedBytes,
+              totalBytes,
+              speed,
+              etaMs,
+              modelDirectory,
+            };
+          } else if (tag === "DownloadFailed") {
+            cell = { kind: "failed", modelDirectory };
+          } else {
+            cell = { kind: "pending", modelDirectory };
           }
-        }
 
-        let models = Array.from(modelMap.values()).sort(
-          (a, b) => b.percentage - a.percentage,
-        );
-        if (models.length === 0 && nodeEntries.length > 0) {
-          models = [
-            {
-              modelId: "Unknown download",
-              percentage: 0,
-              downloadedBytes: 0,
-              totalBytes: 0,
-              speed: 0,
-              etaMs: 0,
-              status: "downloading",
-              files: [],
-            },
-          ];
+          const existing = row.cells[nodeId];
+          if (!existing || shouldUpgradeCell(existing, cell)) {
+            row.cells[nodeId] = cell;
+          }
         }
-
-        // Get disk info for this node
-        const diskInfo = nodeDiskData?.[nodeId];
-        const diskAvailable = diskInfo?.available?.inBytes;
-        const diskTotal = diskInfo?.total?.inBytes;
-
-        built.push({
-          nodeId,
-          nodeName: getNodeLabel(nodeId),
-          models,
-          diskAvailable,
-          diskTotal,
-        });
       }
 
-      downloadOverview = built;
+      const rows = Array.from(rowMap.values()).sort((a, b) => {
+        const aCompleted = Object.values(a.cells).filter(
+          (c) => c.kind === "completed",
+        ).length;
+        const bCompleted = Object.values(b.cells).filter(
+          (c) => c.kind === "completed",
+        ).length;
+        if (aCompleted !== bCompleted) return bCompleted - aCompleted;
+        return a.modelId.localeCompare(b.modelId);
+      });
+
+      modelRows = rows;
+      nodeColumns = columns;
     } catch (err) {
       console.error("Parse downloads error", err);
-      downloadOverview = [];
+      modelRows = [];
+      nodeColumns = [];
     }
   });
 
-  const hasDownloads = $derived(downloadOverview.length > 0);
+  const hasDownloads = $derived(modelRows.length > 0);
   const lastUpdateTs = $derived(lastUpdateStore());
   const downloadKeys = $derived(Object.keys(downloadsData || {}));
 
-  let expanded = $state<Set<string>>(new Set());
-  function toggleExpand(key: string): void {
-    const next = new Set(expanded);
-    if (next.has(key)) next.delete(key);
-    else next.add(key);
-    expanded = next;
-  }
-
   onMount(() => {
-    // Ensure we fetch at least once when visiting downloads directly
     refreshState();
   });
 </script>
@@ -415,253 +349,384 @@
         </div>
       </div>
     {:else}
-      <div class="downloads-grid gap-4">
-        {#each downloadOverview as node}
-          <div
-            class="rounded border border-exo-medium-gray/30 bg-exo-black/30 p-4 space-y-3 flex flex-col"
-          >
-            <div class="flex items-center justify-between gap-3">
-              <div class="min-w-0 flex-1">
-                <div class="text-lg font-mono text-white truncate">
-                  {node.nodeName}
-                </div>
-                <div class="text-xs text-exo-light-gray font-mono truncate">
-                  {node.nodeId}
-                </div>
-                <div class="text-xs text-exo-light-gray font-mono mt-1">
-                  {formatBytes(
-                    node.models
-                      .filter((m) => m.status === "completed")
-                      .reduce((sum, m) => sum + m.totalBytes, 0),
-                  )} models{#if node.diskAvailable != null}
-                    - {formatBytes(node.diskAvailable)} free{/if}
-                </div>
-              </div>
-              <div
-                class="text-xs font-mono uppercase tracking-wider whitespace-nowrap shrink-0 text-right"
-              >
-                <div>
-                  <span class="text-green-400"
-                    >{node.models.filter((m) => m.status === "completed")
-                      .length}</span
-                  ><span class="text-exo-yellow">
-                    / {node.models.length} models</span
-                  >
-                </div>
-              </div>
-            </div>
-
-            {#each node.models as model}
-              {@const key = `${node.nodeId}|${model.modelId}`}
-              {@const pct = clampPercent(model.percentage)}
-              {@const gradient = getBarGradient(pct)}
-              {@const isExpanded = expanded.has(key)}
-              <div
-                class="rounded border border-exo-medium-gray/30 bg-exo-dark-gray/60 p-3 space-y-2"
+      <div
+        class="rounded border border-exo-medium-gray/30 bg-exo-black/30 overflow-x-auto"
+      >
+        <table class="w-full text-left font-mono text-xs">
+          <thead>
+            <tr class="border-b border-exo-medium-gray/30">
+              <th
+                class="sticky left-0 z-10 bg-exo-black px-4 py-3 text-[11px] uppercase tracking-wider text-exo-yellow font-medium whitespace-nowrap border-r border-exo-medium-gray/20"
               >
-                <div class="flex items-center justify-between gap-3">
-                  <div class="min-w-0 space-y-0.5">
-                    <div
-                      class="text-xs font-mono text-white truncate"
-                      title={model.prettyName ?? model.modelId}
-                    >
-                      {model.prettyName ?? model.modelId}
-                    </div>
+                Model
+              </th>
+              {#each nodeColumns as col}
+                <th
+                  class="px-4 py-3 text-[11px] uppercase tracking-wider text-exo-light-gray font-medium text-center whitespace-nowrap min-w-[120px]"
+                >
+                  <div>{col.label}</div>
+                  {#if col.diskAvailable != null}
                     <div
-                      class="text-[10px] text-exo-light-gray font-mono truncate"
-                      title={model.modelId}
+                      class="text-[9px] text-exo-light-gray/60 normal-case tracking-normal mt-0.5"
                     >
-                      {model.modelId}
+                      {formatBytes(col.diskAvailable)} free
                     </div>
-                    {#if model.status !== "completed"}
-                      <div class="text-[11px] text-exo-light-gray font-mono">
-                        {formatBytes(model.downloadedBytes)} / {formatBytes(
-                          model.totalBytes,
-                        )}
-                      </div>
-                    {/if}
-                  </div>
+                  {/if}
+                </th>
+              {/each}
+            </tr>
+          </thead>
+          <tbody>
+            {#each modelRows as row}
+              <tr
+                class="group border-b border-exo-medium-gray/20 hover:bg-exo-medium-gray/10 transition-colors"
+              >
+                <td
+                  class="sticky left-0 z-10 bg-exo-dark-gray group-hover:bg-[oklch(0.18_0_0)] transition-colors px-4 py-3 whitespace-nowrap border-r border-exo-medium-gray/20"
+                >
                   <div class="flex items-center gap-2">
-                    <span
-                      class="text-xs font-mono {pct >= 100
-                        ? 'text-green-400'
-                        : pct <= 0
-                          ? 'text-red-400'
-                          : 'text-exo-yellow'}"
+                    <div class="min-w-0">
+                      <div class="text-white text-xs" title={row.modelId}>
+                        {row.prettyName ?? row.modelId}
+                      </div>
+                      {#if row.prettyName}
+                        <div
+                          class="text-[10px] text-exo-light-gray/60"
+                          title={row.modelId}
+                        >
+                          {row.modelId}
+                        </div>
+                      {/if}
+                    </div>
+                    <button
+                      type="button"
+                      class="p-1 rounded hover:bg-white/10 transition-colors flex-shrink-0 opacity-60 group-hover:opacity-100"
+                      onclick={() => (infoRow = row)}
+                      title="View model details"
                     >
-                      {pct.toFixed(1)}%
-                    </span>
-                    {#if model.status !== "completed" && model.shardMetadata}
-                      <button
-                        type="button"
-                        class="text-exo-light-gray hover:text-exo-yellow transition-colors"
-                        onclick={() =>
-                          startDownload(node.nodeId, model.shardMetadata!)}
-                        title="Start download"
+                      <svg
+                        class="w-4 h-4 text-white/30 hover:text-white/60"
+                        viewBox="0 0 24 24"
+                        fill="currentColor"
+                      >
+                        <path
+                          d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"
+                        />
+                      </svg>
+                    </button>
+                  </div>
+                </td>
+
+                {#each nodeColumns as col}
+                  {@const cell = row.cells[col.nodeId] ?? {
+                    kind: "not_present" as const,
+                  }}
+                  <td class="px-4 py-3 text-center align-middle">
+                    {#if cell.kind === "completed"}
+                      <div
+                        class="flex flex-col items-center gap-0.5"
+                        title="Completed ({formatBytes(cell.totalBytes)})"
                       >
                         <svg
-                          class="w-4 h-4"
+                          class="w-5 h-5 text-green-400"
                           viewBox="0 0 20 20"
-                          fill="none"
-                          stroke="currentColor"
-                          stroke-width="2"
+                          fill="currentColor"
                         >
                           <path
-                            d="M10 3v10m0 0l-3-3m3 3l3-3M3 17h14"
-                            stroke-linecap="round"
-                            stroke-linejoin="round"
+                            fill-rule="evenodd"
+                            d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
+                            clip-rule="evenodd"
                           ></path>
                         </svg>
-                      </button>
-                    {/if}
-                    {#if model.status === "completed"}
-                      <button
-                        type="button"
-                        class="text-exo-light-gray hover:text-red-400 transition-colors"
-                        onclick={() =>
-                          deleteDownload(node.nodeId, model.modelId)}
-                        title="Delete download"
+                        <span class="text-[10px] text-exo-light-gray/70"
+                          >{formatBytes(cell.totalBytes)}</span
+                        >
+                        <button
+                          type="button"
+                          class="text-exo-light-gray/40 hover:text-red-400 transition-colors mt-0.5"
+                          onclick={() =>
+                            deleteDownload(col.nodeId, row.modelId)}
+                          title="Delete from this node"
+                        >
+                          <svg
+                            class="w-3.5 h-3.5"
+                            viewBox="0 0 20 20"
+                            fill="none"
+                            stroke="currentColor"
+                            stroke-width="2"
+                          >
+                            <path
+                              d="M4 6h12M8 6V4h4v2m1 0v10a1 1 0 01-1 1H8a1 1 0 01-1-1V6h6"
+                              stroke-linecap="round"
+                              stroke-linejoin="round"
+                            ></path>
+                          </svg>
+                        </button>
+                      </div>
+                    {:else if cell.kind === "downloading"}
+                      <div
+                        class="flex flex-col items-center gap-1"
+                        title="{formatBytes(
+                          cell.downloadedBytes,
+                        )} / {formatBytes(cell.totalBytes)} - {formatSpeed(
+                          cell.speed,
+                        )} - ETA {formatEta(cell.etaMs)}"
+                      >
+                        <span class="text-exo-yellow text-xs font-medium"
+                          >{clampPercent(cell.percentage).toFixed(1)}%</span
+                        >
+                        <div
+                          class="w-14 h-1.5 bg-exo-black/60 rounded-sm overflow-hidden"
+                        >
+                          <div
+                            class="h-full bg-gradient-to-r from-exo-yellow to-exo-yellow/70 transition-all duration-300"
+                            style="width: {clampPercent(
+                              cell.percentage,
+                            ).toFixed(1)}%"
+                          ></div>
+                        </div>
+                        <span class="text-[9px] text-exo-light-gray/60"
+                          >{formatSpeed(cell.speed)}</span
+                        >
+                      </div>
+                    {:else if cell.kind === "pending"}
+                      <div
+                        class="flex flex-col items-center gap-0.5"
+                        title="Download pending"
+                      >
+                        <span class="text-exo-light-gray/50 text-sm">...</span>
+                      </div>
+                    {:else if cell.kind === "failed"}
+                      <div
+                        class="flex flex-col items-center gap-0.5"
+                        title="Download failed"
                       >
                         <svg
-                          class="w-4 h-4"
+                          class="w-5 h-5 text-red-400"
                           viewBox="0 0 20 20"
-                          fill="none"
-                          stroke="currentColor"
-                          stroke-width="2"
+                          fill="currentColor"
                         >
                           <path
-                            d="M4 6h12M8 6V4h4v2m1 0v10a1 1 0 01-1 1H8a1 1 0 01-1-1V6h6"
-                            stroke-linecap="round"
-                            stroke-linejoin="round"
+                            fill-rule="evenodd"
+                            d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
+                            clip-rule="evenodd"
                           ></path>
                         </svg>
-                      </button>
-                    {/if}
-                    <button
-                      type="button"
-                      class="text-exo-light-gray hover:text-exo-yellow transition-colors"
-                      onclick={() => toggleExpand(key)}
-                      aria-expanded={isExpanded}
-                      title="Toggle file details"
-                    >
-                      <svg
-                        class="w-4 h-4"
-                        viewBox="0 0 20 20"
-                        fill="none"
-                        stroke="currentColor"
-                        stroke-width="2"
-                      >
-                        <path
-                          d="M6 8l4 4 4-4"
-                          class={isExpanded
-                            ? "transform rotate-180 origin-center transition-transform duration-150"
-                            : "transition-transform duration-150"}
-                        ></path>
-                      </svg>
-                    </button>
-                  </div>
-                </div>
-
-                <div
-                  class="relative h-2 bg-exo-black/60 rounded-sm overflow-hidden"
-                >
-                  <div
-                    class={`absolute inset-y-0 left-0 bg-gradient-to-r ${gradient} transition-all duration-300`}
-                    style={`width: ${pct.toFixed(1)}%`}
-                  ></div>
-                </div>
-
-                <div
-                  class="flex items-center justify-between text-xs font-mono text-exo-light-gray"
-                >
-                  <span
-                    >{model.status === "completed"
-                      ? `Completed (${formatBytes(model.totalBytes)})`
-                      : `${formatSpeed(model.speed)} • ETA ${formatEta(model.etaMs)}`}</span
-                  >
-                  {#if model.status !== "completed"}
-                    <span
-                      >{model.files.length} file{model.files.length === 1
-                        ? ""
-                        : "s"}</span
-                    >
-                  {/if}
-                </div>
-
-                {#if isExpanded}
-                  <div class="mt-2 space-y-1.5">
-                    {#if model.files.length === 0}
-                      <div class="text-[11px] font-mono text-exo-light-gray/70">
-                        No file details reported.
+                        {#if row.shardMetadata}
+                          <button
+                            type="button"
+                            class="text-exo-light-gray/40 hover:text-exo-yellow transition-colors"
+                            onclick={() =>
+                              startDownload(col.nodeId, row.shardMetadata!)}
+                            title="Retry download on this node"
+                          >
+                            <svg
+                              class="w-3.5 h-3.5"
+                              viewBox="0 0 20 20"
+                              fill="none"
+                              stroke="currentColor"
+                              stroke-width="2"
+                            >
+                              <path
+                                d="M10 3v10m0 0l-3-3m3 3l3-3M3 17h14"
+                                stroke-linecap="round"
+                                stroke-linejoin="round"
+                              ></path>
+                            </svg>
+                          </button>
+                        {/if}
                       </div>
                     {:else}
-                      {#each model.files as f}
-                        {@const fpct = clampPercent(f.percentage)}
-                        {@const fgradient = getBarGradient(fpct)}
-                        <div
-                          class="rounded border border-exo-medium-gray/20 bg-exo-black/40 p-2 space-y-1"
+                      <div
+                        class="flex flex-col items-center"
+                        title="Not on this node"
+                      >
+                        <span class="text-exo-medium-gray text-lg leading-none"
+                          >--</span
                         >
-                          <div
-                            class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray/90"
-                          >
-                            <span class="truncate pr-2">{f.name}</span>
-                            <span
-                              class={fpct >= 100
-                                ? "text-green-400"
-                                : fpct <= 0
-                                  ? "text-red-400"
-                                  : "text-exo-yellow"}>{fpct.toFixed(1)}%</span
-                            >
-                          </div>
-                          <div
-                            class="relative h-1.5 bg-exo-black/60 rounded-sm overflow-hidden"
+                        {#if row.shardMetadata}
+                          <button
+                            type="button"
+                            class="text-exo-light-gray/30 hover:text-exo-yellow transition-colors mt-0.5 opacity-0 group-hover:opacity-100"
+                            onclick={() =>
+                              startDownload(col.nodeId, row.shardMetadata!)}
+                            title="Download to this node"
                           >
-                            <div
-                              class={`absolute inset-y-0 left-0 bg-gradient-to-r ${fgradient} transition-all duration-300`}
-                              style={`width: ${fpct.toFixed(1)}%`}
-                            ></div>
-                          </div>
-                          <div
-                            class="flex items-center justify-between text-[10px] text-exo-light-gray/70"
-                          >
-                            <span
-                              >{formatBytes(f.downloadedBytes)} / {formatBytes(
-                                f.totalBytes,
-                              )}</span
-                            >
-                            <span
-                              >{formatSpeed(f.speed)} • ETA {formatEta(
-                                f.etaMs,
-                              )}</span
+                            <svg
+                              class="w-3.5 h-3.5"
+                              viewBox="0 0 20 20"
+                              fill="none"
+                              stroke="currentColor"
+                              stroke-width="2"
                             >
-                          </div>
-                        </div>
-                      {/each}
+                              <path
+                                d="M10 3v10m0 0l-3-3m3 3l3-3M3 17h14"
+                                stroke-linecap="round"
+                                stroke-linejoin="round"
+                              ></path>
+                            </svg>
+                          </button>
+                        {/if}
+                      </div>
                     {/if}
-                  </div>
-                {/if}
-              </div>
+                  </td>
+                {/each}
+              </tr>
             {/each}
-          </div>
-        {/each}
+          </tbody>
+        </table>
       </div>
     {/if}
   </div>
 </div>
 
+<!-- Info modal -->
+{#if infoRow}
+  <div
+    class="fixed inset-0 z-[60] bg-black/60"
+    transition:fade={{ duration: 150 }}
+    onclick={() => (infoRow = null)}
+    role="presentation"
+  ></div>
+  <div
+    class="fixed z-[60] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[min(80vw,400px)] bg-exo-dark-gray border border-exo-yellow/10 rounded-lg shadow-2xl p-4"
+    transition:fly={{ y: 10, duration: 200, easing: cubicOut }}
+    role="dialog"
+    aria-modal="true"
+  >
+    <div class="flex items-start justify-between mb-3">
+      <h3 class="font-mono text-lg text-white">
+        {infoRow.prettyName ?? infoRow.modelId}
+      </h3>
+      <button
+        type="button"
+        class="p-1 rounded hover:bg-white/10 transition-colors text-white/50"
+        onclick={() => (infoRow = null)}
+        title="Close model details"
+        aria-label="Close info dialog"
+      >
+        <svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
+          <path
+            d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"
+          />
+        </svg>
+      </button>
+    </div>
+    <div class="space-y-2 text-xs font-mono">
+      <div class="flex items-center gap-2">
+        <span class="text-white/40">Model ID:</span>
+        <span class="text-white/70">{infoRow.modelId}</span>
+      </div>
+      {#if infoRow.modelCard}
+        {#if infoRow.modelCard.family}
+          <div class="flex items-center gap-2">
+            <span class="text-white/40">Family:</span>
+            <span class="text-white/70">{infoRow.modelCard.family}</span>
+          </div>
+        {/if}
+        {#if infoRow.modelCard.baseModel}
+          <div class="flex items-center gap-2">
+            <span class="text-white/40">Base model:</span>
+            <span class="text-white/70">{infoRow.modelCard.baseModel}</span>
+          </div>
+        {/if}
+        {#if infoRow.modelCard.quantization}
+          <div class="flex items-center gap-2">
+            <span class="text-white/40">Quantization:</span>
+            <span class="text-white/70">{infoRow.modelCard.quantization}</span>
+          </div>
+        {/if}
+        {#if infoRow.modelCard.storageSize > 0}
+          <div class="flex items-center gap-2">
+            <span class="text-white/40">Size:</span>
+            <span class="text-white/70"
+              >{formatBytes(infoRow.modelCard.storageSize)}</span
+            >
+          </div>
+        {/if}
+        {#if infoRow.modelCard.nLayers > 0}
+          <div class="flex items-center gap-2">
+            <span class="text-white/40">Layers:</span>
+            <span class="text-white/70">{infoRow.modelCard.nLayers}</span>
+          </div>
+        {/if}
+        {#if infoRow.modelCard.capabilities.length > 0}
+          <div class="flex items-center gap-2">
+            <span class="text-white/40">Capabilities:</span>
+            <span class="text-white/70"
+              >{infoRow.modelCard.capabilities.join(", ")}</span
+            >
+          </div>
+        {/if}
+        <div class="flex items-center gap-2">
+          <span class="text-white/40">Tensor parallelism:</span>
+          <span class="text-white/70"
+            >{infoRow.modelCard.supportsTensor ? "Yes" : "No"}</span
+          >
+        </div>
+      {/if}
+
+      <!-- Per-node download status -->
+      {#if nodeColumns.filter((col) => (infoRow?.cells[col.nodeId]?.kind ?? "not_present") !== "not_present").length > 0}
+        <div class="mt-3 pt-3 border-t border-exo-yellow/10">
+          <div class="flex items-center gap-2 mb-1">
+            <svg
+              class="w-3.5 h-3.5"
+              viewBox="0 0 24 24"
+              fill="none"
+              stroke="currentColor"
+              stroke-width="2"
+              stroke-linecap="round"
+              stroke-linejoin="round"
+            >
+              <path
+                class="text-white/40"
+                d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"
+              />
+              <path class="text-green-400" d="m9 13 2 2 4-4" />
+            </svg>
+            <span class="text-white/40">On nodes:</span>
+          </div>
+          <div class="flex flex-col gap-1.5 mt-1">
+            {#each nodeColumns as col}
+              {@const cellStatus = infoRow?.cells[col.nodeId]}
+              {#if cellStatus && cellStatus.kind !== "not_present"}
+                <div class="flex flex-col gap-0.5">
+                  <span
+                    class="inline-block w-fit px-1.5 py-0.5 rounded text-[10px] {cellStatus.kind ===
+                    'completed'
+                      ? 'bg-green-500/10 text-green-400/80 border border-green-500/20'
+                      : cellStatus.kind === 'downloading'
+                        ? 'bg-exo-yellow/10 text-exo-yellow/80 border border-exo-yellow/20'
+                        : cellStatus.kind === 'failed'
+                          ? 'bg-red-500/10 text-red-400/80 border border-red-500/20'
+                          : 'bg-white/5 text-white/50 border border-white/10'}"
+                  >
+                    {col.label}
+                    {#if cellStatus.kind === "downloading" && "percentage" in cellStatus}
+                      ({clampPercent(cellStatus.percentage).toFixed(0)}%)
+                    {/if}
+                  </span>
+                  {#if "modelDirectory" in cellStatus && cellStatus.modelDirectory}
+                    <span
+                      class="text-[9px] text-white/30 break-all pl-1"
+                      title={cellStatus.modelDirectory}
+                    >
+                      {cellStatus.modelDirectory}
+                    </span>
+                  {/if}
+                </div>
+              {/if}
+            {/each}
+          </div>
+        </div>
+      {/if}
+    </div>
+  </div>
+{/if}
+
 <style>
-  .downloads-grid {
-    display: grid;
-    grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
-  }
-  @media (min-width: 1024px) {
-    .downloads-grid {
-      grid-template-columns: repeat(3, minmax(0, 1fr));
-    }
-  }
-  @media (min-width: 1600px) {
-    .downloads-grid {
-      grid-template-columns: repeat(4, minmax(0, 1fr));
-    }
+  table {
+    min-width: max-content;
   }
 </style>
diff --git a/pyproject.toml b/pyproject.toml
index def495c7..5d8d79a5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -132,7 +132,7 @@ markers = [
 env = [
   "EXO_TESTS=1"
 ]
-addopts = "-m 'not slow'"
+addopts = "-m 'not slow' --ignore=tests/start_distributed_test.py"
 filterwarnings = [
     "ignore:builtin type Swig:DeprecationWarning",
 ]
diff --git a/src/exo/download/coordinator.py b/src/exo/download/coordinator.py
index a05bd6f8..db13ccef 100644
--- a/src/exo/download/coordinator.py
+++ b/src/exo/download/coordinator.py
@@ -14,6 +14,7 @@ from exo.download.download_utils import (
     map_repo_download_progress_to_download_progress_data,
 )
 from exo.download.shard_downloader import ShardDownloader
+from exo.shared.constants import EXO_MODELS_DIR
 from exo.shared.models.model_cards import ModelId
 from exo.shared.types.commands import (
     CancelDownload,
@@ -63,6 +64,9 @@ class DownloadCoordinator:
         self.event_sender, self.event_receiver = channel[Event]()
         self.shard_downloader.on_progress(self._download_progress_callback)
 
+    def _model_dir(self, model_id: ModelId) -> str:
+        return str(EXO_MODELS_DIR / model_id.normalize())
+
     async def _download_progress_callback(
         self, callback_shard: ShardMetadata, progress: RepoDownloadProgress
     ) -> None:
@@ -74,6 +78,7 @@ class DownloadCoordinator:
                 shard_metadata=callback_shard,
                 node_id=self.node_id,
                 total_bytes=progress.total_bytes,
+                model_directory=self._model_dir(model_id),
             )
             self.download_status[model_id] = completed
             await self.event_sender.send(
@@ -93,6 +98,7 @@ class DownloadCoordinator:
                 download_progress=map_repo_download_progress_to_download_progress_data(
                     progress
                 ),
+                model_directory=self._model_dir(model_id),
             )
             self.download_status[model_id] = ongoing
             await self.event_sender.send(
@@ -170,7 +176,11 @@ class DownloadCoordinator:
                 return
 
         # Emit pending status
-        progress = DownloadPending(shard_metadata=shard, node_id=self.node_id)
+        progress = DownloadPending(
+            shard_metadata=shard,
+            node_id=self.node_id,
+            model_directory=self._model_dir(model_id),
+        )
         self.download_status[model_id] = progress
         await self.event_sender.send(NodeDownloadProgress(download_progress=progress))
 
@@ -184,6 +194,7 @@ class DownloadCoordinator:
                 shard_metadata=shard,
                 node_id=self.node_id,
                 total_bytes=initial_progress.total_bytes,
+                model_directory=self._model_dir(model_id),
             )
             self.download_status[model_id] = completed
             await self.event_sender.send(
@@ -206,6 +217,7 @@ class DownloadCoordinator:
             download_progress=map_repo_download_progress_to_download_progress_data(
                 initial_progress
             ),
+            model_directory=self._model_dir(model_id),
         )
         self.download_status[model_id] = status
         self.event_sender.send_nowait(NodeDownloadProgress(download_progress=status))
@@ -219,6 +231,7 @@ class DownloadCoordinator:
                     shard_metadata=shard,
                     node_id=self.node_id,
                     error_message=str(e),
+                    model_directory=self._model_dir(model_id),
                 )
                 self.download_status[model_id] = failed
                 await self.event_sender.send(
@@ -253,6 +266,7 @@ class DownloadCoordinator:
             pending = DownloadPending(
                 shard_metadata=current_status.shard_metadata,
                 node_id=self.node_id,
+                model_directory=self._model_dir(model_id),
             )
             await self.event_sender.send(
                 NodeDownloadProgress(download_progress=pending)
@@ -295,11 +309,18 @@ class DownloadCoordinator:
                             node_id=self.node_id,
                             shard_metadata=progress.shard,
                             total_bytes=progress.total_bytes,
+                            model_directory=self._model_dir(
+                                progress.shard.model_card.model_id
+                            ),
                         )
                     elif progress.status in ["in_progress", "not_started"]:
                         if progress.downloaded_bytes_this_session.in_bytes == 0:
                             status = DownloadPending(
-                                node_id=self.node_id, shard_metadata=progress.shard
+                                node_id=self.node_id,
+                                shard_metadata=progress.shard,
+                                model_directory=self._model_dir(
+                                    progress.shard.model_card.model_id
+                                ),
                             )
                         else:
                             status = DownloadOngoing(
@@ -308,6 +329,9 @@ class DownloadCoordinator:
                                 download_progress=map_repo_download_progress_to_download_progress_data(
                                     progress
                                 ),
+                                model_directory=self._model_dir(
+                                    progress.shard.model_card.model_id
+                                ),
                             )
                     else:
                         continue
diff --git a/src/exo/shared/types/worker/downloads.py b/src/exo/shared/types/worker/downloads.py
index 77162e7c..a29edcbf 100644
--- a/src/exo/shared/types/worker/downloads.py
+++ b/src/exo/shared/types/worker/downloads.py
@@ -26,6 +26,7 @@ class DownloadProgressData(CamelCaseModel):
 class BaseDownloadProgress(TaggedModel):
     node_id: NodeId
     shard_metadata: ShardMetadata
+    model_directory: str = ""
 
 
 class DownloadPending(BaseDownloadProgress):

← 6d1ca668 don't time out node identities (#1493)  ·  back to Exo  ·  Fix graceful process shutdown in macOS app (#1372) db79c350 →