Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
eb34c03
feat(desktop): integrate DeepSeek Harness
tonyfettes Aug 13, 2026
eb37796
fix(desktop): surface dsh startup failures
tonyfettes Aug 15, 2026
1e22d0e
fix(desktop): make dsh refreshes recoverable
tonyfettes Aug 15, 2026
24d4f1e
fix(desktop): serialize dsh session snapshots
tonyfettes Aug 15, 2026
e0d0ce5
fix(desktop): recover dsh page interactions
tonyfettes Aug 15, 2026
5804186
fix(desktop): follow workspace package split
tonyfettes Aug 15, 2026
5cba901
fix(desktop): reconnect dsh after event overflow
tonyfettes Aug 15, 2026
b29d4a6
fix(desktop): order dsh history snapshots
tonyfettes Aug 15, 2026
5feed81
fix(desktop): keep dsh reconnect available
tonyfettes Aug 15, 2026
bc45840
fix(desktop): type dsh command envelopes
tonyfettes Aug 15, 2026
8ec878b
fix(desktop): harden dsh event handling
tonyfettes Aug 15, 2026
c9949d9
fix(desktop): scope dsh session replies
tonyfettes Aug 15, 2026
2ff002e
fix(desktop): isolate dsh request state
tonyfettes Aug 15, 2026
1850168
fix(desktop): scope dsh drafts and responses
tonyfettes Aug 15, 2026
55a9bda
fix(desktop): keep dsh interactions busy
tonyfettes Aug 15, 2026
9961670
fix(desktop): keep dsh creation busy
tonyfettes Aug 15, 2026
72fa38c
fix(desktop): scope dsh business errors
tonyfettes Aug 15, 2026
f0c0143
fix(desktop): finish dsh creates and cancels
tonyfettes Aug 15, 2026
d926ad0
refactor(desktop): route dsh through shared composer
tonyfettes Aug 16, 2026
7bdd7fe
fix(desktop): clear stale dsh state
tonyfettes Aug 16, 2026
7930b9c
fix(desktop): fence dsh workspace grants
tonyfettes Aug 16, 2026
e747655
fix(desktop): bind dsh grants to connections
tonyfettes Aug 16, 2026
1712a01
fix(desktop): preserve dsh status events
tonyfettes Aug 16, 2026
4acb207
fix(desktop): skip dsh tool context copies
tonyfettes Aug 16, 2026
566d5d7
feat(desktop): add dsh models and worktrees
tonyfettes Aug 16, 2026
2befdf1
fix(desktop): preserve dsh session-specific state
tonyfettes Aug 16, 2026
3f6f9bd
fix(desktop): retire accepted dsh interactions
tonyfettes Aug 16, 2026
27658e8
fix(desktop): ignore stale dsh model errors
tonyfettes Aug 16, 2026
bded1ca
fix(desktop): settle navigated dsh worktree prompts
tonyfettes Aug 16, 2026
be9cfe6
fix(desktop): release removed dsh worktrees
tonyfettes Aug 16, 2026
39a0afa
fix(desktop): release abandoned dsh worktrees
tonyfettes Aug 16, 2026
c2ef149
fix(desktop): fence stale dsh interactions
tonyfettes Aug 16, 2026
4d96d26
fix(desktop): contain dsh worktree cleanup failures
tonyfettes Aug 16, 2026
2fe7c5b
fix(desktop): continue dsh worktree setup after navigation
tonyfettes Aug 16, 2026
64ee67f
fix(desktop): disable dsh workspace panels while offline
tonyfettes Aug 16, 2026
1066896
fix(desktop): fence dsh workspace cleanup races
tonyfettes Aug 16, 2026
fd5a52a
fix(desktop): bound dsh reply concurrency
tonyfettes Aug 16, 2026
25dad42
fix(desktop): route dsh editor comments
tonyfettes Aug 16, 2026
a85d6dd
fix(desktop): fence dsh workspace lifecycle
tonyfettes Aug 16, 2026
4764478
fix(desktop): stream dsh WebSocket frames
tonyfettes Aug 16, 2026
895609b
fix(desktop): order dsh status snapshots
tonyfettes Aug 16, 2026
a577e93
fix(desktop): settle dsh reconnect state
tonyfettes Aug 16, 2026
23a5367
fix(desktop): fence dsh projection snapshots
tonyfettes Aug 16, 2026
9e571a9
fix(desktop): bound dsh streaming events
tonyfettes Aug 16, 2026
7a61fab
fix(desktop): retain failed dsh prompts
tonyfettes Aug 16, 2026
a73b1ec
fix(desktop): reconcile dsh lifecycle state
tonyfettes Aug 16, 2026
f4fc067
fix(desktop): persist dsh lifecycle authority
tonyfettes Aug 16, 2026
606cf52
fix(desktop): preserve dsh cleanup index
tonyfettes Aug 16, 2026
977ddd1
fix(desktop): preserve dsh quick open screen
tonyfettes Aug 16, 2026
1853ba3
fix(desktop): verify ambiguous dsh moves
tonyfettes Aug 16, 2026
bb48d78
fix(desktop): retain dsh recovery state
tonyfettes Aug 16, 2026
fa79b07
fix(desktop): reconcile dsh worktree setup
tonyfettes Aug 16, 2026
16d014d
fix(desktop): preserve dsh lifecycle events
tonyfettes Aug 16, 2026
0f21294
fix(desktop): recover dsh composer actions
tonyfettes Aug 16, 2026
78ac5f7
fix(desktop): reconcile dsh worktree ownership
tonyfettes Aug 16, 2026
b45339d
fix(desktop): reconcile dsh prompt delivery
tonyfettes Aug 16, 2026
d5cd367
refactor(desktop): ask dsh where its conversations run
tonyfettes Aug 17, 2026
37049f4
fix(desktop): start dsh worktree conversations instead of moving them
tonyfettes Aug 17, 2026
82c6e55
fix(desktop): decode dsh's Workspace list once, and ask it outside th…
tonyfettes Aug 17, 2026
8520f18
fix(desktop): keep a dsh checkout removable, and fence its setup stages
tonyfettes Aug 17, 2026
ba403ce
fix(desktop): show a dsh worktree under its project, not beside it
tonyfettes Aug 17, 2026
53e4ae8
feat(desktop): archive dsh conversations and drop Workspace registrat…
tonyfettes Aug 17, 2026
c2c2262
docs(desktop): dsh archiving cannot be undone by anyone
tonyfettes Aug 17, 2026
d046838
feat(desktop): confirm before archiving a dsh conversation
tonyfettes Aug 17, 2026
cdf09b6
fix(desktop): stop reselecting a dsh conversation right after archivi…
tonyfettes Aug 17, 2026
a20e7e3
fix(desktop): reconcile dsh with current frontend contracts
tonyfettes Aug 20, 2026
235fe77
fix(desktop): harden dsh lifecycle and workspace authority
tonyfettes Aug 20, 2026
f64d0a5
feat(desktop): configure and select DeepSeek Harness
tonyfettes Aug 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 80 additions & 9 deletions desktop/frontend/agent_model.mbt
Original file line number Diff line number Diff line change
@@ -1,23 +1,27 @@
// The unified model chip: one composer control whose grouped menu spans both
// agents. Picking an option decides an (agent, model) pair — the state is two
// facts even though the control is one select. A conversation's agent is
// The unified model chip: one composer control whose grouped menu spans every
// available agent. Picking an option decides an (agent, model) pair — the
// state is two facts even though the control is one select. A conversation's agent is
// fixed by its first turn; only a composing conversation (an OpenSeek chat
// with nothing sent, or a Codex draft with no app-server thread) may still be
// handed to the other agent, so only those chips offer both groups.
// handed to another agent, so only those chips offer cross-agent groups. dsh
// cannot list models before a session exists, so its entry creates that
// session first; the dsh composer then renders the authoritative catalog.

