crush.json ๐
@@ -3,5 +3,14 @@
"Go": {
"command": "gopls"
}
+ },
+ "mcp": {
+ "linear": {
+ "type": "stdio",
+ "command": "mcp-remote",
+ "args": [
+ "https://mcp.linear.app/sse"
+ ]
+ }
}
}
Kujtim Hoxha created
crush.json | 9
internal/tui/components/chat/chat.go | 8
internal/tui/exp/list/list.go | 166
internal/tui/exp/list/list_test.go | 237
internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_bottom_in_backwards_list.golden | 10
internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_botton.golden | 10
internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_prepended_and_we_are_at_the_top_in_forward_list.golden | 10
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_appended_and_we_are_in_forward_list.golden | 10
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_prepended_and_we_are_in_backwards_list.golden | 10
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_down_in_forward_list.golden | 10
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up.golden | 10
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up_in_backwards_list.golden | 10
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_decreases_in_forward_list.golden | 10
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased.golden | 10
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_backwards_list.golden | 10
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_forward_list.golden | 10
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases.golden | 10
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases_in_backwards_list.golden | 10
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased#01.golden | 10
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased.golden | 10
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_backwards_list.golden | 10
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_forward_list.golden | 10
internal/tui/page/chat/chat.go | 1
23 files changed, 591 insertions(+), 10 deletions(-)
@@ -3,5 +3,14 @@
"Go": {
"command": "gopls"
}
+ },
+ "mcp": {
+ "linear": {
+ "type": "stdio",
+ "command": "mcp-remote",
+ "args": [
+ "https://mcp.linear.app/sse"
+ ]
+ }
}
}
@@ -40,6 +40,7 @@ type MessageListCmp interface {
layout.Help
SetSession(session.Session) tea.Cmd
+ GoToBottom() tea.Cmd
}
// messageListCmp implements MessageListCmp, providing a virtualized list
@@ -64,6 +65,7 @@ func New(app *app.App) MessageListCmp {
[]list.Item{},
list.WithGap(1),
list.WithDirectionBackward(),
+ list.WithFocus(false),
list.WithKeyMap(defaultListKeyMap),
)
return &messageListCmp{
@@ -76,7 +78,7 @@ func New(app *app.App) MessageListCmp {
// Init initializes the component.
func (m *messageListCmp) Init() tea.Cmd {
- return tea.Sequence(m.listCmp.Init(), m.listCmp.Blur())
+ return m.listCmp.Init()
}
// Update handles incoming messages and updates the component state.
@@ -531,3 +533,7 @@ func (m *messageListCmp) IsFocused() bool {
func (m *messageListCmp) Bindings() []key.Binding {
return m.defaultListKeyMap.KeyBindings()
}
+
+func (m *messageListCmp) GoToBottom() tea.Cmd {
+ return m.listCmp.GoToBottom()
+}
@@ -1,10 +1,12 @@
package list
import (
+ "slices"
"strings"
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/crush/internal/tui/components/anim"
"github.com/charmbracelet/crush/internal/tui/components/core/layout"
"github.com/charmbracelet/crush/internal/tui/util"
"github.com/charmbracelet/lipgloss/v2"
@@ -16,6 +18,10 @@ type Item interface {
ID() string
}
+type HasAnim interface {
+ Item
+ Spinning() bool
+}
type (
renderedMsg struct{}
List[T Item] interface {
@@ -172,6 +178,18 @@ func (l *list[T]) Init() tea.Cmd {
// Update implements List.
func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
+ case anim.StepMsg:
+ var cmds []tea.Cmd
+ for _, item := range l.items {
+ if i, ok := any(item).(HasAnim); ok && i.Spinning() {
+ updated, cmd := i.Update(msg)
+ cmds = append(cmds, cmd)
+ if u, ok := updated.(T); ok {
+ cmds = append(cmds, l.UpdateItem(u.ID(), u))
+ }
+ }
+ }
+ return l, tea.Batch(cmds...)
case tea.KeyPressMsg:
if l.focused {
switch {
@@ -553,8 +571,44 @@ func (l *list[T]) renderItem(item Item) renderedItem {
}
// AppendItem implements List.
-func (l *list[T]) AppendItem(T) tea.Cmd {
- panic("unimplemented")
+func (l *list[T]) AppendItem(item T) tea.Cmd {
+ var cmds []tea.Cmd
+ cmd := item.Init()
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+
+ l.items = append(l.items, item)
+ l.indexMap = make(map[string]int)
+ for inx, item := range l.items {
+ l.indexMap[item.ID()] = inx
+ }
+ if l.width > 0 && l.height > 0 {
+ cmd = item.SetSize(l.width, l.height)
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ }
+ cmd = l.render()
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ if l.direction == DirectionBackward {
+ if l.offset == 0 {
+ cmd = l.GoToBottom()
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ } else {
+ newItem := l.renderedItems[item.ID()]
+ newLines := newItem.height
+ if len(l.items) > 1 {
+ newLines += l.gap
+ }
+ l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
+ }
+ }
+ return tea.Sequence(cmds...)
}
// Blur implements List.
@@ -564,8 +618,34 @@ func (l *list[T]) Blur() tea.Cmd {
}
// DeleteItem implements List.
-func (l *list[T]) DeleteItem(string) tea.Cmd {
- panic("unimplemented")
+func (l *list[T]) DeleteItem(id string) tea.Cmd {
+ inx := l.indexMap[id]
+ l.items = slices.Delete(l.items, inx, inx+1)
+ delete(l.renderedItems, id)
+ for inx, item := range l.items {
+ l.indexMap[item.ID()] = inx
+ }
+
+ if l.selectedItem == id {
+ if inx > 0 {
+ l.selectedItem = l.items[inx-1].ID()
+ } else {
+ l.selectedItem = ""
+ }
+ }
+ cmd := l.render()
+ if l.rendered != "" {
+ renderedHeight := lipgloss.Height(l.rendered)
+ if renderedHeight <= l.height {
+ l.offset = 0
+ } else {
+ maxOffset := renderedHeight - l.height
+ if l.offset > maxOffset {
+ l.offset = maxOffset
+ }
+ }
+ }
+ return cmd
}
// Focus implements List.
@@ -651,8 +731,35 @@ func (l *list[T]) MoveUp(n int) tea.Cmd {
}
// PrependItem implements List.
-func (l *list[T]) PrependItem(T) tea.Cmd {
- panic("unimplemented")
+func (l *list[T]) PrependItem(item T) tea.Cmd {
+ cmds := []tea.Cmd{
+ item.Init(),
+ }
+ l.items = append([]T{item}, l.items...)
+ l.indexMap = make(map[string]int)
+ for inx, item := range l.items {
+ l.indexMap[item.ID()] = inx
+ }
+ if l.width > 0 && l.height > 0 {
+ cmds = append(cmds, item.SetSize(l.width, l.height))
+ }
+ cmds = append(cmds, l.render())
+ if l.direction == DirectionForward {
+ if l.offset == 0 {
+ cmd := l.GoToTop()
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ } else {
+ newItem := l.renderedItems[item.ID()]
+ newLines := newItem.height
+ if len(l.items) > 1 {
+ newLines += l.gap
+ }
+ l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
+ }
+ }
+ return tea.Batch(cmds...)
}
// SelectItemAbove implements List.
@@ -707,7 +814,12 @@ func (l *list[T]) SelectedItem() *T {
// SetItems implements List.
func (l *list[T]) SetItems(items []T) tea.Cmd {
l.items = items
- return l.reset()
+ var cmds []tea.Cmd
+ for _, item := range l.items {
+ cmds = append(cmds, item.Init())
+ }
+ cmds = append(cmds, l.reset())
+ return tea.Batch(cmds...)
}
// SetSelected implements List.
@@ -745,6 +857,42 @@ func (l *list[T]) SetSize(width int, height int) tea.Cmd {
}
// UpdateItem implements List.
-func (l *list[T]) UpdateItem(string, T) tea.Cmd {
- panic("unimplemented")
+func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
+ var cmds []tea.Cmd
+ if inx, ok := l.indexMap[id]; ok {
+ l.items[inx] = item
+ oldItem := l.renderedItems[id]
+ oldPosition := l.offset
+ if l.direction == DirectionBackward {
+ oldPosition = (lipgloss.Height(l.rendered) - 1) - l.offset
+ }
+
+ delete(l.renderedItems, id)
+ cmd := l.render()
+
+ // need to check for nil because of sequence not handling nil
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ if l.direction == DirectionBackward {
+ // if we are the last item and there is no offset
+ // make sure to go to the bottom
+ if inx == len(l.items)-1 && l.offset == 0 {
+ cmd = l.GoToBottom()
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ // if the item is at least partially below the viewport
+ } else if oldPosition < oldItem.end {
+ newItem := l.renderedItems[item.ID()]
+ newLines := newItem.height - oldItem.height
+ l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
+ }
+ } else if l.offset > oldItem.start {
+ newItem := l.renderedItems[item.ID()]
+ newLines := newItem.height - oldItem.height
+ l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
+ }
+ }
+ return tea.Sequence(cmds...)
}
@@ -315,6 +315,243 @@ func TestListMovement(t *testing.T) {
assert.Equal(t, 0, l.offset)
golden.RequireEqual(t, []byte(l.View()))
})
+
+ t.Run("should not change offset when new items are appended and we are at the bottom in backwards list", func(t *testing.T) {
+ t.Parallel()
+ items := []Item{}
+ for i := range 30 {
+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
+ content = strings.TrimSuffix(content, "\n")
+ item := NewSelectableItem(content)
+ items = append(items, item)
+ }
+ l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
+ execCmd(l, l.Init())
+ execCmd(l, l.AppendItem(NewSelectableItem("Testing")))
+
+ assert.Equal(t, 0, l.offset)
+ golden.RequireEqual(t, []byte(l.View()))
+ })
+
+ t.Run("should stay at the position it is when new items are added but we moved up in backwards list", func(t *testing.T) {
+ t.Parallel()
+ items := []Item{}
+ for i := range 30 {
+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+ items = append(items, item)
+ }
+ l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
+ execCmd(l, l.Init())
+
+ execCmd(l, l.MoveUp(2))
+ viewBefore := l.View()
+ execCmd(l, l.AppendItem(NewSelectableItem("Testing\nHello\n")))
+ viewAfter := l.View()
+ assert.Equal(t, viewBefore, viewAfter)
+ assert.Equal(t, 5, l.offset)
+ assert.Equal(t, 33, lipgloss.Height(l.rendered))
+ golden.RequireEqual(t, []byte(l.View()))
+ })
+ t.Run("should stay at the position it is when the hight of an item below is increased in backwards list", func(t *testing.T) {
+ t.Parallel()
+ items := []Item{}
+ for i := range 30 {
+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+ items = append(items, item)
+ }
+ l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
+ execCmd(l, l.Init())
+
+ execCmd(l, l.MoveUp(2))
+ viewBefore := l.View()
+ item := items[29]
+ execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3")))
+ viewAfter := l.View()
+ assert.Equal(t, viewBefore, viewAfter)
+ assert.Equal(t, 4, l.offset)
+ assert.Equal(t, 32, lipgloss.Height(l.rendered))
+ golden.RequireEqual(t, []byte(l.View()))
+ })
+ t.Run("should stay at the position it is when the hight of an item below is decreases in backwards list", func(t *testing.T) {
+ t.Parallel()
+ items := []Item{}
+ for i := range 30 {
+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+ items = append(items, item)
+ }
+ items = append(items, NewSelectableItem("Item 30\nLine 2\nLine 3"))
+ l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
+ execCmd(l, l.Init())
+
+ execCmd(l, l.MoveUp(2))
+ viewBefore := l.View()
+ item := items[30]
+ execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 30")))
+ viewAfter := l.View()
+ assert.Equal(t, viewBefore, viewAfter)
+ assert.Equal(t, 0, l.offset)
+ assert.Equal(t, 31, lipgloss.Height(l.rendered))
+ golden.RequireEqual(t, []byte(l.View()))
+ })
+ t.Run("should stay at the position it is when the hight of an item above is increased in backwards list", func(t *testing.T) {
+ t.Parallel()
+ items := []Item{}
+ for i := range 30 {
+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+ items = append(items, item)
+ }
+ l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
+ execCmd(l, l.Init())
+
+ execCmd(l, l.MoveUp(2))
+ viewBefore := l.View()
+ item := items[1]
+ execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 1\nLine 2\nLine 3")))
+ viewAfter := l.View()
+ assert.Equal(t, viewBefore, viewAfter)
+ assert.Equal(t, 2, l.offset)
+ assert.Equal(t, 32, lipgloss.Height(l.rendered))
+ golden.RequireEqual(t, []byte(l.View()))
+ })
+ t.Run("should stay at the position it is if an item is prepended and we are in backwards list", func(t *testing.T) {
+ t.Parallel()
+ items := []Item{}
+ for i := range 30 {
+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+ items = append(items, item)
+ }
+ l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
+ execCmd(l, l.Init())
+
+ execCmd(l, l.MoveUp(2))
+ viewBefore := l.View()
+ execCmd(l, l.PrependItem(NewSelectableItem("New")))
+ viewAfter := l.View()
+ assert.Equal(t, viewBefore, viewAfter)
+ assert.Equal(t, 2, l.offset)
+ assert.Equal(t, 31, lipgloss.Height(l.rendered))
+ golden.RequireEqual(t, []byte(l.View()))
+ })
+
+ t.Run("should not change offset when new items are prepended and we are at the top in forward list", func(t *testing.T) {
+ t.Parallel()
+ items := []Item{}
+ for i := range 30 {
+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
+ content = strings.TrimSuffix(content, "\n")
+ item := NewSelectableItem(content)
+ items = append(items, item)
+ }
+ l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
+ execCmd(l, l.Init())
+ execCmd(l, l.PrependItem(NewSelectableItem("Testing")))
+
+ assert.Equal(t, 0, l.offset)
+ golden.RequireEqual(t, []byte(l.View()))
+ })
+
+ t.Run("should stay at the position it is when new items are added but we moved down in forward list", func(t *testing.T) {
+ t.Parallel()
+ items := []Item{}
+ for i := range 30 {
+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+ items = append(items, item)
+ }
+ l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
+ execCmd(l, l.Init())
+
+ execCmd(l, l.MoveDown(2))
+ viewBefore := l.View()
+ execCmd(l, l.PrependItem(NewSelectableItem("Testing\nHello\n")))
+ viewAfter := l.View()
+ assert.Equal(t, viewBefore, viewAfter)
+ assert.Equal(t, 5, l.offset)
+ assert.Equal(t, 33, lipgloss.Height(l.rendered))
+ golden.RequireEqual(t, []byte(l.View()))
+ })
+
+ t.Run("should stay at the position it is when the hight of an item above is increased in forward list", func(t *testing.T) {
+ t.Parallel()
+ items := []Item{}
+ for i := range 30 {
+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+ items = append(items, item)
+ }
+ l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
+ execCmd(l, l.Init())
+
+ execCmd(l, l.MoveDown(2))
+ viewBefore := l.View()
+ item := items[0]
+ execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3")))
+ viewAfter := l.View()
+ assert.Equal(t, viewBefore, viewAfter)
+ assert.Equal(t, 4, l.offset)
+ assert.Equal(t, 32, lipgloss.Height(l.rendered))
+ golden.RequireEqual(t, []byte(l.View()))
+ })
+
+ t.Run("should stay at the position it is when the hight of an item above is decreases in forward list", func(t *testing.T) {
+ t.Parallel()
+ items := []Item{}
+ items = append(items, NewSelectableItem("At top\nLine 2\nLine 3"))
+ for i := range 30 {
+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+ items = append(items, item)
+ }
+ l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
+ execCmd(l, l.Init())
+
+ execCmd(l, l.MoveDown(3))
+ viewBefore := l.View()
+ item := items[0]
+ execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("At top")))
+ viewAfter := l.View()
+ assert.Equal(t, viewBefore, viewAfter)
+ assert.Equal(t, 1, l.offset)
+ assert.Equal(t, 31, lipgloss.Height(l.rendered))
+ golden.RequireEqual(t, []byte(l.View()))
+ })
+
+ t.Run("should stay at the position it is when the hight of an item below is increased in forward list", func(t *testing.T) {
+ t.Parallel()
+ items := []Item{}
+ for i := range 30 {
+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+ items = append(items, item)
+ }
+ l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
+ execCmd(l, l.Init())
+
+ execCmd(l, l.MoveDown(2))
+ viewBefore := l.View()
+ item := items[29]
+ execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3")))
+ viewAfter := l.View()
+ assert.Equal(t, viewBefore, viewAfter)
+ assert.Equal(t, 2, l.offset)
+ assert.Equal(t, 32, lipgloss.Height(l.rendered))
+ golden.RequireEqual(t, []byte(l.View()))
+ })
+ t.Run("should stay at the position it is if an item is appended and we are in forward list", func(t *testing.T) {
+ t.Parallel()
+ items := []Item{}
+ for i := range 30 {
+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+ items = append(items, item)
+ }
+ l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
+ execCmd(l, l.Init())
+
+ execCmd(l, l.MoveDown(2))
+ viewBefore := l.View()
+ execCmd(l, l.AppendItem(NewSelectableItem("New")))
+ viewAfter := l.View()
+ assert.Equal(t, viewBefore, viewAfter)
+ assert.Equal(t, 2, l.offset)
+ assert.Equal(t, 31, lipgloss.Height(l.rendered))
+ golden.RequireEqual(t, []byte(l.View()))
+ })
}
type SelectableItem interface {
@@ -0,0 +1,10 @@
+Item 29
+Item 29
+Item 29
+Item 29
+Item 29
+Item 29
+Item 29
+Item 29
+Item 29
+โTesting
@@ -0,0 +1,10 @@
+Item 29
+Item 29
+Item 29
+Item 29
+Item 29
+Item 29
+Item 29
+Item 29
+Item 29
+โTesting
@@ -0,0 +1,10 @@
+โTesting
+Item 0
+Item 1
+Item 1
+Item 2
+Item 2
+Item 2
+Item 3
+Item 3
+Item 3
@@ -0,0 +1,10 @@
+โItem 2
+Item 3
+Item 4
+Item 5
+Item 6
+Item 7
+Item 8
+Item 9
+Item 10
+Item 11
@@ -0,0 +1,10 @@
+Item 18
+Item 19
+Item 20
+Item 21
+Item 22
+Item 23
+Item 24
+Item 25
+Item 26
+โItem 27
@@ -0,0 +1,10 @@
+โItem 2
+Item 3
+Item 4
+Item 5
+Item 6
+Item 7
+Item 8
+Item 9
+Item 10
+Item 11
@@ -0,0 +1,10 @@
+Item 18
+Item 19
+Item 20
+Item 21
+Item 22
+Item 23
+Item 24
+Item 25
+Item 26
+โItem 27
@@ -0,0 +1,10 @@
+Item 18
+Item 19
+Item 20
+Item 21
+Item 22
+Item 23
+Item 24
+Item 25
+Item 26
+โItem 27
@@ -0,0 +1,10 @@
+โItem 0
+Item 1
+Item 2
+Item 3
+Item 4
+Item 5
+Item 6
+Item 7
+Item 8
+Item 9
@@ -0,0 +1,10 @@
+Item 18
+Item 19
+Item 20
+Item 21
+Item 22
+Item 23
+Item 24
+Item 25
+Item 26
+โItem 27
@@ -0,0 +1,10 @@
+Item 18
+Item 19
+Item 20
+Item 21
+Item 22
+Item 23
+Item 24
+Item 25
+Item 26
+โItem 27
@@ -0,0 +1,10 @@
+โItem 2
+Item 3
+Item 4
+Item 5
+Item 6
+Item 7
+Item 8
+Item 9
+Item 10
+Item 11
@@ -0,0 +1,10 @@
+Item 21
+Item 22
+Item 23
+Item 24
+Item 25
+Item 26
+Item 27
+Item 28
+โItem 29
+Item 30
@@ -0,0 +1,10 @@
+Item 21
+Item 22
+Item 23
+Item 24
+Item 25
+Item 26
+Item 27
+Item 28
+โItem 29
+Item 30
@@ -0,0 +1,10 @@
+Item 18
+Item 19
+Item 20
+Item 21
+Item 22
+Item 23
+Item 24
+Item 25
+Item 26
+โItem 27
@@ -0,0 +1,10 @@
+Item 18
+Item 19
+Item 20
+Item 21
+Item 22
+Item 23
+Item 24
+Item 25
+Item 26
+โItem 27
@@ -0,0 +1,10 @@
+Item 18
+Item 19
+Item 20
+Item 21
+Item 22
+Item 23
+Item 24
+Item 25
+Item 26
+โItem 27
@@ -0,0 +1,10 @@
+โItem 2
+Item 3
+Item 4
+Item 5
+Item 6
+Item 7
+Item 8
+Item 9
+Item 10
+Item 11
@@ -604,6 +604,7 @@ func (p *chatPage) sendMessage(text string, attachments []message.Attachment) te
if err != nil {
return util.ReportError(err)
}
+ cmds = append(cmds, p.chat.GoToBottom())
return tea.Batch(cmds...)
}