Files
ForgeLM/.planning/phases/06-chat-ui-redesign/06-01-PLAN.md
T

209 lines
9.9 KiB
Markdown

---
phase: 06-chat-ui-redesign
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- tauri-app/src-tauri/src/model_files.rs
- tauri-app/src/components/ModelSelectorPill.tsx
- tauri-app/src/components/Sidebar.tsx
- tauri-app/src/App.tsx
- tauri-app/src/components/ChatView.tsx
autonomous: true
requirements: [UI-01]
must_haves:
truths:
- "scan_models returns only GGUF files at the root of the models directory (no subdirectory traversal)"
- "Model selector dropdown appears inside the chat input area (both hero and regular input)"
- "Model selector is absent from the Sidebar"
- "Selecting a model in the input pill updates the active model"
artifacts:
- path: "tauri-app/src-tauri/src/model_files.rs"
provides: "scan_model_dir_top_level function replaces recursive scan"
contains: "scan_model_dir_top_level"
- path: "tauri-app/src/components/ModelSelectorPill.tsx"
provides: "Reusable model selector dropdown pill for input area"
exports: ["ModelSelectorPill", "ModelSelectorPillProps"]
- path: "tauri-app/src/components/Sidebar.tsx"
provides: "Sidebar without model-section block"
key_links:
- from: "tauri-app/src/App.tsx"
to: "tauri-app/src/components/ChatView.tsx"
via: "new props: models, localModels, selectedModel, onModelChange, onModelSwap, isSwapping, status"
pattern: "models=\\{models\\}"
- from: "tauri-app/src/components/ChatView.tsx"
to: "tauri-app/src/components/ModelSelectorPill.tsx"
via: "import and render inside .input-box"
pattern: "<ModelSelectorPill"
- from: "tauri-app/src-tauri/src/model_files.rs"
to: "scan_model_dir_top_level"
via: "scan_models Tauri command calls top-level scanner"
pattern: "scan_model_dir_top_level"
---
<objective>
Fix the Rust model scan to return only top-level GGUF files, create a ModelSelectorPill component, move the model selector out of the Sidebar and into the ChatView input area.
Purpose: The model selector must live in the chat input bubble (Manus AI pattern), not the sidebar. The scan bug returns unintended nested models from HuggingFace cache dirs.
Output: `scan_model_dir_top_level` Rust function, `ModelSelectorPill.tsx` component, Sidebar cleaned of model-section, ChatView wired with model selector in both input boxes.
</objective>
<execution_context>
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
@$HOME/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/06-chat-ui-redesign/06-RESEARCH.md
@tauri-app/src-tauri/src/model_files.rs
@tauri-app/src/components/Sidebar.tsx
@tauri-app/src/components/ChatView.tsx
@tauri-app/src/App.tsx
</context>
<interfaces>
<!-- Key contracts the executor needs. Extracted from codebase. -->
From tauri-app/src/components/Sidebar.tsx (lines 13-22) — props that move to ChatView:
```typescript
interface SidebarProps {
models: ModelInfo[]; // <- these 6 props move to ChatView
localModels: RuntimeModelFile[];
selectedModel: string;
onModelChange: (v: string) => void;
onModelSwap?: (path: string) => void;
isSwapping?: boolean;
status: RuntimeStatus;
// ... non-model props stay in Sidebar
}
```
From tauri-app/src/components/ChatView.tsx (lines 18-28) — current ChatView Props:
```typescript
interface Props {
db: DB;
chatId: number | null;
server: string;
apiKey: string;
model: string;
onChatCreated: (id: number) => void;
onChatUpdated: () => void;
onOpenSidebar?: () => void;
onDegradationDetected?: () => void;
}
// New props to add: models, localModels, selectedModel, onModelChange, onModelSwap, isSwapping, status
```
From tauri-app/src/components/Sidebar.tsx (lines 56-71) — deduplication logic to extract:
```typescript
// Merge API models + local scanned models, dedupe by id, filter isInChatSelector
const allModels: ModelOption[] = [
...models
.filter((m) => m.id !== "" && m.id !== "local")
.map((m) => ({ id: m.id, label: getDisplayName(m.id) })),
...localModels.map((m) => ({ id: m.path, label: getDisplayName(m.path) })),
].filter((m, i, arr) => arr.findIndex((x) => x.id === m.id) === i);
const selectedLabel = getDisplayName(selectedModel) || "No model";
```
New ModelSelectorPill interface (to create):
```typescript
interface ModelSelectorPillProps {
models: ModelInfo[];
localModels: RuntimeModelFile[];
selectedModel: string;
onModelChange: (id: string) => void;
onModelSwap?: (path: string) => void;
isSwapping?: boolean;
status: RuntimeStatus;
}
```
Rust scan_models Tauri command signature (model_files.rs):
```rust
#[tauri::command]
pub fn scan_models(dir: String) -> Result<Vec<ModelFile>, String>
// Currently calls scan_model_dir() -> scan_model_dir_recursive()
// Must change to call scan_model_dir_top_level()
```
</interfaces>
<tasks>
<task type="auto">
<name>Task 1: Fix Rust model scan to top-level only + update test</name>
<files>tauri-app/src-tauri/src/model_files.rs</files>
<action>
Add a new `scan_model_dir_top_level(path: &Path) -> Result<Vec<ModelFile>, String>` function that reads only the immediate children of `path` (depth=1) — no recursion. Only collects entries where `p.is_file()` and extension is `"gguf"` (case-insensitive). Sort by name. Keep `scan_model_dir_recursive` in place (used by existing tests and potentially future use) — do NOT delete it.
Update `scan_model_dir()` to call `scan_model_dir_top_level` instead of `scan_model_dir_recursive` — OR bypass `scan_model_dir()` entirely and have the `scan_models` Tauri command call `scan_model_dir_top_level` directly.
Update the `scan_model_dir_finds_gguf_in_subdirectories` test (line 196): rename it to `scan_model_dir_returns_only_top_level_gguf` and update the assertion so nested models are NOT in the result — only `["top-level.gguf"]`. Add a new test `scan_model_dir_recursive_still_finds_nested` that exercises `scan_model_dir_recursive` directly to ensure the recursive function still works.
Verify: `cargo test -p commands` (or `cargo test` in `tauri-app/src-tauri/`) passes with no failures.
</action>
<verify>
<automated>cd tauri-app/src-tauri && cargo test scan_model 2>&1 | tail -10</automated>
</verify>
<done>All `scan_model*` tests pass. `scan_models` Tauri command uses top-level scan. Nested GGUFs are NOT returned by default.</done>
</task>
<task type="auto">
<name>Task 2: Create ModelSelectorPill + wire into ChatView + clean Sidebar</name>
<files>
tauri-app/src/components/ModelSelectorPill.tsx,
tauri-app/src/components/Sidebar.tsx,
tauri-app/src/components/ChatView.tsx,
tauri-app/src/App.tsx
</files>
<action>
**Step A — Create `ModelSelectorPill.tsx`:**
Create `tauri-app/src/components/ModelSelectorPill.tsx`. Extract the deduplication logic from `Sidebar.tsx` lines 56-71 into this component (or call a shared util). The component renders a compact pill button showing the selected model's display name (`getDisplayName`). Clicking opens a dropdown listbox (reuse the same HTML pattern as `Sidebar.tsx` lines 136-168: `model-dropdown-menu` with `model-dropdown-item` entries). Use `getDisplayName` from `lib/modelAlias`. Emit `onModelChange(id)` when item selected; if item is a local model path, also call `onModelSwap(path)`. Show a spinner/disabled state when `isSwapping` is true. Style: `bg-[#262626] text-[#9CA3AF] rounded-md px-2 py-1 text-sm flex items-center gap-1.5` for the pill trigger. Dropdown: absolute positioned, `bg-[#1E1E1E] border border-[#333333]`. Props: `ModelSelectorPillProps` as defined in the interface block above.
**Step B — Clean Sidebar.tsx:**
In `Sidebar.tsx`, delete the `model-section` div block (lines ~131-170) and remove from `SidebarProps`: `models`, `localModels`, `selectedModel`, `onModelChange`, `onModelSwap`, `isSwapping`. Remove the `status` prop if it was only used for model selector logic. Remove the deduplication logic (lines 56-69) and the `handleModelSelect` handler. Remove `ModelInfo` import if no longer used.
**Step C — Update App.tsx:**
In `App.tsx`, stop passing the 6 model props to `<Sidebar>`. Add them as new props to `<ChatView>`: `models={models}`, `localModels={localModels}`, `selectedModel={selectedModel}`, `onModelChange={setSelectedModel}`, `onModelSwap={handleModelSwap}`, `isSwapping={isSwapping}`, `status={status}`.
**Step D — Update ChatView.tsx:**
Add the 6 model props to ChatView's `Props` interface. Import `ModelSelectorPill`. Render `<ModelSelectorPill ...props />` inside the input area in TWO places:
1. Hero input block (around line 657) — inside `.input-box`, before the textarea, on its own row or inline left.
2. Regular input block (around line 860-875) — same position inside `.input-box`.
Do NOT change any existing chat logic, message handling, or streaming logic.
Pitfall to avoid: Do NOT use `useModelSelector` hook — just pass props directly. Do NOT create a context. Keep it simple prop-drilling.
</action>
<verify>
<automated>cd tauri-app && npm run build 2>&1 | tail -20</automated>
</verify>
<done>TypeScript build passes. ModelSelectorPill renders in both hero and regular input areas. Sidebar has no model-section. App.tsx routes model props to ChatView.</done>
</task>
</tasks>
<verification>
- `cargo test scan_model` passes — top-level scan verified by updated test
- `npm run build` passes — no TypeScript errors
- Sidebar.tsx no longer contains `model-section`
- ChatView.tsx imports and renders `ModelSelectorPill` in both input contexts
- App.tsx passes model props to ChatView (not Sidebar)
</verification>
<success_criteria>
- Top-level model scan: `scan_models("./models")` returns only `.gguf` files directly in the folder — NOT files from subdirectories
- Model selector visible in chat input pill (hero screen and regular screen)
- No model selector in the sidebar
- TypeScript and Rust compile without errors
</success_criteria>
<output>
After completion, create `.planning/phases/06-chat-ui-redesign/06-01-SUMMARY.md`
</output>