///|
/// Which agent runs a conversation. Persisted as the default for the next
/// "New chat"; a conversation that has begun never changes agents.
priv enum AgentChoice {
OpenSeekAgent
CodexAgent
DshAgent
} derive(Eq)

///|
fn AgentChoice::wire(self : AgentChoice) -> String {
match self {
OpenSeekAgent => "openseek"
CodexAgent => "codex"
DshAgent => "dsh"
}
}

Expand All @@ -26,6 +30,7 @@ fn AgentChoice::parse(value : String) -> AgentChoice? {
match value.trim().to_owned() {
"openseek" => Some(OpenSeekAgent)
"codex" => Some(CodexAgent)
"dsh" => Some(DshAgent)
_ => None
}
}
Expand All @@ -43,6 +48,12 @@ priv enum ModelChoice {
// is a request to open the Codex page, where sign-in and connection state
// live; it never changes the selected model.
CodexUnavailablePick
// dsh's model catalog belongs to one concrete session. A ready pick creates
// that session in the current local project; the placeholder opens the dsh
// page so its connection problem can be resolved without pretending that a
// model is already selectable.
DshNewConversation
DshUnavailablePick
}

///|
Expand All @@ -54,12 +65,20 @@ const CodexChoicePrefix = "codex:"
///|
const CodexUnavailableChoiceWire = "codex.unavailable"

