Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 0 additions & 5 deletions .claude/rules/go-style.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,6 @@ paths:

`.claude/rules/*.md` への参照は別で、これは今も有効な規約を指しているので残してよい。

<!-- TRANSIENT(Part2 作業順 8): spec 参照を消したらこの注記を消す -->
> **2026-09-07 時点で、非テストコードに `spec N` / `Task N` の参照が 26 箇所ある。**
> 消す作業は Phase 2 立て直しの作業順 8(Part 2 の実装計画で扱う)。
> **新しく書くコードでこれを言い訳にしない。**

良い例。`gh api` が `--repo` を受け付けないという外部の事情は、コードを読んでも分からない。

```go
Expand Down
6 changes: 0 additions & 6 deletions .claude/rules/tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,6 @@ const (

エラー文字列も mode ごとに分けて持たない。どこに描くかは mode が決める。

<!-- TRANSIENT(Part2 作業順 6): bool を enum に畳んだらこの注記を消す -->
> **2026-09-07 時点で `detail` と `diff` がこれを破っている。**
> `detail.Model` は bool 10 個 + エラー文字列 3 本を持つ。畳む作業は
> Phase 2 立て直しの作業順 6(Part 2 の実装計画で扱う)。
> **新しく書くビューでこれを言い訳にしない。**

## 表示幅

**日本語は 1 文字が 2 桁を占める。** 桁数を数えるときは必ず
Expand Down
1,507 changes: 1,507 additions & 0 deletions docs/superpowers/plans/2026-09-07-phase2-remediation-part2.md

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions docs/superpowers/specs/2026-09-06-phase2-remediation-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,17 @@ Bubble Tea を起動せずに検証する。
4 と 6 は同じファイルを触るため、4 を完了してから 6 に入る。
各段階で `make check` を通し、通らない状態でコミットしない。

> **2026-09-07 追記。** Part 2(作業順 6〜9)は **9 → 6 → 7 → 8** の順で実施した。
> 9 のシナリオテストは 6 が書き換える遷移をそのまま通るので、現行コードの上で
> 先に書けば 6 と 7 の回帰ネットになる。あわせて、6 の前に未収録の golden
> (`detail` の compose / picker / loading / エラー行、`diff` の各オーバーレイ)を
> 録った。「golden 不変で等価性を確認する」は録れているセルにしか効かないため。
>
> §4.4 の表から 3 点変えた。`diff` の `loading` は畳まない(`c` / `v` / `X` は
> レビューコンテキストでゲートされており、差分の取得中でもオーバーレイは開ける)。
> 送信中は `review.Model` の `sending` に任せ、`phase` に写さない。
> `picker` が持っていた 4 本目のエラー文字列も 1 本に畳んだ。

## 7. スコープ外

- **DI コンテナは作らない。** `cmd/octoscope` で `cli.New → usecase.New → app.New` と
Expand Down
2 changes: 1 addition & 1 deletion internal/gh/review.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func (t ReviewThread) Pending() bool {

// Collapsed reports whether the thread is drawn as a count rather than in
// full. Settled conversations must not push the code they were about off the
// screen (spec 4.4.1).
// screen.
func (t ReviewThread) Collapsed() bool { return t.Resolved || t.Outdated }

// PendingComment is a line comment on its way to GitHub.
Expand Down
99 changes: 57 additions & 42 deletions internal/tui/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ type Options struct {
// the working directory is a subprocess, and doing it before the UI
// started left the terminal blank for as long as it took. The root asks
// as soon as it has a size, and the Repos tab appears when the answer
// arrives (spec 3.4).
// arrives.
HasRepo bool
}

Expand Down Expand Up @@ -166,19 +166,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.MouseMsg:
return m.handleMouse(msg)
case repoResolvedMsg:
if !msg.found {
m.repoLookupTimedOut = msg.timedOut
return m, nil
}
m.opts.HasRepo = true
// broadcast skips the list until this point, so it never saw the
// WindowSizeMsg that told the others how wide they are: an unsized
// list clips nothing and runs off the terminal.
m.repo, _ = m.repo.Update(tea.WindowSizeMsg{
Width: m.width,
Height: max(m.height-tabRowHeight, 1),
})
return m, m.repo.Init()
return m.repoResolved(msg)
case work.OpenDetailMsg:
return m.openDetail(msg.Ref)
case repo.OpenDetailMsg:
Expand All @@ -200,40 +188,67 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case repo.ErrorMsg:
return m.fail(msg.Err)
case detail.ErrorMsg:
// The detail view keeps requests in flight after the user leaves it.
// Their failures must not drag a closed view's error onto the screen.
if !m.has(overlayDetail) {
return m, nil
}
return m.failOverlay(msg.Err, overlayDetail)
return m.detailFailed(msg)
case diff.ErrorMsg:
// Same rule as detail.ErrorMsg: a request outlives the view that
// started it, and its failure must not reach the error screen once
// the diff is no longer on the stack.
if !m.has(overlayDiff) {
return m, nil
}
return m.failOverlay(msg.Err, overlayDiff)
return m.diffFailed(msg)
case review.SubmittedMsg:
// detail and diff each refetch their own PR when a review goes out
// (broadcast below reaches them); the board and the Repos list have
// no popup of their own to notice from, so the root refreshes them
// (spec 4.4.2).
next, cmd := m.broadcast(msg)
m = next.(Model)
var workCmd tea.Cmd
m.work, workCmd = m.work.Refresh()
cmds := []tea.Cmd{cmd, workCmd}
if m.opts.HasRepo {
var repoCmd tea.Cmd
m.repo, repoCmd = m.repo.Refresh()
cmds = append(cmds, repoCmd)
}
return m, tea.Batch(cmds...)
return m.reviewSubmitted(msg)
}
return m.broadcast(msg)
}

func (m Model) repoResolved(msg repoResolvedMsg) (tea.Model, tea.Cmd) {
if !msg.found {
m.repoLookupTimedOut = msg.timedOut
return m, nil
}
m.opts.HasRepo = true
// broadcast skips the list until this point, so it never saw the
// WindowSizeMsg that told the others how wide they are: an unsized
// list clips nothing and runs off the terminal.
m.repo, _ = m.repo.Update(tea.WindowSizeMsg{
Width: m.width,
Height: max(m.height-tabRowHeight, 1),
})
return m, m.repo.Init()
}

// The detail view keeps requests in flight after the user leaves it.
// Their failures must not drag a closed view's error onto the screen.
func (m Model) detailFailed(msg detail.ErrorMsg) (tea.Model, tea.Cmd) {
if !m.has(overlayDetail) {
return m, nil
}
return m.failOverlay(msg.Err, overlayDetail)
}

// Same rule as detail.ErrorMsg: a request outlives the view that
// started it, and its failure must not reach the error screen once
// the diff is no longer on the stack.
func (m Model) diffFailed(msg diff.ErrorMsg) (tea.Model, tea.Cmd) {
if !m.has(overlayDiff) {
return m, nil
}
return m.failOverlay(msg.Err, overlayDiff)
}

// detail and diff each refetch their own PR when a review goes out
// (broadcast below reaches them); the board and the Repos list have
// no popup of their own to notice from, so the root refreshes them.
func (m Model) reviewSubmitted(msg review.SubmittedMsg) (tea.Model, tea.Cmd) {
next, cmd := m.broadcast(msg)
m = next.(Model)
var workCmd tea.Cmd
m.work, workCmd = m.work.Refresh()
cmds := []tea.Cmd{cmd, workCmd}
if m.opts.HasRepo {
var repoCmd tea.Cmd
m.repo, repoCmd = m.repo.Refresh()
cmds = append(cmds, repoCmd)
}
return m, tea.Batch(cmds...)
}

// has reports whether o is anywhere on the stack, not only on top: the
// detail view keeps fetching while the diff is drawn over it.
func (m Model) has(o overlay) bool {
Expand Down
7 changes: 3 additions & 4 deletions internal/tui/app/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func (m Model) View() tea.View {
v := tea.NewView(content)
v.AltScreen = true
// Nothing else turns the mouse on: without this the terminal reports no
// clicks and no wheel at all (spec 4).
// clicks and no wheel at all.
v.MouseMode = tea.MouseModeCellMotion
return v
}
Expand All @@ -39,9 +39,8 @@ func (m Model) activeTab() string {
}

// tabLabels names the tabs on offer, in display order. Without a target
// repository the Repos tab is not offered at all (spec 3.4). Both the tab row
// and the mouse hit-test read this, so they cannot disagree about where a
// label sits.
// repository the Repos tab is not offered at all. Both the tab row and the
// mouse hit-test read this, so they cannot disagree about where a label sits.
func (m Model) tabLabels() []string {
labels := []string{"1 " + i18n.T("tab.work")}
if m.opts.HasRepo {
Expand Down
Loading