///|
const DshNewConversationWire = "dsh:new"

///|
const DshUnavailableChoiceWire = "dsh.unavailable"

///|
fn ModelChoice::wire(self : ModelChoice) -> String {
match self {
OpenSeekModel(model) => OpenSeekChoicePrefix + model.wire()
CodexModel(id) => CodexChoicePrefix + id
CodexUnavailablePick => CodexUnavailableChoiceWire
DshNewConversation => DshNewConversationWire
DshUnavailablePick => DshUnavailableChoiceWire
}
}

Expand All @@ -68,6 +87,12 @@ fn ModelChoice::parse(value : String) -> ModelChoice? {
if value == CodexUnavailableChoiceWire {
return Some(CodexUnavailablePick)
}
if value == DshNewConversationWire {
return Some(DshNewConversation)
}
if value == DshUnavailableChoiceWire {
return Some(DshUnavailablePick)
}
if value.has_prefix(OpenSeekChoicePrefix) {
let rest = value.view(start_offset=OpenSeekChoicePrefix.length()).to_owned()
return EngineModel::parse(rest).map(model => OpenSeekModel(model))
Expand Down Expand Up @@ -96,6 +121,8 @@ test "model choices round-trip and the agent namespaces stay disjoint" {
assert_true(
ModelChoice::parse("codex.unavailable") is Some(CodexUnavailablePick),
)
assert_true(ModelChoice::parse("dsh:new") is Some(DshNewConversation))
assert_true(ModelChoice::parse("dsh.unavailable") is Some(DshUnavailablePick))
assert_true(ModelChoice::parse("codex:") is None)
assert_true(ModelChoice::parse("openseek:") is None)
inspect(
Expand All @@ -113,10 +140,21 @@ test "model choices round-trip and the agent namespaces stay disjoint" {
test "agent choices round-trip their wire names" {
assert_true(AgentChoice::parse("openseek") is Some(OpenSeekAgent))
assert_true(AgentChoice::parse("codex") is Some(CodexAgent))
assert_true(AgentChoice::parse("dsh") is Some(DshAgent))
assert_true(AgentChoice::parse("") is None)
assert_true(AgentChoice::parse("claude") is None)
inspect(AgentChoice::OpenSeekAgent.wire(), content="openseek")
inspect(AgentChoice::CodexAgent.wire(), content="codex")
inspect(AgentChoice::DshAgent.wire(), content="dsh")
}

///|
test "the dsh group stays an honest doorway until dsh can create a session" {
let group = { ..test_model(), is_desktop: true }.dsh_model_group()
assert_eq(group.label, "DeepSeek Harness")
assert_eq(group.options.length(), 1)
assert_eq(group.options[0].value, DshUnavailableChoiceWire)
assert_eq(group.options[0].label, "Open DeepSeek Harness")
}

///|
Expand Down Expand Up @@ -165,11 +203,33 @@ fn Model::codex_model_group(self : Model) -> @composer.ModelGroup {
}
}

///|
/// dsh cannot answer `session.models` until a session exists. Keep that fact
/// explicit in the unified menu: a ready row creates a conversation, while an
/// unavailable row only opens the dsh page and asks its actor to reconnect.
fn Model::dsh_model_group(self : Model) -> @composer.ModelGroup {
if self.dsh.can_create() {
{
label: "DeepSeek Harness",
options: [
{ value: DshNewConversationWire, label: "New DeepSeek Harness chat" },
],
}
} else {
{
label: "DeepSeek Harness",
options: [
{ value: DshUnavailableChoiceWire, label: "Open DeepSeek Harness" },
],
}
}
}

///|
/// The unified model chip on an OpenSeek conversation. A conversation that
/// has not begun may still be handed to Codex, so its chip offers both
/// groups; once the transcript exists the agent is fixed and the menu
/// narrows to OpenSeek's tiers.
/// has not begun may still be handed to another available local agent; once
/// the transcript exists the agent is fixed and the menu narrows to
/// OpenSeek's tiers.
fn Model::openseek_model_chip(
self : Model,
dispatch : @cmd.Emit[Msg],
Expand All @@ -180,6 +240,12 @@ fn Model::openseek_model_chip(
]
if self.composing_new_chat() {
groups.push(self.codex_model_group())
// dsh lives in this native process. A remote or browser conversation has
// no local project authority to hand to it, so do not advertise a switch
// whose workspace could not be preserved.
if self.is_desktop && self.focused == @interop.ChannelId::Local {
groups.push(self.dsh_model_group())
}
}
@composer.ModelSelect::{
groups,
Expand Down Expand Up @@ -219,8 +285,8 @@ fn Model::openseek_reasoning_chip(

///|
/// The unified model chip on the Codex page. A composing draft may still be
/// handed back to OpenSeek; a stored thread's transcript is Codex's alone,
/// so its menu narrows to Codex models.
/// handed to OpenSeek or local dsh; a stored thread's transcript is Codex's
/// alone, so its menu narrows to Codex models.
fn Model::codex_model_chip(
self : Model,
dispatch : @cmd.Emit[Msg],
Expand All @@ -230,6 +296,11 @@ fn Model::codex_model_chip(
groups.push(openseek_model_group(self.default_model))
}
groups.push(self.codex_model_group())
if self.codex.composing_draft() &&
self.is_desktop &&
self.focused == @interop.ChannelId::Local {
groups.push(self.dsh_model_group())
}
let selected = match self.codex.model_listing() {
CodexModels(_, selected~) => ModelChoice::CodexModel(selected).wire()
CodexModelsUnavailable(_) => CodexUnavailableChoiceWire
Expand Down
34 changes: 34 additions & 0 deletions desktop/frontend/archive.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,40 @@ fn delete_archived_session(
})
}

///|
/// The confirmation staged before archiving a DeepSeek Harness conversation.
/// dsh's archive set is append-only — no client, dsh's own included, can bring
/// one back — so this is the same class of decision as a permanent delete,
/// even though the transcript stays on disk.
priv struct DshArchiveConfirm {
session_id : String
title : String
} derive(Debug, Eq)

///|
fn dsh_archive_modal(
dispatch : @cmd.Emit[Msg],
confirm : DshArchiveConfirm,
) -> @html.Html {
@html.div(class="modal-backdrop", on_click=dispatch(CancelDshArchive), [
@html.div(class="modal", [
@html.div(class="modal-title", "Archive this conversation?"),
@html.div(
class="modal-body",
"“\{confirm.title}” disappears from DeepSeek Harness for good: nothing in dsh can bring an archived conversation back. Its transcript stays on disk, and project files and Git branches remain.",
),
@html.div(class="modal-actions", [
@html.button(on_click=dispatch(CancelDshArchive), "Cancel"),
@html.button(
class="danger",
on_click=dispatch(ConfirmDshArchive),
"Archive",
),
]),
]),
])
}

///|
/// Permanent archived-chat deletion requires a second, explicit click. The
/// copy names what disappears and what survives so the user does not have to
Expand Down
36 changes: 33 additions & 3 deletions desktop/frontend/boot.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ fn SidebarDispatcher::conversation_open(
}
"codex.live" => self.emit(CodexOpenThread(id.conversation()))
"codex.draft" => self.emit(OpenCodex)
"dsh.live" => self.emit(DshOpenSession(id.conversation()))
source => self.unroutable("open", "source \{source}")
}
}
Expand All @@ -191,6 +192,7 @@ fn SidebarDispatcher::conversation_archive(
match id.source() {
"openseek.live" => self.emit(ArchiveSession(channel, id.conversation()))
"codex.live" => self.emit(CodexArchiveThread(id.conversation()))
"dsh.live" => self.emit(DshArchiveSession(id.conversation()))
source => self.unroutable("archive", "\{source}@\{id.channel()}")
}
}
Expand Down Expand Up @@ -246,6 +248,7 @@ fn SidebarDispatcher::project_new(
}
self.emit(NewChatIn(channel, Some(project)))
}
"dsh.workspaces" => self.emit(DshNewSessionIn(id.root()))
source => self.unroutable("new chat", "\{source}@\{id.channel()}")
}
}
Expand All @@ -272,6 +275,12 @@ fn SidebarDispatcher::project_detach(
guard @interop.ChannelId::from_uri_authority(id.channel()) is Some(channel) else {
return self.unroutable("detach", "channel \{id.channel()}")
}
// A dsh row is keyed by Workspace id rather than by path: dsh owns that
// registry, and detaching there means forgetting the grouping, not the
// directory.
if id.source() == "dsh.workspaces" {
return self.emit(DshRemoveWorkspace(id.root()))
}
guard @resource.of_path(channel, id.root()) is Some(project) else {
return @cmd.none
}
Expand All @@ -289,6 +298,7 @@ fn SidebarDispatcher::section_primary(
match id.source() {
"openseek.projects" => self.emit(PickProject(channel))
"openseek.chats" => self.emit(NewChatIn(channel, None))
"dsh" => self.emit(DshNewSession)
source => self.unroutable("section action", "source \{source}")
}
}
Expand All @@ -298,7 +308,10 @@ fn SidebarDispatcher::placeholder_open(
self : SidebarDispatcher,
id : String,
) -> @cmd.Cmd {
self.unroutable("placeholder action", id)
match id {
"dsh.open" => self.emit(OpenDsh)
_ => self.unroutable("placeholder action", id)
}
}

///|
Expand Down Expand Up @@ -501,6 +514,21 @@ pub fn boot() -> Unit {
codex_composer_input,
emit.map(action => CodexComposerAction(action)),
)
let dsh_emit = emit.map(msg => Dsh(msg))
let dsh_composer_input = state.map(model => {
model.dsh.composer_input(dsh_emit)
})
let dsh_composer_html = @composer.component(
dsh_composer_input,
emit.map(action => Dsh(@dsh.Msg::composer_action(action))),
)
// Rabbita combines at most nine incremental values in one root view.
// Codex and dsh are mutually exclusive conversation pages, so carry their
// independently stateful composer components as one external-page pair.
let external_composers = codex_composer_html.map2(dsh_composer_html, (
codex,
dsh,
) => (codex, dsh))
let sidebar_input = state.map(model => model.sidebar_component_input())
let sidebar_html = @sidebar.component(
sidebar_input,
Expand All @@ -520,7 +548,7 @@ pub fn boot() -> Unit {
transcript_html,
right_panel_html,
composer_html,
codex_composer_html,
external_composers,
sidebar_html,
quick_open_html,
skills_html,
Expand All @@ -530,11 +558,12 @@ pub fn boot() -> Unit {
transcript_html,
right_panel_html,
composer_html,
codex_composer_html,
external_composers,
sidebar_html,
quick_open_html,
skills_html,
) => {
let (codex_composer_html, dsh_composer_html) = external_composers
view(
emit,
terminal_emit(@terminal.ToggleTerminal),
Expand All @@ -545,6 +574,7 @@ pub fn boot() -> Unit {
right_panel_html,
composer_html,
codex_composer_html,
dsh_composer_html,
sidebar_html,
quick_open_html,
skills_html,
Expand Down
36 changes: 35 additions & 1 deletion desktop/frontend/bridge.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,20 @@ fn bridge_msg(
} else {
None
}
// dsh is a native Desktop-only source. The host never relays these
// notifications, but keep the Local guard explicit at the frontend edge.
DshStatusChanged(payload) =>
if channel is Local {
Some(Dsh(@dsh.Msg::status_changed(payload.to_json())))
} else {
None
}
DshEvent(payload) =>
if channel is Local {
Some(Dsh(@dsh.Msg::event(payload.to_json())))
} else {
None
}
_ => device_msg(channel, notification).map(msg => FromDevice(channel, msg))
}
}
Expand Down Expand Up @@ -365,7 +379,7 @@ fn proton_event_methods() -> Array[@protocol.NotificationKind] {
}

///|
test "proton subscribes to all Codex app-server events" {
test "proton subscribes to local Codex and dsh events" {
assert_true(
proton_event_methods().contains(
@protocol.NotificationKind::CodexStatusChanged,
Expand All @@ -392,6 +406,26 @@ test "proton subscribes to all Codex app-server events" {
)
is Some(Codex(channel=Local, _)),
)
assert_true(
proton_event_methods().contains(
@protocol.NotificationKind::DshStatusChanged,
),
)
assert_true(
proton_event_methods().contains(@protocol.NotificationKind::DshEvent),
)
assert_true(
bridge_msg(
Local,
@protocol.Notification::DshEvent({
stream: "host",
message: {},
generation: 0,
session_revision: 0,
}),
)
is Some(Dsh(_)),
)
}

///|
Expand Down
Loading
Loading